Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-11-12 18:54:01 +0300
committerLeitwolf112026-11-12 18:54:01 +0300
commit9cff1e2b6a37959abc8a8b66946df61850a0aa47 (patch)
treed15985510bec5aa895c1934ce8a1f429887de448
parent6c7e9ad4e59c46da58a121d70caa17c01006273b (diff)
parent01036658064b95c24c1136d2bba9403c81c3337d (diff)
downloadtazel-feature/plugin-api-and-gradle-plugin.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.
-rw-r--r--CHANGELOG.md2
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/cli/Custom.kt100
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/cli/Main.kt127
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/cli/Suggestions.kt74
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/driver/Processes.kt145
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/driver/Programs.kt49
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/dsl/custom/CustomCommands.kt112
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/dsl/custom/lexer/CustomLexer.kt158
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/dsl/custom/parser/CustomParser.kt117
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/dsl/options/TazelOptions.kt202
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/CommandFixture.kt75
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/CustomCommandsTest.kt328
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/TazelOptionsTest.kt130
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 == '-' || 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.