Replace toml config with a javascript file

This commit is contained in:
Hubert Van De Walle 2021-04-02 18:12:13 +02:00
parent 6e1e82c2c5
commit 6ed31db7dc
23 changed files with 265 additions and 183 deletions

View File

@ -14,7 +14,7 @@ Or build a native image with GraalVM
```bash ```bash
./gradlew installShadowDist ./gradlew installShadowDist
cd app/build/install/app-shadow/lib 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 ## Usage
@ -28,7 +28,7 @@ cd generator-directory
You can then add [Pebble templates](https://pebbletemplates.io/) and files in the tree 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 ### List generators

View File

@ -8,7 +8,7 @@ dependencies {
runtimeOnly("org.slf4j:slf4j-simple:2.0.0-alpha1") runtimeOnly("org.slf4j:slf4j-simple:2.0.0-alpha1")
implementation("io.pebbletemplates:pebble:3.1.5") implementation("io.pebbletemplates:pebble:3.1.5")
implementation("com.github.ajalt.clikt:clikt:3.1.0") 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 { application {
@ -21,4 +21,4 @@ tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
archiveBaseName.set("scaffold") archiveBaseName.set("scaffold")
archiveClassifier.set("") archiveClassifier.set("")
archiveVersion.set("") archiveVersion.set("")
} }

View File

@ -1,7 +1,4 @@
[ [
{
"name":"com.electronwill.nightconfig.toml.TomlFormat"
},
{ {
"name":"java.util.Map$Entry", "name":"java.util.Map$Entry",
"allPublicMethods":true "allPublicMethods":true

View File

@ -1,5 +1,11 @@
{ {
"resources":{ "resources": {
"includes":[{"pattern":"\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"}]}, "includes": [
"bundles":[] {"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": []
} }

View File

@ -0,0 +1 @@
# {{ name }}

46
app/resources/init/index.d.ts vendored Normal file
View File

@ -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

View File

@ -0,0 +1,7 @@
echo("Example")
const name = prompt("Project name")
const ctx = {name: name}
write(render("README.md", ctx), "README.md")

View File

@ -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<Path>,
val files: List<Path>,
val prompts: Map<String, List<PromptValue>>
)
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<String>.expandGlobs() = flatMap { Globs.matches(allFiles, treeRoot, it) }
val files = config.get<List<String>>("files").expandGlobs()
val templates = config.get<List<String>>("templates").expandGlobs()
val prompts = mutableMapOf<String, MutableList<PromptValue>>()
val section = config.get<Any?>("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)
}

View File

@ -5,13 +5,12 @@ import java.nio.file.Path
import kotlin.streams.toList import kotlin.streams.toList
class Generators(val configDirectory: Path) { class Generators(val configDirectory: Path) {
fun isValid(name: String): Boolean { fun isValid(name: String) = isValid(configDirectory.resolve(name))
val generatorRoot = configDirectory.resolve(name)
return Files.isDirectory(generatorRoot) && Files.isRegularFile(generatorRoot.resolve("config.toml")) private fun isValid(path: Path) = Files.isDirectory(path) && Files.isRegularFile(path.resolve("index.js"))
}
fun findAll(): List<Path> = Files fun findAll(): List<Path> = Files
.list(configDirectory) .list(configDirectory)
.filter { Files.exists(it.resolve("config.toml")) } .filter(::isValid)
.toList() .toList()
} }

View File

@ -1,11 +0,0 @@
package scaffold
import java.nio.file.FileSystems
import java.nio.file.Path
object Globs {
fun matches(allFiles: List<Path>, root: Path, glob: String): List<Path> {
val matcher = FileSystems.getDefault().getPathMatcher("glob:$root/$glob")
return allFiles.filter(matcher::matches)
}
}

View File

@ -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,
)

View File

@ -24,6 +24,6 @@ fun main(args: Array<String>) {
Scaffold().subcommands( Scaffold().subcommands(
ListCommand(generators), ListCommand(generators),
GenerateCommand(generators), GenerateCommand(generators),
NewCommand(generators) NewCommand(generators),
).main(args) ).main(args)
} }

View File

@ -2,6 +2,8 @@ package scaffold.commands
import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.ProgramResult 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.arguments.argument
import com.github.ajalt.clikt.parameters.options.convert import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.option 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 com.mitchellbosecke.pebble.template.PebbleTemplate
import scaffold.Generator import scaffold.Generator
import scaffold.Generators import scaffold.Generators
import scaffold.PromptType import scaffold.scripting.ScriptContext
import scaffold.loadConfig import scaffold.scripting.ScriptEngine
import java.io.StringWriter import java.io.StringWriter
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
class GenerateCommand(private val generators: Generators) : CliktCommand("generate") { class GenerateCommand(private val generators: Generators) : CliktCommand("generate") {
private val name by argument(help = "generator name") 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() { override fun run() {
checkPreconditions() checkPreconditions()
val generator = Generator(generators.configDirectory, name) val generator = Generator(generators.configDirectory, name)
val config = generator.loadConfig()
val templateContext = mutableMapOf<String, Any?>()
for ((path, prompts) in config.prompts) {
val map = mutableMapOf<String, Any>()
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() val pebble = PebbleEngine.Builder()
.autoEscaping(false) .autoEscaping(false)
@ -49,29 +34,57 @@ class GenerateCommand(private val generators: Generators) : CliktCommand("genera
.loader(FileLoader().apply { prefix = generator.treeRoot.toString() }) .loader(FileLoader().apply { prefix = generator.treeRoot.toString() })
.build()!! .build()!!
for (template in config.templates) { val scriptContext = object : ScriptContext {
val templatePath = generator.treeRoot.relativize(template).toString() override fun echo(message: String) = this@GenerateCommand.echo(message)
val renderedTemplate = pebble.getTemplate(templatePath)(templateContext)
val outputPath = output.resolve(templatePath) override fun prompt(text: String, default: String?) = this@GenerateCommand.prompt(text, default)!!
Files.createDirectories(outputPath.parent)
Files.writeString(outputPath, renderedTemplate) 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<String, Any?>) = 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 engine = ScriptEngine(scriptContext)
val relativeFile = generator.treeRoot.relativize(inputFile) val script = Files.readString(generator.rootPath.resolve("index.js"))
val outputFile = output.resolve(relativeFile)
Files.createDirectories(outputFile.parent) engine.eval(script)
Files.copy(inputFile, outputFile)
}
echo("Generated project in $output")
} }
private fun checkPreconditions() { private fun checkPreconditions() {
if (Files.exists(output)) { if (Files.exists(outputPathRoot)) {
if (Files.list(output).findAny().isPresent) { if (Files.list(outputPathRoot).findAny().isPresent) {
echo("Output path `$output` is not empty") echo("Output path `$outputPathRoot` is not empty")
throw ProgramResult(1) throw ProgramResult(1)
} }
} }

View File

@ -19,29 +19,15 @@ class NewCommand(private val generators: Generators) : CliktCommand("new") {
Files.createDirectories(root) Files.createDirectories(root)
val configFile = """ fun resource(path: String) = javaClass.getResource("/$path").readText()
templates = [
"README.md"
]
files = [
".gitignore"
]
[prompt]
[[prompt.project]] Files.writeString(root.resolve("index.js"), resource("init/index.js"))
type = "String" Files.writeString(root.resolve("index.d.ts"), resource("init/index.d.ts"))
name = "name"
info = "Project name"
""".trimIndent()
Files.writeString(root.resolve("config.toml"), configFile)
val tree = root.resolve("tree") val tree = root.resolve("tree")
Files.createDirectory(tree) Files.createDirectory(tree)
Files.writeString(tree.resolve("README.md"), "# {{ project.name }}") Files.writeString(tree.resolve("README.md"), resource("init/README.md"))
Files.writeString(tree.resolve(".gitignore"), "# https://git-scm.com/docs/gitignore")
echo("Generator created in $root") echo("Generator created in $root")
} }

View File

@ -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, Any?>): String
fun write(content: String, output: String)
fun copy(input: String, output: String)
}

View File

@ -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<String, Any?>)
})
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)
}
}

View File

@ -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<ScriptContext>()
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()) }
}
}

View File

@ -4,11 +4,6 @@ plugins {
repositories { repositories {
mavenCentral() 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 { java {

View File

@ -8,7 +8,9 @@ plugins {
dependencies { dependencies {
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
implementation(platform(kotlin("bom"))) 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<Test> { tasks.withType<Test> {
@ -19,14 +21,10 @@ tasks.withType<KotlinCompile> {
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
javaParameters = true javaParameters = true
freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
} }
} }
kotlin { kotlin {
sourceSets.all {
languageSettings.enableLanguageFeature("InlineClasses")
}
sourceSets["main"].kotlin.setSrcDirs(listOf("src")) sourceSets["main"].kotlin.setSrcDirs(listOf("src"))
sourceSets["test"].kotlin.setSrcDirs(listOf("test")) sourceSets["test"].kotlin.setSrcDirs(listOf("test"))
} }

View File

@ -1,15 +0,0 @@
templates = [
"README.md"
]
files = [
".gitignore",
".gitattributes"
]
[prompt]
[[prompt.project]]
type = "String"
name = "name"
info = "Project name"

View File

@ -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

View File

@ -1,2 +0,0 @@
.gradle
build

View File

@ -1,7 +0,0 @@
# {{ project.name }}
## Usage
```bash
./gradlew run
```