Allow renaming Java/Kotlin classes/packages during code generation (#499)

Adds a `rename` field to the Java/Kotlin code generators that allows renaming packages and classes during codegen.

* Add `--rename` flag to CLIs
* Add `rename` property to Gradle API
This commit is contained in:
Vladimir Matveev
2024-06-12 15:43:43 -07:00
committed by GitHub
parent b03530ed1f
commit d7a1778199
25 changed files with 1099 additions and 157 deletions
@@ -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. 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: Common code generator options:
include::{partialsdir}/cli-codegen-options.adoc[] include::{partialsdir}/cli-codegen-options.adoc[]
@@ -21,3 +21,57 @@ Relative paths are resolved against the working directory.
Default: (not set) + Default: (not set) +
Flag that indicates to generate config classes for use with Spring Boot. 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.
====
@@ -36,4 +36,54 @@ Example: `generateSpringBootConfig = true` +
Whether to generate config classes for use with Spring Boot. Whether to generate config classes for use with Spring Boot.
==== ====
.packageMapping: MapProperty<String, String>
[%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) // TODO: fixme (implementSerializable)
@@ -54,7 +54,16 @@ data class CliJavaCodeGeneratorOptions(
val nonNullAnnotation: String? = null, val nonNullAnnotation: String? = null,
/** Whether to make generated classes implement [java.io.Serializable] */ /** 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<String, String> = emptyMap()
) { ) {
fun toJavaCodegenOptions() = fun toJavaCodegenOptions() =
JavaCodegenOptions( JavaCodegenOptions(
@@ -64,6 +73,7 @@ data class CliJavaCodeGeneratorOptions(
generateSpringBootConfig, generateSpringBootConfig,
paramsAnnotation, paramsAnnotation,
nonNullAnnotation, nonNullAnnotation,
implementSerializable implementSerializable,
renames
) )
} }
@@ -34,8 +34,10 @@ import kotlin.apply
import kotlin.let import kotlin.let
import kotlin.takeIf import kotlin.takeIf
import kotlin.to import kotlin.to
import org.pkl.commons.NameMapper
import org.pkl.core.* import org.pkl.core.*
import org.pkl.core.util.CodeGeneratorUtils import org.pkl.core.util.CodeGeneratorUtils
import org.pkl.core.util.IoUtils
class JavaCodeGeneratorException(message: String) : RuntimeException(message) class JavaCodeGeneratorException(message: String) : RuntimeException(message)
@@ -68,7 +70,15 @@ data class JavaCodegenOptions(
val nonNullAnnotation: String? = null, val nonNullAnnotation: String? = null,
/** Whether to make generated classes implement [java.io.Serializable] */ /** 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<String, String> = emptyMap()
) )
/** Entrypoint for the Java code generator API. */ /** Entrypoint for the Java code generator API. */
@@ -123,7 +133,8 @@ class JavaCodeGenerator(
} }
private val propertyFileName: String 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 private val propertiesFile: String
get() { get() {
@@ -150,8 +161,16 @@ class JavaCodeGenerator(
return AnnotationSpec.builder(className).build() return AnnotationSpec.builder(className).build()
} }
val javaFileName: String private val javaFileName: String
get() = relativeOutputPathFor(schema.moduleName) 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 val javaFile: String
get() { get() {
@@ -183,9 +202,7 @@ class JavaCodeGenerator(
moduleClass.addMethod(appendPropertyMethod().addModifiers(modifier).build()) moduleClass.addMethod(appendPropertyMethod().addModifiers(modifier).build())
} }
val moduleName = schema.moduleName val (packageName, _) = nameMapper.map(schema.moduleName)
val index = moduleName.lastIndexOf(".")
val packageName = if (index == -1) "" else moduleName.substring(0, index)
return JavaFile.builder(packageName, moduleClass.build()) return JavaFile.builder(packageName, moduleClass.build())
.indent(codegenOptions.indent) .indent(codegenOptions.indent)
@@ -193,20 +210,7 @@ class JavaCodeGenerator(
.toString() .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 { private fun generateTypeSpec(pClass: PClass, schema: ModuleSchema): TypeSpec.Builder {
val isModuleClass = pClass == schema.moduleClass val isModuleClass = pClass == schema.moduleClass
val javaPoetClassName = pClass.toJavaPoetName() val javaPoetClassName = pClass.toJavaPoetName()
val superclass = val superclass =
@@ -687,9 +691,7 @@ class JavaCodeGenerator(
.endControlFlow() .endControlFlow()
private fun PClass.toJavaPoetName(): ClassName { private fun PClass.toJavaPoetName(): ClassName {
val index = moduleName.lastIndexOf(".") val (packageName, moduleClassName) = nameMapper.map(moduleName)
val packageName = if (index == -1) "" else moduleName.substring(0, index)
val moduleClassName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() }
return if (isModuleClass) { return if (isModuleClass) {
ClassName.get(packageName, moduleClassName) ClassName.get(packageName, moduleClassName)
} else { } else {
@@ -699,9 +701,7 @@ class JavaCodeGenerator(
// generated type is a nested enum class // generated type is a nested enum class
private fun TypeAlias.toJavaPoetName(): ClassName { private fun TypeAlias.toJavaPoetName(): ClassName {
val index = moduleName.lastIndexOf(".") val (packageName, moduleClassName) = nameMapper.map(moduleName)
val packageName = if (index == -1) "" else moduleName.substring(0, index)
val moduleClassName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() }
return ClassName.get(packageName, moduleClassName, simpleName) return ClassName.get(packageName, moduleClassName, simpleName)
} }
@@ -872,6 +872,8 @@ class JavaCodeGenerator(
} else key } else key
} }
} }
private val nameMapper = NameMapper(codegenOptions.renames)
} }
internal val javaReservedWords = internal val javaReservedWords =
@@ -17,6 +17,7 @@
package org.pkl.codegen.java 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.default
import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.option
@@ -108,6 +109,21 @@ class PklJavaCodegenCommand :
) )
.flag() .flag()
private val renames: Map<String, String> by
option(
names = arrayOf("--rename"),
metavar = "<old_name=new_name>",
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() { override fun run() {
val options = val options =
CliJavaCodeGeneratorOptions( CliJavaCodeGeneratorOptions(
@@ -119,7 +135,8 @@ class PklJavaCodegenCommand :
generateSpringBootConfig = generateSpringboot, generateSpringBootConfig = generateSpringboot,
paramsAnnotation = paramsAnnotation, paramsAnnotation = paramsAnnotation,
nonNullAnnotation = nonNullAnnotation, nonNullAnnotation = nonNullAnnotation,
implementSerializable = implementSerializable implementSerializable = implementSerializable,
renames = renames
) )
CliJavaCodeGenerator(options).run() CliJavaCodeGenerator(options).run()
} }
@@ -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) { private fun assertContains(part: String, code: String) {
val trimmedPart = part.trim().trimMargin() val trimmedPart = part.trim().trimMargin()
if (!code.contains(trimmedPart)) { if (!code.contains(trimmedPart)) {
@@ -30,7 +30,9 @@ object InMemoryJavaCompiler {
val fileManager = val fileManager =
InMemoryFileManager(compiler.getStandardFileManager(diagnosticsCollector, null, null)) InMemoryFileManager(compiler.getStandardFileManager(diagnosticsCollector, null, null))
val sourceObjects = 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 task = compiler.getTask(null, fileManager, diagnosticsCollector, null, null, sourceObjects)
val result = task.call() val result = task.call()
if (!result) { if (!result) {
@@ -33,6 +33,8 @@ import org.pkl.core.util.IoUtils
class JavaCodeGeneratorTest { class JavaCodeGeneratorTest {
companion object { companion object {
const val MAPPER_PREFIX = "resources/META-INF/org/pkl/config/java/mapper/classes"
private val simpleClass by lazy { private val simpleClass by lazy {
compileJavaCode( compileJavaCode(
generateJavaCode( generateJavaCode(
@@ -101,7 +103,8 @@ class JavaCodeGeneratorTest {
generateJavadoc: Boolean = false, generateJavadoc: Boolean = false,
generateSpringBootConfig: Boolean = false, generateSpringBootConfig: Boolean = false,
nonNullAnnotation: String? = null, nonNullAnnotation: String? = null,
implementSerializable: Boolean = false implementSerializable: Boolean = false,
renames: Map<String, String> = emptyMap()
): String { ): String {
val module = Evaluator.preconfigured().evaluateSchema(text(pklCode)) val module = Evaluator.preconfigured().evaluateSchema(text(pklCode))
val generator = val generator =
@@ -113,6 +116,7 @@ class JavaCodeGeneratorTest {
generateSpringBootConfig = generateSpringBootConfig, generateSpringBootConfig = generateSpringBootConfig,
nonNullAnnotation = nonNullAnnotation, nonNullAnnotation = nonNullAnnotation,
implementSerializable = implementSerializable, implementSerializable = implementSerializable,
renames = renames
) )
) )
return generator.javaFile return generator.javaFile
@@ -127,6 +131,8 @@ class JavaCodeGeneratorTest {
} }
} }
@TempDir lateinit var tempDir: Path
@Test @Test
fun testEquals() { fun testEquals() {
val ctor = simpleClass.constructors.first() val ctor = simpleClass.constructors.first()
@@ -1420,7 +1426,7 @@ class JavaCodeGeneratorTest {
} }
@Test @Test
fun `import module`(@TempDir tempDir: Path) { fun `import module`() {
val library = val library =
PklModule( PklModule(
"library", "library",
@@ -1449,7 +1455,7 @@ class JavaCodeGeneratorTest {
.trimIndent() .trimIndent()
) )
val javaSourceFiles = generateJavaFiles(tempDir, library, client) val javaSourceFiles = generateFiles(library, client)
val javaClientCode = val javaClientCode =
javaSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.java") }!!.value javaSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.java") }!!.value
@@ -1467,7 +1473,7 @@ class JavaCodeGeneratorTest {
} }
@Test @Test
fun `extend module`(@TempDir tempDir: Path) { fun `extend module`() {
val base = val base =
PklModule( PklModule(
"base", "base",
@@ -1496,7 +1502,7 @@ class JavaCodeGeneratorTest {
.trimIndent() .trimIndent()
) )
val javaSourceFiles = generateJavaFiles(tempDir, base, derived) val javaSourceFiles = generateFiles(base, derived)
val javaDerivedCode = val javaDerivedCode =
javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value
@@ -1520,7 +1526,7 @@ class JavaCodeGeneratorTest {
} }
@Test @Test
fun `extend module that only contains type aliases`(@TempDir tempDir: Path) { fun `extend module that only contains type aliases`() {
val base = val base =
PklModule( PklModule(
"base", "base",
@@ -1545,7 +1551,7 @@ class JavaCodeGeneratorTest {
.trimIndent() .trimIndent()
) )
val javaSourceFiles = generateJavaFiles(tempDir, base, derived) val javaSourceFiles = generateFiles(base, derived)
val javaDerivedCode = val javaDerivedCode =
javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value
@@ -1561,7 +1567,7 @@ class JavaCodeGeneratorTest {
} }
@Test @Test
fun `generated properties files`(@TempDir tempDir: Path) { fun `generated properties files`() {
val pklModule = val pklModule =
PklModule( PklModule(
"Mod.pkl", "Mod.pkl",
@@ -1582,7 +1588,7 @@ class JavaCodeGeneratorTest {
""" """
.trimIndent() .trimIndent()
) )
val generated = generateFiles(tempDir, pklModule) val generated = generateFiles(pklModule)
val expectedPropertyFile = val expectedPropertyFile =
"resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties" "resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties"
assertThat(generated).containsKey(expectedPropertyFile) assertThat(generated).containsKey(expectedPropertyFile)
@@ -1596,7 +1602,7 @@ class JavaCodeGeneratorTest {
} }
@Test @Test
fun `generated properties files with normalized java name`(@TempDir tempDir: Path) { fun `generated properties files with normalized java name`() {
val pklModule = val pklModule =
PklModule( PklModule(
"mod.pkl", "mod.pkl",
@@ -1617,7 +1623,7 @@ class JavaCodeGeneratorTest {
""" """
.trimIndent() .trimIndent()
) )
val generated = generateFiles(tempDir, pklModule) val generated = generateFiles(pklModule)
val expectedPropertyFile = val expectedPropertyFile =
"resources/META-INF/org/pkl/config/java/mapper/classes/my.mod.properties" "resources/META-INF/org/pkl/config/java/mapper/classes/my.mod.properties"
assertThat(generated).containsKey(expectedPropertyFile) assertThat(generated).containsKey(expectedPropertyFile)
@@ -1824,24 +1830,235 @@ class JavaCodeGeneratorTest {
assertCompilesSuccessfully(javaCode) assertCompilesSuccessfully(javaCode)
} }
private fun generateFiles(tempDir: Path, vararg pklModules: PklModule): Map<String, String> { @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<String, String>.validateContents(
vararg assertions: kotlin.Pair<String, List<String>>
) {
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<String, String> {
val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) }
val evaluator = Evaluator.preconfigured() val evaluator = Evaluator.preconfigured()
return pklFiles.fold(mapOf()) { acc, pklFile -> return pklFiles.fold(mapOf()) { acc, pklFile ->
val pklSchema = evaluator.evaluateSchema(path(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<String, String> { private fun JavaCodegenOptions.generateFiles(
val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } vararg pklModules: kotlin.Pair<String, String>
val evaluator = Evaluator.preconfigured() ): Map<String, String> =
return pklFiles.fold(mapOf()) { acc, pklFile -> generateFiles(*pklModules.map { (name, text) -> PklModule(name, text) }.toTypedArray())
val pklSchema = evaluator.evaluateSchema(path(pklFile))
val generator = JavaCodeGenerator(pklSchema, JavaCodegenOptions()) private fun generateFiles(vararg pklModules: PklModule): Map<String, String> =
acc + arrayOf(generator.javaFileName to generator.javaFile) JavaCodegenOptions().generateFiles(*pklModules)
}
}
private fun instantiateOtherAndPropertyTypes(): kotlin.Pair<Any, Any> { private fun instantiateOtherAndPropertyTypes(): kotlin.Pair<Any, Any> {
val otherCtor = propertyTypesClasses.getValue("my.Mod\$Other").constructors.first() val otherCtor = propertyTypesClasses.getValue("my.Mod\$Other").constructors.first()
@@ -36,8 +36,23 @@ data class CliKotlinCodeGeneratorOptions(
val generateSpringBootConfig: Boolean = false, val generateSpringBootConfig: Boolean = false,
/** Whether to make generated classes implement [java.io.Serializable] */ /** 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<String, String> = emptyMap()
) { ) {
fun toKotlinCodegenOptions(): KotlinCodegenOptions = fun toKotlinCodegenOptions(): KotlinCodegenOptions =
KotlinCodegenOptions(indent, generateKdoc, generateSpringBootConfig, implementSerializable) KotlinCodegenOptions(
indent,
generateKdoc,
generateSpringBootConfig,
implementSerializable,
renames
)
} }
@@ -20,6 +20,7 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import java.io.StringWriter import java.io.StringWriter
import java.net.URI import java.net.URI
import java.util.* import java.util.*
import org.pkl.commons.NameMapper
import org.pkl.core.* import org.pkl.core.*
import org.pkl.core.util.CodeGeneratorUtils import org.pkl.core.util.CodeGeneratorUtils
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
@@ -35,7 +36,15 @@ data class KotlinCodegenOptions(
val generateSpringBootConfig: Boolean = false, val generateSpringBootConfig: Boolean = false,
/** Whether to make generated classes implement [java.io.Serializable] */ /** 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<String, String> = emptyMap(),
) )
class KotlinCodeGeneratorException(message: String) : RuntimeException(message) class KotlinCodeGeneratorException(message: String) : RuntimeException(message)
@@ -106,10 +115,17 @@ class KotlinCodeGenerator(
.toString() .toString()
} }
val kotlinFileName: String private val kotlinFileName: String
get() = buildString { 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("kot")
append("lin/${relativeOutputPathFor(moduleSchema.moduleName)}") append("lin/")
if (dirPath.isNotEmpty()) {
append("$dirPath/")
}
append("$fileName.kt")
} }
val kotlinFile: String val kotlinFile: String
@@ -169,9 +185,8 @@ class KotlinCodeGenerator(
} }
val moduleName = moduleSchema.moduleName val moduleName = moduleSchema.moduleName
val index = moduleName.lastIndexOf(".")
val packageName = if (index == -1) "" else moduleName.substring(0, index) val (packageName, moduleTypeName) = nameMapper.map(moduleName)
val moduleTypeName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() }
val fileSpec = FileSpec.builder(packageName, moduleTypeName).indent(options.indent) val fileSpec = FileSpec.builder(packageName, moduleTypeName).indent(options.indent)
@@ -195,17 +210,6 @@ class KotlinCodeGenerator(
return fileSpec.build().toString() 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 { private fun generateObjectSpec(pClass: PClass): TypeSpec.Builder {
val builder = TypeSpec.objectBuilder(pClass.toKotlinPoetName()) val builder = TypeSpec.objectBuilder(pClass.toKotlinPoetName())
val docComment = pClass.docComment val docComment = pClass.docComment
@@ -636,9 +640,7 @@ class KotlinCodeGenerator(
.endControlFlow() .endControlFlow()
private fun PClass.toKotlinPoetName(): ClassName { private fun PClass.toKotlinPoetName(): ClassName {
val index = moduleName.lastIndexOf(".") val (packageName, moduleTypeName) = nameMapper.map(moduleName)
val packageName = if (index == -1) "" else moduleName.substring(0, index)
val moduleTypeName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() }
return if (isModuleClass) { return if (isModuleClass) {
ClassName(packageName, moduleTypeName) ClassName(packageName, moduleTypeName)
} else { } else {
@@ -647,8 +649,7 @@ class KotlinCodeGenerator(
} }
private fun TypeAlias.toKotlinPoetName(): ClassName { private fun TypeAlias.toKotlinPoetName(): ClassName {
val index = moduleName.lastIndexOf(".") val (packageName, moduleTypeName) = nameMapper.map(moduleName)
val packageName = if (index == -1) "" else moduleName.substring(0, index)
return when { return when {
aliasedType is PType.Alias -> { aliasedType is PType.Alias -> {
@@ -663,7 +664,6 @@ class KotlinCodeGenerator(
) )
} }
// Kotlin type generated for [this] is a nested enum class // Kotlin type generated for [this] is a nested enum class
val moduleTypeName = moduleName.substring(index + 1).replaceFirstChar { it.titlecaseChar() }
ClassName(packageName, moduleTypeName, simpleName) ClassName(packageName, moduleTypeName, simpleName)
} }
else -> { else -> {
@@ -786,4 +786,6 @@ class KotlinCodeGenerator(
private fun List<PType>.toKotlinPoet(): Array<TypeName> = private fun List<PType>.toKotlinPoet(): Array<TypeName> =
map { it.toKotlinPoetName() }.toTypedArray() map { it.toKotlinPoetName() }.toTypedArray()
private val nameMapper = NameMapper(options.renames)
} }
@@ -17,6 +17,7 @@
package org.pkl.codegen.kotlin 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.default
import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.option
@@ -81,6 +82,21 @@ class PklKotlinCodegenCommand :
) )
.flag() .flag()
private val renames: Map<String, String> by
option(
names = arrayOf("--rename"),
metavar = "<old_name=new_name>",
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() { override fun run() {
val options = val options =
CliKotlinCodeGeneratorOptions( CliKotlinCodeGeneratorOptions(
@@ -89,7 +105,8 @@ class PklKotlinCodegenCommand :
indent = indent, indent = indent,
generateKdoc = generateKdoc, generateKdoc = generateKdoc,
generateSpringBootConfig = generateSpringboot, generateSpringBootConfig = generateSpringboot,
implementSerializable = implementSerializable implementSerializable = implementSerializable,
renames = renames
) )
CliKotlinCodeGenerator(options).run() CliKotlinCodeGenerator(options).run()
} }
@@ -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) { private fun assertContains(part: String, code: String) {
val trimmedPart = part.trim().trimMargin() val trimmedPart = part.trim().trimMargin()
if (!code.contains(trimmedPart)) { if (!code.contains(trimmedPart)) {
@@ -56,6 +56,7 @@ object InMemoryKotlinCompiler {
val (importLines, remainder) = val (importLines, remainder) =
sourceFiles.entries sourceFiles.entries
.filter { (filename, _) -> filename.endsWith(".kt") }
.flatMap { (_, text) -> text.lines() } .flatMap { (_, text) -> text.lines() }
.partition { it.startsWith("import") } .partition { it.startsWith("import") }
val importBlock = importLines.sorted().distinct() val importBlock = importLines.sorted().distinct()
@@ -32,6 +32,8 @@ import org.pkl.core.util.IoUtils
class KotlinCodeGeneratorTest { class KotlinCodeGeneratorTest {
companion object { companion object {
const val MAPPER_PREFIX = "resources/META-INF/org/pkl/config/java/mapper/classes"
// according to: // according to:
// https://github.com/JetBrains/kotlin/blob/master/core/descriptors/ // https://github.com/JetBrains/kotlin/blob/master/core/descriptors/
// src/org/jetbrains/kotlin/renderer/KeywordStringsGenerated.java // src/org/jetbrains/kotlin/renderer/KeywordStringsGenerated.java
@@ -153,9 +155,13 @@ class KotlinCodeGeneratorTest {
private fun compileKotlinCode(kotlinCode: String): Map<String, KClass<*>> = private fun compileKotlinCode(kotlinCode: String): Map<String, KClass<*>> =
InMemoryKotlinCompiler.compile(mapOf("my/Mod.kt" to kotlinCode)) 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 @Test
fun testEquals() { fun testEquals() {
val ctor = simpleClass.constructors.first() val ctor = simpleClass.constructors.first()
@@ -1206,7 +1212,7 @@ class KotlinCodeGeneratorTest {
} }
@Test @Test
fun `import module`(@TempDir tempDir: Path) { fun `import module`() {
val library = val library =
PklModule( PklModule(
"library", "library",
@@ -1235,7 +1241,7 @@ class KotlinCodeGeneratorTest {
.trimIndent() .trimIndent()
) )
val kotlinSourceFiles = generateKotlinFiles(tempDir, library, client) val kotlinSourceFiles = generateFiles(library, client)
val kotlinClientCode = val kotlinClientCode =
kotlinSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.kt") }!!.value kotlinSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.kt") }!!.value
@@ -1253,7 +1259,7 @@ class KotlinCodeGeneratorTest {
} }
@Test @Test
fun `extend module`(@TempDir tempDir: Path) { fun `extend module`() {
val base = val base =
PklModule( PklModule(
"base", "base",
@@ -1282,7 +1288,7 @@ class KotlinCodeGeneratorTest {
.trimIndent() .trimIndent()
) )
val kotlinSourceFiles = generateKotlinFiles(tempDir, base, derived) val kotlinSourceFiles = generateFiles(base, derived)
val kotlinDerivedCode = val kotlinDerivedCode =
kotlinSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.kt") }!!.value kotlinSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.kt") }!!.value
@@ -1317,7 +1323,7 @@ class KotlinCodeGeneratorTest {
} }
@Test @Test
fun `extend module that only contains type aliases`(@TempDir tempDir: Path) { fun `extend module that only contains type aliases`() {
val moduleOne = val moduleOne =
PklModule( PklModule(
"base", "base",
@@ -1342,7 +1348,7 @@ class KotlinCodeGeneratorTest {
.trimIndent() .trimIndent()
) )
val kotlinSourceFiles = generateKotlinFiles(tempDir, moduleOne, moduleTwo) val kotlinSourceFiles = generateFiles(moduleOne, moduleTwo)
val kotlinDerivedCode = val kotlinDerivedCode =
kotlinSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.kt") }!!.value kotlinSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.kt") }!!.value
@@ -1359,7 +1365,7 @@ class KotlinCodeGeneratorTest {
} }
@Test @Test
fun `generated properties files`(@TempDir tempDir: Path) { fun `generated properties files`() {
val pklModule = val pklModule =
PklModule( PklModule(
"Mod.pkl", "Mod.pkl",
@@ -1380,17 +1386,17 @@ class KotlinCodeGeneratorTest {
""" """
.trimIndent() .trimIndent()
) )
val generated = generateFiles(tempDir, pklModule) val generated = generateFiles(pklModule)
val expectedPropertyFile = val expectedPropertyFile =
"resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties" "resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties"
assertThat(generated).containsKey(expectedPropertyFile) assertThat(generated).containsKey(expectedPropertyFile)
val propertyFileContents = generated[expectedPropertyFile]!! val propertyFileContents = generated[expectedPropertyFile]!!
assertThat(propertyFileContents) assertThat(propertyFileContents)
.contains("org.pkl.config.java.mapper.org.pkl.Mod\\#ModuleClass=org.pkl.Mod") .contains(
assertThat(propertyFileContents) "org.pkl.config.java.mapper.org.pkl.Mod\\#ModuleClass=org.pkl.Mod",
.contains("org.pkl.config.java.mapper.org.pkl.Mod\\#Foo=org.pkl.Mod\$Foo") "org.pkl.config.java.mapper.org.pkl.Mod\\#Foo=org.pkl.Mod\$Foo",
assertThat(propertyFileContents) "org.pkl.config.java.mapper.org.pkl.Mod\\#Bar=org.pkl.Mod\$Bar"
.contains("org.pkl.config.java.mapper.org.pkl.Mod\\#Bar=org.pkl.Mod\$Bar") )
} }
@Test @Test
@@ -1502,45 +1508,253 @@ class KotlinCodeGeneratorTest {
} }
@Test @Test
fun `encoded file paths`(@TempDir path: Path) { fun `encoded file paths`() {
val kotlinCode = val kotlinCode =
generateKotlinFiles( generateFiles(
path,
PklModule( PklModule(
"FooBar.pkl", "FooBar.pkl",
""" """
module `Foo*Bar` module `Foo*Bar`
someProp: String someProp: String
""" """
.trimIndent() .trimIndent()
) )
) )
assertThat(kotlinCode).containsKey("kotlin/Foo(2a)Bar.kt") assertThat(kotlinCode).containsKey("kotlin/Foo(2a)Bar.kt")
} }
private fun generateFiles(tempDir: Path, vararg pklModules: PklModule): Map<String, String> { @Test
val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } fun `override names in a standalone module`() {
val evaluator = Evaluator.preconfigured() val files =
return pklFiles.fold(mapOf()) { acc, pklFile -> KotlinCodegenOptions(
val pklSchema = evaluator.evaluateSchema(ModuleSource.path(pklFile)) renames = mapOf("a.b.c" to "x.y.z", "d.e.f.AnotherModule" to "u.v.w.RenamedModule")
acc + KotlinCodeGenerator(pklSchema, KotlinCodegenOptions()).output )
} .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( @Test
tempDir: Path, 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<String, String>.validateContents(
vararg assertions: kotlin.Pair<String, List<String>>
) {
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 vararg pklModules: PklModule
): Map<String, String> { ): Map<String, String> {
val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) } val pklFiles = pklModules.map { it.writeToDisk(tempDir.resolve("pkl/${it.name}.pkl")) }
val evaluator = Evaluator.preconfigured() val evaluator = Evaluator.preconfigured()
return pklFiles.fold(mapOf()) { acc, pklFile -> return pklFiles.fold(mapOf()) { acc, pklFile ->
val pklSchema = evaluator.evaluateSchema(ModuleSource.path(pklFile)) val pklSchema = evaluator.evaluateSchema(ModuleSource.path(pklFile))
val generator = KotlinCodeGenerator(pklSchema, KotlinCodegenOptions()) val generator = KotlinCodeGenerator(pklSchema, this)
acc + arrayOf(generator.kotlinFileName to generator.kotlinFile) acc + generator.output
} }
} }
private fun KotlinCodegenOptions.generateFiles(
vararg pklModules: kotlin.Pair<String, String>
): Map<String, String> =
generateFiles(*pklModules.map { (name, text) -> PklModule(name, text) }.toTypedArray())
private fun generateFiles(vararg pklModules: PklModule): Map<String, String> =
KotlinCodegenOptions().generateFiles(*pklModules)
private fun instantiateOtherAndPropertyTypes(): kotlin.Pair<Any, Any> { private fun instantiateOtherAndPropertyTypes(): kotlin.Pair<Any, Any> {
val otherCtor = propertyTypesClasses.getValue("Other").constructors.first() val otherCtor = propertyTypesClasses.getValue("Other").constructors.first()
val other = otherCtor.call("pigeon") val other = otherCtor.call("pigeon")
@@ -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<String, String>) {
private val sortedMapping = mapping.toList().sortedBy { -it.first.length }
private fun doMap(sourceName: String): Pair<String, Boolean> {
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<String, String> {
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
}
}
@@ -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")
}
}
+5 -1
View File
@@ -26,7 +26,11 @@ dependencies {
// //
// To debug shaded code in IntelliJ, temporarily remove the conditional. // To debug shaded code in IntelliJ, temporarily remove the conditional.
if (System.getProperty("idea.sync.active") == null) { 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) testImplementation(projects.pklCommonsTest)
@@ -412,6 +412,7 @@ public class PklPlugin implements Plugin<Project> {
task.getOutputDir().set(spec.getOutputDir()); task.getOutputDir().set(spec.getOutputDir());
task.getGenerateSpringBootConfig().set(spec.getGenerateSpringBootConfig()); task.getGenerateSpringBootConfig().set(spec.getGenerateSpringBootConfig());
task.getImplementSerializable().set(spec.getImplementSerializable()); task.getImplementSerializable().set(spec.getImplementSerializable());
task.getPackageMapping().set(spec.getPackageMapping());
} }
private <T extends BasePklTask, S extends BasePklSpec> void configureBaseTask(T task, S spec) { private <T extends BasePklTask, S extends BasePklSpec> void configureBaseTask(T task, S spec) {
@@ -16,6 +16,7 @@
package org.pkl.gradle.spec; package org.pkl.gradle.spec;
import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property; import org.gradle.api.provider.Property;
import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSet;
@@ -30,4 +31,6 @@ public interface CodeGenSpec extends ModulesSpec {
Property<Boolean> getGenerateSpringBootConfig(); Property<Boolean> getGenerateSpringBootConfig();
Property<Boolean> getImplementSerializable(); Property<Boolean> getImplementSerializable();
MapProperty<String, String> getPackageMapping();
} }
@@ -16,6 +16,7 @@
package org.pkl.gradle.task; package org.pkl.gradle.task;
import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property; import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.OutputDirectory;
@@ -32,4 +33,7 @@ public abstract class CodeGenTask extends ModulesTask {
@Input @Input
public abstract Property<Boolean> getImplementSerializable(); public abstract Property<Boolean> getImplementSerializable();
@Input
public abstract MapProperty<String, String> getPackageMapping();
} }
@@ -52,7 +52,8 @@ public abstract class JavaCodeGenTask extends CodeGenTask {
getGenerateSpringBootConfig().get(), getGenerateSpringBootConfig().get(),
getParamsAnnotation().getOrNull(), getParamsAnnotation().getOrNull(),
getNonNullAnnotation().getOrNull(), getNonNullAnnotation().getOrNull(),
getImplementSerializable().get())) getImplementSerializable().get(),
getPackageMapping().get()))
.run(); .run();
} }
} }
@@ -37,7 +37,8 @@ public abstract class KotlinCodeGenTask extends CodeGenTask {
getIndent().get(), getIndent().get(),
getGenerateKdoc().get(), getGenerateKdoc().get(),
getGenerateSpringBootConfig().get(), getGenerateSpringBootConfig().get(),
getImplementSerializable().get())) getImplementSerializable().get(),
getPackageMapping().get()))
.run(); .run();
} }
} }
@@ -13,7 +13,7 @@ class JavaCodeGeneratorsTest : AbstractTest() {
runTask("configClasses") 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") val moduleFile = baseDir.resolve("Mod.java")
assertThat(baseDir.listDirectoryEntries().count()).isEqualTo(1) assertThat(baseDir.listDirectoryEntries().count()).isEqualTo(1)
@@ -36,7 +36,7 @@ class JavaCodeGeneratorsTest : AbstractTest() {
| public static final class Person { | public static final class Person {
| public final @Nonnull String name; | public final @Nonnull String name;
| |
| public final @Nonnull List<@Nonnull Address> addresses; | public final @Nonnull List<Address> addresses;
""" """
) )
@@ -53,28 +53,14 @@ class JavaCodeGeneratorsTest : AbstractTest() {
@Test @Test
fun `compile generated code`() { fun `compile generated code`() {
writeBuildFile() writeBuildFile()
writeFile("mod.pkl", """ writePklFile()
module org.mod
class Person {
name: String
addresses: List<Address?>
}
class Address {
street: String
zip: Int
}
other: Any = 42
""".trimIndent())
runTask("compileJava") runTask("compileJava")
val classesDir = testProjectDir.resolve("build/classes/java/main") val classesDir = testProjectDir.resolve("build/classes/java/main")
val moduleClassFile = classesDir.resolve("org/Mod.class") val moduleClassFile = classesDir.resolve("foo/bar/Mod.class")
val personClassFile = classesDir.resolve("org/Mod\$Person.class") val personClassFile = classesDir.resolve("foo/bar/Mod\$Person.class")
val addressClassFile = classesDir.resolve("org/Mod\$Address.class") val addressClassFile = classesDir.resolve("foo/bar/Mod\$Address.class")
assertThat(moduleClassFile).exists() assertThat(moduleClassFile).exists()
assertThat(personClassFile).exists() assertThat(personClassFile).exists()
assertThat(addressClassFile).exists() assertThat(addressClassFile).exists()
@@ -127,6 +113,9 @@ class JavaCodeGeneratorsTest : AbstractTest() {
paramsAnnotation = "javax.inject.Named" paramsAnnotation = "javax.inject.Named"
nonNullAnnotation = "javax.annotation.Nonnull" nonNullAnnotation = "javax.annotation.Nonnull"
settingsModule = "pkl:settings" 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() { private fun writePklFile() {
writeFile( writeFile(
"mod.pkl", """ "mod.pkl", """
@@ -153,7 +130,7 @@ class JavaCodeGeneratorsTest : AbstractTest() {
class Person { class Person {
name: String name: String
addresses: List<Address> addresses: List<Address?>
} }
class Address { class Address {
@@ -13,7 +13,7 @@ class KotlinCodeGeneratorsTest : AbstractTest() {
runTask("configClasses") 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") val kotlinFile = baseDir.resolve("Mod.kt")
assertThat(baseDir.listDirectoryEntries().count()).isEqualTo(1) assertThat(baseDir.listDirectoryEntries().count()).isEqualTo(1)
@@ -58,9 +58,9 @@ class KotlinCodeGeneratorsTest : AbstractTest() {
runTask("compileKotlin") runTask("compileKotlin")
val classesDir = testProjectDir.resolve("build/classes/kotlin/main") val classesDir = testProjectDir.resolve("build/classes/kotlin/main")
val moduleClassFile = classesDir.resolve("org/Mod.class") val moduleClassFile = classesDir.resolve("foo/bar/Mod.class")
val personClassFile = classesDir.resolve("org/Mod\$Person.class") val personClassFile = classesDir.resolve("foo/bar/Mod\$Person.class")
val addressClassFile = classesDir.resolve("org/Mod\$Address.class") val addressClassFile = classesDir.resolve("foo/bar/Mod\$Address.class")
assertThat(moduleClassFile).exists() assertThat(moduleClassFile).exists()
assertThat(personClassFile).exists() assertThat(personClassFile).exists()
assertThat(addressClassFile).exists() assertThat(addressClassFile).exists()
@@ -125,6 +125,9 @@ class KotlinCodeGeneratorsTest : AbstractTest() {
sourceModules = ["mod.pkl"] sourceModules = ["mod.pkl"]
outputDir = file("build/generated") outputDir = file("build/generated")
settingsModule = "pkl:settings" settingsModule = "pkl:settings"
packageMapping = [
'org.': 'foo.bar.'
]
} }
} }
} }