Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions sweetspi-processor/src/main/kotlin/analyze.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
rnett marked this conversation as resolved.
invalidDeclarationError(),
it
)
}
} else {
isValid = false
logger.error("@ServiceProvider target '${it.simpleName.asString()}' should be an 'object'", it)
logger.error(
invalidDeclarationError(),
it
)
}
}
is KSFunctionDeclaration -> {
Expand Down
3 changes: 2 additions & 1 deletion sweetspi-processor/src/main/kotlin/generate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion sweetspi-runtime/src/commonMain/kotlin/ServiceLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KotlinNativeTarget>().configureEach {
binaries.test(listOf(NativeBuildType.RELEASE))
}
targets.withType<KotlinNativeTargetWithTests<*>>().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'"
)
}
}
}
86 changes: 86 additions & 0 deletions sweetspi-tests/src/test/kotlin/runtime/MultiplatformRuntimeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<KotlinNativeTarget>().configureEach {
binaries.test(listOf(NativeBuildType.RELEASE))
}
targets.withType<KotlinNativeTargetWithTests<*>>().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<SimpleService>()
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 })
}
}
}