diff --git a/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/KxsTsGenerator.kt b/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/KxsTsGenerator.kt index 2ab1a9fb..e0511ee3 100644 --- a/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/KxsTsGenerator.kt +++ b/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/KxsTsGenerator.kt @@ -14,6 +14,8 @@ import dev.adamko.kxstsgen.core.TsTypeRefConverter import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule /** @@ -28,8 +30,8 @@ import kotlinx.serialization.descriptors.nullable */ open class KxsTsGenerator( open val config: KxsTsConfig = KxsTsConfig(), - open val sourceCodeGenerator: TsSourceCodeGenerator = TsSourceCodeGenerator.Default(config), + open val serializersModule: SerializersModule = EmptySerializersModule(), ) { @@ -60,7 +62,8 @@ open class KxsTsGenerator( open val descriptorsExtractor = object : SerializerDescriptorsExtractor { - val extractor: SerializerDescriptorsExtractor = SerializerDescriptorsExtractor.Default + val extractor: SerializerDescriptorsExtractor = + SerializerDescriptorsExtractor.default(serializersModule) val cache: MutableMap, Set> = mutableMapOf() override fun invoke(serializer: KSerializer<*>): Set = diff --git a/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractor.kt b/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractor.kt index 32db4d08..56b78975 100644 --- a/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractor.kt +++ b/modules/kxs-ts-gen-core/src/commonMain/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractor.kt @@ -1,8 +1,13 @@ package dev.adamko.kxstsgen.core -import dev.adamko.kxstsgen.core.util.MutableMapWithDefaultPut import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.* +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.SerializersModuleCollector +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlin.reflect.KClass + /** @@ -14,75 +19,220 @@ fun interface SerializerDescriptorsExtractor { serializer: KSerializer<*> ): Set + companion object { + /** The default [SerializerDescriptorsExtractor], for easy use. */ + fun default( + serializersModule: SerializersModule, + ): SerializerDescriptorsExtractor { + return Default( + elementDescriptorsExtractor = TsElementDescriptorsExtractor.default(serializersModule) + ) + } + } - object Default : SerializerDescriptorsExtractor { + class Default( + private val elementDescriptorsExtractor: TsElementDescriptorsExtractor, + ) : SerializerDescriptorsExtractor { override operator fun invoke( serializer: KSerializer<*> ): Set { - return extractDescriptors(serializer.descriptor) + val moduleDescriptors = elementDescriptorsExtractor.collectModuleDescriptors() + return extractDescriptors( + current = serializer.descriptor, + queue = ArrayDeque(), + extracted = emptySet(), + moduleDescriptors = moduleDescriptors + ) .distinctBy { it.nullable } .toSet() } - private tailrec fun extractDescriptors( current: SerialDescriptor? = null, queue: ArrayDeque = ArrayDeque(), extracted: Set = emptySet(), + moduleDescriptors: Set = emptySet(), ): Set { return if (current == null) { extracted } else { - val currentDescriptors = elementDescriptors.getValue(current) + val currentDescriptors = elementDescriptorsExtractor.elementDescriptors(current, moduleDescriptors) queue.addAll(currentDescriptors - extracted) - extractDescriptors(queue.removeFirstOrNull(), queue, extracted + current) + + // A contextual descriptor is just a placeholder (e.g. `ContextualSerializer`). + // When it resolves to a concrete descriptor via the SerializersModule, that concrete + // descriptor (e.g. `interface Foo`) is already queued above. The placeholder itself + // must NOT be emitted as a declaration, otherwise it renders as `type Foo = any` and + // collides with the resolved `interface Foo` (TS2300 duplicate identifier). When it + // cannot be resolved we keep it, so the referencing field still resolves to + // `type Foo = any` rather than an undefined type. + val nextExtracted = + if (current.kind == SerialKind.CONTEXTUAL && currentDescriptors.any()) extracted + else extracted + current + + extractDescriptors(queue.removeFirstOrNull(), queue, nextExtracted, moduleDescriptors) } } + } +} - private val elementDescriptors by MutableMapWithDefaultPut> { descriptor -> - when (descriptor.kind) { - SerialKind.ENUM -> emptyList() - - SerialKind.CONTEXTUAL -> emptyList() - - PrimitiveKind.BOOLEAN, - PrimitiveKind.BYTE, - PrimitiveKind.CHAR, - PrimitiveKind.SHORT, - PrimitiveKind.INT, - PrimitiveKind.LONG, - PrimitiveKind.FLOAT, - PrimitiveKind.DOUBLE, - PrimitiveKind.STRING -> emptyList() - - StructureKind.CLASS, - StructureKind.LIST, - StructureKind.MAP, - StructureKind.OBJECT -> descriptor.elementDescriptors - - PolymorphicKind.SEALED, - PolymorphicKind.OPEN -> - // Polymorphic descriptors have 2 elements, the 'type' and 'value' - we don't need either - // for generation, they're metadata that will be used later. - // The elements of 'value' are similarly unneeded, but their elements might contain new - // descriptors - so extract them - descriptor.elementDescriptors - .flatMap { it.elementDescriptors } - .flatMap { it.elementDescriptors } - - // Example: - // com.application.Polymorphic - // ├── 'type' descriptor (ignore / it's a String, so check its elements, it doesn't hurt) - // └── 'value' descriptor (check elements...) - // ├── com.application.Polymorphic (ignore) - // │ ├── Double (extract!) - // │ └── com.application.SomeOtherClass (extract!) - // └── com.application.Polymorphic (ignore) - // ├── UInt (extract!) - // └── List): Iterable + fun collectModuleDescriptors(): Set = emptySet() + + companion object { + + fun default(serializersModule: SerializersModule) = + object : TsElementDescriptorsExtractor { + + private val cachedModuleDescriptors by lazy { + collectDescriptorsFromModule(serializersModule) + } + + override fun collectModuleDescriptors(): Set = cachedModuleDescriptors + + override fun elementDescriptors(descriptor: SerialDescriptor, moduleDescriptors: Set): Iterable { + return when (descriptor.kind) { + SerialKind.ENUM -> emptyList() + + SerialKind.CONTEXTUAL -> { + resolveContextualDescriptor(descriptor, moduleDescriptors) + } + + PrimitiveKind.BOOLEAN, + PrimitiveKind.BYTE, + PrimitiveKind.CHAR, + PrimitiveKind.SHORT, + PrimitiveKind.INT, + PrimitiveKind.LONG, + PrimitiveKind.FLOAT, + PrimitiveKind.DOUBLE, + PrimitiveKind.STRING -> emptyList() + + StructureKind.CLASS, + StructureKind.LIST, + StructureKind.MAP, + StructureKind.OBJECT -> descriptor.elementDescriptors + + PolymorphicKind.SEALED -> + // Sealed subclasses are already part of the descriptor (sealed classes are closed) + // and are rendered as a discriminated namespace by TsElementConverter. We only + // traverse INTO the subclasses to pick up their property types - the subclass + // declarations themselves must not be extracted, or they'd be emitted again as + // top-level interfaces, duplicating the namespaced ones. + descriptor.elementDescriptors + .flatMap { it.elementDescriptors } + .flatMap { it.elementDescriptors } + + PolymorphicKind.OPEN -> + // TODO OPEN-polymorphism-via-module is not implemented. resolvePolymorphicDescriptors + // relies on isSubclassOf, which fails because the base type's package is lost when + // the base name is parsed out of `<...>`. Until fixed, an @Polymorphic field whose + // base type is only known via the module resolves to `type = any`. + resolvePolymorphicDescriptors(descriptor, moduleDescriptors) + } + } + + /** + * Resolve a contextual placeholder (e.g. `ContextualSerializer`) to the concrete + * descriptors registered in the [SerializersModule], by matching the placeholder's type + * parameter (a simple name) against each module descriptor's serial name. + * + * The match requires a package/separator boundary before the simple name, so that a + * placeholder for `Foo` does not also match `BarFoo`. (A loose `endsWith(simpleName)` + * match can resolve to more than one descriptor; if two of those share a rendered name + * the generator emits a TS2300 duplicate identifier.) + */ + private fun resolveContextualDescriptor(descriptor: SerialDescriptor, moduleDescriptors: Set): Iterable { + val typeParameters = getTypeParametersFromDescriptor(descriptor) + return typeParameters.flatMap { typeParam -> + moduleDescriptors.filter { moduleDesc -> + val serialName = moduleDesc.serialName + serialName == typeParam || serialName.endsWith(".$typeParam") + } + } + } + + private fun resolvePolymorphicDescriptors(descriptor: SerialDescriptor, moduleDescriptors: Set): Iterable { + val baseType = getPolymorphicBaseType(descriptor) ?: descriptor.serialName + + val subclassDescriptors = moduleDescriptors.filter { moduleDesc -> + moduleDesc.kind.let { it is StructureKind.CLASS || it is StructureKind.OBJECT } && + isSubclassOf(moduleDesc.serialName, baseType) + } + + // Emit each discovered subclass, plus its property-type descriptors. + return subclassDescriptors + subclassDescriptors.flatMap { it.elementDescriptors } + } + + private fun getTypeParametersFromDescriptor(descriptor: SerialDescriptor): List { + val serialName = descriptor.serialName + if (serialName.contains("<") && serialName.contains(">")) { + val typeParam = serialName.substringAfter("<").substringBefore(">") + return listOf(typeParam) + } + return emptyList() + } + + private fun getPolymorphicBaseType(descriptor: SerialDescriptor): String? { + val serialName = descriptor.serialName + return if (serialName.contains("<") && serialName.contains(">")) { + serialName.substringAfter("<").substringBefore(">") + } else { + null + } + } + + private fun isSubclassOf(subclassSerialName: String, baseClassSerialName: String): Boolean { + val subclassSimpleName = subclassSerialName.substringAfterLast(".") + val baseSimpleName = baseClassSerialName.substringAfterLast(".") + + val subclassPackage = subclassSerialName.substringBeforeLast(".", "") + val basePackage = baseClassSerialName.substringBeforeLast(".", "") + + return subclassSimpleName != baseSimpleName && + !subclassSimpleName.contains("$") && + !subclassSimpleName.contains("Companion") && + subclassPackage == basePackage + } } + + private fun collectDescriptorsFromModule(serializersModule: SerializersModule): Set { + val descriptors = mutableSetOf() + + serializersModule.dumpTo(object : SerializersModuleCollector { + override fun contextual( + kClass: KClass, + provider: (typeArgumentsSerializers: List>) -> KSerializer<*> + ) { + val serializer = provider(emptyList()) + descriptors.add(serializer.descriptor) + } + + override fun polymorphic( + baseClass: KClass, + actualClass: KClass, + actualSerializer: KSerializer + ) { + descriptors.add(actualSerializer.descriptor) + } + + override fun polymorphicDefaultSerializer( + baseClass: KClass, + defaultSerializerProvider: (value: Base) -> SerializationStrategy? + ) { + } + + override fun polymorphicDefaultDeserializer( + baseClass: KClass, + defaultDeserializerProvider: (className: String?) -> DeserializationStrategy? + ) { + } + }) + + return descriptors } } } diff --git a/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/KxsTsGeneratorSerializersModuleTest.kt b/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/KxsTsGeneratorSerializersModuleTest.kt new file mode 100644 index 00000000..da866d16 --- /dev/null +++ b/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/KxsTsGeneratorSerializersModuleTest.kt @@ -0,0 +1,107 @@ +package dev.adamko.kxstsgen.core + +import dev.adamko.kxstsgen.KxsTsGenerator +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule + +/** + * End-to-end tests for [KxsTsGenerator] driven by a [SerializersModule]. + * + * These assert on the *generated TypeScript* (not just the extracted descriptor set) so that + * regressions like a `type Foo = any` placeholder colliding with a resolved `interface Foo` + * (TS2300 duplicate identifier) are caught. + */ +class KxsTsGeneratorSerializersModuleTest : FunSpec({ + + context("contextual serializers") { + + test("a contextual type registered in the module is emitted as an interface, with no duplicate type-alias") { + val module = SerializersModule { + contextual(ContextualExample.SomeType::class, ContextualExample.SomeType.serializer()) + } + + val ts = KxsTsGenerator(serializersModule = module) + .generate(ContextualExample.TypeHolder.serializer()) + + // the resolved type is generated as an interface... + ts shouldContain "export interface SomeType {" + // ...and the contextual placeholder must NOT also be rendered as `type SomeType = any` + // (that would collide with the interface and produce a TS2300 duplicate identifier). + ts shouldNotContain "type SomeType = any" + // the referencing field must point at the resolved type + ts shouldContain "required: SomeType;" + + ts shouldBe """ + |export interface TypeHolder { + | required: SomeType; + |} + | + |export interface SomeType { + | a: string; + |} + """.trimMargin() + } + + test("a contextual type NOT registered in the module falls back to `type Foo = any`") { + val ts = KxsTsGenerator() + .generate(ContextualExample.TypeHolder.serializer()) + + // no dangling reference: the placeholder becomes a type-alias to `any`... + ts shouldContain "type SomeType = any" + // ...and no interface is invented + ts shouldNotContain "export interface SomeType {" + } + } + + context("polymorphic serializers") { + + test("a sealed hierarchy is rendered as a discriminated namespace, with no flat duplicate subclass") { + val module = SerializersModule { + polymorphic(SealedExample.Parent::class, SealedExample.SubClass::class, SealedExample.SubClass.serializer()) + } + + val ts = KxsTsGenerator(serializersModule = module) + .generate(SealedExample.Parent.serializer()) + + // the discriminated namespace, discriminator enum and union are all present... + ts shouldContain "export namespace Parent {" + ts shouldContain "export enum Type {" + ts shouldContain "| Parent.SubClass" + // ...the subclass lives (correctly) inside the namespace... + ts shouldContain " export interface SubClass {" + + // ...and there must be NO flat top-level duplicate of the subclass interface. + // (A line starting with `export interface SubClass` at column 0 would be such a duplicate.) + ts.lineSequence() + .filter { it.startsWith("export interface SubClass") } + .toList() + .shouldBeEmpty() + } + } +}) { + @Suppress("unused") + private object ContextualExample { + @Serializable + class SomeType(val a: String) + + @Serializable + class TypeHolder( + @kotlinx.serialization.Contextual + val required: SomeType, + ) + } + + @Suppress("unused") + private object SealedExample { + @Serializable + sealed class Parent + + @Serializable + class SubClass(val x: String) : Parent() + } +} diff --git a/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractorTest.kt b/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractorTest.kt index 83e7ab77..5bac4153 100644 --- a/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractorTest.kt +++ b/modules/kxs-ts-gen-core/src/commonTest/kotlin/dev/adamko/kxstsgen/core/SerializerDescriptorsExtractorTest.kt @@ -3,12 +3,18 @@ package dev.adamko.kxstsgen.core import io.kotest.assertions.withClue import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.modules.SerializersModule class SerializerDescriptorsExtractorTest : FunSpec({ + val module = SerializersModule { } + val extractor = SerializerDescriptorsExtractor.default(module) + test("Example1: given parent class, expect subclass property descriptor extracted") { val expected = listOf( @@ -17,7 +23,7 @@ class SerializerDescriptorsExtractorTest : FunSpec({ String.serializer().descriptor, ) - val actual = SerializerDescriptorsExtractor.Default(Example1.Parent.serializer()) + val actual = extractor(Example1.Parent.serializer()) actual shouldContainDescriptors expected } @@ -30,7 +36,7 @@ class SerializerDescriptorsExtractorTest : FunSpec({ String.serializer().descriptor, ) - val actual = SerializerDescriptorsExtractor.Default(Example2.Parent.serializer()) + val actual = extractor(Example2.Parent.serializer()) actual shouldContainDescriptors expected } @@ -43,11 +49,81 @@ class SerializerDescriptorsExtractorTest : FunSpec({ String.serializer().descriptor, ) - val actual = SerializerDescriptorsExtractor.Default(Example3.TypeHolder.serializer()) + val actual = extractor(Example3.TypeHolder.serializer()) actual shouldContainDescriptors expected } -}) { + + test("Example4: contextual serializer is extracted from SerializersModule") { + val module = SerializersModule { + contextual(Example4.SomeType::class, Example4.SomeType.serializer()) + } + val extractor = SerializerDescriptorsExtractor.default(module) + + val actual = extractor(Example4.TypeHolder.serializer()) + + val someTypeDescriptor = Example4.SomeType.serializer().descriptor + withClue("Should contain SomeType descriptor from module") { + actual.any { it.serialName == someTypeDescriptor.serialName } shouldBe true + } + } + + test("Example4b: contextual placeholder is suppressed when resolvable from the module") { + val module = SerializersModule { + contextual(Example4.SomeType::class, Example4.SomeType.serializer()) + } + val extractor = SerializerDescriptorsExtractor.default(module) + + val actual = extractor(Example4.TypeHolder.serializer()) + + // the resolved SomeType descriptor must be present... + val someTypeDescriptor = Example4.SomeType.serializer().descriptor + withClue("resolved SomeType descriptor should be present") { + actual.any { it.serialName == someTypeDescriptor.serialName } shouldBe true + } + // ...and the contextual placeholder must NOT survive into the extracted set - otherwise it + // renders as `type SomeType = any`, colliding with the resolved `interface SomeType` + // (TS2300 duplicate identifier). + withClue("no contextual placeholder should remain") { + actual.none { it.kind == SerialKind.CONTEXTUAL } shouldBe true + } + } + + test("Example4c: contextual resolution matches by name boundary, not a loose suffix") { + val module = SerializersModule { + contextual(Example4c.Foo::class, Example4c.Foo.serializer()) + contextual(Example4c.BarFoo::class, Example4c.BarFoo.serializer()) + } + val extractor = SerializerDescriptorsExtractor.default(module) + + val actual = extractor(Example4c.Holder.serializer()) + + val fooDescriptor = Example4c.Foo.serializer().descriptor + val barFooDescriptor = Example4c.BarFoo.serializer().descriptor + + // A `@Contextual Foo` must resolve to Foo only, and must NOT also pick up the unrelated + // BarFoo, whose simple name merely ends in "Foo". + withClue("Should contain Foo") { + actual.any { it.serialName == fooDescriptor.serialName } shouldBe true + } + withClue("Should NOT contain BarFoo") { + actual.any { it.serialName == barFooDescriptor.serialName } shouldBe false + } + } + + test("Example6: contextual without module registration does not extract descriptor") { + val emptyModule = SerializersModule { } + val extractor = SerializerDescriptorsExtractor.default(emptyModule) + + val actual = extractor(Example4.TypeHolder.serializer()) + + val someTypeDescriptor = Example4.SomeType.serializer().descriptor + withClue("Should NOT contain SomeType descriptor when not registered") { + actual.any { it.serialName == someTypeDescriptor.serialName } shouldBe false + } + } + + }) { companion object { private infix fun Collection.shouldContainDescriptors(expected: Collection) { val actual = this @@ -105,3 +181,34 @@ private object Example3 { val optional: SomeType?, ) } + + +@Suppress("unused") +private object Example4 { + + @Serializable + class SomeType(val a: String) + + @Serializable + class TypeHolder( + @kotlinx.serialization.Contextual + val required: SomeType, + ) +} + + +@Suppress("unused") +private object Example4c { + + @Serializable + class Foo(val a: String) + + @Serializable + class BarFoo(val b: String) + + @Serializable + class Holder( + @kotlinx.serialization.Contextual + val x: Foo, + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a32042f..100e8c31 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,12 @@ pluginManagement { } } +plugins { + // Allow Gradle to auto-download JVM toolchains (e.g. JDK 11 for :docs:code) when they + // are not installed locally. https://docs.gradle.org/current/userguide/toolchains.html + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)