diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..14f43d4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## v2.1.0 (SNAPSHOT) + +### Added +- Added support for serialization of sealed classes and interfaces. + - Added `discriminator` property to the `@KtConfig` annotation for handling sealed hierarchies. +- Added new methods to `KtConfigLoader` for easier file handling: + - `loadAndSave`: Loads a file and immediately saves it back. + - `loadAndSaveIfNotExists`: Loads a file and saves default values if the file doesn't exist. + - `saveIfNotExists`: Saves the configuration only if the file does not already exist. +- Added `FormattedColorSerializer#isSupportedAlpha` property to detect Minecraft version support for an alpha channel in colors. + +### Changed +- Improved the KSP code generator to use explicit imports instead of fully qualified names in generated loader classes. + - This results in cleaner and more readable generated code. + -
+ Example + + ```kotlin + // Target + @KtConfig + data class ExampleConfig( + val string: String, + val list: List, + ) + + // Before + private val ListOfString: Serializer> = + dev.s7a.ktconfig.serializer.ListSerializer(dev.s7a.ktconfig.serializer.StringSerializer) + + override fun load(configuration: YamlConfiguration, parentPath: String): ExampleConfig = ExampleConfig( + dev.s7a.ktconfig.serializer.StringSerializer.getOrThrow(configuration, "${parentPath}string"), + ListOfString.getOrThrow(configuration, "${parentPath}list"), + ) + + // After + private val ListOfString: Serializer> = ListSerializer(StringSerializer) + + override fun load(configuration: YamlConfiguration, parentPath: String): ExampleConfig = ExampleConfig( + StringSerializer.getOrThrow(configuration, "${parentPath}string"), + ListOfString.getOrThrow(configuration, "${parentPath}list"), + ) + ``` +
+- Deprecated `@PathName` and replaced it with `@SerialName` for better consistency. + - `@PathName` is scheduled to be removed in v2.4.0. +- Fixed `FormattedColorSerializer` to ignore alpha values of 255 (fully opaque) when encoding colors, treating them as if no alpha channel is specified. + +### Fixed +- Fixed KSP loader generation to respect nullability when the original type is a nullable typealias (previously, `Nullable` could be ignored and a non-null loader would be generated). +- Fixed `FormattedColorSerializer` to ignore an alpha channel when encoding colors on Minecraft versions that don't support alpha transparency. +- Fixed `BigInteger`, `BigDecimal` unsupported exception. + +## v2.0.0 + +Initial release. diff --git a/DEPRECATION.md b/DEPRECATION.md new file mode 100644 index 0000000..7ff7427 --- /dev/null +++ b/DEPRECATION.md @@ -0,0 +1,32 @@ +# Deprecation List + +This document lists features and APIs that have been deprecated in `ktConfig`. + +## Annotations + +### @PathName + +- **Deprecated in**: v2.1.0 +- **Scheduled for removal**: v2.4.0 +- **Replacement**: `dev.s7a.ktconfig.SerialName` + +`@PathName` was used to specify a custom YAML path name for a property. It is being replaced by `@SerialName` to provide a unified annotation for both property paths and sealed class discriminators. + +#### Example Migration + +```kotlin +// ❌ Deprecated (Compilation Error in v2.1.0+) +@KtConfig +data class Config( + @PathName("server-port") + val port: Int +) + +// ✅ Recommended +@KtConfig +data class Config( + @SerialName("server-port") + val port: Int +) +``` + diff --git a/README.md b/README.md index 8afe090..6c2d24c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # ktConfig v2 -Spigot configuration library for Kotlin using class annotations. The library generates configuration loaders at -build-time, ensuring zero runtime overhead (except for YamlConfiguration operations). +Spigot configuration library for Kotlin using class annotations. +The library generates configuration loaders at build-time, ensuring zero runtime overhead (except for YamlConfiguration operations). ## ⚡ Features - **Zero Runtime Overhead**: All configuration loaders are generated at build-time (KSP). - **Type-Safe**: Fully typed configuration using Kotlin data classes. -- **Wide Type Support**: Supports primitives, collections, Bukkit types, and more out of the box. +- **Wide Type Support**: Supports primitives, collections, Bukkit types, and more. +- **Sealed Classes and Interfaces Support**: Support for sealed classes and interfaces. - **Rich Features**: Built-in support for comments and custom serializers. - **Default Values**: Support for default values using Kotlin default values (e.g., `val count: Int = 0`). @@ -22,12 +23,12 @@ plugins { } repositories { - mavenCentral() + maven(url = "https://central.sonatype.com/repository/maven-snapshots/") } dependencies { - implementation("dev.s7a:ktConfig:2.0.0") - ksp("dev.s7a:ktConfig-ksp:2.0.0") + implementation("dev.s7a:ktConfig:2.1.0-SNAPSHOT") + ksp("dev.s7a:ktConfig-ksp:2.1.0-SNAPSHOT") } ``` @@ -64,10 +65,13 @@ maxPlayers: 100 The loader class provides the following methods: -- `load(File): T` -- `loadFromString(String): T` -- `save(T, File)` -- `saveToString(T): String` +- `load(File): T` - Loads configuration from the file. +- `loadAndSave(File): T` - Loads configuration from the file and immediately saves it back. +- `loadAndSaveIfNotExists(File): T` - Loads configuration from the file, or creates it with default values if it doesn't exist. +- `loadFromString(String): T` - Loads configuration from a YAML string. +- `save(File, T)` - Saves the configuration to the file. +- `saveIfNotExists(File, T)` - Saves the configuration only if the file doesn't already exist. +- `saveToString(T): String` - Serializes the configuration to a YAML string. ## 🚀 Usage @@ -75,7 +79,7 @@ ktConfig provides various annotations to customize configuration behavior: - `@KtConfig`: Marks a class as a configuration class. Required for code generation. - `@Comment`: Adds comments to configuration headers or properties. -- `@PathName`: Customizes the YAML path name for a property. +- `@SerialName`: Customizes the YAML path name for a property. - `@UseSerializer`: Specifies a custom serializer for a property. ### Adding Comments @@ -93,12 +97,16 @@ data class AppConfig( ### Change the YAML Path Name -You can customize the YAML path name using the `@PathName` annotation. +You can customize the YAML path name using the `@SerialName` annotation. + +> [!WARNING] +> +> `@PathName` is deprecated since v2.1.0 and will be removed in v2.4.0. ```kotlin @KtConfig data class ServerConfig( - @PathName("server-name") + @SerialName("server-name") val serverName: String ) ``` @@ -228,6 +236,58 @@ data class CustomConfig( ) ``` +You can also use type aliases with custom serializers for cleaner code reuse: + +```kotlin +typealias SerializableWrapper = + @UseSerializer(WrapperSerializer::class) Wrapper + +@KtConfig +data class CustomConfig( + val data: SerializableWrapper +) +``` + +### Sealed classes and interfaces + +- Use the `discriminator` property in `@KtConfig` to specify the YAML key name (default is `$`). +- Use `@SerialName` on subclasses to define their identifier in YAML (default is the class full name). + +```kotlin +@KtConfig(discriminator = "type") +sealed interface AppConfig { + @KtConfig + @SerialName("message") + data class Message( + val content: String + ) : AppConfig + + @KtConfig + @SerialName("broadcast") + data class Broadcast( + val content: String, + val delay: Int + ) : AppConfig +} +``` + +#### YAML Representation + +Depending on the class being saved, the YAML will look like this: + +```yaml +# For AppConfig.Message +type: message +content: "Hello World" +``` + +```yaml +# For AppConfig.Broadcast +type: broadcast +content: "Attention!" +delay: 20 +``` + ## 📦 Supported Types ktConfig supports the following types: @@ -287,6 +347,7 @@ ktConfig supports the following types: - `java.time.Period` - [Enum classes](https://kotlinlang.org/docs/enum-classes.html) - [Inline value classes](https://kotlinlang.org/docs/inline-classes.html) +- [Sealed classes and interfaces](https://kotlinlang.org/docs/sealed-classes.html) ### Formatted Types diff --git a/build.gradle.kts b/build.gradle.kts index a503ecd..5860b8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ plugins { } group = "dev.s7a" -version = "2.0.0" +version = "2.1.0-SNAPSHOT" allprojects { apply(plugin = "kotlin") diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 86912b0..75bf88a 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -20,7 +20,7 @@ repositories { dependencies { library(kotlin("stdlib")) - compileOnly(libs.spigot8) + compileOnly(libs.spigot) project.logger.lifecycle("Use Local Build: $useLocalBuild") if (useLocalBuild) { @@ -92,7 +92,27 @@ listOf( .get() .asFile.absolutePath, ) - jarUrl.set(LaunchMinecraftServerTask.JarUrl.Paper(version)) + // FIXME For some reason this doesn't work: 'kotlinx.serialization.KSerializer[] kotlinx.serialization.internal.GeneratedSerializer.typeParametersSerializers()' + // jarUrl.set(LaunchMinecraftServerTask.JarUrl.Paper(version)) + jarUrl.set( + when (version) { + "1.21.11" -> { + LaunchMinecraftServerTask.JarUrl { + "https://fill-data.papermc.io/v1/objects/5be84d9fc43181a72d5fdee7e3167824d9667bfc97b1bf9721713f9a971481ca/paper-1.21.11-88.jar" + } + } + + "1.8.8" -> { + LaunchMinecraftServerTask.JarUrl { + "https://fill-data.papermc.io/v1/objects/7ff6d2cec671ef0d95b3723b5c92890118fb882d73b7f8fa0a2cd31d97c55f86/paper-1.8.8-445.jar" + } + } + + else -> { + error("Unknown Minecraft version: $version") + } + }, + ) agreeEula.set(true) } } diff --git a/example/src/main/kotlin/dev/s7a/example/ExamplePlugin.kt b/example/src/main/kotlin/dev/s7a/example/ExamplePlugin.kt index c0667fc..5e04873 100644 --- a/example/src/main/kotlin/dev/s7a/example/ExamplePlugin.kt +++ b/example/src/main/kotlin/dev/s7a/example/ExamplePlugin.kt @@ -1,10 +1,15 @@ package dev.s7a.example import dev.s7a.example.config.HasDefaultConfigLoader +import dev.s7a.example.config.SealedTestConfig +import dev.s7a.example.config.SealedTestConfigBLoader +import dev.s7a.example.config.SealedTestConfigLoader import dev.s7a.example.config.SerializerTestConfig import dev.s7a.example.config.SerializerTestConfigLoader import dev.s7a.example.type.CustomData +import dev.s7a.ktconfig.type.FormattedColorSerializer import org.bukkit.Bukkit +import org.bukkit.Color import org.bukkit.Location import org.bukkit.Material import org.bukkit.inventory.ItemStack @@ -31,6 +36,8 @@ class ExamplePlugin : JavaPlugin() { override fun onEnable() { testSerializer() testDefaultSerializer() + testSealedSerializer() + testFormattedColor() server.shutdown() } @@ -77,6 +84,7 @@ class ExamplePlugin : JavaPlugin() { formattedVector2 = Vector(Random.nextDouble(), Random.nextDouble(), Random.nextDouble()), formattedVector3 = Vector(Random.nextDouble(), Random.nextDouble(), Random.nextDouble()), formattedVector4 = Vector(Random.nextDouble(), Random.nextDouble(), Random.nextDouble()), + nullableFormattedVector = Vector(Random.nextDouble(), Random.nextDouble(), Random.nextDouble()), customData = CustomData(Random.nextInt()), array = Array(3) { UUID.randomUUID().toString() }, byteArray = ByteArray(3) { Random.nextInt(-128, 128).toByte() }, @@ -242,7 +250,7 @@ class ExamplePlugin : JavaPlugin() { } output.info() - output.info("Check @PathName") + output.info("Check @SerialName") if (lines.contains("path-name: ${expected.pathName}")) { output.info("- Path name is overridden") } else { @@ -368,6 +376,11 @@ class ExamplePlugin : JavaPlugin() { "formattedVector4: expected=${expected.formattedVector4}, actual=${actual.formattedVector4}", ) } + if (expected.nullableFormattedVector != actual.nullableFormattedVector) { + output.error( + "nullableFormattedVector: expected=${expected.nullableFormattedVector}, actual=${actual.nullableFormattedVector}", + ) + } if (expected.customData != actual.customData) { output.error( "customData: expected=${expected.customData}, actual=${actual.customData}", @@ -550,4 +563,134 @@ class ExamplePlugin : JavaPlugin() { output.info("value: OK") } } + + private fun testSealedSerializer() { + output.info("Test sealed serializer:") + + // Use discriminator: '$' (ignore 'type') + listOf( + Pair( + """ + $: dev.s7a.example.config.SealedTestConfig.A + a: text1 + value: '5' + enum: TestA + + """.trimIndent(), + SealedTestConfig.A("text1", 5, SealedTestConfig.A.Enum.TestA), + ), + Pair( + """ + $: dev.s7a.example.config.SealedTestConfig.A + a: text1 + value: '5' + enum: TestA + + """.trimIndent(), + SealedTestConfig.A("text1", 5, SealedTestConfig.A.Enum.TestA), + ), + Pair( + """ + $: b1 + b1: text2 + enum: TestB1 + + """.trimIndent(), + SealedTestConfig.B.B1("text2", SealedTestConfig.B.B1.Enum.TestB1), + ), + Pair( + """ + $: dev.s7a.example.config.SealedTestConfig.B.B2 + b2: text3 + + """.trimIndent(), + SealedTestConfig.B.B2("text3"), + ), + Pair( + """ + $: dev.s7a.example.config.SealedTestConfig.C.C1 + c1: text4 + + """.trimIndent(), + SealedTestConfig.C.C1("text4"), + ), + ).forEach { (yaml, expected) -> + val config = SealedTestConfigLoader.loadFromString(yaml) + if (config != expected) { + output.error("SealedTestConfig: expected=$expected, actual=$config") + } else { + output.info("SealedTestConfig: OK") + } + + val actual = SealedTestConfigLoader.saveToString(config) + if (actual != yaml) { + output.error("SealedTestConfig: expected=$yaml, actual=$actual") + } else { + output.info("SealedTestConfig: OK") + } + } + + // Use discriminator: 'type' + listOf( + Pair( + """ + type: b1 + b1: text2 + enum: TestB1 + + """.trimIndent(), + SealedTestConfig.B.B1("text2", SealedTestConfig.B.B1.Enum.TestB1), + ), + Pair( + """ + type: dev.s7a.example.config.SealedTestConfig.B.B2 + b2: text3 + + """.trimIndent(), + SealedTestConfig.B.B2("text3"), + ), + ).forEach { (yaml, expected) -> + val config = SealedTestConfigBLoader.loadFromString(yaml) + if (config != expected) { + output.error("SealedTestConfigB: expected=$expected, actual=$config") + } else { + output.info("SealedTestConfigB: OK") + } + + val actual = SealedTestConfigBLoader.saveToString(config) + if (actual != yaml) { + output.error("SealedTestConfigB: expected=$yaml, actual=$actual") + } else { + output.info("SealedTestConfigB: OK") + } + } + } + + private fun testFormattedColor() { + val expected = Color.fromRGB(0x1F, 0x2E, 0x3D) + val actual = FormattedColorSerializer.decode("#1F2E3D") + if (actual != expected) { + output.error("FormattedColorSerializer: expected=$expected, actual=$actual") + } else { + output.info("FormattedColorSerializer: OK") + } + + if (FormattedColorSerializer.isSupportedAlpha) { + // Check alpha + val expected = Color.fromARGB(0x1F, 0x2E, 0x3D, 0x4C) + val actual = FormattedColorSerializer.decode("#1F2E3D4C") + if (actual != expected) { + output.error("FormattedColorSerializer(alpha): expected=$expected, actual=$actual") + } else { + output.info("FormattedColorSerializer(alpha): OK") + } + } else { + // Check alpha support + if (Bukkit.getVersion().contains("1.8.8").not()) { + output.error("FormattedColorSerializer(alpha): isSupportedAlpha=false") + } else { + output.info("FormattedColorSerializer(alpha): Unsupported version") + } + } + } } diff --git a/example/src/main/kotlin/dev/s7a/example/config/SealedTestConfig.kt b/example/src/main/kotlin/dev/s7a/example/config/SealedTestConfig.kt new file mode 100644 index 0000000..96e22ca --- /dev/null +++ b/example/src/main/kotlin/dev/s7a/example/config/SealedTestConfig.kt @@ -0,0 +1,50 @@ +package dev.s7a.example.config + +import dev.s7a.ktconfig.KtConfig +import dev.s7a.ktconfig.SerialName + +@KtConfig +sealed interface SealedTestConfig { + @KtConfig + data class A( + val a: String, + val value: Int, + val enum: Enum, + ) : SealedTestConfig { + enum class Enum { + TestA, + } + } + + @KtConfig(discriminator = "type") + sealed interface B : SealedTestConfig { + @KtConfig + @SerialName("b1") + data class B1( + val b1: String, + val enum: Enum, + ) : B { + enum class Enum { + TestB1, + } + } + + @KtConfig + data class B2( + val b2: String, + ) : B + } + + @KtConfig(discriminator = "") // -> $ + sealed class C : SealedTestConfig { + @KtConfig + data class C1( + val c1: String, + ) : C() + } + + @KtConfig + data class Ignored( + val ignored: String, + ) +} diff --git a/example/src/main/kotlin/dev/s7a/example/config/SerializerTestConfig.kt b/example/src/main/kotlin/dev/s7a/example/config/SerializerTestConfig.kt index 064fab1..0f97de2 100644 --- a/example/src/main/kotlin/dev/s7a/example/config/SerializerTestConfig.kt +++ b/example/src/main/kotlin/dev/s7a/example/config/SerializerTestConfig.kt @@ -3,7 +3,7 @@ package dev.s7a.example.config import dev.s7a.example.type.CustomData import dev.s7a.ktconfig.Comment import dev.s7a.ktconfig.KtConfig -import dev.s7a.ktconfig.PathName +import dev.s7a.ktconfig.SerialName import dev.s7a.ktconfig.UseSerializer import dev.s7a.ktconfig.type.FormattedVector import dev.s7a.ktconfig.type.FormattedVectorSerializer @@ -67,6 +67,7 @@ data class SerializerTestConfig( Vector, val formattedVector3: FormattedVectorAlias, val formattedVector4: FormattedVector2Alias, + val nullableFormattedVector: FormattedVector?, val customData: CustomData, val byteArray: ByteArray, val charArray: CharArray, @@ -108,7 +109,7 @@ data class SerializerTestConfig( val nullableListMap: List>, val nullableMapList: Map>, val nullableListNullableMap: List?>, - @PathName("path-name") + @SerialName("path-name") val pathName: String, val listPathName: List, val mapPathName: Map, @@ -150,7 +151,7 @@ data class SerializerTestConfig( @KtConfig data class NestedPathName( - @PathName("path-name") + @SerialName("path-name") val string: String, ) @@ -271,6 +272,7 @@ data class SerializerTestConfig( result = 31 * result + formattedVector2.hashCode() result = 31 * result + formattedVector3.hashCode() result = 31 * result + formattedVector4.hashCode() + result = 31 * result + (nullableFormattedVector?.hashCode() ?: 0) result = 31 * result + customData.hashCode() result = 31 * result + byteArray.contentHashCode() result = 31 * result + charArray.contentHashCode() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec5edd3..5dd1765 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ ksp = "2.3.4" kover = "0.9.4" kotlinter = "5.3.0" pluginYml = "0.8.0" -minecraftServer = "4.0.1" +minecraftServer = "4.0.2" shadow = "9.3.0" kotlinpoet = "2.2.0" dokka = "2.1.0" @@ -33,4 +33,3 @@ kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotli kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" } spigot8 = { group = "org.spigotmc", name = "spigot-api", version.ref = "spigot8" } spigot = { group = "org.spigotmc", name = "spigot-api", version.ref = "spigot" } -mavenPublish = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "mavenPublish" } diff --git a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KSPHelpers.kt b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KSPHelpers.kt new file mode 100644 index 0000000..c4ba9c0 --- /dev/null +++ b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KSPHelpers.kt @@ -0,0 +1,46 @@ +package dev.s7a.ktconfig.ksp + +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile + +/** + * Gets the full name of a class by traversing its parent hierarchy. + * For nested classes, returns a list of class names from outermost to innermost. + * For top-level classes, returns a single-element list with the class name. + * + * @param declaration The class declaration to get the full name for + * @return List of class names representing the full hierarchy + */ +fun getFullName(declaration: KSClassDeclaration): List = + if (declaration.parent is KSFile) { + listOf(declaration.simpleName.asString()) + } else { + getFullName(declaration.parent as KSClassDeclaration) + declaration.simpleName.asString() + } + +/** + * Generates a loader class name for the given class declaration. + * Combines the full class name parts with underscores and appends "Loader". + * + * @param declaration The class declaration to generate a loader name for + * @return The generated loader class name + */ +fun getLoaderName(declaration: KSClassDeclaration): String { + val fullName = getFullName(declaration).joinToString("") + return "${fullName}Loader" +} + +/** + * Recursively retrieves all sealed subclasses of this class declaration. + * For sealed classes with nested sealed subclasses, this function traverses the entire hierarchy + * and returns only the leaf (non-sealed or final sealed) subclasses. + * + * @receiver The sealed class declaration to get subclasses from + * @return List of all leaf sealed subclasses in the hierarchy + */ +fun KSClassDeclaration.getSealedSubclassesDeeply(): List { + val subclasses = getSealedSubclasses().toList() + return subclasses.flatMap { + it.getSealedSubclassesDeeply().ifEmpty { listOf(it) } + } +} diff --git a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KotlinPoetHelpers.kt b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KotlinPoetHelpers.kt new file mode 100644 index 0000000..0b3fb5f --- /dev/null +++ b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KotlinPoetHelpers.kt @@ -0,0 +1,40 @@ +package dev.s7a.ktconfig.ksp + +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.buildCodeBlock + +/** + * ["element1", "element2"] -> 'listOf("element1", "element2")' + */ +fun List.asLiteralList() = "listOf(${joinToString(", ") { "\"$it\"" } })" + +inline fun FunSpec.Builder.addControlFlowCode( + controlFlow: String, + vararg args: Any?, + block: CodeBlock.Builder.() -> Unit, +) { + addCode(controlFlowCode(controlFlow, *args, block = block)) +} + +inline fun CodeBlock.Builder.addControlFlowCode( + controlFlow: String, + vararg args: Any?, + block: CodeBlock.Builder.() -> Unit, +) { + add(controlFlowCode(controlFlow, *args, block = block)) +} + +inline fun controlFlowCode( + controlFlow: String, + vararg args: Any?, + block: CodeBlock.Builder.() -> Unit, +) = buildCodeBlock { + addControlFlow(controlFlow, *args, block = block) +} + +inline fun CodeBlock.Builder.addControlFlow( + controlFlow: String, + vararg args: Any?, + block: CodeBlock.Builder.() -> Unit, +) = beginControlFlow(controlFlow, *args).apply(block).endControlFlow() diff --git a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigAnnotation.kt b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigAnnotation.kt new file mode 100644 index 0000000..ddb88d1 --- /dev/null +++ b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigAnnotation.kt @@ -0,0 +1,125 @@ +package dev.s7a.ktconfig.ksp + +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSType + +/** + * Represents a @KtConfig annotation with its configuration parameters. + * + * @property hasDefault Whether the configuration has default values + * @property discriminator The discriminator character used for configuration keys (defaults to "$") + */ +data class KtConfigAnnotation( + val hasDefault: Boolean, + val discriminator: String, +) { + companion object { + /** + * Checks if the sequence contains a @KtConfig annotation. + * + * @return True if @KtConfig annotation is present, false otherwise + */ + fun KSAnnotated.getKtConfigAnnotation() = + annotations.firstNotNullOfOrNull { annotation -> + if (annotation.shortName.asString() != "KtConfig") return@firstNotNullOfOrNull null + val arguments = annotation.arguments.associate { it.name?.asString() to it.value } + KtConfigAnnotation( + hasDefault = arguments["hasDefault"] as? Boolean ?: false, + discriminator = (arguments["discriminator"] as? String).orEmpty().ifBlank { "$" }, + ) + } + } + + /** + * Represents a @Comment annotation with its content lines. + * + * @property content The list of comment lines + */ + data class Comment( + val content: List, + ) { + companion object { + /** + * Checks if the sequence contains a @Comment annotation and extracts its content. + * + * @return Comment instance with the content lines if @Comment annotation is present, null otherwise + */ + fun KSAnnotated.getCommentAnnotation() = + annotations.firstNotNullOfOrNull { annotation -> + if (annotation.shortName.asString() != "Comment") return@firstNotNullOfOrNull null + val arguments = annotation.arguments.associate { it.name?.asString() to it.value } + val content = arguments["content"] as? List<*> ?: return@firstNotNullOfOrNull null + if (content.isEmpty()) return@firstNotNullOfOrNull null + if (content.first() !is String) return@firstNotNullOfOrNull null + Comment(content.map(Any?::toString)) + } + } + } + + /** + * Represents a @SerialName annotation with its name value. + * + * @property name The serial name value + */ + data class SerialName( + val name: String, + ) { + companion object { + /** + * Checks if the sequence contains a @SerialName annotation and extracts its value. + * + * @return SerialName instance with the name value if @SerialName annotation is present, null otherwise + */ + fun KSAnnotated.getSerialNameAnnotation() = + annotations.firstNotNullOfOrNull { annotation -> + if (annotation.shortName.asString() != "SerialName") return@firstNotNullOfOrNull null + val arguments = annotation.arguments.associate { it.name?.asString() to it.value } + val name = arguments["name"] as? String ?: return@firstNotNullOfOrNull null + SerialName(name) + } + } + } + + /** + * Represents a @UseSerializer annotation with its serializer type. + * + * @property serializer The KSType of the custom serializer + */ + data class UseSerializer( + val serializer: KSType, + ) { + companion object { + /** + * Helper function to extract @UseSerializer annotation from a sequence of annotations. + * + * @return UseSerializer instance if @UseSerializer annotation is present, null otherwise + */ + private fun Sequence.get() = + firstNotNullOfOrNull { annotation -> + if (annotation.shortName.asString() != "UseSerializer") return@firstNotNullOfOrNull null + val arguments = annotation.arguments.associate { it.name?.asString() to it.value } + val serializer = arguments["serializer"] ?: return@firstNotNullOfOrNull null + if (serializer is KSType) { + UseSerializer(serializer) + } else { + null + } + } + + /** + * Extracts @UseSerializer annotation from a KSType. + * + * @return UseSerializer instance if @UseSerializer annotation is present, null otherwise + */ + fun KSType.getUseSerializerAnnotation() = annotations.get() + + /** + * Extracts @UseSerializer annotation from a KSAnnotated element. + * + * @return UseSerializer instance if @UseSerializer annotation is present, null otherwise + */ + fun KSAnnotated.getUseSerializerAnnotation() = annotations.get() + } + } +} diff --git a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigSymbolProcessor.kt b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigSymbolProcessor.kt index 1181aba..3ade176 100644 --- a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigSymbolProcessor.kt +++ b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/KtConfigSymbolProcessor.kt @@ -1,12 +1,10 @@ package dev.s7a.ktconfig.ksp -import com.google.devtools.ksp.getAllSuperTypes import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.symbol.KSAnnotated -import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSFile import com.google.devtools.ksp.symbol.KSType @@ -23,10 +21,16 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.buildCodeBlock import com.squareup.kotlinpoet.ksp.addOriginatingKSFile import com.squareup.kotlinpoet.ksp.writeTo +import dev.s7a.ktconfig.ksp.KtConfigAnnotation.Comment.Companion.getCommentAnnotation +import dev.s7a.ktconfig.ksp.KtConfigAnnotation.Companion.getKtConfigAnnotation +import dev.s7a.ktconfig.ksp.KtConfigAnnotation.SerialName.Companion.getSerialNameAnnotation +import dev.s7a.ktconfig.ksp.KtConfigAnnotation.UseSerializer.Companion.getUseSerializerAnnotation +import dev.s7a.ktconfig.ksp.Serializer.Companion.extractInitializableSerializers +import kotlin.collections.map /** * Symbol processor that generates loader classes for configurations annotated with @KtConfig. @@ -61,7 +65,9 @@ class KtConfigSymbolProcessor( private val stringClassName = ClassName("kotlin", "String") private val mapClassName = ClassName("kotlin.collections", "Map") private val anyClassName = ClassName("kotlin", "Any") + private val stringSerializerClassName = ClassName("dev.s7a.ktconfig.serializer", "StringSerializer") private val notFoundValueExceptionClassName = ClassName("dev.s7a.ktconfig.exception", "NotFoundValueException") + private val invalidDiscriminatorExceptionClassName = ClassName("dev.s7a.ktconfig.exception", "InvalidDiscriminatorException") /** * Visits each class declaration and generates a corresponding loader class. @@ -72,25 +78,12 @@ class KtConfigSymbolProcessor( data: Unit, ) { // Get @KtConfig annotation - val ktConfig = classDeclaration.annotations.getKtConfig() + val ktConfig = classDeclaration.getKtConfigAnnotation() if (ktConfig == null) { logger.error("Classes must be annotated with @KtConfig", classDeclaration) return } - // Get primary constructor from data class - val primaryConstructor = classDeclaration.primaryConstructor - if (primaryConstructor == null) { - logger.error("Classes annotated with @KtConfig must have a primary constructor", classDeclaration) - return - } - - // Get header comment - val headerComment = classDeclaration.annotations.getComment() - - // Get parameters from data class constructor - val parameters = primaryConstructor.parameters.map { createParameter(it) ?: return } - val packageName = classDeclaration.packageName.asString() val fullName = getFullName(classDeclaration) val className = ClassName(packageName, fullName) @@ -107,271 +100,482 @@ class KtConfigSymbolProcessor( .apply { addAnnotation(AnnotationSpec.builder(Suppress::class).addMember("%S", "ktlint").build()) - // Add properties for nested type serializer classes like ListOfString - parameters - .map(Parameter::serializer) - .extractInitializableSerializers() - .forEach { serializer -> - val className = if (serializer.keyable) keyableSerializerClassName else serializerClassName + // sealed interface/class + val sealedSubclasses = classDeclaration.getSealedSubclassesDeeply() + if (sealedSubclasses.isNotEmpty()) { + return@apply addSealedLoader(classDeclaration, className, loaderSimpleName, file, ktConfig, sealedSubclasses) + } + + // default + addDefaultLoader(classDeclaration, className, loaderSimpleName, file, ktConfig) + }.build() + .writeTo(codeGenerator, false) + } + + /** + * Generates a default loader class for non-sealed configuration classes. + * Creates implementations for load, save, decode, and encode functions that handle + * serialization and deserialization of configuration properties. + * + * @param classDeclaration The configuration class declaration to generate a loader for + * @param className The fully qualified class name + * @param loaderSimpleName The name for the generated loader class + * @param file The source file containing the class + * @param ktConfig The KtConfig annotation configuration including default value settings + */ + private fun FileSpec.Builder.addDefaultLoader( + classDeclaration: KSClassDeclaration, + className: ClassName, + loaderSimpleName: String, + file: KSFile, + ktConfig: KtConfigAnnotation, + ) { + val parameters = getParameters(classDeclaration) ?: return + + // Add properties for nested type serializer classes like ListOfString + addInitializableSerializerProperties(parameters) + + addType( + TypeSpec + .objectBuilder(loaderSimpleName) + .addOriginatingKSFile(file) + .superclass(loaderClassName.parameterizedBy(className)) + .apply { + if (ktConfig.hasDefault) { addProperty( PropertySpec - .builder(serializer.uniqueName, className.parameterizedBy(serializer.type)) + .builder("defaultValue", className) .addModifiers(KModifier.PRIVATE) - .initializer("%L", serializer.initialize) + .initializer("%T()", className) .build(), ) } - - addType( - TypeSpec - .objectBuilder(loaderSimpleName) - .addOriginatingKSFile(file) - .superclass(loaderClassName.parameterizedBy(className)) - .apply { - if (ktConfig.hasDefault) { - addProperty( - PropertySpec - .builder("defaultValue", className) - .addModifiers(KModifier.PRIVATE) - .initializer("%T()", className) - .build(), - ) + }.addLoadFunSpec(className) { + addCode( + "return %T(\n%L)", + className, + buildCodeBlock { + parameters.forEach { parameter -> + if (ktConfig.hasDefault) { + addStatement( + "${parameter.serializer.refKey}.get(configuration, \"%L%L\") ?: defaultValue.%N,", + parameter.serializer.ref, + $$"${parentPath}", + parameter.pathName, + parameter.name, + ) + } else { + addStatement( + "${parameter.serializer.refKey}.%N(configuration, \"%L%L\"),", + parameter.serializer.ref, + parameter.serializer.getFn, + $$"${parentPath}", + parameter.pathName, + ) + } } - }.addFunction( - // Add `load` function to KtConfig loader class - FunSpec - .builder("load") - .addModifiers(KModifier.OVERRIDE) - .addParameter(ParameterSpec("configuration", yamlConfigurationClassName)) - .addParameter(ParameterSpec("parentPath", stringClassName)) - .addCode( - "return %T(\n", - className, - ).apply { - parameters.forEach { parameter -> - if (ktConfig.hasDefault) { - addStatement( - $$"%L.get(configuration, \"${parentPath}%L\") ?: defaultValue.%N,", - parameter.serializer.ref, - parameter.pathName, - parameter.name, - ) - } else { - addStatement( - $$"%L.%N(configuration, \"${parentPath}%L\"),", - parameter.serializer.ref, - parameter.serializer.getFn, - parameter.pathName, - ) - } + }, + ) + }.addSaveFunSpec(classDeclaration, className) { + parameters.forEach { parameter -> + addStatement( + "${parameter.serializer.refKey}.set(configuration, \"%L%L\", value.%N)", + parameter.serializer.ref, + $$"${parentPath}", + parameter.pathName, + parameter.name, + ) + + val comment = parameter.comment + if (comment != null) { + // Add property comment + addStatement( + "setComment(configuration, \"%L%L\", %L)", + $$"${parentPath}", + parameter.pathName, + comment.asLiteralList(), + ) + } + } + }.addDecodeFunSpec(className) { + addCode( + "return %T(\n%L)", + className, + buildCodeBlock { + parameters.forEach { parameter -> + when { + ktConfig.hasDefault -> { + addStatement( + "value[%S]?.let(${parameter.serializer.refKey}::deserialize) ?: defaultValue.%N,", + parameter.pathName, + parameter.serializer.ref, + parameter.name, + ) } - }.addCode(")") - .returns(className) - .build(), - ).addFunction( - // Add `save` function to KtConfig loader class - FunSpec - .builder("save") - .addModifiers(KModifier.OVERRIDE) - .addParameter(ParameterSpec("configuration", yamlConfigurationClassName)) - .addParameter(ParameterSpec("value", className)) - .addParameter(ParameterSpec("parentPath", stringClassName)) - .apply { - if (headerComment != null) { - // Add header comment + + parameter.isNullable -> { addStatement( - "setHeaderComment(configuration, parentPath, listOf(%L))", - headerComment.joinToString { "\"${it}\"" }, + "value[%S]?.let(${parameter.serializer.refKey}::deserialize),", + parameter.pathName, + parameter.serializer.ref, ) } - parameters.forEach { parameter -> + else -> { addStatement( - $$"%L.set(configuration, \"${parentPath}%L\", value.%N)", + "value[%S]?.let(${parameter.serializer.refKey}::deserialize) ?: throw %T(%S),", + parameter.pathName, parameter.serializer.ref, + notFoundValueExceptionClassName, parameter.pathName, - parameter.name, ) - - val comment = parameter.comment - if (comment != null) { - // Add property comment - addStatement( - $$"setComment(configuration, \"${parentPath}%L\", listOf(%L))", - parameter.pathName, - comment.joinToString { "\"${it}\"" }, - ) - } - } - }.build(), - ).addFunction( - // Add `decode` function to KtConfig loader class - FunSpec - .builder("decode") - .addModifiers(KModifier.OVERRIDE) - .addParameter( - ParameterSpec( - "value", - mapClassName.parameterizedBy(stringClassName, anyClassName.copy(nullable = true)), - ), - ).addCode( - "return %T(\n", - className, - ).apply { - parameters.forEach { parameter -> - when { - ktConfig.hasDefault -> { - addStatement( - "value[%S]?.let(%L::deserialize) ?: defaultValue.%N,", - parameter.pathName, - parameter.serializer.ref, - parameter.name, - ) - } - - parameter.isNullable -> { - addStatement( - "value[%S]?.let(%L::deserialize),", - parameter.pathName, - parameter.serializer.ref, - ) - } - - else -> { - addStatement( - "value[%S]?.let(%L::deserialize) ?: throw %L(%S),", - parameter.pathName, - parameter.serializer.ref, - notFoundValueExceptionClassName, - parameter.pathName, - ) - } - } - } - }.addCode(")") - .returns(className) - .build(), - ).addFunction( - // Add `encode` function to KtConfig loader class - FunSpec - .builder("encode") - .addModifiers(KModifier.OVERRIDE) - .addParameter(ParameterSpec("value", className)) - .addCode( - "return mapOf(\n", - ).apply { - parameters.forEach { parameter -> - if (parameter.isNullable) { - addStatement( - "%S to value.%L?.let(%L::serialize),", - parameter.pathName, - parameter.name, - parameter.serializer.ref, - ) - } else { - addStatement( - "%S to %L.serialize(value.%N),", - parameter.pathName, - parameter.serializer.ref, - parameter.name, - ) - } } - }.addCode( - ")", - ).returns(mapClassName.parameterizedBy(stringClassName, anyClassName.copy(nullable = true))) - .build(), - ).build(), - ) - }.build() - .writeTo(codeGenerator, false) + } + } + }, + ) + }.addEncodeFunSpec(className) { + addCode( + "return mapOf(\n%L)", + buildCodeBlock { + parameters.forEach { parameter -> + if (parameter.isNullable) { + addStatement( + "%S to value.%N?.let(${parameter.serializer.refKey}::serialize),", + parameter.pathName, + parameter.name, + parameter.serializer.ref, + ) + } else { + addStatement( + "%S to ${parameter.serializer.refKey}.serialize(value.%N),", + parameter.pathName, + parameter.serializer.ref, + parameter.name, + ) + } + } + }, + ) + }.build(), + ) } /** - * Gets the full name of a class by traversing its parent hierarchy. - * For nested classes, returns a list of class names from outermost to innermost. - * For top-level classes, returns a single-element list with the class name. + * Generates a loader class for sealed interfaces/classes. + * Creates a loader that handles polymorphic deserialization based on a discriminator field. * - * @param declaration The class declaration to get the full name for - * @return List of class names representing the full hierarchy + * @param classDeclaration The sealed class or interface declaration + * @param className The fully qualified class name + * @param loaderSimpleName The name for the generated loader class + * @param file The source file containing the class + * @param ktConfig The KtConfig annotation configuration + * @param sealedSubclasses List of sealed subclasses to support in the loader */ - private fun getFullName(declaration: KSClassDeclaration): List = - if (declaration.parent is KSFile) { - listOf(declaration.simpleName.asString()) - } else { - getFullName(declaration.parent as KSClassDeclaration) + declaration.simpleName.asString() - } + private fun FileSpec.Builder.addSealedLoader( + classDeclaration: KSClassDeclaration, + className: ClassName, + loaderSimpleName: String, + file: KSFile, + ktConfig: KtConfigAnnotation, + sealedSubclasses: List, + ) { + val sealedSubclassDiscriminators = + sealedSubclasses.associateWith { subclass -> + getDiscriminator(subclass) ?: return + } + + addType( + TypeSpec + .objectBuilder(loaderSimpleName) + .addOriginatingKSFile(file) + .superclass(loaderClassName.parameterizedBy(className)) + .addLoadFunSpec(className) { + addControlFlowCode( + "return when (val discriminator = %T.getOrThrow(configuration, \"%L%L\"))", + stringSerializerClassName, + $$"${parentPath}", + ktConfig.discriminator, + ) { + sealedSubclassDiscriminators.forEach { (subclass, discriminator) -> + addControlFlow("%S ->", discriminator) { + addStatement("%T.load(configuration, parentPath)", ClassName(packageName, getLoaderName(subclass))) + } + } + addControlFlow("else ->") { + addStatement("throw %T(discriminator)", invalidDiscriminatorExceptionClassName) + } + } + }.addSaveFunSpec(classDeclaration, className) { + addControlFlowCode("when (value)") { + sealedSubclassDiscriminators.forEach { (subclass, discriminator) -> + addControlFlowCode( + "is %T ->", + ClassName(subclass.packageName.asString(), getFullName(subclass)), + ) { + addStatement( + "%T.set(configuration, \"%L%L\", %S)", + stringSerializerClassName, + $$"${parentPath}", + ktConfig.discriminator, + discriminator, + ) + addStatement( + "%T.save(configuration, value, parentPath)", + ClassName(packageName, getLoaderName(subclass)), + ) + } + } + } + }.addDecodeFunSpec(className) { + addControlFlowCode( + "return when (val discriminator = value[%S]?.let(%T::deserialize) ?: throw %T(%S))", + ktConfig.discriminator, + stringSerializerClassName, + notFoundValueExceptionClassName, + ktConfig.discriminator, + ) { + sealedSubclassDiscriminators.forEach { (subclass, discriminator) -> + addControlFlow("%S ->", discriminator) { + addStatement("%T.decode(value)", ClassName(packageName, getLoaderName(subclass))) + } + } + addControlFlow("else ->") { + addStatement("throw %T(discriminator)", invalidDiscriminatorExceptionClassName) + } + } + }.addEncodeFunSpec(className) { + addControlFlowCode("return when (value)") { + sealedSubclassDiscriminators.forEach { (subclass, discriminator) -> + addControlFlowCode( + "is %T ->", + ClassName(subclass.packageName.asString(), getFullName(subclass)), + ) { + addStatement( + "mapOf(%S to %T.serialize(%S)) + %T.encode(value)", + ktConfig.discriminator, + stringSerializerClassName, + discriminator, + ClassName(packageName, getLoaderName(subclass)), + ) + } + } + } + }.build(), + ) + } /** - * Generates a loader class name for the given class declaration. - * Combines the full class name parts with underscores and appends "Loader". + * Adds a load function to the TypeSpec builder by creating and adding the function specification. + * This is a convenience wrapper around [createLoadFunSpec]. * - * @param declaration The class declaration to generate a loader name for - * @return The generated loader class name + * @param className The fully qualified class name to return from the load function + * @param block Additional configuration for the function builder + * @return This TypeSpec.Builder for chaining */ - private fun getLoaderName(declaration: KSClassDeclaration): String { - val fullName = getFullName(declaration).joinToString("") - return "${fullName}Loader" - } + private fun TypeSpec.Builder.addLoadFunSpec( + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = addFunction(createLoadFunSpec(className, block)) /** - * Extracts comment content from @Comment annotations in the sequence. - * Processes the annotation arguments to get the comment strings. + * Creates a load function specification that deserializes configuration data into a class instance. + * This function reads values from a YamlConfiguration using the parent path as a prefix. * - * @return List of comment strings, or null if no valid comment annotation is found + * @param className The fully qualified class name to return from the load function + * @param block Additional configuration for the function builder + * @return A function specification for the load method */ - private fun Sequence.getComment(): List? { - forEach { annotation -> - if (annotation.shortName.asString() == "Comment") { - val content = annotation.arguments.firstOrNull { it.name?.asString() == "content" } - if (content != null) { - val value = content.value - if (value is List<*> && value.isNotEmpty() && value.first() is String) { - return value.map(Any?::toString) - } - } + private fun createLoadFunSpec( + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = FunSpec + .builder("load") + .addModifiers(KModifier.OVERRIDE) + .addParameter(ParameterSpec("configuration", yamlConfigurationClassName)) + .addParameter(ParameterSpec("parentPath", stringClassName)) + .apply(block) + .returns(className) + .build() + + /** + * Adds a save function to the TypeSpec builder by creating and adding the function specification. + * This is a convenience wrapper around [createSaveFunSpec]. + * + * @param classDeclaration The source class declaration to extract annotations from + * @param className The fully qualified class name to accept as a parameter + * @param block Additional configuration for the function builder + * @return This TypeSpec.Builder for chaining + */ + private fun TypeSpec.Builder.addSaveFunSpec( + classDeclaration: KSClassDeclaration, + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = addFunction(createSaveFunSpec(classDeclaration, className, block)) + + /** + * Creates a save function specification that serializes a class instance into configuration data. + * This function writes values to a YamlConfiguration using the parent path as a prefix, + * and handles header comments from the class declaration if present. + * + * @param classDeclaration The source class declaration to extract annotations from + * @param className The fully qualified class name to accept as a parameter + * @param block Additional configuration for the function builder + * @return A function specification for the save method + */ + private fun createSaveFunSpec( + classDeclaration: KSClassDeclaration, + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = FunSpec + .builder("save") + .addModifiers(KModifier.OVERRIDE) + .addParameter(ParameterSpec("configuration", yamlConfigurationClassName)) + .addParameter(ParameterSpec("value", className)) + .addParameter(ParameterSpec("parentPath", stringClassName)) + .apply { + // Get header comment + val headerComment = classDeclaration.getCommentAnnotation()?.content + + if (headerComment.isNullOrEmpty().not()) { + // Add header comment + addStatement( + "setHeaderComment(configuration, parentPath, %L)", + headerComment.asLiteralList(), + ) } - } + }.apply(block) + .build() - return null - } + /** + * Adds a decode function to the TypeSpec builder by creating and adding the function specification. + * This is a convenience wrapper around [createDecodeFunSpec]. + * + * @param className The fully qualified class name to return from the decode function + * @param block Additional configuration for the function builder + * @return This TypeSpec.Builder for chaining + */ + private fun TypeSpec.Builder.addDecodeFunSpec( + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = addFunction(createDecodeFunSpec(className, block)) /** - * Extracts the path name from @PathName annotations in the sequence. - * Processes the annotation arguments to get the path name string. + * Creates a decode function specification that deserializes a map into a class instance. + * This function converts a map of string keys to nullable values into the target class type, + * validating required fields and handling nullable values appropriately. * - * @return The path name string from the annotation, or null if no valid @PathName annotation is found + * @param className The fully qualified class name to return from the decode function + * @param block Additional configuration for the function builder + * @return A function specification for the decode method */ - private fun Sequence.getPathName(): String? { - forEach { annotation -> - if (annotation.shortName.asString() == "PathName") { - val content = annotation.arguments.firstOrNull { it.name?.asString() == "name" } - if (content != null) { - val value = content.value - if (value is String) { - return value - } - } + private fun createDecodeFunSpec( + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = FunSpec + .builder("decode") + .addModifiers(KModifier.OVERRIDE) + .addParameter( + ParameterSpec( + "value", + mapClassName.parameterizedBy(stringClassName, anyClassName.copy(nullable = true)), + ), + ).apply(block) + .returns(className) + .build() + + /** + * Adds an encode function to the TypeSpec builder by creating and adding the function specification. + * This is a convenience wrapper around [createEncodeFunSpec]. + * + * @param className The fully qualified class name to accept as a parameter + * @param block Additional configuration for the function builder + * @return This TypeSpec.Builder for chaining + */ + private fun TypeSpec.Builder.addEncodeFunSpec( + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = addFunction(createEncodeFunSpec(className, block)) + + /** + * Creates an encode function specification that serializes a class instance into a map. + * This function converts the target class into a map with string keys and nullable values, + * preserving the structure for configuration persistence. + * + * @param className The fully qualified class name to accept as a parameter + * @param block Additional configuration for the function builder + * @return A function specification for the encode method + */ + private fun createEncodeFunSpec( + className: ClassName, + block: FunSpec.Builder.() -> Unit, + ) = FunSpec + .builder("encode") + .addModifiers(KModifier.OVERRIDE) + .addParameter(ParameterSpec("value", className)) + .apply(block) + .returns(mapClassName.parameterizedBy(stringClassName, anyClassName.copy(nullable = true))) + .build() + + /** + * Adds property declarations for serializers that require initialization. + * Extracts nested type serializers (like ListOfString) from parameters and creates + * private properties for them in the generated loader class. + * + * @param parameters List of configuration parameters that may contain nested serializers + */ + private fun FileSpec.Builder.addInitializableSerializerProperties(parameters: List) { + parameters + .map(Parameter::serializer) + .extractInitializableSerializers() + .forEach { serializer -> + val className = if (serializer.keyable) keyableSerializerClassName else serializerClassName + addProperty( + PropertySpec + .builder(serializer.uniqueName, className.parameterizedBy(serializer.typeName)) + .addModifiers(KModifier.PRIVATE) + .initializer("%L", serializer.initialize) + .build(), + ) } + } + + private fun getParameters(declaration: KSClassDeclaration): List? { + // Get primary constructor from data class + val primaryConstructor = declaration.primaryConstructor + if (primaryConstructor == null) { + logger.error("Classes annotated with @KtConfig must have a primary constructor", declaration) + return null } - return null + // Get parameters from data class constructor + return primaryConstructor.parameters.map { createParameter(it) ?: return null } } /** - * Checks if the sequence contains a @KtConfig annotation. + * Determines the discriminator value for a sealed class subclass. + * The discriminator is used to identify which subclass to deserialize when loading sealed types. + * + * First checks for a @SerialName annotation on the class declaration and uses that value if present. + * If no @SerialName is found, falls back to using the class's fully qualified name as the discriminator. * - * @return True if @KtConfig annotation is present, false otherwise + * @param declaration The sealed class subclass declaration to get the discriminator for + * @return The discriminator string (from @SerialName or qualified name), or null if the class has no qualified name */ - private fun Sequence.getKtConfig(): KtConfigAnnotation? = - firstNotNullOfOrNull { annotation -> - if (annotation.shortName.asString() != "KtConfig") return null - val arguments = annotation.arguments.associate { it.name?.asString() to it.value } - KtConfigAnnotation( - hasDefault = arguments["hasDefault"] as? Boolean ?: false, - ) + private fun getDiscriminator(declaration: KSClassDeclaration): String? { + val serialName = declaration.getSerialNameAnnotation() + if (serialName != null) { + return serialName.name } + val qualifiedName = declaration.qualifiedName?.asString() + if (qualifiedName == null) { + logger.error("Class declaration must have a qualified name", declaration) + return null + } + return qualifiedName + } + /** * Creates a Parameter object from a KSValueParameter, validating the parameter name and type. * Returns null if the parameter is invalid or unsupported. @@ -384,42 +588,36 @@ class KtConfigSymbolProcessor( } val serializer = getSerializer(declaration) ?: return null - val pathName = declaration.annotations.getPathName() - val comment = declaration.annotations.getComment() + val pathName = declaration.getSerialNameAnnotation()?.name + val comment = declaration.getCommentAnnotation()?.content return Parameter(pathName ?: name, name, serializer, comment) } - private fun Sequence.getSerializer(): Serializer.Custom? { - forEach { annotation -> - if (annotation.shortName.asString() == "UseSerializer") { - val serializer = annotation.arguments.firstOrNull { it.name?.asString() == "serializer" } - if (serializer != null) { - val value = serializer.value - if (value is KSType) { - val qualifiedName = value.declaration.qualifiedName - if (qualifiedName != null) { - return Serializer.Custom(qualifiedName.asString()) - } - } - } - } - } - - return null - } - private fun KSType.solveTypeAlias(): Pair { val declaration = this.declaration - val serializer = + val annotation = // Get typealias-annotated serializer - annotations.getSerializer() + getUseSerializerAnnotation() ?: // Get class-annotated serializer - declaration.annotations.getSerializer() + declaration.getUseSerializerAnnotation() + val serializer = + annotation + ?.serializer + ?.declaration + ?.qualifiedName + ?.asString() + ?.let(Serializer::Custom) // Solve typealias if (declaration is KSTypeAlias) { val (resolvedType, resolvedSerializer) = declaration.type.resolve().solveTypeAlias() - return resolvedType to (resolvedSerializer ?: serializer) + return resolvedType.let { + if (isMarkedNullable) { + it.makeNullable() + } else { + it.makeNotNullable() + } + } to (resolvedSerializer ?: serializer) } return this to serializer @@ -462,7 +660,7 @@ class KtConfigSymbolProcessor( val modifiers = declaration.modifiers when { modifiers.contains(Modifier.ENUM) -> { - return Parameter.Serializer.EnumClass(className, type.isMarkedNullable, qualifiedName) + return Parameter.Serializer.EnumClass(className, type.isMarkedNullable) } modifiers.contains(Modifier.VALUE) -> { @@ -496,7 +694,7 @@ class KtConfigSymbolProcessor( } // Get serializer - val serializer = customSerializer ?: findSerializer(qualifiedName, type) + val serializer = customSerializer ?: Serializer.findSerializer(qualifiedName, type) if (serializer == null) { logger.error("Unsupported type: $qualifiedName", declaration) return null @@ -507,11 +705,11 @@ class KtConfigSymbolProcessor( when (serializer) { Serializer.ConfigurationSerializable -> { - return Parameter.Serializer.ConfigurationSerializableClass(className, isNullable, qualifiedName) + return Parameter.Serializer.ConfigurationSerializableClass(className, isNullable) } is Serializer.BuiltIn -> { - return Parameter.Serializer.Object(className, isNullable, serializer.name, serializer.qualifiedName) + return Parameter.Serializer.Object(className, isNullable, serializer.name, serializer.serializerType) } is Serializer.Collection -> { @@ -532,13 +730,13 @@ class KtConfigSymbolProcessor( className, isNullable, serializer.name, - serializer.qualifiedName, + serializer.serializerType, argumentSerializers, serializer.supportNullableValue && nullableValue, ) } - return Parameter.Serializer.Object(className, isNullable, serializer.name, serializer.qualifiedName) + return Parameter.Serializer.Object(className, isNullable, serializer.name, serializer.serializerType) } is Serializer.Nested -> { @@ -546,7 +744,7 @@ class KtConfigSymbolProcessor( className, isNullable, serializer.qualifiedName, - serializer.loaderName, + serializer.loaderType, ) } @@ -555,290 +753,10 @@ class KtConfigSymbolProcessor( className, isNullable, serializer.qualifiedName.replace('.', '_'), // unique name - serializer.qualifiedName, + serializer.serializerType, ) } } } - - private val serializers = - mapOf( - // Primitive - "kotlin.Byte" to Serializer.BuiltIn("Byte"), - "kotlin.Char" to Serializer.BuiltIn("Char"), - "kotlin.Int" to Serializer.BuiltIn("Int"), - "kotlin.Long" to Serializer.BuiltIn("Long"), - "kotlin.Short" to Serializer.BuiltIn("Short"), - "kotlin.String" to Serializer.BuiltIn("String"), - "kotlin.UByte" to Serializer.BuiltIn("UByte"), - "kotlin.UInt" to Serializer.BuiltIn("UInt"), - "kotlin.ULong" to Serializer.BuiltIn("ULong"), - "kotlin.UShort" to Serializer.BuiltIn("UShort"), - "kotlin.Double" to Serializer.BuiltIn("Double"), - "kotlin.Float" to Serializer.BuiltIn("Float"), - "kotlin.Boolean" to Serializer.BuiltIn("Boolean"), - // Common - "java.util.UUID" to Serializer.BuiltIn("UUID"), - "java.time.Instant" to Serializer.BuiltIn("Instant"), - "java.time.LocalTime" to Serializer.BuiltIn("LocalTime"), - "java.time.LocalDate" to Serializer.BuiltIn("LocalDate"), - "java.time.LocalDateTime" to Serializer.BuiltIn("LocalDateTime"), - "java.time.Year" to Serializer.BuiltIn("Year"), - "java.time.YearMonth" to Serializer.BuiltIn("YearMonth"), - "java.time.OffsetTime" to Serializer.BuiltIn("OffsetTime"), - "java.time.OffsetDateTime" to Serializer.BuiltIn("OffsetDateTime"), - "java.time.ZonedDateTime" to Serializer.BuiltIn("ZonedDateTime"), - "java.time.Duration" to Serializer.BuiltIn("Duration"), - "java.time.Period" to Serializer.BuiltIn("Period"), - // Collections - "kotlin.Array" to Serializer.Collection("Array", true), - "kotlin.ByteArray" to Serializer.Collection("ByteArray", false), - "kotlin.CharArray" to Serializer.Collection("CharArray", false), - "kotlin.IntArray" to Serializer.Collection("IntArray", false), - "kotlin.LongArray" to Serializer.Collection("LongArray", false), - "kotlin.ShortArray" to Serializer.Collection("ShortArray", false), - "kotlin.UByteArray" to Serializer.Collection("UByteArray", false), - "kotlin.UIntArray" to Serializer.Collection("UIntArray", false), - "kotlin.ULongArray" to Serializer.Collection("ULongArray", false), - "kotlin.UShortArray" to Serializer.Collection("UShortArray", false), - "kotlin.DoubleArray" to Serializer.Collection("DoubleArray", false), - "kotlin.FloatArray" to Serializer.Collection("FloatArray", false), - "kotlin.BooleanArray" to Serializer.Collection("BooleanArray", false), - "kotlin.collections.List" to Serializer.Collection("List", true), - "kotlin.collections.Set" to Serializer.Collection("Set", true), - "kotlin.collections.ArrayDeque" to Serializer.Collection("ArrayDeque", true), - "kotlin.collections.Map" to Serializer.Collection("Map", true), - ) - - /** - * Finds the appropriate serializer name for a given type. - * First checks if the type implements ConfigurationSerializable, - * then looks up built-in serializers. - * - * @param qualifiedName The fully qualified name of the type - * @param type The KSType representing the type to find a serializer for - * @return The name of the serializer to use, or null if no suitable serializer is found - */ - private fun findSerializer( - qualifiedName: String, - type: KSType, - ): Serializer? { - val declaration = type.declaration - if (declaration is KSClassDeclaration) { - // Check if type marked @KtConfig - val ktConfig = declaration.annotations.getKtConfig() - if (ktConfig != null) { - return Serializer.Nested(qualifiedName, "${declaration.packageName.asString()}.${getLoaderName(declaration)}") - } - - // Check if type implements ConfigurationSerializable - declaration.getAllSuperTypes().forEach { superType -> - val qualifiedName = superType.declaration.qualifiedName?.asString() - if (qualifiedName == "org.bukkit.configuration.serialization.ConfigurationSerializable") { - return Serializer.ConfigurationSerializable - } - } - } - - // Lookup serializer name from the predefined map of built-in serializers - return serializers[qualifiedName] - } - - /** - * Resolves a list of serializers by flattening nested serializers and removing duplicates. - * - * @return A flattened list of unique initializable serializers identified by their unique names that need initialization - */ - private fun List.extractInitializableSerializers(): List = - filterIsInstance() - .flatMap { - when (it) { - is Parameter.Serializer.Class -> { - it.arguments.extractInitializableSerializers() + it - } - - is Parameter.Serializer.Nested -> { - listOf(it) - } - - is Parameter.Serializer.ConfigurationSerializableClass -> { - listOf(it) - } - - is Parameter.Serializer.EnumClass -> { - listOf(it) - } - - is Parameter.Serializer.ValueClass -> { - listOf(it.argument).extractInitializableSerializers() + it - } - } - }.distinctBy(Parameter.Serializer::uniqueName) - } - - private data class KtConfigAnnotation( - val hasDefault: Boolean, - ) - - private data class Parameter( - val pathName: String, - val name: String, - val serializer: Serializer, - val comment: List?, - ) { - val isNullable - get() = serializer.isNullable - - sealed class Serializer( - val type: TypeName, - val isNullable: Boolean, - ) { - abstract val uniqueName: String - abstract val ref: String - abstract val keyable: Boolean - - val getFn = if (isNullable) "get" else "getOrThrow" - - class Object( - type: ClassName, - isNullable: Boolean, - name: String, - qualifiedName: String, - ) : Serializer(type, isNullable) { - override val uniqueName = name - override val ref = qualifiedName - override val keyable = true - } - - sealed class InitializableSerializer( - type: TypeName, - isNullable: Boolean, - name: String, - protected val qualifiedName: String = "dev.s7a.ktconfig.serializer.${name}Serializer", - ) : Serializer(type, isNullable) { - abstract val initialize: String - } - - // Properties like type, uniqueName, ref are stored as class properties - // to avoid recalculating them each time they are accessed - class Class( - parentType: ClassName, - isNullable: Boolean, - name: String, - qualifiedName: String, - val arguments: List, - val nullableValue: Boolean, - ) : InitializableSerializer( - parentType.parameterizedBy( - arguments.mapIndexed { index, it -> - if (arguments.lastIndex == index) { - it.type.copy(nullable = nullableValue) - } else { - it.type - } - }, - ), - isNullable, - name, - qualifiedName, - ) { - override val uniqueName = - buildString { - if (nullableValue) append("Nullable") - append(name) - append("Of") - arguments.forEach { - append(it.uniqueName) - } - } - override val ref = uniqueName - override val keyable = false - override val initialize = - buildString { - append(qualifiedName) - if (nullableValue) { - append(".Nullable") - } - append("(${arguments.joinToString(", ") { it.ref }})") - } - } - - class Nested( - type: ClassName, - isNullable: Boolean, - qualifiedName: String, - loaderName: String, - ) : InitializableSerializer(type, isNullable, "Nested") { - override val uniqueName = qualifiedName.replace('.', '_') - override val ref = uniqueName - override val keyable = false - override val initialize = "${super.qualifiedName}($loaderName)" - } - - class ConfigurationSerializableClass( - type: ClassName, - isNullable: Boolean, - classQualifiedName: String, - ) : InitializableSerializer(type, isNullable, "ConfigurationSerializable") { - override val uniqueName = type.canonicalName.replace(".", "_") - override val ref = uniqueName - override val keyable = false - override val initialize = "$qualifiedName<$classQualifiedName>()" - } - - class EnumClass( - type: ClassName, - isNullable: Boolean, - enumQualifiedName: String, - ) : InitializableSerializer(type, isNullable, "Enum") { - override val uniqueName = type.canonicalName.replace(".", "_") - override val ref = uniqueName - override val keyable = true - override val initialize = "$qualifiedName($enumQualifiedName::class.java)" - } - - class ValueClass( - type: ClassName, - isNullable: Boolean, - parameterName: String, - val argument: Serializer, - ) : InitializableSerializer(type, isNullable, "Value") { - override val uniqueName = type.canonicalName.replace(".", "_") - override val ref = uniqueName - override val keyable = argument.keyable - override val initialize = - buildString { - append(qualifiedName) - if (keyable) append(".Keyable") - append("(${argument.ref}, { $type(it) }, { it.$parameterName })") - } - } - } - } - - private sealed interface Serializer { - data object ConfigurationSerializable : Serializer - - data class BuiltIn( - val name: String, - ) : Serializer { - val qualifiedName = "dev.s7a.ktconfig.serializer.${name}Serializer" - } - - data class Collection( - val name: String, - val supportNullableValue: Boolean, - ) : Serializer { - val qualifiedName = "dev.s7a.ktconfig.serializer.${name}Serializer" - } - - data class Nested( - val qualifiedName: String, - val loaderName: String, - ) : Serializer - - data class Custom( - val qualifiedName: String, - ) : Serializer } } diff --git a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/Parameter.kt b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/Parameter.kt new file mode 100644 index 0000000..a6a7ac4 --- /dev/null +++ b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/Parameter.kt @@ -0,0 +1,162 @@ +package dev.s7a.ktconfig.ksp + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.buildCodeBlock +import com.squareup.kotlinpoet.joinToCode + +data class Parameter( + val pathName: String, + val name: String, + val serializer: Serializer, + val comment: List?, +) { + val isNullable + get() = serializer.isNullable + + sealed class Serializer( + val typeName: TypeName, + val isNullable: Boolean, + ) { + abstract val uniqueName: String + abstract val refKey: String + abstract val ref: Any + abstract val keyable: Boolean + + val getFn = if (isNullable) "get" else "getOrThrow" + + class Object( + type: ClassName, + isNullable: Boolean, + name: String, + serializerType: ClassName, + ) : Serializer(type, isNullable) { + override val uniqueName = name + override val refKey = "%T" + override val ref = serializerType + override val keyable = true + } + + sealed class InitializableSerializer( + type: TypeName, + isNullable: Boolean, + name: String, + protected val serializerType: ClassName = ClassName("dev.s7a.ktconfig.serializer", "${name}Serializer"), + ) : Serializer(type, isNullable) { + abstract val initialize: CodeBlock + override val refKey = "%L" + } + + // Properties like type, uniqueName, ref are stored as class properties + // to avoid recalculating them each time they are accessed + class Class( + parentType: ClassName, + isNullable: Boolean, + name: String, + serializerType: ClassName, + val arguments: List, + val nullableValue: Boolean, + ) : InitializableSerializer( + parentType.parameterizedBy( + arguments.mapIndexed { index, it -> + if (arguments.lastIndex == index) { + it.typeName.copy(nullable = nullableValue) + } else { + it.typeName + } + }, + ), + isNullable, + name, + serializerType, + ) { + override val uniqueName = + buildString { + if (nullableValue) append("Nullable") + append(name) + append("Of") + arguments.forEach { + append(it.uniqueName) + } + } + override val ref = uniqueName + override val keyable = false + override val initialize = + buildCodeBlock { + add( + "%T(%L)", + if (nullableValue) serializerType.nestedClass("Nullable") else serializerType, + arguments.joinToCode { + buildCodeBlock { + add(it.refKey, it.ref) + } + }, + ) + } + } + + class Nested( + type: ClassName, + isNullable: Boolean, + qualifiedName: String, + loaderType: ClassName, + ) : InitializableSerializer(type, isNullable, "Nested") { + override val uniqueName = qualifiedName.replace('.', '_') + override val ref = uniqueName + override val keyable = false + override val initialize = + buildCodeBlock { + add("%T(%T)", serializerType, loaderType) + } + } + + class ConfigurationSerializableClass( + type: ClassName, + isNullable: Boolean, + ) : InitializableSerializer(type, isNullable, "ConfigurationSerializable") { + override val uniqueName = type.canonicalName.replace(".", "_") + override val ref = uniqueName + override val keyable = false + override val initialize = + buildCodeBlock { + add("%T<%T>()", serializerType, type) + } + } + + class EnumClass( + type: ClassName, + isNullable: Boolean, + ) : InitializableSerializer(type, isNullable, "Enum") { + override val uniqueName = type.canonicalName.replace(".", "_") + override val ref = uniqueName + override val keyable = true + override val initialize = + buildCodeBlock { + add("%T(%T::class.java)", serializerType, type) + } + } + + class ValueClass( + type: ClassName, + isNullable: Boolean, + parameterName: String, + val argument: Serializer, + ) : InitializableSerializer(type, isNullable, "Value") { + override val uniqueName = type.canonicalName.replace(".", "_") + override val ref = uniqueName + override val keyable = argument.keyable + override val initialize = + buildCodeBlock { + add( + "%T(${argument.refKey}, { %T(it) }, { it.%L })", + if (keyable) serializerType.nestedClass("Keyable") else serializerType, + argument.ref, + type, + parameterName, + ) + } + } + } +} diff --git a/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/Serializer.kt b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/Serializer.kt new file mode 100644 index 0000000..3425f23 --- /dev/null +++ b/ksp/src/main/kotlin/dev/s7a/ktconfig/ksp/Serializer.kt @@ -0,0 +1,154 @@ +package dev.s7a.ktconfig.ksp + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.ClassName +import dev.s7a.ktconfig.ksp.KtConfigAnnotation.Companion.getKtConfigAnnotation +import kotlin.sequences.forEach + +sealed interface Serializer { + data object ConfigurationSerializable : Serializer + + data class BuiltIn( + val name: String, + ) : Serializer { + val serializerType = ClassName("dev.s7a.ktconfig.serializer", "${name}Serializer") + } + + data class Collection( + val name: String, + val supportNullableValue: Boolean, + ) : Serializer { + val serializerType = ClassName("dev.s7a.ktconfig.serializer", "${name}Serializer") + } + + data class Nested( + val qualifiedName: String, + val loaderType: ClassName, + ) : Serializer + + data class Custom( + val qualifiedName: String, + ) : Serializer { + val serializerType = ClassName(qualifiedName.substringBeforeLast('.'), qualifiedName.substringAfterLast('.')) + } + + companion object { + private val serializers = + mapOf( + // Primitive + "kotlin.Byte" to BuiltIn("Byte"), + "kotlin.Char" to BuiltIn("Char"), + "kotlin.Int" to BuiltIn("Int"), + "kotlin.Long" to BuiltIn("Long"), + "kotlin.Short" to BuiltIn("Short"), + "kotlin.String" to BuiltIn("String"), + "kotlin.UByte" to BuiltIn("UByte"), + "kotlin.UInt" to BuiltIn("UInt"), + "kotlin.ULong" to BuiltIn("ULong"), + "kotlin.UShort" to BuiltIn("UShort"), + "kotlin.Double" to BuiltIn("Double"), + "kotlin.Float" to BuiltIn("Float"), + "kotlin.Boolean" to BuiltIn("Boolean"), + "java.math.BigInteger" to BuiltIn("BigInteger"), + "java.math.BigDecimal" to BuiltIn("BigDecimal"), + // Common + "java.util.UUID" to BuiltIn("UUID"), + "java.time.Instant" to BuiltIn("Instant"), + "java.time.LocalTime" to BuiltIn("LocalTime"), + "java.time.LocalDate" to BuiltIn("LocalDate"), + "java.time.LocalDateTime" to BuiltIn("LocalDateTime"), + "java.time.Year" to BuiltIn("Year"), + "java.time.YearMonth" to BuiltIn("YearMonth"), + "java.time.OffsetTime" to BuiltIn("OffsetTime"), + "java.time.OffsetDateTime" to BuiltIn("OffsetDateTime"), + "java.time.ZonedDateTime" to BuiltIn("ZonedDateTime"), + "java.time.Duration" to BuiltIn("Duration"), + "java.time.Period" to BuiltIn("Period"), + // Collections + "kotlin.Array" to Collection("Array", true), + "kotlin.ByteArray" to Collection("ByteArray", false), + "kotlin.CharArray" to Collection("CharArray", false), + "kotlin.IntArray" to Collection("IntArray", false), + "kotlin.LongArray" to Collection("LongArray", false), + "kotlin.ShortArray" to Collection("ShortArray", false), + "kotlin.UByteArray" to Collection("UByteArray", false), + "kotlin.UIntArray" to Collection("UIntArray", false), + "kotlin.ULongArray" to Collection("ULongArray", false), + "kotlin.UShortArray" to Collection("UShortArray", false), + "kotlin.DoubleArray" to Collection("DoubleArray", false), + "kotlin.FloatArray" to Collection("FloatArray", false), + "kotlin.BooleanArray" to Collection("BooleanArray", false), + "kotlin.collections.List" to Collection("List", true), + "kotlin.collections.Set" to Collection("Set", true), + "kotlin.collections.ArrayDeque" to Collection("ArrayDeque", true), + "kotlin.collections.Map" to Collection("Map", true), + ) + + /** + * Finds the appropriate serializer name for a given type. + * First checks if the type implements ConfigurationSerializable, + * then looks up built-in serializers. + * + * @param qualifiedName The fully qualified name of the type + * @param type The KSType representing the type to find a serializer for + * @return The name of the serializer to use, or null if no suitable serializer is found + */ + fun findSerializer( + qualifiedName: String, + type: KSType, + ): Serializer? { + val declaration = type.declaration + if (declaration is KSClassDeclaration) { + // Check if type marked @KtConfig + val ktConfig = declaration.getKtConfigAnnotation() + if (ktConfig != null) { + return Nested(qualifiedName, ClassName(declaration.packageName.asString(), getLoaderName(declaration))) + } + + // Check if type implements ConfigurationSerializable + declaration.getAllSuperTypes().forEach { superType -> + val qualifiedName = superType.declaration.qualifiedName?.asString() + if (qualifiedName == "org.bukkit.configuration.serialization.ConfigurationSerializable") { + return ConfigurationSerializable + } + } + } + + // Lookup serializer name from the predefined map of built-in serializers + return serializers[qualifiedName] + } + + /** + * Resolves a list of serializers by flattening nested serializers and removing duplicates. + * + * @return A flattened list of unique initializable serializers identified by their unique names that need initialization + */ + fun List.extractInitializableSerializers(): List = + filterIsInstance() + .flatMap { + when (it) { + is Parameter.Serializer.Class -> { + it.arguments.extractInitializableSerializers() + it + } + + is Parameter.Serializer.Nested -> { + listOf(it) + } + + is Parameter.Serializer.ConfigurationSerializableClass -> { + listOf(it) + } + + is Parameter.Serializer.EnumClass -> { + listOf(it) + } + + is Parameter.Serializer.ValueClass -> { + listOf(it.argument).extractInitializableSerializers() + it + } + } + }.distinctBy(Parameter.Serializer::uniqueName) + } +} diff --git a/src/main/kotlin/dev/s7a/ktconfig/KtConfig.kt b/src/main/kotlin/dev/s7a/ktconfig/KtConfig.kt index 92ab63f..c466368 100644 --- a/src/main/kotlin/dev/s7a/ktconfig/KtConfig.kt +++ b/src/main/kotlin/dev/s7a/ktconfig/KtConfig.kt @@ -6,9 +6,13 @@ package dev.s7a.ktconfig * This annotation enables automatic serialization and deserialization of configuration data. * * @property hasDefault Indicates that the configuration class has default values. + * @property discriminator Discriminator for sealed interfaces/classes, default is '$'. + * This annotation only affects the class it is directly applied to and **does not propagate to child classes**. + * For data classes, changing the discriminator value will be ignored. * @since 2.0.0 */ @Target(AnnotationTarget.CLASS) annotation class KtConfig( val hasDefault: Boolean = false, + val discriminator: String = "$", ) diff --git a/src/main/kotlin/dev/s7a/ktconfig/KtConfigLoader.kt b/src/main/kotlin/dev/s7a/ktconfig/KtConfigLoader.kt index 8f1269b..0a475a0 100644 --- a/src/main/kotlin/dev/s7a/ktconfig/KtConfigLoader.kt +++ b/src/main/kotlin/dev/s7a/ktconfig/KtConfigLoader.kt @@ -50,6 +50,32 @@ abstract class KtConfigLoader : }, ) + /** + * Loads configuration data from a file and immediately saves it back. + * This is useful for updating the file with default values or normalizing the format. + * + * @param file The file to load configuration from and save back to + * @return The loaded configuration object of type T + * @since 2.1.0 + */ + fun loadAndSave(file: File) = + load(file).also { + save(file, it) + } + + /** + * Loads configuration data from a file. If the file does not exist, loads the default configuration and saves it to the file. + * This is useful for creating configuration files with default values on the first run. + * + * @param file The file to load configuration from + * @return The loaded configuration object of type T + * @since 2.1.0 + */ + fun loadAndSaveIfNotExists(file: File) = + load(file).also { + saveIfNotExists(file, it) + } + /** * Loads configuration data from a string content. * @@ -92,6 +118,22 @@ abstract class KtConfigLoader : save(this, value) }.save(file) + /** + * Saves configuration data to a file if not exists. + * + * @param file The file to save configuration to + * @param value The configuration object to save + * @since 2.1.0 + */ + fun saveIfNotExists( + file: File, + value: T, + ) { + if (file.exists().not()) { + save(file, value) + } + } + /** * Saves configuration data to a string. * diff --git a/src/main/kotlin/dev/s7a/ktconfig/PathName.kt b/src/main/kotlin/dev/s7a/ktconfig/PathName.kt index b0ee46e..43d35cd 100644 --- a/src/main/kotlin/dev/s7a/ktconfig/PathName.kt +++ b/src/main/kotlin/dev/s7a/ktconfig/PathName.kt @@ -20,8 +20,10 @@ package dev.s7a.ktconfig * * @property name The custom path name to be used for the annotated property * @since 2.0.0 + * @deprecated Deprecated in version 2.1.0. Will be removed in version 2.4.0. Use [SerialName] instead. */ @Target(AnnotationTarget.VALUE_PARAMETER) +@Deprecated("Use @SerialName instead", ReplaceWith("SerialName(name)", "dev.s7a.ktconfig.SerialName"), level = DeprecationLevel.ERROR) annotation class PathName( val name: String, ) diff --git a/src/main/kotlin/dev/s7a/ktconfig/SerialName.kt b/src/main/kotlin/dev/s7a/ktconfig/SerialName.kt new file mode 100644 index 0000000..4f4c93f --- /dev/null +++ b/src/main/kotlin/dev/s7a/ktconfig/SerialName.kt @@ -0,0 +1,31 @@ +package dev.s7a.ktconfig + +/** + * Specifies the name to use for serialization, deserialization, and configuration path mapping. + * + * This annotation allows mapping between Kotlin property/class names and their serialized representations, + * as well as defining custom names for reading from and writing to configuration files. + * + * When applied to a class, it specifies the serialization name for that class. + * When applied to a property, it defines the custom name to be used in the configuration file instead of the property's actual name. + * + * Example: + * ```kotlin + * @KtConfig + * @SerialName("server-config") + * data class ServerConfig( + * @SerialName("server-port") + * val port: Int, + * + * @SerialName("server-name") + * val name: String, + * ) + * ``` + * + * @property name The custom name to use for serialization/deserialization and configuration path mapping + * @since 2.1.0 + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.VALUE_PARAMETER) +annotation class SerialName( + val name: String, +) diff --git a/src/main/kotlin/dev/s7a/ktconfig/exception/InvalidDiscriminatorException.kt b/src/main/kotlin/dev/s7a/ktconfig/exception/InvalidDiscriminatorException.kt new file mode 100644 index 0000000..5269e1a --- /dev/null +++ b/src/main/kotlin/dev/s7a/ktconfig/exception/InvalidDiscriminatorException.kt @@ -0,0 +1,11 @@ +package dev.s7a.ktconfig.exception + +/** + * Exception thrown when a discriminator value is invalid for a sealed interface or class. + * + * @property discriminator The discriminator value that was invalid + * @since 2.0.0 + */ +class InvalidDiscriminatorException( + val discriminator: String, +) : KtConfigException("Invalid discriminator: $discriminator") diff --git a/src/main/kotlin/dev/s7a/ktconfig/type/FormattedColor.kt b/src/main/kotlin/dev/s7a/ktconfig/type/FormattedColor.kt index 51e545b..91c6453 100644 --- a/src/main/kotlin/dev/s7a/ktconfig/type/FormattedColor.kt +++ b/src/main/kotlin/dev/s7a/ktconfig/type/FormattedColor.kt @@ -4,6 +4,7 @@ import dev.s7a.ktconfig.UseSerializer import dev.s7a.ktconfig.exception.InvalidFormatException import dev.s7a.ktconfig.serializer.StringSerializer import dev.s7a.ktconfig.serializer.TransformSerializer +import org.bukkit.Bukkit import org.bukkit.Color /** @@ -36,21 +37,48 @@ typealias FormattedColor = * @since 2.0.0 */ object FormattedColorSerializer : TransformSerializer.Keyable(StringSerializer) { + /** + * Indicates whether the current Minecraft version supports an alpha channel in colors. + * + * This property attempts to check for the existence of the `alpha` field in the [Color] class. + * If the field is not found, it means the Minecraft version does not support alpha transparency. + * + * @since 2.1.0 + */ + val isSupportedAlpha = + try { + Color::class.java.getDeclaredField("alpha") + true + } catch (_: NoSuchFieldException) { + Bukkit.getLogger().warning("Color#alpha is not found. This version of Minecraft does not support alpha.") + false + } + override fun decode(value: String): Color { val regex = "^#?([0-9A-Fa-f]{6,8})$".toRegex() val result = regex.find(value) ?: throw InvalidFormatException(value, "[#]?[AA]?RRGGBB") val hex = result.groupValues[1] - return if (hex.length == 6) { - Color.fromRGB(hex.toInt(16)) - } else { - Color.fromARGB(hex.toInt(16)) + return when { + hex.length == 6 -> { + Color.fromRGB(hex.toInt(16)) + } + + isSupportedAlpha -> { + Color.fromARGB(hex.toInt(16)) + } + + else -> { + throw InvalidFormatException(value, "[#]?RRGGBB") + } } } override fun encode(value: Color) = buildString { append('#') - append(value.alpha.toString(16).padStart(2, '0')) + if (isSupportedAlpha && value.alpha != 255) { + append(value.alpha.toString(16).padStart(2, '0')) + } append(value.red.toString(16).padStart(2, '0')) append(value.green.toString(16).padStart(2, '0')) append(value.blue.toString(16).padStart(2, '0')) diff --git a/src/test/kotlin/KtConfigLoaderTest.kt b/src/test/kotlin/KtConfigLoaderTest.kt index 3202e76..4bc9d94 100644 --- a/src/test/kotlin/KtConfigLoaderTest.kt +++ b/src/test/kotlin/KtConfigLoaderTest.kt @@ -7,13 +7,23 @@ import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class KtConfigLoaderTest { data class CustomData( val value: String, ) - class CustomLoader : KtConfigLoader() { + open class CustomLoader : KtConfigLoader() { + class HasDefault : CustomLoader() { + override fun load( + configuration: YamlConfiguration, + parentPath: String, + ) = CustomData( + StringSerializer.get(configuration, "${parentPath}value") ?: "default", + ) + } + override fun load( configuration: YamlConfiguration, parentPath: String, @@ -65,7 +75,7 @@ class KtConfigLoaderTest { } @Test - fun testLoadAndSaveFile() { + fun testSaveAndLoadFile() { val loader = CustomLoader() val tempDir = createTempDirectory().toFile() val configFile = File(tempDir, "config.yml") @@ -76,4 +86,81 @@ class KtConfigLoaderTest { assertEquals(originalData, loadedData) } + + @Test + fun testLoadAndSaveFile() { + val loader = CustomLoader.HasDefault() + val tempDir = createTempDirectory().toFile() + val configFile = File(tempDir, "config.yml") + + val expectedData = CustomData("default") + val loadedData = loader.loadAndSave(configFile) + assertEquals(expectedData, loadedData) + assertTrue(configFile.exists()) + } + + @Test + fun testLoadAndSaveFileUpdated() { + val loader = CustomLoader.HasDefault() + val tempDir = createTempDirectory().toFile() + val configFile = File(tempDir, "config.yml") + configFile.writeText( + """ + value: updated + ignore: delete this value after loadAndSave + + """.trimIndent(), + ) + + val expectedData = CustomData("updated") + val loadedData = loader.loadAndSave(configFile) + assertEquals(expectedData, loadedData) + assertTrue(configFile.exists()) + assertEquals( + """ + value: updated + + """.trimIndent(), + configFile.readText(), + ) + } + + @Test + fun testLoadAndSaveFileIfNotExist() { + val loader = CustomLoader.HasDefault() + val tempDir = createTempDirectory().toFile() + val configFile = File(tempDir, "config.yml") + + val expectedData = CustomData("default") + val loadedData = loader.loadAndSaveIfNotExists(configFile) + assertEquals(expectedData, loadedData) + assertTrue(configFile.exists()) + } + + @Test + fun testLoadAndSaveFileIfNotExistUpdated() { + val loader = CustomLoader.HasDefault() + val tempDir = createTempDirectory().toFile() + val configFile = File(tempDir, "config.yml") + configFile.writeText( + """ + value: updated + ignore: keep this value after loadAndSaveIfNotExists + + """.trimIndent(), + ) + + val expectedData = CustomData("updated") + val loadedData = loader.loadAndSaveIfNotExists(configFile) // <- if not exists + assertEquals(expectedData, loadedData) + assertTrue(configFile.exists()) + assertEquals( + """ + value: updated + ignore: keep this value after loadAndSaveIfNotExists + + """.trimIndent(), + configFile.readText(), + ) + } }