From 58af4e920f7b6cb478c55c41dc7db214e2d3fb1e Mon Sep 17 00:00:00 2001 From: Ryan Nett Date: Sun, 2 Nov 2025 12:08:10 -0800 Subject: [PATCH 1/4] Allow service providers to be classes, as long as they have a defaultable constructor --- sweetspi-processor/src/main/kotlin/analyze.kt | 21 ++++- .../src/main/kotlin/generate.kt | 3 +- .../processor/MultiplatformProcessorTest.kt | 70 +++++++++++++++ .../runtime/MultiplatformRuntimeTest.kt | 86 +++++++++++++++++++ 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt diff --git a/sweetspi-processor/src/main/kotlin/analyze.kt b/sweetspi-processor/src/main/kotlin/analyze.kt index e835d49..f81d8f9 100644 --- a/sweetspi-processor/src/main/kotlin/analyze.kt +++ b/sweetspi-processor/src/main/kotlin/analyze.kt @@ -42,9 +42,26 @@ fun analyze(logger: KSPLogger, resolver: Resolver): SweetContext? { // TODO: check visibility when (it) { is KSClassDeclaration -> { - if (it.classKind != ClassKind.OBJECT) { + if (it.classKind == ClassKind.OBJECT) { + isValid = true + } else if (it.classKind == ClassKind.CLASS) { + val ctors = it.getConstructors().toList() + // if there are no constructors, a default 0-arg constructor will be generated by Kotlin compiler + if (ctors.isEmpty() || ctors.any { it.parameters.all { it.hasDefault } }) { + isValid = true + } else { + isValid = false + logger.error( + "@ServiceProvider target class '${it.simpleName.asString()}' must have a defaultable constructor or be an 'object'", + it + ) + } + } else { isValid = false - logger.error("@ServiceProvider target '${it.simpleName.asString()}' should be an 'object'", it) + logger.error( + "@ServiceProvider target '${it.simpleName.asString()}' should be an 'object' or 'class' with a defaultable constructor", + it + ) } } is KSFunctionDeclaration -> { diff --git a/sweetspi-processor/src/main/kotlin/generate.kt b/sweetspi-processor/src/main/kotlin/generate.kt index a0fd4e0..59472de 100644 --- a/sweetspi-processor/src/main/kotlin/generate.kt +++ b/sweetspi-processor/src/main/kotlin/generate.kt @@ -47,7 +47,8 @@ fun generate(codeGenerator: CodeGenerator, platform: PlatformInfo, context: Swee context.serviceProviders.forEach { (service, providers) -> val data = providers.map { when (it) { - is KSClassDeclaration -> "%T" to it.toClassName() + // the analyzer guarantees that we have either objects or classes with defaultable constructors + is KSClassDeclaration -> (if (it.classKind == ClassKind.OBJECT) "%T" else "%T()") to it.toClassName() is KSFunctionDeclaration -> "%M()" to MemberName(it.packageName.asString(), it.simpleName.asString()) is KSPropertyDeclaration -> "%M" to MemberName(it.packageName.asString(), it.simpleName.asString()) else -> error("should not happen") diff --git a/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt b/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt new file mode 100644 index 0000000..bba1e7a --- /dev/null +++ b/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.sweetspi.tests.processor + +import dev.whyoleg.sweetspi.tests.* +import org.gradle.testkit.runner.* +import kotlin.test.* + +class MultiplatformProcessorTest : AbstractTest() { + override val defaultTemplate: TestTemplate get() = TestTemplate.MULTIPLATFORM + + @FastVersionedTest + fun testClassWithoutDefaultConstructor(versions: TestVersions) { + val project = project(versions) { + + withSweetSpi() + allTargets() + prepend(BUILD_GRADLE_KTS) { + // for native tasks + "import org.jetbrains.kotlin.gradle.plugin.mpp.*" + } + append(BUILD_GRADLE_KTS) { + """ + kotlin { + // setup tests running in RELEASE mode + targets.withType().configureEach { + binaries.test(listOf(NativeBuildType.RELEASE)) + } + targets.withType>().configureEach { + testRuns.create("releaseTest") { + setExecutionSourceFrom(binaries.getTest(NativeBuildType.RELEASE)) + } + } + } + """.trimIndent() + } + kotlinSourceFile( + sourceSet = COMMON_MAIN, path = "main.kt", + code = """ + @Service interface SimpleService + @ServiceProvider class SimpleServiceImpl(val a: Int) : SimpleService + """.trimIndent() + ) + } + project.gradle("build", expectFailure = true) { + assertEquals( + setOf( + ":kspKotlinAndroidNativeX64", + ":kspKotlinAndroidNativeArm32", + ":kspKotlinAndroidNativeArm64", + ":kspKotlinLinuxArm64", + ":kspKotlinMingwX64", + ":kspKotlinAndroidNativeX86", + ":kspKotlinLinuxX64", + ":kspKotlinJs", + ":kspKotlinJvm", + ":kspKotlinWasmJs", + ":kspKotlinWasmWasi" + ), + taskPaths(TaskOutcome.FAILED).toSet() + ) + assertContains( + output, + "@ServiceProvider target class 'SimpleServiceImpl' must have a defaultable constructor or be an 'object'" + ) + } + } +} \ No newline at end of file diff --git a/sweetspi-tests/src/test/kotlin/runtime/MultiplatformRuntimeTest.kt b/sweetspi-tests/src/test/kotlin/runtime/MultiplatformRuntimeTest.kt index b6ccada..491536a 100644 --- a/sweetspi-tests/src/test/kotlin/runtime/MultiplatformRuntimeTest.kt +++ b/sweetspi-tests/src/test/kotlin/runtime/MultiplatformRuntimeTest.kt @@ -151,4 +151,90 @@ class MultiplatformRuntimeTest : AbstractTest() { assert(tasks.any { it.path in nativeTestTasks && it.outcome.isPositive }) } } + + @FastVersionedTest + fun testAllTargetsClassServices(versions: TestVersions) { + val project = project(versions) { + + withSweetSpi() + allTargets() + kotlinTest(COMMON_TEST) + prepend(BUILD_GRADLE_KTS) { + // for native tasks + "import org.jetbrains.kotlin.gradle.plugin.mpp.*" + } + append(BUILD_GRADLE_KTS) { + """ + kotlin { + // setup tests running in RELEASE mode + targets.withType().configureEach { + binaries.test(listOf(NativeBuildType.RELEASE)) + } + targets.withType>().configureEach { + testRuns.create("releaseTest") { + setExecutionSourceFrom(binaries.getTest(NativeBuildType.RELEASE)) + } + } + } + """.trimIndent() + } + kotlinSourceFile( + sourceSet = COMMON_MAIN, path = "main.kt", + code = """ + @Service interface SimpleService + @ServiceProvider object SimpleServiceImpl : SimpleService + @ServiceProvider class SimpleServiceImpl2 : SimpleService + @ServiceProvider class SimpleServiceImpl3() : SimpleService + @ServiceProvider class SimpleServiceImpl4(val a: Int = 4) : SimpleService + @ServiceProvider class SimpleServiceImpl5(val a: Int) : SimpleService { + constructor() : this(5) + } + @ServiceProvider class SimpleServiceImpl6(val a: Int) : SimpleService { + constructor(b: String = "5") : this(6) + } + """.trimIndent() + ) + kotlinSourceFile( + sourceSet = COMMON_TEST, path = "test.kt", + code = """ + import kotlin.test.* + + class SimpleTest { + @Test + fun doTest() { + val services = ServiceLoader.load() + assertEquals(6, services.size) + assertEquals( + setOf( + SimpleServiceImpl::class, + SimpleServiceImpl2::class, + SimpleServiceImpl3::class, + SimpleServiceImpl4::class, + SimpleServiceImpl5::class, + SimpleServiceImpl6::class, + ), + services.map { it::class }.toSet() + ) + } + } + """.trimIndent() + ) + } + project.gradle("build") { + assert(task(":jvmTest")!!.outcome.isPositive) + assert(task(":jsTest")!!.outcome.isPositive) + assert(task(":wasmJsTest")!!.outcome.isPositive) + assert(task(":wasmWasiTest")!!.outcome.isPositive) + + // tasks are different on different OS, only desktop targets are mentioned + val nativeTestTasks = setOf( + ":macosArm64Test", + ":macosX64Test", + ":linuxX64Test", + ":linuxArm64Test", + ":mingwX64Test", + ) + assert(tasks.any { it.path in nativeTestTasks && it.outcome.isPositive }) + } + } } From 5dac44b560e8d4f8cab7856025948d786e8c53f3 Mon Sep 17 00:00:00 2001 From: Ryan Nett Date: Sun, 2 Nov 2025 14:15:01 -0800 Subject: [PATCH 2/4] Update documentation --- sweetspi-runtime/src/commonMain/kotlin/ServiceLoader.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sweetspi-runtime/src/commonMain/kotlin/ServiceLoader.kt b/sweetspi-runtime/src/commonMain/kotlin/ServiceLoader.kt index 7a4304d..bd59402 100644 --- a/sweetspi-runtime/src/commonMain/kotlin/ServiceLoader.kt +++ b/sweetspi-runtime/src/commonMain/kotlin/ServiceLoader.kt @@ -37,7 +37,10 @@ public annotation class Service * that have the [Service] annotation. * * This annotation can be applied to the following targets: - * - [AnnotationTarget.CLASS]: Only applicable to objects + * - [AnnotationTarget.CLASS]: Applicable to objects or classes with: + * 1. No explicit constructor, or + * 2. A constructor that has no parameters, or + * 3. A constructor where all parameters have default values. * - [AnnotationTarget.PROPERTY]: Only applicable to immutable non-suspend properties with getter or initializer * - [AnnotationTarget.FUNCTION]: Only applicable to non-suspend functions without arguments and without receiver * From aa5dd6a9ae415786ed9ff3c0c0cee42439585a10 Mon Sep 17 00:00:00 2001 From: Ryan Nett Date: Tue, 4 Nov 2025 16:55:46 -0800 Subject: [PATCH 3/4] Improve error message for invalid service providers --- sweetspi-processor/src/main/kotlin/analyze.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/sweetspi-processor/src/main/kotlin/analyze.kt b/sweetspi-processor/src/main/kotlin/analyze.kt index f81d8f9..15b8f97 100644 --- a/sweetspi-processor/src/main/kotlin/analyze.kt +++ b/sweetspi-processor/src/main/kotlin/analyze.kt @@ -42,24 +42,27 @@ fun analyze(logger: KSPLogger, resolver: Resolver): SweetContext? { // TODO: check visibility when (it) { is KSClassDeclaration -> { + fun invalidDeclarationError() = + "@ServiceProvider target '${it.simpleName.asString()}' should be an 'object' or 'class' with a constructor that has no parameters or a constructor with all parameters having default value." + if (it.classKind == ClassKind.OBJECT) { - isValid = true + // valid } else if (it.classKind == ClassKind.CLASS) { val ctors = it.getConstructors().toList() // if there are no constructors, a default 0-arg constructor will be generated by Kotlin compiler if (ctors.isEmpty() || ctors.any { it.parameters.all { it.hasDefault } }) { - isValid = true + // valid } else { isValid = false logger.error( - "@ServiceProvider target class '${it.simpleName.asString()}' must have a defaultable constructor or be an 'object'", + invalidDeclarationError(), it ) } } else { isValid = false logger.error( - "@ServiceProvider target '${it.simpleName.asString()}' should be an 'object' or 'class' with a defaultable constructor", + invalidDeclarationError(), it ) } From d54d2588d95272710c962718de72620c82b05af6 Mon Sep 17 00:00:00 2001 From: Ryan Nett Date: Tue, 4 Nov 2025 16:56:05 -0800 Subject: [PATCH 4/4] Adjust test assertions to not be platform dependent --- .../processor/MultiplatformProcessorTest.kt | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt b/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt index bba1e7a..b4b3992 100644 --- a/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt +++ b/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt @@ -45,22 +45,7 @@ class MultiplatformProcessorTest : AbstractTest() { ) } project.gradle("build", expectFailure = true) { - assertEquals( - setOf( - ":kspKotlinAndroidNativeX64", - ":kspKotlinAndroidNativeArm32", - ":kspKotlinAndroidNativeArm64", - ":kspKotlinLinuxArm64", - ":kspKotlinMingwX64", - ":kspKotlinAndroidNativeX86", - ":kspKotlinLinuxX64", - ":kspKotlinJs", - ":kspKotlinJvm", - ":kspKotlinWasmJs", - ":kspKotlinWasmWasi" - ), - taskPaths(TaskOutcome.FAILED).toSet() - ) + assertTrue(taskPaths(TaskOutcome.SUCCESS).none { it.startsWith(":kspKotlin") }) assertContains( output, "@ServiceProvider target class 'SimpleServiceImpl' must have a defaultable constructor or be an 'object'"