From 3a3916ee5cdbb94eeef483a90ed6436e79e43c56 Mon Sep 17 00:00:00 2001 From: David Tesler Date: Wed, 19 Feb 2025 18:31:40 -0800 Subject: [PATCH 1/9] Java class and record generators and friends side-by-side 1. Java code generation via Java Records and 2. Wither implementation close to JEP 468 3. extra "generateRecords" option in Gradle JavaCodeGenTask 4. extra "--generate-records" option in pkl-codegen-java CLI 5. updated docs see pkl-codegen-java/README.md --- docs/modules/java-binding/pages/codegen.adoc | 14 +- docs/modules/pkl-gradle/pages/index.adoc | 10 + pkl-codegen-java/README.md | 157 + pkl-codegen-java/pkl-codegen-java.gradle.kts | 2 +- .../pkl/codegen/java/CliJavaCodeGenerator.kt | 15 +- .../java/CliJavaCodeGeneratorOptions.kt | 8 + .../org/pkl/codegen/java/JavaCodeGenerator.kt | 114 - .../codegen/java/JavaRecordCodeGenerator.kt | 973 +++++++ .../main/kotlin/org/pkl/codegen/java/Main.kt | 13 + .../pkl/codegen/java/JavaCodeGeneratorTest.kt | 2 + .../java/JavaRecordCodeGeneratorTest.kt | 2562 +++++++++++++++++ .../pkl/codegen/java/InheritanceRecord.jva | 96 + .../org/pkl/codegen/java/JavadocRecord.jva | 66 + .../pkl/codegen/java/PropertyTypesRecord.jva | 181 ++ pkl-config-java/pkl-config-java.gradle.kts | 31 +- ...ectToDataObjectOverriddenPropertyTest.java | 4 +- ...ectToDataObjectOverriddenPropertyTest.java | 39 + .../recordsCodegenPkl/OverriddenProperty.pkl | 28 + .../recordsCodegenPkl/PolymorphicLib.pkl | 14 + .../PolymorphicModuleTest.pkl | 24 + .../main/java/org/pkl/gradle/PklPlugin.java | 2 + .../org/pkl/gradle/spec/JavaCodeGenSpec.java | 4 +- .../org/pkl/gradle/task/JavaCodeGenTask.java | 9 +- .../kotlin/org/pkl/gradle/AbstractTest.kt | 6 +- .../org/pkl/gradle/JavaCodeGeneratorsTest.kt | 98 +- 25 files changed, 4342 insertions(+), 130 deletions(-) create mode 100644 pkl-codegen-java/README.md create mode 100644 pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaRecordCodeGenerator.kt create mode 100644 pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaRecordCodeGeneratorTest.kt create mode 100644 pkl-codegen-java/src/test/resources/org/pkl/codegen/java/InheritanceRecord.jva create mode 100644 pkl-codegen-java/src/test/resources/org/pkl/codegen/java/JavadocRecord.jva create mode 100644 pkl-codegen-java/src/test/resources/org/pkl/codegen/java/PropertyTypesRecord.jva create mode 100644 pkl-config-java/src/test/java/org/pkl/config/java/mapper/RecordPObjectToDataObjectOverriddenPropertyTest.java create mode 100644 pkl-config-java/src/test/resources/recordsCodegenPkl/OverriddenProperty.pkl create mode 100644 pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicLib.pkl create mode 100644 pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicModuleTest.pkl diff --git a/docs/modules/java-binding/pages/codegen.adoc b/docs/modules/java-binding/pages/codegen.adoc index a6b670cc8..053aaef09 100644 --- a/docs/modules/java-binding/pages/codegen.adoc +++ b/docs/modules/java-binding/pages/codegen.adoc @@ -10,7 +10,8 @@ The benefits of code generation are: * The entire configuration tree can be code-completed in Java IDEs. * Any drift between Java code and Pkl configuration structure is caught at compile time. -The generated classes are immutable and have component-wise implementations of `equals()`, `hashCode()`, and `toString()`. +The generated classes are immutable and have component-wise implementations of `equals()`, `hashCode()`, and `toString()`, +or can optionally be produced as Java Records, in case of `generateRecords=true`, delivering the same benefits. == Installation @@ -118,7 +119,8 @@ See xref:pkl-gradle:index.adoc#java-code-gen[Java Code Generation] in the Gradle === Java Library The Java library offers two APIs: a high-level API that corresponds to the CLI, and a lower-level API that provides additional features and control. -The entry points for these APIs are `org.pkl.codegen.java.CliJavaCodeGenerator` and `org.pkl.codegen.java.JavaCodeGenerator`, respectively. +The entry points for these APIs are `org.pkl.codegen.java.CliJavaCodeGenerator`, and `org.pkl.codegen.java.JavaCodeGenerator` or +`org.pkl.codegen.java.JavaRecordCodeGenerator`, respectively. For more information, refer to the Javadoc documentation. === CLI @@ -141,6 +143,14 @@ Default: (flag not set) + Flag that indicates to generate private final fields and public getter methods instead of public final fields. ==== +.--generate-records +[%collapsible] +==== +Default: (flag not set) + +Flag that indicates to generate Java records, the related interfaces, and JEP 468 like `withers`. +Overrides Java class generation option `--generate-getters`. +==== + .--generate-javadoc [%collapsible] ==== diff --git a/docs/modules/pkl-gradle/pages/index.adoc b/docs/modules/pkl-gradle/pages/index.adoc index 0b1066df3..76fcfbbb7 100644 --- a/docs/modules/pkl-gradle/pages/index.adoc +++ b/docs/modules/pkl-gradle/pages/index.adoc @@ -373,6 +373,16 @@ Example: `generateGetters = true` + Whether to generate private final fields and public getter methods rather than public final fields. ==== +.generateRecords: Property +[%collapsible] +==== +Default: `false` + +Example: `generateRecords = true` + +Whether to generate Java records, the related interfaces, and JEP 468 like `withers`. +If set to `true`, overrides Java class generation option `generateGetters`. + +==== + .paramsAnnotation: Property [%collapsible] ==== diff --git a/pkl-codegen-java/README.md b/pkl-codegen-java/README.md new file mode 100644 index 000000000..ec9bc7152 --- /dev/null +++ b/pkl-codegen-java/README.md @@ -0,0 +1,157 @@ +# Java Code Generation Specification: + +## Preamble + +1. The goal of the current spec is to change **only** Pkl classes production, leaving the rest of the generation intact +2. Each custom immutable Java class with the one-property withers to be replaced by: + 1. each Pkl abstract class would be generated as the corresponding Java interface, such that: + 1. each its declared public property would become the `interface`'s abstract method of the same `type` and `name + 2. the `interface` would implement the Pkl abstract class's superclass, if present + 2. each Pkl class, including modules, would be generated as the corresponding Java record, such that: + 1. `record`'s components are identical to the current custom Java class + 2. `record` would implement its Pkl superclass corresponding Java interface + 3. `record` would in addition implement the common generic `Wither` interface (in line with https://openjdk.org/jeps/468 which is not available yet) + 4. `record` would have its special `Memento` public static inner class generated as described below + 3. each Pkl `open` class would in addition have its default interface generated like in the case of a Pkl abstract class +3. The following would be generated as the singleton common constructs for all: +```java + +import java.util.function.Consumer; + +public interface Wither { + R with(Consumer setter); +} + +``` +4. The record `R`, its `Memento` would be generated as follows: +```java + +record R(String p1, String p2, String p3) implements Wither, Serializable { + + @Override + public R with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public String p1; + public String p2; + public String p3; + + private Memento(final R r) { + p1 = r.p1; + p2 = r.p2; + p3 = r.p3; + } + + private R build() { + return new R(p1, p2, p3); + } + } +} + +``` +5. The usage in the Java consumer code would be as follows: +```java + +class Scratch { + + public static void main(String[] args) { + final R r1 = new R("a", "b", "c"); + final R r2 = r1.with(it -> it.p1 = "a2").with(it -> { + it.p2 = "b2"; + it.p3 = "c2"; + }); + + System.out.println(r1); // R[p1=a, p2=b, p3=c] + System.out.println(r2); // R[p1=a2, p2=b2, p3=c2] + } +} + + +``` +6. Given Pkl is single inheritance and doesn't support creation or extension of generic classes, the above generation scheme should be sufficient and adequate. +7. As an extension API, the generation offers the option to expose empty base interface(-s) to be extended by the Java consumer as follows: + 1. one base interface implemented by all generated records as follows: + 1. `IPklBase` interface code would have to be implemented elsewhere in the Java consumer code, otherwise causing the compilation error + ```java + record R(/* component list of */) implements Wither, IPklBase { + // ... + } + ``` + 2. Most likely, `IPklBase` would have the default methods, thus effectively extending the functionality of all classes + 2. a base interface per record type, to be implemented elsewhere in the Java consumer code similar to above: + ```java + record R(/* component list of */) implements Wither, IR { + // ... + } + ``` +8. Serialization would be delegated to the Record API as follows: + 1. if requested in options, each generated Java record would in addition implement a Java `java.io.Serializable` + +> [!IMPORTANT] +> All the annotation, name handling regarding Java reserved words, and such would be handled as currently. + +
+ +See a complete example of Java code generation + +```java + +package com.apple.pkl.code.gen.java.example; + +import java.io.Serializable; +import java.util.function.Consumer; + +class Demo implements Serializable { + + public static void main(final String[] args) { + final R r1 = new R("a", "b", "c"); + final R r2 = r1.with(it -> it.p1 = "a2").with(it -> { + it.p2 = "b2"; + it.p3 = "c2"; + }); + + System.out.println(r1); + System.out.println(r2); + } +} + +//TODO: include as-is once +interface Wither { + R with(Consumer setter); +} + +//TODO: include per Pkl class +record R(String p1, String p2, String p3) implements Wither, Serializable { + + @Override + public R with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + + public String p1; + public String p2; + public String p3; + + private Memento(final R r) { + p1 = r.p1; + p2 = r.p2; + p3 = r.p3; + } + + private R build() { + return new R(p1, p2, p3); + } + } +} + +``` + +
diff --git a/pkl-codegen-java/pkl-codegen-java.gradle.kts b/pkl-codegen-java/pkl-codegen-java.gradle.kts index cb1b748cf..9df26debe 100644 --- a/pkl-codegen-java/pkl-codegen-java.gradle.kts +++ b/pkl-codegen-java/pkl-codegen-java.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2025 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. diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGenerator.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGenerator.kt index cc4d58fc8..c7ebfc5ce 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGenerator.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGenerator.kt @@ -30,12 +30,23 @@ class CliJavaCodeGenerator(private val options: CliJavaCodeGeneratorOptions) : override fun doRun() { val builder = evaluatorBuilder() try { + if (options.generateRecords) { + options.outputDir.resolve(JavaRecordCodeGenerator.commonCodePackageFile).apply { + createParentDirectories().writeString(JavaRecordCodeGenerator.generateCommonCode()) + } + } + builder.build().use { evaluator -> for (moduleUri in options.base.normalizedSourceModules) { val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri)) - val codeGenerator = JavaCodeGenerator(schema, options.toJavaCodeGeneratorOptions()) + + val output = + if (options.generateRecords) + JavaRecordCodeGenerator(schema, options.toJavaCodeGeneratorOptions()).output + else JavaCodeGenerator(schema, options.toJavaCodeGeneratorOptions()).output + try { - for ((fileName, fileContents) in codeGenerator.output) { + for ((fileName, fileContents) in output) { val outputFile = options.outputDir.resolve(fileName) try { outputFile.createParentDirectories().writeString(fileContents) 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 5735486e6..1843402a3 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 @@ -74,6 +74,13 @@ data class CliJavaCodeGeneratorOptions( * Pkl module name, and the value is the desired replacement. */ val renames: Map = emptyMap(), + + /** + * Whether to generate Java records, the related interfaces, and JEP 468 like withers. + * + * This overrides any Java class generation related options! + */ + val generateRecords: Boolean = false, ) { @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("deprecated without replacement") @@ -89,5 +96,6 @@ data class CliJavaCodeGeneratorOptions( nonNullAnnotation, implementSerializable, renames, + generateRecords, ) } 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 251c39a3b..a96bfa654 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 @@ -25,7 +25,6 @@ import kotlin.AssertionError import kotlin.Boolean import kotlin.Int import kotlin.Long -import kotlin.RuntimeException import kotlin.String import kotlin.Suppress import kotlin.Unit @@ -38,61 +37,6 @@ import org.pkl.core.* import org.pkl.core.util.CodeGeneratorUtils import org.pkl.core.util.IoUtils -class JavaCodeGeneratorException(message: String) : RuntimeException(message) - -@kotlin.Deprecated("renamed to JavaCodeGeneratorOptions", ReplaceWith("JavaCodeGeneratorOptions")) -typealias JavaCodegenOptions = JavaCodeGeneratorOptions - -data class JavaCodeGeneratorOptions( - /** The characters to use for indenting generated Java code. */ - val indent: String = " ", - - /** - * Whether to generate public getter methods and protected final fields instead of public final - * fields. - */ - val generateGetters: Boolean = false, - - /** Whether to preserve Pkl doc comments by generating corresponding Javadoc comments. */ - val generateJavadoc: Boolean = false, - - /** Whether to generate config classes for use with Spring Boot. */ - val generateSpringBootConfig: Boolean = false, - - /** - * Fully qualified name of the annotation type to use for annotating constructor parameters with - * their name. - * - * The specified annotation type must have a `value` parameter of type [java.lang.String] or the - * generated code may not compile. - * - * If set to `null`, constructor parameters are not annotated. The default value is `null` if - * [generateSpringBootConfig] is `true` and `"org.pkl.config.java.mapper.Named"` otherwise. - */ - val paramsAnnotation: String? = - if (generateSpringBootConfig) null else "org.pkl.config.java.mapper.Named", - - /** - * Fully qualified name of the annotation type to use for annotating non-null types. - * - * The specified annotation type must have a [java.lang.annotation.Target] of - * [java.lang.annotation.ElementType.TYPE_USE] or the generated code may not compile. If set to - * `null`, [org.pkl.config.java.mapper.NonNull] will be used. - */ - val nonNullAnnotation: String? = null, - - /** Whether to generate classes that implement [java.io.Serializable]. */ - 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. */ class JavaCodeGenerator( private val schema: ModuleSchema, @@ -885,61 +829,3 @@ class JavaCodeGenerator( private val nameMapper = NameMapper(codegenOptions.renames) } - -internal val javaReservedWords = - setOf( - "_", // java 9+ - "abstract", - "assert", - "boolean", - "break", - "byte", - "case", - "catch", - "char", - "class", - "const", - "continue", - "default", - "double", - "do", - "else", - "enum", - "extends", - "false", - "final", - "finally", - "float", - "for", - "goto", - "if", - "implements", - "import", - "instanceof", - "int", - "interface", - "long", - "native", - "new", - "null", - "package", - "private", - "protected", - "public", - "return", - "short", - "static", - "strictfp", - "super", - "switch", - "synchronized", - "this", - "throw", - "throws", - "transient", - "true", - "try", - "void", - "volatile", - "while", - ) diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaRecordCodeGenerator.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaRecordCodeGenerator.kt new file mode 100644 index 000000000..3f52277f2 --- /dev/null +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaRecordCodeGenerator.kt @@ -0,0 +1,973 @@ +/* + * Copyright © 2024-2025 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.codegen.java + +import com.palantir.javapoet.* +import java.io.StringWriter +import java.lang.Deprecated +import java.nio.file.Path +import java.util.* +import java.util.function.Consumer +import java.util.regex.Pattern +import javax.lang.model.element.Modifier +import kotlin.AssertionError +import kotlin.Boolean +import kotlin.ReplaceWith +import kotlin.RuntimeException +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +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) + +@kotlin.Deprecated("renamed to JavaCodeGeneratorOptions", ReplaceWith("JavaCodeGeneratorOptions")) +typealias JavaCodegenOptions = JavaCodeGeneratorOptions + +data class JavaCodeGeneratorOptions( + /** The characters to use for indenting generated Java code. */ + val indent: String = " ", + + /** + * Whether to generate public getter methods and protected final fields instead of public final + * fields. + */ + val generateGetters: Boolean = false, + + /** Whether to preserve Pkl doc comments by generating corresponding Javadoc comments. */ + val generateJavadoc: Boolean = false, + + /** Whether to generate config classes for use with Spring Boot. */ + val generateSpringBootConfig: Boolean = false, + + /** + * Fully qualified name of the annotation type to use for annotating constructor parameters with + * their name. + * + * The specified annotation type must have a `value` parameter of type [java.lang.String] or the + * generated code may not compile. + * + * If set to `null`, constructor parameters are not annotated. The default value is `null` if + * [generateSpringBootConfig] is `true` and `"org.pkl.config.java.mapper.Named"` otherwise. + */ + val paramsAnnotation: String? = + if (generateSpringBootConfig) null else "org.pkl.config.java.mapper.Named", + + /** + * Fully qualified name of the annotation type to use for annotating non-null types. + * + * The specified annotation type must have a [java.lang.annotation.Target] of + * [java.lang.annotation.ElementType.TYPE_USE] or the generated code may not compile. If set to + * `null`, [org.pkl.config.java.mapper.NonNull] will be used. + */ + val nonNullAnnotation: String? = null, + + /** Whether to generate classes that implement [java.io.Serializable]. */ + 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(), + + /** + * Whether to generate Java records, the related interfaces, and JEP 468 like withers. + * + * This overrides any Java class generation related options! + */ + val generateRecords: Boolean = false, +) + +/** Entrypoint for the Java code generator API. */ +class JavaRecordCodeGenerator( + private val schema: ModuleSchema, + private val codegenOptions: JavaCodeGeneratorOptions, +) { + + companion object { + private val OBJECT = ClassName.get(Object::class.java) + private val STRING = ClassName.get(String::class.java) + private val DURATION = ClassName.get(Duration::class.java) + private val DURATION_UNIT = ClassName.get(DurationUnit::class.java) + private val DATA_SIZE = ClassName.get(DataSize::class.java) + private val DATASIZE_UNIT = ClassName.get(DataSizeUnit::class.java) + private val PAIR = ClassName.get(Pair::class.java) + private val COLLECTION = ClassName.get(Collection::class.java) + private val LIST = ClassName.get(List::class.java) + private val SET = ClassName.get(Set::class.java) + private val MAP = ClassName.get(Map::class.java) + private val PMODULE = ClassName.get(PModule::class.java) + private val PCLASS = ClassName.get(PClass::class.java) + private val PATTERN = ClassName.get(Pattern::class.java) + private val URI = ClassName.get(java.net.URI::class.java) + private val VERSION = ClassName.get(Version::class.java) + + private const val PROPERTY_PREFIX: String = "org.pkl.config.java.mapper." + + private val commonCodePackage: String = "${this::class.java.packageName}.common.code" + val commonCodePackageFile: Path = + Path.of("java", commonCodePackage.replace(".", "/"), "Wither.java") + + private fun toClassName(fqn: String): ClassName { + val index = fqn.lastIndexOf(".") + if (index == -1) { + throw JavaCodeGeneratorException( + """ + Annotation `$fqn` is not a valid Java class. + The name of the annotation should be the canonical Java name of the class, for example, `com.example.Foo`. + """ + .trimIndent() + ) + } + val packageName = fqn.substring(0, index) + val classParts = fqn.substring(index + 1).split('$') + return if (classParts.size == 1) { + ClassName.get(packageName, classParts.first()) + } else { + ClassName.get(packageName, classParts.first(), *classParts.drop(1).toTypedArray()) + } + } + + fun generateCommonCode(): String = + """ + package $commonCodePackage; + + import java.util.function.Consumer; + + public interface Wither { + R with(Consumer setter); + } + """ + .trimIndent() + } + + val output: Map + get() { + return getJavaFiles() + (propertyFileName to propertiesFile) + } + + private val propertyFileName: String + get() = + "resources/META-INF/org/pkl/config/java/mapper/classes/${IoUtils.encodePath(schema.moduleName)}.properties" + + private val propertiesFile: String + get() { + val props = Properties() + props["$PROPERTY_PREFIX${schema.moduleClass.qualifiedName}"] = + schema.moduleClass.toJavaPoetName().reflectionName() + for (pClass in schema.classes.values) { + props["$PROPERTY_PREFIX${pClass.qualifiedName}"] = pClass.toJavaPoetName().reflectionName() + } + return StringWriter() + .apply { props.store(this, "Java mappings for Pkl module `${schema.moduleName}`") } + .toString() + } + + private val nonNullAnnotation: AnnotationSpec + get() { + val annotation = codegenOptions.nonNullAnnotation + val className = + if (annotation == null) { + ClassName.get("org.pkl.config.java.mapper", "NonNull") + } else { + toClassName(annotation) + } + return AnnotationSpec.builder(className).build() + } + + private fun getJavaFileName(isInterface: Boolean): String { + val (packageName, className) = nameMapper.map(schema.moduleName) + val dirPath = packageName.replace('.', '/') + val fileName = "${if (isInterface) "I" else ""}${className}" + return if (dirPath.isEmpty()) { + "java/$fileName.java" + } else { + "java/$dirPath/$fileName.java" + } + } + + fun getJavaFiles(): Map { + if (schema.moduleUri.scheme == "pkl") { + throw JavaCodeGeneratorException( + "Cannot generate Java code for a Pkl standard library module (`${schema.moduleUri}`)." + ) + } + + val pModuleClass = schema.moduleClass + + val (moduleClass: TypeSpec.Builder, moduleInterface: TypeSpec.Builder?) = + generateTypeSpec(pModuleClass, schema).let { + when { + pModuleClass.isAbstract -> it[TypeSpec.Kind.INTERFACE]!! to null + pModuleClass.isOpen -> it[TypeSpec.Kind.RECORD]!! to it[TypeSpec.Kind.INTERFACE] + else -> it[TypeSpec.Kind.RECORD]!! to null + } + } + + for (pClass in schema.classes.values) { + generateTypeSpec(pClass, schema).forEach { moduleClass.addType(it.value.build()) } + } + + for (typeAlias in schema.typeAliases.values) { + val stringLiterals = mutableSetOf() + if (CodeGeneratorUtils.isRepresentableAsEnum(typeAlias.aliasedType, stringLiterals)) { + moduleClass.addType(generateEnumTypeSpec(typeAlias, stringLiterals).build()) + } + } + + val (packageName, _) = nameMapper.map(schema.moduleName) + + val moduleMap: Map = + mapOf( + getJavaFileName(isInterface = false) to + JavaFile.builder(packageName, moduleClass.build()) + .indent(codegenOptions.indent) + .build() + .toString() + ) + + val interfaceMap = + moduleInterface?.let { + mapOf( + getJavaFileName(isInterface = true) to + JavaFile.builder(packageName, moduleInterface.build()) + .indent(codegenOptions.indent) + .build() + .toString() + ) + } ?: emptyMap() + + return moduleMap + interfaceMap + } + + // 1. if pClass is module then a singleton list of module type + // 2. if pClass is abstract then a singleton list of interface type + // 3. else a list of a record type + its default interface type + private fun generateTypeSpec( + pClass: PClass, + schema: ModuleSchema, + ): Map { + val isModuleClass = pClass == schema.moduleClass + val javaPoetClassName = pClass.toJavaPoetName() + val superclass = + pClass.superclass?.takeIf { it.info != PClassInfo.Typed && it.info != PClassInfo.Module } + val superProperties = + superclass?.let { renameIfReservedWord(it.allProperties) }?.filterValues { !it.isHidden } + ?: mapOf() + val properties = renameIfReservedWord(pClass.properties).filterValues { !it.isHidden } + val allProperties = superProperties + properties + + fun PClass.Property.isRegex(): Boolean = + (this.type as? PType.Class)?.pClass?.info == PClassInfo.Regex + + // do the minimum work necessary to avoid (most) java compile errors + // generating idiomatic Javadoc would require parsing doc comments, converting member links, + // etc. + // TODO: consider Java 23 https://openjdk.org/jeps/467 + fun renderAsJavadoc(docComment: String): String { + val escaped = docComment.replace("*/", "*/").trimEnd() + return if (escaped[escaped.length - 1] != '\n') escaped + '\n' else escaped + } + + fun generateDeprecation( + annotations: Collection, + hasJavadoc: Boolean, + addAnnotation: (Class<*>) -> Unit, + addJavadoc: (String) -> Unit, + ) { + annotations + .firstOrNull { it.classInfo == PClassInfo.Deprecated } + ?.let { deprecation -> + addAnnotation(Deprecated::class.java) + if (codegenOptions.generateJavadoc) { + (deprecation["message"] as String?)?.let { + if (hasJavadoc) { + addJavadoc("\n") + } + addJavadoc(renderAsJavadoc("@deprecated $it")) + } + } + } + } + + fun generateRecordDeprecationJavadoc(builder: TypeSpec.Builder) { + // generate Javadoc @deprecated on type (and on type only! + // should combine the type deprecation first, followed by parameters deprecations in order! + // TODO: refactor into its own function + if (codegenOptions.generateJavadoc) { + val deprecationStart = + listOf( + CodeBlock.of( + """ + @deprecated + + """ + .trimIndent() + ) + ) + + val typeDeprecation = + pClass.annotations + .firstOrNull { it.classInfo == PClassInfo.Deprecated } + ?.let { deprecation -> + (deprecation["message"] as String?)?.let { + // CodeBlock.of("""${'$'}L${'$'}W""", renderAsJavadoc(it)) + CodeBlock.of("""${'$'}L""", renderAsJavadoc(it)) + } + } + ?.let { listOf(it) } ?: emptyList() + + val propEntries = + superProperties.entries.filterNot { (k, _) -> properties.containsKey(k) } + + properties.entries + + val paramsDeprecations = + propEntries + .map { (k, v) -> + v.annotations + .firstOrNull { it.classInfo == PClassInfo.Deprecated } + ?.let { deprecation -> + (deprecation["message"] as String?)?.let { + // CodeBlock.of("""${'$'}N - ${'$'}L${'$'}W""", k, + // renderAsJavadoc(it)) + CodeBlock.of("""${'$'}N - ${'$'}L""", k, renderAsJavadoc(it)) + } + } + } + .filterNotNull() + + (typeDeprecation + paramsDeprecations) + .takeIf { it.isNotEmpty() } + ?.let { + builder.addJavadoc(CodeBlock.join(deprecationStart + CodeBlock.join(it, "
"), "")) + } + } + } + + fun addCtorParameter( + builder: MethodSpec.Builder, + propJavaName: String, + property: PClass.Property, + ) { + val paramBuilder = ParameterSpec.builder(property.type.toJavaPoetName(), propJavaName) + if (paramsAnnotationName != null) { + paramBuilder.addAnnotation( + AnnotationSpec.builder(paramsAnnotationName) + .addMember("value", "\$S", property.simpleName) + .build() + ) + } + + val hasJavadoc = property.docComment != null && codegenOptions.generateJavadoc + + if (hasJavadoc) { + paramBuilder.addJavadoc(renderAsJavadoc(property.docComment!!)) + } + + generateDeprecation( + property.annotations, + hasJavadoc, + { paramBuilder.addAnnotation(it) }, + // { paramBuilder.addJavadoc(it) }, // FIXME improve by doing something with + // @deprecated + {}, // no Javadocs on parameters! + ) + + builder.addParameter(paramBuilder.build()) + } + + fun generateConstructor(isInstantiable: Boolean): MethodSpec { + val builder = + MethodSpec.constructorBuilder() + // choose most restrictive access modifier possible + .addModifiers( + when { + isInstantiable -> Modifier.PUBLIC + pClass.isAbstract || pClass.isOpen -> Modifier.PROTECTED + else -> Modifier.PRIVATE + } + ) + + if (superProperties.isNotEmpty()) { + for ((name, property) in superProperties) { + if (properties.containsKey(name)) + continue // FIXME should be the other way around - superProps should be first, right? + addCtorParameter(builder, name, property) + } + } + + for ((name, property) in properties) { + addCtorParameter(builder, name, property) + } + + return builder.build() + } + + fun generateSpringBootAnnotations(builder: TypeSpec.Builder) { + if (isModuleClass) { + builder.addAnnotation( + ClassName.get("org.springframework.boot.context.properties", "ConfigurationProperties") + ) + } else { + // not very efficient to repeat computing module property base types for every class + val modulePropertiesWithMatchingType = + schema.moduleClass.allProperties.values.filter { property -> + var propertyType = property.type + while (propertyType is PType.Constrained || propertyType is PType.Nullable) { + if (propertyType is PType.Constrained) { + propertyType = propertyType.baseType + } else if (propertyType is PType.Nullable) { + propertyType = propertyType.baseType + } + } + propertyType is PType.Class && propertyType.pClass == pClass + } + if (modulePropertiesWithMatchingType.size == 1) { + // exactly one module property has this type -> make it available for direct injection + // (potential improvement: make type available for direct injection if it occurs exactly + // once in property tree) + builder.addAnnotation( + AnnotationSpec.builder( + ClassName.get( + "org.springframework.boot.context.properties", + "ConfigurationProperties", + ) + ) + // use "value" instead of "prefix" to entice JavaPoet to generate a single-line + // annotation + // that can easily be filtered out by JavaCodeGeneratorTest.`spring boot config` + .addMember("value", "\$S", modulePropertiesWithMatchingType.first().simpleName) + .build() + ) + } + } + } + + fun generateWither(builder: TypeSpec.Builder, constructorSpec: MethodSpec) { + // org.pkl.codegen.java.common.code.Wither wherein R is this record class + builder.addSuperinterface( + ParameterizedTypeName.get( + ClassName.get(commonCodePackage, "Wither"), + TypeVariableName.get(javaPoetClassName.simpleName()), + TypeVariableName.get(javaPoetClassName.simpleName() + ".Memento"), + ) + ) + + // add the `with` method with its standard body + val withMethod = + MethodSpec.methodBuilder("with") + .addModifiers(Modifier.PUBLIC) + .addParameter( + ParameterizedTypeName.get( + ClassName.get(Consumer::class.java), + ClassName.get("", "Memento"), + ), + "setter", + Modifier.FINAL, + ) + .returns(ClassName.get("", javaPoetClassName.simpleName())) + .addAnnotation(Override::class.java) + .addCode( + """ + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + """ + .trimIndent() + ) + .build() + + builder.addMethod(withMethod) + + // add Memento class + val memento = + TypeSpec.classBuilder("Memento") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) + .addFields( + constructorSpec.parameters().map { + FieldSpec.builder(it.type(), it.name(), Modifier.PUBLIC).build() + } + ) + .addMethod( + MethodSpec.constructorBuilder() + .addModifiers(Modifier.PRIVATE) + .addParameter(ClassName.get("", javaPoetClassName.simpleName()), "r", Modifier.FINAL) + .addCode( + CodeBlock.builder() + .apply { + constructorSpec.parameters().forEach { + addStatement("${'$'}1N = r.${'$'}1N", it.name()) + } + } + .build() + ) + .build() + ) + .addMethod( + MethodSpec.methodBuilder("build") + .addModifiers(Modifier.PRIVATE) + .returns(ClassName.get("", javaPoetClassName.simpleName())) + .addCode( + CodeBlock.builder() + .addStatement( + "return new \$L(\$L)", + javaPoetClassName.simpleName(), + constructorSpec.parameters().joinToString { it.name() }, + ) + .build() + ) + .build() + ) + .build() + + builder.addType(memento) + } + + fun generateInterface(): TypeSpec.Builder { + val builder = + TypeSpec.interfaceBuilder( + "${if (pClass.isOpen) "I" else ""}${javaPoetClassName.simpleName()}" + ) + .addModifiers(Modifier.PUBLIC) + + val docComment = pClass.docComment + val hasJavadoc = docComment != null && codegenOptions.generateJavadoc + if (hasJavadoc) { + builder.addJavadoc(renderAsJavadoc(docComment!!)) + } + + generateDeprecation( + pClass.annotations, + hasJavadoc, + { builder.addAnnotation(it) }, + { builder.addJavadoc(it) }, + ) + + superclass?.let { builder.addSuperinterface(it.toJavaPoetName(forceInterface = true)) } + + for ((name, property) in properties) { + // TODO: See generateField method + val methodBuilder = + MethodSpec.methodBuilder(name) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .returns(property.type.toJavaPoetName(forceInterface = true)) + + val docComment = property.docComment + val hasJavadoc = docComment != null && codegenOptions.generateJavadoc + if (hasJavadoc) { + methodBuilder.addJavadoc(renderAsJavadoc(docComment!!)) + } + + generateDeprecation( + property.annotations, + hasJavadoc, + { methodBuilder.addAnnotation(it) }, + { methodBuilder.addJavadoc(it) }, + ) + + builder.addMethod(methodBuilder.build()) + } + + return builder + } + + @Suppress("DuplicatedCode") + fun generateClass(): TypeSpec.Builder { + val builder = + TypeSpec.recordBuilder(javaPoetClassName.simpleName()).addModifiers(Modifier.PUBLIC) + + val docComment = pClass.docComment + val hasJavadoc = docComment != null && codegenOptions.generateJavadoc + if (hasJavadoc) { + builder.addJavadoc(renderAsJavadoc(docComment!!)) + } + + generateDeprecation( + pClass.annotations, + hasJavadoc, + { builder.addAnnotation(it) }, + // { builder.addJavadoc(it) }, + {}, + ) + + generateRecordDeprecationJavadoc(builder) + + if (codegenOptions.generateSpringBootConfig) { + generateSpringBootAnnotations(builder) + } + + superclass?.let { builder.addSuperinterface(it.toJavaPoetName(forceInterface = true)) } + + if (pClass.isOpen) { + builder.addSuperinterface(pClass.toJavaPoetName(forceInterface = true)) + } + + // stateless final module classes are non-instantiable by choice + val isInstantiable = + !(pClass.isAbstract || (isModuleClass && !pClass.isOpen && allProperties.isEmpty())) + + val constructorSpec = generateConstructor(isInstantiable) + builder.recordConstructor(constructorSpec) + + if (isInstantiable) { + if (codegenOptions.implementSerializable) { + builder.addSuperinterface(java.io.Serializable::class.java) + } + + generateWither(builder, constructorSpec) + } + + return builder + } + + return when { + pClass.isAbstract -> mapOf(TypeSpec.Kind.INTERFACE to generateInterface()) + pClass.isOpen -> + mapOf( + TypeSpec.Kind.INTERFACE to generateInterface(), + TypeSpec.Kind.RECORD to generateClass(), + ) + else -> mapOf(TypeSpec.Kind.RECORD to generateClass()) + } + } + + private fun generateEnumTypeSpec( + typeAlias: TypeAlias, + stringLiterals: Set, + ): TypeSpec.Builder { + val enumConstantToPklNames = + stringLiterals + .groupingBy { literal -> + CodeGeneratorUtils.toEnumConstantName(literal) + ?: throw JavaCodeGeneratorException( + "Cannot generate Java enum class for Pkl type alias `${typeAlias.displayName}` " + + "because string literal type \"$literal\" cannot be converted to a valid enum constant name." + ) + } + .reduce { enumConstantName, firstLiteral, secondLiteral -> + throw JavaCodeGeneratorException( + "Cannot generate Java enum class for Pkl type alias `${typeAlias.displayName}` " + + "because string literal types \"$firstLiteral\" and \"$secondLiteral\" " + + "would both be converted to enum constant name `$enumConstantName`." + ) + } + + val builder = + TypeSpec.enumBuilder(typeAlias.simpleName) + .addModifiers(Modifier.PUBLIC) + .addField(String::class.java, "value", Modifier.PRIVATE) + .addMethod( + MethodSpec.constructorBuilder() + .addModifiers(Modifier.PRIVATE) + .addParameter(String::class.java, "value") + .addStatement("this.value = value") + .build() + ) + .addMethod( + MethodSpec.methodBuilder("toString") + .addModifiers(Modifier.PUBLIC) + .addAnnotation(Override::class.java) + .returns(String::class.java) + .addStatement("return this.value") + .build() + ) + + for ((enumConstantName, pklName) in enumConstantToPklNames) { + builder.addEnumConstant( + enumConstantName, + TypeSpec.anonymousClassBuilder("\$S", pklName).build(), + ) + } + + return builder + } + + private val paramsAnnotationName: ClassName? = + codegenOptions.paramsAnnotation?.let { toClassName(it) } + + private fun PClass.toJavaPoetName(forceInterface: Boolean = false): ClassName { + val (packageName, moduleClassName) = nameMapper.map(moduleName) + return if (isModuleClass) { + ClassName.get(packageName, "${if (forceInterface && isOpen) "I" else ""}${moduleClassName}") + } else { + ClassName.get( + packageName, + moduleClassName, + "${if (forceInterface && isOpen) "I" else ""}${simpleName}", + ) + } + } + + // generated type is a nested enum class + private fun TypeAlias.toJavaPoetName(): ClassName { + val (packageName, moduleClassName) = nameMapper.map(moduleName) + return ClassName.get(packageName, moduleClassName, simpleName) + } + + /** Generate `List` if `Foo` is `abstract` or `open`, to allow subclassing. */ + private fun PType.toJavaPoetTypeArgumentName(forceInterface: Boolean = false): TypeName { + val baseName = toJavaPoetName(boxed = true, forceInterface = forceInterface) + return if (this is PType.Class && (pClass.isAbstract || pClass.isOpen)) { + WildcardTypeName.subtypeOf(baseName) + } else { + baseName + } + } + + private fun PType.toJavaPoetName( + nullable: Boolean = false, + boxed: Boolean = false, + forceInterface: Boolean = false, + ): TypeName = + when (this) { + PType.UNKNOWN -> OBJECT.nullableIf(nullable) + PType.NOTHING -> TypeName.VOID + is PType.StringLiteral -> STRING.nullableIf(nullable) + is PType.Class -> { + // if in doubt, spell it out + when (val classInfo = pClass.info) { + PClassInfo.Any -> OBJECT + PClassInfo.Typed, + PClassInfo.Dynamic -> OBJECT.nullableIf(nullable) + PClassInfo.Boolean -> TypeName.BOOLEAN.boxIf(boxed).nullableIf(nullable) + PClassInfo.String -> STRING.nullableIf(nullable) + // seems more useful to generate `double` than `java.lang.Number` + PClassInfo.Number -> TypeName.DOUBLE.boxIf(boxed).nullableIf(nullable) + PClassInfo.Int -> TypeName.LONG.boxIf(boxed).nullableIf(nullable) + PClassInfo.Float -> TypeName.DOUBLE.boxIf(boxed).nullableIf(nullable) + PClassInfo.Duration -> DURATION.nullableIf(nullable) + PClassInfo.DataSize -> DATA_SIZE.nullableIf(nullable) + PClassInfo.Pair -> + ParameterizedTypeName.get( + PAIR, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[0].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[1].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + ) + .nullableIf(nullable) + + PClassInfo.Collection -> + ParameterizedTypeName.get( + COLLECTION, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[0].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + ) + .nullableIf(nullable) + + PClassInfo.List, + PClassInfo.Listing -> { + ParameterizedTypeName.get( + LIST, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[0].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + ) + .nullableIf(nullable) + } + + PClassInfo.Set -> + ParameterizedTypeName.get( + SET, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[0].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + ) + .nullableIf(nullable) + + PClassInfo.Map, + PClassInfo.Mapping -> + ParameterizedTypeName.get( + MAP, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[0].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + if (typeArguments.isEmpty()) { + OBJECT + } else { + typeArguments[1].toJavaPoetTypeArgumentName(forceInterface = forceInterface) + }, + ) + .nullableIf(nullable) + + PClassInfo.Module -> PMODULE.nullableIf(nullable) + PClassInfo.Class -> PCLASS.nullableIf(nullable) + PClassInfo.Regex -> PATTERN.nullableIf(nullable) + PClassInfo.Version -> VERSION.nullableIf(nullable) + else -> + when { + !classInfo.isStandardLibraryClass -> + pClass.toJavaPoetName(forceInterface = forceInterface).nullableIf(nullable) + else -> + throw JavaCodeGeneratorException( + "Standard library class `${pClass.qualifiedName}` is not supported by Java code generator. " + + "If you think this is an omission, please let us know." + ) + } + } + } + + is PType.Nullable -> + baseType.toJavaPoetName(forceInterface = forceInterface, nullable = true, boxed = true) + is PType.Constrained -> + baseType.toJavaPoetName(forceInterface = forceInterface, nullable = nullable, boxed = boxed) + is PType.Alias -> + when (typeAlias.qualifiedName) { + "pkl.base#NonNull" -> OBJECT.nullableIf(nullable) + "pkl.base#Int8" -> TypeName.BYTE.boxIf(boxed).nullableIf(nullable) + "pkl.base#Int16", + "pkl.base#UInt8" -> TypeName.SHORT.boxIf(boxed).nullableIf(nullable) + "pkl.base#Int32", + "pkl.base#UInt16" -> TypeName.INT.boxIf(boxed).nullableIf(nullable) + "pkl.base#UInt", + "pkl.base#UInt32" -> TypeName.LONG.boxIf(boxed).nullableIf(nullable) + "pkl.base#DurationUnit" -> DURATION_UNIT.nullableIf(nullable) + "pkl.base#DataSizeUnit" -> DATASIZE_UNIT.nullableIf(nullable) + "pkl.base#Uri" -> URI.nullableIf(nullable) + else -> { + if (CodeGeneratorUtils.isRepresentableAsEnum(aliasedType, null)) { + if (typeAlias.isStandardLibraryMember) { + throw JavaCodeGeneratorException( + "Standard library typealias `${typeAlias.qualifiedName}` is not supported by Java code generator. " + + "If you think this is an omission, please let us know." + ) + } else { + // reference generated enum class + typeAlias.toJavaPoetName().nullableIf(nullable) + } + } else { + // inline type alias + aliasedType.toJavaPoetName(nullable) + } + } + } + + is PType.Function -> + throw JavaCodeGeneratorException( + "Pkl function types are not supported by the Java code generator." + ) + + is PType.Union -> + if (CodeGeneratorUtils.isRepresentableAsString(this)) STRING.nullableIf(nullable) + else + throw JavaCodeGeneratorException( + "Pkl union types are not supported by the Java code generator." + ) + + else -> + // should never encounter PType.TypeVariableNode because it can only occur in stdlib classes + throw AssertionError("Encountered unexpected PType subclass: $this") + } + + private fun TypeName.nullableIf(isNullable: Boolean): TypeName = + if (isPrimitive && isNullable) box() + else if (isPrimitive || isNullable) this else annotated(nonNullAnnotation) + + private fun TypeName.boxIf(shouldBox: Boolean): TypeName = if (shouldBox) box() else this + + private fun renameIfReservedWord(map: Map): Map { + return map.mapKeys { (key, _) -> + if (key in javaReservedWords) { + generateSequence("_$key") { "_$it" }.first { it !in map.keys } + } else key + } + } + + private val nameMapper = NameMapper(codegenOptions.renames) +} + +internal val javaReservedWords = + setOf( + "_", // java 9+ + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "double", + "do", + "else", + "enum", + "extends", + "false", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "native", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "strictfp", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "true", + "try", + "void", + "volatile", + "while", + ) 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 7c10ee184..ae3bcdebf 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 @@ -124,6 +124,18 @@ class PklJavaCodegenCommand : ModulesCommand(name = "pkl-codegen-java", helpLink ) .associate() + private val generateRecords: Boolean by + option( + names = arrayOf("--generate-records"), + help = + """ + Whether to generate Java records, the related interfaces, and JEP 468 like withers. + This overrides any Java class generation related options! + """ + .trimIndent(), + ) + .flag() + override val helpString: String = "Generate Java classes and interfaces from Pkl module(s)" override fun run() { @@ -139,6 +151,7 @@ class PklJavaCodegenCommand : ModulesCommand(name = "pkl-codegen-java", helpLink nonNullAnnotation = nonNullAnnotation, implementSerializable = implementSerializable, renames = renames, + generateRecords = generateRecords, ) CliJavaCodeGenerator(options).run() } 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 5d4fffc6b..56fe5070a 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 @@ -22,6 +22,7 @@ import java.util.regex.Pattern import org.assertj.core.api.AbstractAssert import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertAll import org.junit.jupiter.api.assertDoesNotThrow @@ -34,6 +35,7 @@ import org.pkl.core.ModuleSource.path import org.pkl.core.ModuleSource.text import org.pkl.core.util.IoUtils +@Tag("generate-classes") class JavaCodeGeneratorTest { companion object { private const val MAPPER_PREFIX = "resources/META-INF/org/pkl/config/java/mapper/classes" diff --git a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaRecordCodeGeneratorTest.kt b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaRecordCodeGeneratorTest.kt new file mode 100644 index 000000000..18ef89e75 --- /dev/null +++ b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaRecordCodeGeneratorTest.kt @@ -0,0 +1,2562 @@ +/* + * Copyright © 2024-2025 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.codegen.java + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.ObjectStreamClass +import java.nio.file.Path +import java.util.function.BiPredicate +import java.util.function.Consumer +import java.util.regex.Pattern +import org.assertj.core.api.AbstractAssert +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.pkl.core.DataSize +import org.pkl.core.DataSizeUnit +import org.pkl.core.Duration +import org.pkl.core.DurationUnit +import org.pkl.core.Evaluator +import org.pkl.core.ModuleSource.path +import org.pkl.core.ModuleSource.text +import org.pkl.core.Pair +import org.pkl.core.util.IoUtils + +@Tag("generate-records") +class JavaRecordCodeGeneratorTest { + companion object { + private const val MAPPER_PREFIX = "resources/META-INF/org/pkl/config/java/mapper/classes" + + private val commonCodeSource: kotlin.Pair = + "org/pkl/codegen/java/common/code/Wither.java" to + """ + package org.pkl.codegen.java.common.code; + + import java.util.function.Consumer; + + public interface Wither { + R with(Consumer setter); + } + """ + .trimIndent() + + private fun inMemoryCompile(sourceFiles: Map): Map> { + return InMemoryJavaCompiler.compile(sourceFiles + commonCodeSource) + } + + private val simpleClass: Class<*> by lazy { + generateJavaCode( + """ + module my.mod + + class Simple { + str: String + list: List + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true), + ) + .compile() + .getValue("my.Mod\$Simple") + } + + private val propertyTypesSources: JavaSourceCode by lazy { + generateJavaCode( + """ + module my.mod + + class PropertyTypes { + boolean: Boolean + int: Int + float: Float + string: String + duration: Duration + durationUnit: DurationUnit + dataSize: DataSize + dataSizeUnit: DataSizeUnit + nullable: String? + nullable2: String? + pair: Pair + pair2: Pair + coll: Collection + coll2: Collection + list: List + list2: List + set: Set + set2: Set + map: Map + map2: Map + container: Mapping + container2: Mapping + other: Other + regex: Regex + any: Any + nonNull: NonNull + enum: Direction + } + + class Other { + name: String + } + + typealias Direction = "north"|"east"|"south"|"west" + """, + JavaCodeGeneratorOptions(generateRecords = true), + ) + } + + private val propertyTypesClasses: Map> by lazy { + propertyTypesSources.compile() + } + + private fun generateJavaCode( + pklCode: String, + options: JavaCodeGeneratorOptions = JavaCodeGeneratorOptions(), + ): JavaSourceCode { + val module = Evaluator.preconfigured().evaluateSchema(text(pklCode)) + val generator = JavaRecordCodeGenerator(module, options) + return generator + .getJavaFiles() + .also { check(it.size == 1) { "code generation only for a non-open module" } } + .values + .first() + .let { JavaSourceCode(it) } + } + + private fun generateJavaCodeForOpenModule( + pklCode: String, + options: JavaCodeGeneratorOptions = JavaCodeGeneratorOptions(), + ): List { + val module = Evaluator.preconfigured().evaluateSchema(text(pklCode)) + val generator = JavaRecordCodeGenerator(module, options) + return generator.getJavaFiles().map { (_, v) -> JavaSourceCode(v) } + } + } + + @TempDir lateinit var tempDir: Path + + @Test + fun testEquals() { + val ctor = simpleClass.constructors.first() + val instance1 = ctor.newInstance("foo", listOf(1, 2, 3)) + val instance2 = ctor.newInstance("foo", listOf(1, 2, 3)) + val instance3 = ctor.newInstance("foo", listOf(1, 3, 2)) + val instance4 = ctor.newInstance("bar", listOf(1, 2, 3)) + + assertThat(instance1).isEqualTo(instance1).isEqualTo(instance2) + assertThat(instance2).isEqualTo(instance1) + assertThat(instance3).isNotEqualTo(instance1) + assertThat(instance4).isNotEqualTo(instance1) + } + + @Test + fun testHashCode() { + val ctor = simpleClass.constructors.first() + val instance1 = ctor.newInstance("foo", listOf(1, 2, 3)) + val instance2 = ctor.newInstance("foo", listOf(1, 2, 3)) + val instance3 = ctor.newInstance("foo", listOf(1, 3, 2)) + val instance4 = ctor.newInstance("bar", listOf(1, 2, 3)) + + assertThat(instance1.hashCode()).isEqualTo(instance1.hashCode()).isEqualTo(instance2.hashCode()) + assertThat(instance3.hashCode()).isNotEqualTo(instance1.hashCode()) + assertThat(instance4.hashCode()).isNotEqualTo(instance1.hashCode()) + } + + @Test + fun testToString() { + val (_, propertyTypes) = instantiateOtherAndPropertyTypes() + + assertThat(propertyTypes.toString()) + .isEqualToIgnoringWhitespace( + """ + PropertyTypes[ + _boolean=true, + _int=42, + _float=42.3, + string=string, + duration=5.min, + durationUnit=min, + dataSize=3.gb, + dataSizeUnit=gb, + nullable=idea, + nullable2=null, + pair=Pair(1, 2), + pair2=Pair(pigeon, Other[name=pigeon]), + coll=[1, 2, 3], + coll2=[Other[name=pigeon], Other[name=pigeon]], + list=[1, 2, 3], + list2=[Other[name=pigeon], + Other[name=pigeon]], + set=[1, 2, 3], + set2=[Other[name=pigeon]], + map={1=one, 2=two}, + map2={one=Other[name=pigeon], + two=Other[name=pigeon]}, + container={1=one, 2=two}, + container2={one=Other[name=pigeon], + two=Other[name=pigeon]}, + other=Other[name=pigeon], + regex=(i?)\w*, + any=Other[name=pigeon], + nonNull=Other[name=pigeon], + _enum=north + ] + """ + .trimIndent() + ) + } + + @Test + fun `deprecated property with message`() { + val javaCode = + generateJavaCode( + """ + class ClassWithDeprecatedProperty { + @Deprecated { message = "property deprecation message" } + deprecatedProperty: Int = 1337 + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * @deprecated + * deprecatedProperty - property deprecation message + */ + public record ClassWithDeprecatedProperty( + @Named("deprecatedProperty") @Deprecated long deprecatedProperty) + """ + .trimMargin() + ) + } + + @Test + fun `deprecated class with message`() { + val javaCode = + generateJavaCode( + """ + @Deprecated { message = "class deprecation message" } + class DeprecatedClass { + propertyOfDeprecatedClass: Int = 42 + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * @deprecated + * class deprecation message + */ + @Deprecated + public record DeprecatedClass( + @Named("propertyOfDeprecatedClass") long propertyOfDeprecatedClass) + """ + .trimMargin() + ) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `deprecated module class with message`(generateJavadoc: Boolean) { + val javaCode = + generateJavaCode( + """ + @Deprecated{ message = "module class deprecation message" } + module DeprecatedModule + + propertyInDeprecatedModuleClass : Int = 42 + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = generateJavadoc), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + @Deprecated + public record DeprecatedModule( + @Named("propertyInDeprecatedModuleClass") long propertyInDeprecatedModuleClass) + """ + .trimMargin() + ) + + if (generateJavadoc) { + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * @deprecated + * module class deprecation message + */ + """ + .trimMargin() + ) + } else { + assertThat(javaCode.text).doesNotContain("* @deprecated") + } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `deprecated property`(generateJavadoc: Boolean) { + val javaCode = + generateJavaCode( + """ + class ClassWithDeprecatedProperty { + @Deprecated + deprecatedProperty: Int = 1337 + } + """ + .trimIndent(), + // no message, so no Javadoc, regardless of flag + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = generateJavadoc), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record ClassWithDeprecatedProperty( + @Named("deprecatedProperty") @Deprecated long deprecatedProperty) + """ + .trimMargin() + ) + .doesNotContain("* @deprecated") + } + + @Test + fun `deprecated class`() { + val javaCode = + generateJavaCode( + """ + @Deprecated + class DeprecatedClass { + propertyOfDeprecatedClass: Int = 42 + } + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + @Deprecated + public record DeprecatedClass( + @Named("propertyOfDeprecatedClass") long propertyOfDeprecatedClass) + """ + .trimMargin() + ) + .doesNotContain("* @deprecated") + } + + @Test + fun `deprecated module class`() { + val javaCode = + generateJavaCode( + """ + @Deprecated + module DeprecatedModule + + propertyInDeprecatedModuleClass : Int = 42 + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + @Deprecated + public record DeprecatedModule( + @Named("propertyInDeprecatedModuleClass") long propertyInDeprecatedModuleClass) + """ + .trimMargin() + ) + .doesNotContain("* @deprecated") + } + + @Test + fun `deprecation with message and doc comment on the same property`() { + val javaCode = + generateJavaCode( + """ + /// Documenting deprecatedProperty + @Deprecated { message = "property is deprecated" } + deprecatedProperty: Int + """, + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * @deprecated + * deprecatedProperty - property is deprecated + * + * @param deprecatedProperty Documenting deprecatedProperty + */ + public record Text( + @Named("deprecatedProperty") @Deprecated long deprecatedProperty) + """ + .trimMargin() + ) + } + + @Test + fun `deprecation with message and doc comment on the same property, for 2 properties`() { + val javaCode = + generateJavaCode( + """ + /// Documenting deprecatedProperty 1 + @Deprecated { message = "property 1 is deprecated" } + deprecatedProperty1: Int + /// Documenting deprecatedProperty 2 + @Deprecated { message = "property 2 is deprecated" } + deprecatedProperty2: Int + """, + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * @deprecated + * deprecatedProperty1 - property 1 is deprecated + *
deprecatedProperty2 - property 2 is deprecated + * + * @param deprecatedProperty1 Documenting deprecatedProperty 1 + * @param deprecatedProperty2 Documenting deprecatedProperty 2 + */ + public record Text(@Named("deprecatedProperty1") @Deprecated long deprecatedProperty1, + @Named("deprecatedProperty2") @Deprecated long deprecatedProperty2) + """ + .trimMargin() + ) + } + + @Test + fun `deprecation with message and doc comment on the same property, for abstract class`() { + val javaCode = + generateJavaCode( + """ + abstract class Foo { + /// Documenting deprecatedProperty 1 + @Deprecated { message = "property 1 is deprecated" } + deprecatedProperty1: Int + } + """, + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public interface Foo { + /** + * Documenting deprecatedProperty 1 + * + * @deprecated property 1 is deprecated + */ + @Deprecated + long deprecatedProperty1(); + } + """ + .trimMargin() + ) + } + + @Test + fun `deprecation with message and doc comment on an abstract class`() { + val javaCode = + generateJavaCode( + """ + /// Documenting Foo + @Deprecated { message = "Foo is deprecated" } + abstract class Foo { + /// Documenting deprecatedProperty 1 + @Deprecated { message = "property 1 is deprecated" } + deprecatedProperty1: Int + } + """, + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * Documenting Foo + * + * @deprecated Foo is deprecated + */ + @Deprecated + public interface Foo { + /** + * Documenting deprecatedProperty 1 + * + * @deprecated property 1 is deprecated + */ + @Deprecated + long deprecatedProperty1(); + } + """ + .trimMargin() + ) + } + + @Test + fun `deprecation with message and doc comment on an open class`() { + val javaCode = + generateJavaCode( + """ + /// Documenting Foo + @Deprecated { message = "Foo is deprecated" } + open class Foo { + /// Documenting deprecatedProperty 1 + @Deprecated { message = "property 1 is deprecated" } + deprecatedProperty1: Int + } + """, + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + /** + * Documenting Foo + * + * @deprecated Foo is deprecated + */ + @Deprecated + public interface IFoo { + /** + * Documenting deprecatedProperty 1 + * + * @deprecated property 1 is deprecated + */ + @Deprecated + long deprecatedProperty1(); + } + + /** + * Documenting Foo + * @deprecated + * Foo is deprecated + *
deprecatedProperty1 - property 1 is deprecated + * + * @param deprecatedProperty1 Documenting deprecatedProperty 1 + */ + @Deprecated + public record Foo( + @Named("deprecatedProperty1") @Deprecated long deprecatedProperty1) implements IFoo + """ + .trimMargin() + ) + } + + @Test + fun properties() { + val (other, propertyTypes) = instantiateOtherAndPropertyTypes() + + assertThat(other).extracting("name").isEqualTo("pigeon") + + assertThat(propertyTypes).extracting("_boolean").isEqualTo(true) + assertThat(propertyTypes).extracting("_int").isEqualTo(42L) + assertThat(propertyTypes).extracting("_float").isEqualTo(42.3) + assertThat(propertyTypes).extracting("string").isEqualTo("string") + assertThat(propertyTypes).extracting("duration").isEqualTo(Duration(5.0, DurationUnit.MINUTES)) + assertThat(propertyTypes) + .extracting("dataSize") + .isEqualTo(DataSize(3.0, DataSizeUnit.GIGABYTES)) + assertThat(propertyTypes).extracting("nullable").isEqualTo("idea") + assertThat(propertyTypes).extracting("nullable2").isEqualTo(null as String?) + assertThat(propertyTypes).extracting("list").isEqualTo(listOf(1, 2, 3)) + assertThat(propertyTypes).extracting("list2").isEqualTo(listOf(other, other)) + assertThat(propertyTypes).extracting("set").isEqualTo(setOf(1, 2, 3)) + assertThat(propertyTypes).extracting("set2").isEqualTo(setOf(other)) + assertThat(propertyTypes).extracting("map").isEqualTo(mapOf(1 to "one", 2 to "two")) + assertThat(propertyTypes).extracting("map2").isEqualTo(mapOf("one" to other, "two" to other)) + assertThat(propertyTypes).extracting("container").isEqualTo(mapOf(1 to "one", 2 to "two")) + assertThat(propertyTypes) + .extracting("container2") + .isEqualTo(mapOf("one" to other, "two" to other)) + assertThat(propertyTypes).extracting("other").isEqualTo(other) + assertThat(propertyTypes).extracting("regex").isInstanceOf(Pattern::class.java) + assertThat(propertyTypes).extracting("any").isEqualTo(other) + assertThat(propertyTypes).extracting("nonNull").isEqualTo(other) + } + + @Test + fun `properties 2`() { + assertThat(propertyTypesSources).isEqualToResourceFile("PropertyTypesRecord.jva") + } + + @Test + fun `enum constant names`() { + val cases = + listOf( + "camelCasedName" to "CAMEL_CASED_NAME", + "hyphenated-name" to "HYPHENATED_NAME", + "EnQuad\u2000EmSpace\u2003IdeographicSpace\u3000" to "EN_QUAD_EM_SPACE_IDEOGRAPHIC_SPACE_", + "ᾊᾨ" to "ᾊᾨ", + "0-digit" to "_0_DIGIT", + "digit-1" to "DIGIT_1", + "42" to "_42", + "àœü" to "ÀŒÜ", + "日本-つくば" to "日本_つくば", + ) + val javaCode = + generateJavaCode( + """ + module my.mod + typealias MyTypeAlias = ${cases.joinToString(" | ") { "\"${it.first}\"" }} + """ + .trimIndent() + ) + val javaClass = javaCode.compile().getValue("my.Mod\$MyTypeAlias") + + assertThat(javaClass.enumConstants.size) + .isEqualTo(cases.size) // make sure zip doesn't drop cases + + assertAll( + "generated enum constants have correct names", + javaClass.declaredFields.zip(cases) { field, (_, kotlinName) -> + { + assertThat(field.name).isEqualTo(kotlinName) + Unit + } + }, + ) + + assertAll( + "toString() returns Pkl name", + javaClass.enumConstants.zip(cases) { enumConstant, (pklName, _) -> + { + assertThat(enumConstant.toString()).isEqualTo(pklName) + Unit + } + }, + ) + } + + @Test + fun `conflicting enum constant names`() { + val exception = + assertThrows { + generateJavaCode( + """ + module my.mod + typealias MyTypeAlias = "foo-bar" | "foo bar" + """ + .trimIndent() + ) + } + + assertThat(exception) + .hasMessageContainingAll("both be converted to enum constant name", "FOO_BAR") + } + + @Test + fun `empty enum constant name`() { + val exception = + assertThrows { + generateJavaCode( + """ + module my.mod + typealias MyTypeAlias = "foo" | "" | "bar" + """ + .trimIndent() + ) + } + + assertThat(exception).hasMessageContaining("cannot be converted") + } + + @Test + fun `inconvertible enum constant name`() { + val exception = + assertThrows { + generateJavaCode( + """ + module my.mod + typealias MyTypeAlias = "foo" | "✅" | "bar" + """ + .trimIndent() + ) + } + assertThat(exception).hasMessageContainingAll("✅", "cannot be converted") + } + + @Test + fun `recursive types`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + class Foo { + other: Int + bar: Bar + } + class Bar { + foo: Foo + other: String + } + """ + .trimIndent() + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Foo(@Named("other") long other, + @Named("bar") @NonNull Bar bar) + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Bar(@Named("foo") @NonNull Foo foo, + @Named("other") @NonNull String other) + """ + .trimMargin() + ) + }) + } + + @Test + fun inheritance() { + val javaCode = + generateJavaCode( + """ + module my.mod + + abstract class Foo { + one: Int + } + open class None extends Foo {} + open class Bar extends None { + two: String? + } + class Baz extends Bar { + three: Duration + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true), + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod() { + public interface Foo { + long one(); + } + """ + .trimIndent() + ) + .containsIgnoringWhitespaces( + """ + public interface INone extends Foo { + } + + public record None(@Named("one") long one) implements Foo, INone + """ + .trimIndent() + ) + .containsIgnoringWhitespaces( + """ + public interface IBar extends INone { + String two(); + } + + public record Bar(@Named("one") long one, + @Named("two") String two) implements INone, IBar + """ + .trimIndent() + ) + .containsIgnoringWhitespaces( + """ + public record Baz(@Named("one") long one, @Named("two") String two, + @Named("three") @NonNull Duration three) implements IBar + """ + .trimIndent() + ) + }) + .isEqualToResourceFile("InheritanceRecord.jva") + } + + @Test + fun `stateless classes`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + class Foo + abstract class Bar + class Baz extends Bar + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record Foo() + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public interface Bar { + } + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Baz() implements Bar + """ + .trimMargin() + ) + } + + @Test + fun `stateless module classes`() { + var javaCode = generateJavaCode("module my.mod") + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + package my; + + public record Mod() { + } + """ + .trimMargin() + ) + + javaCode = generateJavaCode("abstract module my.mod") + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + package my; + + public interface Mod { + } + """ + .trimMargin() + ) + + val javaCodes = generateJavaCodeForOpenModule("open module my.mod") + + assertThat(javaCodes) + .anySatisfy { + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod() implements IMod + """ + .trimMargin() + ) + } + .anySatisfy { + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public interface IMod { + } + """ + .trimMargin() + ) + } + } + + @Test + fun `reserved words`() { + val props = javaReservedWords.joinToString("\n") { "`$it`: Int" } + + val fooClass = + generateJavaCode( + """ + module my.mod + + class Foo { + $props + } + """ + .trimIndent() + ) + .compile() + .getValue("my.Mod\$Foo") + + assertThat(fooClass.declaredFields).allSatisfy(Consumer { it.name.startsWith("_") }) + } + + @Test + fun `'with' methods`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + abstract class Foo { + x: Int + } + class Bar extends Foo { + y: String + } + """ + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public interface Foo { + long x(); + } + """ + .trimIndent() + ) + .containsIgnoringWhitespaces( + """ + public record Bar(@Named("x") long x, + @Named("y") @NonNull String y) implements Foo, Wither { + @Override + public Bar with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public long x; + + public @NonNull String y; + + private Memento(final Bar r) { + x = r.x; + y = r.y; + } + + private Bar build() { + return new Bar(x, y); + } + } + } + } + """ + .trimIndent() + ) + }) + } + + @Test + fun `module class`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + pigeon: String + parrot: String + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("pigeon") @NonNull String pigeon, + @Named("parrot") @NonNull String parrot) + """ + .trimMargin() + ) + } + + @Test + fun `hidden properties`() { + val javaCode = + generateJavaCode( + """ + hidden pigeon1: String + parrot1: String + + class Persons { + hidden pigeon2: String + parrot2: String + } + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .doesNotContain("String pigeon1") + .contains("@NonNull String parrot1") + .doesNotContain("String pigeon2") + .contains("@NonNull String parrot2") + } + + @Test + fun javadoc() { + val javaCode = + generateJavaCode( + """ + /// module comment. + /// *emphasized* `code`. + module my.mod + + /// module property comment. + /// *emphasized* `code`. + pigeon: Person + + /// class comment. + /// *emphasized* `code`. + class Person { + /// class property comment. + /// *emphasized* `code`. + name: String + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode).compilesSuccessfully().isEqualToResourceFile("JavadocRecord.jva") + } + + @Test + fun `javadoc 2`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + /// module property comment. + /// can contain /* and */ characters. + pigeon: Person + + class Person { + /// class property comment. + /// can contain /* and */ characters. + name: String + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateJavadoc = true), + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + /** + * @param pigeon module property comment. + * can contain /* and */ characters. + */ + public record Mod( + @Named("pigeon") Mod. @NonNull Person pigeon) + """ + .trimIndent() + ) + .containsIgnoringWhitespaces( + """ + /** + * @param name class property comment. + * can contain /* and */ characters. + */ + public record Person( + @Named("name") @NonNull String name) + """ + .trimMargin() + ) + }) + } + + @Test + fun `pkl_base type aliases`() { + val javaCode = + generateJavaCode( + """ + module mod + + uint8: UInt8 + uint16: UInt16 + uint32: UInt32 + uint: UInt + int8: Int8 + int16: Int16 + int32: Int32 + uri: Uri + + pair: Pair + list: List + set: Set + map: Map + listing: Listing + mapping: Mapping + nullable: UInt16? + + class Foo { + uint8: UInt8 + uint16: UInt16 + uint32: UInt32 + uint: UInt + int8: Int8 + int16: Int16 + int32: Int32 + uri: Uri + list: List + } + """ + .trimIndent() + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("uint8") short uint8, @Named("uint16") int uint16, + @Named("uint32") long uint32, @Named("uint") long uint, @Named("int8") byte int8, + @Named("int16") short int16, @Named("int32") int int32, @Named("uri") @NonNull URI uri, + @Named("pair") @NonNull Pair<@NonNull Short, @NonNull Integer> pair, + @Named("list") @NonNull List<@NonNull Long> list, @Named("set") @NonNull Set<@NonNull Long> set, + @Named("map") @NonNull Map<@NonNull Byte, @NonNull Short> map, + @Named("listing") @NonNull List<@NonNull Integer> listing, + @Named("mapping") @NonNull Map<@NonNull URI, @NonNull Short> mapping, + @Named("nullable") Integer nullable) + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Foo(@Named("uint8") short uint8, @Named("uint16") int uint16, + @Named("uint32") long uint32, @Named("uint") long uint, @Named("int8") byte int8, + @Named("int16") short int16, @Named("int32") int int32, @Named("uri") @NonNull URI uri, + @Named("list") @NonNull List<@NonNull Long> list) + """ + .trimMargin() + ) + }) + } + + @Test + fun `nullable properties`() { + var javaCode = + generateJavaCode( + """ + module mod + + foo: String + """ + .trimIndent(), + JavaCodeGeneratorOptions( + generateRecords = true, + nonNullAnnotation = "com.example.Annotations\$NonNull", + ), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces("import com.example.Annotations;") + .containsIgnoringWhitespaces( + """ + public record Mod( + @Named("foo") @Annotations.NonNull String foo) + """ + .trimIndent() + ) + + javaCode = + generateJavaCode( + """ + module mod + + foo: Int + bar: Int? + baz: Any + qux: String + foo2: List? + bar2: List + baz2: List + qux2: List + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("foo") long foo, @Named("bar") Long bar, @Named("baz") Object baz, + @Named("qux") @NonNull String qux, @Named("foo2") List<@NonNull String> foo2, + @Named("bar2") @NonNull List bar2, @Named("baz2") @NonNull List<@NonNull String> baz2, + @Named("qux2") @NonNull List<@NonNull Long> qux2) + """ + .trimMargin() + ) + } + + @Test + fun `user defined type aliases`() { + val javaCode = + generateJavaCode( + """ + module mod + + typealias Simple = String + typealias Constrained = String(length >= 3) + typealias Parameterized = List + typealias Recursive1 = Parameterized(nonEmpty) + typealias Recursive2 = List + + simple: Simple + constrained: Constrained + parameterized: Parameterized + recursive1: Recursive1 + recursive2: Recursive2 + + class Foo { + simple: Simple + constrained: Constrained + parameterized: Parameterized + recursive1: Recursive1 + recursive2: Recursive2 + } + """ + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("simple") @NonNull String simple, + @Named("constrained") @NonNull String constrained, + @Named("parameterized") @NonNull List<@NonNull Long> parameterized, + @Named("recursive1") @NonNull List<@NonNull Long> recursive1, + @Named("recursive2") @NonNull List<@NonNull String> recursive2) + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Foo(@Named("simple") @NonNull String simple, + @Named("constrained") @NonNull String constrained, + @Named("parameterized") @NonNull List<@NonNull Long> parameterized, + @Named("recursive1") @NonNull List<@NonNull Long> recursive1, + @Named("recursive2") @NonNull List<@NonNull String> recursive2) + """ + .trimMargin() + ) + }) + } + + @Test + fun `generic type aliases`() { + val javaCode = + generateJavaCode( + """ + module mod + + class Person { name: String } + + typealias List2 = List + typealias Map2 = Map + typealias StringMap = Map + typealias MMap = Map + + res1: List2 + res2: List2> + res3: Map2 + res4: StringMap + res5: MMap + + res6: List2 + res7: Map2 + res8: StringMap + res9: MMap + + class Foo { + res1: List2 + res2: List2> + res3: Map2 + res4: StringMap + res5: MMap + + res6: List2 + res7: Map2 + res8: StringMap + res9: MMap + } + """ + .trimIndent() + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("res1") @NonNull List<@NonNull Long> res1, + @Named("res2") @NonNull List<@NonNull List<@NonNull String>> res2, + @Named("res3") @NonNull Map<@NonNull Long, @NonNull String> res3, + @Named("res4") @NonNull Map<@NonNull String, @NonNull Duration> res4, + @Named("res5") @NonNull Map res5, + @Named("res6") @NonNull List<@NonNull Object> res6, + @Named("res7") @NonNull Map<@NonNull Object, @NonNull Object> res7, + @Named("res8") @NonNull Map<@NonNull String, @NonNull Object> res8, + @Named("res9") @NonNull Map<@NonNull Object, @NonNull Object> res9) + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Person( + @Named("name") @NonNull String name) + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Foo(@Named("res1") @NonNull List<@NonNull Long> res1, + @Named("res2") @NonNull List<@NonNull List<@NonNull String>> res2, + @Named("res3") @NonNull Map<@NonNull Long, @NonNull String> res3, + @Named("res4") @NonNull Map<@NonNull String, @NonNull Duration> res4, + @Named("res5") @NonNull Map res5, + @Named("res6") @NonNull List<@NonNull Object> res6, + @Named("res7") @NonNull Map<@NonNull Object, @NonNull Object> res7, + @Named("res8") @NonNull Map<@NonNull String, @NonNull Object> res8, + @Named("res9") @NonNull Map<@NonNull Object, @NonNull Object> res9) + """ + .trimMargin() + ) + }) + } + + @Test + fun `union of string literals`() { + val javaCode = + generateJavaCode( + """ + module mod + + x: "Pigeon"|"Barn Owl"|"Parrot" + """ + .trimIndent() + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces("""public record Mod(@Named("x") @NonNull String x)""") + }) + } + + @Test + fun `other union type`() { + val e = + assertThrows { + generateJavaCode( + """ + module mod + + x: "Pigeon"|Int|"Parrot" + """ + .trimIndent() + ) + } + assertThat(e).hasMessageContaining("Pkl union types are not supported") + } + + @Test + fun `stringy type`() { + val javaCode = + generateJavaCode( + """ + module mod + + v1: "RELEASE" + v2: "RELEASE"|String + v3: String|"RELEASE" + v4: "RELEASE"|String|"LATEST" + v5: Version|String|"LATEST" + v6: (Version|String)|("LATEST"|String) + + typealias Version = "RELEASE"|String|"LATEST" + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("v1") @NonNull String v1, @Named("v2") @NonNull String v2, + @Named("v3") @NonNull String v3, @Named("v4") @NonNull String v4, + @Named("v5") @NonNull String v5, + @Named("v6") @NonNull String v6) + """ + .trimIndent() + ) + } + + @Test + fun `stringy type alias`() { + val javaCode = + generateJavaCode( + """ + module mod + + typealias Version1 = "RELEASE"|String + typealias Version2 = String|"RELEASE" + typealias Version3 = "RELEASE"|String|"LATEST" + typealias Version4 = Version3|String|"LATEST" + typealias Version5 = (Version4|String)|("LATEST"|String) + typealias Version6 = Version5 + + v1: Version1 + v2: Version2 + v3: Version3 + v4: Version4 + v5: Version5 + v6: Version6 + """ + .trimIndent() + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record Mod(@Named("v1") @NonNull String v1, @Named("v2") @NonNull String v2, + @Named("v3") @NonNull String v3, @Named("v4") @NonNull String v4, + @Named("v5") @NonNull String v5, + @Named("v6") @NonNull String v6) + """ + .trimIndent() + ) + } + + @Test + fun `custom constructor parameter annotation`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + name: String + """ + .trimIndent(), + JavaCodeGeneratorOptions( + generateRecords = true, + paramsAnnotation = "org.project.MyAnnotation", + ), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces("import org.project.MyAnnotation;") + .containsIgnoringWhitespaces( + """public record Mod(@MyAnnotation("name") @NonNull String name)""" + ) + } + + @Test + fun `no constructor parameter annotation`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + name: String + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, paramsAnnotation = null), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces("""public record Mod(@NonNull String name)""") + } + + @Test + fun `spring boot config`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + server: Server + + class Server { + port: Int + urls: Listing + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateSpringBootConfig = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + @ConfigurationProperties + public record Mod(Mod. @NonNull Server server) + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + @ConfigurationProperties("server") + public record Server(long port, + @NonNull List<@NonNull URI> urls) + """ + .trimMargin() + ) + .doesNotContain("@ConstructorBinding") + .doesNotContain("@Named") + + // not worthwhile to add spring & spring boot dependency just so that this test can compile + // their annotations + val javaCodeWithoutSpringAnnotations = + javaCode.deleteLines { it.contains("ConfigurationProperties") } + assertThat(javaCodeWithoutSpringAnnotations).compilesSuccessfully() + } + + @Test + fun `import module`() { + val library = + PklModule( + "library", + """ + module library + + class Person { name: String; age: Int } + + pigeon: Person + """ + .trimIndent(), + ) + + val client = + PklModule( + "client", + """ + module client + + import "library.pkl" + + lib: library + + parrot: library.Person + """ + .trimIndent(), + ) + + val javaSourceFiles = generateFiles(library, client) + assertDoesNotThrow { inMemoryCompile(javaSourceFiles.mapValues { it.value.text }) } + + val javaClientCode = + javaSourceFiles.entries.find { (fileName, _) -> fileName.endsWith("Client.java") }!!.value + assertThat(javaClientCode.text) + .containsIgnoringWhitespaces( + """ + public record Client(@Named("lib") @NonNull Library lib, + @Named("parrot") Library. @NonNull Person parrot) + """ + .trimMargin() + ) + } + + @Test + fun `extend module`() { + val base = + PklModule( + "base", + """ + open module base + + open class Person { name: String } + + pigeon: Person + """ + .trimIndent(), + ) + + val derived = + PklModule( + "derived", + """ + module derived + extends "base.pkl" + + class Person2 extends Person { age: Int } + + person1: Person + person2: Person2 + """ + .trimIndent(), + ) + + val javaSourceFiles = generateFiles(base, derived) + assertDoesNotThrow { inMemoryCompile(javaSourceFiles.mapValues { it.value.text }) } + + val javaDerivedCode = + javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value + assertThat(javaDerivedCode.text) + .containsIgnoringWhitespaces( + """ + public record Derived(@Named("pigeon") Base. @NonNull Person pigeon, + @Named("person1") Base. @NonNull Person person1, + @Named("person2") Derived. @NonNull Person2 person2) implements IBase + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Person2(@Named("name") @NonNull String name, + @Named("age") long age) implements Base.IPerson + """ + .trimMargin() + ) + } + + @Test + fun `empty module`() { + val javaCode = generateJavaCode("module mod") + assertThat(javaCode.text).containsIgnoringWhitespaces("public record Mod() {}") + } + + @Test + fun `extend module that only contains type aliases`() { + val base = + PklModule( + "base", + """ + abstract module base + + typealias Version = "LATEST"|String + """ + .trimIndent(), + ) + + val derived = + PklModule( + "derived", + """ + module derived + + extends "base.pkl" + + v: Version = "1.2.3" + """ + .trimIndent(), + ) + + val javaSourceFiles = generateFiles(base, derived) + assertDoesNotThrow { inMemoryCompile(javaSourceFiles.mapValues { it.value.text }) } + + val javaDerivedCode = + javaSourceFiles.entries.find { (filename, _) -> filename.endsWith("Derived.java") }!!.value + assertThat(javaDerivedCode.text) + .containsIgnoringWhitespaces( + """ + public record Derived( + @Named("v") @NonNull String v) implements Base + """ + .trimMargin() + ) + } + + @Test + fun `generated properties files`() { + val pklModule = + PklModule( + "Mod.pkl", + """ + module org.pkl.Mod + + foo: Foo + + bar: Bar + + class Foo { + prop: String + } + + class Bar { + prop: Int + } + """ + .trimIndent(), + ) + val generated = generateFiles(pklModule) + val expectedPropertyFile = + "resources/META-INF/org/pkl/config/java/mapper/classes/org.pkl.Mod.properties" + assertThat(generated).containsKey(expectedPropertyFile) + val generatedFile = generated[expectedPropertyFile]!! + assertThat(generatedFile) + .contains("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") + .contains("org.pkl.config.java.mapper.org.pkl.Mod\\#Bar=org.pkl.Mod\$Bar") + } + + @Test + fun `generated properties files with normalized java name`() { + val pklModule = + PklModule( + "mod.pkl", + """ + module my.mod + + foo: Foo + + bar: Bar + + class Foo { + prop: String + } + + class Bar { + prop: Int + } + """ + .trimIndent(), + ) + val generated = generateFiles(pklModule) + val expectedPropertyFile = + "resources/META-INF/org/pkl/config/java/mapper/classes/my.mod.properties" + assertThat(generated).containsKey(expectedPropertyFile) + val generatedFile = generated[expectedPropertyFile]!! + assertThat(generatedFile) + .contains("org.pkl.config.java.mapper.my.mod\\#ModuleClass=my.Mod") + .contains("org.pkl.config.java.mapper.my.mod\\#Foo=my.Mod\$Foo") + .contains("org.pkl.config.java.mapper.my.mod\\#Bar=my.Mod\$Bar") + } + + @Test + fun `generates serializable classes`() { + val javaCode = + generateJavaCode( + """ + module mod + + class BigStruct { + boolean: Boolean + int: Int + float: Float + string: String + duration: Duration + dataSize: DataSize + pair: Pair + pair2: Pair + coll: Collection + coll2: Collection + list: List + list2: List + set: Set + set2: Set + map: Map + map2: Map + container: Mapping + container2: Mapping + other: SmallStruct + regex: Regex + nonNull: NonNull + enum: Direction + } + + class SmallStruct { + name: String + } + + typealias Direction = "north"|"east"|"south"|"west" + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, implementSerializable = true), + ) + + assertThat(javaCode.text).containsIgnoringWhitespaces("implements Serializable") + + val classes = javaCode.compile() + + val smallStructCtor = classes.getValue("Mod\$SmallStruct").constructors.first() + val smallStruct = smallStructCtor.newInstance("pigeon") + + val enumClass = classes.getValue("Mod\$Direction") + val enumValue = enumClass.enumConstants.first() + + val bigStructCtor = classes.getValue("Mod\$BigStruct").constructors.first() + val bigStruct = + bigStructCtor.newInstance( + true, + 42L, + 42.3, + "string", + Duration(5.0, DurationUnit.MINUTES), + DataSize(3.0, DataSizeUnit.GIGABYTES), + Pair(1, 2), + Pair("pigeon", smallStruct), + listOf(1, 2, 3), + listOf(smallStruct, smallStruct), + listOf(1, 2, 3), + listOf(smallStruct, smallStruct), + setOf(1, 2, 3), + setOf(smallStruct, smallStruct), + mapOf(1 to "one", 2 to "two"), + mapOf("one" to smallStruct, "two" to smallStruct), + mapOf(1 to "one", 2 to "two"), + mapOf("one" to smallStruct, "two" to smallStruct), + smallStruct, + Pattern.compile("(i?)\\w*"), + smallStruct, + enumValue, + ) + + fun confirmSerDe(instance: Any) { + var restoredInstance: Any? = null + + assertThatCode { + // serialize + val baos = ByteArrayOutputStream() + val oos = ObjectOutputStream(baos) + oos.writeObject(instance) + oos.flush() + + // deserialize + val bais = ByteArrayInputStream(baos.toByteArray()) + val ois = + object : ObjectInputStream(bais) { + override fun resolveClass(desc: ObjectStreamClass?): Class<*> { + return Class.forName(desc!!.name, false, instance.javaClass.classLoader) + } + } + restoredInstance = ois.readObject() + } + .doesNotThrowAnyException() + + val patternCompare = BiPredicate { p1: Pattern, p2: Pattern -> + p1.toString().equals(p2.toString()) + } + + assertThat(restoredInstance!!) + .usingRecursiveComparison() + .withEqualsForType(patternCompare, Pattern::class.java) + .isEqualTo(instance) + } + + confirmSerDe(enumValue) + confirmSerDe(smallStruct) + confirmSerDe(bigStruct) + } + + @Test + fun `non-instantiable classes aren't made serializable`() { + var javaCode = + generateJavaCode( + """ + module my.mod + abstract class Foo { str: String } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, implementSerializable = true), + ) + + assertThat(javaCode.text).doesNotContain("Serializable") + + javaCode = + generateJavaCode( + """ + module my.mod + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, implementSerializable = true), + ) + + assertThat(javaCode.text).doesNotContain("Serializable") + } + + @Test + fun `generates serializable module classes`() { + val javaCode = + generateJavaCode( + """ + module Person + name: String + address: Address + class Address { city: String } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, implementSerializable = true), + ) + + assertThat(javaCode.text) + .containsIgnoringWhitespaces( + """ + public record Person(@Named("name") @NonNull String name, + @Named("address") Person. @NonNull Address address) implements Serializable + """ + .trimMargin() + ) + .containsIgnoringWhitespaces( + """ + public record Address( + @Named("city") @NonNull String city) implements Serializable, Wither { + """ + .trimMargin() + ) + } + + @Test + fun `override property type`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + open class Foo + + class TheFoo extends Foo { + fooProp: String + } + + open class OpenClass { + prop: Foo + } + + class TheClass extends OpenClass { + prop: TheFoo + } + """ + .trimIndent() + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod() { + public interface IFoo { + } + + public record Foo() implements IFoo, Wither { + @Override + public Foo with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + private Memento(final Foo r) { + } + + private Foo build() { + return new Foo(); + } + } + } + + public record TheFoo( + @Named("fooProp") @NonNull String fooProp) implements IFoo, Wither { + @Override + public TheFoo with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull String fooProp; + + private Memento(final TheFoo r) { + fooProp = r.fooProp; + } + + private TheFoo build() { + return new TheFoo(fooProp); + } + } + } + + public interface IOpenClass { + @NonNull IFoo prop(); + } + + public record OpenClass( + @Named("prop") @NonNull Foo prop) implements IOpenClass, Wither { + @Override + public OpenClass with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull Foo prop; + + private Memento(final OpenClass r) { + prop = r.prop; + } + + private OpenClass build() { + return new OpenClass(prop); + } + } + } + + public record TheClass( + @Named("prop") @NonNull TheFoo prop) implements IOpenClass, Wither { + @Override + public TheClass with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull TheFoo prop; + + private Memento(final TheClass r) { + prop = r.prop; + } + + private TheClass build() { + return new TheClass(prop); + } + } + } + } + """ + .trimMargin() + ) + }) + } + + @Test + fun `override property type, with getters`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + open class Foo + + class TheFoo extends Foo { + fooProp: String + } + + open class OpenClass { + prop: Foo + } + + class TheClass extends OpenClass { + prop: TheFoo + } + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateRecords = true, generateGetters = true), + ) + + assertThat(javaCode) + .compilesSuccessfully() + .satisfies({ + assertThat(it.text) + .containsIgnoringWhitespaces( + """ + public record Mod() { + public interface IFoo { + } + + public record Foo() implements IFoo, Wither { + @Override + public Foo with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + private Memento(final Foo r) { + } + + private Foo build() { + return new Foo(); + } + } + } + + public record TheFoo( + @Named("fooProp") @NonNull String fooProp) implements IFoo, Wither { + @Override + public TheFoo with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull String fooProp; + + private Memento(final TheFoo r) { + fooProp = r.fooProp; + } + + private TheFoo build() { + return new TheFoo(fooProp); + } + } + } + + public interface IOpenClass { + @NonNull IFoo prop(); + } + + public record OpenClass( + @Named("prop") @NonNull Foo prop) implements IOpenClass, Wither { + @Override + public OpenClass with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull Foo prop; + + private Memento(final OpenClass r) { + prop = r.prop; + } + + private OpenClass build() { + return new OpenClass(prop); + } + } + } + + public record TheClass( + @Named("prop") @NonNull TheFoo prop) implements IOpenClass, Wither { + @Override + public TheClass with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull TheFoo prop; + + private Memento(final TheClass r) { + prop = r.prop; + } + + private TheClass build() { + return new TheClass(prop); + } + } + } + } + """ + .trimMargin() + ) + }) + } + + @Test + fun `override names in a standalone module`() { + val files = + JavaCodeGeneratorOptions( + generateRecords = true, + 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 record 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 record 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 = + JavaCodeGeneratorOptions( + generateRecords = true, + 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 record 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 record 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 record 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 = + JavaCodeGeneratorOptions( + generateRecords = true, + 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 record 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 record RenamedModule(", + """@Named("owner") 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 record Module3(", + """@Named("owner") 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 = + JavaCodeGeneratorOptions( + generateRecords = true, + 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 record 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 record 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"), + ) + } + + @Test + fun `equals,hashCode,toString work correctly for class that doesn't declare properties`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + open class Foo { + name: String + } + + class Bar extends Foo {} + """ + .trimIndent() + ) + + val classes = javaCode.compile() + val fooClass = classes.getValue("my.Mod\$Foo") + val foo1 = fooClass.getDeclaredConstructor(String::class.java).newInstance("name1") + val barClass = classes.getValue("my.Mod\$Bar") + val bar1 = barClass.getDeclaredConstructor(String::class.java).newInstance("name1") + val anotherBar1 = barClass.getDeclaredConstructor(String::class.java).newInstance("name1") + val bar2 = barClass.getDeclaredConstructor(String::class.java).newInstance("name2") + + assertThat(bar1) + .isEqualTo(bar1) + .isEqualTo(anotherBar1) + .isNotEqualTo(bar2) + .isNotEqualTo(foo1) + .hasSameHashCodeAs(bar1) + .hasSameHashCodeAs(anotherBar1) + assertThat(bar1.toString()).isEqualToIgnoringWhitespace("Bar[name=name1]") + } + + 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 JavaCodeGeneratorOptions.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)) + val generator = JavaRecordCodeGenerator(pklSchema, this) + acc + generator.output + } + } + + private fun JavaCodeGeneratorOptions.generateFiles( + vararg pklModules: kotlin.Pair + ): Map = + generateFiles(*pklModules.map { (name, text) -> PklModule(name, text) }.toTypedArray()) + + private fun generateFiles(vararg pklModules: PklModule): Map = + JavaCodeGeneratorOptions(generateRecords = true).generateFiles(*pklModules).mapValues { + JavaSourceCode(it.value) + } + + private fun instantiateOtherAndPropertyTypes(): kotlin.Pair { + val otherCtor = propertyTypesClasses.getValue("my.Mod\$Other").constructors.first() + val other = otherCtor.newInstance("pigeon") + + val enumClass = propertyTypesClasses.getValue("my.Mod\$Direction") + val enumValue = enumClass.enumConstants.first() + + val propertyTypesCtor = + propertyTypesClasses.getValue("my.Mod\$PropertyTypes").constructors.first() + val propertyTypes = + propertyTypesCtor.newInstance( + true, + 42, + 42.3, + "string", + Duration(5.0, DurationUnit.MINUTES), + DurationUnit.MINUTES, + DataSize(3.0, DataSizeUnit.GIGABYTES), + DataSizeUnit.GIGABYTES, + "idea", + (null as String?), + Pair(1, 2), + Pair("pigeon", other), + listOf(1, 2, 3), + listOf(other, other), + listOf(1, 2, 3), + listOf(other, other), + setOf(1, 2, 3), + setOf(other, other), + mapOf(1 to "one", 2 to "two"), + mapOf("one" to other, "two" to other), + mapOf(1 to "one", 2 to "two"), + mapOf("one" to other, "two" to other), + other, + Pattern.compile("(i?)\\w*"), + other, + other, + enumValue, + ) + + return other to propertyTypes + } + + private fun assertThat(actual: JavaSourceCode): JavaSourceCodeAssert = + JavaSourceCodeAssert(actual) + + private data class JavaSourceCode(val text: String) { + fun compile(): Map> = inMemoryCompile(mapOf("/org/Mod.java" to text)) + + fun deleteLines(predicate: (String) -> Boolean): JavaSourceCode = + JavaSourceCode(text.lines().filterNot(predicate).joinToString("\n")) + } + + private class JavaSourceCodeAssert(actual: JavaSourceCode) : + AbstractAssert(actual, JavaSourceCodeAssert::class.java) { + fun contains(expected: String): JavaSourceCodeAssert { + if (!actual.text.contains(expected)) { + // check for equality to get better error output (IDE diff dialog) + assertThat(actual.text).isEqualTo(expected) + } + return this + } + + fun doesNotContain(expected: String): JavaSourceCodeAssert { + assertThat(actual.text).doesNotContain(expected) + return this + } + + fun compilesSuccessfully(): JavaSourceCodeAssert { + assertThatCode { actual.compile() }.doesNotThrowAnyException() + return this + } + + fun isEqualTo(expected: String): JavaSourceCodeAssert { + assertThat(actual.text).isEqualTo(expected) + return this + } + + fun isEqualToResourceFile(fileName: String): JavaSourceCodeAssert { + isEqualTo(IoUtils.readClassPathResourceAsString(javaClass, fileName)) + return this + } + } +} diff --git a/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/InheritanceRecord.jva b/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/InheritanceRecord.jva new file mode 100644 index 000000000..00e276f74 --- /dev/null +++ b/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/InheritanceRecord.jva @@ -0,0 +1,96 @@ +package my; + +import java.lang.Override; +import java.lang.String; +import java.util.function.Consumer; +import org.pkl.codegen.java.common.code.Wither; +import org.pkl.config.java.mapper.Named; +import org.pkl.config.java.mapper.NonNull; +import org.pkl.core.Duration; + +public record Mod() { + public interface Foo { + long one(); + } + + public interface INone extends Foo { + } + + public record None(@Named("one") long one) implements Foo, INone, Wither { + @Override + public None with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public long one; + + private Memento(final None r) { + one = r.one; + } + + private None build() { + return new None(one); + } + } + } + + public interface IBar extends INone { + String two(); + } + + public record Bar(@Named("one") long one, + @Named("two") String two) implements INone, IBar, Wither { + @Override + public Bar with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public long one; + + public String two; + + private Memento(final Bar r) { + one = r.one; + two = r.two; + } + + private Bar build() { + return new Bar(one, two); + } + } + } + + public record Baz(@Named("one") long one, @Named("two") String two, + @Named("three") @NonNull Duration three) implements IBar, Wither { + @Override + public Baz with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public long one; + + public String two; + + public @NonNull Duration three; + + private Memento(final Baz r) { + one = r.one; + two = r.two; + three = r.three; + } + + private Baz build() { + return new Baz(one, two, three); + } + } + } +} diff --git a/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/JavadocRecord.jva b/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/JavadocRecord.jva new file mode 100644 index 000000000..9dfc1e4a5 --- /dev/null +++ b/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/JavadocRecord.jva @@ -0,0 +1,66 @@ +package my; + +import java.lang.Override; +import java.lang.String; +import java.util.function.Consumer; +import org.pkl.codegen.java.common.code.Wither; +import org.pkl.config.java.mapper.Named; +import org.pkl.config.java.mapper.NonNull; + +/** + * module comment. + * *emphasized* `code`. + * + * @param pigeon module property comment. + * *emphasized* `code`. + */ +public record Mod( + @Named("pigeon") Mod. @NonNull Person pigeon) implements Wither { + @Override + public Mod with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull Person pigeon; + + private Memento(final Mod r) { + pigeon = r.pigeon; + } + + private Mod build() { + return new Mod(pigeon); + } + } + + /** + * class comment. + * *emphasized* `code`. + * + * @param name class property comment. + * *emphasized* `code`. + */ + public record Person( + @Named("name") @NonNull String name) implements Wither { + @Override + public Person with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull String name; + + private Memento(final Person r) { + name = r.name; + } + + private Person build() { + return new Person(name); + } + } + } +} diff --git a/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/PropertyTypesRecord.jva b/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/PropertyTypesRecord.jva new file mode 100644 index 000000000..1e1f06421 --- /dev/null +++ b/pkl-codegen-java/src/test/resources/org/pkl/codegen/java/PropertyTypesRecord.jva @@ -0,0 +1,181 @@ +package my; + +import java.lang.Object; +import java.lang.Override; +import java.lang.String; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import org.pkl.codegen.java.common.code.Wither; +import org.pkl.config.java.mapper.Named; +import org.pkl.config.java.mapper.NonNull; +import org.pkl.core.DataSize; +import org.pkl.core.DataSizeUnit; +import org.pkl.core.Duration; +import org.pkl.core.DurationUnit; +import org.pkl.core.Pair; + +public record Mod() { + public record PropertyTypes(@Named("boolean") boolean _boolean, @Named("int") long _int, + @Named("float") double _float, @Named("string") @NonNull String string, + @Named("duration") @NonNull Duration duration, + @Named("durationUnit") @NonNull DurationUnit durationUnit, + @Named("dataSize") @NonNull DataSize dataSize, + @Named("dataSizeUnit") @NonNull DataSizeUnit dataSizeUnit, @Named("nullable") String nullable, + @Named("nullable2") String nullable2, @Named("pair") @NonNull Pair pair, + @Named("pair2") @NonNull Pair<@NonNull String, @NonNull Other> pair2, + @Named("coll") @NonNull Collection coll, + @Named("coll2") @NonNull Collection<@NonNull Other> coll2, + @Named("list") @NonNull List list, + @Named("list2") @NonNull List<@NonNull Other> list2, @Named("set") @NonNull Set set, + @Named("set2") @NonNull Set<@NonNull Other> set2, + @Named("map") @NonNull Map map, + @Named("map2") @NonNull Map<@NonNull String, @NonNull Other> map2, + @Named("container") @NonNull Map container, + @Named("container2") @NonNull Map<@NonNull String, @NonNull Other> container2, + @Named("other") @NonNull Other other, @Named("regex") @NonNull Pattern regex, + @Named("any") Object any, @Named("nonNull") @NonNull Object nonNull, + @Named("enum") @NonNull Direction _enum) implements Wither { + @Override + public PropertyTypes with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public boolean _boolean; + + public long _int; + + public double _float; + + public @NonNull String string; + + public @NonNull Duration duration; + + public @NonNull DurationUnit durationUnit; + + public @NonNull DataSize dataSize; + + public @NonNull DataSizeUnit dataSizeUnit; + + public String nullable; + + public String nullable2; + + public @NonNull Pair pair; + + public @NonNull Pair<@NonNull String, @NonNull Other> pair2; + + public @NonNull Collection coll; + + public @NonNull Collection<@NonNull Other> coll2; + + public @NonNull List list; + + public @NonNull List<@NonNull Other> list2; + + public @NonNull Set set; + + public @NonNull Set<@NonNull Other> set2; + + public @NonNull Map map; + + public @NonNull Map<@NonNull String, @NonNull Other> map2; + + public @NonNull Map container; + + public @NonNull Map<@NonNull String, @NonNull Other> container2; + + public @NonNull Other other; + + public @NonNull Pattern regex; + + public Object any; + + public @NonNull Object nonNull; + + public @NonNull Direction _enum; + + private Memento(final PropertyTypes r) { + _boolean = r._boolean; + _int = r._int; + _float = r._float; + string = r.string; + duration = r.duration; + durationUnit = r.durationUnit; + dataSize = r.dataSize; + dataSizeUnit = r.dataSizeUnit; + nullable = r.nullable; + nullable2 = r.nullable2; + pair = r.pair; + pair2 = r.pair2; + coll = r.coll; + coll2 = r.coll2; + list = r.list; + list2 = r.list2; + set = r.set; + set2 = r.set2; + map = r.map; + map2 = r.map2; + container = r.container; + container2 = r.container2; + other = r.other; + regex = r.regex; + any = r.any; + nonNull = r.nonNull; + _enum = r._enum; + } + + private PropertyTypes build() { + return new PropertyTypes(_boolean, _int, _float, string, duration, durationUnit, dataSize, dataSizeUnit, nullable, nullable2, pair, pair2, coll, coll2, list, list2, set, set2, map, map2, container, container2, other, regex, any, nonNull, _enum); + } + } + } + + public record Other(@Named("name") @NonNull String name) implements Wither { + @Override + public Other with(final Consumer setter) { + final var memento = new Memento(this); + setter.accept(memento); + return memento.build(); + } + + public static final class Memento { + public @NonNull String name; + + private Memento(final Other r) { + name = r.name; + } + + private Other build() { + return new Other(name); + } + } + } + + public enum Direction { + NORTH("north"), + + EAST("east"), + + SOUTH("south"), + + WEST("west"); + + private String value; + + private Direction(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + } +} diff --git a/pkl-config-java/pkl-config-java.gradle.kts b/pkl-config-java/pkl-config-java.gradle.kts index fc381c18a..151b47500 100644 --- a/pkl-config-java/pkl-config-java.gradle.kts +++ b/pkl-config-java/pkl-config-java.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2025 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. @@ -40,9 +40,31 @@ val generateTestConfigClasses by ) } -tasks.processTestResources { dependsOn(generateTestConfigClasses) } +val generateTestConfigRecordClasses by + tasks.registering(JavaExec::class) { + val outputDir = layout.buildDirectory.dir("testConfigRecordClasses") + outputs.dir(outputDir) + inputs.dir("src/test/resources/recordsCodegenPkl") + + classpath = pklCodegenJava + mainClass.set("org.pkl.codegen.java.Main") + argumentProviders.add( + CommandLineArgumentProvider { + listOf( + "--output-dir", + outputDir.get().asFile.path, + "--generate-javadoc", + "--generate-records", + // "--rename", + // "com.example.=com.example.records.", + ) + fileTree("src/test/resources/recordsCodegenPkl").map { it.path } + } + ) + } + +tasks.processTestResources { dependsOn(generateTestConfigClasses, generateTestConfigRecordClasses) } -tasks.compileTestKotlin { dependsOn(generateTestConfigClasses) } +tasks.compileTestKotlin { dependsOn(generateTestConfigClasses, generateTestConfigRecordClasses) } val bundleTests by tasks.registering(Jar::class) { from(sourceSets.test.get().output) } @@ -75,6 +97,9 @@ val testFromJar by sourceSets.getByName("test") { java.srcDir(layout.buildDirectory.dir("testConfigClasses/java")) resources.srcDir(layout.buildDirectory.dir("testConfigClasses/resources")) + + java.srcDir(layout.buildDirectory.dir("testConfigRecordClasses/java")) + resources.srcDir(layout.buildDirectory.dir("testConfigRecordClasses/resources")) } dependencies { diff --git a/pkl-config-java/src/test/java/org/pkl/config/java/mapper/PObjectToDataObjectOverriddenPropertyTest.java b/pkl-config-java/src/test/java/org/pkl/config/java/mapper/PObjectToDataObjectOverriddenPropertyTest.java index 6ea1f78e7..c9f8966b6 100644 --- a/pkl-config-java/src/test/java/org/pkl/config/java/mapper/PObjectToDataObjectOverriddenPropertyTest.java +++ b/pkl-config-java/src/test/java/org/pkl/config/java/mapper/PObjectToDataObjectOverriddenPropertyTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2025 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. @@ -18,10 +18,12 @@ import static org.assertj.core.api.Assertions.assertThat; import com.example.OverriddenProperty; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.pkl.config.java.ConfigEvaluator; import org.pkl.core.ModuleSource; +@Tag("generate-classes") class PObjectToDataObjectOverriddenPropertyTest { @Test void overriddenProperty() { diff --git a/pkl-config-java/src/test/java/org/pkl/config/java/mapper/RecordPObjectToDataObjectOverriddenPropertyTest.java b/pkl-config-java/src/test/java/org/pkl/config/java/mapper/RecordPObjectToDataObjectOverriddenPropertyTest.java new file mode 100644 index 000000000..010d46d4c --- /dev/null +++ b/pkl-config-java/src/test/java/org/pkl/config/java/mapper/RecordPObjectToDataObjectOverriddenPropertyTest.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2024-2025 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.config.java.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.records.OverriddenProperty; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.pkl.config.java.ConfigEvaluator; +import org.pkl.core.ModuleSource; + +@Tag("generate-records") +class RecordPObjectToDataObjectOverriddenPropertyTest { + @Test + void overriddenProperty() { + try (var evaluator = ConfigEvaluator.preconfigured()) { + var result = + evaluator + .evaluate(ModuleSource.modulePath("/codegenPkl/OverriddenProperty.pkl")) + .as(OverriddenProperty.class); + assertThat(result.theClass().bar().get(0).prop1()).isEqualTo("hello"); + assertThat(result.theClass().bar().get(0).prop2()).isEqualTo("hello again"); + } + } +} diff --git a/pkl-config-java/src/test/resources/recordsCodegenPkl/OverriddenProperty.pkl b/pkl-config-java/src/test/resources/recordsCodegenPkl/OverriddenProperty.pkl new file mode 100644 index 000000000..6211eba96 --- /dev/null +++ b/pkl-config-java/src/test/resources/recordsCodegenPkl/OverriddenProperty.pkl @@ -0,0 +1,28 @@ +module com.example.records.OverriddenProperty + +abstract class BaseClass { + fixed bar: Listing = new { + new { + prop1 = "hello" + } + } +} + +theClass: TheClass + +class TheClass extends BaseClass { + fixed bar: Listing = new { + new { + prop1 = "hello" + prop2 = "hello again" + } + } +} + +open class BaseBar { + prop1: String +} + +class Bar extends BaseBar { + prop2: String +} diff --git a/pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicLib.pkl b/pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicLib.pkl new file mode 100644 index 000000000..99e122f2c --- /dev/null +++ b/pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicLib.pkl @@ -0,0 +1,14 @@ +module com.example.records.lib + +open class Airplane { + name: String + numSeats: Int +} + +class Jet extends Airplane { + isSuperSonic: Boolean +} + +class Propeller extends Airplane { + isTurboprop: Boolean +} diff --git a/pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicModuleTest.pkl b/pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicModuleTest.pkl new file mode 100644 index 000000000..b5c5320e3 --- /dev/null +++ b/pkl-config-java/src/test/resources/recordsCodegenPkl/PolymorphicModuleTest.pkl @@ -0,0 +1,24 @@ +/// Gets generated into a Java via Gradle task `generateTestConfigClasses`. +module com.example.records.PolymorphicModuleTest + +import "PolymorphicLib.pkl" + +abstract class Dessert + +class Strudel extends Dessert { + numberOfRolls: Int +} + +class TurkishDelight extends Dessert { + isOfferedToEdmund: Boolean +} + +desserts: Listing = new { + new Strudel { numberOfRolls = 3 } + new TurkishDelight { isOfferedToEdmund = true } +} + +planes: Listing = new { + new PolymorphicLib.Jet { name = "Concorde"; numSeats = 128; isSuperSonic = true } + new PolymorphicLib.Propeller { name = "Cessna 172"; numSeats = 4; isTurboprop = true } +} 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 60904e4b5..1e9ffc729 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -192,6 +192,7 @@ private void configureJavaCodeGenTasks(NamedDomainObjectContainer getGenerateJavadoc(); + Property getGenerateRecords(); + Property getParamsAnnotation(); Property getNonNullAnnotation(); 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 3f8abc1fc..f36a6a13b 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 @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2025 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. @@ -37,6 +37,10 @@ public abstract class JavaCodeGenTask extends CodeGenTask { @Optional public abstract Property getNonNullAnnotation(); + @Input + @Optional + public abstract Property getGenerateRecords(); + @Override protected void doRunTask() { //noinspection ResultOfMethodCallIgnored @@ -53,7 +57,8 @@ protected void doRunTask() { getParamsAnnotation().getOrNull(), getNonNullAnnotation().getOrNull(), getImplementSerializable().get(), - getRenames().get())) + getRenames().get(), + getGenerateRecords().get())) .run(); } } diff --git a/pkl-gradle/src/test/kotlin/org/pkl/gradle/AbstractTest.kt b/pkl-gradle/src/test/kotlin/org/pkl/gradle/AbstractTest.kt index 90f81e903..9d8253af8 100644 --- a/pkl-gradle/src/test/kotlin/org/pkl/gradle/AbstractTest.kt +++ b/pkl-gradle/src/test/kotlin/org/pkl/gradle/AbstractTest.kt @@ -65,16 +65,16 @@ abstract class AbstractTest { protected fun checkFileContents(file: Path, contents: String) { assertThat(file).exists() - assertThat(file.readString().trim()).isEqualTo(contents.trim()) + assertThat(file.readString().trim()).isEqualToIgnoringWhitespace(contents.trim()) } protected fun checkTextContains(text: String, vararg contents: String) { for (content in contents) { try { - assertThat(text).contains(content.trimMargin()) + assertThat(text).containsIgnoringWhitespaces(content.trimMargin()) } catch (e: AssertionError) { // to get diff output in IDE - assertThat(text).isEqualTo(content.trimMargin()) + assertThat(text).isEqualToIgnoringWhitespace(content.trimMargin()) } } } 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 68a5b5336..59dee8004 100644 --- a/pkl-gradle/src/test/kotlin/org/pkl/gradle/JavaCodeGeneratorsTest.kt +++ b/pkl-gradle/src/test/kotlin/org/pkl/gradle/JavaCodeGeneratorsTest.kt @@ -21,6 +21,71 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test class JavaCodeGeneratorsTest : AbstractTest() { + @Test + fun `generate records code`() { + writeBuildFile(isGenerateRecords = true) + writePklFile() + + runTask("configClasses") + + val commonCodeDir = + testProjectDir.resolve("build/generated/java/org/pkl/codegen/java/common/code") + val witherFile = commonCodeDir.resolve("Wither.java") + + assertThat(commonCodeDir.listDirectoryEntries().size).isEqualTo(1) + assertThat(witherFile).exists() + + val witherText = witherFile.readText() + + assertThat(witherText) + .isEqualToIgnoringWhitespace( + """ + package org.pkl.codegen.java.common.code; + + import java.util.function.Consumer; + + public interface Wither { + R with(Consumer setter); + } + """ + .trimIndent() + ) + + val baseDir = testProjectDir.resolve("build/generated/java/foo/bar") + val moduleFile = baseDir.resolve("Mod.java") + + assertThat(baseDir.listDirectoryEntries().size).isEqualTo(1) + assertThat(moduleFile).exists() + + val text = moduleFile.readText() + + // shading must not affect generated code + assertThat(text).doesNotContain("org.pkl.thirdparty") + + checkTextContains( + text, + """ + public record Mod(@Named("other") @Nonnull Object other) implements Wither { + """, + ) + + checkTextContains( + text, + """ + public record Person(@Named("name") @Nonnull String name, + @Named("addresses") @Nonnull List
addresses) implements Wither { + """, + ) + + checkTextContains( + text, + """ + public record Address(@Named("street") @Nonnull String street, + @Named("zip") long zip) implements Wither { + """, + ) + } + @Test fun `generate code`() { writeBuildFile() @@ -68,6 +133,36 @@ class JavaCodeGeneratorsTest : AbstractTest() { ) } + @Test + fun `compile generated records code`() { + writeBuildFile(isGenerateRecords = true) + writePklFile() + + runTask("compileJava") + + val classesDir = testProjectDir.resolve("build/classes/java/main") + + // common classes + val witherClassFile = classesDir.resolve("org/pkl/codegen/java/common/code/Wither.class") + + // module classes + 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") + val modMementoClassFile = classesDir.resolve("foo/bar/Mod\$Memento.class") + val modPersonMementoClassFile = classesDir.resolve("foo/bar/Mod\$Person\$Memento.class") + val modAddressMementoClassFile = classesDir.resolve("foo/bar/Mod\$Address\$Memento.class") + + assertThat(witherClassFile).exists() + + assertThat(moduleClassFile).exists() + assertThat(personClassFile).exists() + assertThat(addressClassFile).exists() + assertThat(modMementoClassFile).exists() + assertThat(modPersonMementoClassFile).exists() + assertThat(modAddressMementoClassFile).exists() + } + @Test fun `compile generated code`() { writeBuildFile() @@ -107,7 +202,7 @@ class JavaCodeGeneratorsTest : AbstractTest() { assertThat(result.output).contains("No source modules specified.") } - private fun writeBuildFile() { + private fun writeBuildFile(isGenerateRecords: Boolean = false) { writeFile( "build.gradle", """ @@ -132,6 +227,7 @@ class JavaCodeGeneratorsTest : AbstractTest() { outputDir = file("build/generated") paramsAnnotation = "javax.inject.Named" nonNullAnnotation = "javax.annotation.Nonnull" + generateRecords = $isGenerateRecords settingsModule = "pkl:settings" renames = [ 'org': 'foo.bar' From baec91efbb2802d32928800fd7f3af51542fd13a Mon Sep 17 00:00:00 2001 From: David Tesler Date: Sat, 22 Feb 2025 17:02:08 -0800 Subject: [PATCH 2/9] Java class and record generators and friends side-by-side 1. Java code generation via Java Records and 2. Wither implementation close to JEP 468 3. extra "generateRecords" option in Gradle JavaCodeGenTask 4. extra "--generate-records" option in pkl-codegen-java CLI 5. updated docs see pkl-codegen-java/README.md --- .idea/codeStyles/Project.xml | 2 - .../pkl/codegen/java/CliJavaCodeGenerator.kt | 13 +- .../codegen/java/JavaRecordCodeGenerator.kt | 183 +++++++------- .../java/JavaRecordCodeGeneratorTest.kt | 228 +++++++----------- .../pkl/codegen/java/InheritanceRecord.jva | 18 +- .../org/pkl/codegen/java/JavadocRecord.jva | 12 +- .../pkl/codegen/java/PropertyTypesRecord.jva | 12 +- .../org/pkl/gradle/JavaCodeGeneratorsTest.kt | 78 +++--- 8 files changed, 259 insertions(+), 287 deletions(-) diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index afb7ce46c..1fa98d1f5 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -239,10 +239,8 @@ -