From f26858cc7a54e253e5024a78c32ea43fe0917b70 Mon Sep 17 00:00:00 2001 From: Sunny Vardhan Date: Thu, 7 May 2026 12:02:13 -0700 Subject: [PATCH] =?UTF-8?q?Add=20picocli-codegen-ksp=20module=20=E2=80=94?= =?UTF-8?q?=20KSP-based=20GraalVM=20native-image=20config=20generator=20(#?= =?UTF-8?q?1564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new `picocli-codegen-ksp` Gradle submodule that provides a Kotlin Symbol Processing (KSP) equivalent of the existing KAPT/APT-based `NativeImageConfigGeneratorProcessor`. The processor scans picocli-annotated classes (@Command, @Option, @Parameters, @Mixin, @ArgGroup, @Spec, @Unmatched, @ParentCommand) at compile time and emits: - reflect-config.json (classes, fields, methods for GraalVM reflection) - resource-config.json (resource bundles and patterns) - proxy-config.json (dynamic proxies for @Command interfaces) Supports the same KSP options as the APT processor: `project`, `verbose`, `disable.reflect.config`, `disable.resource.config`, `disable.proxy.config`, `other.resource.bundles`, `other.resource.patterns`, `other.proxy.interfaces`. Users add it as a `ksp(...)` dependency rather than `kapt(...)`: ksp("info.picocli:picocli-codegen-ksp:") Also fixes the Spring repository declaration in build.gradle to scope it to Spring group IDs only, avoiding unnecessary resolution attempts for non-Spring artifacts. --- build.gradle | 8 +- picocli-codegen-ksp/build.gradle | 51 +++ .../codegen/ksp/PicocliKspProcessor.kt | 396 ++++++++++++++++++ .../ksp/PicocliKspProcessorProvider.kt | 31 ++ .../picocli/codegen/ksp/ReflectedClass.kt | 62 +++ ...ols.ksp.processing.SymbolProcessorProvider | 1 + .../codegen/ksp/PicocliKspProcessorTest.kt | 307 ++++++++++++++ .../picocli/codegen/ksp/ReflectedClassTest.kt | 96 +++++ settings.gradle | 1 + 9 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 picocli-codegen-ksp/build.gradle create mode 100644 picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessor.kt create mode 100644 picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessorProvider.kt create mode 100644 picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/ReflectedClass.kt create mode 100644 picocli-codegen-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/PicocliKspProcessorTest.kt create mode 100644 picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/ReflectedClassTest.kt diff --git a/build.gradle b/build.gradle index fb7ffc47c..e6683c4cb 100644 --- a/build.gradle +++ b/build.gradle @@ -97,7 +97,13 @@ allprojects { compileTestJava.options.encoding = "UTF-8" repositories { - maven { url = 'https://repo.spring.io/libs-snapshot' } + maven { + url = 'https://repo.spring.io/libs-snapshot' + content { + includeGroupByRegex 'org\\.springframework.*' + includeGroupByRegex 'io\\.spring.*' + } + } mavenCentral() } diff --git a/picocli-codegen-ksp/build.gradle b/picocli-codegen-ksp/build.gradle new file mode 100644 index 000000000..400cba97d --- /dev/null +++ b/picocli-codegen-ksp/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' + id 'org.jetbrains.kotlin.jvm' version '2.1.20' +} + +group = 'info.picocli' +description = 'Picocli KSP - Kotlin Symbol Processing support for picocli. Generates GraalVM native-image configuration at compile time.' +version = "$projectVersion" + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions.jvmTarget = '1.8' +} +// The kctfork compile-testing API is marked @ExperimentalCompilerApi; opt-in only for tests +compileTestKotlin { + kotlinOptions { + freeCompilerArgs += ['-opt-in=org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi'] + } +} + +dependencies { + api rootProject + compileOnly "com.google.devtools.ksp:symbol-processing-api:2.1.20-1.0.32" + + testImplementation "dev.zacsweers.kctfork:core:0.7.1" + testImplementation "dev.zacsweers.kctfork:ksp:0.7.1" + testImplementation supportDependencies.junit5Api + testRuntimeOnly supportDependencies.junit5Engine + testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.${junit5Version.tokenize('.')[1]}.${junit5Version.tokenize('.')[2]}" +} + +test { + useJUnitPlatform() +} + +jar { + manifest { + attributes 'Specification-Title' : 'Picocli KSP', + 'Specification-Vendor' : 'Remko Popma', + 'Specification-Version' : archiveVersion.get(), + 'Implementation-Title' : 'Picocli KSP', + 'Implementation-Vendor' : 'Remko Popma', + 'Implementation-Version': archiveVersion.get() + } +} + +ext { + PUBLISH_GROUP_ID = group + PUBLISH_ARTIFACT_ID = project.name + PUBLISH_VERSION = "$projectVersion" +} +apply from: "${rootProject.projectDir}/gradle/publish-mavencentral.gradle" diff --git a/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessor.kt b/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessor.kt new file mode 100644 index 000000000..9da3c4427 --- /dev/null +++ b/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessor.kt @@ -0,0 +1,396 @@ +package picocli.codegen.ksp + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.* +import com.google.devtools.ksp.symbol.* + +/** + * KSP [SymbolProcessor] that generates GraalVM native-image configuration files for picocli-annotated classes. + * + * Generates the same output as [picocli.codegen.aot.graalvm.processor.NativeImageConfigGeneratorProcessor] + * (the kapt/APT-based processor), but using the KSP API instead of `javax.annotation.processing`. + * + * @since 4.7.8 + */ +class PicocliKspProcessor(environment: SymbolProcessorEnvironment) : SymbolProcessor { + + private val codeGenerator = environment.codeGenerator + private val logger = environment.logger + private val options = environment.options + + // Accumulated data across processing rounds, keyed by binary class name + private val reflectedClasses = sortedMapOf() + private val resourceBundles = linkedSetOf() + private val resourcePatterns = linkedSetOf() + private val proxyInterfaceNames = mutableListOf() + + // Track already-visited class names to avoid re-processing + private val visitedClasses = mutableSetOf() + + companion object { + private const val BASE_PATH = "META-INF/native-image/picocli-generated" + + const val OPTION_PROJECT = "project" + const val OPTION_VERBOSE = "verbose" + const val OPTION_DISABLE_REFLECT = "disable.reflect.config" + const val OPTION_DISABLE_RESOURCE = "disable.resource.config" + const val OPTION_DISABLE_PROXY = "disable.proxy.config" + const val OPTION_BUNDLES = "other.resource.bundles" + const val OPTION_RESOURCE_REGEX = "other.resource.patterns" + const val OPTION_INTERFACE_CLASSES = "other.proxy.interfaces" + + private const val PICOCLI_PKG = "picocli.CommandLine" + const val COMMAND_ANN = "$PICOCLI_PKG.Command" + const val OPTION_ANN = "$PICOCLI_PKG.Option" + const val PARAMETERS_ANN = "$PICOCLI_PKG.Parameters" + const val MIXIN_ANN = "$PICOCLI_PKG.Mixin" + const val ARG_GROUP_ANN = "$PICOCLI_PKG.ArgGroup" + const val SPEC_ANN = "$PICOCLI_PKG.Spec" + const val UNMATCHED_ANN = "$PICOCLI_PKG.Unmatched" + const val PARENT_COMMAND_ANN = "$PICOCLI_PKG.ParentCommand" + + // "No-op" default classes picocli uses when an attribute is not set + private val PICOCLI_NOOPS = setOf( + "picocli.CommandLine.NoVersionProvider", + "picocli.CommandLine.NoDefaultProvider" + ) + + // Types that don't need to appear in reflect-config (mirrors ReflectionConfigGenerator.Visitor.excluded) + private val EXCLUDED_TYPES = setOf( + "boolean", "byte", "char", "double", "float", "int", "long", "short", + "boolean[]", "byte[]", "char[]", "double[]", "float[]", "int[]", "long[]", "short[]", + "picocli.CommandLine.Model.CommandSpec", + "java.lang.reflect.Method", + "java.lang.Object", + "java.lang.String", "java.lang.String[]", + "java.io.File", "java.io.File[]", + "java.util.List", "java.util.Set", "java.util.Map", + "java.lang.Class", "java.lang.Class[]", + "java.lang.reflect.Executable", "java.lang.reflect.Parameter", + "org.fusesource.jansi.AnsiConsole", + "java.util.ResourceBundle", + "java.time.Duration", "java.time.Instant", "java.time.LocalDate", + "java.time.LocalDateTime", "java.time.LocalTime", "java.time.MonthDay", + "java.time.OffsetDateTime", "java.time.OffsetTime", "java.time.Period", + "java.time.Year", "java.time.YearMonth", "java.time.ZonedDateTime", + "java.time.ZoneId", "java.time.ZoneOffset", + "java.nio.file.Path", "java.nio.file.Paths", + "java.sql.Connection", "java.sql.Driver", "java.sql.DriverManager", + "java.sql.Time", "java.sql.Timestamp" + ) + } + + override fun process(resolver: Resolver): List { + // Process @Command on classes and interfaces + resolver.getSymbolsWithAnnotation(COMMAND_ANN).forEach { symbol -> + when (symbol) { + is KSClassDeclaration -> processCommandClass(symbol) + is KSFunctionDeclaration -> processCommandMethod(symbol) + else -> {} + } + } + + // Also register classes that carry picocli member annotations but no @Command + val memberAnnotations = listOf( + OPTION_ANN, PARAMETERS_ANN, MIXIN_ANN, ARG_GROUP_ANN, + SPEC_ANN, UNMATCHED_ANN, PARENT_COMMAND_ANN + ) + memberAnnotations.forEach { annName -> + resolver.getSymbolsWithAnnotation(annName).forEach { symbol -> + val enclosing: KSClassDeclaration? = when (symbol) { + is KSPropertyDeclaration -> symbol.parentDeclaration as? KSClassDeclaration + is KSFunctionDeclaration -> symbol.parentDeclaration as? KSClassDeclaration + else -> null + } + if (enclosing != null) processCommandClass(enclosing) + } + } + + return emptyList() + } + + // ------------------------------------------------------------------------- + // Command-level processing + // ------------------------------------------------------------------------- + + private fun processCommandClass(classDecl: KSClassDeclaration) { + val className = classDecl.toBinaryName() ?: return + if (!visitedClasses.add(className)) return // skip if already visited + + logVerbose("Processing @Command class: $className") + + val reflectedClass = getOrCreateReflectedClass(className) + + // @Command-annotated interfaces need a dynamic proxy + if (classDecl.classKind == ClassKind.INTERFACE) { + proxyInterfaceNames += className + } + + // Extract info from the @Command annotation itself + classDecl.findAnnotation(COMMAND_ANN)?.let { ann -> + ann.getStringArg("resourceBundle")?.takeIf { it.isNotEmpty() } + ?.let { resourceBundles += it } + + ann.getClassArg("versionProvider") + ?.takeUnless { it in PICOCLI_NOOPS } + ?.let { registerReflectedClass(it) } + + ann.getClassArg("defaultValueProvider") + ?.takeUnless { it in PICOCLI_NOOPS } + ?.let { registerReflectedClass(it) } + + ann.getClassListArg("subcommands") + ?.forEach { registerReflectedClass(it) } + } + + // Walk the full type hierarchy (getAllProperties / getAllFunctions handle superclasses too) + classDecl.getAllProperties().forEach { prop -> + processAnnotatedProperty(prop, reflectedClass) + } + classDecl.getAllFunctions().forEach { func -> + processAnnotatedFunction(func, classDecl, reflectedClass) + } + } + + private fun processCommandMethod(func: KSFunctionDeclaration) { + // A method annotated with @Command is a subcommand factory method. + // We need to register the enclosing class and the method. + val enclosing = func.parentDeclaration as? KSClassDeclaration ?: return + val className = enclosing.toBinaryName() ?: return + logVerbose("Processing @Command method: ${func.simpleName.asString()} in $className") + + val reflectedClass = getOrCreateReflectedClass(className) + reflectedClass.addMethod(func.simpleName.asString(), func.parameterTypeNames()) + + // Parameters of the method may be annotated with @Option/@Parameters + func.parameters.forEach { param -> + processAnnotatedParameter(param, reflectedClass) + } + } + + // ------------------------------------------------------------------------- + // Member-level processing + // ------------------------------------------------------------------------- + + private fun processAnnotatedProperty(prop: KSPropertyDeclaration, enclosingClass: ReflectedClass) { + val hasPicocli = listOf( + OPTION_ANN, PARAMETERS_ANN, MIXIN_ANN, ARG_GROUP_ANN, + SPEC_ANN, UNMATCHED_ANN, PARENT_COMMAND_ANN + ).any { prop.hasAnnotation(it) } + if (!hasPicocli) return + + val fieldName = prop.simpleName.asString() + val isFinal = !prop.isMutable + enclosingClass.addField(fieldName, isFinal) + + // Register the field's own type + prop.type.resolve().registerType() + + // Extract class-valued attributes from @Option / @Parameters + prop.findAnnotation(OPTION_ANN)?.extractConverterAttributes() + prop.findAnnotation(PARAMETERS_ANN)?.extractConverterAttributes() + } + + private fun processAnnotatedFunction( + func: KSFunctionDeclaration, + enclosingClass: KSClassDeclaration, + reflectedClass: ReflectedClass + ) { + val hasPicocli = listOf( + OPTION_ANN, PARAMETERS_ANN, MIXIN_ANN, ARG_GROUP_ANN, + SPEC_ANN, UNMATCHED_ANN, PARENT_COMMAND_ANN + ).any { func.hasAnnotation(it) } + if (!hasPicocli) return + + val methodName = func.simpleName.asString() + reflectedClass.addMethod(methodName, func.parameterTypeNames()) + + // Register return type + func.returnType?.resolve()?.registerType() + + // Converter attributes + func.findAnnotation(OPTION_ANN)?.extractConverterAttributes() + func.findAnnotation(PARAMETERS_ANN)?.extractConverterAttributes() + } + + private fun processAnnotatedParameter(param: KSValueParameter, enclosingClass: ReflectedClass) { + val hasPicocli = listOf(OPTION_ANN, PARAMETERS_ANN, MIXIN_ANN, ARG_GROUP_ANN) + .any { param.hasAnnotation(it) } + if (!hasPicocli) return + + param.type.resolve().registerType() + param.findAnnotation(OPTION_ANN)?.extractConverterAttributes() + param.findAnnotation(PARAMETERS_ANN)?.extractConverterAttributes() + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Registers type converter, completion candidates, and parameter consumer classes. */ + private fun KSAnnotation.extractConverterAttributes() { + getClassListArg("converter")?.forEach { registerReflectedClass(it) } + getClassArg("completionCandidates")?.let { registerReflectedClass(it) } + getClassArg("parameterConsumer")?.let { registerReflectedClass(it) } + } + + /** Registers the type name in the reflect-config, including generic type arguments. */ + private fun KSType.registerType() { + val name = toBinaryName() ?: return + registerReflectedClass(name) + // Also register generic type arguments (e.g. List → MyType) + arguments.forEach { it.type?.resolve()?.registerType() } + } + + private fun registerReflectedClass(name: String) { + if (!isExcluded(name)) getOrCreateReflectedClass(name) + } + + private fun getOrCreateReflectedClass(name: String): ReflectedClass = + reflectedClasses.getOrPut(name) { ReflectedClass(name) } + + private fun isExcluded(name: String): Boolean = + name in EXCLUDED_TYPES || name.startsWith("[") + + // ------------------------------------------------------------------------- + // KSP annotation helpers + // ------------------------------------------------------------------------- + + private fun KSAnnotated.findAnnotation(qualifiedName: String): KSAnnotation? = + annotations.find { ann -> + ann.annotationType.resolve().declaration.qualifiedName?.asString() == qualifiedName + } + + private fun KSAnnotated.hasAnnotation(qualifiedName: String): Boolean = + findAnnotation(qualifiedName) != null + + private fun KSAnnotation.getStringArg(name: String): String? = + arguments.find { it.name?.asString() == name }?.value as? String + + private fun KSAnnotation.getClassArg(name: String): String? { + val value = arguments.find { it.name?.asString() == name }?.value as? KSType + return value?.toBinaryName() + } + + @Suppress("UNCHECKED_CAST") + private fun KSAnnotation.getClassListArg(name: String): List? { + val list = arguments.find { it.name?.asString() == name }?.value as? List<*> + ?: return null + return list.mapNotNull { (it as? KSType)?.toBinaryName() } + } + + /** Converts a [KSType] to its JVM binary class name (uses `$` for nested classes). */ + private fun KSType.toBinaryName(): String? = declaration.toBinaryName() + + /** Converts a [KSDeclaration] to its JVM binary class name. */ + private fun KSDeclaration.toBinaryName(): String? { + val fqn = qualifiedName?.asString() ?: return null + val parent = parentDeclaration + return if (parent is KSClassDeclaration) { + val parentName = parent.toBinaryName() ?: return null + "$parentName\$${simpleName.asString()}" + } else { + fqn + } + } + + /** Returns the JVM binary names of a function's parameter types. */ + private fun KSFunctionDeclaration.parameterTypeNames(): List = + parameters.mapNotNull { it.type.resolve().toBinaryName() } + + // ------------------------------------------------------------------------- + // finish() – write the three config files + // ------------------------------------------------------------------------- + + override fun finish() { + // Merge user-specified extra entries from KSP options + options[OPTION_BUNDLES]?.splitTrimmed()?.forEach { resourceBundles += it } + options[OPTION_RESOURCE_REGEX]?.splitTrimmed()?.forEach { resourcePatterns += it } + options[OPTION_INTERFACE_CLASSES]?.splitTrimmed()?.forEach { proxyInterfaceNames += it } + + val basePath = buildBasePath() + logVerbose("Writing native-image configs to: $basePath") + + if (!options.containsKey(OPTION_DISABLE_REFLECT)) { + writeFile(basePath, "reflect-config.json", buildReflectConfig()) + } + if (!options.containsKey(OPTION_DISABLE_RESOURCE)) { + writeFile(basePath, "resource-config.json", buildResourceConfig()) + } + if (!options.containsKey(OPTION_DISABLE_PROXY)) { + writeFile(basePath, "proxy-config.json", buildProxyConfig()) + } + } + + private fun buildBasePath(): String { + val project = options[OPTION_PROJECT]?.replace('\\', '/') + return if (project != null) "$BASE_PATH/$project/" else "$BASE_PATH/" + } + + private fun buildReflectConfig(): String { + val sb = StringBuilder("[\n") + val entries = reflectedClasses.values.filter { !isExcluded(it.name) } + entries.forEachIndexed { i, cls -> + if (i > 0) sb.append(",\n") + sb.append(cls.toJson()) + } + sb.append("\n]\n") + return sb.toString() + } + + private fun buildResourceConfig(): String = buildString { + append("{\n") + append(" \"bundles\" : [") + resourceBundles.forEachIndexed { i, bundle -> + if (i > 0) append(",") + append("\n {\"name\" : \"$bundle\"}") + } + append("\n ],\n") + append(" \"resources\" : [") + resourcePatterns.forEachIndexed { i, pattern -> + if (i > 0) append(",") + append("\n {\"pattern\" : \"$pattern\"}") + } + append("\n ]\n") + append("}\n") + } + + private fun buildProxyConfig(): String = buildString { + append("[\n") + proxyInterfaceNames.forEachIndexed { i, iface -> + if (i > 0) append(",\n") + append(" [\"$iface\"]") + } + append("\n]\n") + } + + private fun writeFile(basePath: String, fileName: String, content: String) { + try { + val path = "$basePath$fileName" + logVerbose("Writing: $path") + // Strip extension from fileName for KSP API (it appends it back) + val dotIdx = fileName.lastIndexOf('.') + val fileNameNoExt = if (dotIdx >= 0) fileName.substring(0, dotIdx) else fileName + val ext = if (dotIdx >= 0) fileName.substring(dotIdx + 1) else "" + val stream = codeGenerator.createNewFile( + dependencies = Dependencies.ALL_FILES, + packageName = "", + fileName = "$basePath$fileNameNoExt", + extensionName = ext + ) + stream.use { it.write(content.toByteArray(Charsets.UTF_8)) } + } catch (e: FileAlreadyExistsException) { + // KSP may call finish() across incremental rounds; ignore duplicate writes + logVerbose("File already exists, skipping: $basePath$fileName") + } catch (e: Exception) { + logger.error("picocli-ksp: failed to write $basePath$fileName: ${e.message}") + } + } + + private fun logVerbose(msg: String) { + if (options.containsKey(OPTION_VERBOSE)) logger.info("[picocli-ksp] $msg") + } + + private fun String.splitTrimmed(): List = + split(',').map { it.trim() }.filter { it.isNotEmpty() } +} diff --git a/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessorProvider.kt b/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessorProvider.kt new file mode 100644 index 000000000..f022478b8 --- /dev/null +++ b/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/PicocliKspProcessorProvider.kt @@ -0,0 +1,31 @@ +package picocli.codegen.ksp + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** + * KSP [SymbolProcessorProvider] for picocli that generates GraalVM native-image configuration files + * ([reflect-config.json][picocli.codegen.aot.graalvm.ReflectionConfigGenerator], + * [resource-config.json][picocli.codegen.aot.graalvm.ResourceConfigGenerator] and + * [proxy-config.json][picocli.codegen.aot.graalvm.DynamicProxyConfigGenerator]) + * for picocli-annotated classes. + * + * Register this provider by adding `info.picocli:picocli-codegen-ksp` as a `ksp` dependency. + * + * **Supported KSP options:** + * - `project` – subdirectory under `META-INF/native-image/picocli-generated/` for the output files + * - `verbose` – if present, log progress messages + * - `disable.reflect.config` – if present, skip generating `reflect-config.json` + * - `disable.resource.config` – if present, skip generating `resource-config.json` + * - `disable.proxy.config` – if present, skip generating `proxy-config.json` + * - `other.resource.bundles` – comma-separated additional resource bundle base names to include + * - `other.resource.patterns` – comma-separated additional Java regex resource patterns to include + * - `other.proxy.interfaces` – comma-separated additional interface names to include in the proxy config + * + * @since 4.7.8 + */ +class PicocliKspProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + PicocliKspProcessor(environment) +} diff --git a/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/ReflectedClass.kt b/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/ReflectedClass.kt new file mode 100644 index 000000000..25f7c3a21 --- /dev/null +++ b/picocli-codegen-ksp/src/main/kotlin/picocli/codegen/ksp/ReflectedClass.kt @@ -0,0 +1,62 @@ +package picocli.codegen.ksp + +/** + * Represents a class entry in the GraalVM `reflect-config.json` file. + * Mirrors [picocli.codegen.aot.graalvm.ReflectionConfigGenerator.ReflectedClass]. + */ +internal class ReflectedClass(val name: String) { + + private val fields = sortedSetOf(compareBy { it.name }) + private val methods = sortedSetOf(compareBy({ it.name }, { it.paramTypes.joinToString() })) + + fun addField(fieldName: String, isFinal: Boolean): ReflectedClass { + fields += ReflectedField(fieldName, isFinal) + return this + } + + fun addMethod(methodName: String, paramTypes: List): ReflectedClass { + methods += ReflectedMethod(methodName, paramTypes) + return this + } + + /** Serialises this entry to the JSON fragment expected by GraalVM. */ + fun toJson(): String = buildString { + append(" {\n") + append(" \"name\" : \"$name\",\n") + append(" \"allDeclaredConstructors\" : true,\n") + append(" \"allPublicConstructors\" : true,\n") + append(" \"allDeclaredMethods\" : true,\n") + append(" \"allPublicMethods\" : true") + if (fields.isNotEmpty()) { + append(",\n \"fields\" : [\n") + fields.forEachIndexed { i, f -> + if (i > 0) append(",\n") + append(" ").append(f.toJson()) + } + append("\n ]") + } + if (methods.isNotEmpty()) { + append(",\n \"methods\" : [\n") + methods.forEachIndexed { i, m -> + if (i > 0) append(",\n") + append(" ").append(m.toJson()) + } + append("\n ]") + } + append("\n }") + } +} + +internal data class ReflectedField(val name: String, val isFinal: Boolean) { + fun toJson(): String { + val allowWrite = if (isFinal) ", \"allowWrite\" : true" else "" + return "{\"name\" : \"$name\"$allowWrite}" + } +} + +internal data class ReflectedMethod(val name: String, val paramTypes: List) { + fun toJson(): String { + val params = paramTypes.joinToString(", ") { "\"$it\"" } + return "{\"name\" : \"$name\", \"parameterTypes\" : [$params]}" + } +} diff --git a/picocli-codegen-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/picocli-codegen-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 000000000..ed85d27fa --- /dev/null +++ b/picocli-codegen-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +picocli.codegen.ksp.PicocliKspProcessorProvider diff --git a/picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/PicocliKspProcessorTest.kt b/picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/PicocliKspProcessorTest.kt new file mode 100644 index 000000000..36cf3a6e2 --- /dev/null +++ b/picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/PicocliKspProcessorTest.kt @@ -0,0 +1,307 @@ +package picocli.codegen.ksp + +import com.tschuchort.compiletesting.JvmCompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspProcessorOptions +import com.tschuchort.compiletesting.symbolProcessorProviders +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Integration tests for [PicocliKspProcessor]. + * + * Each test compiles a small Kotlin snippet with the KSP processor active and then + * asserts on the content of the generated GraalVM native-image configuration files. + */ +class PicocliKspProcessorTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun compile( + vararg sources: SourceFile, + options: Map = emptyMap() + ): Pair { + val compilation = KotlinCompilation().apply { + this.sources = sources.toList() + symbolProcessorProviders = mutableListOf(PicocliKspProcessorProvider()) + kspProcessorOptions = options.toMutableMap() + inheritClassPath = true + messageOutputStream = System.out + } + return compilation.compile() to compilation.workingDir + } + + /** Finds the first file matching the given name anywhere under the given root. */ + private fun File.findFile(name: String): File? = + walkTopDown().firstOrNull { it.name == name } + + // ------------------------------------------------------------------------- + // Simple @Command class + // ------------------------------------------------------------------------- + + private val simpleCommandSource = SourceFile.kotlin( + "SimpleCommand.kt", """ + import picocli.CommandLine.Command + import picocli.CommandLine.Option + import picocli.CommandLine.Parameters + + @Command(name = "simple", description = ["A simple command"]) + class SimpleCommand : Runnable { + @Option(names = ["-v", "--verbose"]) + var verbose: Boolean = false + + @Parameters(index = "0") + var file: String = "" + + override fun run() {} + } + """.trimIndent() + ) + + @Test + fun `compilation succeeds for simple Command class`() { + val (result, _) = compile(simpleCommandSource) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + } + + @Test + fun `generates all three config files by default`() { + val (result, workingDir) = compile(simpleCommandSource) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + assertNotNull(workingDir.findFile("reflect-config.json"), "reflect-config.json should be generated") + assertNotNull(workingDir.findFile("resource-config.json"), "resource-config.json should be generated") + assertNotNull(workingDir.findFile("proxy-config.json"), "proxy-config.json should be generated") + } + + @Test + fun `reflect-config contains the Command class`() { + val (result, workingDir) = compile(simpleCommandSource) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("reflect-config.json")?.readText() + ?: fail("reflect-config.json not found") + assertTrue(content.contains("SimpleCommand"), "Should contain the command class name") + assertTrue(content.contains("allDeclaredConstructors"), "Should have allDeclaredConstructors") + assertTrue(content.contains("allPublicMethods"), "Should have allPublicMethods") + } + + @Test + fun `reflect-config contains annotated fields`() { + val (result, workingDir) = compile(simpleCommandSource) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("reflect-config.json")?.readText() + ?: fail("reflect-config.json not found") + assertTrue(content.contains("\"verbose\""), "Should contain the @Option field") + assertTrue(content.contains("\"file\""), "Should contain the @Parameters field") + } + + @Test + fun `resource-config is valid JSON object`() { + val (result, workingDir) = compile(simpleCommandSource) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("resource-config.json")?.readText() + ?: fail("resource-config.json not found") + assertTrue(content.trimStart().startsWith("{"), "resource-config should be a JSON object") + assertTrue(content.contains("\"bundles\""), "resource-config should have a 'bundles' key") + assertTrue(content.contains("\"resources\""), "resource-config should have a 'resources' key") + } + + @Test + fun `proxy-config is valid JSON array`() { + val (result, workingDir) = compile(simpleCommandSource) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("proxy-config.json")?.readText() + ?: fail("proxy-config.json not found") + assertTrue(content.trimStart().startsWith("["), "proxy-config should be a JSON array") + } + + // ------------------------------------------------------------------------- + // Disable flags + // ------------------------------------------------------------------------- + + @Test + fun `disable reflect-config via option`() { + val (result, workingDir) = compile( + simpleCommandSource, + options = mapOf(PicocliKspProcessor.OPTION_DISABLE_REFLECT to "") + ) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + assertNull(workingDir.findFile("reflect-config.json"), "reflect-config.json should NOT be generated") + assertNotNull(workingDir.findFile("resource-config.json")) + assertNotNull(workingDir.findFile("proxy-config.json")) + } + + @Test + fun `disable resource-config via option`() { + val (result, workingDir) = compile( + simpleCommandSource, + options = mapOf(PicocliKspProcessor.OPTION_DISABLE_RESOURCE to "") + ) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + assertNotNull(workingDir.findFile("reflect-config.json")) + assertNull(workingDir.findFile("resource-config.json"), "resource-config.json should NOT be generated") + assertNotNull(workingDir.findFile("proxy-config.json")) + } + + @Test + fun `disable proxy-config via option`() { + val (result, workingDir) = compile( + simpleCommandSource, + options = mapOf(PicocliKspProcessor.OPTION_DISABLE_PROXY to "") + ) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + assertNotNull(workingDir.findFile("reflect-config.json")) + assertNotNull(workingDir.findFile("resource-config.json")) + assertNull(workingDir.findFile("proxy-config.json"), "proxy-config.json should NOT be generated") + } + + // ------------------------------------------------------------------------- + // Resource bundle + // ------------------------------------------------------------------------- + + @Test + fun `resource bundle from @Command is in resource-config`() { + val source = SourceFile.kotlin("BundleCommand.kt", """ + import picocli.CommandLine.Command + + @Command(name = "bundled", resourceBundle = "com.example.Messages") + class BundleCommand + """.trimIndent()) + + val (result, workingDir) = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("resource-config.json")?.readText() + ?: fail("resource-config.json not found") + assertTrue(content.contains("com.example.Messages"), + "Resource bundle should appear in resource-config") + } + + // ------------------------------------------------------------------------- + // Interface command → proxy-config + // ------------------------------------------------------------------------- + + @Test + fun `interface @Command is added to proxy-config`() { + val source = SourceFile.kotlin("InterfaceCommand.kt", """ + import picocli.CommandLine.Command + import picocli.CommandLine.Option + + @Command(name = "iface") + interface InterfaceCommand { + @Option(names = ["-v"]) + fun verbose(v: Boolean) + } + """.trimIndent()) + + val (result, workingDir) = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("proxy-config.json")?.readText() + ?: fail("proxy-config.json not found") + assertTrue(content.contains("InterfaceCommand"), + "Interface command should appear in proxy-config") + } + + // ------------------------------------------------------------------------- + // extra resource bundles option + // ------------------------------------------------------------------------- + + @Test + fun `extra bundles from option appear in resource-config`() { + val (result, workingDir) = compile( + simpleCommandSource, + options = mapOf(PicocliKspProcessor.OPTION_BUNDLES to "com.example.Extra,com.example.More") + ) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("resource-config.json")?.readText() + ?: fail("resource-config.json not found") + assertTrue(content.contains("com.example.Extra"), "Extra bundle should be present") + assertTrue(content.contains("com.example.More"), "Second extra bundle should be present") + } + + // ------------------------------------------------------------------------- + // @Mixin + // ------------------------------------------------------------------------- + + @Test + fun `Mixin field appears in reflect-config`() { + val source = SourceFile.kotlin("MixinCommand.kt", """ + import picocli.CommandLine.Command + import picocli.CommandLine.Mixin + import picocli.CommandLine.Option + + class VerboseMixin { + @Option(names = ["-v"]) + var verbose: Boolean = false + } + + @Command(name = "mixin-cmd") + class MixinCommand { + @Mixin + var mixin: VerboseMixin = VerboseMixin() + } + """.trimIndent()) + + val (result, workingDir) = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("reflect-config.json")?.readText() + ?: fail("reflect-config.json not found") + assertTrue(content.contains("MixinCommand"), "MixinCommand should be in reflect-config") + assertTrue(content.contains("\"mixin\""), "Mixin field should be listed") + } + + // ------------------------------------------------------------------------- + // project option + // ------------------------------------------------------------------------- + + @Test + fun `project option places files in the correct subdirectory`() { + val (result, workingDir) = compile( + simpleCommandSource, + options = mapOf(PicocliKspProcessor.OPTION_PROJECT to "my/project") + ) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val reflectFile = workingDir.findFile("reflect-config.json") + assertNotNull(reflectFile, "reflect-config.json should be generated") + assertTrue( + reflectFile!!.path.replace('\\', '/').contains("my/project"), + "File should be under the project subdirectory, but was at: ${reflectFile.path}" + ) + } + + // ------------------------------------------------------------------------- + // Class with @Option but no @Command + // ------------------------------------------------------------------------- + + @Test + fun `class with Option field but no Command annotation is still reflected`() { + val source = SourceFile.kotlin("OptionOnly.kt", """ + import picocli.CommandLine.Option + + class OptionOnly { + @Option(names = ["-x"]) + var x: Int = 0 + } + """.trimIndent()) + + val (result, workingDir) = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val content = workingDir.findFile("reflect-config.json")?.readText() + ?: fail("reflect-config.json not found") + assertTrue(content.contains("OptionOnly"), + "Class with @Option fields should be in reflect-config even without @Command") + } +} diff --git a/picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/ReflectedClassTest.kt b/picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/ReflectedClassTest.kt new file mode 100644 index 000000000..3808c83c8 --- /dev/null +++ b/picocli-codegen-ksp/src/test/kotlin/picocli/codegen/ksp/ReflectedClassTest.kt @@ -0,0 +1,96 @@ +package picocli.codegen.ksp + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ReflectedClassTest { + + @Test + fun `empty class produces minimal JSON`() { + val json = ReflectedClass("com.example.MyCommand").toJson() + + assertTrue(json.contains("\"name\" : \"com.example.MyCommand\"")) + assertTrue(json.contains("\"allDeclaredConstructors\" : true")) + assertTrue(json.contains("\"allPublicConstructors\" : true")) + assertTrue(json.contains("\"allDeclaredMethods\" : true")) + assertTrue(json.contains("\"allPublicMethods\" : true")) + assertFalse(json.contains("fields")) + assertFalse(json.contains("methods")) + } + + @Test + fun `class with mutable field has no allowWrite`() { + val cls = ReflectedClass("com.example.Cmd").addField("verbose", isFinal = false) + val json = cls.toJson() + + assertTrue(json.contains("\"name\" : \"verbose\"")) + assertFalse(json.contains("allowWrite")) + } + + @Test + fun `class with final field has allowWrite true`() { + val cls = ReflectedClass("com.example.Cmd").addField("port", isFinal = true) + val json = cls.toJson() + + assertTrue(json.contains("\"name\" : \"port\"")) + assertTrue(json.contains("\"allowWrite\" : true")) + } + + @Test + fun `class with method serialises parameter types`() { + val cls = ReflectedClass("com.example.Cmd") + .addMethod("setVerbose", listOf("boolean")) + val json = cls.toJson() + + assertTrue(json.contains("\"name\" : \"setVerbose\"")) + assertTrue(json.contains("\"parameterTypes\" : [\"boolean\"]")) + } + + @Test + fun `class with no-arg method serialises empty parameterTypes array`() { + val cls = ReflectedClass("com.example.Cmd").addMethod("call", emptyList()) + val json = cls.toJson() + + assertTrue(json.contains("\"parameterTypes\" : []")) + } + + @Test + fun `nested class uses dollar sign separator`() { + val cls = ReflectedClass("com.example.Outer\$Inner") + val json = cls.toJson() + + assertTrue(json.contains("com.example.Outer\$Inner")) + } + + @Test + fun `fields are sorted alphabetically`() { + val cls = ReflectedClass("com.example.Cmd") + .addField("zzz", false) + .addField("aaa", false) + .addField("mmm", false) + val json = cls.toJson() + + val aaaIdx = json.indexOf("\"aaa\"") + val mmmIdx = json.indexOf("\"mmm\"") + val zzzIdx = json.indexOf("\"zzz\"") + assertTrue(aaaIdx < mmmIdx && mmmIdx < zzzIdx, "Fields should be sorted alphabetically") + } + + @Test + fun `reflected field toJson without allowWrite`() { + val f = ReflectedField("verbose", isFinal = false) + assertEquals("{\"name\" : \"verbose\"}", f.toJson()) + } + + @Test + fun `reflected field toJson with allowWrite`() { + val f = ReflectedField("port", isFinal = true) + assertEquals("{\"name\" : \"port\", \"allowWrite\" : true}", f.toJson()) + } + + @Test + fun `reflected method toJson with multiple params`() { + val m = ReflectedMethod("connect", listOf("java.lang.String", "int")) + assertEquals("{\"name\" : \"connect\", \"parameterTypes\" : [\"java.lang.String\", \"int\"]}", m.toJson()) + } +} diff --git a/settings.gradle b/settings.gradle index cca309a64..8930c66bb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ include 'picocli-groovy' include 'picocli-examples' include 'picocli-shell-jline2' include 'picocli-codegen' +include 'picocli-codegen-ksp' include 'picocli-tests-java8' if (org.gradle.api.JavaVersion.current().isJava8Compatible()) {