diff --git a/sweetspi-processor/src/main/kotlin/analyze.kt b/sweetspi-processor/src/main/kotlin/analyze.kt index e835d49..15b8f97 100644 --- a/sweetspi-processor/src/main/kotlin/analyze.kt +++ b/sweetspi-processor/src/main/kotlin/analyze.kt @@ -42,9 +42,29 @@ fun analyze(logger: KSPLogger, resolver: Resolver): SweetContext? { // TODO: check visibility when (it) { is KSClassDeclaration -> { - if (it.classKind != ClassKind.OBJECT) { + 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) { + // 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 } }) { + // valid + } else { + isValid = false + logger.error( + invalidDeclarationError(), + it + ) + } + } else { isValid = false - logger.error("@ServiceProvider target '${it.simpleName.asString()}' should be an 'object'", it) + logger.error( + invalidDeclarationError(), + 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-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 * 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..b4b3992 --- /dev/null +++ b/sweetspi-tests/src/test/kotlin/processor/MultiplatformProcessorTest.kt @@ -0,0 +1,55 @@ +/* + * 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) { + assertTrue(taskPaths(TaskOutcome.SUCCESS).none { it.startsWith(":kspKotlin") }) + 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 }) + } + } }