diff --git a/krpc/krpc-test/build.gradle.kts b/krpc/krpc-test/build.gradle.kts index 80e328f36..b5cc28ea2 100644 --- a/krpc/krpc-test/build.gradle.kts +++ b/krpc/krpc-test/build.gradle.kts @@ -2,7 +2,6 @@ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ -import com.osacky.doctor.internal.sysProperty import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest diff --git a/settings.gradle.kts b/settings.gradle.kts index 39f557186..f99464f30 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,6 +59,7 @@ includePublic(":krpc:krpc-ktor:krpc-ktor-server") includePublic(":krpc:krpc-ktor:krpc-ktor-client") include(":tests") +include(":tests:krpc-compatibility-tests") val kotlinMasterBuild = providers.gradleProperty("kotlinx.rpc.kotlinMasterBuild").orNull == "true" diff --git a/tests/krpc-compatibility-tests/build.gradle.kts b/tests/krpc-compatibility-tests/build.gradle.kts new file mode 100644 index 000000000..72e15ff9f --- /dev/null +++ b/tests/krpc-compatibility-tests/build.gradle.kts @@ -0,0 +1,80 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import util.applyAtomicfuPlugin + +plugins { + alias(libs.plugins.conventions.jvm) + alias(libs.plugins.serialization) + alias(libs.plugins.kotlinx.rpc) +} + +applyAtomicfuPlugin() + +val main: SourceSet by sourceSets.getting +val test: SourceSet by sourceSets.getting + +val oldApi: SourceSet by sourceSets.creating { + compileClasspath += main.output + compileClasspath += test.compileClasspath + runtimeClasspath += main.output + runtimeClasspath += test.runtimeClasspath +} + +val newApi: SourceSet by sourceSets.creating { + compileClasspath += main.output + compileClasspath += test.compileClasspath + runtimeClasspath += main.output + runtimeClasspath += test.runtimeClasspath +} + +val compatibilityTestSourcesDir: File = project.layout.buildDirectory.dir("compatibilityTestSources").get().asFile + +val copyOldToTestResources by tasks.register("copyOldToTestResources") { + dependsOn(oldApi.output) + from(oldApi.output) + into(compatibilityTestSourcesDir.resolve("old")) +} + +val copyNewToTestResources by tasks.register("copyNewToTestResources") { + dependsOn(newApi.output) + from(newApi.output) + into(compatibilityTestSourcesDir.resolve("new")) +} + +test.resources { + srcDir(compatibilityTestSourcesDir) +} + +tasks.processTestResources.configure { + dependsOn(copyOldToTestResources, copyNewToTestResources) +} + +dependencies { + api(libs.atomicfu) + + api(projects.krpc.krpcCore) + api(projects.krpc.krpcServer) + api(projects.krpc.krpcClient) + + implementation(projects.krpc.krpcSerialization.krpcSerializationJson) + + implementation(libs.serialization.core) + implementation(libs.coroutines.test) + implementation(libs.kotlin.test.junit5) + implementation(libs.kotlin.reflect) + + testImplementation(libs.slf4j.api) + testImplementation(libs.logback.classic) + testImplementation(libs.coroutines.debug) +} + +kotlin { + explicitApi = ExplicitApiMode.Disabled +} + +tasks.test { + useJUnitPlatform() +} diff --git a/tests/krpc-compatibility-tests/src/main/kotlin/kotlinx/rpc/krpc/compatibility/CompatibilityTest.kt b/tests/krpc-compatibility-tests/src/main/kotlin/kotlinx/rpc/krpc/compatibility/CompatibilityTest.kt new file mode 100644 index 000000000..8f12fd704 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/main/kotlin/kotlinx/rpc/krpc/compatibility/CompatibilityTest.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.krpc.compatibility + +import kotlinx.rpc.RpcClient + +interface CompatibilityTest { + fun getAllTests(): Map Unit> +} diff --git a/tests/krpc-compatibility-tests/src/main/kotlin/kotlinx/rpc/krpc/compatibility/TestApiServer.kt b/tests/krpc-compatibility-tests/src/main/kotlin/kotlinx/rpc/krpc/compatibility/TestApiServer.kt new file mode 100644 index 000000000..3a449698f --- /dev/null +++ b/tests/krpc-compatibility-tests/src/main/kotlin/kotlinx/rpc/krpc/compatibility/TestApiServer.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.krpc.compatibility + +import kotlinx.rpc.RpcServer + +interface TestApiServer { + fun serveAllInterfaces(rpcServer: RpcServer) +} diff --git a/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Bar.kt b/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Bar.kt new file mode 100644 index 000000000..69afa0f28 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Bar.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package interfaces + +import kotlinx.rpc.RemoteService +import kotlinx.rpc.annotations.Rpc +import kotlin.coroutines.CoroutineContext + +@Rpc +interface BarInterface : RemoteService { + suspend fun get(): Unit + suspend fun get2(): Unit +} + +class BarInterfaceImpl(override val coroutineContext: CoroutineContext) : BarInterface { + override suspend fun get() {} + + override suspend fun get2() {} +} diff --git a/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Baz.kt b/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Baz.kt new file mode 100644 index 000000000..1e201d0c6 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Baz.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package interfaces + +import kotlinx.rpc.RemoteService +import kotlinx.rpc.annotations.Rpc +import kotlinx.serialization.Serializable +import kotlin.coroutines.CoroutineContext + +@Serializable +data class Baz(val field: String, val field2: String = "") + +@Rpc +interface BazInterface : RemoteService { + suspend fun get(): Baz +} + +class BazInterfaceImpl(override val coroutineContext: CoroutineContext) : BazInterface { + override suspend fun get(): Baz = Baz("asd", "def") +} diff --git a/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Foo.kt b/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Foo.kt new file mode 100644 index 000000000..2e5fa75d0 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/newApi/kotlin/interfaces/Foo.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package interfaces + +import kotlinx.rpc.RemoteService +import kotlinx.rpc.annotations.Rpc +import kotlinx.serialization.Serializable +import kotlin.coroutines.CoroutineContext + +@Serializable +data class Foo(val field: String, val field2: String? = null) + +@Rpc +interface FooInterface : RemoteService { + suspend fun get(): Foo +} + +class FooInterfaceImpl(override val coroutineContext: CoroutineContext) : FooInterface { + override suspend fun get(): Foo { + return Foo("", "") + } +} diff --git a/tests/krpc-compatibility-tests/src/newApi/kotlin/tests/ApiServer.kt b/tests/krpc-compatibility-tests/src/newApi/kotlin/tests/ApiServer.kt new file mode 100644 index 000000000..f6ef49a9e --- /dev/null +++ b/tests/krpc-compatibility-tests/src/newApi/kotlin/tests/ApiServer.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package tests + +import kotlinx.rpc.krpc.compatibility.TestApiServer +import interfaces.BarInterface +import interfaces.BarInterfaceImpl +import interfaces.BazInterface +import interfaces.BazInterfaceImpl +import interfaces.FooInterface +import interfaces.FooInterfaceImpl +import kotlinx.rpc.RpcServer +import kotlinx.rpc.registerService + +@Suppress("unused") +class ApiServer : TestApiServer { + override fun serveAllInterfaces(rpcServer: RpcServer) { + rpcServer.apply { + registerService { FooInterfaceImpl(it) } + registerService { BarInterfaceImpl(it) } + registerService { BazInterfaceImpl(it) } + } + } +} diff --git a/tests/krpc-compatibility-tests/src/newApi/kotlin/tests/CompatibilityTests.kt b/tests/krpc-compatibility-tests/src/newApi/kotlin/tests/CompatibilityTests.kt new file mode 100644 index 000000000..fce858896 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/newApi/kotlin/tests/CompatibilityTests.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:Suppress("FunctionName") + +package tests + +import kotlinx.rpc.krpc.compatibility.CompatibilityTest +import interfaces.BarInterface +import interfaces.BazInterface +import interfaces.FooInterface +import kotlinx.rpc.RpcClient +import kotlinx.rpc.withService +import kotlin.reflect.KCallable +import kotlin.reflect.full.callSuspend +import kotlin.test.assertEquals + +@Suppress("unused") +class CompatibilityTests : CompatibilityTest { + override fun getAllTests(): Map Unit> { + return mapOf( + this::`should work with older data class without nullable field`.toEntry(), + this::`should work with older interface without method`.toEntry(), + this::`should work with older data class without a field with default value`.toEntry(), + ) + } + + suspend fun `should work with older data class without nullable field`(rpcClient: RpcClient) { + val service = rpcClient.withService() + val res = service.get() + assertEquals("", res.field) + assertEquals(null, res.field2) + } + + suspend fun `should work with older interface without method`(rpcClient: RpcClient) { + val service = rpcClient.withService() + service.get() + // Of course, we can't call the second method + } + + suspend fun `should work with older data class without a field with default value`(rpcClient: RpcClient) { + val service = rpcClient.withService() + val res = service.get() + assertEquals("asd", res.field) + assertEquals("", res.field2) + // Of course, we can't call the second method + } + + private fun KCallable.toEntry(): Pair Unit> { + return name to { callSuspend(it) } + } +} diff --git a/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Bar.kt b/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Bar.kt new file mode 100644 index 000000000..99bb03569 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Bar.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package interfaces + +import kotlinx.rpc.RemoteService +import kotlinx.rpc.annotations.Rpc +import kotlin.coroutines.CoroutineContext + +@Rpc +interface BarInterface : RemoteService { + suspend fun get() +} + +class BarInterfaceImpl(override val coroutineContext: CoroutineContext) : BarInterface { + override suspend fun get() {} +} diff --git a/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Baz.kt b/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Baz.kt new file mode 100644 index 000000000..cee355f01 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Baz.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package interfaces + +import kotlinx.rpc.RemoteService +import kotlinx.rpc.annotations.Rpc +import kotlinx.serialization.Serializable +import kotlin.coroutines.CoroutineContext + +@Serializable +data class Baz(val field: String) + +@Rpc +interface BazInterface : RemoteService { + suspend fun get(): Baz +} + +class BazInterfaceImpl(override val coroutineContext: CoroutineContext) : BazInterface { + override suspend fun get(): Baz = Baz("asd") +} diff --git a/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Foo.kt b/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Foo.kt new file mode 100644 index 000000000..6ca38219d --- /dev/null +++ b/tests/krpc-compatibility-tests/src/oldApi/kotlin/interfaces/Foo.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package interfaces + +import kotlinx.rpc.RemoteService +import kotlinx.rpc.annotations.Rpc +import kotlinx.serialization.Serializable +import kotlin.coroutines.CoroutineContext + +@Serializable +data class Foo(val field: String) + +@Rpc +interface FooInterface : RemoteService { + suspend fun get(): Foo +} + +class FooInterfaceImpl(override val coroutineContext: CoroutineContext) : FooInterface { + override suspend fun get(): Foo { + return Foo("") + } +} diff --git a/tests/krpc-compatibility-tests/src/oldApi/kotlin/tests/ApiServer.kt b/tests/krpc-compatibility-tests/src/oldApi/kotlin/tests/ApiServer.kt new file mode 100644 index 000000000..f6ef49a9e --- /dev/null +++ b/tests/krpc-compatibility-tests/src/oldApi/kotlin/tests/ApiServer.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package tests + +import kotlinx.rpc.krpc.compatibility.TestApiServer +import interfaces.BarInterface +import interfaces.BarInterfaceImpl +import interfaces.BazInterface +import interfaces.BazInterfaceImpl +import interfaces.FooInterface +import interfaces.FooInterfaceImpl +import kotlinx.rpc.RpcServer +import kotlinx.rpc.registerService + +@Suppress("unused") +class ApiServer : TestApiServer { + override fun serveAllInterfaces(rpcServer: RpcServer) { + rpcServer.apply { + registerService { FooInterfaceImpl(it) } + registerService { BarInterfaceImpl(it) } + registerService { BazInterfaceImpl(it) } + } + } +} diff --git a/tests/krpc-compatibility-tests/src/oldApi/kotlin/tests/CompatibilityTests.kt b/tests/krpc-compatibility-tests/src/oldApi/kotlin/tests/CompatibilityTests.kt new file mode 100644 index 000000000..0456f541c --- /dev/null +++ b/tests/krpc-compatibility-tests/src/oldApi/kotlin/tests/CompatibilityTests.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:Suppress("FunctionName") + +package tests + +import kotlinx.rpc.krpc.compatibility.CompatibilityTest +import interfaces.BarInterface +import interfaces.BazInterface +import interfaces.FooInterface +import kotlinx.rpc.RpcClient +import kotlinx.rpc.withService +import kotlin.reflect.KCallable +import kotlin.reflect.full.callSuspend +import kotlin.test.assertEquals + +@Suppress("unused") +class CompatibilityTests : CompatibilityTest { + override fun getAllTests(): Map Unit> { + return mapOf( + this::`should work with interface with additional method`.toEntry(), + this::`should work with newer data class with additional nullable field`.toEntry(), + this::`should work with newer data class with a field with default value`.toEntry(), + ) + } + + suspend fun `should work with newer data class with additional nullable field`(rpcClient: RpcClient) { + val service = rpcClient.withService() + val res = service.get() + assertEquals("", res.field) + } + + suspend fun `should work with interface with additional method`(rpcClient: RpcClient) { + val service = rpcClient.withService() + service.get() + } + + suspend fun `should work with newer data class with a field with default value`(rpcClient: RpcClient) { + val service = rpcClient.withService() + val res = service.get() + assertEquals("asd", res.field) + // Of course, we can't call the second method + } + + private fun KCallable.toEntry(): Pair Unit> { + return name to { callSuspend(it) } + } +} diff --git a/tests/krpc-compatibility-tests/src/test/kotlin/kotlinx/rpc/krpc/compatibility/KrpcCompatibilityTests.kt b/tests/krpc-compatibility-tests/src/test/kotlin/kotlinx/rpc/krpc/compatibility/KrpcCompatibilityTests.kt new file mode 100644 index 000000000..9101b2b94 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/test/kotlin/kotlinx/rpc/krpc/compatibility/KrpcCompatibilityTests.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.krpc.compatibility + +import kotlinx.coroutines.test.runTest +import kotlinx.rpc.krpc.rpcClientConfig +import kotlinx.rpc.krpc.rpcServerConfig +import kotlinx.rpc.krpc.serialization.json.json +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import java.net.URLClassLoader +import java.util.stream.Stream + +class KrpcCompatibilityTests { + private class ClientServer(private val clientClassLoader: URLClassLoader, val serverClassLoader: URLClassLoader) : + AutoCloseable { + val client = clientClassLoader + .loadClass("tests.CompatibilityTests") + .getDeclaredConstructor() + .newInstance() as CompatibilityTest + + val server = serverClassLoader + .loadClass("tests.ApiServer") + .getDeclaredConstructor() + .newInstance() as TestApiServer + + override fun close() { + clientClassLoader.close() + serverClassLoader.close() + } + } + + private fun prepareClientServer(oldClient: Boolean): ClientServer { + val newResourcePath = javaClass.classLoader.getResource("new/")!! + val oldResourcePath = javaClass.classLoader.getResource("old/")!! + + val oldApiClassLoader = URLClassLoader(arrayOf(oldResourcePath), javaClass.classLoader) + val newApiClassLoader = URLClassLoader(arrayOf(newResourcePath), javaClass.classLoader) + + val clientClassLoader = if (oldClient) oldApiClassLoader else newApiClassLoader + val serverClassLoader = if (oldClient) newApiClassLoader else oldApiClassLoader + + return ClientServer(clientClassLoader = clientClassLoader, serverClassLoader = serverClassLoader) + } + + private val rpcServerConfig = rpcServerConfig { + serialization { + json() + } + } + + private val rpcClientConfig = rpcClientConfig { + serialization { + json { + ignoreUnknownKeys = true + } + } + } + + private fun compatibilityTests(clientServer: ClientServer): Stream { + return clientServer.client.getAllTests().map { (name, test) -> + DynamicTest.dynamicTest(name) { + runTest { + val localTransport = LocalTransport() + val server = KrpcTestServer(rpcServerConfig, localTransport.server) + val client = KrpcTestClient(rpcClientConfig, localTransport.client) + clientServer.server.serveAllInterfaces(server) + + test(client) + } + } + }.stream() + } + + @TestFactory + fun testCompatibilityNew(): Stream { + val clientServer = prepareClientServer(oldClient = false) + return compatibilityTests(clientServer) + } + + @TestFactory + fun testCompatibilityOld(): Stream { + val clientServer = prepareClientServer(oldClient = true) + return compatibilityTests(clientServer) + } +} diff --git a/tests/krpc-compatibility-tests/src/test/kotlin/kotlinx/rpc/krpc/compatibility/LocalTransport.kt b/tests/krpc-compatibility-tests/src/test/kotlin/kotlinx/rpc/krpc/compatibility/LocalTransport.kt new file mode 100644 index 000000000..9739e2234 --- /dev/null +++ b/tests/krpc-compatibility-tests/src/test/kotlin/kotlinx/rpc/krpc/compatibility/LocalTransport.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.krpc.compatibility + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.job +import kotlinx.rpc.krpc.KrpcConfig +import kotlinx.rpc.krpc.KrpcTransport +import kotlinx.rpc.krpc.KrpcTransportMessage +import kotlinx.rpc.krpc.client.KrpcClient +import kotlinx.rpc.krpc.server.KrpcServer +import kotlin.coroutines.CoroutineContext + +class KrpcTestServer( + config: KrpcConfig.Server, + transport: KrpcTransport, +) : KrpcServer(config, transport) + +class KrpcTestClient( + config: KrpcConfig.Client, + transport: KrpcTransport, +) : KrpcClient(config, transport) + +class LocalTransport(parentScope: CoroutineScope? = null) : CoroutineScope { + override val coroutineContext = parentScope?.run { SupervisorJob(coroutineContext.job) } + ?: SupervisorJob() + + private val clientIncoming = Channel() + private val serverIncoming = Channel() + + val client: KrpcTransport = object : KrpcTransport { + override val coroutineContext: CoroutineContext = Job(this@LocalTransport.coroutineContext.job) + + override suspend fun send(message: KrpcTransportMessage) { + serverIncoming.send(message) + } + + override suspend fun receive(): KrpcTransportMessage { + return clientIncoming.receive() + } + } + + val server: KrpcTransport = object : KrpcTransport { + override val coroutineContext: CoroutineContext = Job(this@LocalTransport.coroutineContext) + + override suspend fun send(message: KrpcTransportMessage) { + clientIncoming.send(message) + } + + override suspend fun receive(): KrpcTransportMessage { + return serverIncoming.receive() + } + } +} diff --git a/versions-root/libs.versions.toml b/versions-root/libs.versions.toml index bd4a3aed0..b391bda93 100644 --- a/versions-root/libs.versions.toml +++ b/versions-root/libs.versions.toml @@ -50,6 +50,7 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", versi kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-lang" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-lang" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin-lang" } +kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin-lang" } kotlin-script-runtime = { module = "org.jetbrains.kotlin:kotlin-script-runtime", version.ref = "kotlin-lang" } kotlin-annotations-jvm = { module = "org.jetbrains.kotlin:kotlin-annotations-jvm", version.ref = "kotlin-lang" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-lang" }