diff --git a/docs/modules/java-binding/pages/codegen.adoc b/docs/modules/java-binding/pages/codegen.adoc index b16f22e3..91f3372d 100644 --- a/docs/modules/java-binding/pages/codegen.adoc +++ b/docs/modules/java-binding/pages/codegen.adoc @@ -164,13 +164,6 @@ This annotation is required to have `java.lang.annotation.ElementType.TYPE_USE` or it may generate code that does not compile. ==== -.--implement-serializable -[%collapsible] -==== -Default: (flag not set) + -Whether to make generated classes implement `java.io.Serializable`. -==== - Common code generator options: include::{partialsdir}/cli-codegen-options.adoc[] diff --git a/docs/modules/java-binding/partials/cli-codegen-options.adoc b/docs/modules/java-binding/partials/cli-codegen-options.adoc index 42f16e4d..4aab11f5 100644 --- a/docs/modules/java-binding/partials/cli-codegen-options.adoc +++ b/docs/modules/java-binding/partials/cli-codegen-options.adoc @@ -21,3 +21,57 @@ Relative paths are resolved against the working directory. Default: (not set) + Flag that indicates to generate config classes for use with Spring Boot. ==== + +.--implement-serializable +[%collapsible] +==== +Default: (not set) + +Whether to make generated classes implement `java.io.Serializable`. +==== + +.--rename +[%collapsible] +==== +Default: (none) + +Example: `foo.=com.example.foo.` + +Allows to change default class and package names (derived from Pkl module names) in the generated code. + +When you need the generated class or package names to be different from the default names derived from the Pkl module names, you can define a rename mapping, where the key is the original Pkl module name prefix, and the value is its replacement. +When you do, the generated code's `package` declarations, class names, as well as file locations, will be modified according to this mapping. + +The prefixes are replaced literally, which means that dots at the end are important. +If you want to rename packages only, in most cases, you must ensure that you have an ending dot on both sides of a mapping (except for an empty mapping, if you use it), otherwise you may get unexpected results: + +---- +// Assuming the following arguments: +--rename com.foo.=x // Dot on the left only +--rename org.bar=y. // Dot on the right only +--rename net.baz=z // No dots + +// The following renames will be made: +"com.foo.bar" -> "xbar" // Target prefix merged into the suffix +"org.bar.baz" -> "y..baz" // Double dot, invalid name +"net.baz.qux" -> "z.qux" // Looks okay, but... +"net.bazqux" -> "zqux" // ...may cut the name in the middle. +---- + +When computing the appropriate target name, the longest matching prefix is used: + +---- +// Assuming the following arguments: +--rename com.foo.Main=w.Main +--rename com.foo.=x. +--rename com.=y. +--rename =z. + +// The following renames will be made: +com.foo.Main -> w.Main +com.foo.bar -> x.bar +com.baz.qux -> y.baz.qux +org.foo.bar -> z.org.foo.bar +---- + +Repeat this option to define multiple mappings. +Keys can be arbitrary strings, including an empty string. +Values must be valid dot-separated fully qualified class name prefixes, possibly terminated by a dot. +==== diff --git a/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc index 1911c588..05c4fed8 100644 --- a/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc @@ -36,4 +36,54 @@ Example: `generateSpringBootConfig = true` + Whether to generate config classes for use with Spring Boot. ==== +.packageMapping: MapProperty +[%collapsible] +==== +Default: `[:]` + +Example: `packageMapping = ["foo.": "com.example.foo.", "bar.Config": "com.example.bar.Config"]` + +Allows to change default class and package names (derived from Pkl module names) in the generated code. + +When you need the generated class or package names to be different from the default names derived from the Pkl module names, you can define a rename mapping, where the key is the original Pkl module name prefix, and the value is its replacement. +When you do, the generated code's `package` declarations, class names, as well as file locations, will be modified according to this mapping. + +The prefixes are replaced literally, which means that dots at the end are important. +If you want to rename packages only, in most cases, you must ensure that you have an ending dot on both sides of a mapping (except for an empty mapping, if you use it), otherwise you may get unexpected results: + +.... +// Assuming the following mapping configuration: +packageMapping = [ + "com.foo.": "x", // Dot on the left only + "org.bar": "y.", // Dot on the right only + "net.baz": "z" // No dots +] + +// The following renames will be made: +"com.foo.bar" -> "xbar" // Target prefix merged into the suffix +"org.bar.baz" -> "y..baz" // Double dot, invalid name +"net.baz.qux" -> "z.qux" // Looks okay, but... +"net.bazqux" -> "zqux" // ...may cut the name in the middle. +.... + +When computing the appropriate target name, the longest matching prefix is used: + +.... +// Assuming the following mapping configuration: +packageMapping = [ + "com.foo.Main": "w.Main", + "com.foo.": "x.", + "com.": "y.", + "": "z." +] + +// The following renames will be made: +com.foo.Main -> w.Main +com.foo.bar -> x.bar +com.baz.qux -> y.baz.qux +org.foo.bar -> z.org.foo.bar +.... + +Keys in this mapping can be arbitrary strings, including an empty string. +Values must be valid dot-separated fully qualifed class name prefixes, possibly terminated by a dot. +==== + // TODO: fixme (implementSerializable) diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt index aaa3a92c..e36b7c18 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt @@ -54,7 +54,16 @@ data class CliJavaCodeGeneratorOptions( val nonNullAnnotation: String? = null, /** Whether to make generated classes implement [java.io.Serializable] */ - val implementSerializable: Boolean = false + val implementSerializable: Boolean = false, + + /** + * A rename mapping for class names. + * + * When you need to have Java class or package names different from the default names derived from + * Pkl module names, you can define a rename mapping, where the key is a prefix of the original + * Pkl module name, and the value is the desired replacement. + */ + val renames: Map = emptyMap() ) { fun toJavaCodegenOptions() = JavaCodegenOptions( @@ -64,6 +73,7 @@ data class CliJavaCodeGeneratorOptions( generateSpringBootConfig, paramsAnnotation, nonNullAnnotation, - implementSerializable + implementSerializable, + renames ) } diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt index 872fafa8..b1164fca 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt @@ -34,8 +34,10 @@ import kotlin.apply import kotlin.let import kotlin.takeIf import kotlin.to +import org.pkl.commons.NameMapper import org.pkl.core.* import org.pkl.core.util.CodeGeneratorUtils +import org.pkl.core.util.IoUtils class JavaCodeGeneratorException(message: String) : RuntimeException(message) @@ -68,7 +70,15 @@ data class JavaCodegenOptions( val nonNullAnnotation: String? = null, /** Whether to make generated classes implement [java.io.Serializable] */ - val implementSerializable: Boolean = false + val implementSerializable: Boolean = false, + + /** + * A mapping from Pkl module name prefixes to their replacements. + * + * Can be used when the class or package name in the generated source code should be different + * from the corresponding name derived from the Pkl module declaration . + */ + val renames: Map = emptyMap() ) /** Entrypoint for the Java code generator API. */ @@ -123,7 +133,8 @@ class JavaCodeGenerator( } private val propertyFileName: String - get() = "resources/META-INF/org/pkl/config/java/mapper/classes/${schema.moduleName}.properties" + get() = + "resources/META-INF/org/pkl/config/java/mapper/classes/${IoUtils.encodePath(schema.moduleName)}.properties" private val propertiesFile: String get() { @@ -150,8 +161,16 @@ class JavaCodeGenerator( return AnnotationSpec.builder(className).build() } - val javaFileName: String - get() = relativeOutputPathFor(schema.moduleName) + private val javaFileName: String + get() { + val (packageName, className) = nameMapper.map(schema.moduleName) + val dirPath = packageName.replace('.', '/') + return if (dirPath.isEmpty()) { + "java/$className.java" + } else { + "java/$dirPath/$className.java" + } + } val javaFile: String get() { @@ -183,9 +202,7 @@ class JavaCodeGenerator( moduleClass.addMethod(appendPropertyMethod().addModifiers(modifier).build()) } - val moduleName = schema.moduleName - val index = moduleName.lastIndexOf(".") - val packageName = if (index == -1) "" else moduleName.substring(0, index) + val (packageName, _) = nameMapper.map(schema.moduleName) return JavaFile.builder(packageName, moduleClass.build()) .indent(codegenOptions.indent) @@ -193,20 +210,7 @@ class JavaCodeGenerator( .toString() } - private fun relativeOutputPathFor(moduleName: String): String { - val moduleNameParts = moduleName.split(".") - val dirPath = moduleNameParts.dropLast(1).joinToString("/") - val fileName = moduleNameParts.last().replaceFirstChar { it.titlecaseChar() } - return if (dirPath.isEmpty()) { - "java/$fileName.java" - } else { - "java/$dirPath/$fileName.java" - } - } - - @Suppress("NAME_SHADOWING") private fun generateTypeSpec(pClass: PClass, schema: ModuleSchema): TypeSpec.Builder { - val isModuleClass = pClass == schema.moduleClass val javaPoetClassName = pClass.toJavaPoetName() val superclass = @@ -687,9 +691,7 @@ class JavaCodeGenerator( .endControlFlow() private fun PClass.toJavaPoetName(): ClassName { - val index = moduleName.lastIndexOf(".") - val packageName = if (index == -1) "" else moduleName.substring(0, index) - val moduleClassName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() } + val (packageName, moduleClassName) = nameMapper.map(moduleName) return if (isModuleClass) { ClassName.get(packageName, moduleClassName) } else { @@ -699,9 +701,7 @@ class JavaCodeGenerator( // generated type is a nested enum class private fun TypeAlias.toJavaPoetName(): ClassName { - val index = moduleName.lastIndexOf(".") - val packageName = if (index == -1) "" else moduleName.substring(0, index) - val moduleClassName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() } + val (packageName, moduleClassName) = nameMapper.map(moduleName) return ClassName.get(packageName, moduleClassName, simpleName) } @@ -872,6 +872,8 @@ class JavaCodeGenerator( } else key } } + + private val nameMapper = NameMapper(codegenOptions.renames) } internal val javaReservedWords = diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt index 11b4441d..ced7d108 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt @@ -17,6 +17,7 @@ package org.pkl.codegen.java +import com.github.ajalt.clikt.parameters.options.associate import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option @@ -108,6 +109,21 @@ class PklJavaCodegenCommand : ) .flag() + private val renames: Map by + option( + names = arrayOf("--rename"), + metavar = "", + help = + """ + Replace a prefix in the names of the generated Java classes (repeatable). + By default, the names of generated classes are derived from the Pkl module names. + With this option, you can override the modify the default names, renaming entire + classes or just their packages. + """ + .trimIndent() + ) + .associate() + override fun run() { val options = CliJavaCodeGeneratorOptions( @@ -119,7 +135,8 @@ class PklJavaCodegenCommand : generateSpringBootConfig = generateSpringboot, paramsAnnotation = paramsAnnotation, nonNullAnnotation = nonNullAnnotation, - implementSerializable = implementSerializable + implementSerializable = implementSerializable, + renames = renames ) CliJavaCodeGenerator(options).run() } diff --git a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorTest.kt b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorTest.kt index e89974fa..105fa6b8 100644 --- a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorTest.kt +++ b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorTest.kt @@ -173,6 +173,115 @@ class CliJavaCodeGeneratorTest { ) } + @Test + fun `custom package names`(@TempDir tempDir: Path) { + val module1 = + PklModule( + "org.foo.Module1", + """ + module org.foo.Module1 + + class Person { + name: String + } + """ + ) + + val module2 = + PklModule( + "org.bar.Module2", + """ + module org.bar.Module2 + + import "../../org/foo/Module1.pkl" + + class Group { + owner: Module1.Person + name: String + } + """ + ) + + val module3 = + PklModule( + "org.baz.Module3", + """ + module org.baz.Module3 + + import "../../org/bar/Module2.pkl" + + class Supergroup { + owner: Module2.Group + } + """ + ) + + val module1PklFile = module1.writeToDisk(tempDir.resolve("org/foo/Module1.pkl")) + val module2PklFile = module2.writeToDisk(tempDir.resolve("org/bar/Module2.pkl")) + val module3PklFile = module3.writeToDisk(tempDir.resolve("org/baz/Module3.pkl")) + val outputDir = tempDir.resolve("output") + + val generator = + CliJavaCodeGenerator( + CliJavaCodeGeneratorOptions( + CliBaseOptions(listOf(module1PklFile, module2PklFile, module3PklFile).map { it.toUri() }), + outputDir, + renames = mapOf("org.foo" to "com.foo.x", "org.baz" to "com.baz.a.b") + ) + ) + + generator.run() + + val module1JavaFile = outputDir.resolve("java/com/foo/x/Module1.java") + module1JavaFile.readString().let { + assertContains("package com.foo.x;", it) + + assertContains("public final class Module1 {", it) + + assertContains( + """ + | public static final class Person { + | public final @NonNull String name; + """, + it + ) + } + + val module2JavaFile = outputDir.resolve("java/org/bar/Module2.java") + module2JavaFile.readString().let { + assertContains("package org.bar;", it) + + assertContains("import com.foo.x.Module1;", it) + + assertContains("public final class Module2 {", it) + + assertContains( + """ + | public static final class Group { + | public final Module1. @NonNull Person owner; + """, + it + ) + } + + val module3JavaFile = outputDir.resolve("java/com/baz/a/b/Module3.java") + module3JavaFile.readString().let { + assertContains("package com.baz.a.b;", it) + + assertContains("import org.bar.Module2;", it) + + assertContains("public final class Module3 {", it) + + assertContains( + """ + | public static final class Supergroup { + | public final Module2. @NonNull Group owner; + """, + it + ) + } + } + private fun assertContains(part: String, code: String) { val trimmedPart = part.trim().trimMargin() if (!code.contains(trimmedPart)) { diff --git a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/InMemoryJavaCompiler.kt b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/InMemoryJavaCompiler.kt index 25c019c9..03d59012 100644 --- a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/InMemoryJavaCompiler.kt +++ b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/InMemoryJavaCompiler.kt @@ -30,7 +30,9 @@ object InMemoryJavaCompiler { val fileManager = InMemoryFileManager(compiler.getStandardFileManager(diagnosticsCollector, null, null)) val sourceObjects = - sourceFiles.map { (filename, contents) -> ReadableSourceFileObject(filename, contents) } + sourceFiles + .filter { (filename, _) -> filename.endsWith(".java") } + .map { (filename, contents) -> ReadableSourceFileObject(filename, contents) } val task = compiler.getTask(null, fileManager, diagnosticsCollector, null, null, sourceObjects) val result = task.call() if (!result) { diff --git a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt index 42bec7b6..6f5ad3c9 100644 --- a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt +++ b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt @@ -33,6 +33,8 @@ import org.pkl.core.util.IoUtils class JavaCodeGeneratorTest { companion object { + const val MAPPER_PREFIX = "resources/META-INF/org/pkl/config/java/mapper/classes" + private val simpleClass by lazy { compileJavaCode( generateJavaCode( @@ -101,7 +103,8 @@ class JavaCodeGeneratorTest { generateJavadoc: Boolean = false, generateSpringBootConfig: Boolean = false, nonNullAnnotation: String? = null, - implementSerializable: Boolean = false + implementSerializable: Boolean = false, + renames: Map = emptyMap() ): String { val module = Evaluator.preconfigured().evaluateSchema(text(pklCode)) val generator = @@ -113,6 +116,7 @@ class JavaCodeGeneratorTest { generateSpringBootConfig = generateSpringBootConfig, nonNullAnnotation = nonNullAnnotation, implementSerializable = implementSerializable, + renames = renames ) ) return generator.javaFile @@ -127,6 +131,8 @@ class JavaCodeGeneratorTest { } } + @TempDir lateinit var tempDir: Path + @Test fun testEquals() { val ctor = simpleClass.constructors.first() @@ -1420,7 +1426,7 @@ class JavaCodeGeneratorTest { } @Test - fun `import module`(@TempDir tempDir: Path) { + fun `import module`() { val library = PklModule( "library", @@ -1449,7 +1455,7 @@ class JavaCodeGeneratorTest { .trimIndent() ) - val javaSourceFiles = generateJavaFiles(tempDir, library, client) + val javaSourceFiles = generateFiles(library, client) val javaClientCode = javaSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.java") }!!.value @@ -1467,7 +1473,7 @@ class JavaCodeGeneratorTest { } @Test - fun `extend module`(@TempDir tempDir: Path) { + fun `extend module`() { val base = PklModule( "base", @@ -1496,7 +1502,7 @@ class JavaCodeGeneratorTest { .trimIndent() ) - val javaSourceFiles = generateJavaFiles(tempDir, base, derived) + val javaSourceFiles = generateFiles(base, derived) val javaDerivedCode = javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value @@ -1520,7 +1526,7 @@ class JavaCodeGeneratorTest { } @Test - fun `extend module that only contains type aliases`(@TempDir tempDir: Path) { + fun `extend module that only contains type aliases`() { val base = PklModule( "base", @@ -1545,7 +1551,7 @@ class JavaCodeGeneratorTest { .trimIndent() ) - val javaSourceFiles = generateJavaFiles(tempDir, base, derived) + val javaSourceFiles = generateFiles(base, derived) val javaDerivedCode = javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value @@ -1561,7 +1567,7 @@ class JavaCodeGeneratorTest { } @Test - fun `generated properties files`(@TempDir tempDir: Path) { + fun `generated properties files`() { val pklModule = PklModule( "Mod.pkl", @@ -1582,7 +1588,7 @@ class JavaCodeGeneratorTest { """ .trimIndent() ) - val generated = generateFiles(tempDir, pklModule) + val generated = generateFiles(pklModule) val expectedPropertyFile = "resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties" assertThat(generated).containsKey(expectedPropertyFile) @@ -1596,7 +1602,7 @@ class JavaCodeGeneratorTest { } @Test - fun `generated properties files with normalized java name`(@TempDir tempDir: Path) { + fun `generated properties files with normalized java name`() { val pklModule = PklModule( "mod.pkl", @@ -1617,7 +1623,7 @@ class JavaCodeGeneratorTest { """ .trimIndent() ) - val generated = generateFiles(tempDir, pklModule) + val generated = generateFiles(pklModule) val expectedPropertyFile = "resources/META-INF/org/pkl/config/java/mapper/classes/my.mod.properties" assertThat(generated).containsKey(expectedPropertyFile) @@ -1824,24 +1830,235 @@ class JavaCodeGeneratorTest { assertCompilesSuccessfully(javaCode) } - private fun generateFiles(tempDir: Path, vararg pklModules: PklModule): Map { + @Test + fun `override names in a standalone module`() { + val files = + JavaCodegenOptions( + renames = mapOf("a.b.c." to "x.y.z.", "d.e.f.AnotherModule" to "u.v.w.RenamedModule") + ) + .generateFiles( + "MyModule.pkl" to + """ + module a.b.c.MyModule + + foo: String = "abc" + """ + .trimIndent(), + "AnotherModule.pkl" to + """ + module d.e.f.AnotherModule + + bar: Int = 123 + """ + .trimIndent() + ) + .toMutableMap() + + files.validateContents( + "java/x/y/z/MyModule.java" to listOf("package x.y.z;", "public final class MyModule {"), + "$MAPPER_PREFIX/a.b.c.MyModule.properties" to + listOf("org.pkl.config.java.mapper.a.b.c.MyModule\\#ModuleClass=x.y.z.MyModule"), + // --- + "java/u/v/w/RenamedModule.java" to + listOf("package u.v.w;", "public final class RenamedModule {"), + "$MAPPER_PREFIX/d.e.f.AnotherModule.properties" to + listOf("org.pkl.config.java.mapper.d.e.f.AnotherModule\\#ModuleClass=u.v.w.RenamedModule"), + ) + } + + @Test + fun `override names based on the longest prefix`() { + val files = + JavaCodegenOptions( + renames = mapOf("com.foo.bar." to "x.", "com.foo." to "y.", "com." to "z.", "" to "w.") + ) + .generateFiles( + "com/foo/bar/Module1" to + """ + module com.foo.bar.Module1 + + bar: String + """ + .trimIndent(), + "com/Module2" to + """ + module com.Module2 + + com: String + """ + .trimIndent(), + "org/baz/Module3" to + """ + module org.baz.Module3 + + baz: String + """ + .trimIndent() + ) + + files.validateContents( + "java/x/Module1.java" to listOf("package x;", "public final class Module1 {"), + "$MAPPER_PREFIX/com.foo.bar.Module1.properties" to + listOf("org.pkl.config.java.mapper.com.foo.bar.Module1\\#ModuleClass=x.Module1"), + // --- + "java/z/Module2.java" to listOf("package z;", "public final class Module2 {"), + "$MAPPER_PREFIX/com.Module2.properties" to + listOf("org.pkl.config.java.mapper.com.Module2\\#ModuleClass=z.Module2"), + // --- + "java/w/org/baz/Module3.java" to listOf("package w.org.baz;", "public final class Module3 {"), + "$MAPPER_PREFIX/org.baz.Module3.properties" to + listOf("org.pkl.config.java.mapper.org.baz.Module3\\#ModuleClass=w.org.baz.Module3"), + ) + } + + @Test + fun `override names in multiple modules using each other`() { + val files = + JavaCodegenOptions( + renames = + mapOf( + "org.foo." to "com.foo.x.", + "org.bar.Module2" to "org.bar.RenamedModule", + "org.baz." to "com.baz.a.b." + ) + ) + .generateFiles( + "org/foo/Module1" to + """ + module org.foo.Module1 + + class Person { + name: String + } + """ + .trimIndent(), + "org/bar/Module2" to + """ + module org.bar.Module2 + + import "../../org/foo/Module1.pkl" + + class Group { + owner: Module1.Person + name: String + } + """ + .trimIndent(), + "org/baz/Module3" to + """ + module org.baz.Module3 + + import "../../org/bar/Module2.pkl" + + class Supergroup { + owner: Module2.Group + } + """ + .trimIndent() + ) + + files.validateContents( + "java/com/foo/x/Module1.java" to listOf("package com.foo.x;", "public final class Module1 {"), + "$MAPPER_PREFIX/org.foo.Module1.properties" to + listOf( + "org.pkl.config.java.mapper.org.foo.Module1\\#ModuleClass=com.foo.x.Module1", + "org.pkl.config.java.mapper.org.foo.Module1\\#Person=com.foo.x.Module1${'$'}Person", + ), + // --- + "java/org/bar/RenamedModule.java" to + listOf( + "package org.bar;", + "import com.foo.x.Module1;", + "public final class RenamedModule {", + "public final Module1. @NonNull Person owner;" + ), + "$MAPPER_PREFIX/org.bar.Module2.properties" to + listOf( + "org.pkl.config.java.mapper.org.bar.Module2\\#ModuleClass=org.bar.RenamedModule", + "org.pkl.config.java.mapper.org.bar.Module2\\#Group=org.bar.RenamedModule${'$'}Group", + ), + // --- + "java/com/baz/a/b/Module3.java" to + listOf( + "package com.baz.a.b;", + "import org.bar.RenamedModule;", + "public final class Module3 {", + "public final RenamedModule. @NonNull Group owner;" + ), + "$MAPPER_PREFIX/org.baz.Module3.properties" to + listOf( + "org.pkl.config.java.mapper.org.baz.Module3\\#ModuleClass=com.baz.a.b.Module3", + "org.pkl.config.java.mapper.org.baz.Module3\\#Supergroup=com.baz.a.b.Module3${'$'}Supergroup", + ), + ) + } + + @Test + fun `do not capitalize names of renamed classes`() { + val files = + JavaCodegenOptions( + renames = mapOf("a.b.c.MyModule" to "x.y.z.renamed_module", "d.e.f." to "u.v.w.") + ) + .generateFiles( + "MyModule.pkl" to + """ + module a.b.c.MyModule + + foo: String = "abc" + """ + .trimIndent(), + "lower_module.pkl" to + """ + module d.e.f.lower_module + + bar: Int = 123 + """ + .trimIndent() + ) + + files.validateContents( + "java/x/y/z/renamed_module.java" to + listOf("package x.y.z;", "public final class renamed_module {"), + "$MAPPER_PREFIX/a.b.c.MyModule.properties" to + listOf("org.pkl.config.java.mapper.a.b.c.MyModule\\#ModuleClass=x.y.z.renamed_module"), + // --- + "java/u/v/w/Lower_module.java" to + listOf("package u.v.w;", "public final class Lower_module {"), + "$MAPPER_PREFIX/d.e.f.lower_module.properties" to + listOf("org.pkl.config.java.mapper.d.e.f.lower_module\\#ModuleClass=u.v.w.Lower_module"), + ) + } + + private fun Map.validateContents( + vararg assertions: kotlin.Pair> + ) { + val files = toMutableMap() + + for ((fileName, lines) in assertions) { + assertThat(files).containsKey(fileName) + assertThat(files.remove(fileName)).describedAs("Contents of $fileName").contains(lines) + } + + assertThat(files).isEmpty() + } + + private fun JavaCodegenOptions.generateFiles(vararg pklModules: PklModule): Map { val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } val evaluator = Evaluator.preconfigured() return pklFiles.fold(mapOf()) { acc, pklFile -> val pklSchema = evaluator.evaluateSchema(path(pklFile)) - acc + JavaCodeGenerator(pklSchema, JavaCodegenOptions()).output + val generator = JavaCodeGenerator(pklSchema, this) + acc + generator.output } } - private fun generateJavaFiles(tempDir: Path, vararg pklModules: PklModule): Map { - val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } - val evaluator = Evaluator.preconfigured() - return pklFiles.fold(mapOf()) { acc, pklFile -> - val pklSchema = evaluator.evaluateSchema(path(pklFile)) - val generator = JavaCodeGenerator(pklSchema, JavaCodegenOptions()) - acc + arrayOf(generator.javaFileName to generator.javaFile) - } - } + private fun JavaCodegenOptions.generateFiles( + vararg pklModules: kotlin.Pair + ): Map = + generateFiles(*pklModules.map { (name, text) -> PklModule(name, text) }.toTypedArray()) + + private fun generateFiles(vararg pklModules: PklModule): Map = + JavaCodegenOptions().generateFiles(*pklModules) private fun instantiateOtherAndPropertyTypes(): kotlin.Pair { val otherCtor = propertyTypesClasses.getValue("my.Mod\$Other").constructors.first() diff --git a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt index 2800fdea..8aae4699 100644 --- a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt +++ b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt @@ -36,8 +36,23 @@ data class CliKotlinCodeGeneratorOptions( val generateSpringBootConfig: Boolean = false, /** Whether to make generated classes implement [java.io.Serializable] */ - val implementSerializable: Boolean = false + val implementSerializable: Boolean = false, + + /** + * A rename mapping for class names. + * + * When you need to have Kotlin class or package names different from the default names derived + * from Pkl module names, you can define a rename mapping, where the key is a prefix of the + * original Pkl module name, and the value is the desired replacement. + */ + val renames: Map = emptyMap() ) { fun toKotlinCodegenOptions(): KotlinCodegenOptions = - KotlinCodegenOptions(indent, generateKdoc, generateSpringBootConfig, implementSerializable) + KotlinCodegenOptions( + indent, + generateKdoc, + generateSpringBootConfig, + implementSerializable, + renames + ) } diff --git a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt index 8de61c5d..f1533331 100644 --- a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt +++ b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt @@ -20,6 +20,7 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import java.io.StringWriter import java.net.URI import java.util.* +import org.pkl.commons.NameMapper import org.pkl.core.* import org.pkl.core.util.CodeGeneratorUtils import org.pkl.core.util.IoUtils @@ -35,7 +36,15 @@ data class KotlinCodegenOptions( val generateSpringBootConfig: Boolean = false, /** Whether to make generated classes implement [java.io.Serializable] */ - val implementSerializable: Boolean = false + val implementSerializable: Boolean = false, + + /** + * A mapping from Pkl module name prefixes to their replacements. + * + * Can be used when the class or package name in the generated source code should be different + * from the corresponding name derived from the Pkl module declaration . + */ + val renames: Map = emptyMap(), ) class KotlinCodeGeneratorException(message: String) : RuntimeException(message) @@ -106,10 +115,17 @@ class KotlinCodeGenerator( .toString() } - val kotlinFileName: String + private val kotlinFileName: String get() = buildString { + val (packageName, className) = nameMapper.map(moduleSchema.moduleName) + val dirPath = packageName.split('.').joinToString("/", transform = IoUtils::encodePath) + val fileName = IoUtils.encodePath(className) append("kot") - append("lin/${relativeOutputPathFor(moduleSchema.moduleName)}") + append("lin/") + if (dirPath.isNotEmpty()) { + append("$dirPath/") + } + append("$fileName.kt") } val kotlinFile: String @@ -169,9 +185,8 @@ class KotlinCodeGenerator( } val moduleName = moduleSchema.moduleName - val index = moduleName.lastIndexOf(".") - val packageName = if (index == -1) "" else moduleName.substring(0, index) - val moduleTypeName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() } + + val (packageName, moduleTypeName) = nameMapper.map(moduleName) val fileSpec = FileSpec.builder(packageName, moduleTypeName).indent(options.indent) @@ -195,17 +210,6 @@ class KotlinCodeGenerator( return fileSpec.build().toString() } - private fun relativeOutputPathFor(moduleName: String): String { - val nameParts = moduleName.split(".").map(IoUtils::encodePath) - val dirPath = nameParts.dropLast(1).joinToString("/") - val fileName = nameParts.last().replaceFirstChar { it.titlecaseChar() } - return if (dirPath.isEmpty()) { - "$fileName.kt" - } else { - "$dirPath/$fileName.kt" - } - } - private fun generateObjectSpec(pClass: PClass): TypeSpec.Builder { val builder = TypeSpec.objectBuilder(pClass.toKotlinPoetName()) val docComment = pClass.docComment @@ -636,9 +640,7 @@ class KotlinCodeGenerator( .endControlFlow() private fun PClass.toKotlinPoetName(): ClassName { - val index = moduleName.lastIndexOf(".") - val packageName = if (index == -1) "" else moduleName.substring(0, index) - val moduleTypeName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() } + val (packageName, moduleTypeName) = nameMapper.map(moduleName) return if (isModuleClass) { ClassName(packageName, moduleTypeName) } else { @@ -647,8 +649,7 @@ class KotlinCodeGenerator( } private fun TypeAlias.toKotlinPoetName(): ClassName { - val index = moduleName.lastIndexOf(".") - val packageName = if (index == -1) "" else moduleName.substring(0, index) + val (packageName, moduleTypeName) = nameMapper.map(moduleName) return when { aliasedType is PType.Alias -> { @@ -663,7 +664,6 @@ class KotlinCodeGenerator( ) } // Kotlin type generated for [this] is a nested enum class - val moduleTypeName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() } ClassName(packageName, moduleTypeName, simpleName) } else -> { @@ -786,4 +786,6 @@ class KotlinCodeGenerator( private fun List.toKotlinPoet(): Array = map { it.toKotlinPoetName() }.toTypedArray() + + private val nameMapper = NameMapper(options.renames) } diff --git a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt index efac3676..df751f16 100644 --- a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt +++ b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt @@ -17,6 +17,7 @@ package org.pkl.codegen.kotlin +import com.github.ajalt.clikt.parameters.options.associate import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option @@ -81,6 +82,21 @@ class PklKotlinCodegenCommand : ) .flag() + private val renames: Map by + option( + names = arrayOf("--rename"), + metavar = "", + help = + """ + Replace a prefix in the names of the generated Kotlin classes (repeatable). + By default, the names of generated classes are derived from the Pkl module names. + With this option, you can override the modify the default names, renaming entire + classes or just their packages. + """ + .trimIndent() + ) + .associate() + override fun run() { val options = CliKotlinCodeGeneratorOptions( @@ -89,7 +105,8 @@ class PklKotlinCodegenCommand : indent = indent, generateKdoc = generateKdoc, generateSpringBootConfig = generateSpringboot, - implementSerializable = implementSerializable + implementSerializable = implementSerializable, + renames = renames ) CliKotlinCodeGenerator(options).run() } diff --git a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorTest.kt b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorTest.kt index be56f0ae..68ed7972 100644 --- a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorTest.kt +++ b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorTest.kt @@ -152,6 +152,119 @@ class CliKotlinCodeGeneratorTest { ) } + @Test + fun `custom package names`(@TempDir tempDir: Path) { + val module1 = + PklModule( + "org.foo.Module1", + """ + module org.foo.Module1 + + class Person { + name: String + } + """ + ) + + val module2 = + PklModule( + "org.bar.Module2", + """ + module org.bar.Module2 + + import "../../org/foo/Module1.pkl" + + class Group { + owner: Module1.Person + name: String + } + """ + ) + + val module3 = + PklModule( + "org.baz.Module3", + """ + module org.baz.Module3 + + import "../../org/bar/Module2.pkl" + + class Supergroup { + owner: Module2.Group + } + """ + ) + + val module1PklFile = module1.writeToDisk(tempDir.resolve("org/foo/Module1.pkl")) + val module2PklFile = module2.writeToDisk(tempDir.resolve("org/bar/Module2.pkl")) + val module3PklFile = module3.writeToDisk(tempDir.resolve("org/baz/Module3.pkl")) + val outputDir = tempDir.resolve("output") + + val generator = + CliKotlinCodeGenerator( + CliKotlinCodeGeneratorOptions( + CliBaseOptions(listOf(module1PklFile, module2PklFile, module3PklFile).map { it.toUri() }), + outputDir, + renames = mapOf("org.foo" to "com.foo.x", "org.baz" to "com.baz.a.b") + ) + ) + + generator.run() + + val module1KotlinFile = outputDir.resolve("kotlin/com/foo/x/Module1.kt") + module1KotlinFile.readString().let { + assertContains("package com.foo.x", it) + + assertContains("object Module1 {", it) + + assertContains( + """ + | data class Person( + | val name: String + | ) + """, + it + ) + } + + val module2KotlinFile = outputDir.resolve("kotlin/org/bar/Module2.kt") + module2KotlinFile.readString().let { + assertContains("package org.bar", it) + + assertContains("import com.foo.x.Module1", it) + + assertContains("object Module2 {", it) + + assertContains( + """ + | data class Group( + | val owner: Module1.Person, + | val name: String + | ) + """, + it + ) + } + + val module3KotlinFile = outputDir.resolve("kotlin/com/baz/a/b/Module3.kt") + module3KotlinFile.readString().let { + assertContains("package com.baz.a.b", it) + + assertContains("import org.bar.Module2", it) + + assertContains("object Module3 {", it) + + assertContains( + """ + | data class Supergroup( + | val owner: Module2.Group + | ) + """, + it + ) + } + } + private fun assertContains(part: String, code: String) { val trimmedPart = part.trim().trimMargin() if (!code.contains(trimmedPart)) { diff --git a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/InMemoryKotlinCompiler.kt b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/InMemoryKotlinCompiler.kt index 1673441d..04032dd7 100644 --- a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/InMemoryKotlinCompiler.kt +++ b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/InMemoryKotlinCompiler.kt @@ -56,6 +56,7 @@ object InMemoryKotlinCompiler { val (importLines, remainder) = sourceFiles.entries + .filter { (filename, _) -> filename.endsWith(".kt") } .flatMap { (_, text) -> text.lines() } .partition { it.startsWith("import") } val importBlock = importLines.sorted().distinct() diff --git a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt index 64c8ef4f..e3ad786f 100644 --- a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt +++ b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt @@ -32,6 +32,8 @@ import org.pkl.core.util.IoUtils class KotlinCodeGeneratorTest { companion object { + const val MAPPER_PREFIX = "resources/META-INF/org/pkl/config/java/mapper/classes" + // according to: // https://github.com/JetBrains/kotlin/blob/master/core/descriptors/ // src/org/jetbrains/kotlin/renderer/KeywordStringsGenerated.java @@ -153,9 +155,13 @@ class KotlinCodeGeneratorTest { private fun compileKotlinCode(kotlinCode: String): Map> = InMemoryKotlinCompiler.compile(mapOf("my/Mod.kt" to kotlinCode)) - private fun assertCompilesSuccessfully(sourceText: String) = compileKotlinCode(sourceText) + private fun assertCompilesSuccessfully(sourceText: String) { + assertThatCode { compileKotlinCode(sourceText) }.doesNotThrowAnyException() + } } + @TempDir lateinit var tempDir: Path + @Test fun testEquals() { val ctor = simpleClass.constructors.first() @@ -1206,7 +1212,7 @@ class KotlinCodeGeneratorTest { } @Test - fun `import module`(@TempDir tempDir: Path) { + fun `import module`() { val library = PklModule( "library", @@ -1235,7 +1241,7 @@ class KotlinCodeGeneratorTest { .trimIndent() ) - val kotlinSourceFiles = generateKotlinFiles(tempDir, library, client) + val kotlinSourceFiles = generateFiles(library, client) val kotlinClientCode = kotlinSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.kt") }!!.value @@ -1253,7 +1259,7 @@ class KotlinCodeGeneratorTest { } @Test - fun `extend module`(@TempDir tempDir: Path) { + fun `extend module`() { val base = PklModule( "base", @@ -1282,7 +1288,7 @@ class KotlinCodeGeneratorTest { .trimIndent() ) - val kotlinSourceFiles = generateKotlinFiles(tempDir, base, derived) + val kotlinSourceFiles = generateFiles(base, derived) val kotlinDerivedCode = kotlinSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.kt") }!!.value @@ -1317,7 +1323,7 @@ class KotlinCodeGeneratorTest { } @Test - fun `extend module that only contains type aliases`(@TempDir tempDir: Path) { + fun `extend module that only contains type aliases`() { val moduleOne = PklModule( "base", @@ -1342,7 +1348,7 @@ class KotlinCodeGeneratorTest { .trimIndent() ) - val kotlinSourceFiles = generateKotlinFiles(tempDir, moduleOne, moduleTwo) + val kotlinSourceFiles = generateFiles(moduleOne, moduleTwo) val kotlinDerivedCode = kotlinSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.kt") }!!.value @@ -1359,7 +1365,7 @@ class KotlinCodeGeneratorTest { } @Test - fun `generated properties files`(@TempDir tempDir: Path) { + fun `generated properties files`() { val pklModule = PklModule( "Mod.pkl", @@ -1380,17 +1386,17 @@ class KotlinCodeGeneratorTest { """ .trimIndent() ) - val generated = generateFiles(tempDir, pklModule) + val generated = generateFiles(pklModule) val expectedPropertyFile = "resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties" assertThat(generated).containsKey(expectedPropertyFile) val propertyFileContents = generated[expectedPropertyFile]!! assertThat(propertyFileContents) - .contains("org.pkl.config.java.mapper.org.pkl.Mod\\#ModuleClass=org.pkl.Mod") - assertThat(propertyFileContents) - .contains("org.pkl.config.java.mapper.org.pkl.Mod\\#Foo=org.pkl.Mod\$Foo") - assertThat(propertyFileContents) - .contains("org.pkl.config.java.mapper.org.pkl.Mod\\#Bar=org.pkl.Mod\$Bar") + .contains( + "org.pkl.config.java.mapper.org.pkl.Mod\\#ModuleClass=org.pkl.Mod", + "org.pkl.config.java.mapper.org.pkl.Mod\\#Foo=org.pkl.Mod\$Foo", + "org.pkl.config.java.mapper.org.pkl.Mod\\#Bar=org.pkl.Mod\$Bar" + ) } @Test @@ -1502,45 +1508,253 @@ class KotlinCodeGeneratorTest { } @Test - fun `encoded file paths`(@TempDir path: Path) { + fun `encoded file paths`() { val kotlinCode = - generateKotlinFiles( - path, + generateFiles( PklModule( "FooBar.pkl", """ - module `Foo*Bar` - - someProp: String - """ + module `Foo*Bar` + + someProp: String + """ .trimIndent() ) ) assertThat(kotlinCode).containsKey("kotlin/Foo(2a)Bar.kt") } - private fun generateFiles(tempDir: Path, vararg pklModules: PklModule): Map { - val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } - val evaluator = Evaluator.preconfigured() - return pklFiles.fold(mapOf()) { acc, pklFile -> - val pklSchema = evaluator.evaluateSchema(ModuleSource.path(pklFile)) - acc + KotlinCodeGenerator(pklSchema, KotlinCodegenOptions()).output - } + @Test + fun `override names in a standalone module`() { + val files = + KotlinCodegenOptions( + renames = mapOf("a.b.c" to "x.y.z", "d.e.f.AnotherModule" to "u.v.w.RenamedModule") + ) + .generateFiles( + "MyModule.pkl" to + """ + module a.b.c.MyModule + + foo: String = "abc" + """ + .trimIndent(), + "AnotherModule.pkl" to + """ + module d.e.f.AnotherModule + + bar: Int = 123 + """ + .trimIndent() + ) + .toMutableMap() + + files.validateContents( + "kotlin/x/y/z/MyModule.kt" to listOf("package x.y.z", "data class MyModule("), + "$MAPPER_PREFIX/a.b.c.MyModule.properties" to + listOf("org.pkl.config.java.mapper.a.b.c.MyModule\\#ModuleClass=x.y.z.MyModule"), + // --- + "kotlin/u/v/w/RenamedModule.kt" to listOf("package u.v.w", "data class RenamedModule("), + "$MAPPER_PREFIX/d.e.f.AnotherModule.properties" to + listOf("org.pkl.config.java.mapper.d.e.f.AnotherModule\\#ModuleClass=u.v.w.RenamedModule"), + ) } - private fun generateKotlinFiles( - tempDir: Path, + @Test + fun `override names based on the longest prefix`() { + val files = + KotlinCodegenOptions( + renames = mapOf("com.foo.bar." to "x.", "com.foo." to "y.", "com." to "z.", "" to "w.") + ) + .generateFiles( + "com/foo/bar/Module1" to + """ + module com.foo.bar.Module1 + + bar: String + """ + .trimIndent(), + "com/Module2" to + """ + module com.Module2 + + com: String + """ + .trimIndent(), + "org/baz/Module3" to + """ + module org.baz.Module3 + + baz: String + """ + .trimIndent() + ) + .toMutableMap() + + files.validateContents( + "kotlin/x/Module1.kt" to listOf("package x", "data class Module1("), + "$MAPPER_PREFIX/com.foo.bar.Module1.properties" to + listOf("org.pkl.config.java.mapper.com.foo.bar.Module1\\#ModuleClass=x.Module1"), + // --- + "kotlin/z/Module2.kt" to listOf("package z", "data class Module2("), + "$MAPPER_PREFIX/com.Module2.properties" to + listOf("org.pkl.config.java.mapper.com.Module2\\#ModuleClass=z.Module2"), + // --- + "kotlin/w/org/baz/Module3.kt" to listOf("package w.org.baz", "data class Module3("), + "$MAPPER_PREFIX/org.baz.Module3.properties" to + listOf("org.pkl.config.java.mapper.org.baz.Module3\\#ModuleClass=w.org.baz.Module3") + ) + } + + @Test + fun `override names in multiple modules using each other`() { + val files = + KotlinCodegenOptions( + renames = + mapOf( + "org.foo" to "com.foo.x", + "org.bar.Module2" to "org.bar.RenamedModule", + "org.baz" to "com.baz.a.b" + ) + ) + .generateFiles( + "org/foo/Module1" to + """ + module org.foo.Module1 + + class Person { + name: String + } + """ + .trimIndent(), + "org/bar/Module2" to + """ + module org.bar.Module2 + + import "../../org/foo/Module1.pkl" + + class Group { + owner: Module1.Person + name: String + } + """ + .trimIndent(), + "org/baz/Module3" to + """ + module org.baz.Module3 + + import "../../org/bar/Module2.pkl" + + class Supergroup { + owner: Module2.Group + } + """ + .trimIndent() + ) + + files.validateContents( + "kotlin/com/foo/x/Module1.kt" to + listOf("package com.foo.x", "object Module1 {", "data class Person("), + "$MAPPER_PREFIX/org.foo.Module1.properties" to + listOf( + "org.pkl.config.java.mapper.org.foo.Module1\\#ModuleClass=com.foo.x.Module1", + "org.pkl.config.java.mapper.org.foo.Module1\\#Person=com.foo.x.Module1${'$'}Person", + ), + // --- + "kotlin/org/bar/RenamedModule.kt" to + listOf( + "package org.bar", + "import com.foo.x.Module1", + "object RenamedModule {", + "val owner: Module1.Person" + ), + "$MAPPER_PREFIX/org.bar.Module2.properties" to + listOf( + "org.pkl.config.java.mapper.org.bar.Module2\\#ModuleClass=org.bar.RenamedModule", + "org.pkl.config.java.mapper.org.bar.Module2\\#Group=org.bar.RenamedModule${'$'}Group", + ), + // --- + "kotlin/com/baz/a/b/Module3.kt" to + listOf( + "package com.baz.a.b", + "import org.bar.RenamedModule", + "object Module3 {", + "val owner: RenamedModule.Group" + ), + "$MAPPER_PREFIX/org.baz.Module3.properties" to + listOf( + "org.pkl.config.java.mapper.org.baz.Module3\\#ModuleClass=com.baz.a.b.Module3", + "org.pkl.config.java.mapper.org.baz.Module3\\#Supergroup=com.baz.a.b.Module3${'$'}Supergroup", + ), + ) + } + + @Test + fun `do not capitalize names of renamed classes`() { + val files = + KotlinCodegenOptions( + renames = mapOf("a.b.c.MyModule" to "x.y.z.renamed_module", "d.e.f." to "u.v.w.") + ) + .generateFiles( + "MyModule.pkl" to + """ + module a.b.c.MyModule + + foo: String = "abc" + """ + .trimIndent(), + "lower_module.pkl" to + """ + module d.e.f.lower_module + + bar: Int = 123 + """ + .trimIndent() + ) + + files.validateContents( + "kotlin/x/y/z/renamed_module.kt" to listOf("package x.y.z", "data class renamed_module("), + "$MAPPER_PREFIX/a.b.c.MyModule.properties" to + listOf("org.pkl.config.java.mapper.a.b.c.MyModule\\#ModuleClass=x.y.z.renamed_module"), + // --- + "kotlin/u/v/w/Lower_module.kt" to listOf("package u.v.w", "data class Lower_module("), + "$MAPPER_PREFIX/d.e.f.lower_module.properties" to + listOf("org.pkl.config.java.mapper.d.e.f.lower_module\\#ModuleClass=u.v.w.Lower_module"), + ) + } + + private fun Map.validateContents( + vararg assertions: kotlin.Pair> + ) { + val files = toMutableMap() + + for ((fileName, lines) in assertions) { + assertThat(files).containsKey(fileName) + assertThat(files.remove(fileName)).describedAs("Contents of $fileName").contains(lines) + } + + assertThat(files).isEmpty() + } + + private fun KotlinCodegenOptions.generateFiles( vararg pklModules: PklModule ): Map { val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } val evaluator = Evaluator.preconfigured() return pklFiles.fold(mapOf()) { acc, pklFile -> val pklSchema = evaluator.evaluateSchema(ModuleSource.path(pklFile)) - val generator = KotlinCodeGenerator(pklSchema, KotlinCodegenOptions()) - acc + arrayOf(generator.kotlinFileName to generator.kotlinFile) + val generator = KotlinCodeGenerator(pklSchema, this) + acc + generator.output } } + private fun KotlinCodegenOptions.generateFiles( + vararg pklModules: kotlin.Pair + ): Map = + generateFiles(*pklModules.map { (name, text) -> PklModule(name, text) }.toTypedArray()) + + private fun generateFiles(vararg pklModules: PklModule): Map = + KotlinCodegenOptions().generateFiles(*pklModules) + private fun instantiateOtherAndPropertyTypes(): kotlin.Pair { val otherCtor = propertyTypesClasses.getValue("Other").constructors.first() val other = otherCtor.call("pigeon") diff --git a/pkl-commons/src/main/kotlin/org/pkl/commons/NameMapper.kt b/pkl-commons/src/main/kotlin/org/pkl/commons/NameMapper.kt new file mode 100644 index 00000000..9377148c --- /dev/null +++ b/pkl-commons/src/main/kotlin/org/pkl/commons/NameMapper.kt @@ -0,0 +1,73 @@ +/** + * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.commons + +/** + * A helper class for translating names of Pkl modules to different names of classes and/or objects + * in the target language of a code generation execution. + * + * The `mapping` parameter is expected to contain valid prefixes of Pkl module names, with an + * optional dot at the end, and values should be valid class names in the language for which code + * generation is performed. + * + * If the rename patterns do not explicitly rename the class, the class name is capitalized. + * + * When computing the appropriate target name, the longest matching prefix is used. + * + * Prefix replacements are literal, and therefore dots are important. When renaming packages, in + * most cases, you must ensure that you have an ending dot on both sides of a mapping (except for + * the empty mapping, if you use it), otherwise you may get unexpected results: + * ```kotlin + * val mapper = NameMapper( + * mapOf( + * "com.foo." to "x", // Dot on the left only + * "org.bar" to "y.", // Dot on the right only + * "net.baz" to "z" // No dots + * ) + * ) + * + * assertThat(mapper.map("com.foo.bar")).isEqualTo("" to "xbar") // Target prefix merged into the suffix + * assertThat(mapper.map("org.bar.baz")).isEqualTo("y." to "Baz") // Double dot, invalid package name + * assertThat(mapper.map("net.baz.qux")).isEqualTo("z" to "Qux") // Looks okay, but... + * assertThat(mapper.map("net.bazqux")).isEqualTo("" to "zqux") // ...may cut the package name in the middle. + * ``` + */ +class NameMapper(mapping: Map) { + private val sortedMapping = mapping.toList().sortedBy { -it.first.length } + + private fun doMap(sourceName: String): Pair { + for ((sourcePrefix, targetPrefix) in sortedMapping) { + if (sourceName.startsWith(sourcePrefix)) { + val rest = sourceName.substring(sourcePrefix.length) + val mapped = targetPrefix + rest + val wasClassRenamed = + !targetPrefix.endsWith('.') && (sourcePrefix.length - 1) >= sourceName.lastIndexOf('.') + return mapped to wasClassRenamed + } + } + return sourceName to false + } + + fun map(sourceName: String): Pair { + val (mappedName, wasClassRenamed) = doMap(sourceName) + val packageName = mappedName.substringBeforeLast(".", "") + val mappedClassName = mappedName.substringAfterLast(".") + val className = + if (wasClassRenamed) mappedClassName + else mappedClassName.replaceFirstChar { it.titlecaseChar() } + return packageName to className + } +} diff --git a/pkl-commons/src/test/kotlin/org/pkl/commons/NameMapperTest.kt b/pkl-commons/src/test/kotlin/org/pkl/commons/NameMapperTest.kt new file mode 100644 index 00000000..6b2ff918 --- /dev/null +++ b/pkl-commons/src/test/kotlin/org/pkl/commons/NameMapperTest.kt @@ -0,0 +1,59 @@ +/** + * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.commons + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class NameMapperTest { + @Test + fun `empty prefixes everything`() { + val mapper = NameMapper(mapOf("" to "bar.")) + assertThat(mapper.map("foo.bar.Baz")).isEqualTo("bar.foo.bar" to "Baz") + assertThat(mapper.map("Baz")).isEqualTo("bar" to "Baz") + } + + @Test + fun `longest prefix wins`() { + val mapper = NameMapper(mapOf("bar." to "com.bar.", "bar.baz." to "foo.bar.")) + assertThat(mapper.map("bar.baz.Buzzy")).isEqualTo("foo.bar" to "Buzzy") + } + + @Test + fun `implicit uppercase classname`() { + val mapper = NameMapper(mapOf("foo." to "bar.")) + assertThat(mapper.map("foo.bar.baz")).isEqualTo("bar.bar" to "Baz") + assertThat(mapper.map("foo.bar")).isEqualTo("bar" to "Bar") + assertThat(mapper.map("baz")).isEqualTo("" to "Baz") + assertThat(mapper.map("baz")).isEqualTo("" to "Baz") + } + + @Test + fun `no implicit uppercased classname if explicitly renamed`() { + val mapper = + NameMapper( + mapOf( + "foo.bar" to "bar.bar", + "foo.c" to "foo.z", + "com.foo." to "x", + ) + ) + assertThat(mapper.map("foo.bar")).isEqualTo("bar" to "bar") + assertThat(mapper.map("foo.bar")).isEqualTo("bar" to "bar") + assertThat(mapper.map("foo.cow")).isEqualTo("foo" to "zow") + assertThat(mapper.map("com.foo.bar")).isEqualTo("" to "xbar") + } +} diff --git a/pkl-gradle/pkl-gradle.gradle.kts b/pkl-gradle/pkl-gradle.gradle.kts index 86dd0263..55a03fc8 100644 --- a/pkl-gradle/pkl-gradle.gradle.kts +++ b/pkl-gradle/pkl-gradle.gradle.kts @@ -26,7 +26,11 @@ dependencies { // // To debug shaded code in IntelliJ, temporarily remove the conditional. if (System.getProperty("idea.sync.active") == null) { - runtimeOnly(project(":pkl-tools", "fatJar")) + runtimeOnly(projects.pklTools) { + attributes { + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.SHADOWED)) + } + } } testImplementation(projects.pklCommonsTest) diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index e4d50fcd..91e58728 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -412,6 +412,7 @@ public class PklPlugin implements Plugin { task.getOutputDir().set(spec.getOutputDir()); task.getGenerateSpringBootConfig().set(spec.getGenerateSpringBootConfig()); task.getImplementSerializable().set(spec.getImplementSerializable()); + task.getPackageMapping().set(spec.getPackageMapping()); } private void configureBaseTask(T task, S spec) { diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java index b0b01651..bd180cb1 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java @@ -16,6 +16,7 @@ package org.pkl.gradle.spec; import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.SourceSet; @@ -30,4 +31,6 @@ public interface CodeGenSpec extends ModulesSpec { Property getGenerateSpringBootConfig(); Property getImplementSerializable(); + + MapProperty getPackageMapping(); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java index a9a98eba..92ca47ec 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java @@ -16,6 +16,7 @@ package org.pkl.gradle.task; import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.OutputDirectory; @@ -32,4 +33,7 @@ public abstract class CodeGenTask extends ModulesTask { @Input public abstract Property getImplementSerializable(); + + @Input + public abstract MapProperty getPackageMapping(); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java index f66c4735..5cffaad4 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java @@ -52,7 +52,8 @@ public abstract class JavaCodeGenTask extends CodeGenTask { getGenerateSpringBootConfig().get(), getParamsAnnotation().getOrNull(), getNonNullAnnotation().getOrNull(), - getImplementSerializable().get())) + getImplementSerializable().get(), + getPackageMapping().get())) .run(); } } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java index 240cbe6e..ecbad809 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java @@ -37,7 +37,8 @@ public abstract class KotlinCodeGenTask extends CodeGenTask { getIndent().get(), getGenerateKdoc().get(), getGenerateSpringBootConfig().get(), - getImplementSerializable().get())) + getImplementSerializable().get(), + getPackageMapping().get())) .run(); } } diff --git a/pkl-gradle/src/test/kotlin/org/pkl/gradle/JavaCodeGeneratorsTest.kt b/pkl-gradle/src/test/kotlin/org/pkl/gradle/JavaCodeGeneratorsTest.kt index 82dff307..65769889 100644 --- a/pkl-gradle/src/test/kotlin/org/pkl/gradle/JavaCodeGeneratorsTest.kt +++ b/pkl-gradle/src/test/kotlin/org/pkl/gradle/JavaCodeGeneratorsTest.kt @@ -13,7 +13,7 @@ class JavaCodeGeneratorsTest : AbstractTest() { runTask("configClasses") - val baseDir = testProjectDir.resolve("build/generated/java/org") + val baseDir = testProjectDir.resolve("build/generated/java/foo/bar") val moduleFile = baseDir.resolve("Mod.java") assertThat(baseDir.listDirectoryEntries().count()).isEqualTo(1) @@ -36,7 +36,7 @@ class JavaCodeGeneratorsTest : AbstractTest() { | public static final class Person { | public final @Nonnull String name; | - | public final @Nonnull List<@Nonnull Address> addresses; + | public final @Nonnull List
addresses; """ ) @@ -53,28 +53,14 @@ class JavaCodeGeneratorsTest : AbstractTest() { @Test fun `compile generated code`() { writeBuildFile() - writeFile("mod.pkl", """ - module org.mod - - class Person { - name: String - addresses: List - } - - class Address { - street: String - zip: Int - } - - other: Any = 42 - """.trimIndent()) + writePklFile() runTask("compileJava") val classesDir = testProjectDir.resolve("build/classes/java/main") - val moduleClassFile = classesDir.resolve("org/Mod.class") - val personClassFile = classesDir.resolve("org/Mod\$Person.class") - val addressClassFile = classesDir.resolve("org/Mod\$Address.class") + val moduleClassFile = classesDir.resolve("foo/bar/Mod.class") + val personClassFile = classesDir.resolve("foo/bar/Mod\$Person.class") + val addressClassFile = classesDir.resolve("foo/bar/Mod\$Address.class") assertThat(moduleClassFile).exists() assertThat(personClassFile).exists() assertThat(addressClassFile).exists() @@ -127,6 +113,9 @@ class JavaCodeGeneratorsTest : AbstractTest() { paramsAnnotation = "javax.inject.Named" nonNullAnnotation = "javax.annotation.Nonnull" settingsModule = "pkl:settings" + packageMapping = [ + 'org': 'foo.bar' + ] } } } @@ -134,18 +123,6 @@ class JavaCodeGeneratorsTest : AbstractTest() { ) } - private fun writeGradlePropertiesFile() { - writeFile("gradle.properties", """ - systemProp.http.proxyHost=proxy.config.pcp.local - systemProp.http.proxyPort=3128 - systemProp.http.nonProxyHosts=localhost|*.apple.com - - systemProp.https.proxyHost=proxy.config.pcp.local - systemProp.https.proxyPort=3128 - systemProp.https.nonProxyHosts=localhost|*.apple.com - """) - } - private fun writePklFile() { writeFile( "mod.pkl", """ @@ -153,7 +130,7 @@ class JavaCodeGeneratorsTest : AbstractTest() { class Person { name: String - addresses: List
+ addresses: List } class Address { diff --git a/pkl-gradle/src/test/kotlin/org/pkl/gradle/KotlinCodeGeneratorsTest.kt b/pkl-gradle/src/test/kotlin/org/pkl/gradle/KotlinCodeGeneratorsTest.kt index 3e563ff6..f487531c 100644 --- a/pkl-gradle/src/test/kotlin/org/pkl/gradle/KotlinCodeGeneratorsTest.kt +++ b/pkl-gradle/src/test/kotlin/org/pkl/gradle/KotlinCodeGeneratorsTest.kt @@ -13,7 +13,7 @@ class KotlinCodeGeneratorsTest : AbstractTest() { runTask("configClasses") - val baseDir = testProjectDir.resolve("build/generated/kotlin/org") + val baseDir = testProjectDir.resolve("build/generated/kotlin/foo/bar") val kotlinFile = baseDir.resolve("Mod.kt") assertThat(baseDir.listDirectoryEntries().count()).isEqualTo(1) @@ -58,9 +58,9 @@ class KotlinCodeGeneratorsTest : AbstractTest() { runTask("compileKotlin") val classesDir = testProjectDir.resolve("build/classes/kotlin/main") - val moduleClassFile = classesDir.resolve("org/Mod.class") - val personClassFile = classesDir.resolve("org/Mod\$Person.class") - val addressClassFile = classesDir.resolve("org/Mod\$Address.class") + val moduleClassFile = classesDir.resolve("foo/bar/Mod.class") + val personClassFile = classesDir.resolve("foo/bar/Mod\$Person.class") + val addressClassFile = classesDir.resolve("foo/bar/Mod\$Address.class") assertThat(moduleClassFile).exists() assertThat(personClassFile).exists() assertThat(addressClassFile).exists() @@ -125,6 +125,9 @@ class KotlinCodeGeneratorsTest : AbstractTest() { sourceModules = ["mod.pkl"] outputDir = file("build/generated") settingsModule = "pkl:settings" + packageMapping = [ + 'org.': 'foo.bar.' + ] } } }