diff options
| author | Leitwolf11 | 2026-11-12 16:05:54 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-11-12 16:05:54 +0300 |
| commit | 74c6d3b2e85cc51e5dde1bcccae581e23eae20df (patch) | |
| tree | 702414275fb5683ef08cb8bfc6afe53dc38cd59b | |
| parent | 0a37512d10792996402c21051c6ad58e4f5f506e (diff) | |
| download | tazel-74c6d3b2e85cc51e5dde1bcccae581e23eae20df.tar.gz | |
Bring Tazel Custom Commands and Tazel Options from the legacy Tazelfeature/custom-commands-and-options
The code and tests come from Progmasoft/tazel-legacy, with the packages
renamed so that the shared lexer sits beside both users:
- com.progmasoft.tazel.custom.commands -> com.progmasoft.tazel.dsl.custom
- com.progmasoft.tazel.custom.commands.lexer -> com.progmasoft.tazel.dsl.custom.lexer
- com.progmasoft.tazel.custom.commands.parser -> com.progmasoft.tazel.dsl.custom.parser
- com.progmasoft.tazel.options -> com.progmasoft.tazel.dsl.options
tazel custom runs a command of the nearest tazel_custom.txt in the
directory of that file, or lists the commands; the name matching, <N>
and <*> arguments, the -- separator, s(...) shell steps (CMD on Windows,
sh elsewhere) and the strict-process check are those of the legacy
Tazel. tazel_options.txt is read as before: a sandbox asked for in any
file at or above the directory stops the command, since Tazel has no
MicroVM to start yet.
The process interface (driver.Processes) and the suggestions under
errors come along; program lookup on PATH is driver.Programs, the part
of the legacy Toolchain that is not about Tala tools. OperatingSystem
becomes the Os of the project files. An unknown command now gets a
"did you mean" hint.
The 33 legacy tests of these parts pass against a smaller fixture.
13 files changed, 1601 insertions, 18 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 532172f..a3d5220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,5 @@ 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. 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 == '-' || letter == '_' || letter == '.' +} diff --git a/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/parser/CustomParser.kt b/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/parser/CustomParser.kt new file mode 100644 index 0000000..c70145e --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/dsl/custom/parser/CustomParser.kt @@ -0,0 +1,117 @@ +/* + * 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.parser + +import com.progmasoft.tazel.dsl.custom.CUSTOM_COMMANDS_FILE +import com.progmasoft.tazel.dsl.custom.CUSTOM_NAME_END +import com.progmasoft.tazel.dsl.custom.CustomArgument +import com.progmasoft.tazel.dsl.custom.Cu |