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

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-11-12 22:40:35 +0300
committerLeitwolf112026-11-12 22:40:35 +0300
commitd73260a011d463f492448bc2e29aa46fba657326 (patch)
tree838f7b44bd0853b1cd7c5ff82aa9a49f8ff61217
parent86214c96285f73d352f934b0da33d231150898f0 (diff)
downloadtazel-feature/action-level-incremental.tar.gz
Make builds incremental per action, by contentfeature/action-level-incremental
Every compile, archive, link and Zig build is now an action: a command line with its directory and environment, the files it reads and the files it writes. ActionCache keeps a record beside the first output of each action, with the digest of the action and the SHA-256 of every input and output. An action runs again only when: - its command line, directory or environment changed, - a file it read has other content: the declared inputs, and what the compiler included, from the depfile it wrote, - or an output is gone or no longer what the action wrote. Files are compared by content, not by time. Touching a file reruns nothing; a translation unit recompiled to the same object (a comment in a header, say) reruns neither the archive nor the program that use it. Digests are kept per build by size and modification time, so each file is read once. - Zig writes no depfile, so every .zig file beside and below the root file, and the libraries it links, are the inputs of a Zig build. It no longer runs on every build. - Staged headers and data files are copied only when their bytes differ. - The .cmd records and the time comparisons of UpToDate are gone. Tests: the action cache (command, environment, touched, changed and restored inputs, altered outputs, forgotten records) and, with the C compiler of the machine, a touch that runs nothing, a comment in a header that recompiles two units but does not archive or link, and a change of code that recompiles, archives and relinks.
-rw-r--r--CHANGELOG.md6
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt184
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt109
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt14
-rw-r--r--sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt107
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt28
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt55
7 files changed, 323 insertions, 180 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73127ed..d33f147 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,8 @@ Versions follow [Semantic Versioning](https://semver.org). The earlier Tazel, 0.
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.
- `tazel build` and `tazel run` for C, C++, Objective-C, Objective-C++ and Zig targets, with `--release` and
- `--triple`. Each translation unit is compiled on its own and again only when the depfile of the compiler or the
- command line says it changed; libraries and programs are archived or linked when an input changed. Clang, GCC and
+ `--triple`. Builds are incremental per action: each compile, archive, link and Zig build runs again only when its command
+ line, its environment or the content of a file it read changed (headers from the depfile of the compiler), or an
+ output it wrote is gone or altered. Touching a file reruns nothing, and an object rebuilt to the same bytes reruns
+ neither its archive nor its program. Clang, GCC and
`zig cc` are found on PATH; Clang and `zig cc` build for other triples. Outputs go to `.tazel/out/<triple>/<mode>`.
diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt
new file mode 100644
index 0000000..730c19b
--- /dev/null
+++ b/sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt
@@ -0,0 +1,184 @@
+/*
+ * 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 java.io.IOException
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * The inputs a Make-style dependency file names, as compilers write it with `-MD -MF`: the target,
+ * a colon, then the inputs, with `\ ` for a space and a backslash at the end of a line to go on.
+ */
+fun depfileInputs(text: String): List<String> {
+ val body = text.substringAfter(": ", text.substringAfter(":\n", ""))
+ val inputs = mutableListOf<String>()
+ val current = StringBuilder()
+ var index = 0
+ fun flush() {
+ if (current.isNotEmpty()) inputs += current.toString()
+ current.clear()
+ }
+ while (index < body.length) {
+ val c = body[index]
+ when {
+ c == '\\' && index + 1 < body.length && body[index + 1] == ' ' -> {
+ current.append(' ')
+ index++
+ }
+ c == '\\' && index + 1 < body.length && body[index + 1] == '\n' -> {
+ flush()
+ index++
+ }
+ c == '\\' &&
+ index + 2 < body.length &&
+ body[index + 1] == '\r' &&
+ body[index + 2] == '\n' -> {
+ flush()
+ index += 2
+ }
+ c == '$' && index + 1 < body.length && body[index + 1] == '$' -> {
+ current.append('$')
+ index++
+ }
+ c.isWhitespace() -> flush()
+ else -> current.append(c)
+ }
+ index++
+ }
+ flush()
+ return inputs
+}
+
+/**
+ * The SHA-256 of files, kept while a file keeps its size and modification time, so a build reads a
+ * file once and a file that is touched but not changed costs one more read.
+ */
+class Digests {
+ private data class Stamp(val size: Long, val modified: Long)
+
+ private val known = java.util.concurrent.ConcurrentHashMap<Path, Pair<Stamp, String>>()
+
+ /** The digest of [file] as hex, or `null` when it is not a regular file. */
+ fun of(file: Path): String? {
+ val attributes =
+ try {
+ Files.readAttributes(file, java.nio.file.attribute.BasicFileAttributes::class.java)
+ } catch (_: IOException) {
+ return null
+ }
+ if (!attributes.isRegularFile) return null
+ val stamp = Stamp(attributes.size(), attributes.lastModifiedTime().toMillis())
+ known[file]?.let { (seen, digest) -> if (seen == stamp) return digest }
+ val digest =
+ try {
+ sha256(Files.newInputStream(file).use { it.readAllBytes() })
+ } catch (_: IOException) {
+ return null
+ }
+ known[file] = stamp to digest
+ return digest
+ }
+
+ companion object {
+ fun sha256(bytes: ByteArray): String =
+ java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") {
+ "%02x".format(it)
+ }
+ }
+}
+
+/**
+ * One action of a build: a command line with its environment, the files it reads and the files it
+ * writes. Inputs the command finds by itself, such as headers, come from [discovered] after it ran.
+ */
+class Action(
+ val command: List<String>,
+ val directory: Path,
+ val inputs: List<Path>,
+ val outputs: List<Path>,
+ val environment: Map<String, String> = emptyMap(),
+ val discovered: () -> List<Path> = { emptyList() },
+) {
+ /**
+ * The digest of what the action is apart from its files: the command, directory and environment.
+ */
+ val key: String
+ get() =
+ Digests.sha256(
+ (command +
+ "\u0001" +
+ directory.toString() +
+ "\u0001" +
+ environment.toSortedMap().map { (name, value) -> "$name=$value" })
+ .joinToString("\u0000")
+ .toByteArray()
+ )
+}
+
+/**
+ * Action-level incremental builds. Each action keeps a record beside its first output: its key, and
+ * the digest of every input it read and every output it wrote. An action is current while its key
+ * is the same, every input has the digest it had, and every output is still what it wrote. Inputs
+ * are compared by content, so touching a file reruns nothing, and an output rebuilt to the same
+ * bytes reruns none of the actions that read it.
+ */
+class ActionCache(private val digests: Digests = Digests()) {
+ /** Whether [action] would write what it wrote last time. */
+ fun current(action: Action): Boolean {
+ val record = read(recordOf(action)) ?: return false
+ if (record.key != action.key) return false
+ return record.files.all { (file, digest) -> digests.of(file) == digest } &&
+ action.outputs.all { it in record.files.keys }
+ }
+
+ /** Records [action] after it succeeded. */
+ fun record(action: Action) {
+ val files = linkedMapOf<Path, String>()
+ for (file in
+ (action.inputs + action.discovered() + action.outputs).map {
+ it.toAbsolutePath().normalize()
+ }) {
+ files[file] = digests.of(file) ?: throw IOException("$file was not written or cannot be read")
+ }
+ val text = buildString {
+ appendLine("key ${action.key}")
+ files.forEach { (file, digest) -> appendLine("file $digest $file") }
+ }
+ Files.writeString(recordOf(action), text)
+ }
+
+ /** Forgets [action], so that a failed or stopped run is redone. */
+ fun forget(action: Action) {
+ Files.deleteIfExists(recordOf(action))
+ }
+
+ private class Record(val key: String, val files: Map<Path, String>)
+
+ private fun read(file: Path): Record? {
+ val lines =
+ try {
+ Files.readAllLines(file)
+ } catch (_: IOException) {
+ return null
+ }
+ val key =
+ lines.firstOrNull()?.removePrefix("key ")?.takeIf { lines.first().startsWith("key ") }
+ ?: return null
+ val files = linkedMapOf<Path, String>()
+ for (line in lines.drop(1)) {
+ if (!line.startsWith("file ")) return null
+ val digest = line.substring(5, minOf(line.length, 5 + 64))
+ files[Path.of(line.substring(5 + 64 + 1))] = digest
+ }
+ return Record(key, files)
+ }
+
+ private fun recordOf(action: Action): Path {
+ val first = action.outputs.first()
+ return first.resolveSibling(first.fileName.toString() + ".action")
+ }
+}
diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt
index c0811f6..283ceb3 100644
--- a/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt
+++ b/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt
@@ -25,9 +25,10 @@ class BuildFailure(message: String) : Exception(message)
data class BuildResult(val products: Map<TargetLabel, Path>, val commandsRun: Int)
/**
- * Builds the targets of a [BuildPlan]: each translation unit is compiled on its own and only when
- * its object is out of date (by the depfile the compiler writes and the command line it ran with);
- * a library or program is archived or linked when one of its inputs changed.
+ * Builds the targets of a [BuildPlan] as actions: each translation unit is compiled on its own,
+ * then each library is archived and each program linked. An action runs only when its command line,
+ * environment or the content of a file it read changed, or an output it wrote is gone or altered
+ * (see [ActionCache]); what a compiler includes comes from the depfile it writes.
*/
class Builder(
private val plan: BuildPlan,
@@ -41,6 +42,8 @@ class Builder(
) {
private val fileSets = FileSets(ignoreCase = host.os == Os.WINDOWS)
private val commandsRun = AtomicInteger()
+ private val digests = Digests()
+ private val cache = ActionCache(digests)
fun build(): BuildResult {
val units = mutableListOf<BuildUnit<Unit>>()
@@ -73,17 +76,20 @@ class Builder(
val objectFile = commands.objectOf(target, source)
BuildUnit("compile ${target.label} $source", "compile $source", after) {
val depfile = objectFile.resolveSibling(objectFile.fileName.toString() + ".d")
- val command = commands.compile(target, source, objectFile, depfile)
- if (!UpToDate.checkObject(objectFile, depfile, command, target.packageDirectory)) {
- Files.createDirectories(objectFile.parent)
- run(
- "Compiling",
- "${target.label} ${target.packageDirectory.relativize(source)}",
- command,
+ Files.createDirectories(objectFile.parent)
+ val action =
+ Action(
+ commands.compile(target, source, objectFile, depfile),
target.packageDirectory,
- objectFile,
+ inputs = listOf(source),
+ outputs = listOf(objectFile, depfile),
+ discovered = {
+ depfileInputs(Files.readString(depfile)).map {
+ target.packageDirectory.resolve(it).normalize()
+ }
+ },
)
- }
+ run("Compiling", "${target.label} ${target.packageDirectory.relativize(source)}", action)
}
}
val objects = sources.map { commands.objectOf(target, it) }
@@ -93,15 +99,26 @@ class Builder(
"link ${target.label}",
compiles.map { it.id }.toSet() + after,
) {
- val inputs = objects + target.libraries.map { it.product }
+ val settings = target.target.settings
+ val scripts =
+ listOfNotNull(settings.linkerScript, settings.defFile).map {
+ target.packageDirectory.resolve(it).normalize()
+ }
val (verb, command) =
if (target.kind == TargetKind.STATICLIB) "Archiving" to commands.archive(target, objects)
else "Linking" to commands.link(target, objects)
- if (!UpToDate.check(target.product, command, inputs)) {
+ val action =
+ Action(
+ command,
+ target.packageDirectory,
+ inputs = objects + target.libraries.map { it.product } + scripts,
+ outputs = listOf(target.product),
+ )
+ // ar adds to an archive that is there, so an archive is made anew.
+ run(verb, target.label.toString(), action) {
if (target.kind == TargetKind.STATICLIB) Files.deleteIfExists(target.product)
- run(verb, target.label.toString(), command, target.packageDirectory, target.product)
- if (target.kind == TargetKind.SHAREDLIB) linkUnversioned(target)
}
+ if (target.kind == TargetKind.SHAREDLIB) linkUnversioned(target)
copyData(target)
}
return compiles + final
@@ -112,7 +129,26 @@ class Builder(
return listOf(
BuildUnit(finalUnit(target.label), "zig ${target.label}", after) {
val command = zigCommand(target)
- run("Building", target.label.toString(), command, target.packageDirectory, target.product)
+ val root =
+ target.packageDirectory
+ .resolve((target.target.settings.sources.single() as FilePath).path)
+ .normalize()
+ // Zig writes no depfile: every Zig file beside and below the root file is an input.
+ val zigFiles =
+ Files.walk(root.parent).use { files ->
+ files
+ .filter { Files.isRegularFile(it) && it.toString().endsWith(".zig") }
+ .sorted()
+ .toList()
+ }
+ val action =
+ Action(
+ command,
+ target.packageDirectory,
+ inputs = zigFiles + target.libraries.map { it.product },
+ outputs = listOf(target.product),
+ )
+ run("Building", target.label.toString(), action)
copyData(target)
}
)
@@ -166,6 +202,8 @@ class Builder(
if (plan.triple.os == Os.WINDOWS) return
val name = target.target.settings.outputName ?: target.label.name
val plain = target.product.resolveSibling(OutputNames.sharedLibrary(name, plan.triple.os, null))
+ if (Files.isSymbolicLink(plain) && Files.readSymbolicLink(plain) == target.product.fileName)
+ return
Files.deleteIfExists(plain)
try {
Files.createSymbolicLink(plain, target.product.fileName)
@@ -182,34 +220,33 @@ class Builder(
target.product.resolveSibling(
target.packageDirectory.relativize(file).toString().replace("..", "__")
)
- Files.createDirectories(destination.parent)
- Files.copy(
- file,
- destination,
- StandardCopyOption.REPLACE_EXISTING,
- StandardCopyOption.COPY_ATTRIBUTES,
- )
+ copyIfChanged(file, destination)
}
}
- private fun run(
- verb: String,
- what: String,
- command: List<String>,
- directory: Path,
- output: Path,
- ) {
+ /** Copies [source] to [destination] unless it already holds the same bytes. */
+ private fun copyIfChanged(source: Path, destination: Path) {
+ if (Files.isRegularFile(destination) && digests.of(destination) == digests.of(source)) return
+ Files.createDirectories(destination.parent)
+ Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING)
+ }
+
+ /** Runs [action] unless it is current; [before] prepares its outputs. */
+ private fun run(verb: String, what: String, action: Action, before: () -> Unit = {}) {
+ if (cache.current(action)) return
synchronized(out) { out.appendLine("%12s %s".format(verb, what)) }
- UpToDate.forget(output)
+ cache.forget(action)
+ before()
commandsRun.incrementAndGet()
- val result = processes.runGathered(command, directory)
- if (result.output.isNotBlank())
+ val result = processes.runGatheredWith(action.command, action.directory, action.environment)
+ if (result.output.isNotBlank()) {
synchronized(out) { out.append(result.output.trimEnd()).appendLine() }
+ }
if (result.exitCode != 0) {
throw BuildFailure(
- "$what: ${Path.of(command.first()).fileName} exited with ${result.exitCode}"
+ "$what: ${Path.of(action.command.first()).fileName} exited with ${result.exitCode}"
)
}
- UpToDate.record(output, command)
+ cache.record(action)
}
}
diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt
index c05a9c3..b5f7cdc 100644
--- a/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt
+++ b/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt
@@ -88,16 +88,10 @@ class CcCommands(private val plan: BuildPlan, private val compiler: CcCompiler)
val destination =
staged.resolve(settings.includePrefix?.let { "$it/$relative" } ?: relative)
Files.createDirectories(destination.parent)
- if (
- !Files.exists(destination) ||
- Files.getLastModifiedTime(destination) != Files.getLastModifiedTime(file)
- ) {
- Files.copy(
- file,
- destination,
- java.nio.file.StandardCopyOption.REPLACE_EXISTING,
- java.nio.file.StandardCopyOption.COPY_ATTRIBUTES,
- )
+ // A staged header is rewritten only when its bytes differ, so what includes it is
+ // recompiled only for a real change.
+ if (!Files.isRegularFile(destination) || Files.mismatch(file, destination) != -1L) {
+ Files.copy(file, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}
}
diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt
deleted file mode 100644
index a185177..0000000
--- a/sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * 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 java.io.IOException
-import java.nio.file.Files
-import java.nio.file.Path
-
-/**
- * The inputs a Make-style dependency file names, as compilers write it with `-MD -MF`: the target,
- * a colon, then the inputs, with `\ ` for a space and a backslash at the end of a line to go on.
- */
-fun depfileInputs(text: String): List<String> {
- val body = text.substringAfter(": ", text.substringAfter(":\n", ""))
- val inputs = mutableListOf<String>()
- val current = StringBuilder()
- var index = 0
- fun flush() {
- if (current.isNotEmpty()) inputs += current.toString()
- current.clear()
- }
- while (index < body.length) {
- val c = body[index]
- when {
- c == '\\' && index + 1 < body.length && body[index + 1] == ' ' -> {
- current.append(' ')
- index++
- }
- c == '\\' && index + 1 < body.length && body[index + 1] == '\n' -> {
- flush()
- index++
- }
- c == '\\' &&
- index + 2 < body.length &&
- body[index + 1] == '\r' &&
- body[index + 2] == '\n' -> {
- flush()
- index += 2
- }
- c == '$' && index + 1 < body.length && body[index + 1] == '$' -> {
- current.append('$')
- index++
- }
- c.isWhitespace() -> flush()
- else -> current.append(c)
- }
- index++
- }
- flush()
- return inputs
-}
-
-/**
- * Decides whether an output is still what its command would make. The command line is kept beside
- * the output in a `.cmd` file; the output is current when that line is the same and no input is
- * newer than the output.
- */
-object UpToDate {
- /** Whether [output] made by [command] from [inputs] is current. */
- fun check(output: Path, command: List<String>, inputs: List<Path>): Boolean {
- if (!Files.isRegularFile(output)) return false
- val recorded =
- try {
- Files.readString(commandFile(output))
- } catch (_: IOException) {
- return false
- }
- if (recorded != commandText(command)) return false
- val made = Files.getLastModifiedTime(output).toMillis()
- return inputs.all { input ->
- try {
- Files.getLastModifiedTime(input).toMillis() <= made
- } catch (_: IOException) {
- false
- }
- }
- }
-
- /** Whether an object made by [command] is current, with its inputs from the depfile [depfile]. */
- fun checkObject(objectFile: Path, depfile: Path, command: List<String>, base: Path): Boolean {
- val text =
- try {
- Files.readString(depfile)
- } catch (_: IOException) {
- return false
- }
- val inputs = depfileInputs(text).map { base.resolve(it).normalize() }
- return inputs.isNotEmpty() && check(objectFile, command, inputs)
- }
-
- /** Records that [output] was made by [command]. */
- fun record(output: Path, command: List<String>) {
- Files.writeString(commandFile(output), commandText(command))
- }
-
- /** Forgets how [output] was made, so a failed or stopped run is redone. */
- fun forget(output: Path) {
- Files.deleteIfExists(commandFile(output))
- }
-
- private fun commandFile(output: Path) = output.resolveSibling(output.fileName.toString() + ".cmd")
-
- private fun commandText(command: List<String>) = command.joinToString("\u0000")
-}
diff --git a/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt b/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt
index 36e1323..fa5d0d5 100644
--- a/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt
+++ b/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt
@@ -12,6 +12,7 @@ import com.progmasoft.tazel.driver.SystemProcesses
import com.progmasoft.tazel.dsl.TargetTriple
import java.nio.file.Files
import java.nio.file.Path
+import java.nio.file.attribute.FileTime
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
@@ -126,16 +127,29 @@ class BuildCommandTest {
assertEquals(ExitCode.SUCCESS, again, againOutput.stderr())
assertContains(againOutput.stdout(), "0 command(s) run")
- // A header change recompiles what includes it, and relinks.
+ // A touch changes no content: nothing runs.
val header = root.resolve("core/include/core.h")
+ Files.setLastModifiedTime(header, FileTime.fromMillis(System.currentTimeMillis() + 60_000))
+ assertContains(
+ tazel(root, ".", processes, "build", "//...").second.stdout(),
+ "0 command(s) run",
+ )
+
+ // A comment recompiles both units that include the header; their objects come out the same,
+ // so neither the archive nor the program is made again.
Files.writeString(header, "int sample_add(int a, int b); /* changed */\n")
- Files.setLastModifiedTime(
- header,
- java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 5_000),
+ val (comment, commentOutput) = tazel(root, ".", processes, "build", "//...")
+ assertEquals(ExitCode.SUCCESS, comment, commentOutput.stderr())
+ assertContains(commentOutput.stdout(), "2 command(s) run")
+
+ // A change of code recompiles its unit, archives and relinks.
+ Files.writeString(
+ root.resolve("core/src/core.c"),
+ "#include <sample/core.h>\nint sample_add(int a, int b) { return b + a + SAMPLE_OFFSET - 1; }\n",
)
- val (changed, changedOutput) = tazel(root, ".", processes, "build", "//...")
- assertEquals(ExitCode.SUCCESS, changed, changedOutput.stderr())
- assertContains(changedOutput.stdout(), "4 command(s) run")
+ val (code, codeOutput) = tazel(root, ".", processes, "build", "//...")
+ assertEquals(ExitCode.SUCCESS, code, codeOutput.stderr())
+ assertContains(codeOutput.stdout(), "Archiving")
val (ran, ranOutput) = tazel(root, "app", processes, "run", "--", "extra")
assertEquals(ExitCode.SUCCESS, ran, ranOutput.stderr())
diff --git a/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt b/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt
index 5f7d831..ddde6e9 100644
--- a/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt
+++ b/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt
@@ -30,26 +30,45 @@ class DriverTest {
}
@Test
- fun anOutputIsCurrentUntilItsCommandOrAnInputChanges() {
- val directory = Files.createTempDirectory("tazel-up-to-date")
+ fun anActionIsCurrentUntilItsCommandOrTheContentOfAFileChanges() {
+ val directory = Files.createTempDirectory("tazel-actions")
val input = Files.writeString(directory.resolve("a.c"), "int a;")
+ val header = Files.writeString(directory.resolve("a.h"), "int b;")
val output = Files.writeString(directory.resolve("a.o"), "object")
- Files.setLastModifiedTime(input, FileTime.fromMillis(1_000_000))
- Files.setLastModifiedTime(output, FileTime.fromMillis(2_000_000))
- val command = listOf("cc", "-c", "a.c")
- assertFalse(UpToDate.check(output, command, listOf(input)), "never recorded")
- UpToDate.record(output, command)
- assertTrue(UpToDate.check(output, command, listOf(input)))
- assertFalse(UpToDate.check(output, command + "-O2", listOf(input)), "another command")
- Files.setLastModifiedTime(input, FileTime.fromMillis(3_000_000))
- assertFalse(UpToDate.check(output, command, listOf(input)), "a newer input")
- Files.setLastModifiedTime(input, FileTime.fromMillis(1_000_000))
- assertFalse(
- UpToDate.check(output, command, listOf(directory.resolve("gone.h"))),
- "a missing input",
- )
- UpToDate.forget(output)
- assertFalse(UpToDate.check(output, command, listOf(input)), "forgotten")
+ fun action(
+ command: List<String> = listOf("cc", "-c", "a.c"),
+ environment: Map<String, String> = emptyMap(),
+ ) =
+ Action(
+ command,
+ directory,
+ listOf(input),
+ listOf(output),
+ environment,
+ discovered = { listOf(header) },
+ )
+ val cache = ActionCache()
+ assertFalse(cache.current(action()), "never recorded")
+ cache.record(action())
+ assertTrue(cache.current(action()))
+ assertFalse(cache.current(action(listOf("cc", "-c", "a.c", "-O2"))), "another command")
+ assertFalse(cache.current(action(environment = mapOf("LANG" to "C"))), "another environment")
+
+ // A touch changes no content, so nothing is redone.
+ Files.setLastModifiedTime(input, FileTime.fromMillis(System.currentTimeMillis() + 60_000))
+ Files.setLastModifiedTime(header, FileTime.fromMillis(System.currentTimeMillis() + 60_000))
+ assertTrue(ActionCache().current(action()), "touched only")
+
+ Files.writeString(header, "int b; int c;")
+ assertFalse(ActionCache().current(action()), "a discovered input changed")
+ Files.writeString(header, "int b;")
+ assertTrue(ActionCache().current(action()), "back to what it was")
+
+ Files.writeString(output, "altered")
+ assertFalse(ActionCache().current(action()), "an output was altered")
+ Files.writeString(output, "object")
+ cache.forget(action())
+ assertFalse(ActionCache().current(action()), "forgotten")
}
@Test