diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 71a086f..bd8d8be 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,12 +4,6 @@ "dockerfile": "Containerfile", "options": ["--compress"] }, - "capAdd": [ - "SYS_PTRACE" - ], - "securityOpt": [ - "seccomp=unconfined" - ], "features": {}, "customizations": { "vscode": { diff --git a/.github/actions/setup-zig/action.yml b/.github/actions/setup-zig/action.yml index 03bcc2d..d215d59 100644 --- a/.github/actions/setup-zig/action.yml +++ b/.github/actions/setup-zig/action.yml @@ -5,9 +5,10 @@ description: Install the Zig version specified in build.zig.zon runs: using: composite steps: - - shell: bash + - name: Install minisign + shell: bash run: | - version=$(grep 'minimum_zig_version' build.zig.zon | sed 's/.*"\(.*\)".*/\1/') + set -euo pipefail case "$RUNNER_OS" in Linux) os=linux ;; @@ -21,12 +22,79 @@ runs: *) echo "Unsupported arch: $RUNNER_ARCH" >&2; exit 1 ;; esac - url="https://ziglang.org/download/${version}/zig-${arch}-${os}-${version}.tar.xz" + # Linux release is a tar.gz with minisign-linux/{x86_64,aarch64}/minisign. + # macOS release is a zip with a universal binary at the root. + minisign_ver="0.12" + minisign_base="https://github.com/jedisct1/minisign/releases/download/${minisign_ver}" + dest="${RUNNER_TOOL_CACHE:-$HOME/.cache}/minisign" + mkdir -p "$dest" + + case "$os" in + linux) + tmpdir=$(mktemp -d) + curl -sSfL "${minisign_base}/minisign-${minisign_ver}-linux.tar.gz" \ + | tar -xz -C "$tmpdir" + cp "${tmpdir}/minisign-linux/${arch}/minisign" "${dest}/minisign" + rm -rf "$tmpdir" + ;; + macos) + tmpdir=$(mktemp -d) + curl -sSfL -o "${tmpdir}/minisign-macos.zip" \ + "${minisign_base}/minisign-${minisign_ver}-macos.zip" + python3 -c " + import zipfile + zipfile.ZipFile('${tmpdir}/minisign-macos.zip').extract('minisign', '${dest}') + " + rm -rf "$tmpdir" + ;; + esac + chmod +x "${dest}/minisign" + + echo "${dest}" >> "$GITHUB_PATH" + + - name: Install Zig + shell: bash + run: | + set -euo pipefail + + ZIG_MINISIGN_PUBKEY="RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U" + + # build.zig.zon may come from an untrusted PR checkout; only accept a plain semver triple. + version=$(grep -m1 'minimum_zig_version' build.zig.zon | sed 's/.*"\(.*\)".*/\1/') + if [[ -z "${version}" ]] || [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::minimum_zig_version must be a semver triple (e.g. 0.15.2)" >&2 + exit 1 + fi + + case "$RUNNER_OS" in + Linux) os=linux ;; + macOS) os=macos ;; + *) echo "Unsupported OS: $RUNNER_OS" >&2; exit 1 ;; + esac + + case "$RUNNER_ARCH" in + X64) arch=x86_64 ;; + ARM64) arch=aarch64 ;; + *) echo "Unsupported arch: $RUNNER_ARCH" >&2; exit 1 ;; + esac + + tarball="zig-${arch}-${os}-${version}.tar.xz" + url="https://ziglang.org/download/${version}/${tarball}" echo "Installing Zig ${version} from ${url}" install_dir="${RUNNER_TOOL_CACHE:-$HOME/.cache}/zig" mkdir -p "$install_dir" - curl -sSfL "$url" | tar -xJ -C "$install_dir" + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + curl -sSfL -o "${tmpdir}/${tarball}" "$url" + curl -sSfL -o "${tmpdir}/${tarball}.minisig" "${url}.minisig" + + echo "Verifying minisign signature..." + minisign -Vm "${tmpdir}/${tarball}" -P "$ZIG_MINISIGN_PUBKEY" + + tar -xJ -C "$install_dir" -f "${tmpdir}/${tarball}" # Tarball top-level dir is zig--- zig_dir="${install_dir}/zig-${arch}-${os}-${version}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59f2de3..66c3fd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - sparse-checkout: .github/actions - path: _actions - - uses: ./_actions/.github/actions/setup-zig + - uses: Ledger-Donjon/absolution/.github/actions/setup-zig@main - run: zig build test integration-test: @@ -32,12 +27,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - sparse-checkout: .github/actions - path: _actions - - uses: ./_actions/.github/actions/setup-zig + - uses: Ledger-Donjon/absolution/.github/actions/setup-zig@main - run: zig build -Doptimize=${{ matrix.optimize }} - - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 - - run: uv run scripts/integration.py + - run: zig run scripts/integration.zig diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6f8f6a..e954a1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,12 +26,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.release_tag }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - sparse-checkout: .github/actions - path: _actions - - uses: ./_actions/.github/actions/setup-zig + - uses: Ledger-Donjon/absolution/.github/actions/setup-zig@main - run: zig build -Doptimize=${{ matrix.optimize }} -Dstrip=true - name: Upload tarball uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e42689..a859e0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ absolution/ │ ├── .c.in # Optional input invariant │ └── .c.zon # Golden output ├── scripts/ -│ ├── integration.py # pytest-based integration test runner +│ ├── integration.zig # Integration test runner (zig run scripts/integration.zig) │ └── gen-golden.sh # Golden file generator └── build.zig # Build configuration ``` @@ -96,9 +96,10 @@ zig build run -- --help # Build and run with args ### Testing ```bash -zig build test # Unit tests (in-source) -zig build test --summary all # Verbose unit test output -uv run scripts/integration.py # Integration tests (pytest) +zig build test # Unit tests (in-source) +zig build test --summary all # Verbose unit test output +zig run scripts/integration.zig # Integration tests +zig run scripts/integration.zig -- foo # Run only tests matching "foo" ``` ### Adding a new test case @@ -126,7 +127,7 @@ uv run scripts/integration.py # Integration tests (pytest) 5. Run to verify: ```bash - uv run scripts/integration.py + zig run scripts/integration.zig ``` ## Architecture Notes diff --git a/README.md b/README.md index 54dd69d..79173c7 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,9 @@ working example with multiple translation units. ## Testing ```bash -zig build test # Run unit tests -uv run scripts/integration.py # Integration tests (pytest) +zig build test # Run unit tests +zig run scripts/integration.zig # Integration tests +zig run scripts/integration.zig -- foo # Run only tests matching "foo" ``` ## Documentation diff --git a/scripts/integration.py b/scripts/integration.py deleted file mode 100644 index d2b4aa8..0000000 --- a/scripts/integration.py +++ /dev/null @@ -1,150 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = ["pytest"] -# /// -""" -Integration tests for absolution. - -Finds the Zig binary, builds absolution once, then for each test .c file: - 1. Runs absolution to produce .zon and fuzzer.c - 2. Compiles the generated fuzzer.c with ``zig cc`` - 3. Compares the .zon output against a golden file - -Run with: uv run scripts/integration.py -""" -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - -def _find_zig() -> Path: - """Locate the Zig binary from Cursor/VSCode extensions, falling back to PATH.""" - home = Path.home() - search_dirs = [ - home / ".cursor-server/data/User/globalStorage/ziglang.vscode-zig/zig", - home / ".vscode-server/data/User/globalStorage/ziglang.vscode-zig/zig", - *sorted(home.glob(".cursor/extensions/ziglang.vscode-zig-*/zig"), reverse=True), - *sorted(home.glob(".vscode/extensions/ziglang.vscode-zig-*/zig"), reverse=True), - ] - for d in search_dirs: - if d.is_dir(): - for candidate in sorted(d.rglob("zig"), reverse=True): - if candidate.is_file() and os.access(candidate, os.X_OK): - return candidate - - system_zig = shutil.which("zig") - if system_zig: - return Path(system_zig) - - pytest.fail("Could not find Zig binary — install the Zig extension or add zig to PATH") - - -ZIG = _find_zig() -ABSOLUTION = PROJECT_ROOT / "zig-out/bin/absolution" - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session", autouse=False) -def _build_absolution(): - """Build absolution once before the whole test session.""" - subprocess.check_call([str(ZIG), "build", "install"], cwd=PROJECT_ROOT) - assert ABSOLUTION.is_file(), f"absolution binary not found at {ABSOLUTION}" - - -# --------------------------------------------------------------------------- -# Test discovery -# --------------------------------------------------------------------------- - -def _discover_tests(): - """Yield (c_file, golden_zon) pairs for parametrize, skipping as needed.""" - tests_dir = PROJECT_ROOT / "tests" - for c_file in sorted(tests_dir.rglob("*.c")): - golden = c_file.with_name(c_file.name + ".zon") - skip_marker = c_file.with_name(c_file.name + ".zon.skip-arocc-bug") - - if skip_marker.exists(): - yield pytest.param( - c_file, golden, - id=f"{c_file.parent.name}/{c_file.name}", - marks=pytest.mark.skip(reason=f"arocc parser bug ({skip_marker.name})"), - ) - continue - - if not golden.exists(): - continue - - yield pytest.param(c_file, golden, id=f"{c_file.parent.name}/{c_file.name}") - - -# --------------------------------------------------------------------------- -# The actual test -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("c_file,golden_zon", list(_discover_tests())) -def test_absolution(c_file: Path, golden_zon: Path, tmp_path: Path): - include_dir = c_file.parent - - # absolution expects paths relative to PROJECT_ROOT (matches golden files) - rel = lambda p: str(p.relative_to(PROJECT_ROOT)) - - # -- extra flags from .flags sidecar -- - flags_file = c_file.with_name(c_file.name + ".flags") - extra_flags: list[str] = [] - if flags_file.exists(): - flags = [ - line for line in flags_file.read_text().splitlines() - if line.strip() and not line.startswith("#") - ] - if flags: - extra_flags = ["--"] + flags - - # -- target list from .targets sidecar, or the .c file itself -- - targets_file = c_file.with_name(c_file.name + ".targets") - target_args: list[str] = [] - if targets_file.exists(): - for tgt in targets_file.read_text().splitlines(): - if tgt.strip() and not tgt.startswith("#"): - target_args += ["--targets", rel(include_dir / tgt)] - else: - target_args = ["--targets", rel(c_file)] - - # -- invariant from .in sidecar -- - invariant_file = c_file.with_name(c_file.name + ".in") - invariant_args = ["-i", rel(invariant_file)] if invariant_file.exists() else [] - - out_zon = tmp_path / "out.zon" - out_fuzzer = tmp_path / "fuzzer.c" - out_redef = tmp_path / "redef.txt" - out_obj = tmp_path / "fuzzer.o" - - # 1. Run absolution - subprocess.check_call( - [str(ABSOLUTION)] + target_args - + invariant_args - + ["--zon", str(out_zon), "--out", str(out_fuzzer), "--redef", str(out_redef)] - + extra_flags, - cwd=PROJECT_ROOT, - ) - - # 2. Compile generated fuzzer.c with zig cc - subprocess.check_call( - [str(ZIG), "cc", "-c", str(out_fuzzer), "-o", str(out_obj), "-I", str(include_dir)], - ) - - # 3. Golden-file comparison (pytest shows a rich diff on assertion failure) - actual = out_zon.read_text() - expected = golden_zon.read_text() - assert actual == expected, f"Output differs from golden file {golden_zon}" - - -# Allow `uv run scripts/integration.py` as the entry point. -if __name__ == "__main__": - raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/scripts/integration.zig b/scripts/integration.zig new file mode 100644 index 0000000..9553447 --- /dev/null +++ b/scripts/integration.zig @@ -0,0 +1,346 @@ +//! Integration tests for absolution. +//! +//! Finds .c test files under tests/, builds absolution once, then for each test: +//! 1. Runs absolution to produce .zon and fuzzer.c +//! 2. Compiles the generated fuzzer.c with `zig cc` +//! 3. Compares the .zon output against a golden file +//! +//! Run with: zig run scripts/integration.zig +//! +//! Run a single test (substring match on test id): +//! zig run scripts/integration.zig -- simple_struct + +const std = @import("std"); + +const green = "\x1b[32m"; +const red = "\x1b[31m"; +const yellow = "\x1b[33m"; +const bold = "\x1b[1m"; +const dim = "\x1b[2m"; +const reset = "\x1b[0m"; + +const tmp_base = "/tmp/absolution-integration-tests"; + +pub fn main() !void { + var gpa_impl = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa_impl.deinit(); + const gpa = gpa_impl.allocator(); + + var arena_impl: std.heap.ArenaAllocator = .init(gpa); + defer arena_impl.deinit(); + const arena = arena_impl.allocator(); + + // 0. Parse optional filter from argv + const process_args = try std.process.argsAlloc(gpa); + defer std.process.argsFree(gpa, process_args); + // process_args[0] is the binary itself; anything after is a filter + const filter: ?[]const u8 = if (process_args.len > 1) process_args[1] else null; + if (filter) |f| out(bold ++ "Filter: " ++ reset ++ "{s}\n", .{f}); + + // 1. Build absolution + out(bold ++ "Building absolution..." ++ reset ++ "\n", .{}); + try buildAbsolution(gpa); + + std.fs.cwd().access("zig-out/bin/absolution", .{}) catch { + out(red ++ "absolution binary not found at zig-out/bin/absolution" ++ reset ++ "\n", .{}); + std.process.exit(1); + }; + + // 2. Discover tests + var cases: std.ArrayList(TestCase) = .empty; + try discoverTests(arena, &cases); + std.mem.sort(TestCase, cases.items, {}, struct { + fn lt(_: void, a: TestCase, b: TestCase) bool { + return std.mem.order(u8, a.test_id, b.test_id) == .lt; + } + }.lt); + + out("Collected " ++ bold ++ "{d}" ++ reset ++ " test(s)\n\n", .{cases.items.len}); + + // 3. Prepare temp directory (clean slate each run) + std.fs.deleteTreeAbsolute(tmp_base) catch {}; + try std.fs.cwd().makePath(tmp_base); + + // 4. Run tests + var passed: usize = 0; + var failed: usize = 0; + var skipped: usize = 0; + + var filtered_count: usize = 0; + for (cases.items, 0..) |tc, idx| { + if (filter) |f| { + if (std.mem.indexOf(u8, tc.test_id, f) == null) continue; + } + filtered_count += 1; + if (tc.skip_reason) |reason| { + out(" " ++ yellow ++ "SKIP" ++ reset ++ " {s} " ++ dim ++ "({s})" ++ reset ++ "\n", .{ tc.test_id, reason }); + skipped += 1; + continue; + } + + if (runOneTest(gpa, arena, tc, idx)) { + out(" " ++ green ++ "PASS" ++ reset ++ " {s}\n", .{tc.test_id}); + passed += 1; + } else |err| { + out(" " ++ red ++ "FAIL" ++ reset ++ " {s} " ++ dim ++ "({s})" ++ reset ++ "\n", .{ tc.test_id, @errorName(err) }); + failed += 1; + } + } + + // 5. Summary + if (filter != null and filtered_count == 0) { + out(red ++ "No tests matched filter" ++ reset ++ "\n", .{}); + std.process.exit(1); + } + out("\n" ++ bold ++ "{d}" ++ reset ++ " passed", .{passed}); + if (failed > 0) out(", " ++ bold ++ red ++ "{d} failed" ++ reset, .{failed}); + if (skipped > 0) out(", " ++ bold ++ yellow ++ "{d} skipped" ++ reset, .{skipped}); + out("\n", .{}); + + if (failed > 0) std.process.exit(1); +} + +// ----------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------- + +const TestCase = struct { + c_path: []const u8, + golden_path: []const u8, + dir_path: []const u8, + test_id: []const u8, + skip_reason: ?[]const u8 = null, + flags: []const []const u8 = &.{}, + targets: []const []const u8 = &.{}, + invariant_path: ?[]const u8 = null, +}; + +// ----------------------------------------------------------------------- +// Build +// ----------------------------------------------------------------------- + +fn buildAbsolution(allocator: std.mem.Allocator) !void { + var child = std.process.Child.init(&.{ "zig", "build", "install" }, allocator); + const term = try child.spawnAndWait(); + switch (term) { + .Exited => |code| if (code != 0) { + out(red ++ "Build failed (exit code {d})" ++ reset ++ "\n", .{code}); + std.process.exit(1); + }, + else => { + out(red ++ "Build terminated abnormally" ++ reset ++ "\n", .{}); + std.process.exit(1); + }, + } +} + +// ----------------------------------------------------------------------- +// Test discovery +// ----------------------------------------------------------------------- + +fn discoverTests(arena: std.mem.Allocator, cases: *std.ArrayList(TestCase)) !void { + const cwd = std.fs.cwd(); + var tests_dir = cwd.openDir("tests", .{ .iterate = true }) catch |err| { + std.debug.print("Cannot open tests/ directory: {s}\n", .{@errorName(err)}); + return err; + }; + defer tests_dir.close(); + + var walker = try tests_dir.walk(arena); + defer walker.deinit(); + + while (try walker.next()) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.basename, ".c")) continue; + + const c_path = try std.fmt.allocPrint(arena, "tests/{s}", .{entry.path}); + const golden_path = try std.fmt.allocPrint(arena, "{s}.zon", .{c_path}); + const skip_path = try std.fmt.allocPrint(arena, "{s}.zon.skip-arocc-bug", .{c_path}); + + const dir_path = if (std.mem.lastIndexOfScalar(u8, c_path, '/')) |i| + c_path[0..i] + else + "."; + + const parent_name = if (std.mem.lastIndexOfScalar(u8, dir_path, '/')) |i| + dir_path[i + 1 ..] + else + dir_path; + + const test_id = try std.fmt.allocPrint(arena, "{s}/{s}", .{ parent_name, entry.basename }); + + // Skip marker + if (fileExists(cwd, skip_path)) { + try cases.append(arena, .{ + .c_path = c_path, + .golden_path = golden_path, + .dir_path = dir_path, + .test_id = test_id, + .skip_reason = "arocc parser bug", + }); + continue; + } + + // No golden file → not a test + if (!fileExists(cwd, golden_path)) continue; + + // .flags sidecar (extra compiler flags after --) + const flags_path = try std.fmt.allocPrint(arena, "{s}.flags", .{c_path}); + const flags: []const []const u8 = readNonCommentLines(arena, cwd, flags_path) catch &.{}; + + // .targets sidecar (explicit target list, or fall back to the .c file itself) + const targets_path = try std.fmt.allocPrint(arena, "{s}.targets", .{c_path}); + const targets: []const []const u8 = blk: { + const lines = readNonCommentLines(arena, cwd, targets_path) catch { + const one = try arena.alloc([]const u8, 1); + one[0] = c_path; + break :blk one; + }; + const resolved = try arena.alloc([]const u8, lines.len); + for (lines, 0..) |line, i| { + resolved[i] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, line }); + } + break :blk resolved; + }; + + // .in sidecar (invariant constraint file) + const inv_path = try std.fmt.allocPrint(arena, "{s}.in", .{c_path}); + const invariant_path: ?[]const u8 = if (fileExists(cwd, inv_path)) inv_path else null; + + try cases.append(arena, .{ + .c_path = c_path, + .golden_path = golden_path, + .dir_path = dir_path, + .test_id = test_id, + .flags = flags, + .targets = targets, + .invariant_path = invariant_path, + }); + } +} + +// ----------------------------------------------------------------------- +// Run a single test +// ----------------------------------------------------------------------- + +fn runOneTest( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + tc: TestCase, + idx: usize, +) !void { + const test_dir = try std.fmt.allocPrint(arena, tmp_base ++ "/{d}", .{idx}); + try std.fs.cwd().makePath(test_dir); + + const out_zon = try std.fmt.allocPrint(arena, "{s}/out.zon", .{test_dir}); + const out_fuzzer = try std.fmt.allocPrint(arena, "{s}/fuzzer.c", .{test_dir}); + const out_redef = try std.fmt.allocPrint(arena, "{s}/redef.txt", .{test_dir}); + const out_obj = try std.fmt.allocPrint(arena, "{s}/fuzzer.o", .{test_dir}); + + // -- Build absolution argv -- + var argv: std.ArrayList([]const u8) = .empty; + try argv.append(arena, "zig-out/bin/absolution"); + for (tc.targets) |t| { + try argv.appendSlice(arena, &.{ "--targets", t }); + } + if (tc.invariant_path) |inv| { + try argv.appendSlice(arena, &.{ "-i", inv }); + } + try argv.appendSlice(arena, &.{ "--zon", out_zon, "--out", out_fuzzer, "--redef", out_redef }); + if (tc.flags.len > 0) { + try argv.append(arena, "--"); + try argv.appendSlice(arena, tc.flags); + } + + // 1. Run absolution + try execCapture(gpa, argv.items); + + // 2. Compile generated fuzzer.c + try execCapture(gpa, &.{ "zig", "cc", "-c", out_fuzzer, "-o", out_obj, "-I", tc.dir_path }); + + // 3. Golden-file comparison + const actual = try std.fs.cwd().readFileAlloc(gpa, out_zon, 10 * 1024 * 1024); + defer gpa.free(actual); + const expected = try std.fs.cwd().readFileAlloc(gpa, tc.golden_path, 10 * 1024 * 1024); + defer gpa.free(expected); + + if (!std.mem.eql(u8, actual, expected)) { + std.debug.print(" Golden mismatch: expected {s}\n", .{tc.golden_path}); + std.debug.print(" Actual output: {s}\n", .{out_zon}); + return error.GoldenMismatch; + } +} + +// ----------------------------------------------------------------------- +// Subprocess helpers +// ----------------------------------------------------------------------- + +fn execCapture(gpa: std.mem.Allocator, argv: []const []const u8) !void { + const result = std.process.Child.run(.{ + .allocator = gpa, + .argv = argv, + .max_output_bytes = 10 * 1024 * 1024, + }) catch |err| { + std.debug.print(" Failed to spawn:", .{}); + for (argv) |arg| std.debug.print(" {s}", .{arg}); + std.debug.print("\n {s}\n", .{@errorName(err)}); + return err; + }; + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + switch (result.term) { + .Exited => |code| { + if (code != 0) { + std.debug.print(" Command exited with code {d}:", .{code}); + for (argv) |arg| std.debug.print(" {s}", .{arg}); + std.debug.print("\n", .{}); + if (result.stderr.len > 0) { + std.debug.print(" {s}\n", .{std.mem.trimRight(u8, result.stderr, "\n")}); + } + return error.CommandFailed; + } + }, + else => { + std.debug.print(" Command terminated abnormally:", .{}); + for (argv) |arg| std.debug.print(" {s}", .{arg}); + std.debug.print("\n", .{}); + return error.CommandFailed; + }, + } +} + +// ----------------------------------------------------------------------- +// Output helpers +// ----------------------------------------------------------------------- + +/// Write formatted text to stdout. Errors are silently discarded. +fn out(comptime fmt: []const u8, args: anytype) void { + const stdout = std.fs.File.stdout(); + var buf: [8192]u8 = undefined; + const str = std.fmt.bufPrint(&buf, fmt, args) catch return; + stdout.writeAll(str) catch {}; +} + +// ----------------------------------------------------------------------- +// File helpers +// ----------------------------------------------------------------------- + +fn fileExists(dir: std.fs.Dir, path: []const u8) bool { + dir.access(path, .{}) catch return false; + return true; +} + +/// Read non-empty, non-comment lines from a file. Returns error on missing file. +fn readNonCommentLines(arena: std.mem.Allocator, dir: std.fs.Dir, path: []const u8) ![]const []const u8 { + const content = try dir.readFileAlloc(arena, path, 10 * 1024 * 1024); + var lines: std.ArrayList([]const u8) = .empty; + var iter = std.mem.splitScalar(u8, content, '\n'); + while (iter.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) continue; + if (trimmed[0] == '#') continue; + try lines.append(arena, trimmed); + } + return lines.items; +}