Skip to content

Commit

Permalink
Tools module and BundleLineCreator for libktx#14
Browse files Browse the repository at this point in the history
  • Loading branch information
cypherdare committed Jul 12, 2020
1 parent 4d522fe commit c01bc35
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 1 deletion.
4 changes: 3 additions & 1 deletion i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ include(
'scene2d',
'style',
'tiled',
'tools',
'vis',
'vis-style',
)
75 changes: 75 additions & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
@@ -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"
}
```

17 changes: 17 additions & 0 deletions tools/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
}
}
2 changes: 2 additions & 0 deletions tools/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
projectName=ktx-tools
projectDesc=Development tools for LibKtx
160 changes: 160 additions & 0 deletions tools/src/main/kotlin/ktx/tools/BundleLinesCreator.kt
Original file line number Diff line number Diff line change
@@ -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<File> {
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<File>, commonBaseName: String?): Map<String, Set<String>> {
val outMap = mutableMapOf<String, MutableSet<String>>()
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>
): 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
65 changes: 65 additions & 0 deletions tools/src/main/kotlin/ktx/tools/KtxToolsPlugin.kt
Original file line number Diff line number Diff line change
@@ -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<Project> {
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")

0 comments on commit c01bc35

Please sign in to comment.