diff options
| author | Leitwolf11 | 2026-11-12 18:54:01 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-11-12 18:54:01 +0300 |
| commit | 9cff1e2b6a37959abc8a8b66946df61850a0aa47 (patch) | |
| tree | d15985510bec5aa895c1934ce8a1f429887de448 | |
| parent | 6c7e9ad4e59c46da58a121d70caa17c01006273b (diff) | |
| parent | 01036658064b95c24c1136d2bba9403c81c3337d (diff) | |
| download | tazel-9cff1e2b6a37959abc8a8b66946df61850a0aa47.tar.gz | |
Merge main into feature/plugin-api-and-gradle-pluginfeature/plugin-api-and-gradle-plugin
Keeps both changelog entries: Tazel Custom Commands and Options from
main, then the plugin API and the Gradle plugin.
13 files changed, 1601 insertions, 18 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 569451d..3eb7111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Versions follow [Semantic Versioning](https://semver.org). The earlier Tazel, 0. `c`, `cpp`, `objc`, `objcpp` and `zig` targets of the four kinds, with `isPublic` per file. A setting that a language or kind does not have is a compile error of the project file. A target block runs for each triple and build mode; its labels are resolved and visibility is checked. +- `tazel custom`: Tazel Custom Commands from `tazel_custom.txt`, and Tazel Options from `tazel_options.txt`, taken + from the legacy Tazel with their tests; a sandbox asked for stops a custom command, since Tazel has no MicroVM to start. - The plugin API, `com.progmasoft.tazel:tazel-api` (API version 1), and the Gradle plugin `com.progmasoft.tazel.plugin`, taken from the legacy Tazel. `tazelPlugin { }` now holds `group`, `name` and `version`, which the plugin descriptor records; the group must be a reversed domain and the version a Semantic Version. diff --git a/sources/main/kotlin/com/progmasoft/tazel/cli/Custom.kt b/sources/main/kotlin/com/progmasoft/tazel/cli/Custom.kt new file mode 100644 index 0000000..a2a49ac --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/cli/Custom.kt @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.cli + +import com.progmasoft.tazel.driver.Processes +import com.progmasoft.tazel.driver.Programs +import com.progmasoft.tazel.dsl.Os +import com.progmasoft.tazel.dsl.custom.CUSTOM_COMMANDS_FILE +import com.progmasoft.tazel.dsl.custom.CustomCommand +import com.progmasoft.tazel.dsl.custom.CustomCommandsException +import com.progmasoft.tazel.dsl.custom.CustomStep +import com.progmasoft.tazel.dsl.custom.STRICT_PROCESSES_PROPERTY +import com.progmasoft.tazel.dsl.custom.matching +import com.progmasoft.tazel.dsl.custom.parser.parseCustomCommands +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +/** Runs Tazel Custom Commands: the commands of the nearest `tazel_custom.txt`. */ +internal class CustomCommands( + private val directory: Path, + private val os: Os, + private val programs: Programs, + private val processes: Processes, + private val out: Appendable, +) { + /** Runs the command [words] name, in the directory of its file, or lists the commands. */ + fun run(words: List<String>): Int { + val file = + generateSequence(directory, Path::getParent) + .map { it.resolve(CUSTOM_COMMANDS_FILE) } + .firstOrNull(Files::isRegularFile) + ?: throw Stop("no $CUSTOM_COMMANDS_FILE here or in a directory above") + val commands = + try { + parseCustomCommands(Files.readString(file)) + } catch (error: CustomCommandsException) { + throw Stop(error.message.orEmpty()) + } catch (error: IOException) { + throw Stop("$file could not be read: ${error.message}") + } + if (words.isEmpty()) { + commands.forEach { out.appendLine(it.name) } + return ExitCode.SUCCESS + } + val written = words.joinToString(" ") + val (command, arguments) = + commands.matching(words) + ?: throw Stop( + "no command is named '$written' in $file", + closestTo(written, commands.map(CustomCommand::name))?.let { didYouMean(written, it) }, + ) + if (arguments.size < command.required) { + throw Stop("'${command.name}' needs ${command.required} argument(s), not ${arguments.size}") + } + if (arguments.size > command.required && !command.takesAll) { + throw Stop( + "'${command.name}' takes ${command.required} argument(s), not ${arguments.size}", + closestTo(written, commands.map(CustomCommand::name)) + ?.takeIf { it != command.name } + ?.let { didYouMean(written, it) }, + ) + } + if (os == Os.WINDOWS && strictProcesses()) { + command.lineUnfitForStrictProcesses?.let { + throw Stop( + "'${command.name}' cannot reach CMD unchanged: this JVM runs with " + + "$STRICT_PROCESSES_PROPERTY=false, which refuses or changes the line: $it" + ) + } + } + for (step in command.steps) { + val exitCode = processes.run(commandOf(step, file.parent, arguments), file.parent) + if (exitCode != ExitCode.SUCCESS) return exitCode + } + return ExitCode.SUCCESS + } + + private fun strictProcesses() = + System.getProperty(STRICT_PROCESSES_PROPERTY).equals("false", ignoreCase = true) + + private fun commandOf(step: CustomStep, base: Path, arguments: List<String>): List<String> = + when (step) { + is CustomStep.Shell -> step.started(os) + is CustomStep.Program -> { + val expanded = step.expanded(arguments) + val program = expanded.first() + val found = + if (program.contains('/') || program.contains('\\')) { + base.resolve(program).normalize().toString() + } else { + programs.find(program)?.toString() ?: throw Stop("no program named '$program' on PATH") + } + listOf(found) + expanded.drop(1) + } + } +} diff --git a/sources/main/kotlin/com/progmasoft/tazel/cli/Main.kt b/sources/main/kotlin/com/progmasoft/tazel/cli/Main.kt index 4887228..c5a8f46 100644 --- a/sources/main/kotlin/com/progmasoft/tazel/cli/Main.kt +++ b/sources/main/kotlin/com/progmasoft/tazel/cli/Main.kt @@ -5,8 +5,31 @@ package com.progmasoft.tazel.cli +import com.progmasoft.tazel.driver.Processes +import com.progmasoft.tazel.driver.Programs +import com.progmasoft.tazel.driver.SystemProcesses +import com.progmasoft.tazel.dsl.Os +import com.progmasoft.tazel.dsl.ProjectException +import com.progmasoft.tazel.dsl.TargetTriple +import com.progmasoft.tazel.dsl.custom.MAX_CUSTOM_ARGUMENTS +import com.progmasoft.tazel.dsl.options.TazelOptionsException +import com.progmasoft.tazel.dsl.options.sandboxRefusal +import java.nio.file.Path import kotlin.system.exitProcess +/** The exit codes of Tazel. */ +object ExitCode { + const val SUCCESS = 0 + const val FAILURE = 1 + const val USAGE = 2 +} + +/** + * A command that stops with a message; the message is all the user needs to see, with [hint] under + * it when a word looks like a typo of a real one. + */ +internal class Stop(message: String, val hint: String? = null) : Exception(message) + /** The version the JAR of Tazel was built as; a run from loose classes has none. */ internal fun tazelVersion(): String = object {}.javaClass.`package`?.implementationVersion ?: "development" @@ -19,36 +42,104 @@ internal val HELP = Usage: tazel <command> Commands: + custom runs a command of tazel_custom.txt, or lists them version prints the version of Tazel help prints this help """ .trimIndent() +private val COMMANDS = listOf("custom", "version", "help") + +/** Where a command runs and what it starts programs with; tests give their own. */ +internal class Environment( + val directory: Path = Path.of("").toAbsolutePath(), + val os: Os = TargetTriple.host().os, + val programs: Programs = Programs(os), + val processes: Processes = SystemProcesses, +) + /** - * Runs one command line and returns its exit code: 0 for success, 2 for a command line that is - * wrong. + * Runs one command line and returns its exit code: 0 for success, 1 for a command that failed, 2 + * for a command line that is wrong. */ -internal fun run(arguments: List<String>, out: Appendable, err: Appendable): Int = - when (arguments) { - listOf("version"), - listOf("--version"), - listOf("-v") -> { - out.appendLine("tazel ${tazelVersion()}") - 0 - } - emptyList<String>(), - listOf("help"), - listOf("--help"), - listOf("-h") -> { - out.appendLine(HELP) - 0 +internal fun run( + arguments: List<String>, + out: Appendable, + err: Appendable, + environment: Environment = Environment(), +): Int = + when (arguments.firstOrNull()) { + null, + "help", + "--help", + "-h" -> + if (arguments.size <= 1) { + out.appendLine(HELP) + ExitCode.SUCCESS + } else usage(err, "tazel help takes no arguments") + "version", + "--version", + "-v" -> + if (arguments.size == 1) { + out.appendLine("tazel ${tazelVersion()}") + ExitCode.SUCCESS + } else usage(err, "tazel version takes no arguments") + // What follows `custom` belongs to the command it names, whatever it looks like. + "custom" -> { + val words = arguments.drop(1) + if (words.size > MAX_CUSTOM_ARGUMENTS) { + usage( + err, + "tazel custom takes at most $MAX_CUSTOM_ARGUMENTS arguments, and got ${words.size}", + ) + } else { + stopping(err) { + refuseWithoutSandbox(environment.directory) + CustomCommands( + environment.directory, + environment.os, + environment.programs, + environment.processes, + out, + ) + .run(words) + } + } } else -> { - err.appendLine("error: '${arguments.first()}' is not a command of tazel; write tazel help") - 2 + val written = arguments.first() + err.appendLine("error: '$written' is not a command of tazel; write tazel help") + closestTo(written, COMMANDS)?.let { err.appendLine(didYouMean(written, it)) } + ExitCode.USAGE } } +/** Stops a command that would run what a project brings when a sandbox is asked for. */ +private fun refuseWithoutSandbox(directory: Path) { + try { + sandboxRefusal(directory)?.let { throw Stop(it) } + } catch (error: TazelOptionsException) { + throw Stop(error.message.orEmpty()) + } +} + +private fun usage(err: Appendable, message: String): Int { + err.appendLine("error: $message") + return ExitCode.USAGE +} + +private fun stopping(err: Appendable, block: () -> Int): Int = + try { + block() + } catch (stop: Stop) { + err.appendLine("error: ${stop.message}") + stop.hint?.let(err::appendLine) + ExitCode.FAILURE + } catch (error: ProjectException) { + err.appendLine("error: ${error.message}") + ExitCode.FAILURE + } + fun main(arguments: Array<String>) { exitProcess(run(arguments.toList(), System.out, System.err)) } diff --git a/sources/main/kotlin/com/progmasoft/tazel/cli/Suggestions.kt b/sources/main/kotlin/com/progmasoft/tazel/cli/Suggestions.kt new file mode 100644 index 0000000..4ed5c15 --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/cli/Suggestions.kt @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.cli + +/** + * The candidate that [word] was most likely meant to be, or null when none is close enough to be a + * typo. Case is ignored first, so `Build` finds `build`; otherwise the edit distance, with two + * swapped letters counting as one edit, must be at most one for a short word and two for a longer + * one, and below the length of the word. On a tie the earlier candidate wins. + */ +fun closestTo( + word: String, + candidates: Iterable<String>, +): String? { + val choices = candidates.toList() + choices + .firstOrNull { it.equals(word, ignoreCase = true) && it != word } + ?.let { + return it + } + val limit = if (word.length <= 4) 1 else 2 + return choices + .map { it to editDistance(word.lowercase(), it.lowercase()) } + .filter { (_, distance) -> distance in 1..limit && distance < word.length } + .minByOrNull { (_, distance) -> distance } + ?.first +} + +/** + * The number of insertions, deletions, substitutions and swaps of two neighbouring letters that + * turn [first] into [second] (optimal string alignment). + */ +internal fun editDistance( + first: String, + second: String, +): Int { + val distances = Array(first.length + 1) { IntArray(second.length + 1) } + for (i in 0..first.length) distances[i][0] = i + for (j in 0..second.length) distances[0][j] = j + for (i in 1..first.length) { + for (j in 1..second.length) { + val cost = if (first[i - 1] == second[j - 1]) 0 else 1 + var best = + minOf(distances[i - 1][j] + 1, distances[i][j - 1] + 1, distances[i - 1][j - 1] + cost) + if (i > 1 && j > 1 && first[i - 1] == second[j - 2] && first[i - 2] == second[j - 1]) { + best = minOf(best, distances[i - 2][j - 2] + 1) + } + distances[i][j] = best + } + } + return distances[first.length][second.length] +} + +/** Short asides that follow a suggestion; the word that was typed picks one, so a run repeats. */ +private val asides = + listOf( + "Fingers sometimes outrun the keyboard.", + "One letter wandered off.", + "Typos happen to the best of us.", + "So close the keys almost had it.", + "The keyboard is innocent, probably.", + ) + +/** The aside for a suggestion made for [word]. */ +fun asideFor(word: String): String = asides[Math.floorMod(word.hashCode(), asides.size)] + +/** The hint printed under an error: the suggestion and its aside. */ +fun didYouMean( + word: String, + suggestion: String, +): String = "did you mean $suggestion? ${asideFor(word)}" diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/Processes.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/Processes.kt new file mode 100644 index 0000000..c592fc1 --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/driver/Processes.kt @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.driver + +import com.progmasoft.tazel.dsl.ProjectException +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +/** Starts the programs Tazel runs. A test replaces it to see the commands without running them. */ +interface Processes { + /** Runs [command] in [directory] on the streams of Tazel and returns its exit code. */ + fun run( + command: List<String>, + directory: Path, + ): Int + + /** + * Runs [command] like [run] with the variables of [environment] added to those of Tazel. Only the + * build steps of plugins pass variables; a stand-in that starts no program may ignore them. + */ + fun runWith( + command: List<String>, + directory: Path, + environment: Map<String, String>, + ): Int = run(command, directory) + + /** Runs [command] like [runGathered] with the variables of [environment] added. */ + fun runGatheredWith( + command: List<String>, + directory: Path, + environment: Map<String, String>, + ): GatheredRun = runGathered(command, directory) + + /** Runs [command] and returns what it wrote to its output, or null when it failed. */ + fun capture(command: List<String>): String? + + /** + * Runs [command] in [directory] with its output and errors gathered instead of shown, so the + * caller can print them where it wants, above a progress bar for instance. + */ + fun runGathered( + command: List<String>, + directory: Path, + ): GatheredRun = GatheredRun(run(command, directory), "") + + /** + * Runs [command] in [directory] and hands each line it writes, output and errors in the order + * written, to [line] as soon as the line is complete. + */ + fun runLines( + command: List<String>, + directory: Path, + line: (String) -> Unit, + ): Int = runGathered(command, directory).also { it.output.lines().forEach(line) }.exitCode +} + +/** The exit code of a program and everything it wrote, output and errors in the order written. */ +data class GatheredRun( + val exitCode: Int, + val output: String, +) + +object SystemProcesses : Processes { + override fun run( + command: List<String>, + directory: Path, + ): Int = runWith(command, directory, emptyMap()) + + override fun runWith( + command: List<String>, + directory: Path, + environment: Map<String, String>, + ): Int = + try { + ProcessBuilder(command) + .directory(directory.toFile()) + .inheritIO() + .also { it.environment().putAll(environment) } + .start() + .waitFor() + } catch (error: java.io.IOException) { + throw ProjectException("${command.first()} could not be started: ${error.message}") + } + + override fun runGathered( + command: List<String>, + directory: Path, + ): GatheredRun = runGatheredWith(command, directory, emptyMap()) + + override fun runGatheredWith( + command: List<String>, + directory: Path, + environment: Map<String, String>, + ): GatheredRun = + try { + val process = + ProcessBuilder(command) + .directory(directory.toFile()) + .also { it.environment().putAll(environment) } + .redirectErrorStream(true) + .redirectInput(ProcessBuilder.Redirect.from(nullDevice())) + .start() + val output = process.inputStream.bufferedReader().readText() + GatheredRun(process.waitFor(), output) + } catch (error: java.io.IOException) { + throw ProjectException("${command.first()} could not be started: ${error.message}") + } + + override fun runLines( + command: List<String>, + directory: Path, + line: (String) -> Unit, + ): Int = + try { + val process = + ProcessBuilder(command) + .directory(directory.toFile()) + .redirectErrorStream(true) + .redirectInput(ProcessBuilder.Redirect.from(nullDevice())) + .start() + process.inputStream.bufferedReader().forEachLine(line) + process.waitFor() + } catch (error: java.io.IOException) { + throw ProjectException("${command.first()} could not be started: ${error.message}") + } + + override fun capture(command: List<String>): String? = + try { + val process = + ProcessBuilder(command) + .redirectErrorStream(true) + .redirectInput(ProcessBuilder.Redirect.from(nullDevice())) + .start() + val output = process.inputStream.bufferedReader().readText() + if (process.waitFor(30, TimeUnit.SECONDS) && process.exitValue() == 0) output else null + } catch (_: java.io.IOException) { + null + } + + private fun nullDevice() = + java.io.File(if (System.getProperty("os.name").startsWith("Windows")) "NUL" else "/dev/null") +} diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/Programs.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/Programs.kt new file mode 100644 index 0000000..a13996a --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/driver/Programs.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.driver + +import com.progmasoft.tazel.dsl.Os +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +/** Finds the programs Tazel starts on `PATH`. */ +class Programs( + private val os: Os, + private val environment: Map<String, String> = System.getenv(), +) { + /** + * The program [name] on `PATH`. On Windows a program may also be a script, like `npm.cmd`, and a + * name without an extension finds any of them. + */ + fun find(name: String): Path? { + val extensions = if (os == Os.WINDOWS) WINDOWS_PROGRAMS else emptyList() + val fileNames = + if (extensions.isEmpty() || extensions.any { name.endsWith(it, ignoreCase = true) }) { + listOf(name) + } else { + extensions.map { name + it } + } + return variable("PATH") + .orEmpty() + .split(File.pathSeparatorChar) + .filter(String::isNotEmpty) + .flatMap { directory -> fileNames.map { Path.of(directory).resolve(it) } } + .firstOrNull(Files::isRegularFile) + } + + private companion object { + /** What Windows runs by itself, in the order a directory is searched. */ + val WINDOWS_PROGRAMS = listOf(".exe", ".cmd", ".bat", ".com") + } + + // Windows spells the same variable "Path" or "PATH". + private fun variable(name: String) = + environment.entries + .firstOrNull { it.key.equals(name, ignoreCase = os == Os.WINDOWS) } + ?.value + ?.takeIf(String::isNotBlank) +} diff --git a/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/CustomCommands.kt b/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/CustomCommands.kt new file mode 100644 index 0000000..887588f --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/CustomCommands.kt @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.dsl.custom + +import com.progmasoft.tazel.dsl.Os + +/** The file that holds the commands of `tazel custom`, in the directory they run in. */ +const val CUSTOM_COMMANDS_FILE = "tazel_custom.txt" + +/** The most words `tazel custom` takes, those of the name of the command included. */ +const val MAX_CUSTOM_ARGUMENTS = 191 + +/** Ends the name of a custom command on the command line; what follows it is arguments. */ +const val CUSTOM_NAME_END = "--" + +/** With the value `false`, the JVM quotes the arguments of processes on Windows more strictly. */ +const val STRICT_PROCESSES_PROPERTY = "jdk.lang.Process.allowAmbiguousCommands" + +/** One argument of a program in a custom command. */ +sealed interface CustomArgument { + /** A string of the file. */ + data class Text(val value: String) : CustomArgument + + /** `<N>`: the Nth argument of the command line, from 1. */ + data class Position(val number: Int) : CustomArgument + + /** `<*>`: all the arguments of the command line. */ + data object All : CustomArgument +} + +/** One step of a custom command. */ +sealed interface CustomStep { + /** + * A program started directly, with these arguments and no shell in between. The first is the + * program, and is a string. + */ + data class Program(val arguments: List<CustomArgument>) : CustomStep { + /** What to start, given the arguments of the command line. */ + fun expanded(given: List<String>): List<String> = arguments.flatMap { + when (it) { + is CustomArgument.Text -> listOf(it.value) + is CustomArgument.Position -> listOf(given[it.number - 1]) + CustomArgument.All -> given + } + } + } + + /** A whole command line for the shell of the host: CMD on Windows, `sh` elsewhere. */ + data class Shell(val line: String) : CustomStep { + /** + * What to start on [os]. The line reaches the shell unchanged: `/s` makes CMD drop the quotes + * around it, and Java leaves an argument that is already quoted alone, where it would otherwise + * double a trailing backslash and treat a line that starts and ends with a quote as quoted. + */ + fun started(os: Os): List<String> = + if (os == Os.WINDOWS) listOf("cmd.exe", "/d", "/s", "/c", "\"$line\"") + else listOf("sh", "-c", line) + + /** + * Whether [started] still holds under `jdk.lang.Process.allowAmbiguousCommands=false`. In that + * mode Java refuses a quoted argument with a quote inside and doubles the backslash before a + * closing quote, and it has no other way to give CMD a line as it is. + */ + val fitsStrictProcesses: Boolean + get() = '"' !in line && !line.endsWith('\\') + } +} + +/** A named command of `tazel_custom.txt`: steps that run in order until one fails. */ +data class CustomCommand( + val name: String, + val steps: List<CustomStep>, +) { + private val placeholders + get() = steps.filterIsInstance<CustomStep.Program>().flatMap { it.arguments } + + /** The words of [name]. */ + val words: List<String> + get() = name.split(' ') + + /** The highest `<N>`: the arguments the command cannot do without. */ + val required: Int + get() = placeholders.filterIsInstance<CustomArgument.Position>().maxOfOrNull { it.number } ?: 0 + + /** The first line of a shell step that Java's strict process mode would refuse or change. */ + val lineUnfitForStrictProcesses: String? + get() = steps.filterIsInstance<CustomStep.Shell>().firstOrNull { !it.fitsStrictProcesses }?.line + + /** Whether `<*>` takes any number of arguments. */ + val takesAll: Boolean + get() = CustomArgument.All in placeholders +} + +/** + * The command that [words] name, with the arguments that follow its name. Names may start with + * other names, so the longest one that fits wins. The first [CUSTOM_NAME_END] stops the name, and + * everything after it is an argument, so that an argument can be a word that a longer name holds. + */ +fun List<CustomCommand>.matching(words: List<String>): Pair<CustomCommand, List<String>>? { + val end = words.indexOf(CUSTOM_NAME_END) + val named = if (end < 0) words else words.take(end) + val after = if (end < 0) emptyList() else words.drop(end + 1) + return filter { named.take(it.words.size) == it.words } + .maxByOrNull { it.words.size } + ?.let { it to named.drop(it.words.size) + after } +} + +/** What is wrong with a `tazel_custom.txt`, with where it is. */ +class CustomCommandsException(message: String) : Exception(message) diff --git a/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/lexer/CustomLexer.kt b/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/lexer/CustomLexer.kt new file mode 100644 index 0000000..0fb67ce --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/lexer/CustomLexer.kt @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.dsl.custom.lexer + +import com.progmasoft.tazel.dsl.custom.CUSTOM_COMMANDS_FILE +import com.progmasoft.tazel.dsl.custom.CustomCommandsException +import com.progmasoft.tazel.dsl.custom.MAX_CUSTOM_ARGUMENTS + +/** The kinds of [Token]. */ +enum class TokenKind { + NAME, + STRING, + + /** `<N>` or `<*>`; its text is what stands between the brackets. */ + PLACEHOLDER, + COLON, + SEMICOLON, + OPEN, + CLOSE, +} + +/** One word of the file, with where it starts. */ +data class Token( + val kind: TokenKind, + val text: String, + val line: Int, + val column: Int, +) + +/** + * Splits the text of [file], `tazel_custom.txt` or `tazel_options.txt`: comments go, and whitespace + * of any kind only separates. + */ +class CustomLexer( + private val text: String, + private val file: String = CUSTOM_COMMANDS_FILE, +) { + private var position = 0 + private var line = 1 + private var column = 1 + + private fun fail( + message: String, + atLine: Int = line, + atColumn: Int = column, + ): Nothing = throw CustomCommandsException("$file:$atLine:$atColumn: $message") + + private fun advance(): Char { + val letter = text[position++] + if (letter == '\n') { + line++ + column = 1 + } else { + column++ + } + return letter + } + + private fun skipBlockComment() { + val startLine = line + val startColumn = column + advance() + advance() + while (position < text.length) { + if (text.startsWith("#>", position)) { + advance() + advance() + return + } + advance() + } + fail("this comment has no closing #>", startLine, startColumn) + } + + // A string has no escapes: it ends at the next quote of its own kind, whatever comes between. + private fun string(): Token { + val startLine = line + val startColumn = column + val quote = advance() + val begin = position + while (position < text.length && text[position] != quote) advance() + if (position == text.length) fail("this string has no closing $quote", startLine, startColumn) + val value = text.substring(begin, position) + advance() + return Token(TokenKind.STRING, value, startLine, startColumn) + } + + private fun placeholder(): Token { + val startLine = line + val startColumn = column + advance() + val begin = position + while (position < text.length && text[position] != '>' && !text[position].isWhitespace()) { + advance() + } + if (position == text.length || text[position] != '>') { + fail("expected <N> or <*>", startLine, startColumn) + } + val value = text.substring(begin, position) + advance() + val number = value.toIntOrNull() + val valid = + value == "*" || + (number != null && + number in 1..MAX_CUSTOM_ARGUMENTS && + !value.startsWith("0") && + value.all(Char::isDigit)) + if (!valid) { + fail( + "<$value> is not an argument: <N> runs from 1 to $MAX_CUSTOM_ARGUMENTS, and <*> is all of " + + "them", + startLine, + startColumn, + ) + } + return Token(TokenKind.PLACEHOLDER, value, startLine, startColumn) + } + + private fun name(): Token { + val startLine = line + val startColumn = column + val begin = position + while (position < text.length && isNameLetter(text[position])) advance() + return Token(TokenKind.NAME, text.substring(begin, position), startLine, startColumn) + } + + fun tokens(): List<Token> = buildList { + while (position < text.length) { + val letter = text[position] + when { + letter.isWhitespace() -> advance() + text.startsWith("<#", position) -> skipBlockComment() + letter == '#' -> while (position < text.length && text[position] != '\n') advance() + letter == '"' || letter == '\'' || letter == '`' -> add(string()) + letter == '<' -> add(placeholder()) + isNameLetter(letter) -> add(name()) + else -> { + val kind = + when (letter) { + ':' -> TokenKind.COLON + ';' -> TokenKind.SEMICOLON + '(' -> TokenKind.OPEN + ')' -> TokenKind.CLOSE + else -> fail("'$letter' has no meaning here") + } + add(Token(kind, letter.toString(), line, column)) + advance() + } + } + } + } + + private fun isNameLetter(letter: Char) = + letter.isLetterOrDigit() || letter == ' |