Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented package mapping for Java/Kotlin code generation #499

Merged
merged 11 commits into from
Jun 12, 2024

Conversation

netvl
Copy link
Contributor

@netvl netvl commented May 17, 2024

See #456 for motivation.

  • Implement package mapping in Java codegen, add tests
  • Implement package mapping in Kotlin codegen, add tests
  • Propagate configuration to the Gradle plugin

Closes #456

@netvl
Copy link
Contributor Author

netvl commented May 17, 2024

@bioball - I've started the package mapping implementation, just wanted to show the overall direction before I start implementing it for Kotlin. What do you think?

Copy link
Contributor

@bioball bioball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks on the right track!

* When this option is set, the mapping will be used to translate package names derived from
* module names to the specified package names.
*/
val packageMapping: String? = null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should accept a structured Map<String, String>; trace how externalProperties and environmentVariables get populated from the CLI args parser.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the pointer, I hoped that there is something like that!

@netvl netvl marked this pull request as ready for review May 30, 2024 20:23
Copy link
Contributor

@bioball bioball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks pretty good! I am thinking we should adjust how the mapping works, though (see comment in review)

@@ -108,6 +109,18 @@ class PklJavaCodegenCommand :
)
.flag()

private val packageMapping: Map<String, String> by
option(
names = arrayOf("-m", "--package-mapping"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tend to be conservative about which flags get short flag names, because they quickly pollute the namespace.

I think this one should just be kept as a long flag name.

Suggested change
names = arrayOf("-m", "--package-mapping"),
names = arrayOf("--package-mapping"),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

@@ -127,6 +129,9 @@ class JavaCodeGeneratorTest {
}
}

@TempDir
lateinit var tempDir: Path
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this moved to the test suite level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two reasons:

  1. tempDir is used by helper methods below, which makes test code simpler compared to constant passing of tempDir as an argument.
  2. Removed repetition because this parameter was present on lots of test methods.

In general, there is not much difference between a field and a method parameter for this use case. JUnit Jupiter always creates a fresh instance of the test class for each test, and it always populates the marked fields before the tests are run, so defining temp dir as a variable allows to simplify some of the helper methods logic and reduce duplication.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, TIL!

)

// No more files.
assertThat(files).isEmpty()
Copy link
Contributor

@bioball bioball Jun 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I typically write these tests this way, because it's clearer what the assertion is and helps with the error message when it fails.

assertThat(files).containsOnlyKeys("foo", "bar", "bar")
assertThat(files["foo"]).contains("something text")
assertThat(files["bar"]).contains("something text")

This isn't a blocker, though, feel free to just keep this as-is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't have much preference either; I chose the current style because this way, I can avoid repeating the map keys.

@@ -872,6 +870,15 @@ class JavaCodeGenerator(
} else key
}
}

private fun mapPackageName(name: String): String =
codegenOptions.packageMapping[name] ?: name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think instead of doing an exact match, this should be a prefix match. That would make this functionality a lot more useful. Also, with an exact match, this seems a little brittle. If a new Pkl module gets added at some subpath, it will fail to match and thus not be mapped to a custom name.

Prefix everything:

mapOf("" to "com.foo.")

Prefix everything starting with "foo." to "com.foo":

mapOf("foo." to "com.foo.")

I think the logic should work like so:

  1. Sort all the mapping entries by length (greatest first)
  2. Find the first entry that matches by prefix

This way, the most specific prefix has greater precedence; the "com.foo" pattern beats the "com" pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this definitely does make sense, and makes the feature more powerful and useful, thanks for the suggestion!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

private fun writePklFile() {
writeFile(
"mod.pkl", """
module org.mod

class Person {
name: String
addresses: List<Address>
addresses: List<Address?>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, there was two almost identical Pkl scripts here, whose difference was basically in this ?. The question mark was necessary, because apparently the code generated without this question mark fails to compile with some really strange error about annotations. I unified these two scripts to avoid repetition, and to make sure that the code can be compiled, I switched it to be nullable. I think it does not matter much here because we are not really concerned with the actual contents of the generated classes, we check that the Gradle plugin works and can be used to generate and compile code properly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, probably because the in-memory compiler didn't have access to our NonNull annotation. Gotcha, thanks for the explanation.


private val packageMapping: Map<String, String> by
option(
names = arrayOf("-m", "--package-mapping"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment earlier about short names

Suggested change
names = arrayOf("-m", "--package-mapping"),
names = arrayOf("--package-mapping"),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!


When you need the generated package names to be different from the default package names derived from Pkl module name prefixes, you can define a package mapping, where the key is the original Pkl module name prefix, and the value is its replacement. When you do, the generated code `package` declarations, as well as file locations, will be modified according to the mapping.

Repeat this option to define multiple mappings. Keys must be valid Pkl module name prefixes. Values must be valid dot-separated package names.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, keys can be anything; and if they don't match anything, they are ignored.

Suggested change
Repeat this option to define multiple mappings. Keys must be valid Pkl module name prefixes. Values must be valid dot-separated package names.
Repeat this option to define multiple mappings. Values must be valid dot-separated package names.

Also: our style here is to separate every sentence to be on their own line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I definitely saw some paragraphs with multiple sentences in one line, that's why I was not concerned about it. I will check again and fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

@bioball
Copy link
Contributor

bioball commented Jun 5, 2024

FYI @sgammon: you might be interested in this.

@sgammon
Copy link
Contributor

sgammon commented Jun 5, 2024

@bioball yes, I very much am! thanks for the tag. I'd be happy to test this if helpful.

netvl added 2 commits June 7, 2024 20:47
* Implemented prefix-based mapping instead of literal matching.
* Removed single-letter name for the new CLI flag.
* Fixed formatting.
Now it is possible to rename not just the package name,
but the class name entirely.
Copy link
Contributor

@bioball bioball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly looks good!

I have mostly nits, but one remaining change here is around the logic for deriving Java/Kotlin class names, where we don't rename yet again for class names that are explicitly renamed.

@@ -125,6 +125,9 @@ class KotlinCodeGeneratorsTest : AbstractTest() {
sourceModules = ["mod.pkl"]
outputDir = file("build/generated")
settingsModule = "pkl:settings"
packageMapping = [
'org': 'foo.bar'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'org': 'foo.bar'
'org.': 'foo.bar.'

@@ -127,6 +129,9 @@ class JavaCodeGeneratorTest {
}
}

@TempDir
lateinit var tempDir: Path
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, TIL!

names = arrayOf("--package-mapping"),
metavar = "<module_prefix=package_prefix>",
names = arrayOf("--rename"),
metavar = "<module_prefix=class_prefix>",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
metavar = "<module_prefix=class_prefix>",
metavar = "<old_name=new_name>",

names = arrayOf("--package-mapping"),
metavar = "<module_prefix=package_prefix>",
names = arrayOf("--rename"),
metavar = "<module_prefix=class_prefix>",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
metavar = "<module_prefix=class_prefix>",
metavar = "<old_name=new_name>",

}

@Test
fun `override package names based on the longest prefix`() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fun `override package names based on the longest prefix`() {
fun `override names based on the longest prefix`() {

acc + JavaCodeGenerator(pklSchema, JavaCodegenOptions()).output
}
@Test
fun `override package names in a standalone module`() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fun `override package names in a standalone module`() {
fun `override names in a standalone module`() {

val packageName = mappedName.substringBeforeLast('.', "")
val className = mappedName.substringAfterLast('.').replaceFirstChar { it.titlecaseChar() }
return packageName to className
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this is a straight up rename, I think we should adjust this so that we don't then change the name again if it's been explicitly renamed.

I think the logic here can be adjusted to something like:

  private fun mapModuleName(name: String): kotlin.Pair<String, String> {
    val (mappedName, isClassRenamed) = nameMapper.map(name)
    val packageName = mappedName.substringBeforeLast('.', "")
    val className = mappedName.substringAfterLast('.').let { segment ->
      if (isClassRenamed) segment else segment.replaceFirstChar { it.titlecaseChar() }
    }
    return packageName to className
  }

Where org.pkl.commons.NameMapper#map gets changed to:

  fun map(sourceName: String): Pair<String, Boolean> {
    for ((sourcePrefix, targetPrefix) in sortedMapping) {
      if (sourceName.startsWith(sourcePrefix)) {
        val remainder = sourceName.substring(sourcePrefix.length)
        return targetPrefix + remainder to ((sourcePrefix.length - 1) > sourceName.lastIndexOf('.'))
      }
    }
    return sourceName to false
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, org.pkl.commons.NameMapper#map just returns a package name and class name tuple.

@@ -101,7 +101,8 @@ class JavaCodeGeneratorTest {
generateJavadoc: Boolean = false,
generateSpringBootConfig: Boolean = false,
nonNullAnnotation: String? = null,
implementSerializable: Boolean = false
implementSerializable: Boolean = false,
packageMapping: Map<String, String> = emptyMap()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
packageMapping: Map<String, String> = emptyMap()
renames: Map<String, String> = emptyMap()

netvl and others added 3 commits June 12, 2024 13:30
Do not titlecase the class name if the target name was
explicitly requested by the user.
Copy link
Contributor

@bioball bioball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, good work, and thank you for the feature!

@bioball bioball merged commit d7a1778 into apple:main Jun 12, 2024
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow overriding Java/Kotlin package name in codegen
3 participants