From c01bc35f933a1036f36d411416fe5eea49322d95 Mon Sep 17 00:00:00 2001 From: Cypher Cove Date: Sat, 11 Jul 2020 23:58:53 -0400 Subject: [PATCH] Tools module and BundleLineCreator for #14 --- i18n/README.md | 4 +- settings.gradle | 1 + tools/README.md | 75 ++++++++ tools/build.gradle | 17 ++ tools/gradle.properties | 2 + .../kotlin/ktx/tools/BundleLinesCreator.kt | 160 ++++++++++++++++++ .../main/kotlin/ktx/tools/KtxToolsPlugin.kt | 65 +++++++ 7 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 tools/README.md create mode 100644 tools/build.gradle create mode 100644 tools/gradle.properties create mode 100644 tools/src/main/kotlin/ktx/tools/BundleLinesCreator.kt create mode 100644 tools/src/main/kotlin/ktx/tools/KtxToolsPlugin.kt diff --git a/i18n/README.md b/i18n/README.md index 24476845..e7a76768 100644 --- a/i18n/README.md +++ b/i18n/README.md @@ -56,7 +56,9 @@ the way `BundleLine` implementations extract lines from `I18NBundle`, override ` #### Automatic `BundleLine` enum generation -You can use the following Gradle Groovy script to generate a Kotlin enum implementing `BundleLine` according to an +The `ktx-tools` package includes a utility for generating a `BundleLine` enum from `.properties` files in the `assets` directory. + +Alternatively, you can use the following Gradle Groovy script to generate a Kotlin enum implementing `BundleLine` according to an existing `.properties` bundle: ```Groovy diff --git a/settings.gradle b/settings.gradle index 99782aed..2cee8a70 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,6 +19,7 @@ include( 'scene2d', 'style', 'tiled', + 'tools', 'vis', 'vis-style', ) diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..152d6296 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,75 @@ +# KTX: Tools + +Utilities for working with Ktx or generating code for use with LibGDX. + +### Why? + +Like the LibGDX `gdx-tools` library, this library contains tools for development rather than use in your game itself. + +### Guide + +This module contains tools that should be not be included in the core module. It can be imported as a `buildscipt` +`classpath` dependency so its Gradle plugin can be used. The plugin adds Gradle tasks to the project in the `ktx tools` +group. It can be used by adding this to the `build.gradle` file: + + apply plugin: "io.github.libktx.tools" + +The tools can also be used from an application. In this case, import this library as a dependency of a JVM module (not +the `core` game module.) + +#### BundleLineCreator + +`BundleLineCreator` is used to generate `BundleLine` enum classes from properties files for use with `ktx-i18n`. It +searches a directory for `.properties` files, extracts the property names, and adds a `BundleLine` enum class with +corresponding enum values. The default behavior requires only an output package name. It automatically finds the +`assets` directory as found in a project created with LibGDX Setup, and favors limiting its search to a sub-directory +named `i18n` or `nls` if either exists. It combines property names into a single enum class named `Nls`, placed in the +output package in the `core` module, overwriting if necessary. + +To run this default behavior from a JVM app, call `BundleLineCreator.execute("com.mycompany.mygame")`. + +To run it as a Gradle task, you can first set the output package name in the `gradle.build` using + +```groovy +ktxTools { + createBundleLines.targetPackage = "com.mycompany.mygame" +} +``` + +and then running the `createBundleLines` task in the `ktx tools` group. IntelliJ's +[FileWatchers](https://www.jetbrains.com/help/idea/using-file-watchers.html) plugin may be of interest as it can be set +up to run the task when a file changes. + +See usage examples below for how to customize the behavior. Note that `BundleLineCreator` can also be subclassed to +customize its behavior farther. + +## + +### Usage examples + +Using all the customization options when running `BundleLinesCreator`. + +In a JVM app: + +```kotlin +BundleLinesCreator.execute( + targetPackage = "com.mycompany.mygame", + explicitParentDirectory = "android/assets/bundles", + searchSubdirectories = false, + targetSourceDirectory = "mygamecore/src", + enumClassName = "Keys" +) +``` + +In a `build.gradle` file to customize `createBundleLines` task behavior: + +```groovy +ktxTools { + createBundleLines.targetPackage = "com.mycompany.mygame" + createBundleLines.explicitParentDirectory = "android/assets/bundles" + createBundleLines.searchSubdirectories = false + createBundleLines.targetSourceDirectory = "mygamecore/src" + createBundleLines.enumClassName = "Keys" +} +``` + diff --git a/tools/build.gradle b/tools/build.gradle new file mode 100644 index 00000000..af3dc78f --- /dev/null +++ b/tools/build.gradle @@ -0,0 +1,17 @@ + +plugins { + id 'java-gradle-plugin' +} + +dependencies { + implementation "commons-io:commons-io:2.4" +} + +gradlePlugin { + plugins { + simplePlugin { + id = 'io.github.libktx.tools' + implementationClass = 'ktx.tools.KtxToolsPlugin' + } + } +} diff --git a/tools/gradle.properties b/tools/gradle.properties new file mode 100644 index 00000000..837020bd --- /dev/null +++ b/tools/gradle.properties @@ -0,0 +1,2 @@ +projectName=ktx-tools +projectDesc=Development tools for LibKtx diff --git a/tools/src/main/kotlin/ktx/tools/BundleLinesCreator.kt b/tools/src/main/kotlin/ktx/tools/BundleLinesCreator.kt new file mode 100644 index 00000000..df7e0489 --- /dev/null +++ b/tools/src/main/kotlin/ktx/tools/BundleLinesCreator.kt @@ -0,0 +1,160 @@ +package ktx.tools + +import org.apache.commons.io.FileUtils +import org.apache.commons.io.filefilter.AbstractFileFilter +import org.apache.commons.io.filefilter.FileFilterUtils +import org.apache.commons.io.filefilter.SuffixFileFilter +import org.apache.commons.io.filefilter.TrueFileFilter +import java.io.File +import java.io.IOException +import java.util.Properties + +/** Searches for properties files and generates Ktx BundleLine class files for them when executed. The companion object + * instance can be used directly or behavior can be customized by subclassing. */ +open class BundleLinesCreator { + + companion object : BundleLinesCreator() + + /** + * Executes creation of enum source files. + * @param targetPackage The package created enums will be placed in. + * @param explicitParentDirectory Directory that is searched for properties files when creating BundleLines. If null + * (the default), a directory is searched for as described by [findParentDirectory]. + * @param searchSubdirectories Whether to search subdirectories of the parent directory. Default true. + * @param targetSourceDirectory The directory enum source files will be placed in. Default `"core/src"`. + * @param enumClassName The name of the generated enum class. If non-null, a single enum class is created for all + * bundles found. If null, each unique base bundle's name will be used for a distinct enum class. Default `"Nls"`. + */ + fun execute( + targetPackage: String, + explicitParentDirectory: String? = null, + searchSubdirectories: Boolean = true, + targetSourceDirectory: String = "core/src", + enumClassName: String? = "Nls" + ) { + try { + val parentDirectory = findParentDirectory(explicitParentDirectory) + if (parentDirectory == null) { + printlnRed("Failed to find asset directory. No files will be created.") + return + } + val baseFiles = findBasePropertiesFiles(parentDirectory, searchSubdirectories) + val enumNamesToEntryNames = collectKeys(baseFiles, enumClassName) + val outDir = File("$targetSourceDirectory/${targetPackage.replace(".", "/")}") + .apply { mkdirs() } + for ((enumName, entryNames) in enumNamesToEntryNames) { + val outFile = File(outDir, "$enumName.kt") + val sourceCode = generateKtFileContent(targetPackage, enumName, entryNames) + outFile.writeText(sourceCode) + } + println("Created BundleLine enum class(es) for bundles in directory $parentDirectory:") + println(enumNamesToEntryNames.keys.joinToString(separator = ",\n", prefix = " ")) + println("in package $targetPackage in source directory $targetSourceDirectory.") + } catch (e: IOException) { + printlnRed("An IO error was encountered while executing BundleLinesCreator.") + throw e + } + } + + /** Finds the parent directory that will be searched for properties files. The default implementation finds the first + * non-null and existing directory in descending precedence of [assetDirectory], `android/assets/i18n`, + * `android/assets/nls`, `android/assets`, `core/assets/i18n`, `core/assets/nls`, and `core/assets`. + * @return The parent directory, or null if none exists. + */ + protected open fun findParentDirectory(assetDirectory: String?): File? { + return listOfNotNull( + assetDirectory, + "android/assets/i18n", + "android/assets/nls", + "android/assets/", + "core/assets/i18n", + "core/assets/nls", + "core/assets" + ) + .map(::File).firstOrNull(File::isDirectory) + } + + /** Collects properties files that will be processed. The default implementation finds all files with name suffix + * `.properties` and no underscore in the name. The [searchSubdirectories] + * property determines whether subdirectories of [parentDirectory] are also included in the search. + * @param parentDirectory The directory that will be searched. + * @return A list of all applicable properties files found. + */ + protected open fun findBasePropertiesFiles(parentDirectory: File, searchSubdirectories: Boolean): Collection { + val noUnderscoresFilter = object : AbstractFileFilter() { + override fun accept(dir: File, name: String) = '_' !in name + } + + return FileUtils.listFiles( + parentDirectory, + FileFilterUtils.and(SuffixFileFilter("properties"), noUnderscoresFilter), + if (searchSubdirectories) TrueFileFilter.INSTANCE else null + ) + } + + /** Takes the target [propertiesFiles] and returns a map of output enum class BundleLines to their set of String keys. + * By default, if [commonBaseName] is non-null, a single base name is output and all property keys will be merged + * into the single set. Otherwise, the properties files' names are used as base names. */ + protected open fun collectKeys(propertiesFiles: Collection, commonBaseName: String?): Map> { + val outMap = mutableMapOf>() + val sanitizedCommonBaseName = commonBaseName?.let { sanitizeEnumName(it) } + if (sanitizedCommonBaseName != commonBaseName) + println("The provided enumClassName $commonBaseName was changed to $sanitizedCommonBaseName.") + for (file in propertiesFiles) { + val enumName = sanitizedCommonBaseName ?: sanitizeEnumName(file.nameWithoutExtension) + val propertyNames = Properties().run { + load(file.inputStream()) + stringPropertyNames() + } + val enumNames = propertyNames.mapNotNull { sanitizeEntryName(it) } + if (propertyNames.size > enumNames.size) + printlnRed("Warning: Properties file ${file.name} contains at least one empty key, which will be omitted.") + outMap.getOrPut(enumName, ::mutableSetOf) + .addAll(enumNames) + } + return outMap + } + + private fun sanitizeEnumName(name: String): String { + return name.trimStart { !it.isLetterOrDigit() && it != '_' } + .split("\\s+".toRegex()) + .joinToString("", transform = String::capitalize) + } + + /** @return A name based on [name] that is a valid enum entry name, or null if the input name is empty. */ + private fun sanitizeEntryName(name: String): String? { + if (name.isEmpty()) + return null + if (name[0].isDigit() || !name.all { it.isLetterOrDigit() || it == '_' }) + return "`$name`" + return name + } + + protected open fun generateKtFileContent( + packageName: String, + enumClassName: String, + entryNames: Set + ): String { + return buildString { + append("@file:Suppress(\"EnumEntryName\")\n\n") + append("package $packageName\n\n") + append("import ktx.i18n.BundleLine\n") + append("import com.badlogic.gdx.utils.I18NBundle\n") + append("\nenum class $enumClassName: BundleLine {\n") + for ((index, key) in entryNames.withIndex()) { + val lineEnding = if (index < entryNames.size - 1) ',' else ';' + append("${IND}$key$lineEnding\n") + } + append("\n${IND}override val bundle: I18NBundle\n") + append("${IND2}get() = i18nBundle\n") + append("\n${IND}companion object {\n") + append("${IND2}/** The bundle used for [BundleLine.nls] and [BundleLine.invoke] for this enum's values. */\n") + append("${IND2}lateinit var i18nBundle: I18NBundle\n") + append("${IND}}\n") + append('}') + } + } +} + +private const val IND: String = " " +private const val IND2: String = IND + IND diff --git a/tools/src/main/kotlin/ktx/tools/KtxToolsPlugin.kt b/tools/src/main/kotlin/ktx/tools/KtxToolsPlugin.kt new file mode 100644 index 00000000..8ce9309b --- /dev/null +++ b/tools/src/main/kotlin/ktx/tools/KtxToolsPlugin.kt @@ -0,0 +1,65 @@ +@file:Suppress("unused") + +package ktx.tools + +import org.gradle.api.Plugin +import org.gradle.api.Project + +private const val EXTENSION_NAME = "ktxTools" + +class KtxToolsPlugin : Plugin { + override fun apply(project: Project) { + val ktxToolsExtension = project.extensions.create(EXTENSION_NAME, KtxToolsPluginExtension::class.java) + + project.tasks.create("createBundleLines") { task -> + task.doLast { + val ext = ktxToolsExtension.createBundleLines + val targetPackage = ext.targetPackage + if (targetPackage == null) { + printlnRed("Cannot create BundleLines if target package is not set. This can be set in the gradle build file, e.g.:") + printlnRed( + "\n $EXTENSION_NAME.${KtxToolsPluginExtension::createBundleLines.name}" + + ".${CreateBundleLinesParams::targetPackage.name} = \"com.mycompany.mygame\"\n" + ) + return@doLast + } + + BundleLinesCreator.execute( + targetPackage = targetPackage, + explicitParentDirectory = ext.sourceParentDirectory, + searchSubdirectories = ext.searchSubDirectories, + targetSourceDirectory = ext.targetSourceDirectory, + enumClassName = ext.enumClassName + ) + } + task.group = "ktx tools" + } + } +} + +open class KtxToolsPluginExtension { + var createBundleLines = CreateBundleLinesParams() +} + +/** Parameters for the `createBundleLines` task. */ +open class CreateBundleLinesParams { + /** The package created enums will be placed in. If null (the default), the `createBundleLines` task cannot be used. */ + var targetPackage: String? = null + + /** Directory that is searched for properties files when creating BundleLines. If null (the default), the first + * non-null and existing directory in descending precedence of `android/assets/i18n`, `android/assets/nls`, + * `android/assets`, `core/assets/i18n`, `core/assets/nls`, and `core/assets` is used. */ + var sourceParentDirectory: String? = null + + /** Whether to search subdirectories of the parent directory. Default true. */ + var searchSubDirectories: Boolean = true + + /** The directory enum source files will be placed in. Default `"core/src"`. */ + var targetSourceDirectory: String = "core/src" + + /** The name of the generated enum class. If non-null, a single enum class is created for all bundles found. If null, + * each unique base bundle's name will be used for a distinct enum class. Default `"Nls"`.*/ + var enumClassName: String? = "Nls" +} + +internal fun printlnRed(message: String) = println("\u001B[0;31m${message}\u001B[0m")