From 6ed31db7dc6d368b6357cc2138d2e25c0ce77a09 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Fri, 2 Apr 2021 18:12:13 +0200 Subject: [PATCH] Replace toml config with a javascript file --- README.md | 4 +- app/build.gradle.kts | 4 +- .../META-INF/native-image/reflect-config.json | 3 - .../native-image/resource-config.json | 12 ++- app/resources/init/README.md | 1 + app/resources/init/index.d.ts | 46 ++++++++++ app/resources/init/index.js | 7 ++ app/src/Config.kt | 51 ----------- app/src/Generators.kt | 9 +- app/src/Globs.kt | 11 --- app/src/PromptValue.kt | 10 --- app/src/Scaffold.kt | 2 +- app/src/commands/GenerateCommand.kt | 87 +++++++++++-------- app/src/commands/NewCommand.kt | 22 +---- app/src/scripting/ScriptContext.kt | 14 +++ app/src/scripting/ScriptEngine.kt | 52 +++++++++++ app/test/scripting/ScriptEngineTest.kt | 70 +++++++++++++++ .../main/kotlin/java-convention.gradle.kts | 5 -- .../main/kotlin/kotlin-convention.gradle.kts | 8 +- examples/basic/config.toml | 15 ---- examples/basic/tree/.gitattributes | 6 -- examples/basic/tree/.gitignore | 2 - examples/basic/tree/README.md | 7 -- 23 files changed, 265 insertions(+), 183 deletions(-) create mode 100644 app/resources/init/README.md create mode 100644 app/resources/init/index.d.ts create mode 100644 app/resources/init/index.js delete mode 100644 app/src/Config.kt delete mode 100644 app/src/Globs.kt delete mode 100644 app/src/PromptValue.kt create mode 100644 app/src/scripting/ScriptContext.kt create mode 100644 app/src/scripting/ScriptEngine.kt create mode 100644 app/test/scripting/ScriptEngineTest.kt delete mode 100644 examples/basic/config.toml delete mode 100644 examples/basic/tree/.gitattributes delete mode 100644 examples/basic/tree/.gitignore delete mode 100644 examples/basic/tree/README.md diff --git a/README.md b/README.md index 805e0d3..3d0e8f3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Or build a native image with GraalVM ```bash ./gradlew installShadowDist cd app/build/install/app-shadow/lib -native-image --no-fallback -R:MaxNewSize=32 -jar scaffold.jar +native-image --no-fallback -R:MaxNewSize=32 --language:js -jar scaffold.jar ``` ## Usage @@ -28,7 +28,7 @@ cd generator-directory You can then add [Pebble templates](https://pebbletemplates.io/) and files in the tree directory -An example is present in this repo +An kotlin example is present [here](https://git.simplenotes.be/hubert/Kotlin-scaffold) ### List generators diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac79aef..d3b9682 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,7 @@ dependencies { runtimeOnly("org.slf4j:slf4j-simple:2.0.0-alpha1") implementation("io.pebbletemplates:pebble:3.1.5") implementation("com.github.ajalt.clikt:clikt:3.1.0") - implementation("com.electronwill.night-config:toml:3.6.3") + implementation("org.graalvm.js:js:21.0.0.2") } application { @@ -21,4 +21,4 @@ tasks.withType { archiveBaseName.set("scaffold") archiveClassifier.set("") archiveVersion.set("") -} \ No newline at end of file +} diff --git a/app/resources/META-INF/native-image/reflect-config.json b/app/resources/META-INF/native-image/reflect-config.json index 4232fc6..e6339b2 100644 --- a/app/resources/META-INF/native-image/reflect-config.json +++ b/app/resources/META-INF/native-image/reflect-config.json @@ -1,7 +1,4 @@ [ -{ - "name":"com.electronwill.nightconfig.toml.TomlFormat" -}, { "name":"java.util.Map$Entry", "allPublicMethods":true diff --git a/app/resources/META-INF/native-image/resource-config.json b/app/resources/META-INF/native-image/resource-config.json index 6611a21..499982c 100644 --- a/app/resources/META-INF/native-image/resource-config.json +++ b/app/resources/META-INF/native-image/resource-config.json @@ -1,5 +1,11 @@ { - "resources":{ - "includes":[{"pattern":"\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"}]}, - "bundles":[] + "resources": { + "includes": [ + {"pattern": "\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"}, + {"pattern": "\\Qinit/index.d.ts\\E"}, + {"pattern": "\\Qinit/index.js\\E"}, + {"pattern": "\\Qinit/README.md\\E"} + ] + }, + "bundles": [] } diff --git a/app/resources/init/README.md b/app/resources/init/README.md new file mode 100644 index 0000000..84e7c62 --- /dev/null +++ b/app/resources/init/README.md @@ -0,0 +1 @@ +# {{ name }} \ No newline at end of file diff --git a/app/resources/init/index.d.ts b/app/resources/init/index.d.ts new file mode 100644 index 0000000..b7fcb1f --- /dev/null +++ b/app/resources/init/index.d.ts @@ -0,0 +1,46 @@ +/** + * Print a message to the console + * + * @param message + */ +declare function echo(message: string): void + +/** + * Prompt a user for text input + * + * @param text - The text to display for the prompt + * @param def - A default value + */ +declare function prompt(text: string, def?: string): string + +/** + * Prompt a user for text input + * + * @param text - The text to display for the prompt + * @param def - A default value + */ +declare function promptBoolean(text: string, def?: boolean): boolean + +/** + * Renders a template + * + * @param name - The template name + * @param context - The template context + */ +declare function render(name: string, context: any): string + +/** + * Write a string to a file + * + * @param input - The content of the file + * @param output - The output path + */ +declare function write(input: string, output: string): void + +/** + * Copy a file + * + * @param input - The input path + * @param output - The output path + */ +declare function copy(input: string, output: string): void \ No newline at end of file diff --git a/app/resources/init/index.js b/app/resources/init/index.js new file mode 100644 index 0000000..80762e6 --- /dev/null +++ b/app/resources/init/index.js @@ -0,0 +1,7 @@ +echo("Example") + +const name = prompt("Project name") + +const ctx = {name: name} + +write(render("README.md", ctx), "README.md") \ No newline at end of file diff --git a/app/src/Config.kt b/app/src/Config.kt deleted file mode 100644 index 564618a..0000000 --- a/app/src/Config.kt +++ /dev/null @@ -1,51 +0,0 @@ -package scaffold - -import com.electronwill.nightconfig.core.Config -import com.electronwill.nightconfig.core.UnmodifiableConfig -import com.electronwill.nightconfig.core.file.FileConfig -import java.nio.file.Files -import java.nio.file.Files.isRegularFile -import java.nio.file.Path -import kotlin.streams.toList - -data class GeneratorConfig( - val templates: List, - val files: List, - val prompts: Map> -) - -fun Generator.loadConfig(): GeneratorConfig { - val config: UnmodifiableConfig = FileConfig.of(rootPath.resolve("config.toml")).apply { load() } - - val allFiles = Files.walk(treeRoot).filter { isRegularFile(it) }.toList() - - fun List.expandGlobs() = flatMap { Globs.matches(allFiles, treeRoot, it) } - - val files = config.get>("files").expandGlobs() - val templates = config.get>("templates").expandGlobs() - - val prompts = mutableMapOf>() - val section = config.get("prompt") ?: error("Missing prompt section") - if (section !is Config) error("Prompt section is not an object") - - for ((subsectionName, subsection) in section.valueMap()) { - check(subsection is List<*>) { "prompts.$subsectionName is not a list of prompts" } - - for (prompt in subsection) { - check(prompt is Config) - - val promptValue = PromptValue( - name = prompt.get("name") ?: error("Missing name"), - info = prompt.get("info"), - default = prompt.get("default"), - type = prompt.getEnum("type", PromptType::class.java) ?: PromptType.String - ) - - val current = prompts.computeIfAbsent(subsectionName) { ArrayList() } - current += promptValue - } - - } - - return GeneratorConfig(templates, files, prompts) -} diff --git a/app/src/Generators.kt b/app/src/Generators.kt index 5fc42f2..146b434 100644 --- a/app/src/Generators.kt +++ b/app/src/Generators.kt @@ -5,13 +5,12 @@ import java.nio.file.Path import kotlin.streams.toList class Generators(val configDirectory: Path) { - fun isValid(name: String): Boolean { - val generatorRoot = configDirectory.resolve(name) - return Files.isDirectory(generatorRoot) && Files.isRegularFile(generatorRoot.resolve("config.toml")) - } + fun isValid(name: String) = isValid(configDirectory.resolve(name)) + + private fun isValid(path: Path) = Files.isDirectory(path) && Files.isRegularFile(path.resolve("index.js")) fun findAll(): List = Files .list(configDirectory) - .filter { Files.exists(it.resolve("config.toml")) } + .filter(::isValid) .toList() } \ No newline at end of file diff --git a/app/src/Globs.kt b/app/src/Globs.kt deleted file mode 100644 index a8200d8..0000000 --- a/app/src/Globs.kt +++ /dev/null @@ -1,11 +0,0 @@ -package scaffold - -import java.nio.file.FileSystems -import java.nio.file.Path - -object Globs { - fun matches(allFiles: List, root: Path, glob: String): List { - val matcher = FileSystems.getDefault().getPathMatcher("glob:$root/$glob") - return allFiles.filter(matcher::matches) - } -} \ No newline at end of file diff --git a/app/src/PromptValue.kt b/app/src/PromptValue.kt deleted file mode 100644 index d1e01df..0000000 --- a/app/src/PromptValue.kt +++ /dev/null @@ -1,10 +0,0 @@ -package scaffold - -enum class PromptType { String } - -data class PromptValue( - val name: String, - val info: String?, - val default: String?, - val type: PromptType, -) diff --git a/app/src/Scaffold.kt b/app/src/Scaffold.kt index a65270c..fdf0cc2 100644 --- a/app/src/Scaffold.kt +++ b/app/src/Scaffold.kt @@ -24,6 +24,6 @@ fun main(args: Array) { Scaffold().subcommands( ListCommand(generators), GenerateCommand(generators), - NewCommand(generators) + NewCommand(generators), ).main(args) } \ No newline at end of file diff --git a/app/src/commands/GenerateCommand.kt b/app/src/commands/GenerateCommand.kt index 49394a6..2b4664e 100644 --- a/app/src/commands/GenerateCommand.kt +++ b/app/src/commands/GenerateCommand.kt @@ -2,6 +2,8 @@ package scaffold.commands import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.core.UsageError +import com.github.ajalt.clikt.output.TermUi import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.convert import com.github.ajalt.clikt.parameters.options.option @@ -11,37 +13,20 @@ import com.mitchellbosecke.pebble.loader.FileLoader import com.mitchellbosecke.pebble.template.PebbleTemplate import scaffold.Generator import scaffold.Generators -import scaffold.PromptType -import scaffold.loadConfig +import scaffold.scripting.ScriptContext +import scaffold.scripting.ScriptEngine import java.io.StringWriter import java.nio.file.Files import java.nio.file.Path class GenerateCommand(private val generators: Generators) : CliktCommand("generate") { private val name by argument(help = "generator name") - private val output by option().convert { Path.of(it) }.required() + private val outputPathRoot by option("-o", "--output").convert { Path.of(it) }.required() override fun run() { checkPreconditions() val generator = Generator(generators.configDirectory, name) - val config = generator.loadConfig() - - val templateContext = mutableMapOf() - - for ((path, prompts) in config.prompts) { - val map = mutableMapOf() - templateContext[path] = map - for ((name, info, default, type) in prompts) { - if (type != PromptType.String) TODO() - - val value = prompt( - text = info ?: "$path.$name", - default = default - )!! - map[name] = value - } - } val pebble = PebbleEngine.Builder() .autoEscaping(false) @@ -49,29 +34,57 @@ class GenerateCommand(private val generators: Generators) : CliktCommand("genera .loader(FileLoader().apply { prefix = generator.treeRoot.toString() }) .build()!! - for (template in config.templates) { - val templatePath = generator.treeRoot.relativize(template).toString() - val renderedTemplate = pebble.getTemplate(templatePath)(templateContext) - val outputPath = output.resolve(templatePath) - Files.createDirectories(outputPath.parent) - Files.writeString(outputPath, renderedTemplate) + val scriptContext = object : ScriptContext { + override fun echo(message: String) = this@GenerateCommand.echo(message) + + override fun prompt(text: String, default: String?) = this@GenerateCommand.prompt(text, default)!! + + override fun promptBoolean(text: String, default: Boolean?): Boolean { + val suffix = when (default) { + true -> "[Y/n]" + false -> "[y/N]" + null -> "[y/n]" + } + + return this@GenerateCommand.prompt("$text $suffix", default = "$default", showDefault = false) { + when (it.toLowerCase()) { + "y" -> true + "n" -> false + else -> throw UsageError("Can only be [y/n]") + } + }!! + } + + override fun promptInt(text: String, default: Int?): Int { + TODO("Not yet implemented") + } + + override fun render(name: String, ctx: Map) = pebble.getTemplate(name)(ctx) + + override fun write(content: String, output: String) { + val outputPath = outputPathRoot.resolve(output) + Files.createDirectories(outputPath.parent) + Files.writeString(outputPath, content) + } + + override fun copy(input: String, output: String) { + val inputPath = generator.treeRoot.resolve(input) + val outputPath = outputPathRoot.resolve(output) + Files.createDirectories(outputPath.parent) + Files.copy(inputPath, outputPath) + } } - for (inputFile in config.files) { - val relativeFile = generator.treeRoot.relativize(inputFile) - val outputFile = output.resolve(relativeFile) + val engine = ScriptEngine(scriptContext) + val script = Files.readString(generator.rootPath.resolve("index.js")) - Files.createDirectories(outputFile.parent) - Files.copy(inputFile, outputFile) - } - - echo("Generated project in $output") + engine.eval(script) } private fun checkPreconditions() { - if (Files.exists(output)) { - if (Files.list(output).findAny().isPresent) { - echo("Output path `$output` is not empty") + if (Files.exists(outputPathRoot)) { + if (Files.list(outputPathRoot).findAny().isPresent) { + echo("Output path `$outputPathRoot` is not empty") throw ProgramResult(1) } } diff --git a/app/src/commands/NewCommand.kt b/app/src/commands/NewCommand.kt index 64601d7..74f834c 100644 --- a/app/src/commands/NewCommand.kt +++ b/app/src/commands/NewCommand.kt @@ -19,29 +19,15 @@ class NewCommand(private val generators: Generators) : CliktCommand("new") { Files.createDirectories(root) - val configFile = """ - templates = [ - "README.md" - ] - - files = [ - ".gitignore" - ] - - [prompt] + fun resource(path: String) = javaClass.getResource("/$path").readText() - [[prompt.project]] - type = "String" - name = "name" - info = "Project name" - """.trimIndent() + Files.writeString(root.resolve("index.js"), resource("init/index.js")) + Files.writeString(root.resolve("index.d.ts"), resource("init/index.d.ts")) - Files.writeString(root.resolve("config.toml"), configFile) val tree = root.resolve("tree") Files.createDirectory(tree) - Files.writeString(tree.resolve("README.md"), "# {{ project.name }}") - Files.writeString(tree.resolve(".gitignore"), "# https://git-scm.com/docs/gitignore") + Files.writeString(tree.resolve("README.md"), resource("init/README.md")) echo("Generator created in $root") } diff --git a/app/src/scripting/ScriptContext.kt b/app/src/scripting/ScriptContext.kt new file mode 100644 index 0000000..3be343e --- /dev/null +++ b/app/src/scripting/ScriptContext.kt @@ -0,0 +1,14 @@ +package scaffold.scripting + +interface ScriptContext { + fun echo(message: String) + + fun prompt(text: String, default: String?): String + fun promptBoolean(text: String, default: Boolean?): Boolean + fun promptInt(text: String, default: Int?): Int + + fun render(name: String, ctx: Map): String + + fun write(content: String, output: String) + fun copy(input: String, output: String) +} diff --git a/app/src/scripting/ScriptEngine.kt b/app/src/scripting/ScriptEngine.kt new file mode 100644 index 0000000..b0ca168 --- /dev/null +++ b/app/src/scripting/ScriptEngine.kt @@ -0,0 +1,52 @@ +package scaffold.scripting + +import org.graalvm.polyglot.Context +import org.graalvm.polyglot.proxy.ProxyExecutable +import org.intellij.lang.annotations.Language + +class ScriptEngine(private val scriptContext: ScriptContext) { + + private val context = Context + .newBuilder("js") + .allowIO(true) + .build() + + init { + with(context.getBindings("js")) { + putMember("echo", ProxyExecutable { args -> + scriptContext.echo(args[0].asString()) + null + }) + + putMember("prompt", ProxyExecutable { args -> + scriptContext.prompt(args[0].asString(), args.getOrNull(1)?.asString()) + }) + + putMember("promptBoolean", ProxyExecutable { args -> + scriptContext.promptBoolean(args[0].asString(), args.getOrNull(1)?.asBoolean()) + }) + + putMember("promptInt", ProxyExecutable { args -> + scriptContext.promptInt(args[0].asString(), args.getOrNull(1)?.asInt()) + }) + + putMember("render", ProxyExecutable { args -> + @Suppress("UNCHECKED_CAST") + scriptContext.render(args[0].asString(), args[1].`as`(Map::class.java) as Map) + }) + + putMember("write", ProxyExecutable { (input, output) -> + scriptContext.write(input.asString(), output.asString()) + }) + + putMember("copy", ProxyExecutable { (input, output) -> + scriptContext.copy(input.asString(), output.asString()) + }) + } + } + + fun eval(@Language("js") script: CharSequence) { + context.eval("js", script) + } + +} \ No newline at end of file diff --git a/app/test/scripting/ScriptEngineTest.kt b/app/test/scripting/ScriptEngineTest.kt new file mode 100644 index 0000000..0010454 --- /dev/null +++ b/app/test/scripting/ScriptEngineTest.kt @@ -0,0 +1,70 @@ +@file:Suppress("MemberVisibilityCanBePrivate", "PackageDirectoryMismatch") + +package scaffold.scripting + +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle + +@TestInstance(Lifecycle.PER_CLASS) +class ScriptEngineTest { + + val scriptContext = mockk() + val scriptEngine = ScriptEngine(scriptContext) + + @BeforeEach + fun beforeEach() = clearMocks(scriptContext) + + @Test + fun echo() { + every { scriptContext.echo("test") } returns Unit + + val script = "echo('test')" + scriptEngine.eval(script) + + verify { scriptContext.echo(any()) } + } + + @Test + fun render() { + every { scriptContext.render("test", match { it["something"] == "hello" }) } returns "blah" + + val script = """ + const ctx = { + "something": "hello" + } + + render("test", ctx) + """.trimIndent() + + scriptEngine.eval(script) + + verify { scriptContext.render(any(), any()) } + } + + @Test + fun prompt() { + every { scriptContext.prompt("a", null) } returns "a" + + val script = "prompt('a')" + scriptEngine.eval(script) + + verify { scriptContext.prompt(any(), null) } + } + + @Test + fun promptWithDefault(){ + every { scriptContext.prompt("a", "default") } returns "default" + + val script = "prompt('a', 'default')" + scriptEngine.eval(script) + + verify { scriptContext.prompt(any(), any()) } + } + +} diff --git a/buildSrc/src/main/kotlin/java-convention.gradle.kts b/buildSrc/src/main/kotlin/java-convention.gradle.kts index 57939e7..76f6fbe 100644 --- a/buildSrc/src/main/kotlin/java-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/java-convention.gradle.kts @@ -4,11 +4,6 @@ plugins { repositories { mavenCentral() - maven { - url = uri("https://kotlin.bintray.com/kotlinx") - // https://github.com/Kotlin/kotlinx.html/issues/173 - content { includeModule("org.jetbrains.kotlinx", "kotlinx-html-jvm") } - } } java { diff --git a/buildSrc/src/main/kotlin/kotlin-convention.gradle.kts b/buildSrc/src/main/kotlin/kotlin-convention.gradle.kts index f5728e3..398ef92 100644 --- a/buildSrc/src/main/kotlin/kotlin-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/kotlin-convention.gradle.kts @@ -8,7 +8,9 @@ plugins { dependencies { implementation(kotlin("stdlib-jdk8")) implementation(platform(kotlin("bom"))) - testImplementation("io.kotest:kotest-runner-junit5:4.4.1") + testImplementation("org.junit.jupiter:junit-jupiter:5.7.1") + testImplementation("io.strikt:strikt-core:0.30.0") + testImplementation("io.mockk:mockk:1.10.6") } tasks.withType { @@ -19,14 +21,10 @@ tasks.withType { kotlinOptions { jvmTarget = "11" javaParameters = true - freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") } } kotlin { - sourceSets.all { - languageSettings.enableLanguageFeature("InlineClasses") - } sourceSets["main"].kotlin.setSrcDirs(listOf("src")) sourceSets["test"].kotlin.setSrcDirs(listOf("test")) } diff --git a/examples/basic/config.toml b/examples/basic/config.toml deleted file mode 100644 index 8ff31e7..0000000 --- a/examples/basic/config.toml +++ /dev/null @@ -1,15 +0,0 @@ -templates = [ - "README.md" -] - -files = [ - ".gitignore", - ".gitattributes" -] - -[prompt] - -[[prompt.project]] -type = "String" -name = "name" -info = "Project name" diff --git a/examples/basic/tree/.gitattributes b/examples/basic/tree/.gitattributes deleted file mode 100644 index 00a51af..0000000 --- a/examples/basic/tree/.gitattributes +++ /dev/null @@ -1,6 +0,0 @@ -# -# https://help.github.com/articles/dealing-with-line-endings/ -# -# These are explicitly windows files and should use crlf -*.bat text eol=crlf - diff --git a/examples/basic/tree/.gitignore b/examples/basic/tree/.gitignore deleted file mode 100644 index f8b92c3..0000000 --- a/examples/basic/tree/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.gradle -build diff --git a/examples/basic/tree/README.md b/examples/basic/tree/README.md deleted file mode 100644 index b9b5285..0000000 --- a/examples/basic/tree/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# {{ project.name }} - -## Usage - -```bash -./gradlew run -``` \ No newline at end of file