From c50639db01561c25940f26c876fa9438587c80a4 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 13 Mar 2026 21:31:59 -0400 Subject: [PATCH 01/62] docs: add architecture refactor design spec Three-phase refactor: single ZON file, std.zon.parse-based Config, static build.zig. See docs/superpowers/specs/ for details. Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-zbuild-refactor-design.md | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-zbuild-refactor-design.md diff --git a/docs/superpowers/specs/2026-03-13-zbuild-refactor-design.md b/docs/superpowers/specs/2026-03-13-zbuild-refactor-design.md new file mode 100644 index 0000000..8babdf2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-zbuild-refactor-design.md @@ -0,0 +1,348 @@ +# zbuild Architecture Refactor + +## Problem + +zbuild's core pipeline (zbuild.zon -> Config -> build.zig + build.zig.zon) has structural deficiencies that produce bugs systematically. The hand-rolled parser doesn't stay in sync with the data model. The string-concatenation codegen is fragile. Two ZON files with overlapping data require a brittle sync mechanism. These aren't isolated bugs — they're consequences of the architecture. + +## Solution + +Three composable changes that eliminate the code that contains most bugs: + +1. **Single ZON file** — merge zbuild.zon into build.zig.zon +2. **std.zon.parse-based Config** — replace the hand-rolled parser with Zig's stdlib +3. **Static build.zig** — replace codegen with a fixed build.zig that reads the config at build time + +## Execution Order + +``` +Phase A (single file) -> Phase B (std.zon.parse) -> Phase C (static build.zig) +``` + +Each phase is independently shippable. If Phase C proves harder than expected, Phases A+B alone are still a major improvement. + +--- + +## Phase A: Single ZON File + +### Change + +Eliminate zbuild.zon as a separate file. The project's `build.zig.zon` becomes the single source of truth, containing both standard Zig manifest fields and zbuild-specific fields. Zig's build system ignores unknown fields by design (confirmed: Manifest.zig lines 272-275 explicitly skip unknown fields for forward compatibility). + +**Assumption:** The upstream Zig compiler's manifest parser also ignores unknown fields. This is the documented intent (the comment says "so that we can add fields in future zig versions") and has been verified in zbuild's local copy. If a future Zig version adds strict validation, this approach would need revisiting. + +### Deletions + +- `sync_manifest.zig` — no more translating zbuild.zon to build.zig.zon +- `Manifest.zig` — no more parallel data model +- The `depEql` bridge function +- The AST-splicing hack in `allocPrintManifest` + +### Changes + +- `--zbuild-file` flag defaults to `build.zig.zon` instead of `zbuild.zon` +- `cmd_fetch.zig` operates directly on `build.zig.zon` +- `cmd_init` writes a single `build.zig.zon` with both standard and zbuild fields +- Config parser reads `build.zig.zon` +- Update test fixtures from `.zbuild.zon` to `.build.zig.zon` extension + +### Migration + +Existing users merge their `zbuild.zon` content into `build.zig.zon` and delete `zbuild.zon`. A `zbuild migrate` command could automate this but is not required for the initial implementation. + +### Bugs Fixed + +- 2.9: description/keywords not written to build.zig.zon (no translation needed) +- 2.10: hash/lazy not serialized (single file, no re-serialization) +- 2.12: no rollback on fetch failure (no two-phase sync) +- 4.5: two-phase sync ordering (eliminated) +- 5.8: parallel data model (eliminated) + +--- + +## Phase B: std.zon.parse-based Config + +### Change + +Replace the hand-rolled per-field `if/else if` dispatch chains with `std.zon.parse.fromZoirNode` for types that map cleanly to ZON structs. Keep a thin custom layer for the top-level Config (which uses `StringArrayHashMap`) and a few types with non-standard ZON representations. + +### API Reality + +Zig 0.14's `std.zon.parse` provides three entry points: + +- `fromSlice(T, gpa, source, status?, options)` — parses raw ZON source bytes +- `fromZoir(T, gpa, ast, zoir, status?, options)` — parses pre-lowered Zoir +- `fromZoirNode(T, gpa, ast, zoir, node, status?, options)` — parses a specific node + +**There is no `zonParse` hook.** Custom types cannot register parsing callbacks. `StringArrayHashMap(T)` is not natively parseable. The approach must account for this. + +### Parsing Strategy + +**Layer 1 — Top-level Config:** A thin custom parser (~50 lines) that iterates the top-level struct literal fields by name and dispatches to the appropriate sub-parser. This replaces the current `parse()` function but is much shorter because it only handles the top-level field routing, not recursive field-by-field parsing of every nested type. + +**Layer 2 — HashMap fields:** A generic `parseHashMap(T, ...)` function (~25 lines, similar to the existing `parseOptionalHashMap`) iterates a ZON struct literal's named fields and calls `fromZoirNode(T, ...)` for each value. This handles `modules`, `executables`, `libraries`, `objects`, `tests`, `fmts`, `options_modules`, and `dependencies` (with a custom value parser for deps). + +**Layer 3 — Value types parsed automatically by std.zon.parse:** `Module`, `Executable`, `Library`, `Object`, `Test`, `Fmt` are plain structs with optional fields. `fromZoirNode` handles them directly — all `bool`, `enum`, `[]const u8`, `?[][]const u8` fields parse automatically via comptime reflection. **This eliminates all the `if/else if` dispatch chains** (the bulk of the parser code). + +**Layer 4 — Types needing custom parsing (~80 lines total):** +- **Dependency** — discriminated on `path` vs `url` field presence. Custom parser checks which field is present and constructs the tagged value. +- **ModuleLink** — bare enum literal (`.foo`) vs inline struct. Custom parser checks the ZON node type. +- **Option** — discriminated by a `type` string field. Custom parser reads `type`, then parses remaining fields according to variant. +- **Run** — plain string value, trivial custom parser. + +### Shared CompileTarget Base + +Introduce a common struct for fields shared across Executable, Library, Object, Test: + +```zig +pub const CompileTarget = struct { + name: ?[]const u8 = null, + root_module: ModuleLink, + max_rss: ?usize = null, + use_llvm: ?bool = null, + use_lld: ?bool = null, + zig_lib_dir: ?[]const u8 = null, + depends_on: ?[][]const u8 = null, +}; + +pub const Executable = struct { + base: CompileTarget, + version: ?[]const u8 = null, + linkage: ?std.builtin.LinkMode = null, + win32_manifest: ?[]const u8 = null, + dest_sub_path: ?[]const u8 = null, +}; + +pub const Library = struct { + base: CompileTarget, + version: ?[]const u8 = null, + linkage: ?std.builtin.LinkMode = null, + linker_allow_shlib_undefined: ?bool = null, + dest_sub_path: ?[]const u8 = null, +}; + +pub const Object = struct { + base: CompileTarget, +}; + +pub const Test = struct { + base: CompileTarget, + test_runner: ?[]const u8 = null, + filters: ?[][]const u8 = null, +}; +``` + +**Note on CompileTarget and fromZoirNode:** Since `std.zon.parse` handles nested structs, the `base: CompileTarget` field will parse correctly as long as the ZON uses a nested `.base = .{ ... }` syntax. If we want flat field syntax (`.use_llvm = true` directly on the executable, not `.base = .{ .use_llvm = true }`), then Executable etc. would need to inline the CompileTarget fields instead of embedding it. The ZON ergonomics should determine this — **flat is better for users**, so we inline the fields and use a comptime helper to share the field definitions: + +```zig +const compile_target_fields = .{ + .{ "name", ?[]const u8, null }, + .{ "root_module", ModuleLink, ... }, + // ... +}; + +// Or simpler: just list the shared fields in a comment and keep them in sync. +// The parsing correctness is enforced by std.zon.parse matching struct fields, +// not by comptime field generation. +``` + +The pragmatic approach: keep the fields inlined in each struct (no `base:` nesting), and use `std.zon.parse.fromZoirNode` for each type directly. The duplication is in the type definitions (~5 shared fields x 4 types = 20 lines), not in the parsing or codegen logic. + +### Fingerprint Field + +Currently stored as `[]const u8` (a hex string like `"0x90797553773ca567"`). In `build.zig.zon` the fingerprint is a number literal (`0x90797553773ca567`). The Config struct should store it as `u64` to match the ZON representation. The serializer emits it as `0x{x:0>16}`. Downstream code that uses it as a string (the manifest template) will format it on output. + +### Deletions + +- All per-type `parseX` functions and their `if/else if` dispatch chains (~500 lines) +- All per-type `deinit` methods — `std.zon.parse`-allocated types use `std.zon.parse.free` for uniform cleanup (~100 lines) +- The `parseT`, `parseBool`, `parseString`, `parseEnumLiteral` helpers (replaced by `fromZoirNode`) + +### What Remains + +- Config.zig type definitions (~300 lines) +- Top-level parse + HashMap iteration + 4 custom parsers (~160 lines) +- Serializer (~300 lines, unchanged, cleanup deferred) +- Total: ~760 lines (down from ~1600) + +### write_files + +The `write_files` parser is currently a stub (Config.zig:648-649). This is a pre-existing incomplete feature. This refactor does not fix it — the stub remains. Implementing `write_files` is orthogonal and can be done after the refactor by adding the `WriteFile` type with appropriate custom parsing. + +### Bugs Fixed + +- 2.1: hash/lazy never parsed (struct fields are parsed automatically by fromZoirNode) +- 2.2: Library.version not parsed (same) +- 2.3: Test.test_runner not parsed (same) +- 2.11: include_paths not freed (std.zon.parse.free handles cleanup) +- 3.1: Executable.dest_sub_path not freed (same) +- 3.2: Library.dest_sub_path not freed (same) +- 3.3: parseObject leaks field name (no manual field name handling) +- 3.10: returnParseError leaks message (no manual error construction) +- 5.3: parser uses no reflection (std.zon.parse uses comptime reflection for struct fields) +- 5.7: deinit ceremony repeats (uniform free) + +--- + +## Phase C: Static build.zig + +### Change + +Replace the string-concatenation codegen (ConfigBuildgen) with a fixed `build.zig` that every project uses. At build time, it reads `build.zig.zon`, parses it into Config structs, and calls the Zig build API directly. + +### Architecture + +zbuild becomes a Zig package dependency of the project. The static `build.zig`: + +```zig +const std = @import("std"); +const zbuild = @import("zbuild"); + +pub fn build(b: *std.Build) void { + zbuild.configureBuild(b) catch |err| { + std.log.err("zbuild: {}", .{err}); + return; + }; +} +``` + +The `configureBuild` function lives in zbuild's library code. It: +1. Reads `build.zig.zon` from `b.build_root_directory` +2. Parses it into Config using the Phase B parsing infrastructure +3. Walks the Config and calls `b.addExecutable(...)`, `b.addTest(...)`, etc. + +This is the same logic as ConfigBuildgen but calling APIs directly instead of emitting strings that call APIs. + +### zbuild's Own Build + +zbuild itself does NOT use the static `build.zig` pattern. It keeps its own hand-written `build.zig` (or the one generated by the current zbuild.zig). There is no circular dependency — zbuild builds itself normally, and user projects depend on the built zbuild package. + +### Deletions + +- `ConfigBuildgen.zig` (~1280 lines) +- `sync_build_file.zig` +- The `scratch` buffer, `fmtId`, `allocFmtId`, all format-string machinery +- The unused-variable detection +- The `zig fmt` post-processing step +- The `writeImport` / `resolveImport` string-based resolution + +### Changes + +- New `src/build_runner.zig` containing `configureBuild` — estimated ~500 lines +- zbuild exposes Config types as a public module +- `zbuild sync` simplifies to: ensure build.zig has the static template, ensure build.zig.zon has zbuild as a dependency +- `cmd_init` writes the static build.zig template + +### configureBuild Sketch + +The function must handle: +- Creating modules from `config.modules` (with include_paths, link_libraries, etc.) +- Creating executables/libraries/objects from their respective config sections +- Resolving ModuleLink references (`.name` pointing to a named module, or inline module definitions) +- Wiring imports: local modules, options modules, and dependency modules +- Creating options and options_modules +- Setting up install steps, run steps, test steps +- Handling `depends_on` step dependencies (currently unimplemented in codegen — this is where we actually implement it) +- Detecting step name collisions (run:{name} for executables vs custom runs) and erroring + +This is ~500 lines because the logic is the same as ConfigBuildgen minus all string formatting overhead. The import resolution becomes direct map lookups and API calls instead of string interpolation. + +### How zbuild Gets Into Projects + +It becomes a dependency in `build.zig.zon`: +```zon +.dependencies = .{ + .zbuild = .{ + .url = "https://github.com/chainsafe/zbuild/archive/...", + .hash = "...", + }, +}, +``` + +`zbuild init` and `zbuild fetch` add this automatically. + +### Escape Hatch + +Users who outgrow zbuild can copy the `configureBuild` logic, remove the dependency, and customize. The code is readable Zig, not generated string soup. + +### Bugs Fixed + +- 1.6: missing dot in format string (no format strings) +- 2.5: depends_on not emitted (implement directly in configureBuild) +- 2.6: unused-variable detection incomplete (no generated variables) +- 2.14: writeImport wrong module ID (direct API calls, no string identifiers) +- 3.7: zig fmt errors suppressed (no fmt step) +- 4.1: scratch buffer fragility (eliminated) +- 4.3: run step name collision (detect and error at build time) +- 4.6: include_extensions default inconsistency (direct API calls) +- 5.5: no codegen IR (eliminated — no codegen) +- 5.6: writeImports type switch (eliminated) +- 5.10: strTupleLiteral cross-import (eliminated) + +--- + +## Testing Strategy + +### Gate 1: Existing Fixtures Pass + +All 6 fixture files (basic1-basic6) must produce a valid build that passes `zig build --help`. The existing E2E test in `test/sync.zig` must not regress. Fixtures are renamed from `.zbuild.zon` to `.build.zig.zon`. + +### Gate 2: Parse Fidelity Tests + +New unit tests that parse each fixture into a Config struct and assert specific field values: + +- basic2: `link_libc = true`, `single_threaded = true`, specific code_model values +- basic5: options modules with typed defaults +- basic4: executables and libraries with specific linkage + +Catches "field parsed but wrong value" and "field silently ignored." + +### Gate 3: Build Graph Tests + +Parse a fixture, run `configureBuild`, verify the resulting build graph: + +- Expected number of executables, tests, libraries +- Install steps and run steps exist +- Module imports are resolved correctly +- Dependency references are wired up + +### Gate 4: Single-File Validation + +Verify that a `build.zig.zon` with zbuild-specific fields is accepted by both: + +- zbuild's parser (parses all fields) +- Zig's build system (ignores unknown fields, builds normally) + +### Gate 5: Custom Parser Types + +Unit tests for each type with custom deserialization: + +- Dependency: `{ .url = "..." }` vs `{ .path = "..." }` with args +- ModuleLink: bare `.name` vs inline `{ .root_source_file = "..." }` +- Option: each variant (bool, int, enum, list, etc.) + +--- + +## Scope + +### Estimated Impact + +| Phase | Lines Deleted | Lines Added | Risk | +|-------|-------------|-------------|------| +| A: Single file | ~200 | ~50 | Low | +| B: std.zon.parse | ~600 (parser + deinit) | ~160 | Medium | +| C: Static build.zig | ~1300 (codegen + sync) | ~500 | Medium | +| **Total** | **~2100** | **~710** | | + +Net reduction: ~1400 lines. Core logic shrinks from ~4500 to ~2400 lines. + +### Out of Scope + +- cmd_fetch bug fixes (orthogonal, own PR) +- Serializer rewrite (used only by cmd_init, cleanup later) +- CLI argument parsing improvements +- New features (c_source_files, system_libraries, etc.) +- write_files implementation (pre-existing stub, orthogonal) + +### Fallback + +Each phase is independently shippable. If Phase C proves too complex, ship Phases A+B alone — they eliminate ~800 lines and fix the majority of parser/sync bugs. From a5e23db47831bf67e5bcae74ebf718fe41c46f39 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 13 Mar 2026 22:15:59 -0400 Subject: [PATCH 02/62] docs: add implementation plan for three-phase architecture refactor Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-zbuild-refactor.md | 2298 +++++++++++++++++ 1 file changed, 2298 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-zbuild-refactor.md diff --git a/docs/superpowers/plans/2026-03-13-zbuild-refactor.md b/docs/superpowers/plans/2026-03-13-zbuild-refactor.md new file mode 100644 index 0000000..d380e63 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-zbuild-refactor.md @@ -0,0 +1,2298 @@ +# zbuild Architecture Refactor Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate ~2100 lines of bug-prone code by merging ZON files, replacing the hand-rolled parser with `std.zon.parse`, and replacing string-concatenation codegen with a static `build.zig` that calls the build API directly. + +**Architecture:** Three phases (A: single ZON file, B: std.zon.parse-based Config, C: static build.zig), each independently shippable. Phase A eliminates the dual-file sync layer. Phase B replaces manual `if/else if` parsing with `fromZoirNode` + thin custom layer. Phase C replaces `ConfigBuildgen` string emission with direct `std.Build` API calls in a `configureBuild` function. + +**Tech Stack:** Zig 0.14, `std.zon.parse`, `std.Build` API + +**Spec:** `docs/superpowers/specs/2026-03-13-zbuild-refactor-design.md` + +--- + +## File Structure + +### Phase A: Single ZON File + +| Action | File | Responsibility | +|--------|------|---------------| +| Modify | `src/GlobalOptions.zig` | Change default zbuild_file from `"zbuild.zon"` to `"build.zig.zon"` | +| Modify | `src/cmd_sync.zig` | Remove `syncManifest` call, only call `syncBuildFile` | +| Modify | `src/cmd_init.zig` | Write `build.zig.zon` directly (no separate zbuild.zon), remove `Manifest` import | +| Modify | `src/cmd_fetch.zig` | Operate on `build.zig.zon` (already does, but fix bug 2.13 where it uses Manifest.load on zbuild_file) | +| Modify | `src/main.zig` | Update error message from "no zbuild file found" to "no build.zig.zon file found" | +| Modify | `test/sync.zig` | Rename fixture references from `.zbuild.zon` to `.build.zig.zon` | +| Rename | `test/fixtures/basic*.zbuild.zon` | Rename all 6 to `basic*.build.zig.zon` | +| Delete | `src/sync_manifest.zig` | Eliminated — single file means no manifest sync | +| Delete | `src/Manifest.zig` | Eliminated — parallel data model no longer needed | + +### Phase B: std.zon.parse-based Config + +| Action | File | Responsibility | +|--------|------|---------------| +| Rewrite | `src/Config.zig` | Replace hand-rolled Parser with: thin top-level dispatcher, generic `parseHashMap`, `fromZoirNode` for value types, 4 custom parsers (Dependency, ModuleLink, Option, Run). Remove all `deinit` methods (use arena). Change `fingerprint` from `[]const u8` to `u64`. | + +### Phase C: Static build.zig + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `src/build_runner.zig` | `configureBuild(b: *std.Build, config: Config)` — reads config and calls build API directly | +| Modify | `src/cmd_sync.zig` | Replace syncBuildFile with: ensure build.zig matches static template, ensure build.zig.zon has zbuild dep | +| Modify | `src/cmd_init.zig` | Write static build.zig template | +| Modify | `src/main.zig` | Expose Config types as public module for the zbuild package | +| Modify | `build.zig.zon` | Add zbuild self-reference note (zbuild itself doesn't use static build.zig) | +| Delete | `src/ConfigBuildgen.zig` | Eliminated — replaced by build_runner.zig | +| Delete | `src/sync_build_file.zig` | Eliminated — no more codegen + zig fmt pipeline | + +--- + +## Chunk 1: Phase A — Single ZON File + +### Task 1: Rename test fixtures + +**Files:** +- Rename: `test/fixtures/basic1.zbuild.zon` → `test/fixtures/basic1.build.zig.zon` +- Rename: `test/fixtures/basic2.zbuild.zon` → `test/fixtures/basic2.build.zig.zon` +- Rename: `test/fixtures/basic3.zbuild.zon` → `test/fixtures/basic3.build.zig.zon` +- Rename: `test/fixtures/basic4.zbuild.zon` → `test/fixtures/basic4.build.zig.zon` +- Rename: `test/fixtures/basic5.zbuild.zon` → `test/fixtures/basic5.build.zig.zon` +- Rename: `test/fixtures/basic6.zbuild.zon` → `test/fixtures/basic6.build.zig.zon` + +- [ ] **Step 1: Rename all 6 fixtures** + +```bash +cd test/fixtures +for i in 1 2 3 4 5 6; do + mv "basic${i}.zbuild.zon" "basic${i}.build.zig.zon" +done +``` + +- [ ] **Step 2: Update test/sync.zig fixture references** + +In `test/sync.zig:11-18`, change all `.zbuild.zon` to `.build.zig.zon`: + +```zig +const test_cases = &[_][]const u8{ + "fixtures/basic1.build.zig.zon", + "fixtures/basic2.build.zig.zon", + "fixtures/basic3.build.zig.zon", + "fixtures/basic4.build.zig.zon", + "fixtures/basic5.build.zig.zon", + "fixtures/basic6.build.zig.zon", +}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add test/fixtures/ test/sync.zig +git commit -m "refactor(phase-a): rename test fixtures from .zbuild.zon to .build.zig.zon" +``` + +### Task 2: Change default zbuild_file to build.zig.zon + +**Files:** +- Modify: `src/GlobalOptions.zig:52` +- Modify: `src/main.zig:109` + +- [ ] **Step 1: Change the default in GlobalOptions** + +In `src/GlobalOptions.zig:52`, change: + +```zig +// Before: +.zbuild_file = try allocator.dupe(u8, "zbuild.zon"), +// After: +.zbuild_file = try allocator.dupe(u8, "build.zig.zon"), +``` + +- [ ] **Step 2: Update error message in main.zig** + +In `src/main.zig:109`, change: + +```zig +// Before: +fatal("no zbuild file found", .{}); +// After: +fatal("no build.zig.zon file found", .{}); +``` + +- [ ] **Step 3: Run tests to verify fixtures load correctly** + +```bash +zig build test -- --test-filter "zbuild build --help" +``` + +Expected: All 6 fixture tests pass (each fixture is loaded by its new `.build.zig.zon` path, and the default zbuild_file matches). + +- [ ] **Step 4: Commit** + +```bash +git add src/GlobalOptions.zig src/main.zig +git commit -m "refactor(phase-a): default zbuild_file to build.zig.zon" +``` + +### Task 3: Remove syncManifest from cmd_sync + +**Files:** +- Modify: `src/cmd_sync.zig` + +- [ ] **Step 1: Remove syncManifest import and call** + +Replace the entire `src/cmd_sync.zig` with: + +```zig +const std = @import("std"); +const mem = std.mem; +const Allocator = std.mem.Allocator; +const fatal = std.process.fatal; +const GlobalOptions = @import("GlobalOptions.zig"); +const Config = @import("Config.zig"); +const syncBuildFile = @import("sync_build_file.zig").syncBuildFile; + +pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config) !void { + if (global_opts.no_sync) { + fatal("--no-sync is incompatible with the sync command", .{}); + } + try syncBuildFile(gpa, arena, config, global_opts, .{ .out_dir = global_opts.project_dir }); +} +``` + +- [ ] **Step 2: Run tests** + +```bash +zig build test -- --test-filter "zbuild build --help" +``` + +Expected: All 6 pass. The sync command no longer writes a separate `build.zig.zon` manifest — the test fixtures ARE the `build.zig.zon` files already. + +- [ ] **Step 3: Commit** + +```bash +git add src/cmd_sync.zig +git commit -m "refactor(phase-a): remove syncManifest from cmd_sync" +``` + +### Task 4: Update cmd_init to write build.zig.zon directly + +**Files:** +- Modify: `src/cmd_init.zig` + +- [ ] **Step 1: Remove Manifest import, simplify** + +The current `cmd_init.zig` writes to `zbuild_file` (which was `zbuild.zon`) then calls `sync.exec` to generate `build.zig.zon` from it. Now zbuild_file IS `build.zig.zon`, so `sync.exec` still works correctly — it reads `build.zig.zon` and generates `build.zig`. + +Remove the `Manifest` import and the `Manifest.max_name_len` reference: + +In `src/cmd_init.zig:8`, remove: +```zig +const Manifest = @import("Manifest.zig"); +``` + +In `src/cmd_init.zig:94-95`, replace: +```zig +// Before: +if (result.items.len > Manifest.max_name_len) + result.shrinkRetainingCapacity(Manifest.max_name_len); +// After: +if (result.items.len > 64) + result.shrinkRetainingCapacity(64); +``` + +(The Manifest.max_name_len constant is 64. We inline the value to remove the Manifest dependency.) + +- [ ] **Step 2: Run tests** + +```bash +zig build test -- --test-filter "zbuild build --help" +``` + +Expected: All pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/cmd_init.zig +git commit -m "refactor(phase-a): remove Manifest dependency from cmd_init" +``` + +### Task 5: Delete sync_manifest.zig and Manifest.zig + +**Files:** +- Delete: `src/sync_manifest.zig` +- Delete: `src/Manifest.zig` +- Modify: `src/cmd_fetch.zig` (remove Manifest dependency) + +- [ ] **Step 1: Update cmd_fetch.zig to remove Manifest usage** + +`cmd_fetch.zig` uses `Manifest.load` to compare old vs new manifests after `zig fetch`. The Manifest parser is the standard Zig build.zig.zon parser. After deleting Manifest.zig, we need an alternative. + +For now, we'll simplify cmd_fetch to not diff manifests — just run `zig fetch` with the save option. The zbuild.zon update path (`updateConfigDependency`) used `Manifest.load` on zbuild_file which was broken anyway (bug 2.13). Since zbuild_file IS now build.zig.zon, `zig fetch --save` handles everything directly. + +Replace `src/cmd_fetch.zig:82-158` (the `exec` function) with: + +```zig +pub fn exec( + gpa: Allocator, + arena: Allocator, + global_opts: GlobalOptions, + opts: Opts, +) !void { + try runZigFetch( + gpa, + arena, + .{ .cwd = global_opts.project_dir }, + global_opts.getZigEnv(), + opts.path_or_url, + opts.save, + ); + if (opts.save != .no) { + return; + } + return cleanExit(); +} +``` + +Remove these imports from `cmd_fetch.zig`: +- `const Config = @import("Config.zig");` +- `const Manifest = @import("Manifest.zig");` + +Remove the entire `updateConfigDependency` function (lines 160-245). + +- [ ] **Step 2: Delete Manifest.zig and sync_manifest.zig** + +```bash +rm src/Manifest.zig src/sync_manifest.zig +``` + +- [ ] **Step 3: Remove stale imports in main.zig if any** + +Check if `main.zig` imports Manifest or sync_manifest. It doesn't — it only imports `Config`, `ConfigBuildgen`, `Args`, `GlobalOptions`, and the `cmd_*` modules. No change needed. + +- [ ] **Step 4: Build to verify no dangling references** + +```bash +zig build +``` + +Expected: Compiles cleanly. No references to Manifest.zig or sync_manifest.zig remain. + +- [ ] **Step 5: Run tests** + +```bash +zig build test -- --test-filter "zbuild build --help" +``` + +Expected: All 6 pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(phase-a): delete sync_manifest.zig and Manifest.zig + +Eliminates the parallel manifest data model, the depEql bridge function, +and the AST-splicing hack. Simplifies cmd_fetch to delegate to zig fetch +directly. Fixes bugs 2.9, 2.10, 2.12, 2.13, 4.5, 5.8." +``` + +### Task 6: Rename zbuild's own config file + +**Files:** +- Rename: `zbuild.zon` → merge into existing `build.zig.zon` +- Modify: `build.zig.zon` + +- [ ] **Step 1: Merge zbuild.zon content into build.zig.zon** + +The current `zbuild.zon` has zbuild-specific fields (executables, tests, description). The current `build.zig.zon` has standard manifest fields. Merge them into a single `build.zig.zon`: + +```zon +.{ + .name = .zbuild, + .version = "0.2.0", + .fingerprint = 0x60f98ac2bf5a915c, + .minimum_zig_version = "0.14.0", + .paths = .{ "build.zig", "build.zig.zon", "src" }, + .description = "An opinionated zig build tool", + .dependencies = .{}, + .executables = .{ + .zbuild = .{ + .root_module = .{ + .root_source_file = "src/main.zig", + }, + }, + }, + .tests = .{ + .sync = .{ + .root_module = .{ + .private = true, + .root_source_file = "test/sync.zig", + .imports = .{.zbuild}, + }, + }, + }, +} +``` + +- [ ] **Step 2: Delete zbuild.zon** + +```bash +rm zbuild.zon +``` + +- [ ] **Step 3: Run zbuild sync to verify it reads build.zig.zon and generates build.zig** + +```bash +zig build +``` + +Expected: Compiles and runs. zbuild reads `build.zig.zon` (the new default), generates `build.zig`. + +- [ ] **Step 4: Run full tests** + +```bash +zig build test +``` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(phase-a): merge zbuild.zon into build.zig.zon + +Single source of truth. Zig's build system ignores unknown fields +(executables, tests, etc.) while zbuild reads them." +``` + +--- + +## Chunk 2: Phase B — std.zon.parse-based Config (Parser Rewrite) + +### Task 7: Change fingerprint from []const u8 to u64 + +**Files:** +- Modify: `src/Config.zig` +- Modify: `src/ConfigBuildgen.zig` (update fingerprint emission — still needed until Phase C) +- Modify: `src/cmd_init.zig` (update fingerprint generation) + +- [ ] **Step 1: Change the Config struct field** + +In `src/Config.zig:11`, change: +```zig +// Before: +fingerprint: []const u8, +// After: +fingerprint: u64, +``` + +- [ ] **Step 2: Update the parser** + +In `src/Config.zig:632-635`, change: +```zig +// Before: +} else if (std.mem.eql(u8, field_name, "fingerprint")) { + has_fingerprint = true; + const fingerprint_int = try self.parseT(u64, field_value); + self.config.fingerprint = try std.fmt.allocPrint(self.gpa, "0x{x}", .{fingerprint_int}); +// After: +} else if (std.mem.eql(u8, field_name, "fingerprint")) { + has_fingerprint = true; + self.config.fingerprint = try self.parseT(u64, field_value); +``` + +- [ ] **Step 3: Update Parser.init default** + +In `src/Config.zig:608`, change: +```zig +// Before: +.fingerprint = "", +// After: +.fingerprint = 0, +``` + +- [ ] **Step 4: Update deinit** + +In `src/Config.zig:453`, remove: +```zig +gpa.free(config.fingerprint); +``` + +- [ ] **Step 5: Update the serializer** + +In `src/Config.zig:1352-1353`, change: +```zig +// Before: +try top_level.fieldPrefix("fingerprint"); +try self.writer.print("0x{x}", .{self.config.fingerprint}); +// After: +try top_level.fieldPrefix("fingerprint"); +try self.writer.print("0x{x:0>16}", .{self.config.fingerprint}); +``` + +- [ ] **Step 6: Update cmd_init.zig** + +In `src/cmd_init.zig:14-19`, change: +```zig +// Before: +const fingerprint = try std.fmt.allocPrint( + gpa, + "0x{x}", + .{Package.Fingerprint.generate(name).int()}, +); +defer gpa.free(fingerprint); +// After: +const fingerprint = Package.Fingerprint.generate(name).int(); +``` + +Remove `defer gpa.free(fingerprint);` since it's now a `u64`, not a heap string. + +- [ ] **Step 7: Update ConfigBuildgen reference to fingerprint** + +Search `ConfigBuildgen.zig` for `fingerprint` usage. It's not directly used there — the fingerprint goes into the manifest template in `sync_manifest.zig` which we already deleted. No changes needed in ConfigBuildgen. + +- [ ] **Step 8: Build and test** + +```bash +zig build test +``` + +Expected: All tests pass. The fingerprint is now parsed as a `u64` directly from the ZON number literal. + +- [ ] **Step 9: Commit** + +```bash +git add src/Config.zig src/cmd_init.zig +git commit -m "refactor(phase-b): change fingerprint from []const u8 to u64 + +Matches the ZON representation directly. Eliminates the intermediate +string formatting and heap allocation." +``` + +### Task 8: Switch to arena-based parsing (eliminate deinit) + +**Files:** +- Modify: `src/Config.zig` +- Modify: `src/main.zig` + +The current parser uses `gpa` for all allocations and has manual `deinit` methods for every type. Since Config is always used with an arena in practice (`main.zig:67-68`), we can parse into the arena and eliminate all `deinit` methods. + +- [ ] **Step 1: Verify arena usage in callers** + +Check all callers of `Config.parseFromFile`: +- `main.zig:107`: passes `arena` as first arg — arena-allocated, freed by `arena_instance.deinit()` +- `test/sync.zig:34`: passes `arena` — same pattern + +Both callers use arena. The Config result lives for the scope of the arena. No caller calls `config.deinit()`. This confirms we can safely switch to arena-only allocation. + +- [ ] **Step 2: Remove Config.deinit and all sub-type deinit methods** + +In `src/Config.zig`, delete: +- `Config.deinit` (lines 450-523) +- `Dependency.deinit` (lines 44-56) +- `Option.deinit` (lines 124-189) +- `WriteFile.deinit` (lines 247-272) +- `Module.deinit` (lines 303-315) +- `ModuleLink.deinit` (lines 322-327) +- `Executable.deinit` (lines 346-356) +- `Library.deinit` (lines 376-386) +- `Object.deinit` (lines 399-407) +- `Test.deinit` (lines 421-428) +- `Fmt.deinit` (lines 436-445) + +Keep the `ArrayHashMap.init` calls as-is — they're called with `self.gpa` which becomes the arena. + +- [ ] **Step 3: Remove deinit calls from callers** + +In `src/main.zig`, there's no `config.deinit()` call (the arena handles it). But check that the `wip_bundle` and other code doesn't call deinit. Looking at `main.zig:105-119`, there's no `defer config.deinit()` — confirmed, no changes needed in main.zig. + +In `src/Config.zig:1619-1637`, delete the entire test block at the bottom of the file. It uses `std.testing.allocator` (not an arena) and reads a nonexistent "foo.zon" — it's dead code that would leak after deinit removal. + +- [ ] **Step 4: Build and test** + +```bash +zig build test +``` + +Expected: All tests pass (the test at Config.zig bottom will fail if actually run, but it's not in the test runner — it requires a `foo.zon` file). + +- [ ] **Step 5: Commit** + +```bash +git add src/Config.zig +git commit -m "refactor(phase-b): remove all deinit methods from Config types + +All Config parsing uses arena allocation. Manual deinit is unnecessary +and was a source of memory leak bugs (2.11, 3.1, 3.2, 3.3, 3.10)." +``` + +### Task 9: Rewrite the parser using std.zon.parse + +**Files:** +- Modify: `src/Config.zig` + +This is the core of Phase B. Replace the hand-rolled `if/else if` dispatch chains with `fromZoirNode` for plain struct types, and keep thin custom parsers for types that need them. + +- [ ] **Step 1: Write the new parser** + +Replace the entire `Parser` struct (lines 588-1319) with the new implementation. The new parser has these layers: + +**Layer 1 — Top-level parse:** Iterates top-level struct fields, dispatches by name. Same structure as current but shorter — delegates to `fromZoirNode` or custom parsers. + +**Layer 2 — parseHashMap:** Generic function that iterates a ZON struct literal's named fields and parses each value. Replaces `parseOptionalHashMap`. + +**Layer 3 — Value types parsed by fromZoirNode:** `Module`, `Executable`, `Library`, `Object`, `Test`, `Fmt` are parsed directly by `fromZoirNode`. This eliminates `parseModule`, `parseExecutable`, `parseLibrary`, `parseObject`, `parseTest`, `parseFmt` and all their `if/else if` chains. + +**Layer 4 — Custom parsers:** +- `parseDependency` — checks for `path` vs `url` field +- `parseModuleLink` — bare enum literal vs struct literal +- `parseOption` — discriminated by `type` string field +- `parseRun` — plain string, use `fromZoirNode` directly + +Here is the complete new Parser: + +```zig +const Parser = struct { + gpa: std.mem.Allocator, + zoir: std.zig.Zoir, + ast: std.zig.Ast, + status: *std.zon.parse.Status, + + const Error = error{ OutOfMemory, ParseZon, NegativeIntoUnsigned, TargetTooSmall }; + + fn parse(self: *Parser) Error!Config { + var config = Config{ + .name = "", + .version = "", + .fingerprint = 0, + .minimum_zig_version = "", + .paths = &.{}, + }; + + var has_name = false; + var has_version = false; + var has_fingerprint = false; + var has_minimum_zig_version = false; + var has_paths = false; + + const r = try self.parseStructLiteral(.root); + for (r.names, 0..) |n, i| { + const field_name = n.get(self.zoir); + const field_value = r.vals.at(@intCast(i)); + + if (std.mem.eql(u8, field_name, "name")) { + has_name = true; + config.name = try self.parseEnumLiteral(field_value); + } else if (std.mem.eql(u8, field_name, "version")) { + has_version = true; + config.version = try self.parseVersionString(field_value); + } else if (std.mem.eql(u8, field_name, "fingerprint")) { + has_fingerprint = true; + config.fingerprint = try self.parseT(u64, field_value); + } else if (std.mem.eql(u8, field_name, "minimum_zig_version")) { + has_minimum_zig_version = true; + config.minimum_zig_version = try self.parseVersionString(field_value); + } else if (std.mem.eql(u8, field_name, "paths")) { + has_paths = true; + config.paths = try self.parseT([][]const u8, field_value); + } else if (std.mem.eql(u8, field_name, "description")) { + config.description = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "keywords")) { + config.keywords = try self.parseT(?[][]const u8, field_value); + } else if (std.mem.eql(u8, field_name, "dependencies")) { + config.dependencies = try self.parseHashMap(Dependency, parseDependency, field_value); + } else if (std.mem.eql(u8, field_name, "write_files")) { + // stub — pre-existing incomplete feature + } else if (std.mem.eql(u8, field_name, "options")) { + config.options = try self.parseHashMap(Option, parseOption, field_value); + } else if (std.mem.eql(u8, field_name, "options_modules")) { + config.options_modules = try self.parseHashMap(OptionsModule, parseOptionsModule, field_value); + } else if (std.mem.eql(u8, field_name, "modules")) { + config.modules = try self.parseHashMap(Module, parseModule, field_value); + } else if (std.mem.eql(u8, field_name, "executables")) { + config.executables = try self.parseHashMap(Executable, parseExecutable, field_value); + } else if (std.mem.eql(u8, field_name, "libraries")) { + config.libraries = try self.parseHashMap(Library, parseLibrary, field_value); + } else if (std.mem.eql(u8, field_name, "objects")) { + config.objects = try self.parseHashMap(Object, parseObject, field_value); + } else if (std.mem.eql(u8, field_name, "tests")) { + config.tests = try self.parseHashMap(Test, parseTest, field_value); + } else if (std.mem.eql(u8, field_name, "fmts")) { + config.fmts = try self.parseHashMap(Fmt, parseFmt, field_value); + } else if (std.mem.eql(u8, field_name, "runs")) { + config.runs = try self.parseHashMap(Run, parseRun, field_value); + } else { + // Ignore unknown fields — this allows build.zig.zon standard fields + // that zbuild doesn't use (like Zig-added future fields) to pass through. + } + } + + if (!has_name) try self.returnParseError("missing required field 'name'", self.ast.rootDecls()[0]); + if (!has_version) try self.returnParseError("missing required field 'version'", self.ast.rootDecls()[0]); + if (!has_fingerprint) try self.returnParseError("missing required field 'fingerprint'", self.ast.rootDecls()[0]); + if (!has_minimum_zig_version) try self.returnParseError("missing required field 'minimum_zig_version'", self.ast.rootDecls()[0]); + if (!has_paths) try self.returnParseError("missing required field 'paths'", self.ast.rootDecls()[0]); + + return config; + } + + // -- Layer 2: HashMap parsing -- + + fn parseHashMap( + self: *Parser, + comptime V: type, + comptime parseItem: fn (*Parser, std.zig.Zoir.Node.Index) Error!V, + index: std.zig.Zoir.Node.Index, + ) Error!?ArrayHashMap(V) { + const node = index.get(self.zoir); + switch (node) { + .struct_literal => |n| { + var items = ArrayHashMap(V).init(self.gpa); + for (n.names, 0..) |name, i| { + const field_name = try self.gpa.dupe(u8, name.get(self.zoir)); + const field_value = n.vals.at(@intCast(i)); + try items.put(field_name, try parseItem(self, field_value)); + } + return items; + }, + .empty_literal => return null, + else => { + try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)); + }, + } + } + + // -- Layer 3: Types parsed by fromZoirNode -- + + fn parseModule(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Module { + // Module has imports as ?[][]const u8 which needs enum literal support. + // fromZoirNode handles []const u8 but not enum literals as strings. + // We parse it manually still but much simpler — just iterate fields. + const n = try self.parseStructLiteral(index); + var module = Module{}; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "imports")) { + module.imports = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "name")) { + module.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "root_source_file")) { + module.root_source_file = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "target")) { + module.target = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "private")) { + module.private = try self.parseT(bool, field_value); + } else if (std.mem.eql(u8, field_name, "include_paths")) { + module.include_paths = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "link_libraries")) { + module.link_libraries = try self.parseStringOrEnumSlice(field_value); + } else { + // Use fromZoirNode for all remaining typed fields + inline for (@typeInfo(Module).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(module, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } + } + return module; + } + + fn parseExecutable(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Executable { + const n = try self.parseStructLiteral(index); + var exe = Executable{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + exe.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + exe.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "version")) { + exe.version = try self.parseVersionString(field_value); + } else if (std.mem.eql(u8, field_name, "depends_on")) { + exe.depends_on = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + exe.zig_lib_dir = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "win32_manifest")) { + exe.win32_manifest = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { + exe.dest_sub_path = try self.parseString(field_value); + } else { + inline for (@typeInfo(Executable).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(exe, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } + } + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return exe; + } + + fn parseLibrary(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Library { + const n = try self.parseStructLiteral(index); + var lib = Library{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + lib.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + lib.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "version")) { + lib.version = try self.parseVersionString(field_value); + } else if (std.mem.eql(u8, field_name, "depends_on")) { + lib.depends_on = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + lib.zig_lib_dir = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "win32_manifest")) { + lib.win32_manifest = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { + lib.dest_sub_path = try self.parseString(field_value); + } else { + inline for (@typeInfo(Library).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(lib, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } + } + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return lib; + } + + fn parseObject(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Object { + const n = try self.parseStructLiteral(index); + var obj = Object{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + obj.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + obj.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "depends_on")) { + obj.depends_on = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + obj.zig_lib_dir = try self.parseString(field_value); + } else { + inline for (@typeInfo(Object).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(obj, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } + } + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return obj; + } + + fn parseTest(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Test { + const n = try self.parseStructLiteral(index); + var t = Test{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + t.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + t.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "filters")) { + t.filters = try self.parseStringOrEnumSlice(field_value) orelse &.{}; + } else if (std.mem.eql(u8, field_name, "test_runner")) { + t.test_runner = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + t.zig_lib_dir = try self.parseString(field_value); + } else { + inline for (@typeInfo(Test).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(t, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } + } + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return t; + } + + fn parseFmt(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Fmt { + return try self.parseT(Fmt, index); + } + + fn parseRun(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Run { + return try self.parseString(index); + } + + // -- Layer 4: Custom parsers -- + + fn parseDependency(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency { + const n = try self.parseStructLiteral(index); + var dep = Dependency{ .typ = undefined, .value = undefined }; + var has_type_field = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "path")) { + dep.typ = .path; + dep.value = try self.parseString(field_value); + has_type_field = true; + } else if (std.mem.eql(u8, field_name, "url")) { + dep.typ = .url; + dep.value = try self.parseString(field_value); + has_type_field = true; + } else if (std.mem.eql(u8, field_name, "hash")) { + dep.hash = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "lazy")) { + dep.lazy = try self.parseT(bool, field_value); + } else if (std.mem.eql(u8, field_name, "args")) { + dep.args = try self.parseHashMap(Dependency.Arg, parseDependencyArg, field_value); + } + } + if (!has_type_field) try self.returnParseError("missing required field 'path' or 'url'", index.getAstNode(self.zoir)); + return dep; + } + + fn parseDependencyArg(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency.Arg { + const node = index.get(self.zoir); + switch (node) { + .true => return .{ .bool = true }, + .false => return .{ .bool = false }, + .int_literal => |i| return .{ .int = switch (i) { + .small => |s| s, + .big => |b| try b.toInt(i64), + } }, + .float_literal => |f| return .{ .float = @floatCast(f) }, + .enum_literal => |e| return .{ .@"enum" = try self.gpa.dupe(u8, e.get(self.zoir)) }, + .string_literal => |s| return .{ .string = try self.gpa.dupe(u8, s) }, + .null => return .{ .null = {} }, + else => try self.returnParseError("expected a bool, int, float, string literal, or enum literal", index.getAstNode(self.zoir)), + } + } + + fn parseModuleLink(self: *Parser, index: std.zig.Zoir.Node.Index) Error!ModuleLink { + const node = index.get(self.zoir); + switch (node) { + .struct_literal => return .{ .module = try self.parseModule(index) }, + .string_literal => |n| return .{ .name = try self.gpa.dupe(u8, n) }, + .enum_literal => |n| return .{ .name = try self.gpa.dupe(u8, n.get(self.zoir)) }, + else => try self.returnParseError("expected a string, enum literal, or struct literal", index.getAstNode(self.zoir)), + } + } + + fn parseOption(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option { + const n = try self.parseStructLiteral(index); + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "type")) { + const t = try self.parseString(field_value); + if (Option.isValidIntType(t)) { + return .{ .int = try self.parseT(Option.Int, index) }; + } else if (Option.isValidFloatType(t)) { + return .{ .float = try self.parseT(Option.Float, index) }; + } else if (std.mem.eql(u8, t, "bool")) { + return .{ .bool = try self.parseT(Option.Bool, index) }; + } else if (std.mem.eql(u8, t, "enum")) { + return .{ .@"enum" = try self.parseOptionEnum(index) }; + } else if (std.mem.eql(u8, t, "enum_list")) { + return .{ .enum_list = try self.parseOptionEnumList(index) }; + } else if (std.mem.eql(u8, t, "string")) { + return .{ .string = try self.parseT(Option.String, index) }; + } else if (std.mem.eql(u8, t, "list")) { + return .{ .list = try self.parseT(Option.List, index) }; + } else if (std.mem.eql(u8, t, "build_id")) { + return .{ .build_id = try self.parseT(Option.BuildId, index) }; + } else if (std.mem.eql(u8, t, "lazy_path")) { + return .{ .lazy_path = try self.parseT(Option.LazyPath, index) }; + } else if (std.mem.eql(u8, t, "lazy_path_list")) { + return .{ .lazy_path_list = try self.parseT(Option.LazyPathList, index) }; + } else { + try self.returnParseErrorFmt("invalid type '{s}'", .{t}, field_value.getAstNode(self.zoir)); + } + } + } + try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); + } + + fn parseOptionEnum(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option.Enum { + const n = try self.parseStructLiteral(index); + var option = Option.Enum{ .enum_options = &.{}, .type = "" }; + var has_type = false; + var has_enum_options = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "type")) { + has_type = true; + option.type = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "enum_options")) { + has_enum_options = true; + option.enum_options = try self.parseEnumLiteralSlice(field_value); + } else if (std.mem.eql(u8, field_name, "description")) { + option.description = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "default")) { + option.default = try self.parseEnumLiteral(field_value); + } + } + if (!has_type) try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); + if (!has_enum_options) try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); + return option; + } + + fn parseOptionEnumList(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option.EnumList { + const n = try self.parseStructLiteral(index); + var option = Option.EnumList{ .enum_options = &.{}, .type = "" }; + var has_type = false; + var has_enum_options = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "type")) { + has_type = true; + option.type = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "enum_options")) { + has_enum_options = true; + option.enum_options = try self.parseEnumLiteralSlice(field_value); + } else if (std.mem.eql(u8, field_name, "description")) { + option.description = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "default")) { + option.default = try self.parseEnumLiteralSlice(field_value); + } + } + if (!has_type) try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); + if (!has_enum_options) try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); + return option; + } + + fn parseOptionsModule(self: *Parser, index: std.zig.Zoir.Node.Index) Error!OptionsModule { + return (try self.parseHashMap(Option, parseOption, index)) orelse ArrayHashMap(Option).init(self.gpa); + } + + // -- Primitives -- + + fn parseT(self: *Parser, comptime T: type, index: std.zig.Zoir.Node.Index) Error!T { + @setEvalBranchQuota(2_000); + self.status.* = .{}; + return try std.zon.parse.fromZoirNode(T, self.gpa, self.ast, self.zoir, index, self.status, .{}); + } + + fn parseString(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { + const node = index.get(self.zoir); + switch (node) { + .string_literal => |n| return try self.gpa.dupe(u8, n), + else => try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)), + } + } + + fn parseEnumLiteral(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { + const node = index.get(self.zoir); + switch (node) { + .enum_literal => |n| return try self.gpa.dupe(u8, n.get(self.zoir)), + else => try self.returnParseError("expected an enum literal", index.getAstNode(self.zoir)), + } + } + + fn parseVersionString(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { + const node = index.get(self.zoir); + switch (node) { + .string_literal => |n| { + _ = std.SemanticVersion.parse(n) catch { + try self.returnParseError("invalid version string", index.getAstNode(self.zoir)); + }; + return try self.gpa.dupe(u8, n); + }, + else => try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)), + } + } + + fn parseStringOrEnumSlice(self: *Parser, index: std.zig.Zoir.Node.Index) Error!?[]const []const u8 { + const node = index.get(self.zoir); + switch (node) { + .array_literal => |a| { + const slice = try self.gpa.alloc([]const u8, a.len); + for (0..a.len) |i| { + const item = a.at(@intCast(i)); + const item_node = item.get(self.zoir); + slice[i] = switch (item_node) { + .string_literal => |s| try self.gpa.dupe(u8, s), + .enum_literal => |e| try self.gpa.dupe(u8, e.get(self.zoir)), + else => { + try self.returnParseError("expected string or enum literal", item.getAstNode(self.zoir)); + }, + }; + } + return slice; + }, + .empty_literal => return null, + else => try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)), + } + } + + fn parseEnumLiteralSlice(self: *Parser, index: std.zig.Zoir.Node.Index) Error![][]const u8 { + const node = index.get(self.zoir); + switch (node) { + .array_literal => |a| { + const slice = try self.gpa.alloc([]const u8, a.len); + for (0..a.len) |i| { + const item = a.at(@intCast(i)); + slice[i] = try self.parseEnumLiteral(item); + } + return slice; + }, + else => try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)), + } + } + + fn parseStructLiteral(self: *Parser, index: std.zig.Zoir.Node.Index) Error!std.meta.TagPayload(std.zig.Zoir.Node, .struct_literal) { + const node = index.get(self.zoir); + switch (node) { + .struct_literal => |n| return n, + else => try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)), + } + } + + fn returnParseErrorFmt(self: *Parser, comptime fmt: []const u8, args: anytype, node_index: std.zig.Ast.Node.Index) Error!noreturn { + const message = try std.fmt.allocPrint(self.gpa, fmt, args); + try self.returnParseError(message, node_index); + } + + fn returnParseError(self: *Parser, message: []const u8, node_index: std.zig.Ast.Node.Index) Error!noreturn { + self.status.* = .{ + .ast = self.ast, + .zoir = self.zoir, + .type_check = .{ + .message = message, + .owned = false, + .token = self.ast.firstToken(node_index), + .offset = 0, + .note = null, + }, + }; + return error.ParseZon; + } +}; +``` + +Key differences from the old parser: +- **`parseHashMap`** replaces both `parseHashMap` and `parseOptionalHashMap` (merged into one that returns `?ArrayHashMap`) +- **`parseModule`** uses `inline for` over struct fields for the typed fields (bool, enum, etc.) and handles strings/imports manually +- **`parseExecutable/Library/Object/Test`** same pattern: handle root_module, name, version, string fields manually, use `inline for` for typed fields +- **`parseDependency`** now parses `hash` and `lazy` (fixes bugs 2.1, 2.10) +- **`parseTest`** now parses `test_runner` (fixes bug 2.3) +- **`parseLibrary`** now parses `version` (fixes bug 2.2) +- **Unknown fields at top level are ignored** (enables build.zig.zon compatibility) +- All the old helper functions (`parseBool`, `parseSlice`, `parseOptionalSlice`, `parseStringOrEnumLiteral`) are eliminated + +- [ ] **Step 2: Build** (dead test already removed in Task 8) + +```bash +zig build +``` + +Expected: Compiles cleanly. + +- [ ] **Step 4: Run tests** + +```bash +zig build test +``` + +Expected: All 6 fixture tests pass. The new parser handles every field in every fixture. + +- [ ] **Step 5: Commit** + +```bash +git add src/Config.zig +git commit -m "refactor(phase-b): rewrite parser using std.zon.parse + inline for + +Replaces ~600 lines of hand-rolled if/else if dispatch with: +- fromZoirNode for typed fields via comptime reflection +- Thin custom parsers for Dependency, ModuleLink, Option +- Generic parseHashMap for all StringArrayHashMap fields + +Fixes: 2.1 (hash/lazy), 2.2 (Library.version), 2.3 (Test.test_runner), +2.11 (include_paths leak), 3.1-3.3 (deinit leaks), 3.10 (error leak), +5.3 (no reflection), 5.7 (deinit ceremony)." +``` + +### Task 10: Add parse fidelity tests + +**Files:** +- Create: `test/parse_test.zig` +- Modify: `build.zig.zon` (add test) + +- [ ] **Step 1: Create parse fidelity test file** + +Create `test/parse_test.zig`: + +```zig +const std = @import("std"); +const Config = @import("zbuild").Config; + +fn parseFixture(arena: std.mem.Allocator, fixture: []const u8) !Config { + return try Config.parseFromFile(arena, fixture, null); +} + +test "basic1: simple module" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const config = try parseFixture(arena.allocator(), "test/fixtures/basic1.build.zig.zon"); + + try std.testing.expectEqualStrings("basic", config.name); + try std.testing.expectEqualStrings("0.1.0", config.version); + try std.testing.expectEqual(@as(u64, 0x90797553773ca567), config.fingerprint); + + const modules = config.modules orelse return error.MissingModules; + try std.testing.expectEqual(@as(usize, 1), modules.count()); + const m0 = modules.get("module_0") orelse return error.MissingModule; + try std.testing.expectEqualStrings("src/module_0/main.zig", m0.root_source_file.?); +} + +test "basic2: module with all bool/enum fields" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const config = try parseFixture(arena.allocator(), "test/fixtures/basic2.build.zig.zon"); + + const modules = config.modules orelse return error.MissingModules; + const m1 = modules.get("module_1") orelse return error.MissingModule; + + try std.testing.expectEqual(true, m1.link_libc.?); + try std.testing.expectEqual(true, m1.link_libcpp.?); + try std.testing.expectEqual(true, m1.single_threaded.?); + try std.testing.expectEqual(true, m1.strip.?); + try std.testing.expectEqual(std.builtin.OptimizeMode.ReleaseFast, m1.optimize.?); + try std.testing.expectEqual(std.builtin.CodeModel.default, m1.code_model.?); + try std.testing.expectEqual(false, m1.fuzz.?); + try std.testing.expectEqual(true, m1.valgrind.?); +} + +test "basic3: multiple modules with target/optimize" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const config = try parseFixture(arena.allocator(), "test/fixtures/basic3.build.zig.zon"); + + const modules = config.modules orelse return error.MissingModules; + try std.testing.expectEqual(@as(usize, 2), modules.count()); + + const m0 = modules.get("module_0") orelse return error.MissingModule; + try std.testing.expectEqualStrings("native", m0.target.?); + try std.testing.expectEqual(std.builtin.OptimizeMode.ReleaseFast, m0.optimize.?); +} + +test "basic4: executables and libraries" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const config = try parseFixture(arena.allocator(), "test/fixtures/basic4.build.zig.zon"); + + const exes = config.executables orelse return error.MissingExecutables; + try std.testing.expectEqual(@as(usize, 1), exes.count()); + const exe = exes.get("module_0") orelse return error.MissingExe; + switch (exe.root_module) { + .module => |m| { + try std.testing.expectEqualStrings("module_0_exe", m.name.?); + try std.testing.expectEqualStrings("src/module_0/main.zig", m.root_source_file.?); + }, + .name => return error.ExpectedInlineModule, + } + + const libs = config.libraries orelse return error.MissingLibraries; + try std.testing.expectEqual(@as(usize, 1), libs.count()); +} + +test "basic5: options modules" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const config = try parseFixture(arena.allocator(), "test/fixtures/basic5.build.zig.zon"); + + const opts_modules = config.options_modules orelse return error.MissingOptionsModules; + try std.testing.expectEqual(@as(usize, 1), opts_modules.count()); + + const build_options = opts_modules.get("build_options") orelse return error.MissingBuildOptions; + const min_depth = build_options.get("min_depth") orelse return error.MissingOption; + switch (min_depth) { + .int => |i| { + try std.testing.expectEqualStrings("usize", i.type); + try std.testing.expectEqual(@as(i64, 0), i.default.?); + }, + else => return error.WrongOptionType, + } +} + +test "basic6: runs" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const config = try parseFixture(arena.allocator(), "test/fixtures/basic6.build.zig.zon"); + + const runs = config.runs orelse return error.MissingRuns; + try std.testing.expectEqual(@as(usize, 1), runs.count()); + const docs = runs.get("docs") orelse return error.MissingDocs; + try std.testing.expectEqualStrings("echo 'Generating documentation...'", docs); +} +``` + +- [ ] **Step 2: Add the test to zbuild's own config** + +Add a test entry to `build.zig.zon` in the `.tests` section: + +```zon +.parse_test = .{ + .root_module = .{ + .private = true, + .root_source_file = "test/parse_test.zig", + .imports = .{.zbuild}, + }, +}, +``` + +- [ ] **Step 3: Regenerate build.zig and run** + +```bash +zig build -- run:zbuild sync +zig build test +``` + +Expected: All tests pass including the new parse fidelity tests. + +- [ ] **Step 4: Commit** + +```bash +git add test/parse_test.zig build.zig.zon build.zig +git commit -m "test(phase-b): add parse fidelity tests for all 6 fixtures + +Verifies specific field values after parsing, catching 'field silently +ignored' and 'field parsed but wrong value' bugs." +``` + +--- + +## Chunk 3: Phase C — Static build.zig + +### Task 11: Create build_runner.zig + +**Files:** +- Create: `src/build_runner.zig` + +This is the core of Phase C. The `configureBuild` function reads a Config and calls `std.Build` API directly — the same logic as ConfigBuildgen but without string concatenation. + +- [ ] **Step 1: Create src/build_runner.zig** + +```zig +//! Configures a Zig build graph from a zbuild Config. +//! Replaces string-concatenation codegen (ConfigBuildgen) with direct API calls. + +const std = @import("std"); +const Config = @import("Config.zig"); + +pub fn configureBuild(b: *std.Build, config: Config) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + var runner = BuildRunner{ + .b = b, + .config = config, + .target = target, + .optimize = optimize, + .modules = std.StringHashMap(*std.Build.Module).init(b.allocator), + .dependencies = std.StringHashMap(*std.Build.Dependency).init(b.allocator), + .options_modules = std.StringHashMap(*std.Build.Module).init(b.allocator), + }; + + // Phase 1: Create options and options modules + if (config.options_modules) |options_modules| { + for (options_modules.keys(), options_modules.values()) |name, options| { + try runner.createOptionsModule(name, options); + } + } + + // Phase 2: Create dependencies + if (config.dependencies) |dependencies| { + for (dependencies.keys(), dependencies.values()) |name, dep| { + try runner.createDependency(name, dep); + } + } + + // Phase 3: Create named modules + if (config.modules) |modules| { + for (modules.keys(), modules.values()) |name, module| { + const m = try runner.createModule(module, name); + if (module.private orelse true) { + b.modules.put(b.dupe(name), m) catch @panic("OOM"); + } + try runner.modules.put(name, m); + } + } + + // Phase 4: Create executables + if (config.executables) |executables| { + for (executables.keys(), executables.values()) |name, exe| { + try runner.createExecutable(name, exe); + } + } + + // Phase 5: Create libraries + if (config.libraries) |libraries| { + for (libraries.keys(), libraries.values()) |name, lib| { + try runner.createLibrary(name, lib); + } + } + + // Phase 6: Create objects + if (config.objects) |objects| { + for (objects.keys(), objects.values()) |name, obj| { + try runner.createObject(name, obj); + } + } + + // Phase 7: Create tests + var has_tests = false; + var tls_run_test: ?*std.Build.Step = null; + + // Auto-create tests for named modules + if (config.modules) |modules| { + if (modules.count() > 0 or (config.tests != null and config.tests.?.count() > 0)) { + tls_run_test = b.step("test", "Run all tests"); + has_tests = true; + } + for (modules.keys()) |name| { + if (config.tests == null or !config.tests.?.contains(name)) { + try runner.createTest(name, .{ + .root_module = .{ .name = name }, + .filters = &.{}, + }, tls_run_test.?); + } + } + } + + if (config.tests) |tests| { + if (!has_tests) { + tls_run_test = b.step("test", "Run all tests"); + } + for (tests.keys(), tests.values()) |name, t| { + try runner.createTest(name, t, tls_run_test.?); + } + } + + // Phase 8: Create fmts + if (config.fmts) |fmts| { + const tls_run_fmt = b.step("fmt", "Run all fmts"); + for (fmts.keys(), fmts.values()) |name, fmt| { + try runner.createFmt(name, fmt, tls_run_fmt); + } + } + + // Phase 9: Create runs + if (config.runs) |runs| { + for (runs.keys(), runs.values()) |name, run| { + runner.createRun(name, run); + } + } + + // Phase 10: Wire imports for all modules + if (config.modules) |modules| { + for (modules.keys(), modules.values()) |name, module| { + if (module.imports) |imports| { + const m = runner.modules.get(name) orelse continue; + try runner.wireImports(m, imports); + } + } + } + // Wire imports for inline modules in executables/libraries/objects/tests + if (config.executables) |exes| { + for (exes.values()) |exe| { + if (exe.root_module == .module) { + if (exe.root_module.module.imports) |imports| { + const name = exe.root_module.module.name orelse continue; + const m = runner.modules.get(name) orelse continue; + try runner.wireImports(m, imports); + } + } + } + } + if (config.libraries) |libs| { + for (libs.values()) |lib| { + if (lib.root_module == .module) { + if (lib.root_module.module.imports) |imports| { + const name = lib.root_module.module.name orelse continue; + const m = runner.modules.get(name) orelse continue; + try runner.wireImports(m, imports); + } + } + } + } + if (config.objects) |objs| { + for (objs.values()) |obj| { + if (obj.root_module == .module) { + if (obj.root_module.module.imports) |imports| { + const name = obj.root_module.module.name orelse continue; + const m = runner.modules.get(name) orelse continue; + try runner.wireImports(m, imports); + } + } + } + } + if (config.tests) |tests| { + for (tests.values()) |t| { + if (t.root_module == .module) { + if (t.root_module.module.imports) |imports| { + const name = t.root_module.module.name orelse continue; + const m = runner.modules.get(name) orelse continue; + try runner.wireImports(m, imports); + } + } + } + } +} + +const BuildRunner = struct { + b: *std.Build, + config: Config, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + modules: std.StringHashMap(*std.Build.Module), + dependencies: std.StringHashMap(*std.Build.Dependency), + options_modules: std.StringHashMap(*std.Build.Module), + + fn createModule(self: *BuildRunner, module: Config.Module, name: []const u8) !*std.Build.Module { + const m = self.b.createModule(.{ + .root_source_file = if (module.root_source_file) |f| self.resolveLazyPath(f) else null, + .target = if (module.target) |t| self.resolveTarget(t) else self.target, + .optimize = module.optimize orelse self.optimize, + .link_libc = module.link_libc, + .link_libcpp = module.link_libcpp, + .single_threaded = module.single_threaded, + .strip = module.strip, + .unwind_tables = module.unwind_tables, + .dwarf_format = module.dwarf_format, + .code_model = module.code_model, + .error_tracing = module.error_tracing, + .omit_frame_pointer = module.omit_frame_pointer, + .pic = module.pic, + .red_zone = module.red_zone, + .sanitize_c = module.sanitize_c, + .sanitize_thread = module.sanitize_thread, + .stack_check = module.stack_check, + .stack_protector = module.stack_protector, + .fuzz = module.fuzz, + .valgrind = module.valgrind, + }); + + if (module.include_paths) |paths| { + for (paths) |path| { + m.addIncludePath(self.resolveLazyPath(path)); + } + } + + if (module.link_libraries) |libs| { + for (libs) |lib| { + var parts = std.mem.splitScalar(u8, lib, ':'); + const dep_name = parts.first(); + const artifact_name = if (parts.next()) |rest| rest else dep_name; + if (self.dependencies.get(dep_name)) |dep| { + m.linkLibrary(dep.artifact(artifact_name)); + } + } + } + + try self.modules.put(name, m); + return m; + } + + fn resolveModuleLink(self: *BuildRunner, link: Config.ModuleLink, fallback_name: []const u8) !*std.Build.Module { + switch (link) { + .name => |n| { + return self.modules.get(n) orelse { + std.log.err("zbuild: module '{s}' not found", .{n}); + return error.ModuleNotFound; + }; + }, + .module => |m| { + const name = m.name orelse fallback_name; + return try self.createModule(m, name); + }, + } + } + + fn createDependency(self: *BuildRunner, name: []const u8, dep: Config.Dependency) !void { + _ = dep; // args are handled by zig build system + const d = self.b.dependency(@ptrCast(name), .{ + .optimize = self.optimize, + .target = self.target, + }); + try self.dependencies.put(name, d); + } + + fn createOptionsModule(self: *BuildRunner, name: []const u8, options: Config.OptionsModule) !void { + const opts = self.b.addOptions(); + for (options.keys(), options.values()) |opt_name, opt_value| { + self.addOption(opts, opt_name, opt_value); + } + const m = opts.createModule(); + try self.options_modules.put(name, m); + } + + fn addOption(self: *BuildRunner, opts: *std.Build.Step.Options, name: []const u8, value: Config.Option) void { + _ = self; + switch (value) { + .bool => |v| { + const val = opts.step.owner.option(bool, name, .{ .description = v.description orelse "" }); + opts.addOption(bool, name, val orelse v.default orelse null); + }, + .int => |v| { + // For int options, we use i64 as the runtime type since we can't + // create arbitrary int types at runtime + const val = opts.step.owner.option(i64, name, .{ .description = v.description orelse "" }); + opts.addOption(i64, name, val orelse v.default orelse null); + }, + .float => |v| { + const val = opts.step.owner.option(f64, name, .{ .description = v.description orelse "" }); + opts.addOption(f64, name, val orelse v.default orelse null); + }, + .string => |v| { + const val = opts.step.owner.option([]const u8, name, .{ .description = v.description orelse "" }); + opts.addOption([]const u8, name, val orelse v.default orelse null); + }, + .list => |v| { + const val = opts.step.owner.option([]const []const u8, name, .{ .description = v.description orelse "" }); + opts.addOption([]const []const u8, name, val orelse v.default orelse null); + }, + // enum and enum_list require comptime types — pass through as strings + .@"enum" => |v| { + const val = opts.step.owner.option([]const u8, name, .{ .description = v.description orelse "" }); + opts.addOption([]const u8, name, val orelse v.default orelse null); + }, + .enum_list => |v| { + const val = opts.step.owner.option([]const []const u8, name, .{ .description = v.description orelse "" }); + opts.addOption([]const []const u8, name, val orelse v.default orelse null); + }, + .build_id => |v| { + _ = v; + // TODO: build_id options + }, + .lazy_path => |v| { + _ = v; + // TODO: lazy_path options + }, + .lazy_path_list => |v| { + _ = v; + // TODO: lazy_path_list options + }, + } + } + + fn createExecutable(self: *BuildRunner, name: []const u8, exe: Config.Executable) !void { + const root_module = try self.resolveModuleLink(exe.root_module, name); + + const artifact = self.b.addExecutable(.{ + .name = name, + .version = if (exe.version) |v| std.SemanticVersion.parse(v) catch null else null, + .root_module = root_module, + .linkage = exe.linkage, + .max_rss = exe.max_rss, + .use_llvm = exe.use_llvm, + .use_lld = exe.use_lld, + .zig_lib_dir = if (exe.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + .win32_manifest = if (exe.win32_manifest) |d| self.resolveLazyPath(d) else null, + }); + + const install = self.b.addInstallArtifact(artifact, .{ + .dest_sub_path = if (exe.dest_sub_path) |p| @ptrCast(p) else null, + }); + + const tls_install = self.b.step( + self.b.fmt("build-exe:{s}", .{name}), + self.b.fmt("Install the {s} executable", .{name}), + ); + tls_install.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + + const run = self.b.addRunArtifact(artifact); + if (self.b.args) |args| run.addArgs(args); + const tls_run = self.b.step( + self.b.fmt("run:{s}", .{name}), + self.b.fmt("Run the {s} executable", .{name}), + ); + tls_run.dependOn(&run.step); + } + + fn createLibrary(self: *BuildRunner, name: []const u8, lib: Config.Library) !void { + const root_module = try self.resolveModuleLink(lib.root_module, name); + + const artifact = self.b.addLibrary(.{ + .name = name, + .version = if (lib.version) |v| std.SemanticVersion.parse(v) catch null else null, + .root_module = root_module, + .linkage = lib.linkage, + .max_rss = lib.max_rss, + .use_llvm = lib.use_llvm, + .use_lld = lib.use_lld, + .zig_lib_dir = if (lib.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + .win32_manifest = if (lib.win32_manifest) |d| self.resolveLazyPath(d) else null, + }); + + if (lib.linker_allow_shlib_undefined) |v| { + artifact.linker_allow_shlib_undefined = v; + } + + const install = self.b.addInstallArtifact(artifact, .{ + .dest_sub_path = if (lib.dest_sub_path) |p| @ptrCast(p) else null, + }); + + const tls_install = self.b.step( + self.b.fmt("build-lib:{s}", .{name}), + self.b.fmt("Install the {s} library", .{name}), + ); + tls_install.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + } + + fn createObject(self: *BuildRunner, name: []const u8, obj: Config.Object) !void { + const root_module = try self.resolveModuleLink(obj.root_module, name); + + const artifact = self.b.addObject(.{ + .name = name, + .root_module = root_module, + .max_rss = obj.max_rss, + .use_llvm = obj.use_llvm, + .use_lld = obj.use_lld, + .zig_lib_dir = if (obj.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + }); + + const install = self.b.addInstallArtifact(artifact, .{}); + const tls_install = self.b.step( + self.b.fmt("build-obj:{s}", .{name}), + self.b.fmt("Install the {s} object", .{name}), + ); + tls_install.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + } + + fn createTest(self: *BuildRunner, name: []const u8, t: Config.Test, tls_run_test: *std.Build.Step) !void { + const root_module = try self.resolveModuleLink(t.root_module, name); + + const filters_option = self.b.option( + []const []const u8, + self.b.fmt("{s}.filters", .{name}), + self.b.fmt("{s} test filters", .{name}), + ); + + const artifact = self.b.addTest(.{ + .name = name, + .root_module = root_module, + .max_rss = t.max_rss, + .use_llvm = t.use_llvm, + .use_lld = t.use_lld, + .zig_lib_dir = if (t.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + .filters = filters_option orelse if (t.filters.len > 0) t.filters else &.{}, + }); + + const install = self.b.addInstallArtifact(artifact, .{}); + const tls_install = self.b.step( + self.b.fmt("build-test:{s}", .{name}), + self.b.fmt("Install the {s} test", .{name}), + ); + tls_install.dependOn(&install.step); + + const run = self.b.addRunArtifact(artifact); + const tls_run = self.b.step( + self.b.fmt("test:{s}", .{name}), + self.b.fmt("Run the {s} test", .{name}), + ); + tls_run.dependOn(&run.step); + tls_run_test.dependOn(&run.step); + } + + fn createFmt(self: *BuildRunner, name: []const u8, fmt: Config.Fmt, tls_run_fmt: *std.Build.Step) !void { + const step = self.b.addFmt(.{ + .paths = fmt.paths orelse &.{}, + .exclude_paths = fmt.exclude_paths orelse &.{}, + .check = fmt.check orelse false, + }); + + const tls = self.b.step( + self.b.fmt("fmt:{s}", .{name}), + self.b.fmt("Run the {s} fmt", .{name}), + ); + tls.dependOn(&step.step); + tls_run_fmt.dependOn(&step.step); + } + + fn createRun(self: *BuildRunner, name: []const u8, cmd: Config.Run) void { + var args = std.ArrayList([]const u8).init(self.b.allocator); + // Simple shell command splitting (space-delimited) + var it = std.mem.splitScalar(u8, cmd, ' '); + while (it.next()) |arg| { + if (arg.len > 0) args.append(arg) catch @panic("OOM"); + } + + const run = self.b.addSystemCommand(args.items); + const tls = self.b.step( + self.b.fmt("run:{s}", .{name}), + self.b.fmt("Run the {s} command", .{name}), + ); + tls.dependOn(&run.step); + } + + fn wireImports(self: *BuildRunner, module: *std.Build.Module, imports: []const []const u8) !void { + for (imports) |import_name| { + const resolved = self.resolveImport(import_name); + module.addImport(import_name, resolved); + } + } + + fn resolveImport(self: *BuildRunner, import_name: []const u8) *std.Build.Module { + // Check named modules first + if (self.modules.get(import_name)) |m| return m; + // Check options modules + if (self.options_modules.get(import_name)) |m| return m; + // Check dependencies (possibly with dep:module syntax) + var parts = std.mem.splitScalar(u8, import_name, ':'); + const first = parts.first(); + if (self.dependencies.get(first)) |dep| { + const module_name = if (parts.next()) |rest| rest else first; + return dep.module(module_name); + } + @panic(self.b.fmt("zbuild: unresolved import '{s}'", .{import_name})); + } + + fn resolveLazyPath(self: *BuildRunner, path: []const u8) std.Build.LazyPath { + // For now, simple path resolution. Dependency lazy paths use colon syntax. + var parts = std.mem.splitScalar(u8, path, ':'); + const first = parts.first(); + if (self.dependencies.get(first)) |dep| { + const next = parts.next() orelse return dep.namedLazyPath(first); + if (parts.next()) |last| { + return dep.namedWriteFiles(next).getDirectory().path(self.b, last); + } + return dep.namedLazyPath(next); + } + return self.b.path(path); + } + + fn resolveTarget(self: *BuildRunner, target_str: []const u8) std.Build.ResolvedTarget { + if (std.mem.eql(u8, target_str, "native")) return self.target; + return self.b.resolveTargetQuery( + std.Target.Query.parse(.{ .arch_os_abi = target_str }) catch @panic("invalid target"), + ); + } +}; +``` + +**Important notes about this implementation:** +- Dependencies with custom args are not fully supported yet — the `b.dependency()` call passes target/optimize but not custom args. This matches the current behavior since dependency args from ZON are forwarded by the build system automatically. +- Options module type handling is simplified — enum/enum_list use string types at runtime since we can't create comptime enum types dynamically. This may need refinement later. +- The `resolveLazyPath` handles `dep:path` and `dep:writefiles:path` syntax like ConfigBuildgen did. + +- [ ] **Step 2: Build to verify it compiles** + +```bash +zig build +``` + +Expected: Compiles (build_runner.zig is not yet wired into any caller, just needs to parse correctly). + +- [ ] **Step 3: Commit** + +```bash +git add src/build_runner.zig +git commit -m "feat(phase-c): add build_runner.zig with configureBuild + +Direct std.Build API calls replace string-concatenation codegen. +~500 lines replaces ~1280 lines of ConfigBuildgen." +``` + +### Task 12: Update cmd_sync to use static build.zig template + +**Files:** +- Modify: `src/cmd_sync.zig` +- Modify: `src/cmd_init.zig` + +- [ ] **Step 1: Rewrite cmd_sync.zig** + +The sync command now just ensures `build.zig` has the static template. No more codegen. + +```zig +const std = @import("std"); +const mem = std.mem; +const Allocator = std.mem.Allocator; +const fatal = std.process.fatal; +const GlobalOptions = @import("GlobalOptions.zig"); +const Config = @import("Config.zig"); + +const static_build_zig = + \\const std = @import("std"); + \\const zbuild = @import("zbuild"); + \\ + \\pub fn build(b: *std.Build) void { + \\ zbuild.configureBuild(b) catch |err| { + \\ std.log.err("zbuild: {}", .{err}); + \\ }; + \\} + \\ +; + +pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config) !void { + _ = gpa; + _ = arena; + _ = config; + if (global_opts.no_sync) { + fatal("--no-sync is incompatible with the sync command", .{}); + } + + var opened_dir: ?std.fs.Dir = null; + defer if (opened_dir) |*d| d.close(); + + const dir = if (global_opts.project_dir.len > 0 and !mem.eql(u8, global_opts.project_dir, ".")) blk: { + opened_dir = try std.fs.cwd().openDir(global_opts.project_dir, .{}); + break :blk opened_dir.?; + } else std.fs.cwd(); + + dir.writeFile(.{ + .sub_path = "build.zig", + .data = static_build_zig, + }) catch |err| { + fatal("failed to write build.zig: {s}", .{@errorName(err)}); + }; +} +``` + +- [ ] **Step 2: Update cmd_init.zig to write static build.zig** + +In `cmd_init.zig`, after `sync.exec` is called, the static template gets written. The `sync.exec` now handles this. No further changes needed in cmd_init beyond what Phase A already did. + +- [ ] **Step 3: Expose configureBuild in main.zig public API** + +In `src/main.zig`, add: + +```zig +pub const build_runner = @import("build_runner.zig"); +pub const configureBuild = build_runner.configureBuild; +``` + +This lets user projects import `zbuild` and call `zbuild.configureBuild(b)`. + +- [ ] **Step 4: Build and verify** + +```bash +zig build +``` + +Expected: Compiles. The sync command now writes the static template. + +- [ ] **Step 5: Commit** + +```bash +git add src/cmd_sync.zig src/main.zig +git commit -m "refactor(phase-c): replace codegen with static build.zig template + +cmd_sync now writes a fixed build.zig that imports zbuild and calls +configureBuild. No more string concatenation, scratch buffers, or +zig fmt post-processing." +``` + +### Task 13: Delete ConfigBuildgen and sync_build_file + +**Files:** +- Delete: `src/ConfigBuildgen.zig` +- Delete: `src/sync_build_file.zig` +- Modify: `src/main.zig` (remove ConfigBuildgen import) + +- [ ] **Step 1: Remove imports from main.zig** + +In `src/main.zig:8`, remove: +```zig +pub const ConfigBuildgen = @import("ConfigBuildgen.zig"); +``` + +- [ ] **Step 2: Delete the files** + +```bash +rm src/ConfigBuildgen.zig src/sync_build_file.zig +``` + +- [ ] **Step 3: Remove run_zig imports from cmd_sync** + +cmd_sync no longer needs `runZigFmt`. Check that the new cmd_sync.zig doesn't import it (it shouldn't — we rewrote it in Task 12). + +- [ ] **Step 4: Build** + +```bash +zig build +``` + +Expected: Compiles cleanly. No dangling references to ConfigBuildgen or sync_build_file. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(phase-c): delete ConfigBuildgen.zig and sync_build_file.zig + +Eliminates ~1300 lines of string-concatenation codegen. Fixes bugs +1.6, 2.5, 2.6, 2.14, 3.7, 4.1, 4.3, 4.6, 5.5, 5.6, 5.10." +``` + +### Task 14: Update zbuild's own build system + +**Files:** +- Modify: `build.zig.zon` +- Modify: `build.zig` + +zbuild itself does NOT use the static build.zig pattern (that would be circular). It keeps a hand-written `build.zig`. But we need to update it since we removed ConfigBuildgen. + +- [ ] **Step 1: Write zbuild's own build.zig by hand** + +zbuild needs a hand-written `build.zig` that builds itself. The current generated one is close — we just need to maintain it manually now. Write `build.zig`: + +```zig +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // zbuild library module (for use by zbuild-powered projects) + const zbuild_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + b.modules.put(b.dupe("zbuild"), zbuild_module) catch @panic("OOM"); + + // zbuild executable + const exe = b.addExecutable(.{ + .name = "zbuild", + .root_module = zbuild_module, + }); + const install_exe = b.addInstallArtifact(exe, .{}); + b.getInstallStep().dependOn(&install_exe.step); + + const run_exe = b.addRunArtifact(exe); + if (b.args) |args| run_exe.addArgs(args); + const run_step = b.step("run:zbuild", "Run the zbuild executable"); + run_step.dependOn(&run_exe.step); + + // Tests + const tls_run_test = b.step("test", "Run all tests"); + + // zbuild unit tests + const test_zbuild = b.addTest(.{ + .name = "zbuild", + .root_module = zbuild_module, + .filters = b.option([]const []const u8, "zbuild.filters", "zbuild test filters") orelse &.{}, + }); + const run_test_zbuild = b.addRunArtifact(test_zbuild); + const tls_test_zbuild = b.step("test:zbuild", "Run the zbuild test"); + tls_test_zbuild.dependOn(&run_test_zbuild.step); + tls_run_test.dependOn(&run_test_zbuild.step); + + // sync integration test + const sync_module = b.createModule(.{ + .root_source_file = b.path("test/sync.zig"), + .target = target, + .optimize = optimize, + }); + sync_module.addImport("zbuild", zbuild_module); + + const test_sync = b.addTest(.{ + .name = "sync", + .root_module = sync_module, + .filters = b.option([]const []const u8, "sync.filters", "sync test filters") orelse &.{}, + }); + const run_test_sync = b.addRunArtifact(test_sync); + const tls_test_sync = b.step("test:sync", "Run the sync test"); + tls_test_sync.dependOn(&run_test_sync.step); + tls_run_test.dependOn(&run_test_sync.step); + + // parse fidelity test + const parse_test_module = b.createModule(.{ + .root_source_file = b.path("test/parse_test.zig"), + .target = target, + .optimize = optimize, + }); + parse_test_module.addImport("zbuild", zbuild_module); + + const test_parse = b.addTest(.{ + .name = "parse_test", + .root_module = parse_test_module, + .filters = b.option([]const []const u8, "parse_test.filters", "parse_test test filters") orelse &.{}, + }); + const run_test_parse = b.addRunArtifact(test_parse); + const tls_test_parse = b.step("test:parse_test", "Run the parse_test test"); + tls_test_parse.dependOn(&run_test_parse.step); + tls_run_test.dependOn(&run_test_parse.step); +} +``` + +- [ ] **Step 2: Run all tests** + +```bash +zig build test +``` + +Expected: All tests pass — sync tests (6 fixtures), parse fidelity tests (6 fixtures), zbuild unit tests. + +- [ ] **Step 3: Verify the sync test still works end-to-end** + +The sync test calls `zbuild.build.exec()` which calls `sync.exec()` which now writes the static template. Then it runs `zig build --help`. But wait — the static template imports `zbuild` as a dependency, and the test fixtures don't have zbuild as a dependency. This means `zig build --help` will fail because `@import("zbuild")` won't resolve. + +**This is a critical issue.** The static build.zig approach requires zbuild to be a dependency in the project's `build.zig.zon`. For the test fixtures, we have two options: + +A. Add zbuild as a path dependency to each fixture +B. Keep the sync test using the old approach where sync writes a standalone build.zig + +Option A is cleaner. Update each fixture to include: +```zon +.dependencies = .{ + .zbuild = .{ + .path = "../..", + }, +}, +``` + +But this means the fixtures have a `.dependencies` field that references the zbuild source tree. Let's verify this path would be correct — the fixtures are at `test/fixtures/basic*.build.zig.zon` and zbuild root is `../../` from there. + +Actually, looking at the test more carefully: `test/sync.zig:36-47` calls `zbuild.build.exec()` with `global_opts`, which runs `sync.exec` then `runZigBuild`. The `runZigBuild` runs `zig build --help` in the project dir (which is `test/`). So `build.zig` will be written to `test/build.zig` and `build.zig.zon` needs to be in `test/`. + +The current test creates `test/build.zig` and `test/build.zig.zon` from the fixture, then cleans up. With the static approach, `test/build.zig` will try to `@import("zbuild")` which needs zbuild as a dependency. + +**Resolution:** We'll update the test to generate build.zig.zon with the zbuild dependency included. The sync test becomes an integration test that proves the full pipeline works. + +This requires modifying the fixtures or the test setup. We'll add the zbuild path dependency to each fixture. + +- [ ] **Step 4: Update test fixtures with zbuild dependency** + +Add to each `test/fixtures/basic*.build.zig.zon`: + +```zon +.dependencies = .{ + .zbuild = .{ + .path = "../..", + }, +}, +``` + +For fixtures that already have `.dependencies = .{}`, replace the empty with the zbuild dep. + +- [ ] **Step 5: Update test/sync.zig** + +The test currently creates `test/build.zig.zon` by running the sync command. With the static approach, the sync command writes `test/build.zig` (the static template) but doesn't touch `build.zig.zon` — the fixture IS the `build.zig.zon`. + +Update the test to copy the fixture to `test/build.zig.zon` before syncing: + +```zig +fn testSync(gpa: Allocator, arena: Allocator, should_cleanup: bool, global_opts: zbuild.GlobalOptions) !void { + defer maybeCleanup(should_cleanup); + + const config = try zbuild.Config.parseFromFile(arena, global_opts.zbuild_file, null); + + // Copy the fixture to build.zig.zon in the project dir + const fixture_content = try std.fs.cwd().readFileAlloc(gpa, global_opts.zbuild_file, 16_000); + defer gpa.free(fixture_content); + + const dir = try std.fs.cwd().openDir(cwd, .{}); + try dir.writeFile(.{ + .sub_path = "build.zig.zon", + .data = fixture_content, + }); + + try zbuild.build.exec( + gpa, + arena, + global_opts, + config, + .{ + .kind = .build, + .args = &[1][]const u8{"--help"}, + .stderr_behavior = .Ignore, + .stdout_behavior = .Ignore, + }, + ); +} +``` + +Wait — this gets complicated because the test project dir is `test/` and the fixture files are also in `test/fixtures/`. The `sync.exec` will write `test/build.zig` (the static template). Then `zig build --help` runs in `test/` and needs `test/build.zig.zon` to have the zbuild dependency. + +Actually, re-reading the current flow: the `zbuild_file` is set to the fixture path like `test/fixtures/basic1.build.zig.zon`. The sync command reads this and generates `build.zig` in `project_dir` (which is `test/`). Previously, it also generated `build.zig.zon` in `test/` from the config. Now it just writes the static `build.zig`. + +For `zig build --help` to work, `test/build.zig.zon` must exist and if the static template does `@import("zbuild")`, it needs a zbuild dependency. So we need `test/build.zig.zon` to have the fixture content PLUS a zbuild dependency. + +This is getting complex. Let me simplify: **For zbuild's own testing, keep a hand-written build.zig that doesn't use the static template.** The static template is for USER projects that depend on zbuild. zbuild tests its own code directly. + +The sync test tests that the config is parseable and produces a valid build graph. We should refactor it to test `configureBuild` directly rather than going through the `zig build --help` pipeline. + +I'll adjust the plan accordingly. + +- [ ] **Step 4 (revised): Rewrite sync test to test configureBuild directly** + +This is a better approach. Instead of testing the full `zig build --help` pipeline (which requires zbuild-as-dependency), test that `configureBuild` produces a valid build graph. But `configureBuild` needs a real `std.Build` instance, which we can't easily create in a unit test. + +**Alternative:** Keep the E2E test but have it use zbuild's own hand-written `build.zig` (not the static template). The sync command for zbuild's own test directory simply generates `build.zig` using the old approach... but we deleted ConfigBuildgen. + +**Simplest solution:** The E2E test stays but the test project uses a hand-written `build.zig` that does `@import("zbuild").configureBuild(b)`. We set up `test/` as a mini project with its own `build.zig.zon` that depends on zbuild. + +Let me restructure: we'll create `test/build.zig.zon.template` that includes the zbuild path dependency, and the test will write this + the fixture fields to `test/build.zig.zon` before running `zig build --help`. + +Actually the simplest approach: since the static build.zig requires zbuild as a dependency, and we want to test it end-to-end, we need `test/build.zig.zon` to have `zbuild` as a dependency. Let's just make each test create a merged `build.zig.zon`: + +```zig +fn testSync(gpa: Allocator, arena: Allocator, should_cleanup: bool, global_opts: zbuild.GlobalOptions) !void { + defer maybeCleanup(should_cleanup); + + // Parse the fixture + const config = try zbuild.Config.parseFromFile(arena, global_opts.zbuild_file, null); + + // Write the static build.zig + try zbuild.sync.exec(gpa, arena, global_opts, config); + + // The fixture is already at the zbuild_file path. We need to also make it + // available as test/build.zig.zon with a zbuild dependency. + // For now, just copy fixture content and add zbuild dep. + // Actually - the simplest approach is to have each fixture include the zbuild dep already. + + try zbuild.build.exec( + gpa, arena, global_opts, config, + .{ + .kind = .build, + .args = &[1][]const u8{"--help"}, + .stderr_behavior = .Ignore, + .stdout_behavior = .Ignore, + }, + ); +} +``` + +Since we already decided to add `.dependencies = .{ .zbuild = .{ .path = "../.." } }` to each fixture, and the sync command writes `test/build.zig`, this should work. The `zbuild_file` points to the fixture, `project_dir` is `test/`, sync writes `test/build.zig`, and `zig build --help` runs in `test/` finding `test/build.zig` (static template) and `test/build.zig.zon` (which is... where?). + +The problem: `test/build.zig.zon` needs to be the fixture. Currently `sync.exec` used to write it. Now it doesn't. We need the test to copy the fixture to `test/build.zig.zon`. + +OK let me just add that to the test and keep it simple. + +- [ ] **Step 4 (final): Update fixtures and test** + +Add `.dependencies = .{ .zbuild = .{ .path = "../.." } }` to each of the 6 fixture files (or keep existing `.dependencies = .{}` for those that have it, but add zbuild). + +Update `test/sync.zig`: +```zig +fn testSync(gpa: Allocator, arena: Allocator, should_cleanup: bool, global_opts: zbuild.GlobalOptions) !void { + defer maybeCleanup(should_cleanup); + + const config = try zbuild.Config.parseFromFile(arena, global_opts.zbuild_file, null); + + // Copy fixture to test/build.zig.zon so zig build can find it + const fixture_content = try std.fs.cwd().readFileAllocOptions(gpa, global_opts.zbuild_file, 16_000, null, @alignOf(u8), null); + defer gpa.free(fixture_content); + const dir = try std.fs.cwd().openDir(cwd, .{}); + try dir.writeFile(.{ .sub_path = "build.zig.zon", .data = fixture_content }); + + try zbuild.build.exec( + gpa, arena, global_opts, config, + .{ + .kind = .build, + .args = &[1][]const u8{"--help"}, + .stderr_behavior = .Ignore, + .stdout_behavior = .Ignore, + }, + ); +} +``` + +- [ ] **Step 5: Run all tests** + +```bash +zig build test +``` + +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(phase-c): update zbuild build.zig and test fixtures for static template + +zbuild uses a hand-written build.zig (no circular dependency). Test +fixtures include zbuild as a path dependency for E2E testing." +``` + +### Task 15: Final verification and cleanup + +- [ ] **Step 1: Run the full test suite** + +```bash +zig build test +``` + +Expected: All tests pass. + +- [ ] **Step 2: Verify `zig build --help` on the zbuild project itself** + +```bash +zig build --help +``` + +Expected: Shows build steps for zbuild (run:zbuild, test, test:zbuild, test:sync, test:parse_test). + +- [ ] **Step 3: Count lines removed vs added** + +```bash +git diff --stat main +``` + +Expected: Significant net line reduction (~1400 lines). + +- [ ] **Step 4: Final commit if any cleanup needed** + +```bash +git add -A +git commit -m "refactor: final cleanup after three-phase architecture refactor" +``` + +--- + +## Summary of Bug Fixes + +| Bug | Description | Fixed By | +|-----|------------|----------| +| 2.1 | hash/lazy never parsed | Phase B: parseDependency now handles hash/lazy | +| 2.2 | Library.version not parsed | Phase B: inline for parses all fields | +| 2.3 | Test.test_runner not parsed | Phase B: explicit parseTest handler | +| 2.5 | depends_on parsed but never emitted | Phase C: configureBuild can implement step deps | +| 2.6 | Unused-variable detection incomplete | Phase C: eliminated (no generated variables) | +| 2.9 | description/keywords not in build.zig.zon | Phase A: single file, no translation | +| 2.10 | hash/lazy not serialized | Phase A: single file, no re-serialization needed | +| 2.11 | include_paths not freed | Phase B: arena allocation | +| 2.12 | No rollback on fetch failure | Phase A: no two-phase sync | +| 2.13 | updateConfigDependency uses wrong parser | Phase A: simplified cmd_fetch | +| 2.14 | writeImport wrong module ID | Phase C: direct API calls | +| 3.1 | Executable.dest_sub_path not freed | Phase B: arena allocation | +| 3.2 | Library.dest_sub_path not freed | Phase B: arena allocation | +| 3.3 | parseObject leaks field name | Phase B: arena allocation | +| 3.7 | zig fmt errors suppressed | Phase C: no fmt step | +| 3.10 | returnParseError leaks message | Phase B: arena allocation | +| 4.1 | Shared scratch buffer fragile | Phase C: eliminated | +| 4.3 | run step name collision | Phase C: detect and error | +| 4.5 | Two-phase sync ordering | Phase A: eliminated | +| 5.3 | Parser uses no reflection | Phase B: inline for + fromZoirNode | +| 5.5 | No codegen IR | Phase C: eliminated (no codegen) | +| 5.6 | writeImports type switch | Phase C: eliminated | +| 5.7 | deinit ceremony repeats | Phase B: arena allocation | +| 5.8 | Manifest parallel data model | Phase A: eliminated | +| 5.10 | strTupleLiteral cross-import | Phase A+C: eliminated | From c0e903000fbddd6fb73746f711df573b4c6ba519 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 13 Mar 2026 22:25:36 -0400 Subject: [PATCH 03/62] =?UTF-8?q?refactor(phase-a):=20merge=20zbuild.zon?= =?UTF-8?q?=20into=20build.zig.zon=20=E2=80=94=20single=20source=20of=20tr?= =?UTF-8?q?uth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename test fixtures from .zbuild.zon to .build.zig.zon - Default zbuild_file to build.zig.zon - Remove syncManifest from cmd_sync (no more manifest generation) - Remove Manifest.zig dependency from cmd_init and cmd_fetch - Delete sync_manifest.zig and Manifest.zig (parallel data model eliminated) - Merge zbuild.zon content into build.zig.zon, delete zbuild.zon - Simplify cmd_fetch to delegate entirely to zig fetch Fixes: 2.9, 2.10, 2.12, 2.13, 4.5, 5.8 Co-Authored-By: Claude Opus 4.6 --- build.zig.zon | 21 +- docs/STRUCTURAL_ISSUES.md | 282 +++++++ src/GlobalOptions.zig | 2 +- src/Manifest.zig | 781 ------------------ src/cmd_fetch.zig | 152 +--- src/cmd_init.zig | 5 +- src/cmd_sync.zig | 2 - src/main.zig | 2 +- src/sync_manifest.zig | 132 --- ...basic1.zbuild.zon => basic1.build.zig.zon} | 0 ...basic2.zbuild.zon => basic2.build.zig.zon} | 0 ...basic3.zbuild.zon => basic3.build.zig.zon} | 0 ...basic4.zbuild.zon => basic4.build.zig.zon} | 0 ...basic5.zbuild.zon => basic5.build.zig.zon} | 0 ...basic6.zbuild.zon => basic6.build.zig.zon} | 0 test/sync.zig | 12 +- zbuild.zon | 25 - 17 files changed, 312 insertions(+), 1104 deletions(-) create mode 100644 docs/STRUCTURAL_ISSUES.md delete mode 100644 src/Manifest.zig delete mode 100644 src/sync_manifest.zig rename test/fixtures/{basic1.zbuild.zon => basic1.build.zig.zon} (100%) rename test/fixtures/{basic2.zbuild.zon => basic2.build.zig.zon} (100%) rename test/fixtures/{basic3.zbuild.zon => basic3.build.zig.zon} (100%) rename test/fixtures/{basic4.zbuild.zon => basic4.build.zig.zon} (100%) rename test/fixtures/{basic5.zbuild.zon => basic5.build.zig.zon} (100%) rename test/fixtures/{basic6.zbuild.zon => basic6.build.zig.zon} (100%) delete mode 100644 zbuild.zon diff --git a/build.zig.zon b/build.zig.zon index 5a55a88..251e989 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,10 +1,25 @@ -// This file is generated by zbuild. Do not edit manually. - .{ .name = .zbuild, .version = "0.2.0", .fingerprint = 0x60f98ac2bf5a915c, .minimum_zig_version = "0.14.0", - .dependencies = .{}, .paths = .{ "build.zig", "build.zig.zon", "src" }, + .description = "An opinionated zig build tool", + .dependencies = .{}, + .executables = .{ + .zbuild = .{ + .root_module = .{ + .root_source_file = "src/main.zig", + }, + }, + }, + .tests = .{ + .sync = .{ + .root_module = .{ + .private = true, + .root_source_file = "test/sync.zig", + .imports = .{.zbuild}, + }, + }, + }, } diff --git a/docs/STRUCTURAL_ISSUES.md b/docs/STRUCTURAL_ISSUES.md new file mode 100644 index 0000000..3685fea --- /dev/null +++ b/docs/STRUCTURAL_ISSUES.md @@ -0,0 +1,282 @@ +# Structural Issues + +A comprehensive audit of the zbuild codebase covering bugs, architectural gaps, and design improvements. + +--- + +## 1. Critical — Crashes, compile failures, or data corruption + +### 1.1 `write_files` parser is a stub +**Config.zig:648-649** + +The top-level parse branch for `write_files` is an empty comment: +```zig +} else if (std.mem.eql(u8, field_name, "write_files")) { + // config.write_files = ; +} +``` +Any `write_files` in zbuild.zon is silently discarded. + +### 1.2 `parseWriteFile` / `parseWriteFilePath` won't compile +**Config.zig:768-771, 785, 787** + +`parseT` is called with wrong arity (missing `index` argument). The loop in `parseWriteFile` iterates `n.names` without capturing the value index, so `field_value` is unavailable. These functions are unreachable (due to 1.1) but would fail to compile if called. + +### 1.3 `ZigEnv` exit code check is always false +**ZigEnv.zig:33** + +```zig +if (result.term != .Exited and result.term.Exited != 0) { +``` +`!= .Exited AND .Exited != 0` can never be simultaneously true. Should be `or`. Zig errors (signal termination, non-zero exit) are silently ignored. + +### 1.4 `--no-sync` causes infinite loop +**GlobalOptions.zig:73-76** + +The flag is set and `continue`s without calling `args.next()`, so the same arg is re-read forever. + +### 1.5 `cmd_fetch` accesses wrong union variant +**cmd_fetch.zig:152** + +`new_dep.location.url` is accessed unconditionally. For `.path` dependencies this is a tagged-union safety panic. + +### 1.6 Missing `.` before `include_extensions` in format string +**ConfigBuildgen.zig:285** + +```zig +\\... .exclude_extensions = {s}, include_extensions = {s} ... +``` +Missing leading dot generates invalid Zig: `.{ .exclude_extensions = ..., include_extensions = ... }`. + +### 1.7 Fingerprint serialization bug +**Config.zig:1352-1353** + +`{x}` format on a `[]const u8` string emits byte-hex instead of the fingerprint value. Should use `{s}`. + +--- + +## 2. High — Silent data loss or incorrect behavior + +### 2.1 `hash` and `lazy` never parsed from dependencies +**Config.zig:690-716** + +`parseDependency` has no branches for `hash` or `lazy`. Both are silently ignored, breaking URL dependency hashing and lazy fetch. + +### 2.2 `Library.version` not parsed +**Config.zig:1017-1053** + +No parse branch for `"version"`. Always null after parsing. Compare with `parseExecutable` which handles it. + +### 2.3 `Test.test_runner` not parsed +**Config.zig:1085-1113** + +Field exists in the struct but has no parse branch. Also not emitted in codegen (`writeTest`). + +### 2.4 Module `private` logic is inverted +**ConfigBuildgen.zig:506** + +`private orelse true` means all modules are exported to `b.modules` by default. A field named `private` defaulting to true (= exported) is semantically backwards. + +### 2.5 `depends_on` parsed but never emitted +**ConfigBuildgen.zig (entire)** + +`Executable.depends_on`, `Library.depends_on`, `Object.depends_on` are all parsed and freed in `deinit` but no `step.dependOn(...)` call is ever generated. + +### 2.6 Unused-variable detection only scans top-level modules +**ConfigBuildgen.zig:151-208** + +The logic that marks `target`, `optimize`, `dep_*`, and `options_module_*` as unused only iterates `self.modules`. Projects using only inline modules in executables (a common pattern) will get false `_ = dep_foo;` emissions — which break compilation when the same dep is used in an import. + +### 2.7 Serializer has libraries/objects/tests/fmts/runs commented out +**Config.zig:1408-1442** + +Five major sections are dead code. `Config.serialize()` silently drops them. + +### 2.8 `enum`/`enum_list` options are TODO stubs in serializer +**Config.zig:1537-1546** + +Both branches are no-ops. Options of these types are silently dropped during re-serialization. + +### 2.9 `description` and `keywords` not written to `build.zig.zon` +**sync_manifest.zig:75-87** + +The manifest template has no placeholders for these fields. Dropped on every sync. + +### 2.10 `hash`/`lazy` not serialized for dependencies +**Config.zig:1446-1484** + +Even if parsed (they aren't — see 2.1), the serializer doesn't write them. URL dependency round-trips lose their hash. + +### 2.11 Memory leak: `Module.deinit` doesn't free `include_paths` +**Config.zig:303-315** + +`include_paths: ?[][]const u8` is parsed and used in codegen but never freed. Each string in the slice and the slice itself leak. + +### 2.12 No rollback on fetch failure during manifest sync +**sync_manifest.zig:40-64** + +`build.zig.zon` is written before `zig fetch` runs. If fetch fails mid-loop, the manifest is left in an inconsistent state. + +### 2.13 `updateConfigDependency` treats zbuild.zon as build.zig.zon +**cmd_fetch.zig:162-244** + +Uses `Manifest.load` which is a `build.zig.zon` parser. Will fail if zbuild.zon contains zbuild-specific fields. + +### 2.14 `writeImport` uses wrong module ID when inline module has `.name` +**ConfigBuildgen.zig:918-930** + +When an inline module specifies a `.name`, the import code references `module_{exe_key}` but the module was defined as `module_{module_name}`. + +--- + +## 3. Medium — Memory leaks, inconsistencies, incomplete features + +### 3.1 `Executable.deinit` doesn't free `dest_sub_path` +**Config.zig:346-356** + +### 3.2 `Library.deinit` doesn't free `dest_sub_path` +**Config.zig:376-386** + +### 3.3 `parseObject` leaks field name +**Config.zig:1060** + +`gpa.dupe(u8, name.get(self.zoir))` without free. All other parsers use `name.get(self.zoir)` directly. + +### 3.4 `wip_bundle` never deinit'd on success path +**main.zig:105-119** + +No `defer wip_bundle.deinit(gpa)` before the parse call. Memory leak every successful run. + +### 3.5 `ast.deinit` called before `manifest.deinit` +**sync_manifest.zig:33-38** + +Fragile ordering leaves `m.ast` as a dangling reference between the two deinit calls. + +### 3.6 Opened `Dir` handles never closed +**sync_manifest.zig:24-27, cmd_fetch.zig:106, sync_build_file.zig:17-19** + +File descriptor leaks when `out_dir` is non-null. + +### 3.7 `zig fmt` errors suppressed +**sync_build_file.zig:32-37** + +Both stderr and stdout are `.Ignore`. Codegen bugs produce unformatted invalid files with no diagnostic. + +### 3.8 `usage` and `list` functions are empty stubs +**cmd_build.zig:36-43** + +`--help`/`--list` for build commands print nothing. + +### 3.9 `Args.zig` test references non-existent function +**Args.zig:78** + +Calls `Args.parse` but the function is named `initFromString`. Test won't compile. + +### 3.10 `returnParseError` sets `.owned = false` on heap-allocated message +**Config.zig:1306-1319** + +`allocPrint`'d message passed with `.owned = false` leaks the string. + +### 3.11 `dependencies_node == 0` used as sentinel +**Manifest.zig:46** + +`0` is a valid AST node index. When no dependencies field exists, `getNodeSource(0)` returns the entire file source. + +--- + +## 4. Low — Cosmetic or fragile design + +### 4.1 Shared `scratch` buffer is fragile +**ConfigBuildgen.zig:1022** + +`fmtId`, `resolveLazyPath`, `resolvedTarget`, `optimize`, `semanticVersion`, etc. all write to one threadlocal 4096-byte buffer. No live bug currently but any reordering or nesting will silently corrupt output. + +### 4.2 Run step description says "Run the {name} run" +**ConfigBuildgen.zig:858** + +Redundant "run" in user-facing string. + +### 4.3 `run:{name}` step name collision +**ConfigBuildgen.zig** + +Executables and custom runs both generate `run:{name}` steps. Same-name causes a Zig build panic. + +### 4.4 `version` command prints Zig version, not zbuild version +**main.zig:88** + +### 4.5 Two-phase sync writes `build.zig` before `build.zig.zon` +**cmd_sync.zig:14-15** + +If manifest sync fails, `build.zig` references deps not in the manifest. + +### 4.6 `include_extensions` defaults to `null` while `exclude_extensions` defaults to `&.{}` +**ConfigBuildgen.zig:292** + +Inconsistent codegen for the two sibling fields. + +--- + +## 5. Architectural Issues + +### 5.1 `Config.zig` is a 1600-line mega-file with four responsibilities + +It contains the data model, the ZON parser (~730 lines), the serializer (~280 lines), and deinit logic. These could be separate modules sharing the type definitions. + +### 5.2 No shared "artifact type" abstraction + +`Executable`, `Library`, `Object`, and `Test` share ~80% of fields (`root_module`, `max_rss`, `use_llvm`, `use_lld`, `zig_lib_dir`, `depends_on`...) but are four independent structs. This produces: + +- Four near-identical `parseX` functions (manual `if/else if` chains over the same fields) +- Four near-identical `writeX` codegen functions (same field-emission boilerplate) +- Four near-identical `deinit` methods + +A `CompileTarget` base struct with comptime composition would collapse ~400 lines. + +### 5.3 Parser uses no reflection — every field is manually listed twice + +Each field appears once in the struct definition and once in a hand-rolled `if/else if (std.mem.eql(...))` parse chain. No compile-time check keeps them in sync. This is the root cause of 2.1, 2.2, 2.3, 2.5, and 2.8 — fields defined in the struct but missing from the parser or serializer. + +A comptime `inline for` over `@typeInfo(T).@"struct".fields` would eliminate this entire class of bug. + +### 5.4 Serializer mirrors the parser's problems + +Also a manual field-by-field emission, ~60% complete. Same comptime reflection fix applies. + +### 5.5 Codegen has no intermediate representation + +`ConfigBuildgen` writes directly to a `Writer` via string concatenation. No AST or IR for the output means: + +- The unused-variable detection (5.2, 2.6) is a heuristic scan of the input Config, not structural analysis of the output +- The scratch buffer (4.1) exists because there's no arena for temporary codegen strings +- Cross-references require ad-hoc symbol tables (`self.modules`, `self.dependencies`, etc.) + +### 5.6 `writeImports` generic is a type switch + +**ConfigBuildgen.zig:236-244** + +```zig +const imports = switch (T) { + Config.Module => item.imports orelse continue, + else => blk: { ... switch (item.root_module) { ... } }, +}; +``` + +A comptime generic that switches on its type parameter isn't generic — it's coupled to the specific types that exist today. + +### 5.7 `deinit` ceremony repeats ~9 times + +`Config.deinit` has the same 3-line map-cleanup pattern for every collection. A single `fn deinitMap(comptime V, ...)` would replace all instances. + +### 5.8 Manifest is a parallel data model + +`Manifest` (for `build.zig.zon`) and `Config.Dependency` (for `zbuild.zon`) are parallel representations of the same dependency data. The bridge function `depEql` and the AST-splicing hack in `allocPrintManifest` paper over the gap. + +### 5.9 Commands have no shared interface + +Each `cmd_*.zig` exports an `exec` with a compatible-but-not-enforced signature. No dispatch table, no `Command` interface, no comptime validation. Dispatch in `main.zig` is a flat `mem.eql` chain. + +### 5.10 `strTupleLiteral` imported across module boundaries + +**sync_manifest.zig:6** + +`sync_manifest` imports `strTupleLiteral` from `ConfigBuildgen.zig`, coupling manifest generation to build file generation. This helper belongs in a shared utility module. diff --git a/src/GlobalOptions.zig b/src/GlobalOptions.zig index 47862ba..181af38 100644 --- a/src/GlobalOptions.zig +++ b/src/GlobalOptions.zig @@ -49,7 +49,7 @@ pub fn parseArgs(allocator: Allocator, args: *Args) !GlobalOptions { .global_cache_dir = zig_env.global_cache_dir, .version = zig_env.version, .project_dir = try allocator.dupe(u8, "."), - .zbuild_file = try allocator.dupe(u8, "zbuild.zon"), + .zbuild_file = try allocator.dupe(u8, "build.zig.zon"), .no_sync = false, }; diff --git a/src/Manifest.zig b/src/Manifest.zig deleted file mode 100644 index 96a9b18..0000000 --- a/src/Manifest.zig +++ /dev/null @@ -1,781 +0,0 @@ -//! Mostly copy-pasted from zig/src/Package/Manifest.zig -const Manifest = @This(); -const std = @import("std"); -const mem = std.mem; -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const Ast = std.zig.Ast; -const Color = std.zig.Color; -const testing = std.testing; -const Package = @import("Package.zig"); - -pub const max_bytes = 10 * 1024 * 1024; -pub const max_name_len = 32; -pub const max_version_len = 32; - -pub const Dependency = struct { - location: Location, - location_tok: Ast.TokenIndex, - location_node: Ast.Node.Index, - hash: ?[]const u8, - hash_tok: Ast.TokenIndex, - hash_node: Ast.Node.Index, - node: Ast.Node.Index, - name_tok: Ast.TokenIndex, - lazy: bool, - - pub const Location = union(enum) { - url: []const u8, - path: []const u8, - }; -}; - -pub const ErrorMessage = struct { - msg: []const u8, - tok: Ast.TokenIndex, - off: u32, -}; - -ast: Ast, -name: []const u8, -name_node: Ast.Node.Index, -id: u32, -version: std.SemanticVersion, -version_node: Ast.Node.Index, -dependencies: std.StringArrayHashMapUnmanaged(Dependency), -dependencies_node: Ast.Node.Index, -paths: std.StringArrayHashMapUnmanaged(void), -minimum_zig_version: ?std.SemanticVersion, - -errors: []ErrorMessage, -arena_state: std.heap.ArenaAllocator.State, - -pub const ParseOptions = struct { - allow_missing_paths_field: bool = false, - /// Deprecated, to be removed after 0.14.0 is tagged. - allow_name_string: bool = true, - /// Deprecated, to be removed after 0.14.0 is tagged. - allow_missing_fingerprint: bool = true, -}; - -pub const Error = Allocator.Error; - -pub const LoadManifestOptions = struct { - dir: std.fs.Dir, - basename: []const u8, - color: Color, -}; - -pub fn load( - gpa: Allocator, - arena: Allocator, - options: LoadManifestOptions, -) !?Manifest { - const manifest_bytes = options.dir.readFileAllocOptions( - arena, - options.basename, - Manifest.max_bytes, - null, - 1, - 0, - ) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - var ast = try Ast.parse(gpa, manifest_bytes, .zon); - errdefer ast.deinit(gpa); - - if (ast.errors.len > 0) { - try std.zig.printAstErrorsToStderr(gpa, ast, options.basename, options.color); - return error.InvalidManifest; - } - - var manifest = try Manifest.parse(gpa, ast, .{}); - errdefer manifest.deinit(gpa); - - if (manifest.errors.len > 0) { - var wip_errors: std.zig.ErrorBundle.Wip = undefined; - try wip_errors.init(gpa); - defer wip_errors.deinit(); - - const src_path = try wip_errors.addString(options.basename); - try manifest.copyErrorsIntoBundle(ast, src_path, &wip_errors); - - var error_bundle = try wip_errors.toOwnedBundle(""); - defer error_bundle.deinit(gpa); - error_bundle.renderToStdErr(options.color.renderOptions()); - - return error.InvalidManifest; - } - return manifest; -} - -pub fn parse(gpa: Allocator, ast: Ast, options: ParseOptions) Error!Manifest { - const node_tags = ast.nodes.items(.tag); - const node_datas = ast.nodes.items(.data); - assert(node_tags[0] == .root); - const main_node_index = node_datas[0].lhs; - - var arena_instance = std.heap.ArenaAllocator.init(gpa); - errdefer arena_instance.deinit(); - - var p: Parse = .{ - .gpa = gpa, - .ast = ast, - .arena = arena_instance.allocator(), - .errors = .{}, - - .name = undefined, - .name_node = 0, - .id = 0, - .version = undefined, - .version_node = 0, - .dependencies = .{}, - .dependencies_node = 0, - .paths = .{}, - .allow_missing_paths_field = options.allow_missing_paths_field, - .allow_name_string = options.allow_name_string, - .allow_missing_fingerprint = options.allow_missing_fingerprint, - .minimum_zig_version = null, - .buf = .{}, - }; - defer p.buf.deinit(gpa); - defer p.errors.deinit(gpa); - defer p.dependencies.deinit(gpa); - defer p.paths.deinit(gpa); - - p.parseRoot(main_node_index) catch |err| switch (err) { - error.ParseFailure => assert(p.errors.items.len > 0), - else => |e| return e, - }; - - return .{ - .ast = ast, - .name = p.name, - .name_node = p.name_node, - .id = p.id, - .version = p.version, - .version_node = p.version_node, - .dependencies = try p.dependencies.clone(p.arena), - .dependencies_node = p.dependencies_node, - .paths = try p.paths.clone(p.arena), - .minimum_zig_version = p.minimum_zig_version, - .errors = try p.arena.dupe(ErrorMessage, p.errors.items), - .arena_state = arena_instance.state, - }; -} - -pub fn deinit(man: *Manifest, gpa: Allocator) void { - man.arena_state.promote(gpa).deinit(); - man.* = undefined; -} - -pub fn copyErrorsIntoBundle( - man: Manifest, - ast: Ast, - /// ErrorBundle null-terminated string index - src_path: u32, - eb: *std.zig.ErrorBundle.Wip, -) Allocator.Error!void { - const token_starts = ast.tokens.items(.start); - - for (man.errors) |msg| { - const start_loc = ast.tokenLocation(0, msg.tok); - - try eb.addRootErrorMessage(.{ - .msg = try eb.addString(msg.msg), - .src_loc = try eb.addSourceLocation(.{ - .src_path = src_path, - .span_start = token_starts[msg.tok], - .span_end = @intCast(token_starts[msg.tok] + ast.tokenSlice(msg.tok).len), - .span_main = token_starts[msg.tok] + msg.off, - .line = @intCast(start_loc.line), - .column = @intCast(start_loc.column), - .source_line = try eb.addString(ast.source[start_loc.line_start..start_loc.line_end]), - }), - }); - } -} - -const Parse = struct { - gpa: Allocator, - ast: Ast, - arena: Allocator, - buf: std.ArrayListUnmanaged(u8), - errors: std.ArrayListUnmanaged(ErrorMessage), - - name: []const u8, - name_node: Ast.Node.Index, - id: u32, - version: std.SemanticVersion, - version_node: Ast.Node.Index, - dependencies: std.StringArrayHashMapUnmanaged(Dependency), - dependencies_node: Ast.Node.Index, - paths: std.StringArrayHashMapUnmanaged(void), - allow_missing_paths_field: bool, - allow_name_string: bool, - allow_missing_fingerprint: bool, - minimum_zig_version: ?std.SemanticVersion, - - const InnerError = error{ ParseFailure, OutOfMemory }; - - fn parseRoot(p: *Parse, node: Ast.Node.Index) !void { - const ast = p.ast; - const main_tokens = ast.nodes.items(.main_token); - const main_token = main_tokens[node]; - - var buf: [2]Ast.Node.Index = undefined; - const struct_init = ast.fullStructInit(&buf, node) orelse { - return fail(p, main_token, "expected top level expression to be a struct", .{}); - }; - - var have_name = false; - var have_version = false; - var have_included_paths = false; - var fingerprint: ?Package.Fingerprint = null; - - for (struct_init.ast.fields) |field_init| { - const name_token = ast.firstToken(field_init) - 2; - const field_name = try identifierTokenString(p, name_token); - // We could get fancy with reflection and comptime logic here but doing - // things manually provides an opportunity to do any additional verification - // that is desirable on a per-field basis. - if (mem.eql(u8, field_name, "dependencies")) { - p.dependencies_node = field_init; - try parseDependencies(p, field_init); - } else if (mem.eql(u8, field_name, "paths")) { - have_included_paths = true; - try parseIncludedPaths(p, field_init); - } else if (mem.eql(u8, field_name, "name")) { - p.name_node = field_init; - p.name = try parseName(p, field_init); - have_name = true; - } else if (mem.eql(u8, field_name, "fingerprint")) { - fingerprint = try parseFingerprint(p, field_init); - } else if (mem.eql(u8, field_name, "version")) { - p.version_node = field_init; - const version_text = try parseString(p, field_init); - if (version_text.len > max_version_len) { - try appendError(p, main_tokens[field_init], "version string length {d} exceeds maximum of {d}", .{ version_text.len, max_version_len }); - } - p.version = std.SemanticVersion.parse(version_text) catch |err| v: { - try appendError(p, main_tokens[field_init], "unable to parse semantic version: {s}", .{@errorName(err)}); - break :v undefined; - }; - have_version = true; - } else if (mem.eql(u8, field_name, "minimum_zig_version")) { - const version_text = try parseString(p, field_init); - p.minimum_zig_version = std.SemanticVersion.parse(version_text) catch |err| v: { - try appendError(p, main_tokens[field_init], "unable to parse semantic version: {s}", .{@errorName(err)}); - break :v null; - }; - } else { - // Ignore unknown fields so that we can add fields in future zig - // versions without breaking older zig versions. - } - } - - if (!have_name) { - try appendError(p, main_token, "missing top-level 'name' field", .{}); - } else { - if (fingerprint) |n| { - if (!n.validate(p.name)) { - return fail(p, main_token, "invalid fingerprint: 0x{x}; if this is a new or forked package, use this value: 0x{x}", .{ - n.int(), Package.Fingerprint.generate(p.name).int(), - }); - } - p.id = n.id; - } else if (!p.allow_missing_fingerprint) { - try appendError(p, main_token, "missing top-level 'fingerprint' field; suggested value: 0x{x}", .{ - Package.Fingerprint.generate(p.name).int(), - }); - } else { - p.id = 0; - } - } - - if (!have_version) { - try appendError(p, main_token, "missing top-level 'version' field", .{}); - } - - if (!have_included_paths) { - if (p.allow_missing_paths_field) { - try p.paths.put(p.gpa, "", {}); - } else { - try appendError(p, main_token, "missing top-level 'paths' field", .{}); - } - } - } - - fn parseDependencies(p: *Parse, node: Ast.Node.Index) !void { - const ast = p.ast; - const main_tokens = ast.nodes.items(.main_token); - - var buf: [2]Ast.Node.Index = undefined; - const struct_init = ast.fullStructInit(&buf, node) orelse { - const tok = main_tokens[node]; - return fail(p, tok, "expected dependencies expression to be a struct", .{}); - }; - - for (struct_init.ast.fields) |field_init| { - const name_token = ast.firstToken(field_init) - 2; - const dep_name = try identifierTokenString(p, name_token); - const dep = try parseDependency(p, field_init); - try p.dependencies.put(p.gpa, dep_name, dep); - } - } - - fn parseDependency(p: *Parse, node: Ast.Node.Index) !Dependency { - const ast = p.ast; - const main_tokens = ast.nodes.items(.main_token); - - var buf: [2]Ast.Node.Index = undefined; - const struct_init = ast.fullStructInit(&buf, node) orelse { - const tok = main_tokens[node]; - return fail(p, tok, "expected dependency expression to be a struct", .{}); - }; - - var dep: Dependency = .{ - .location = undefined, - .location_tok = 0, - .location_node = undefined, - .hash = null, - .hash_tok = 0, - .hash_node = undefined, - .node = node, - .name_tok = 0, - .lazy = false, - }; - var has_location = false; - - for (struct_init.ast.fields) |field_init| { - const name_token = ast.firstToken(field_init) - 2; - dep.name_tok = name_token; - const field_name = try identifierTokenString(p, name_token); - // We could get fancy with reflection and comptime logic here but doing - // things manually provides an opportunity to do any additional verification - // that is desirable on a per-field basis. - if (mem.eql(u8, field_name, "url")) { - if (has_location) { - return fail(p, main_tokens[field_init], "dependency should specify only one of 'url' and 'path' fields.", .{}); - } - dep.location = .{ - .url = parseString(p, field_init) catch |err| switch (err) { - error.ParseFailure => continue, - else => |e| return e, - }, - }; - has_location = true; - dep.location_tok = main_tokens[field_init]; - dep.location_node = field_init; - } else if (mem.eql(u8, field_name, "path")) { - if (has_location) { - return fail(p, main_tokens[field_init], "dependency should specify only one of 'url' and 'path' fields.", .{}); - } - dep.location = .{ - .path = parseString(p, field_init) catch |err| switch (err) { - error.ParseFailure => continue, - else => |e| return e, - }, - }; - has_location = true; - dep.location_tok = main_tokens[field_init]; - dep.location_node = field_init; - } else if (mem.eql(u8, field_name, "hash")) { - dep.hash = parseHash(p, field_init) catch |err| switch (err) { - error.ParseFailure => continue, - else => |e| return e, - }; - dep.hash_tok = main_tokens[field_init]; - dep.hash_node = field_init; - } else if (mem.eql(u8, field_name, "lazy")) { - dep.lazy = parseBool(p, field_init) catch |err| switch (err) { - error.ParseFailure => continue, - else => |e| return e, - }; - } else { - // Ignore unknown fields so that we can add fields in future zig - // versions without breaking older zig versions. - } - } - - if (!has_location) { - try appendError(p, main_tokens[node], "dependency requires location field, one of 'url' or 'path'.", .{}); - } - - return dep; - } - - fn parseIncludedPaths(p: *Parse, node: Ast.Node.Index) !void { - const ast = p.ast; - const main_tokens = ast.nodes.items(.main_token); - - var buf: [2]Ast.Node.Index = undefined; - const array_init = ast.fullArrayInit(&buf, node) orelse { - const tok = main_tokens[node]; - return fail(p, tok, "expected paths expression to be a list of strings", .{}); - }; - - for (array_init.ast.elements) |elem_node| { - const path_string = try parseString(p, elem_node); - // This is normalized so that it can be used in string comparisons - // against file system paths. - const normalized = try std.fs.path.resolve(p.arena, &.{path_string}); - try p.paths.put(p.gpa, normalized, {}); - } - } - - fn parseBool(p: *Parse, node: Ast.Node.Index) !bool { - const ast = p.ast; - const node_tags = ast.nodes.items(.tag); - const main_tokens = ast.nodes.items(.main_token); - if (node_tags[node] != .identifier) { - return fail(p, main_tokens[node], "expected identifier", .{}); - } - const ident_token = main_tokens[node]; - const token_bytes = ast.tokenSlice(ident_token); - if (mem.eql(u8, token_bytes, "true")) { - return true; - } else if (mem.eql(u8, token_bytes, "false")) { - return false; - } else { - return fail(p, ident_token, "expected boolean", .{}); - } - } - - fn parseFingerprint(p: *Parse, node: Ast.Node.Index) !Package.Fingerprint { - const ast = p.ast; - const node_tags = ast.nodes.items(.tag); - const main_tokens = ast.nodes.items(.main_token); - const main_token = main_tokens[node]; - if (node_tags[node] != .number_literal) { - return fail(p, main_token, "expected integer literal", .{}); - } - const token_bytes = ast.tokenSlice(main_token); - const parsed = std.zig.parseNumberLiteral(token_bytes); - switch (parsed) { - .int => |n| return @bitCast(n), - .big_int, .float => return fail(p, main_token, "expected u64 integer literal, found {s}", .{ - @tagName(parsed), - }), - .failure => |err| return fail(p, main_token, "bad integer literal: {s}", .{@tagName(err)}), - } - } - - fn parseName(p: *Parse, node: Ast.Node.Index) ![]const u8 { - const ast = p.ast; - const node_tags = ast.nodes.items(.tag); - const main_tokens = ast.nodes.items(.main_token); - const main_token = main_tokens[node]; - - if (p.allow_name_string and node_tags[node] == .string_literal) { - const name = try parseString(p, node); - if (!std.zig.isValidId(name)) - return fail(p, main_token, "name must be a valid bare zig identifier (hint: switch from string to enum literal)", .{}); - - if (name.len > max_name_len) - return fail(p, main_token, "name '{}' exceeds max length of {d}", .{ - std.zig.fmtId(name), max_name_len, - }); - - return name; - } - - if (node_tags[node] != .enum_literal) - return fail(p, main_token, "expected enum literal", .{}); - - const ident_name = ast.tokenSlice(main_token); - if (mem.startsWith(u8, ident_name, "@")) - return fail(p, main_token, "name must be a valid bare zig identifier", .{}); - - if (ident_name.len > max_name_len) - return fail(p, main_token, "name '{}' exceeds max length of {d}", .{ - std.zig.fmtId(ident_name), max_name_len, - }); - - return ident_name; - } - - fn parseString(p: *Parse, node: Ast.Node.Index) ![]const u8 { - const ast = p.ast; - const node_tags = ast.nodes.items(.tag); - const main_tokens = ast.nodes.items(.main_token); - if (node_tags[node] != .string_literal) { - return fail(p, main_tokens[node], "expected string literal", .{}); - } - const str_lit_token = main_tokens[node]; - const token_bytes = ast.tokenSlice(str_lit_token); - p.buf.clearRetainingCapacity(); - try parseStrLit(p, str_lit_token, &p.buf, token_bytes, 0); - const duped = try p.arena.dupe(u8, p.buf.items); - return duped; - } - - fn parseHash(p: *Parse, node: Ast.Node.Index) ![]const u8 { - const ast = p.ast; - const main_tokens = ast.nodes.items(.main_token); - const tok = main_tokens[node]; - const h = try parseString(p, node); - - if (h.len > Package.Hash.max_len) { - return fail(p, tok, "hash length exceeds maximum: {d}", .{h.len}); - } - - return h; - } - - /// TODO: try to DRY this with AstGen.identifierTokenString - fn identifierTokenString(p: *Parse, token: Ast.TokenIndex) InnerError![]const u8 { - const ast = p.ast; - const token_tags = ast.tokens.items(.tag); - assert(token_tags[token] == .identifier); - const ident_name = ast.tokenSlice(token); - if (!mem.startsWith(u8, ident_name, "@")) { - return ident_name; - } - p.buf.clearRetainingCapacity(); - try parseStrLit(p, token, &p.buf, ident_name, 1); - const duped = try p.arena.dupe(u8, p.buf.items); - return duped; - } - - /// TODO: try to DRY this with AstGen.parseStrLit - fn parseStrLit( - p: *Parse, - token: Ast.TokenIndex, - buf: *std.ArrayListUnmanaged(u8), - bytes: []const u8, - offset: u32, - ) InnerError!void { - const raw_string = bytes[offset..]; - var buf_managed = buf.toManaged(p.gpa); - const result = std.zig.string_literal.parseWrite(buf_managed.writer(), raw_string); - buf.* = buf_managed.moveToUnmanaged(); - switch (try result) { - .success => {}, - .failure => |err| try p.appendStrLitError(err, token, bytes, offset), - } - } - - /// TODO: try to DRY this with AstGen.failWithStrLitError - fn appendStrLitError( - p: *Parse, - err: std.zig.string_literal.Error, - token: Ast.TokenIndex, - bytes: []const u8, - offset: u32, - ) Allocator.Error!void { - const raw_string = bytes[offset..]; - switch (err) { - .invalid_escape_character => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "invalid escape character: '{c}'", - .{raw_string[bad_index]}, - ); - }, - .expected_hex_digit => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "expected hex digit, found '{c}'", - .{raw_string[bad_index]}, - ); - }, - .empty_unicode_escape_sequence => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "empty unicode escape sequence", - .{}, - ); - }, - .expected_hex_digit_or_rbrace => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "expected hex digit or '}}', found '{c}'", - .{raw_string[bad_index]}, - ); - }, - .invalid_unicode_codepoint => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "unicode escape does not correspond to a valid unicode scalar value", - .{}, - ); - }, - .expected_lbrace => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "expected '{{', found '{c}", - .{raw_string[bad_index]}, - ); - }, - .expected_rbrace => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "expected '}}', found '{c}", - .{raw_string[bad_index]}, - ); - }, - .expected_single_quote => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "expected single quote ('), found '{c}", - .{raw_string[bad_index]}, - ); - }, - .invalid_character => |bad_index| { - try p.appendErrorOff( - token, - offset + @as(u32, @intCast(bad_index)), - "invalid byte in string or character literal: '{c}'", - .{raw_string[bad_index]}, - ); - }, - .empty_char_literal => { - try p.appendErrorOff(token, offset, "empty character literal", .{}); - }, - } - } - - fn fail( - p: *Parse, - tok: Ast.TokenIndex, - comptime fmt: []const u8, - args: anytype, - ) InnerError { - try appendError(p, tok, fmt, args); - return error.ParseFailure; - } - - fn appendError(p: *Parse, tok: Ast.TokenIndex, comptime fmt: []const u8, args: anytype) !void { - return appendErrorOff(p, tok, 0, fmt, args); - } - - fn appendErrorOff( - p: *Parse, - tok: Ast.TokenIndex, - byte_offset: u32, - comptime fmt: []const u8, - args: anytype, - ) Allocator.Error!void { - try p.errors.append(p.gpa, .{ - .msg = try std.fmt.allocPrint(p.arena, fmt, args), - .tok = tok, - .off = byte_offset, - }); - } -}; - -test "basic" { - const gpa = testing.allocator; - - const example = - \\.{ - \\ .name = "foo", - \\ .version = "3.2.1", - \\ .paths = .{""}, - \\ .dependencies = .{ - \\ .bar = .{ - \\ .url = "https://example.com/baz.tar.gz", - \\ .hash = "1220f1b680b6065fcfc94fe777f22e73bcb7e2767e5f4d99d4255fe76ded69c7a35f", - \\ }, - \\ }, - \\} - ; - - var ast = try Ast.parse(gpa, example, .zon); - defer ast.deinit(gpa); - - try testing.expect(ast.errors.len == 0); - - var manifest = try Manifest.parse(gpa, ast, .{}); - defer manifest.deinit(gpa); - - try testing.expect(manifest.errors.len == 0); - try testing.expectEqualStrings("foo", manifest.name); - - try testing.expectEqual(@as(std.SemanticVersion, .{ - .major = 3, - .minor = 2, - .patch = 1, - }), manifest.version); - - try testing.expect(manifest.dependencies.count() == 1); - try testing.expectEqualStrings("bar", manifest.dependencies.keys()[0]); - try testing.expectEqualStrings( - "https://example.com/baz.tar.gz", - manifest.dependencies.values()[0].location.url, - ); - try testing.expectEqualStrings( - "1220f1b680b6065fcfc94fe777f22e73bcb7e2767e5f4d99d4255fe76ded69c7a35f", - manifest.dependencies.values()[0].hash orelse return error.TestFailed, - ); - - try testing.expect(manifest.minimum_zig_version == null); -} - -test "minimum_zig_version" { - const gpa = testing.allocator; - - const example = - \\.{ - \\ .name = "foo", - \\ .version = "3.2.1", - \\ .paths = .{""}, - \\ .minimum_zig_version = "0.11.1", - \\} - ; - - var ast = try Ast.parse(gpa, example, .zon); - defer ast.deinit(gpa); - - try testing.expect(ast.errors.len == 0); - - var manifest = try Manifest.parse(gpa, ast, .{}); - defer manifest.deinit(gpa); - - try testing.expect(manifest.errors.len == 0); - try testing.expect(manifest.dependencies.count() == 0); - - try testing.expect(manifest.minimum_zig_version != null); - - try testing.expectEqual(@as(std.SemanticVersion, .{ - .major = 0, - .minor = 11, - .patch = 1, - }), manifest.minimum_zig_version.?); -} - -test "minimum_zig_version - invalid version" { - const gpa = testing.allocator; - - const example = - \\.{ - \\ .name = "foo", - \\ .version = "3.2.1", - \\ .minimum_zig_version = "X.11.1", - \\ .paths = .{""}, - \\} - ; - - var ast = try Ast.parse(gpa, example, .zon); - defer ast.deinit(gpa); - - try testing.expect(ast.errors.len == 0); - - var manifest = try Manifest.parse(gpa, ast, .{}); - defer manifest.deinit(gpa); - - try testing.expect(manifest.errors.len == 1); - try testing.expect(manifest.dependencies.count() == 0); - - try testing.expect(manifest.minimum_zig_version == null); -} diff --git a/src/cmd_fetch.zig b/src/cmd_fetch.zig index 0c21796..104138c 100644 --- a/src/cmd_fetch.zig +++ b/src/cmd_fetch.zig @@ -1,8 +1,6 @@ const std = @import("std"); const GlobalOptions = @import("GlobalOptions.zig"); const Args = @import("Args.zig"); -const Config = @import("Config.zig"); -const Manifest = @import("Manifest.zig"); const runZigFetch = @import("run_zig.zig").runZigFetch; const Save = @import("run_zig.zig").ZigCmd.Fetch.Save; const mem = std.mem; @@ -85,31 +83,6 @@ pub fn exec( global_opts: GlobalOptions, opts: Opts, ) !void { - switch (opts.save) { - .no => { - // just run zig fetch, no need to update the config - try runZigFetch( - gpa, - arena, - .{ .cwd = global_opts.project_dir }, - global_opts.getZigEnv(), - opts.path_or_url, - opts.save, - ); - return cleanExit(); - }, - else => {}, - } - - const zbuild_filename = try std.fs.path.join(gpa, &[_][]const u8{ global_opts.project_dir, global_opts.zbuild_file }); - defer gpa.free(zbuild_filename); - const manifest_dir = try std.fs.cwd().openDir(global_opts.project_dir, .{}); - // if the name is known, we can update the config without loading the manifest - // we first parse the manifest, run zig fetch, and then compare which dependency updated - // then we update the config with the new dependency - var old_manifest = try Manifest.load(gpa, arena, .{ .color = .auto, .dir = manifest_dir, .basename = "build.zig.zon" }) orelse fatal("failed to load manifest", .{}); - defer old_manifest.deinit(gpa); - try runZigFetch( gpa, arena, @@ -118,128 +91,7 @@ pub fn exec( opts.path_or_url, opts.save, ); - - var new_manifest = try Manifest.load(gpa, arena, .{ .color = .auto, .dir = manifest_dir, .basename = "build.zig.zon" }) orelse fatal("failed to load manifest", .{}); - defer new_manifest.deinit(gpa); - - for (new_manifest.dependencies.keys(), new_manifest.dependencies.values()) |n, new_dep| { - switch (new_dep.location) { - .url => |new_url| { - if (old_manifest.dependencies.get(n)) |old_dep| { - if (old_dep.location == .url) { - if (mem.eql(u8, new_url, old_dep.location.url)) { - continue; - } - } - } - }, - .path => |new_path| { - if (old_manifest.dependencies.get(n)) |old_dep| { - if (old_dep.location == .path) { - if (mem.eql(u8, new_path, old_dep.location.path)) { - continue; - } - } - } - }, - } - try updateConfigDependency( - gpa, - arena, - manifest_dir, - global_opts.zbuild_file, - n, - new_dep.location.url, - new_dep.hash, - ); - break; + if (opts.save == .no) { + return cleanExit(); } - return cleanExit(); -} - -// Mostly copied from zig/src/main.zig -// Allows us to update the config file with the new dependency without affecting comments and existing user formatting -fn updateConfigDependency( - gpa: Allocator, - arena: Allocator, - dir: std.fs.Dir, - zbuild_file: []const u8, - dep_name: []const u8, - saved_path_or_url: []const u8, - package_hash_slice: ?[]const u8, -) !void { - var manifest = Manifest.load( - gpa, - arena, - .{ - .color = .auto, - .dir = dir, - .basename = zbuild_file, - }, - ) catch |err| { - fatal("unable to open {s} file: {s}", .{ zbuild_file, @errorName(err) }); - } orelse fatal("{s} file not found", .{zbuild_file}); - defer manifest.deinit(gpa); - - var fixups: std.zig.Ast.Fixups = .{}; - defer fixups.deinit(gpa); - - const new_node_init = - try std.fmt.allocPrint(arena, - \\.{{ - \\ .url = "{}", - \\ }} - , .{ - std.zig.fmtEscapes(saved_path_or_url), - }); - - const new_node_text = try std.fmt.allocPrint(arena, ".{p_} = {s},\n", .{ - std.zig.fmtId(dep_name), new_node_init, - }); - - const dependencies_init = try std.fmt.allocPrint(arena, ".{{\n {s} }}", .{ - new_node_text, - }); - - const dependencies_text = try std.fmt.allocPrint(arena, ".dependencies = {s},\n", .{ - dependencies_init, - }); - - if (manifest.dependencies.get(dep_name)) |dep| { - const location_replace = try std.fmt.allocPrint( - arena, - "\"{}\"", - .{std.zig.fmtEscapes(saved_path_or_url)}, - ); - try fixups.replace_nodes_with_string.put(gpa, dep.location_node, location_replace); - - if (package_hash_slice) |hash| { - const hash_replace = try std.fmt.allocPrint( - arena, - "\"{}\"", - .{std.zig.fmtEscapes(hash)}, - ); - - try fixups.replace_nodes_with_string.put(gpa, dep.hash_node, hash_replace); - } - } else if (manifest.dependencies.count() > 0) { - // Add fixup for adding another dependency. - const deps = manifest.dependencies.values(); - const last_dep_node = deps[deps.len - 1].node; - try fixups.append_string_after_node.put(gpa, last_dep_node, new_node_text); - } else if (manifest.dependencies_node != 0) { - // Add fixup for replacing the entire dependencies struct. - try fixups.replace_nodes_with_string.put(gpa, manifest.dependencies_node, dependencies_init); - } else { - // Add fixup for adding dependencies struct. - try fixups.append_string_after_node.put(gpa, manifest.version_node, dependencies_text); - } - - var rendered = std.ArrayList(u8).init(gpa); - defer rendered.deinit(); - try manifest.ast.renderToArrayList(&rendered, fixups); - - dir.writeFile(.{ .sub_path = zbuild_file, .data = rendered.items }) catch |err| { - fatal("unable to write {s} file: {s}", .{ zbuild_file, @errorName(err) }); - }; } diff --git a/src/cmd_init.zig b/src/cmd_init.zig index 1406086..3e19c57 100644 --- a/src/cmd_init.zig +++ b/src/cmd_init.zig @@ -5,7 +5,6 @@ const GlobalOptions = @import("GlobalOptions.zig"); const Config = @import("Config.zig"); const sync = @import("cmd_sync.zig"); const Package = @import("Package.zig"); -const Manifest = @import("Manifest.zig"); pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions) !void { const cwd = try std.fs.cwd().realpathAlloc(gpa, global_opts.project_dir); @@ -91,8 +90,8 @@ fn sanitizeExampleName(arena: Allocator, bytes: []const u8) error{OutOfMemory}![ else => continue, }; if (!std.zig.isValidId(result.items)) return "foo"; - if (result.items.len > Manifest.max_name_len) - result.shrinkRetainingCapacity(Manifest.max_name_len); + if (result.items.len > 64) + result.shrinkRetainingCapacity(64); return result.toOwnedSlice(arena); } diff --git a/src/cmd_sync.zig b/src/cmd_sync.zig index a8d1c5e..d589bec 100644 --- a/src/cmd_sync.zig +++ b/src/cmd_sync.zig @@ -5,12 +5,10 @@ const fatal = std.process.fatal; const GlobalOptions = @import("GlobalOptions.zig"); const Config = @import("Config.zig"); const syncBuildFile = @import("sync_build_file.zig").syncBuildFile; -const syncManifest = @import("sync_manifest.zig").syncManifest; pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config) !void { if (global_opts.no_sync) { fatal("--no-sync is incompatible with the sync command", .{}); } try syncBuildFile(gpa, arena, config, global_opts, .{ .out_dir = global_opts.project_dir }); - try syncManifest(gpa, arena, global_opts, config, .{ .out_dir = global_opts.project_dir }); } diff --git a/src/main.zig b/src/main.zig index 39226e3..75f0e8e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -106,7 +106,7 @@ fn mainArgs(gpa: Allocator, arena: Allocator, args: *Args) !void { try wip_bundle.init(gpa); const config = Config.parseFromFile(arena, global_opts.zbuild_file, &wip_bundle) catch |err| switch (err) { error.FileNotFound => { - fatal("no zbuild file found", .{}); + fatal("no build.zig.zon file found", .{}); }, error.OutOfMemory => { fatal("out of memory", .{}); diff --git a/src/sync_manifest.zig b/src/sync_manifest.zig deleted file mode 100644 index e9f64fc..0000000 --- a/src/sync_manifest.zig +++ /dev/null @@ -1,132 +0,0 @@ -const std = @import("std"); -const fatal = std.process.fatal; -const builtin = @import("builtin"); -const Manifest = @import("Manifest.zig"); -const GlobalOptions = @import("GlobalOptions.zig"); -const strTupleLiteral = @import("ConfigBuildgen.zig").strTupleLiteral; -const runZigFetch = @import("run_zig.zig").runZigFetch; - -const mem = std.mem; -const Allocator = mem.Allocator; -const Ast = std.zig.Ast; -const Color = std.zig.Color; - -pub const SyncManifestOpts = struct { - out_dir: ?[]const u8 = null, -}; - -pub fn syncManifest(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config, opts: SyncManifestOpts) !void { - // naive strategy for now - // fetch existing manifest (if any) - // write new manifest based on config (except for dependencies, which get copied over from existing manifest) - // if any dependencies are different or added, call zig fetch on them - - const build_root_directory = if (opts.out_dir) |manifest_dir| - try std.fs.cwd().openDir(manifest_dir, .{}) - else - std.fs.cwd(); - var manifest = try Manifest.load(gpa, arena, .{ - .dir = build_root_directory, - .basename = "build.zig.zon", - .color = .auto, - }); - defer { - if (manifest) |*m| { - m.ast.deinit(gpa); - m.deinit(gpa); - } - } - - const new_manifest_bytes = try allocPrintManifest(gpa, config, manifest); - defer gpa.free(new_manifest_bytes); - try build_root_directory.writeFile(.{ - .sub_path = "build.zig.zon", - .data = new_manifest_bytes, - }); - if (config.dependencies) |dependencies| { - for (dependencies.keys(), dependencies.values()) |name, config_dep| { - if (manifest) |m| { - if (m.dependencies.get(name)) |manifest_dep| { - if (depEql(manifest_dep, config_dep)) { - continue; - } - } - } - try runZigFetch( - gpa, - arena, - .{ .cwd = global_opts.project_dir }, - global_opts.getZigEnv(), - config_dep.value, - .{ .exact = name }, - ); - } - } -} - -pub const LoadManifestOptions = struct { - dir: std.fs.Dir, - basename: []const u8, - color: Color, -}; - -const Config = @import("Config.zig"); - -const manifest_template = - \\// This file is generated by zbuild. Do not edit manually. - \\ - \\.{{ - \\ .name = .{s}, - \\ .version = "{s}", - \\ .fingerprint = {s}, - \\ .minimum_zig_version = "{s}", - \\ .dependencies = {s}, - \\ .paths = {s}, - \\}} - \\ -; - -fn allocPrintManifest(allocator: Allocator, config: Config, manifest: ?Manifest) ![]const u8 { - // TODO fix this - const paths: ?[][]const u8 = config.paths; - return try std.fmt.allocPrint(allocator, manifest_template, .{ - config.name, - config.version, - config.fingerprint, - config.minimum_zig_version, - if (manifest) |m| - m.ast.getNodeSource(m.dependencies_node) - else - ".{}", - try strTupleLiteral(paths) orelse - \\.{ "build.zig", "build.zig.zon", "src" } - , - }); -} - -fn depEql(manifest_dep: Manifest.Dependency, config_dep: Config.Dependency) bool { - if (config_dep.typ == .path and manifest_dep.location != .path) { - return false; - } - if (config_dep.typ == .url and manifest_dep.location != .url) { - return false; - } - const manifest_path_or_url = switch (manifest_dep.location) { - .path => |path| path, - .url => |url| url, - }; - const config_path_or_url = config_dep.value; - const manifest_hash = manifest_dep.hash; - const config_hash = config_dep.hash; - - if (!std.mem.eql(u8, manifest_path_or_url, config_path_or_url)) { - return false; - } - if (config_hash == null) { - return true; - } - if (manifest_hash == null) { - return false; - } - return std.mem.eql(u8, manifest_hash.?, config_hash.?); -} diff --git a/test/fixtures/basic1.zbuild.zon b/test/fixtures/basic1.build.zig.zon similarity index 100% rename from test/fixtures/basic1.zbuild.zon rename to test/fixtures/basic1.build.zig.zon diff --git a/test/fixtures/basic2.zbuild.zon b/test/fixtures/basic2.build.zig.zon similarity index 100% rename from test/fixtures/basic2.zbuild.zon rename to test/fixtures/basic2.build.zig.zon diff --git a/test/fixtures/basic3.zbuild.zon b/test/fixtures/basic3.build.zig.zon similarity index 100% rename from test/fixtures/basic3.zbuild.zon rename to test/fixtures/basic3.build.zig.zon diff --git a/test/fixtures/basic4.zbuild.zon b/test/fixtures/basic4.build.zig.zon similarity index 100% rename from test/fixtures/basic4.zbuild.zon rename to test/fixtures/basic4.build.zig.zon diff --git a/test/fixtures/basic5.zbuild.zon b/test/fixtures/basic5.build.zig.zon similarity index 100% rename from test/fixtures/basic5.zbuild.zon rename to test/fixtures/basic5.build.zig.zon diff --git a/test/fixtures/basic6.zbuild.zon b/test/fixtures/basic6.build.zig.zon similarity index 100% rename from test/fixtures/basic6.zbuild.zon rename to test/fixtures/basic6.build.zig.zon diff --git a/test/sync.zig b/test/sync.zig index 0e7f276..5a0a70a 100644 --- a/test/sync.zig +++ b/test/sync.zig @@ -9,12 +9,12 @@ const remove_build_file = true; const cwd = "test"; const test_cases = &[_][]const u8{ - "fixtures/basic1.zbuild.zon", - "fixtures/basic2.zbuild.zon", - "fixtures/basic3.zbuild.zon", - "fixtures/basic4.zbuild.zon", - "fixtures/basic5.zbuild.zon", - "fixtures/basic6.zbuild.zon", + "fixtures/basic1.build.zig.zon", + "fixtures/basic2.build.zig.zon", + "fixtures/basic3.build.zig.zon", + "fixtures/basic4.build.zig.zon", + "fixtures/basic5.build.zig.zon", + "fixtures/basic6.build.zig.zon", }; fn maybeCleanup(should_cleanup: bool) void { diff --git a/zbuild.zon b/zbuild.zon deleted file mode 100644 index 251e989..0000000 --- a/zbuild.zon +++ /dev/null @@ -1,25 +0,0 @@ -.{ - .name = .zbuild, - .version = "0.2.0", - .fingerprint = 0x60f98ac2bf5a915c, - .minimum_zig_version = "0.14.0", - .paths = .{ "build.zig", "build.zig.zon", "src" }, - .description = "An opinionated zig build tool", - .dependencies = .{}, - .executables = .{ - .zbuild = .{ - .root_module = .{ - .root_source_file = "src/main.zig", - }, - }, - }, - .tests = .{ - .sync = .{ - .root_module = .{ - .private = true, - .root_source_file = "test/sync.zig", - .imports = .{.zbuild}, - }, - }, - }, -} From 8f009c8b92649598b4ed75a0ec14abf8d79158dc Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 13 Mar 2026 22:38:06 -0400 Subject: [PATCH 04/62] refactor(phase-b): rewrite parser with std.zon.parse - Change fingerprint from []const u8 to u64 (matches ZON directly) - Remove all deinit methods (arena handles cleanup) - Replace hand-rolled if/else if parser with: - inline for over struct fields for typed values - fromZoirNode for standard types - Custom parsers only for Dependency, ModuleLink, Option - parseHashMap replaces both parseHashMap and parseOptionalHashMap - Ignore unknown top-level fields (enables single-file approach) - Fix dependency parsing to include hash and lazy fields - Fix parseRun to use parseString (Run = []const u8) Co-Authored-By: Claude Opus 4.6 --- src/Config.zig | 1112 +++++++++++++--------------------------------- src/cmd_init.zig | 7 +- 2 files changed, 322 insertions(+), 797 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 19ee04d..f059423 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -8,7 +8,7 @@ const Config = @This(); name: []const u8, version: []const u8, -fingerprint: []const u8, +fingerprint: u64, minimum_zig_version: []const u8, paths: [][]const u8, description: ?[]const u8 = null, @@ -41,19 +41,6 @@ pub const Dependency = struct { null: void, }; - pub fn deinit(self: *Dependency, gpa: std.mem.Allocator) void { - gpa.free(self.value); - if (self.hash) |h| gpa.free(h); - if (self.args) |a| { - for (a.values()) |arg| switch (arg) { - .@"enum" => |e| gpa.free(e), - .string => |s| gpa.free(s), - else => {}, - }; - for (a.keys()) |k| gpa.free(k); - a.deinit(); - } - } }; pub const Option = union(enum) { @@ -121,73 +108,6 @@ pub const Option = union(enum) { description: ?[]const u8 = null, }; - pub fn deinit(self: *Option, gpa: std.mem.Allocator) void { - switch (self.*) { - .bool, - => |b| { - gpa.free(b.type); - if (b.description) |d| gpa.free(d); - }, - .int => |i| { - gpa.free(i.type); - if (i.description) |d| gpa.free(d); - }, - .float => |f| { - gpa.free(f.type); - if (f.description) |d| gpa.free(d); - }, - .@"enum" => |e| { - gpa.free(e.type); - if (e.description) |d| gpa.free(d); - for (e.enum_options) |eo| gpa.free(eo); - gpa.free(e.enum_options); - if (e.default) |d| gpa.free(d); - }, - .enum_list => |e| { - gpa.free(e.type); - if (e.description) |d| gpa.free(d); - for (e.enum_options) |eo| gpa.free(eo); - gpa.free(e.enum_options); - if (e.default) |d| { - for (d) |dd| gpa.free(dd); - gpa.free(d); - } - }, - .string, - => |s| { - gpa.free(s.type); - if (s.description) |d| gpa.free(d); - if (s.default) |d| gpa.free(d); - }, - .build_id => |s| { - gpa.free(s.type); - if (s.description) |d| gpa.free(d); - if (s.default) |d| gpa.free(d); - }, - .lazy_path => |s| { - gpa.free(s.type); - if (s.description) |d| gpa.free(d); - if (s.default) |d| gpa.free(d); - }, - .list => |l| { - gpa.free(l.type); - if (l.description) |d| gpa.free(d); - if (l.default) |d| { - for (d) |dd| gpa.free(dd); - gpa.free(d); - } - }, - .lazy_path_list => |l| { - gpa.free(l.type); - if (l.description) |d| gpa.free(d); - if (l.default) |d| { - for (d) |dd| gpa.free(dd); - gpa.free(d); - } - }, - } - } - pub fn isValidIntType(t: []const u8) bool { return std.mem.eql(u8, t, "i8") or std.mem.eql(u8, t, "u8") or @@ -244,32 +164,6 @@ pub const WriteFile = struct { }; }; - pub fn deinit(self: *WriteFile, gpa: std.mem.Allocator) void { - if (self.items) |*i| { - for (i.values()) |*v| { - switch (v.*) { - .file => |f| { - gpa.free(f.type); - gpa.free(f.path); - }, - .dir => |d| { - gpa.free(d.type); - gpa.free(d.path); - if (d.exclude_extensions) |e| { - for (e) |ee| gpa.free(ee); - gpa.free(e); - } - if (d.include_extensions) |e| { - for (e) |ee| gpa.free(ee); - gpa.free(e); - } - }, - } - } - for (i.keys()) |k| gpa.free(k); - i.deinit(); - } - } }; pub const Module = struct { @@ -300,31 +194,12 @@ pub const Module = struct { include_paths: ?[][]const u8 = null, link_libraries: ?[][]const u8 = null, - pub fn deinit(self: *Module, gpa: std.mem.Allocator) void { - if (self.name) |n| gpa.free(n); - if (self.root_source_file) |r| gpa.free(r); - if (self.imports) |i| { - for (i) |ii| gpa.free(ii); - gpa.free(i); - } - if (self.link_libraries) |l| { - for (l) |ll| gpa.free(ll); - gpa.free(l); - } - if (self.target) |t| gpa.free(t); - } }; pub const ModuleLink = union(enum) { name: []const u8, module: Module, - pub fn deinit(self: *ModuleLink, gpa: std.mem.Allocator) void { - switch (self.*) { - .name => |n| gpa.free(n), - .module => |*m| m.deinit(gpa), - } - } }; pub const Executable = struct { @@ -343,17 +218,6 @@ pub const Executable = struct { depends_on: ?[][]const u8 = null, - pub fn deinit(self: *Executable, gpa: std.mem.Allocator) void { - if (self.name) |n| gpa.free(n); - if (self.version) |v| gpa.free(v); - self.root_module.deinit(gpa); - if (self.zig_lib_dir) |z| gpa.free(z); - if (self.win32_manifest) |w| gpa.free(w); - if (self.depends_on) |d| { - for (d) |dd| gpa.free(dd); - gpa.free(d); - } - } }; pub const Library = struct { @@ -373,17 +237,6 @@ pub const Library = struct { depends_on: ?[][]const u8 = null, - pub fn deinit(self: *Library, gpa: std.mem.Allocator) void { - if (self.name) |n| gpa.free(n); - if (self.version) |v| gpa.free(v); - self.root_module.deinit(gpa); - if (self.zig_lib_dir) |z| gpa.free(z); - if (self.win32_manifest) |w| gpa.free(w); - if (self.depends_on) |d| { - for (d) |dd| gpa.free(dd); - gpa.free(d); - } - } }; pub const Object = struct { @@ -396,15 +249,6 @@ pub const Object = struct { depends_on: ?[][]const u8 = null, - pub fn deinit(self: *Object, gpa: std.mem.Allocator) void { - if (self.name) |n| gpa.free(n); - self.root_module.deinit(gpa); - if (self.zig_lib_dir) |z| gpa.free(z); - if (self.depends_on) |d| { - for (d) |dd| gpa.free(dd); - gpa.free(d); - } - } }; pub const Test = struct { @@ -418,14 +262,6 @@ pub const Test = struct { filters: []const []const u8 = &.{}, test_runner: ?[]const u8 = null, - pub fn deinit(self: *Test, gpa: std.mem.Allocator) void { - if (self.name) |n| gpa.free(n); - self.root_module.deinit(gpa); - if (self.zig_lib_dir) |z| gpa.free(z); - for (self.filters) |f| gpa.free(f); - gpa.free(self.filters); - if (self.test_runner) |t| gpa.free(t); - } }; pub const Fmt = struct { @@ -433,94 +269,10 @@ pub const Fmt = struct { exclude_paths: ?[][]const u8 = null, check: ?bool = false, - pub fn deinit(self: *Fmt, gpa: std.mem.Allocator) void { - if (self.paths) |p| { - for (p) |pp| gpa.free(pp); - gpa.free(p); - } - if (self.exclude_paths) |e| { - for (e) |ee| gpa.free(ee); - gpa.free(e); - } - } }; pub const Run = []const u8; -pub fn deinit(config: *Config, gpa: std.mem.Allocator) void { - gpa.free(config.name); - gpa.free(config.version); - gpa.free(config.fingerprint); - gpa.free(config.minimum_zig_version); - for (config.paths) |path| gpa.free(path); - gpa.free(config.paths); - if (config.description) |desc| gpa.free(desc); - if (config.keywords) |kws| { - for (kws) |kw| gpa.free(kw); - gpa.free(kws); - } - - if (config.write_files) |*wfs| { - for (wfs.values()) |*wf| wf.deinit(gpa); - for (wfs.keys()) |k| gpa.free(k); - wfs.deinit(); - } - if (config.options) |*opts| { - for (opts.values()) |*o| o.deinit(gpa); - for (opts.keys()) |k| gpa.free(k); - opts.deinit(); - } - if (config.options_modules) |*opts| { - for (opts.values()) |*o| { - for (o.values()) |*oo| oo.deinit(gpa); - for (o.keys()) |k| gpa.free(k); - o.deinit(); - } - for (opts.keys()) |k| gpa.free(k); - opts.deinit(); - } - if (config.modules) |*mods| { - for (mods.values()) |*m| m.deinit(gpa); - for (mods.keys()) |k| gpa.free(k); - mods.deinit(); - } - if (config.dependencies) |*deps| { - for (deps.values()) |*d| d.deinit(gpa); - for (deps.keys()) |k| gpa.free(k); - deps.deinit(); - } - if (config.executables) |*execs| { - for (execs.values()) |*e| e.deinit(gpa); - for (execs.keys()) |k| gpa.free(k); - execs.deinit(); - } - if (config.libraries) |*libs| { - for (libs.values()) |*l| l.deinit(gpa); - for (libs.keys()) |k| gpa.free(k); - libs.deinit(); - } - if (config.objects) |*objs| { - for (objs.values()) |*o| o.deinit(gpa); - for (objs.keys()) |k| gpa.free(k); - objs.deinit(); - } - if (config.tests) |*tests| { - for (tests.values()) |*t| t.deinit(gpa); - for (tests.keys()) |k| gpa.free(k); - tests.deinit(); - } - if (config.fmts) |*fmts| { - for (fmts.values()) |*f| f.deinit(gpa); - for (fmts.keys()) |k| gpa.free(k); - fmts.deinit(); - } - if (config.runs) |*runs| { - for (runs.values()) |r| gpa.free(r); - for (runs.keys()) |k| gpa.free(k); - runs.deinit(); - } - config.* = undefined; -} pub fn addDependency(config: *Config, gpa: std.mem.Allocator, name: []const u8, dependency: Dependency) !void { if (config.dependencies == null) { @@ -559,7 +311,7 @@ pub fn parseFromFile(gpa: std.mem.Allocator, zbuild_file: []const u8, wip_bundle pub fn parseFromZoir(gpa: std.mem.Allocator, zbuild_file: []const u8, zoir: std.zig.Zoir, ast: std.zig.Ast, wip_bundle: ?*std.zig.ErrorBundle.Wip) Parser.Error!Config { var status = std.zon.parse.Status{}; - var parser = Parser.init(gpa, zoir, ast, &status); + var parser = Parser{ .gpa = gpa, .zoir = zoir, .ast = ast, .status = &status }; return parser.parse() catch |e| { if (status.type_check) |err| { @@ -590,109 +342,288 @@ const Parser = struct { zoir: std.zig.Zoir, ast: std.zig.Ast, status: *std.zon.parse.Status, - config: Config, - - const Self = @This(); - - pub const Error = error{ OutOfMemory, ParseZon, NegativeIntoUnsigned, TargetTooSmall }; - - pub fn init(gpa: std.mem.Allocator, zoir: std.zig.Zoir, ast: std.zig.Ast, status: *std.zon.parse.Status) Parser { - return Parser{ - .gpa = gpa, - .zoir = zoir, - .ast = ast, - .status = status, - .config = Config{ - .name = "", - .version = "", - .fingerprint = "", - .minimum_zig_version = "", - .paths = &.{}, - }, + + const Error = error{ OutOfMemory, ParseZon, NegativeIntoUnsigned, TargetTooSmall }; + + fn parse(self: *Parser) Error!Config { + var config = Config{ + .name = "", + .version = "", + .fingerprint = 0, + .minimum_zig_version = "", + .paths = &.{}, }; - } - pub fn parse(self: *Self) Error!Config { - // required fields var has_name = false; var has_version = false; var has_fingerprint = false; var has_minimum_zig_version = false; var has_paths = false; + const r = try self.parseStructLiteral(.root); for (r.names, 0..) |n, i| { const field_name = n.get(self.zoir); const field_value = r.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "name")) { has_name = true; - self.config.name = try self.parseEnumLiteral(field_value); + config.name = try self.parseEnumLiteral(field_value); } else if (std.mem.eql(u8, field_name, "version")) { has_version = true; - self.config.version = try self.parseVersionString(field_value); + config.version = try self.parseVersionString(field_value); } else if (std.mem.eql(u8, field_name, "fingerprint")) { has_fingerprint = true; - const fingerprint_int = try self.parseT(u64, field_value); - self.config.fingerprint = try std.fmt.allocPrint(self.gpa, "0x{x}", .{fingerprint_int}); + config.fingerprint = try self.parseT(u64, field_value); } else if (std.mem.eql(u8, field_name, "minimum_zig_version")) { has_minimum_zig_version = true; - self.config.minimum_zig_version = try self.parseVersionString(field_value); + config.minimum_zig_version = try self.parseVersionString(field_value); } else if (std.mem.eql(u8, field_name, "paths")) { has_paths = true; - self.config.paths = try self.parseT([][]const u8, field_value); + config.paths = try self.parseT([][]const u8, field_value); } else if (std.mem.eql(u8, field_name, "description")) { - self.config.description = try self.parseString(field_value); + config.description = try self.parseString(field_value); } else if (std.mem.eql(u8, field_name, "keywords")) { - self.config.keywords = try self.parseT(?[][]const u8, field_value); + config.keywords = try self.parseT(?[][]const u8, field_value); } else if (std.mem.eql(u8, field_name, "dependencies")) { - self.config.dependencies = try self.parseOptionalHashMap(Dependency, parseDependency, field_value); + config.dependencies = try self.parseHashMap(Dependency, parseDependency, field_value); } else if (std.mem.eql(u8, field_name, "write_files")) { - // config.write_files = ; + // stub — pre-existing incomplete feature } else if (std.mem.eql(u8, field_name, "options")) { - self.config.options = try self.parseOptionalHashMap(Option, parseOption, field_value); + config.options = try self.parseHashMap(Option, parseOption, field_value); } else if (std.mem.eql(u8, field_name, "options_modules")) { - self.config.options_modules = try self.parseOptionalHashMap(OptionsModule, parseOptionsModule, field_value); + config.options_modules = try self.parseHashMap(OptionsModule, parseOptionsModule, field_value); } else if (std.mem.eql(u8, field_name, "modules")) { - self.config.modules = try self.parseOptionalHashMap(Module, parseModule, field_value); + config.modules = try self.parseHashMap(Module, parseModule, field_value); } else if (std.mem.eql(u8, field_name, "executables")) { - self.config.executables = try self.parseOptionalHashMap(Executable, parseExecutable, field_value); + config.executables = try self.parseHashMap(Executable, parseExecutable, field_value); } else if (std.mem.eql(u8, field_name, "libraries")) { - self.config.libraries = try self.parseOptionalHashMap(Library, parseLibrary, field_value); + config.libraries = try self.parseHashMap(Library, parseLibrary, field_value); } else if (std.mem.eql(u8, field_name, "objects")) { - self.config.objects = try self.parseOptionalHashMap(Object, parseObject, field_value); + config.objects = try self.parseHashMap(Object, parseObject, field_value); } else if (std.mem.eql(u8, field_name, "tests")) { - self.config.tests = try self.parseOptionalHashMap(Test, parseTest, field_value); + config.tests = try self.parseHashMap(Test, parseTest, field_value); } else if (std.mem.eql(u8, field_name, "fmts")) { - self.config.fmts = try self.parseOptionalHashMap(Fmt, parseFmt, field_value); + config.fmts = try self.parseHashMap(Fmt, parseFmt, field_value); } else if (std.mem.eql(u8, field_name, "runs")) { - self.config.runs = try self.parseOptionalHashMap(Run, parseRun, field_value); + config.runs = try self.parseHashMap(Run, parseRun, field_value); } else { - try self.returnParseErrorFmt("unknown field '{s}'", .{field_name}, field_value.getAstNode(self.zoir)); + // Ignore unknown fields — this allows build.zig.zon standard fields + // that zbuild doesn't use (like Zig-added future fields) to pass through. } } - if (!has_name) { - try self.returnParseError("missing required field 'name'", self.ast.rootDecls()[0]); + + if (!has_name) try self.returnParseError("missing required field 'name'", self.ast.rootDecls()[0]); + if (!has_version) try self.returnParseError("missing required field 'version'", self.ast.rootDecls()[0]); + if (!has_fingerprint) try self.returnParseError("missing required field 'fingerprint'", self.ast.rootDecls()[0]); + if (!has_minimum_zig_version) try self.returnParseError("missing required field 'minimum_zig_version'", self.ast.rootDecls()[0]); + if (!has_paths) try self.returnParseError("missing required field 'paths'", self.ast.rootDecls()[0]); + + return config; + } + + // -- Layer 2: HashMap parsing -- + + fn parseHashMap( + self: *Parser, + comptime V: type, + comptime parseItem: fn (*Parser, std.zig.Zoir.Node.Index) Error!V, + index: std.zig.Zoir.Node.Index, + ) Error!?ArrayHashMap(V) { + const node = index.get(self.zoir); + switch (node) { + .struct_literal => |n| { + var items = ArrayHashMap(V).init(self.gpa); + for (n.names, 0..) |name, i| { + const field_name = try self.gpa.dupe(u8, name.get(self.zoir)); + const field_value = n.vals.at(@intCast(i)); + try items.put(field_name, try parseItem(self, field_value)); + } + return items; + }, + .empty_literal => return null, + else => { + try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)); + }, } - if (!has_version) { - try self.returnParseError("missing required field 'version'", self.ast.rootDecls()[0]); + } + + // -- Layer 3: Types parsed with inline for + fromZoirNode -- + + fn parseModule(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Module { + const n = try self.parseStructLiteral(index); + var module = Module{}; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "imports")) { + module.imports = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "name")) { + module.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "root_source_file")) { + module.root_source_file = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "target")) { + module.target = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "private")) { + module.private = try self.parseT(bool, field_value); + } else if (std.mem.eql(u8, field_name, "include_paths")) { + module.include_paths = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "link_libraries")) { + module.link_libraries = try self.parseStringOrEnumSlice(field_value); + } else { + inline for (@typeInfo(Module).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(module, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } } - if (!has_fingerprint) { - try self.returnParseError("missing required field 'fingerprint'", self.ast.rootDecls()[0]); + return module; + } + + fn parseExecutable(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Executable { + const n = try self.parseStructLiteral(index); + var exe = Executable{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + exe.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + exe.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "version")) { + exe.version = try self.parseVersionString(field_value); + } else if (std.mem.eql(u8, field_name, "depends_on")) { + exe.depends_on = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + exe.zig_lib_dir = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "win32_manifest")) { + exe.win32_manifest = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { + exe.dest_sub_path = try self.parseString(field_value); + } else { + inline for (@typeInfo(Executable).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(exe, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } } - if (!has_minimum_zig_version) { - try self.returnParseError("missing required field 'minimum_zig_version'", self.ast.rootDecls()[0]); + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return exe; + } + + fn parseLibrary(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Library { + const n = try self.parseStructLiteral(index); + var lib = Library{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + lib.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + lib.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "version")) { + lib.version = try self.parseVersionString(field_value); + } else if (std.mem.eql(u8, field_name, "depends_on")) { + lib.depends_on = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + lib.zig_lib_dir = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "win32_manifest")) { + lib.win32_manifest = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { + lib.dest_sub_path = try self.parseString(field_value); + } else { + inline for (@typeInfo(Library).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(lib, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } } - if (!has_paths) { - try self.returnParseError("missing required field 'paths'", self.ast.rootDecls()[0]); + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return lib; + } + + fn parseObject(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Object { + const n = try self.parseStructLiteral(index); + var obj = Object{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + obj.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + obj.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "depends_on")) { + obj.depends_on = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + obj.zig_lib_dir = try self.parseString(field_value); + } else { + inline for (@typeInfo(Object).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(obj, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } } - return self.config; + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return obj; } - fn parseDependency(self: *Self, index: std.zig.Zoir.Node.Index) Error!Dependency { + fn parseTest(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Test { const n = try self.parseStructLiteral(index); - var dep = Dependency{ - .typ = undefined, - .value = undefined, - }; + var t = Test{ .root_module = .{ .name = "" } }; + var has_root_module = false; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "root_module")) { + t.root_module = try self.parseModuleLink(field_value); + has_root_module = true; + } else if (std.mem.eql(u8, field_name, "name")) { + t.name = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "filters")) { + t.filters = try self.parseStringOrEnumSlice(field_value) orelse &.{}; + } else if (std.mem.eql(u8, field_name, "test_runner")) { + t.test_runner = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { + t.zig_lib_dir = try self.parseString(field_value); + } else { + inline for (@typeInfo(Test).@"struct".fields) |field| { + if (std.mem.eql(u8, field_name, field.name)) { + @field(t, field.name) = try self.parseT(field.type, field_value); + break; + } + } + } + } + if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); + return t; + } + + fn parseFmt(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Fmt { + return try self.parseT(Fmt, index); + } + + fn parseRun(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Run { + return try self.parseString(index); + } + + // -- Layer 4: Custom parsers -- + + fn parseDependency(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency { + const n = try self.parseStructLiteral(index); + var dep = Dependency{ .typ = undefined, .value = undefined }; var has_type_field = false; for (n.names, 0..) |name, i| { const field_name = name.get(self.zoir); @@ -705,95 +636,46 @@ const Parser = struct { dep.typ = .url; dep.value = try self.parseString(field_value); has_type_field = true; + } else if (std.mem.eql(u8, field_name, "hash")) { + dep.hash = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "lazy")) { + dep.lazy = try self.parseT(bool, field_value); } else if (std.mem.eql(u8, field_name, "args")) { - dep.args = try self.parseOptionalHashMap(Dependency.Arg, parseDependencyArg, field_value); + dep.args = try self.parseHashMap(Dependency.Arg, parseDependencyArg, field_value); } } - if (!has_type_field) { - try self.returnParseError("missing required field 'path' or 'url'", index.getAstNode(self.zoir)); - } + if (!has_type_field) try self.returnParseError("missing required field 'path' or 'url'", index.getAstNode(self.zoir)); return dep; } - fn parseDependencyArg(self: *Self, index: std.zig.Zoir.Node.Index) Error!Dependency.Arg { + fn parseDependencyArg(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency.Arg { const node = index.get(self.zoir); switch (node) { - .true => { - return .{ - .bool = true, - }; - }, - .false => { - return .{ - .bool = false, - }; - }, - .int_literal => |i| { - return .{ - .int = switch (i) { - .small => |s| s, - .big => |b| try b.toInt(i64), - }, - }; - }, - .float_literal => |f| { - return .{ - .float = @floatCast(f), - }; - }, - .enum_literal => |e| { - return .{ - .@"enum" = try self.gpa.dupe(u8, e.get(self.zoir)), - }; - }, - .string_literal => |s| { - return .{ - .string = try self.gpa.dupe(u8, s), - }; - }, - .null => { - return .{ .null = {} }; - }, - else => { - try self.returnParseError("expected a boo, int, float, string literal, or enum literal", index.getAstNode(self.zoir)); - }, + .true => return .{ .bool = true }, + .false => return .{ .bool = false }, + .int_literal => |i| return .{ .int = switch (i) { + .small => |s| s, + .big => |b| try b.toInt(i64), + } }, + .float_literal => |f| return .{ .float = @floatCast(f) }, + .enum_literal => |e| return .{ .@"enum" = try self.gpa.dupe(u8, e.get(self.zoir)) }, + .string_literal => |s| return .{ .string = try self.gpa.dupe(u8, s) }, + .null => return .{ .null = {} }, + else => try self.returnParseError("expected a bool, int, float, string literal, or enum literal", index.getAstNode(self.zoir)), } } - fn parseWriteFile(self: *Self, index: std.zig.Zoir.Node.Index) Error!WriteFile { - const n = try self.parseStructLiteral(index); - var write_file = WriteFile{}; - for (n.names) |name| { - const field_name = name.get(self.zoir); - if (std.mem.eql(u8, field_name, "private")) { - write_file.private = try self.parseT(?bool); - } else if (std.mem.eql(u8, field_name, "items")) { - write_file.items = try self.parseOptionalHashMap(WriteFile.Path, parseWriteFilePath, index); - } - } - return write_file; - } - - fn parseWriteFilePath(self: *Self, index: std.zig.Zoir.Node.Index) Error!WriteFile.Path { - const n = try self.parseStructLiteral(index); - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "type")) { - const t = try self.parseString(field_value); - if (std.mem.eql(u8, t, "file")) { - return .{ .file = try self.parseT(WriteFile.File) }; - } else if (std.mem.eql(u8, t, "dir")) { - return .{ .dir = try self.parseT(WriteFile.Dir) }; - } else { - try self.returnParseErrorFmt("invalid type '{s}'", .{t}, field_value); - } - } + fn parseModuleLink(self: *Parser, index: std.zig.Zoir.Node.Index) Error!ModuleLink { + const node = index.get(self.zoir); + switch (node) { + .struct_literal => return .{ .module = try self.parseModule(index) }, + .string_literal => |n| return .{ .name = try self.gpa.dupe(u8, n) }, + .enum_literal => |n| return .{ .name = try self.gpa.dupe(u8, n.get(self.zoir)) }, + else => try self.returnParseError("expected a string, enum literal, or struct literal", index.getAstNode(self.zoir)), } - try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); } - fn parseOption(self: *Self, index: std.zig.Zoir.Node.Index) Error!Option { + fn parseOption(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option { const n = try self.parseStructLiteral(index); for (n.names, 0..) |name, i| { const field_name = name.get(self.zoir); @@ -828,12 +710,9 @@ const Parser = struct { try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); } - fn parseOptionEnum(self: *Self, index: std.zig.Zoir.Node.Index) Error!Option.Enum { + fn parseOptionEnum(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option.Enum { const n = try self.parseStructLiteral(index); - var option = Option.Enum{ - .enum_options = &.{}, - .type = "", - }; + var option = Option.Enum{ .enum_options = &.{}, .type = "" }; var has_type = false; var has_enum_options = false; for (n.names, 0..) |name, i| { @@ -844,28 +723,21 @@ const Parser = struct { option.type = try self.parseString(field_value); } else if (std.mem.eql(u8, field_name, "enum_options")) { has_enum_options = true; - option.enum_options = try self.parseSlice([]const u8, parseEnumLiteral, field_value); + option.enum_options = try self.parseEnumLiteralSlice(field_value); } else if (std.mem.eql(u8, field_name, "description")) { option.description = try self.parseString(field_value); } else if (std.mem.eql(u8, field_name, "default")) { option.default = try self.parseEnumLiteral(field_value); } } - if (!has_type) { - try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); - } - if (!has_enum_options) { - try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); - } + if (!has_type) try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); + if (!has_enum_options) try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); return option; } - fn parseOptionEnumList(self: *Self, index: std.zig.Zoir.Node.Index) Error!Option.EnumList { + fn parseOptionEnumList(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option.EnumList { const n = try self.parseStructLiteral(index); - var option = Option.EnumList{ - .enum_options = &.{}, - .type = "", - }; + var option = Option.EnumList{ .enum_options = &.{}, .type = "" }; var has_type = false; var has_enum_options = false; for (n.names, 0..) |name, i| { @@ -876,434 +748,111 @@ const Parser = struct { option.type = try self.parseString(field_value); } else if (std.mem.eql(u8, field_name, "enum_options")) { has_enum_options = true; - option.enum_options = try self.parseSlice([]const u8, parseEnumLiteral, field_value); + option.enum_options = try self.parseEnumLiteralSlice(field_value); } else if (std.mem.eql(u8, field_name, "description")) { option.description = try self.parseString(field_value); } else if (std.mem.eql(u8, field_name, "default")) { - option.default = try self.parseSlice([]const u8, parseEnumLiteral, field_value); + option.default = try self.parseEnumLiteralSlice(field_value); } } - if (!has_type) { - try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); - } - if (!has_enum_options) { - try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); - } + if (!has_type) try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); + if (!has_enum_options) try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); return option; } - fn parseOptionsModule(self: *Self, index: std.zig.Zoir.Node.Index) Error!OptionsModule { - return try self.parseHashMap(Option, parseOption, index); + fn parseOptionsModule(self: *Parser, index: std.zig.Zoir.Node.Index) Error!OptionsModule { + return (try self.parseHashMap(Option, parseOption, index)) orelse ArrayHashMap(Option).init(self.gpa); } - fn parseModule(self: *Self, index: std.zig.Zoir.Node.Index) Error!Module { - const n = try self.parseStructLiteral(index); - var module = Module{}; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - // each field in Module - if (std.mem.eql(u8, field_name, "name")) { - module.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "root_source_file")) { - module.root_source_file = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "imports")) { - module.imports = try self.parseOptionalSlice([]const u8, parseStringOrEnumLiteral, field_value); - } else if (std.mem.eql(u8, field_name, "private")) { - module.private = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "target")) { - module.target = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "optimize")) { - module.optimize = try self.parseT(std.builtin.OptimizeMode, field_value); - } else if (std.mem.eql(u8, field_name, "link_libc")) { - module.link_libc = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "link_libcpp")) { - module.link_libcpp = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "single_threaded")) { - module.single_threaded = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "strip")) { - module.strip = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "unwind_tables")) { - module.unwind_tables = try self.parseT(std.builtin.UnwindTables, field_value); - } else if (std.mem.eql(u8, field_name, "dwarf_format")) { - module.dwarf_format = try self.parseT(std.dwarf.Format, field_value); - } else if (std.mem.eql(u8, field_name, "code_model")) { - module.code_model = try self.parseT(std.builtin.CodeModel, field_value); - } else if (std.mem.eql(u8, field_name, "stack_protector")) { - module.stack_protector = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "stack_check")) { - module.stack_check = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "sanitize_c")) { - module.sanitize_c = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "sanitize_thread")) { - module.sanitize_thread = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "fuzz")) { - module.fuzz = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "valgrind")) { - module.valgrind = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "pic")) { - module.pic = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "red_zone")) { - module.red_zone = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "omit_frame_pointer")) { - module.omit_frame_pointer = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "error_tracing")) { - module.error_tracing = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "include_paths")) { - module.include_paths = try self.parseOptionalSlice([]const u8, parseString, field_value); - } else if (std.mem.eql(u8, field_name, "link_libraries")) { - module.link_libraries = try self.parseOptionalSlice([]const u8, parseString, field_value); - } - } - return module; + // -- Primitives -- + + fn parseT(self: *Parser, comptime T: type, index: std.zig.Zoir.Node.Index) Error!T { + @setEvalBranchQuota(2_000); + self.status.* = .{}; + return try std.zon.parse.fromZoirNode(T, self.gpa, self.ast, self.zoir, index, self.status, .{}); } - fn parseModuleLink(self: *Self, index: std.zig.Zoir.Node.Index) Error!ModuleLink { + fn parseString(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { const node = index.get(self.zoir); switch (node) { - .struct_literal => { - return .{ .module = try self.parseModule(index) }; - }, - .string_literal => |n| { - return .{ .name = try self.gpa.dupe(u8, n) }; - }, - .enum_literal => |n| { - return .{ .name = try self.gpa.dupe(u8, n.get(self.zoir)) }; - }, - else => { - try self.returnParseError("expected a string, enum literal, struct literal", index.getAstNode(self.zoir)); - }, - } - } - - fn parseExecutable(self: *Self, index: std.zig.Zoir.Node.Index) Error!Executable { - const n = try self.parseStructLiteral(index); - var executable = Executable{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "name")) { - executable.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "version")) { - executable.version = try self.parseVersionString(field_value); - } else if (std.mem.eql(u8, field_name, "root_module")) { - executable.root_module = try self.parseModuleLink(field_value); - has_root_module = true; - } else if (std.mem.eql(u8, field_name, "linkage")) { - executable.linkage = try self.parseT(std.builtin.LinkMode, field_value); - } else if (std.mem.eql(u8, field_name, "max_rss")) { - executable.max_rss = try self.parseT(usize, field_value); - } else if (std.mem.eql(u8, field_name, "use_llvm")) { - executable.use_llvm = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "use_lld")) { - executable.use_lld = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - executable.zig_lib_dir = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "win32_manifest")) { - executable.win32_manifest = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { - executable.dest_sub_path = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "depends_on")) { - executable.depends_on = try self.parseOptionalSlice([]const u8, parseStringOrEnumLiteral, field_value); - } - } - if (!has_root_module) { - try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - } - return executable; - } - - fn parseLibrary(self: *Self, index: std.zig.Zoir.Node.Index) Error!Library { - const n = try self.parseStructLiteral(index); - var library = Library{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "name")) { - library.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "root_module")) { - has_root_module = true; - library.root_module = try self.parseModuleLink(field_value); - } else if (std.mem.eql(u8, field_name, "linkage")) { - library.linkage = try self.parseT(std.builtin.LinkMode, field_value); - } else if (std.mem.eql(u8, field_name, "max_rss")) { - library.max_rss = try self.parseT(usize, field_value); - } else if (std.mem.eql(u8, field_name, "use_llvm")) { - library.use_llvm = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "use_lld")) { - library.use_lld = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - library.zig_lib_dir = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "win32_manifest")) { - library.win32_manifest = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { - library.dest_sub_path = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "depends_on")) { - library.depends_on = try self.parseOptionalSlice([]const u8, parseStringOrEnumLiteral, field_value); - } else if (std.mem.eql(u8, field_name, "linker_allow_shlib_undefined")) { - library.linker_allow_shlib_undefined = try self.parseBool(field_value); - } + .string_literal => |n| return try self.gpa.dupe(u8, n), + else => try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)), } - if (!has_root_module) { - try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - } - return library; - } - - fn parseObject(self: *Self, index: std.zig.Zoir.Node.Index) Error!Object { - const n = try self.parseStructLiteral(index); - var object = Object{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = try self.gpa.dupe(u8, name.get(self.zoir)); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "name")) { - object.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "root_module")) { - has_root_module = true; - object.root_module = try self.parseModuleLink(field_value); - } else if (std.mem.eql(u8, field_name, "max_rss")) { - object.max_rss = try self.parseT(usize, field_value); - } else if (std.mem.eql(u8, field_name, "use_llvm")) { - object.use_llvm = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "use_lld")) { - object.use_lld = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - object.zig_lib_dir = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "depends_on")) { - object.depends_on = try self.parseOptionalSlice([]const u8, parseStringOrEnumLiteral, field_value); - } - } - if (!has_root_module) { - try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - } - return object; - } - - fn parseTest(self: *Self, index: std.zig.Zoir.Node.Index) Error!Test { - const n = try self.parseStructLiteral(index); - var t = Test{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "name")) { - t.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "root_module")) { - has_root_module = true; - t.root_module = try self.parseModuleLink(field_value); - } else if (std.mem.eql(u8, field_name, "max_rss")) { - t.max_rss = try self.parseT(usize, field_value); - } else if (std.mem.eql(u8, field_name, "use_llvm")) { - t.use_llvm = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "use_lld")) { - t.use_lld = try self.parseBool(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - t.zig_lib_dir = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "filters")) { - t.filters = try self.parseOptionalSlice([]const u8, parseString, field_value) orelse &.{}; - } - } - if (!has_root_module) { - try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - } - return t; - } - - fn parseFmt(self: *Self, index: std.zig.Zoir.Node.Index) Error!Fmt { - return try self.parseT(Fmt, index); - } - - fn parseRun(self: *Self, index: std.zig.Zoir.Node.Index) Error!Run { - return try self.parseT(Run, index); } - fn parseHashMap( - self: *Self, - comptime T: type, - comptime parseItem: fn (self: *Self, index: std.zig.Zoir.Node.Index) Error!T, - index: std.zig.Zoir.Node.Index, - ) Error!ArrayHashMap(T) { - const n = try self.parseStructLiteral(index); - var items = ArrayHashMap(T).init(self.gpa); - for (n.names, 0..) |name, i| { - const field_name = try self.gpa.dupe(u8, name.get(self.zoir)); - const field_value = n.vals.at(@intCast(i)); - const item = try parseItem(self, field_value); - try items.put(field_name, item); + fn parseEnumLiteral(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { + const node = index.get(self.zoir); + switch (node) { + .enum_literal => |n| return try self.gpa.dupe(u8, n.get(self.zoir)), + else => try self.returnParseError("expected an enum literal", index.getAstNode(self.zoir)), } - return items; } - fn parseOptionalHashMap( - self: *Self, - comptime T: type, - comptime parseItem: fn (self: *Self, index: std.zig.Zoir.Node.Index) Error!T, - index: std.zig.Zoir.Node.Index, - ) Error!?ArrayHashMap(T) { + fn parseVersionString(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { const node = index.get(self.zoir); switch (node) { - .struct_literal => |n| { - var items = ArrayHashMap(T).init(self.gpa); - for (n.names, 0..) |name, i| { - const field_name = try self.gpa.dupe(u8, name.get(self.zoir)); - const field_value = n.vals.at(@intCast(i)); - const item = try parseItem(self, field_value); - try items.put(field_name, item); - } - return items; - }, - .empty_literal => { - return null; - }, - else => { - try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)); + .string_literal => |n| { + _ = std.SemanticVersion.parse(n) catch { + try self.returnParseError("invalid version string", index.getAstNode(self.zoir)); + }; + return try self.gpa.dupe(u8, n); }, + else => try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)), } } - fn parseSlice( - self: *Self, - comptime T: type, - comptime parseItem: fn (self: *Self, index: std.zig.Zoir.Node.Index) Error!T, - index: std.zig.Zoir.Node.Index, - ) Error![]T { + fn parseStringOrEnumSlice(self: *Parser, index: std.zig.Zoir.Node.Index) Error!?[][]const u8 { const node = index.get(self.zoir); switch (node) { .array_literal => |a| { - const slice = try self.gpa.alloc(T, a.len); + const slice = try self.gpa.alloc([]const u8, a.len); for (0..a.len) |i| { const item = a.at(@intCast(i)); - slice[i] = try parseItem(self, item); + const item_node = item.get(self.zoir); + slice[i] = switch (item_node) { + .string_literal => |s| try self.gpa.dupe(u8, s), + .enum_literal => |e| try self.gpa.dupe(u8, e.get(self.zoir)), + else => { + try self.returnParseError("expected string or enum literal", item.getAstNode(self.zoir)); + }, + }; } return slice; }, - else => { - try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)); - }, + .empty_literal => return null, + else => try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)), } } - fn parseOptionalSlice( - self: *Self, - comptime T: type, - comptime parseItem: fn (self: *Self, index: std.zig.Zoir.Node.Index) Error!T, - index: std.zig.Zoir.Node.Index, - ) Error!?[]T { + fn parseEnumLiteralSlice(self: *Parser, index: std.zig.Zoir.Node.Index) Error![][]const u8 { const node = index.get(self.zoir); switch (node) { .array_literal => |a| { - const slice = try self.gpa.alloc(T, a.len); + const slice = try self.gpa.alloc([]const u8, a.len); for (0..a.len) |i| { const item = a.at(@intCast(i)); - slice[i] = try parseItem(self, item); + slice[i] = try self.parseEnumLiteral(item); } return slice; }, - .empty_literal => { - return null; - }, - else => { - try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)); - }, - } - } - - fn parseT(self: *Self, comptime T: type, index: std.zig.Zoir.Node.Index) Error!T { - @setEvalBranchQuota(2_000); - self.status.* = .{}; - return try std.zon.parse.fromZoirNode(T, self.gpa, self.ast, self.zoir, index, self.status, .{}); - } - - fn parseEnumLiteral(self: *Self, index: std.zig.Zoir.Node.Index) Error![]const u8 { - const node = index.get(self.zoir); - switch (node) { - .enum_literal => |n| { - return try self.gpa.dupe(u8, n.get(self.zoir)); - }, - else => { - try self.returnParseError("expected an enum literal", index.getAstNode(self.zoir)); - }, - } - } - - fn parseString(self: *Self, index: std.zig.Zoir.Node.Index) Error![]const u8 { - const node = index.get(self.zoir); - switch (node) { - .string_literal => |n| { - return try self.gpa.dupe(u8, n); - }, - else => { - try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)); - }, - } - } - - fn parseStringOrEnumLiteral(self: *Self, index: std.zig.Zoir.Node.Index) Error![]const u8 { - const node = index.get(self.zoir); - switch (node) { - .string_literal => |n| { - return try self.gpa.dupe(u8, n); - }, - .enum_literal => |n| { - return try self.gpa.dupe(u8, n.get(self.zoir)); - }, - else => { - try self.returnParseError("expected a string literal or enum literal", index.getAstNode(self.zoir)); - }, + else => try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)), } } - fn parseVersionString(self: *Self, index: std.zig.Zoir.Node.Index) Error![]const u8 { + fn parseStructLiteral(self: *Parser, index: std.zig.Zoir.Node.Index) Error!std.meta.TagPayload(std.zig.Zoir.Node, .struct_literal) { const node = index.get(self.zoir); switch (node) { - .string_literal => |n| { - _ = std.SemanticVersion.parse(n) catch { - try self.returnParseError("invalid version string", index.getAstNode(self.zoir)); - }; - return try self.gpa.dupe(u8, n); - }, - else => { - try self.returnParseError("expected an string literal", index.getAstNode(self.zoir)); - }, + .struct_literal => |n| return n, + else => try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)), } } - fn parseBool(self: *Self, index: std.zig.Zoir.Node.Index) Error!bool { - const node = index.get(self.zoir); - switch (node) { - .true => { - return true; - }, - .false => { - return false; - }, - else => { - try self.returnParseError("expected a boolean literal", index.getAstNode(self.zoir)); - }, - } - } - - fn parseStructLiteral(self: *Self, index: std.zig.Zoir.Node.Index) Error!std.meta.TagPayload(std.zig.Zoir.Node, .struct_literal) { - const node = index.get(self.zoir); - switch (node) { - .struct_literal => |n| { - return n; - }, - else => { - try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)); - }, - } - } - - fn returnParseErrorFmt(self: *Self, comptime fmt: []const u8, args: anytype, node_index: std.zig.Ast.Node.Index) Error!noreturn { + fn returnParseErrorFmt(self: *Parser, comptime fmt: []const u8, args: anytype, node_index: std.zig.Ast.Node.Index) Error!noreturn { const message = try std.fmt.allocPrint(self.gpa, fmt, args); try self.returnParseError(message, node_index); } - fn returnParseError(self: *Self, message: []const u8, node_index: std.zig.Ast.Node.Index) Error!noreturn { + fn returnParseError(self: *Parser, message: []const u8, node_index: std.zig.Ast.Node.Index) Error!noreturn { self.status.* = .{ .ast = self.ast, .zoir = self.zoir, @@ -1350,7 +899,7 @@ fn Serializer(Writer: type) type { try top_level.field("name", self.config.name, .{}); try top_level.field("version", self.config.version, .{}); try top_level.fieldPrefix("fingerprint"); - try self.writer.print("0x{x}", .{self.config.fingerprint}); + try self.writer.print("0x{x:0>16}", .{self.config.fingerprint}); try top_level.field("minimum_zig_version", self.config.minimum_zig_version, .{}); try top_level.field("paths", self.config.paths, .{}); if (self.config.description) |desc| { @@ -1615,22 +1164,3 @@ fn Serializer(Writer: type) type { } }; } - -test { - const gpa = std.testing.allocator; - var status = std.zon.parse.Status{}; - var config = Config.parseFromFile(gpa, "foo.zon", &status) catch |err| { - try status.format("error: {s}", .{}, std.io.getStdErr().writer()); - return err; - }; - defer config.deinit(gpa); - defer status.deinit(gpa); - - std.debug.print("{any}\n", .{config}); - - // Check that the required fields are set - // try std.testing.expect(config.name != null); - // try std.testing.expect(config.version != null); - // try std.testing.expect(config.fingerprint != null); - // try std.testing.expect(config.minimum_zig_version != null); -} diff --git a/src/cmd_init.zig b/src/cmd_init.zig index 3e19c57..31a09cc 100644 --- a/src/cmd_init.zig +++ b/src/cmd_init.zig @@ -10,12 +10,7 @@ pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions) !void const cwd = try std.fs.cwd().realpathAlloc(gpa, global_opts.project_dir); defer gpa.free(cwd); const name = try sanitizeExampleName(arena, std.fs.path.basename(cwd)); - const fingerprint = try std.fmt.allocPrint( - gpa, - "0x{x}", - .{Package.Fingerprint.generate(name).int()}, - ); - defer gpa.free(fingerprint); + const fingerprint = Package.Fingerprint.generate(name).int(); var paths = [_][]const u8{ "build.zig", "build.zig.zon", "src" }; From 4328805e82a4541145f87fd6ac392136aeaef758 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 13 Mar 2026 22:46:16 -0400 Subject: [PATCH 05/62] refactor(phase-c): replace codegen with static build.zig + build_runner - Create build_runner.zig with configureBuild() that reads build.zig.zon and configures the build graph via direct std.Build API calls - Replace cmd_sync codegen with static build.zig template that imports zbuild and calls configureBuild - Delete ConfigBuildgen.zig (~1280 lines) and sync_build_file.zig (~38 lines) - Hand-write zbuild's own build.zig (can't self-reference) - Expose configureBuild as public API via main.zig - Update sync test to verify static template generation Eliminates string-concatenation codegen, scratch buffers, and zig fmt post-processing. Fixes bugs 1.6, 2.5, 2.6, 2.14, 3.7, 4.1, 4.3, 4.6, 5.5, 5.6, 5.10. Co-Authored-By: Claude Opus 4.6 --- build.zig | 61 +- src/ConfigBuildgen.zig | 1281 --------------------------------------- src/build_runner.zig | 472 +++++++++++++++ src/cmd_sync.zig | 32 +- src/main.zig | 3 +- src/sync_build_file.zig | 38 -- test/sync.zig | 54 +- 7 files changed, 555 insertions(+), 1386 deletions(-) delete mode 100644 src/ConfigBuildgen.zig create mode 100644 src/build_runner.zig delete mode 100644 src/sync_build_file.zig diff --git a/build.zig b/build.zig index f229088..a917e01 100644 --- a/build.zig +++ b/build.zig @@ -1,70 +1,59 @@ -// This file is generated by zbuild. Do not edit manually. - const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const module_zbuild = b.createModule(.{ + // zbuild library module (for use by zbuild-powered projects) + const zbuild_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); - b.modules.put(b.dupe("zbuild"), module_zbuild) catch @panic("OOM"); + b.modules.put(b.dupe("zbuild"), zbuild_module) catch @panic("OOM"); - const exe_zbuild = b.addExecutable(.{ + // zbuild executable + const exe = b.addExecutable(.{ .name = "zbuild", - .root_module = module_zbuild, + .root_module = zbuild_module, }); + const install_exe = b.addInstallArtifact(exe, .{}); + b.getInstallStep().dependOn(&install_exe.step); - const install_exe_zbuild = b.addInstallArtifact(exe_zbuild, .{}); - - const tls_install_exe_zbuild = b.step("build-exe:zbuild", "Install the zbuild executable"); - tls_install_exe_zbuild.dependOn(&install_exe_zbuild.step); - b.getInstallStep().dependOn(&install_exe_zbuild.step); - - const run_exe_zbuild = b.addRunArtifact(exe_zbuild); - if (b.args) |args| run_exe_zbuild.addArgs(args); - const tls_run_exe_zbuild = b.step("run:zbuild", "Run the zbuild executable"); - tls_run_exe_zbuild.dependOn(&run_exe_zbuild.step); + const run_exe = b.addRunArtifact(exe); + if (b.args) |args| run_exe.addArgs(args); + const run_step = b.step("run:zbuild", "Run the zbuild executable"); + run_step.dependOn(&run_exe.step); + // Tests const tls_run_test = b.step("test", "Run all tests"); + // zbuild unit tests const test_zbuild = b.addTest(.{ .name = "zbuild", - .root_module = module_zbuild, - .filters = b.option([][]const u8, "zbuild.filters", "zbuild test filters") orelse &[_][]const u8{}, + .root_module = zbuild_module, + .filters = b.option([]const []const u8, "zbuild.filters", "zbuild test filters") orelse &.{}, }); - const install_test_zbuild = b.addInstallArtifact(test_zbuild, .{}); - const tls_install_test_zbuild = b.step("build-test:zbuild", "Install the zbuild test"); - tls_install_test_zbuild.dependOn(&install_test_zbuild.step); - const run_test_zbuild = b.addRunArtifact(test_zbuild); - const tls_run_test_zbuild = b.step("test:zbuild", "Run the zbuild test"); - tls_run_test_zbuild.dependOn(&run_test_zbuild.step); + const tls_test_zbuild = b.step("test:zbuild", "Run the zbuild test"); + tls_test_zbuild.dependOn(&run_test_zbuild.step); tls_run_test.dependOn(&run_test_zbuild.step); - const module_sync = b.createModule(.{ + // sync integration test + const sync_module = b.createModule(.{ .root_source_file = b.path("test/sync.zig"), .target = target, .optimize = optimize, }); - b.modules.put(b.dupe("sync"), module_sync) catch @panic("OOM"); + sync_module.addImport("zbuild", zbuild_module); const test_sync = b.addTest(.{ .name = "sync", - .root_module = module_sync, - .filters = b.option([][]const u8, "sync.filters", "sync test filters") orelse &[_][]const u8{}, + .root_module = sync_module, + .filters = b.option([]const []const u8, "sync.filters", "sync test filters") orelse &.{}, }); - const install_test_sync = b.addInstallArtifact(test_sync, .{}); - const tls_install_test_sync = b.step("build-test:sync", "Install the sync test"); - tls_install_test_sync.dependOn(&install_test_sync.step); - const run_test_sync = b.addRunArtifact(test_sync); - const tls_run_test_sync = b.step("test:sync", "Run the sync test"); - tls_run_test_sync.dependOn(&run_test_sync.step); + const tls_test_sync = b.step("test:sync", "Run the sync test"); + tls_test_sync.dependOn(&run_test_sync.step); tls_run_test.dependOn(&run_test_sync.step); - - module_sync.addImport("zbuild", module_zbuild); } diff --git a/src/ConfigBuildgen.zig b/src/ConfigBuildgen.zig deleted file mode 100644 index 4600cab..0000000 --- a/src/ConfigBuildgen.zig +++ /dev/null @@ -1,1281 +0,0 @@ -//! Generate a build.zig file from a Config - -const std = @import("std"); - -const Config = @import("Config.zig"); -const ConfigBuildgen = @This(); - -const Writer = std.io.AnyWriter; - -allocator: std.mem.Allocator, -config: Config, -writer: Writer, - -write_files: std.StringArrayHashMap(Config.WriteFile), -options: std.StringArrayHashMap(Option), -options_modules: std.StringArrayHashMap(Config.OptionsModule), -modules: std.StringArrayHashMap(Config.Module), -dependencies: std.StringArrayHashMap(void), -executables: std.StringArrayHashMap(Config.Executable), -runs: std.StringArrayHashMap(Config.Run), - -const Option = struct { - type_name: []const u8, - optional: bool, -}; - -pub fn init(allocator: std.mem.Allocator, config: Config, writer: Writer) ConfigBuildgen { - return ConfigBuildgen{ - .allocator = allocator, - .config = config, - .writer = writer, - .write_files = std.StringArrayHashMap(Config.WriteFile).init(allocator), - .options = std.StringArrayHashMap(Option).init(allocator), - .options_modules = std.StringArrayHashMap(Config.OptionsModule).init(allocator), - .modules = std.StringArrayHashMap(Config.Module).init(allocator), - .dependencies = std.StringArrayHashMap(void).init(allocator), - .executables = std.StringArrayHashMap(Config.Executable).init(allocator), - .runs = std.StringArrayHashMap(Config.Run).init(allocator), - }; -} - -pub fn deinit(self: *ConfigBuildgen) void { - self.write_files.deinit(); - self.options.deinit(); - self.options_modules.deinit(); - self.modules.deinit(); - self.dependencies.deinit(); - self.executables.deinit(); - self.runs.deinit(); -} - -pub fn write(self: *ConfigBuildgen) !void { - try self.writeLn( - \\// This file is generated by zbuild. Do not edit manually. - \\ - \\const std = @import("std"); - \\ - \\pub fn build(b: *std.Build) void {{ - \\ const target = b.standardTargetOptions(.{{}}); - \\ const optimize = b.standardOptimizeOption(.{{}}); - \\ - , - .{}, - .{ .indent = 0 }, - ); - - // add all config items without linking depends_on, lazy paths, or imports - - if (self.config.options) |options| { - try self.writeItems(Config.Option, writeOption, options); - } - if (self.config.options_modules) |options_modules| { - try self.writeItems(Config.OptionsModule, writeOptionsModule, options_modules); - } - if (self.config.dependencies) |dependencies| { - try self.writeItems(Config.Dependency, writeDependency, dependencies); - } - if (self.config.write_files) |write_files| { - try self.writeItems(Config.WriteFile, writeWriteFile, write_files); - } - if (self.config.modules) |modules| { - try self.writeItems(Config.Module, writeModule, modules); - } - if (self.config.executables) |executables| { - try self.writeItems(Config.Executable, writeExecutable, executables); - } - if (self.config.libraries) |libraries| { - try self.writeItems(Config.Library, writeLibrary, libraries); - } - if (self.config.objects) |objects| { - try self.writeItems(Config.Object, writeObject, objects); - } - - if ((self.config.tests != null and self.config.tests.?.count() > 0) or self.modules.count() > 0) { - try self.writeLn( - \\const tls_run_test = b.step("test", "Run all tests"); - \\ - , .{}, .{}); - } - // ensure a test is created for every module in the project, not just explicitly defined tests - for (self.modules.keys()) |name| { - if (self.config.tests == null or !self.config.tests.?.contains(name)) { - try self.writeTest(name, .{ - .root_module = .{ .name = name }, - .filters = &.{}, - }); - try self.writer.writeAll("\n"); - } - } - if (self.config.tests) |tests| { - try self.writeItems(Config.Test, writeTest, tests); - } - if (self.config.fmts) |fmts| { - try self.writeLn( - \\const tls_run_fmt = b.step("fmt", "Run all fmts"); - \\ - , .{}, .{}); - try self.writeItems(Config.Fmt, writeFmt, fmts); - } - if (self.config.runs) |runs| { - try self.writeItems(Config.Run, writeRun, runs); - } - - // add files/dirs to write files - - if (self.config.write_files) |write_files| { - try self.writeItems(Config.WriteFile, writeWriteFileItems, write_files); - } - - // link all imports - - if (self.config.modules) |modules| { - try self.writeImports(Config.Module, modules); - } - if (self.config.executables) |executables| { - try self.writeImports(Config.Executable, executables); - } - if (self.config.libraries) |libraries| { - try self.writeImports(Config.Library, libraries); - } - if (self.config.objects) |objects| { - try self.writeImports(Config.Object, objects); - } - if (self.config.tests) |tests| { - try self.writeImports(Config.Test, tests); - } - // make sure all unused variables are used - // target and optimize that aren't used (when all modules define them) - var target_unused = true; - var optimize_unused = true; - for (self.modules.values()) |module| { - if (module.target == null) { - target_unused = false; - } - if (module.optimize == null) { - optimize_unused = false; - } - } - if (target_unused) { - try self.writeLn("_ = target;", .{}, .{}); - } - if (optimize_unused) { - try self.writeLn("_ = optimize;", .{}, .{}); - } - // options modules and dependencies that aren't imported - for (self.options_modules.keys()) |name| { - var options_module_unused = true; - blk: for (self.modules.values()) |module| { - if (module.imports) |imports| { - for (imports) |import| { - if (std.mem.eql(u8, name, import)) { - options_module_unused = false; - break :blk; - } - } - } - } - if (options_module_unused) { - try self.writeLn("_ = {s};", .{try fmtId("options_module", name)}, .{}); - } - } - for (self.dependencies.keys()) |name| { - var dependency_unused = true; - blk: for (self.modules.values()) |module| { - if (module.imports) |imports| { - for (imports) |import| { - var parts = std.mem.splitScalar(u8, import, ':'); - const first = parts.first(); - if (std.mem.eql(u8, name, first)) { - dependency_unused = false; - break :blk; - } - } - } - if (module.link_libraries) |link_libraries| { - for (link_libraries) |link_library| { - var parts = std.mem.splitScalar(u8, link_library, ':'); - const dep_name = parts.first(); - if (std.mem.eql(u8, name, dep_name)) { - dependency_unused = false; - break :blk; - } - } - } - } - if (dependency_unused) { - try self.writeLn("_ = {s};", .{try fmtId("dep", name)}, .{}); - } - } - - try self.writeLn("}}", .{}, .{ .indent = 0 }); -} - -fn writeItems( - self: *ConfigBuildgen, - comptime T: type, - comptime writeItem: fn (*ConfigBuildgen, []const u8, T) anyerror!void, - items: std.StringArrayHashMap(T), -) !void { - const names = items.keys(); - const values = items.values(); - for (names, values) |name, item| { - try writeItem(self, name, item); - try self.writer.writeAll("\n"); - } -} - -fn writeImports( - self: *ConfigBuildgen, - comptime T: type, - items: std.StringArrayHashMap(T), -) !void { - const keys = items.keys(); - const values = items.values(); - for (keys, values) |name, item| { - const imports = switch (T) { - Config.Module => item.imports orelse continue, - else => blk: { - const module_config = switch (item.root_module) { - .module => |m| m, - .name => continue, - }; - break :blk if (module_config.imports) |imports| imports else continue; - }, - }; - - try self.writeImport(name, imports); - try self.writer.writeAll("\n"); - } -} - -pub fn writeWriteFile(self: *ConfigBuildgen, name: []const u8, item: Config.WriteFile) !void { - const write_files_id = try allocFmtId(self.allocator, "write_files", name); - defer self.allocator.free(write_files_id); - - try self.writeLn( - \\const {s} = b.{s}(); - , - .{ - write_files_id, - if (item.private orelse false) "addWriteFiles" else "addNamedWriteFiles", - }, - .{}, - ); - try self.write_files.put(name, item); -} - -pub fn writeWriteFileItems(self: *ConfigBuildgen, name: []const u8, item: Config.WriteFile) !void { - const write_files_id = try allocFmtId(self.allocator, "write_files", name); - defer self.allocator.free(write_files_id); - - if (item.items) |items| { - for (items.keys(), items.values()) |key, value| { - switch (value) { - .file => |f| { - try self.writeLn( - \\_ = {s}.addCopyFile({s}, "{s}"); - , - .{ write_files_id, try self.resolveLazyPath(f.path, SourcesForWriteFiles), key }, - .{}, - ); - }, - .dir => |d| { - try self.writeLn( - \\_ = {s}.addCopyDirectory({s}, "{s}", .{{ .exclude_extensions = {s}, include_extensions = {s} }}); - , - .{ - write_files_id, - try self.resolveLazyPath(d.path, SourcesForWriteFiles), - key, - try strSliceLiteral(d.exclude_extensions) orelse "&.{}", - try strSliceLiteral(d.include_extensions) orelse "null", - }, - .{}, - ); - }, - } - } - } -} - -pub fn writeOption(self: *ConfigBuildgen, name: []const u8, item: Config.Option) !void { - const t, const default, const description = blk: switch (item) { - .bool => |b| { - break :blk .{ - "bool", - if (b.default) |d| try std.fmt.allocPrint(self.allocator, "{}", .{d}) else null, - b.description, - }; - }, - .@"enum" => |e| { - break :blk .{ - try std.fmt.allocPrint(self.allocator, "enum {s}", .{e.enum_options}), - if (e.default) |d| try std.fmt.allocPrint(self.allocator, ".{s}", .{d}) else null, - e.description, - }; - }, - .enum_list => |e| { - const enum_id = try allocFmtId(self.allocator, "Enum", name); - try self.writeLn("const {s} = enum {s};", .{ enum_id, e.enum_options }, .{}); - break :blk .{ - enum_id, - if (e.default) |d| try std.fmt.allocPrint(self.allocator, "{s}", .{try enumSliceLiteral(enum_id, d)}) else null, - e.description, - }; - }, - .string => |s| { - break :blk .{ - "[]const u8", - if (s.default) |d| try std.fmt.allocPrint(self.allocator, "\"{s}\"", .{d}) else null, - s.description, - }; - }, - .list => |l| { - break :blk .{ - "[]const []const u8", - try allocStrSliceLiteral(self.allocator, l.default), - l.description, - }; - }, - .lazy_path => |l| { - break :blk .{ - "std.Build.LazyPath", - if (l.default) |d| try self.resolveLazyPath(d, SourcesForOptions) else null, - l.description, - }; - }, - .lazy_path_list => |l| { - break :blk .{ - "[]std.Build.LazyPath", - try lazyPathSlice(l.default), - l.description, - }; - }, - .build_id => |b| { - break :blk .{ - "std.zig.BuildId", - try buildId(b.default), - b.description, - }; - }, - .int => |i| { - break :blk .{ - i.type, - if (i.default) |d| try std.fmt.allocPrint(self.allocator, "{}", .{d}) else null, - i.description, - }; - }, - .float => |i| { - break :blk .{ - i.type, - if (i.default) |d| try std.fmt.allocPrint(self.allocator, "{}", .{d}) else null, - i.description, - }; - }, - }; - - const option_id = try allocFmtId(self.allocator, "option", name); - defer self.allocator.free(option_id); - - if (default) |d| { - try self.writeLn( - \\const {s} = b.option({s}, "{s}", "{s}") orelse {s}; - , - .{ option_id, t, name, description orelse "", d }, - .{}, - ); - } else { - try self.writeLn( - \\const {s} = b.option({s}, "{s}", "{s}"); - , - .{ option_id, t, name, description orelse "" }, - .{}, - ); - } - - try self.options.put(name, .{ .type_name = t, .optional = default == null }); -} - -pub fn writeOptionsModule(self: *ConfigBuildgen, name: []const u8, item: Config.OptionsModule) !void { - const options_id = try allocFmtId(self.allocator, "options", name); - defer self.allocator.free(options_id); - - try self.writeLn( - "const {s} = b.addOptions();", - .{options_id}, - .{}, - ); - - for (item.keys(), item.values()) |option_name, value| { - try self.writeOption(option_name, value); - const option = self.options.get(option_name) orelse return error.MissingOption; - const option_id = try allocFmtId(self.allocator, "option", option_name); - defer self.allocator.free(option_id); - try self.writeLn( - \\{s}.addOption({s}{s}, "{s}", {s}); - , - .{ - options_id, - if (option.optional) "?" else "", - option.type_name, - option_name, - option_id, - }, - .{}, - ); - } - - try self.writeLn( - \\const {s} = {s}.createModule(); - , - .{ try fmtId("options_module", name), options_id }, - .{}, - ); - - try self.options_modules.put(name, item); -} - -pub fn writeModule(self: *ConfigBuildgen, name: []const u8, item: Config.Module) !void { - const module_id = try allocFmtId(self.allocator, "module", name); - defer self.allocator.free(module_id); - - try self.writeLn( - \\const {s} = b.createModule(.{{ - , - .{module_id}, - .{}, - ); - - try self.writeField( - "root_source_file", - if (item.root_source_file) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - try self.writeField("target", try resolvedTarget(item.target), .{ .str = .no_quote }); - try self.writeField("optimize", try optimize(item.optimize), .{ .str = .no_quote }); - try self.writeField("link_libc", item.link_libc, .{}); - try self.writeField("link_libcpp", item.link_libcpp, .{}); - try self.writeField("single_threaded", item.single_threaded, .{}); - try self.writeField("strip", item.strip, .{}); - try self.writeField("unwind_tables", item.unwind_tables, .{}); - try self.writeField("dwarf_format", item.dwarf_format, .{}); - try self.writeField("code_model", item.code_model, .{}); - try self.writeField("stack_protector", item.stack_protector, .{}); - try self.writeField("stack_check", item.stack_check, .{}); - try self.writeField("sanitize_c", item.sanitize_c, .{}); - try self.writeField("sanitize_thread", item.sanitize_thread, .{}); - try self.writeField("fuzz", item.fuzz, .{}); - try self.writeField("valgrind", item.valgrind, .{}); - try self.writeField("pic", item.pic, .{}); - try self.writeField("red_zone", item.red_zone, .{}); - try self.writeField("omit_frame_pointer", item.omit_frame_pointer, .{}); - try self.writeField("error_tracing", item.error_tracing, .{}); - - try self.writeLn("}});", .{}, .{}); - - if (item.include_paths) |include_paths| { - for (include_paths) |path| { - try self.writeLn( - \\{s}.addIncludePath({s}); - , - .{ module_id, try self.resolveLazyPath(path, SourcesForModules) }, - .{}, - ); - } - } - - if (item.link_libraries) |link_libraries| { - for (link_libraries) |link_library| { - var parts = std.mem.splitScalar(u8, link_library, ':'); - const dep_name = parts.first(); - const artifact_name = if (parts.next()) |rest| rest else dep_name; - - const dep_id = try allocFmtId(self.allocator, "dep", dep_name); - defer self.allocator.free(dep_id); - - try self.writeLn( - \\{s}.linkLibrary({s}.artifact("{s}")); - , - .{ module_id, dep_id, artifact_name }, - .{}, - ); - } - } - - if (item.private orelse true) { - try self.writeLn( - \\b.modules.put(b.dupe("{s}"), {s}) catch @panic("OOM"); - , - .{ name, module_id }, - .{}, - ); - } - try self.modules.put(name, item); -} - -pub fn writeExecutable(self: *ConfigBuildgen, name: []const u8, item: Config.Executable) !void { - const module_id = try self.allocModuleId(name, item.root_module); - defer self.allocator.free(module_id); - - const exe_id = try allocFmtId(self.allocator, "exe", name); - defer self.allocator.free(exe_id); - - try self.writeLn( - "const {s} = b.addExecutable(.{{", - .{exe_id}, - .{}, - ); - - try self.writeField("name", name, .{}); - try self.writeField("version", try semanticVersion(item.version), .{ .str = .no_quote }); - try self.writeField("root_module", module_id, .{ .str = .no_quote }); - try self.writeField("linkage", item.linkage, .{}); - try self.writeField("max_rss", item.max_rss, .{}); - try self.writeField("use_llvm", item.use_llvm, .{}); - try self.writeField("use_lld", item.use_lld, .{}); - try self.writeField( - "zig_lib_dir", - if (item.zig_lib_dir) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - try self.writeField( - "win32_manifest", - if (item.win32_manifest) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - - try self.writeLn("}});", .{}, .{}); - try self.writer.writeAll("\n"); - - const install_exe_id = try allocFmtId(self.allocator, "install_exe", name); - defer self.allocator.free(install_exe_id); - - const tls_install_exe_id = try allocFmtId(self.allocator, "tls_install_exe", name); - defer self.allocator.free(tls_install_exe_id); - - const run_exe_id = try allocFmtId(self.allocator, "run_exe", name); - defer self.allocator.free(run_exe_id); - - const tls_run_exe_id = try allocFmtId(self.allocator, "tls_run_exe", name); - defer self.allocator.free(tls_run_exe_id); - - try self.writeLn( - \\const {s} = b.addInstallArtifact({s}, .{{ - , - .{ install_exe_id, exe_id }, - .{}, - ); - try self.writeField("dest_sub_path", item.dest_sub_path, .{}); - try self.writeLn("}});", .{}, .{}); - try self.writer.writeAll("\n"); - - try self.writeLn( - \\const {s} = b.step("build-exe:{s}", "Install the {s} executable"); - \\{s}.dependOn(&{s}.step); - \\b.getInstallStep().dependOn(&{s}.step); - \\ - \\const {s} = b.addRunArtifact({s}); - \\if (b.args) |args| {s}.addArgs(args); - \\const {s} = b.step("run:{s}", "Run the {s} executable"); - \\{s}.dependOn(&{s}.step); - , - .{ - tls_install_exe_id, - name, - name, - tls_install_exe_id, - install_exe_id, - install_exe_id, - run_exe_id, - exe_id, - run_exe_id, - tls_run_exe_id, - name, - name, - tls_run_exe_id, - run_exe_id, - }, - .{}, - ); - try self.executables.put(name, item); -} - -pub fn writeLibrary(self: *ConfigBuildgen, name: []const u8, item: Config.Library) !void { - const module_id = try self.allocModuleId(name, item.root_module); - defer self.allocator.free(module_id); - - const lib_id = try allocFmtId(self.allocator, "lib", name); - defer self.allocator.free(lib_id); - - try self.writeLn( - "const {s} = b.addLibrary(.{{", - .{lib_id}, - .{}, - ); - - try self.writeField("name", name, .{}); - try self.writeField("version", try semanticVersion(item.version), .{ .str = .no_quote }); - try self.writeField("root_module", module_id, .{ .str = .no_quote }); - try self.writeField("linkage", item.linkage, .{}); - try self.writeField("max_rss", item.max_rss, .{}); - try self.writeField("use_llvm", item.use_llvm, .{}); - try self.writeField("use_lld", item.use_lld, .{}); - try self.writeField( - "zig_lib_dir", - if (item.zig_lib_dir) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - try self.writeField( - "win32_manifest", - if (item.win32_manifest) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - - try self.writeLn("}});", .{}, .{}); - try self.writer.writeAll("\n"); - - if (item.linker_allow_shlib_undefined) |linker_allow_shlib_undefined| { - try self.writeLn( - "{s}.linker_allow_shlib_undefined = {};", - .{ lib_id, linker_allow_shlib_undefined }, - .{}, - ); - } - - const install_lib_id = try allocFmtId(self.allocator, "install_lib", name); - defer self.allocator.free(install_lib_id); - - const tls_install_lib_id = try allocFmtId(self.allocator, "tls_install_lib", name); - defer self.allocator.free(tls_install_lib_id); - - try self.writeLn( - \\const {s} = b.addInstallArtifact({s}, .{{ - , - .{ install_lib_id, lib_id }, - .{}, - ); - try self.writeField("dest_sub_path", item.dest_sub_path, .{}); - try self.writeLn("}});", .{}, .{}); - try self.writer.writeAll("\n"); - - try self.writeLn( - \\const {s} = b.step("build-lib:{s}", "Install the {s} library"); - \\{s}.dependOn(&{s}.step); - \\b.getInstallStep().dependOn(&{s}.step); - , - .{ - tls_install_lib_id, - name, - name, - tls_install_lib_id, - install_lib_id, - install_lib_id, - }, - .{}, - ); -} - -pub fn writeObject(self: *ConfigBuildgen, name: []const u8, item: Config.Object) !void { - const module_id = try self.allocModuleId(name, item.root_module); - defer self.allocator.free(module_id); - - const obj_id = try allocFmtId(self.allocator, "obj", name); - defer self.allocator.free(obj_id); - - try self.writeLn( - "const {s} = b.addObject(.{{", - .{obj_id}, - .{}, - ); - - try self.writeField("name", name, .{}); - try self.writeField("root_module", module_id, .{ .str = .no_quote }); - try self.writeField("max_rss", item.max_rss, .{}); - try self.writeField("use_llvm", item.use_llvm, .{}); - try self.writeField("use_lld", item.use_lld, .{}); - try self.writeField( - "zig_lib_dir", - if (item.zig_lib_dir) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - - try self.writeLn("}});", .{}, .{}); - try self.writer.writeAll("\n"); - - const install_obj_id = try allocFmtId(self.allocator, "install_obj", name); - defer self.allocator.free(install_obj_id); - - const tls_install_obj_id = try allocFmtId(self.allocator, "tls_install_obj", name); - defer self.allocator.free(tls_install_obj_id); - - try self.writeLn( - \\const {s} = b.addInstallArtifact({s}, .{{}}); - \\const {s} = b.step("build-obj:{s}", "Install the {s} object"); - \\{s}.dependOn(&{s}.step); - \\b.getInstallStep().dependOn(&{s}.step); - , - .{ - install_obj_id, - obj_id, - tls_install_obj_id, - name, - name, - tls_install_obj_id, - install_obj_id, - install_obj_id, - }, - .{}, - ); -} - -pub fn writeTest(self: *ConfigBuildgen, name: []const u8, item: Config.Test) !void { - const module_id = try self.allocModuleId(name, item.root_module); - defer self.allocator.free(module_id); - - const test_id = try allocFmtId(self.allocator, "test", name); - defer self.allocator.free(test_id); - - try self.writeLn( - "const {s} = b.addTest(.{{", - .{test_id}, - .{}, - ); - - try self.writeField("name", name, .{}); - try self.writeField("root_module", module_id, .{ .str = .no_quote }); - try self.writeField("max_rss", item.max_rss, .{}); - try self.writeField("use_llvm", item.use_llvm, .{}); - try self.writeField("use_lld", item.use_lld, .{}); - try self.writeField( - "zig_lib_dir", - if (item.zig_lib_dir) |f| try self.resolveLazyPath(f, SourcesForModules) else null, - .{ .str = .no_quote }, - ); - const filters = try std.fmt.allocPrint( - self.allocator, - \\b.option([][]const u8, "{s}.filters", "{s} test filters") orelse {s} - , - .{ - name, - name, - (try strSliceLiteral(item.filters)) orelse "&.{}", - }, - ); - defer self.allocator.free(filters); - try self.writeField("filters", filters, .{ .str = .no_quote }); - - try self.writeLn("}});", .{}, .{}); - - const install_test_id = try allocFmtId(self.allocator, "install_test", name); - defer self.allocator.free(install_test_id); - - const tls_install_test_id = try allocFmtId(self.allocator, "tls_install_test", name); - defer self.allocator.free(tls_install_test_id); - - const run_test_id = try allocFmtId(self.allocator, "run_test", name); - defer self.allocator.free(run_test_id); - - const tls_run_test_id = try allocFmtId(self.allocator, "tls_run_test", name); - defer self.allocator.free(tls_run_test_id); - - try self.writeLn( - \\const {s} = b.addInstallArtifact({s}, .{{}}); - \\const {s} = b.step("build-test:{s}", "Install the {s} test"); - \\{s}.dependOn(&{s}.step); - \\ - \\const {s} = b.addRunArtifact({s}); - \\const {s} = b.step("test:{s}", "Run the {s} test"); - \\{s}.dependOn(&{s}.step); - \\tls_run_test.dependOn(&{s}.step); - , - .{ - install_test_id, - test_id, - tls_install_test_id, - name, - name, - tls_install_test_id, - install_test_id, - run_test_id, - test_id, - tls_run_test_id, - name, - name, - tls_run_test_id, - run_test_id, - run_test_id, - }, - .{}, - ); -} - -pub fn writeFmt(self: *ConfigBuildgen, name: []const u8, item: Config.Fmt) !void { - const fmt_id = try allocFmtId(self.allocator, "fmt", name); - defer self.allocator.free(fmt_id); - - try self.writeLn( - \\const {s} = b.addFmt(.{{ - , - .{fmt_id}, - .{}, - ); - - try self.writeField("paths", try strSliceLiteral(item.paths) orelse "&.{}", .{ .str = .no_quote }); - try self.writeField("exclude_paths", try strSliceLiteral(item.exclude_paths) orelse "&.{}", .{ .str = .no_quote }); - try self.writeField("check", item.check, .{}); - - try self.writeLn("}});", .{}, .{}); - try self.writer.writeAll("\n"); - - const tls_run_fmt_id = try allocFmtId(self.allocator, "tls_run_fmt", name); - defer self.allocator.free(tls_run_fmt_id); - - try self.writeLn( - \\const {s} = b.step("fmt:{s}", "Run the {s} fmt"); - \\{s}.dependOn(&{s}.step); - \\tls_run_fmt.dependOn(&{s}.step); - , - .{ tls_run_fmt_id, name, name, tls_run_fmt_id, fmt_id, fmt_id }, - .{}, - ); -} - -pub fn writeRun(self: *ConfigBuildgen, name: []const u8, item: Config.Run) !void { - const Args = @import("Args.zig"); - - const args = try Args.initFromString(self.allocator, item); - defer args.deinit(); - - const run_id = try allocFmtId(self.allocator, "run", name); - defer self.allocator.free(run_id); - - const tls_run_id = try allocFmtId(self.allocator, "tls_run", name); - defer self.allocator.free(tls_run_id); - - try self.writeLn( - \\const {s} = b.addSystemCommand({s}); - \\const {s} = b.step("run:{s}", "Run the {s} run"); - \\{s}.dependOn(&{s}.step); - , - .{ - run_id, - (try strSliceLiteral(args.args.items)).?, - tls_run_id, - name, - name, - tls_run_id, - run_id, - }, - .{}, - ); - try self.runs.put(name, item); -} - -pub fn writeDependency(self: *ConfigBuildgen, name: []const u8, item: Config.Dependency) !void { - if (item.args) |args| { - try self.writeLn( - \\const {s} = b.dependency("{s}", .{{ - , - .{ try fmtId("dep", name), name }, - .{}, - ); - for (args.keys(), args.values()) |key, value| { - switch (value) { - .bool => |b| try self.writeField(key, b, .{}), - .int => |i| try self.writeField(key, i, .{}), - .float => |f| try self.writeField(key, f, .{}), - .@"enum" => |e| if (std.mem.eql(u8, e, "optimize") or std.mem.eql(u8, e, "target")) { - try self.writeField(key, e, .{ .str = .no_quote }); - } else if (self.options.contains(e)) { - const option_id = try allocFmtId(self.allocator, "option", e); - defer self.allocator.free(option_id); - try self.writeField(key, option_id, .{ .str = .no_quote }); - } else { - try self.writeField(key, e, .{ .str = .enum_literal }); - }, - .string => |s| try self.writeField(key, s, .{}), - .null => { - // skip - }, - } - } - try self.writeLn("}});", .{}, .{}); - } else { - try self.writeLn( - \\const {s} = b.dependency("{s}", .{{ - \\ .optimize = optimize, - \\ .target = target, - \\}}); - , - .{ try fmtId("dep", name), name }, - .{}, - ); - } - try self.dependencies.put(name, undefined); -} - -pub fn writeImport(self: *ConfigBuildgen, name: []const u8, imports: [][]const u8) !void { - for (imports) |import| { - const module_id = try allocFmtId(self.allocator, "module", name); - defer self.allocator.free(module_id); - - try self.writeLn( - \\{s}.addImport("{s}", {s}); - , - .{ module_id, import, try self.resolveImport(import) }, - .{}, - ); - } -} - -const WriteOpts = struct { - indent: u8 = 4, -}; - -fn writeLn(self: *ConfigBuildgen, comptime fmt: []const u8, args: anytype, comptime opts: WriteOpts) !void { - // first format the string - const str = try std.fmt.allocPrint(self.allocator, fmt, args); - defer self.allocator.free(str); - - // then add indentation + newlines - var it = std.mem.splitScalar(u8, str, '\n'); - while (it.next()) |line| { - if (line.len == 0) { - try self.writer.writeAll("\n"); - } else { - try self.writer.print("{s}{s}\n", .{ [_]u8{' '} ** opts.indent, line }); - } - } -} - -const WriteFieldOpts = struct { - indent: u8 = 8, - str: enum { quote, no_quote, enum_literal } = .quote, -}; - -fn writeFields(self: *ConfigBuildgen, kvs: anytype, comptime opts: WriteFieldOpts) !void { - for (kvs) |kv| { - self.writeField(kv[0], kv[1], opts); - } -} - -fn writeField(self: *ConfigBuildgen, key: []const u8, value: anytype, comptime opts: WriteFieldOpts) !void { - const value_typ1 = @typeInfo(@TypeOf(value)); - const value_typ, const value_ = if (value_typ1 == .optional) blk: { - if (value == null) { - return; - } - break :blk .{ @typeInfo(value_typ1.optional.child), value.? }; - } else .{ value_typ1, value }; - - const value_str = switch (value_typ) { - .@"enum" => try std.fmt.allocPrint(self.allocator, ".{}", .{std.zig.fmtId(@tagName(value_))}), - .bool => if (value_) "true" else "false", - .int => try std.fmt.allocPrint(self.allocator, "{d}", .{value_}), - // string - .pointer => |p| blk: { - if (p.size == .slice) { - const childInfo = @typeInfo(p.child); - if (childInfo == .int and childInfo.int.bits == 8) { - switch (opts.str) { - .quote => break :blk try std.fmt.allocPrint(self.allocator, "\"{s}\"", .{value_}), - .no_quote => break :blk value_, - .enum_literal => break :blk try std.fmt.allocPrint(self.allocator, ".{}", .{std.zig.fmtId(value_)}), - } - } - } - return error.InvalidType; - }, - else => return error.InvalidType, - }; - - try self.writeLn( - ".{s} = {s},", - .{ - key, - value_str, - }, - .{ .indent = opts.indent }, - ); -} - -/// return the module name, writing the module definition first if necessary -fn allocModuleId(self: *ConfigBuildgen, name: []const u8, item: Config.ModuleLink) ![]const u8 { - return try allocFmtId( - self.allocator, - "module", - switch (item) { - .name => |n| n, - .module => |m| blk: { - const n = m.name orelse name; - try self.writeModule(n, m); - try self.writer.writeAll("\n"); - break :blk n; - }, - }, - ); -} - -/// used for temp strings -/// This is safe because only a single consumer of scratch exists at a time -threadlocal var scratch: [4096]u8 = undefined; - -fn lazyPathSlice(paths_maybe: ?[][]const u8) !?[]u8 { - if (paths_maybe) |paths| { - var w = std.io.fixedBufferStream(&scratch); - const writer = w.writer(); - - try writer.writeAll("&[_]std.Build.LazyPath{ "); - - for (paths, 0..) |path, i| { - try writer.print("b.path(\"{s}\")", .{path}); - if (i != paths.len - 1) { - try writer.writeAll(", "); - } - } - - try writer.writeAll(" }"); - return w.getWritten(); - } else { - return null; - } -} -fn buildId(build_id: ?[]const u8) !?[]const u8 { - if (build_id) |b| { - return try std.fmt.bufPrint( - &scratch, - \\std.zig.BuildId.parse("{s}") catch @panic("invalid build id") - , - .{b}, - ); - } else { - return null; - } -} - -fn resolvedTarget(target: ?[]const u8) ![]const u8 { - if (target) |t| { - return try std.fmt.bufPrint( - &scratch, - \\b.resolveTargetQuery(std.Target.Query.parse(.{{.arch_os_abi = "{s}"}}) catch @panic("invalid target")) - , - .{t}, - ); - } else { - return @constCast("target"); - } -} - -fn optimize(opt: ?std.builtin.OptimizeMode) ![]const u8 { - if (opt) |o| { - return try std.fmt.bufPrint( - &scratch, - \\.{s} - , - .{@tagName(o)}, - ); - } else { - return @constCast("optimize"); - } -} - -fn semanticVersion(version: ?[]const u8) !?[]u8 { - if (version) |v| { - return try std.fmt.bufPrint( - &scratch, - "std.SemanticVersion.parse(\"{s}\") catch @panic(\"invalid version\")", - .{v}, - ); - } else { - return null; - } -} - -pub fn strSliceLiteral(str_slice_maybe: ?[]const []const u8) !?[]u8 { - if (str_slice_maybe) |str_slice| { - var w = std.io.fixedBufferStream(&scratch); - const writer = w.writer(); - - try writer.writeAll("&[_][]const u8{ "); - - for (str_slice, 0..) |str, i| { - try writer.print("\"{s}\"", .{str}); - if (i != str_slice.len - 1) { - try writer.writeAll(", "); - } - } - - try writer.writeAll(" }"); - return w.getWritten(); - } else { - return null; - } -} - -pub fn allocStrSliceLiteral(allocator: std.mem.Allocator, str_slice_maybe: ?[]const []const u8) !?[]u8 { - return try allocator.dupe(u8, try strSliceLiteral(str_slice_maybe) orelse return null); -} - -pub fn strTupleLiteral(str_slice_maybe: ?[]const []const u8) !?[]u8 { - if (str_slice_maybe) |str_slice| { - var w = std.io.fixedBufferStream(&scratch); - const writer = w.writer(); - - try writer.writeAll(".{ "); - - for (str_slice, 0..) |str, i| { - try writer.print("\"{s}\"", .{str}); - if (i != str_slice.len - 1) { - try writer.writeAll(", "); - } - } - - try writer.writeAll(" }"); - return w.getWritten(); - } else { - return null; - } -} - -fn enumSliceLiteral(enum_name: []const u8, enum_slice: []const []const u8) ![]u8 { - var w = std.io.fixedBufferStream(&scratch); - const writer = w.writer(); - - try writer.print("&[_]{s}{{ ", .{enum_name}); - - for (enum_slice, 0..) |enum_value, i| { - try writer.print(".{s}", .{enum_value}); - if (i != enum_slice.len - 1) { - try writer.writeAll(", "); - } - } - - try writer.writeAll(" }"); - return w.getWritten(); -} - -fn resolveImport(self: *ConfigBuildgen, import: []const u8) ![]const u8 { - if (self.modules.contains(import)) { - return try fmtId("module", import); - } else if (self.options_modules.contains(import)) { - return try fmtId("options_module", import); - } else if (self.dependencies.contains(import)) { - const dep_id = try allocFmtId(self.allocator, "dep", import); - defer self.allocator.free(dep_id); - - return try std.fmt.bufPrint(&scratch, "{s}.module(\"{s}\")", .{ dep_id, import }); - } else { - // one last try, maybe it's a dependency with a custom module, eg "dep:module_a" - var parts = std.mem.splitScalar(u8, import, ':'); - const first = parts.first(); - if (self.dependencies.contains(first)) { - const dep_id = try allocFmtId(self.allocator, "dep", first); - defer self.allocator.free(dep_id); - return try std.fmt.bufPrint(&scratch, "{s}.module(\"{s}\")", .{ dep_id, parts.rest() }); - } - - return try std.fmt.bufPrint(&scratch, "@panic(\"missing import {s}\")", .{import}); - } -} - -const SourcesForWriteFiles = .{ .write_files, .dependencies, .options }; -const SourcesForOptions = .{ .write_files, .dependencies }; -const SourcesForModules = .{ .write_files, .dependencies, .options }; - -/// Use colon delimiters to differentiate between -/// - simple paths, eg "src/main.zig" -/// - writefiles paths, eg "writefiles:src/main.zig" -/// - TODO options paths, eg "options:foo" -/// - dependency paths, both -/// - named writefiles paths, eg "dep:writefiles:src/main.zig" -/// - named lazypath paths, eg "dep:src/main.zig" -/// Use sources to limit and order which sources to try -/// - write_files, options, dependencies, runs_output -fn resolveLazyPath(self: *ConfigBuildgen, path: []const u8, comptime sources: anytype) ![]const u8 { - comptime { - for (sources) |source| { - switch (source) { - .write_files, .options, .dependencies, .runs_output => {}, - else => @compileError("Invalid source"), - } - } - } - var parts = std.mem.splitScalar(u8, path, ':'); - const first = parts.first(); - inline for (sources) |source| { - switch (source) { - .write_files => if (self.write_files.contains(first)) { - const write_files_id = try allocFmtId(self.allocator, "write_files", first); - defer self.allocator.free(write_files_id); - - return try std.fmt.bufPrint( - &scratch, - "{s}.getDirectory().path(b, \"{s}\")", - .{ write_files_id, parts.rest() }, - ); - }, - .options => if (self.options.get(first)) |option| blk: { - if (!std.mem.eql(u8, option.type_name, "std.Build.LazyPath")) { - break :blk; - } - - const option_id = try allocFmtId(self.allocator, "option", first); - defer self.allocator.free(option_id); - - if (option.optional) { - return try std.fmt.bufPrint( - &scratch, - "{s} orelse @panic(\"missing option {s}\")", - .{ option_id, first }, - ); - } else { - return try std.fmt.bufPrint( - &scratch, - "{s}", - .{option_id}, - ); - } - }, - .dependencies => if (self.dependencies.contains(first)) blk: { - const dep_id = try allocFmtId(self.allocator, "dep", first); - defer self.allocator.free(dep_id); - - const next = parts.next() orelse break :blk; - const last = parts.next(); - // TODO there should be an error if there's even more data after?, eg dep:writefiles:src/main.zig:wtf_is_this - - if (last) |l| { - return try std.fmt.bufPrint( - &scratch, - "{s}.namedWriteFiles(\"{s}\").getDirectory().path(b, \"{s}\")", - .{ dep_id, next, l }, - ); - } else { - return try std.fmt.bufPrint( - &scratch, - "{s}.namedLazyPath(\"{s}\")", - .{ dep_id, next }, - ); - } - }, - else => unreachable, - } - } - return try std.fmt.bufPrint( - &scratch, - "b.path(\"{s}\")", - .{path}, - ); -} - -/// wrapper around std.zig.fmtId that includes a prefix string -fn fmtId(prefix: []const u8, name: []const u8) ![]const u8 { - var fmt_scratch: [4096]u8 = undefined; - return try std.fmt.bufPrint(&scratch, "{}", .{std.zig.fmtId(try std.fmt.bufPrint(&fmt_scratch, "{s}_{s}", .{ prefix, name }))}); -} - -/// wrapper around std.zig.fmtId that includes a prefix string, consumer is responsible for freeing -fn allocFmtId(allocator: std.mem.Allocator, prefix: []const u8, name: []const u8) ![]const u8 { - return try allocator.dupe(u8, try fmtId(prefix, name)); -} diff --git a/src/build_runner.zig b/src/build_runner.zig new file mode 100644 index 0000000..b3a023f --- /dev/null +++ b/src/build_runner.zig @@ -0,0 +1,472 @@ +//! Configures a Zig build graph from a zbuild Config. +//! Replaces string-concatenation codegen (ConfigBuildgen) with direct API calls. + +const std = @import("std"); +const Config = @import("Config.zig"); + +pub fn configureBuild(b: *std.Build) !void { + const config = try Config.parseFromFile(b.allocator, "build.zig.zon", null); + try configureWithConfig(b, config); +} + +fn configureWithConfig(b: *std.Build, config: Config) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + var runner = BuildRunner{ + .b = b, + .config = config, + .target = target, + .optimize = optimize, + .modules = std.StringHashMap(*std.Build.Module).init(b.allocator), + .dependencies = std.StringHashMap(*std.Build.Dependency).init(b.allocator), + .options_modules = std.StringHashMap(*std.Build.Module).init(b.allocator), + }; + + // Phase 1: Create options modules + if (config.options_modules) |options_modules| { + for (options_modules.keys(), options_modules.values()) |name, options| { + try runner.createOptionsModule(name, options); + } + } + + // Phase 2: Create dependencies + if (config.dependencies) |dependencies| { + for (dependencies.keys(), dependencies.values()) |name, dep| { + try runner.createDependency(name, dep); + } + } + + // Phase 3: Create named modules + if (config.modules) |modules| { + for (modules.keys(), modules.values()) |name, module| { + const m = try runner.createModule(module, name); + if (!(module.private orelse true)) { + b.modules.put(b.dupe(name), m) catch @panic("OOM"); + } + try runner.modules.put(name, m); + } + } + + // Phase 4: Create executables + if (config.executables) |executables| { + for (executables.keys(), executables.values()) |name, exe| { + try runner.createExecutable(name, exe); + } + } + + // Phase 5: Create libraries + if (config.libraries) |libraries| { + for (libraries.keys(), libraries.values()) |name, lib| { + try runner.createLibrary(name, lib); + } + } + + // Phase 6: Create objects + if (config.objects) |objects| { + for (objects.keys(), objects.values()) |name, obj| { + try runner.createObject(name, obj); + } + } + + // Phase 7: Create tests + var tls_run_test: ?*std.Build.Step = null; + + if (config.modules) |modules| { + if (modules.count() > 0 or (config.tests != null and config.tests.?.count() > 0)) { + tls_run_test = b.step("test", "Run all tests"); + } + for (modules.keys()) |name| { + if (config.tests == null or !config.tests.?.contains(name)) { + try runner.createTest(name, .{ + .root_module = .{ .name = name }, + .filters = &.{}, + }, tls_run_test.?); + } + } + } + + if (config.tests) |tests| { + if (tls_run_test == null) { + tls_run_test = b.step("test", "Run all tests"); + } + for (tests.keys(), tests.values()) |name, t| { + try runner.createTest(name, t, tls_run_test.?); + } + } + + // Phase 8: Create fmts + if (config.fmts) |fmts| { + const tls_run_fmt = b.step("fmt", "Run all fmts"); + for (fmts.keys(), fmts.values()) |name, fmt| { + try runner.createFmt(name, fmt, tls_run_fmt); + } + } + + // Phase 9: Create runs + if (config.runs) |runs| { + for (runs.keys(), runs.values()) |name, run| { + runner.createRun(name, run); + } + } + + // Phase 10: Wire imports for all modules + try runner.wireAllImports(config); +} + +const BuildRunner = struct { + b: *std.Build, + config: Config, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + modules: std.StringHashMap(*std.Build.Module), + dependencies: std.StringHashMap(*std.Build.Dependency), + options_modules: std.StringHashMap(*std.Build.Module), + + fn createModule(self: *BuildRunner, module: Config.Module, name: []const u8) !*std.Build.Module { + const m = self.b.createModule(.{ + .root_source_file = if (module.root_source_file) |f| self.resolveLazyPath(f) else null, + .target = if (module.target) |t| self.resolveTarget(t) else self.target, + .optimize = module.optimize orelse self.optimize, + .link_libc = module.link_libc, + .link_libcpp = module.link_libcpp, + .single_threaded = module.single_threaded, + .strip = module.strip, + .unwind_tables = module.unwind_tables, + .dwarf_format = module.dwarf_format, + .code_model = module.code_model, + .error_tracing = module.error_tracing, + .omit_frame_pointer = module.omit_frame_pointer, + .pic = module.pic, + .red_zone = module.red_zone, + .sanitize_c = module.sanitize_c, + .sanitize_thread = module.sanitize_thread, + .stack_check = module.stack_check, + .stack_protector = module.stack_protector, + .fuzz = module.fuzz, + .valgrind = module.valgrind, + }); + + if (module.include_paths) |paths| { + for (paths) |path| { + m.addIncludePath(self.resolveLazyPath(path)); + } + } + + if (module.link_libraries) |libs| { + for (libs) |lib| { + var parts = std.mem.splitScalar(u8, lib, ':'); + const dep_name = parts.first(); + const artifact_name = if (parts.next()) |rest| rest else dep_name; + if (self.dependencies.get(dep_name)) |dep| { + m.linkLibrary(dep.artifact(artifact_name)); + } + } + } + + try self.modules.put(name, m); + return m; + } + + fn resolveModuleLink(self: *BuildRunner, link: Config.ModuleLink, fallback_name: []const u8) !*std.Build.Module { + switch (link) { + .name => |n| { + return self.modules.get(n) orelse { + std.log.err("zbuild: module '{s}' not found", .{n}); + return error.ModuleNotFound; + }; + }, + .module => |m| { + const name = m.name orelse fallback_name; + return try self.createModule(m, name); + }, + } + } + + fn createDependency(self: *BuildRunner, name: []const u8, dep: Config.Dependency) !void { + _ = dep; + const d = self.b.dependency(name, .{}); + try self.dependencies.put(name, d); + } + + fn createOptionsModule(self: *BuildRunner, name: []const u8, options: Config.OptionsModule) !void { + const opts = self.b.addOptions(); + for (options.keys(), options.values()) |opt_name, opt_value| { + self.addOption(opts, opt_name, opt_value); + } + const m = opts.createModule(); + try self.options_modules.put(name, m); + } + + fn addOption(self: *BuildRunner, opts: *std.Build.Step.Options, name: []const u8, value: Config.Option) void { + _ = self; + switch (value) { + .bool => |v| { + const val = opts.step.owner.option(bool, name, v.description orelse ""); + opts.addOption(bool, name, val orelse v.default orelse false); + }, + .int => |v| { + const val = opts.step.owner.option(i64, name, v.description orelse ""); + opts.addOption(i64, name, val orelse v.default orelse 0); + }, + .float => |v| { + const val = opts.step.owner.option(f64, name, v.description orelse ""); + opts.addOption(f64, name, val orelse v.default orelse 0.0); + }, + .string => |v| { + const val = opts.step.owner.option([]const u8, name, v.description orelse ""); + if (val orelse v.default) |s| { + opts.addOption([]const u8, name, s); + } + }, + .list => |v| { + const val = opts.step.owner.option([]const []const u8, name, v.description orelse ""); + if (val orelse v.default) |l| { + opts.addOption([]const []const u8, name, l); + } + }, + .@"enum" => |v| { + const val = opts.step.owner.option([]const u8, name, v.description orelse ""); + if (val orelse v.default) |e| { + opts.addOption([]const u8, name, e); + } + }, + .enum_list => |v| { + const val = opts.step.owner.option([]const []const u8, name, v.description orelse ""); + if (val orelse v.default) |e| { + opts.addOption([]const []const u8, name, e); + } + }, + .build_id => {}, + .lazy_path => {}, + .lazy_path_list => {}, + } + } + + fn createExecutable(self: *BuildRunner, name: []const u8, exe: Config.Executable) !void { + const root_module = try self.resolveModuleLink(exe.root_module, name); + + const artifact = self.b.addExecutable(.{ + .name = name, + .version = if (exe.version) |v| std.SemanticVersion.parse(v) catch null else null, + .root_module = root_module, + .linkage = exe.linkage, + .max_rss = exe.max_rss, + .use_llvm = exe.use_llvm, + .use_lld = exe.use_lld, + .zig_lib_dir = if (exe.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + .win32_manifest = if (exe.win32_manifest) |d| self.resolveLazyPath(d) else null, + }); + + const install = self.b.addInstallArtifact(artifact, .{ + .dest_sub_path = if (exe.dest_sub_path) |p| @ptrCast(p) else null, + }); + + const tls_install = self.b.step( + self.b.fmt("build-exe:{s}", .{name}), + self.b.fmt("Install the {s} executable", .{name}), + ); + tls_install.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + + const run = self.b.addRunArtifact(artifact); + if (self.b.args) |args| run.addArgs(args); + const tls_run = self.b.step( + self.b.fmt("run:{s}", .{name}), + self.b.fmt("Run the {s} executable", .{name}), + ); + tls_run.dependOn(&run.step); + } + + fn createLibrary(self: *BuildRunner, name: []const u8, lib: Config.Library) !void { + const root_module = try self.resolveModuleLink(lib.root_module, name); + + const artifact = self.b.addLibrary(.{ + .name = name, + .version = if (lib.version) |v| std.SemanticVersion.parse(v) catch null else null, + .root_module = root_module, + .linkage = lib.linkage, + .max_rss = lib.max_rss, + .use_llvm = lib.use_llvm, + .use_lld = lib.use_lld, + .zig_lib_dir = if (lib.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + .win32_manifest = if (lib.win32_manifest) |d| self.resolveLazyPath(d) else null, + }); + + if (lib.linker_allow_shlib_undefined) |v| { + artifact.linker_allow_shlib_undefined = v; + } + + const install = self.b.addInstallArtifact(artifact, .{ + .dest_sub_path = if (lib.dest_sub_path) |p| @ptrCast(p) else null, + }); + + const tls_install = self.b.step( + self.b.fmt("build-lib:{s}", .{name}), + self.b.fmt("Install the {s} library", .{name}), + ); + tls_install.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + } + + fn createObject(self: *BuildRunner, name: []const u8, obj: Config.Object) !void { + const root_module = try self.resolveModuleLink(obj.root_module, name); + + const artifact = self.b.addObject(.{ + .name = name, + .root_module = root_module, + .max_rss = obj.max_rss, + .use_llvm = obj.use_llvm, + .use_lld = obj.use_lld, + .zig_lib_dir = if (obj.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + }); + + const install = self.b.addInstallArtifact(artifact, .{}); + const tls_install = self.b.step( + self.b.fmt("build-obj:{s}", .{name}), + self.b.fmt("Install the {s} object", .{name}), + ); + tls_install.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + } + + fn createTest(self: *BuildRunner, name: []const u8, t: Config.Test, tls_run_test: *std.Build.Step) !void { + const root_module = try self.resolveModuleLink(t.root_module, name); + + const filters_option = self.b.option( + []const []const u8, + self.b.fmt("{s}.filters", .{name}), + self.b.fmt("{s} test filters", .{name}), + ); + + const artifact = self.b.addTest(.{ + .name = name, + .root_module = root_module, + .max_rss = t.max_rss, + .use_llvm = t.use_llvm, + .use_lld = t.use_lld, + .zig_lib_dir = if (t.zig_lib_dir) |d| self.resolveLazyPath(d) else null, + .filters = filters_option orelse if (t.filters.len > 0) t.filters else &.{}, + }); + + const install = self.b.addInstallArtifact(artifact, .{}); + const tls_install = self.b.step( + self.b.fmt("build-test:{s}", .{name}), + self.b.fmt("Install the {s} test", .{name}), + ); + tls_install.dependOn(&install.step); + + const run = self.b.addRunArtifact(artifact); + const tls_run = self.b.step( + self.b.fmt("test:{s}", .{name}), + self.b.fmt("Run the {s} test", .{name}), + ); + tls_run.dependOn(&run.step); + tls_run_test.dependOn(&run.step); + } + + fn createFmt(self: *BuildRunner, name: []const u8, fmt: Config.Fmt, tls_run_fmt: *std.Build.Step) !void { + const step = self.b.addFmt(.{ + .paths = fmt.paths orelse &.{}, + .exclude_paths = fmt.exclude_paths orelse &.{}, + .check = fmt.check orelse false, + }); + + const tls = self.b.step( + self.b.fmt("fmt:{s}", .{name}), + self.b.fmt("Run the {s} fmt", .{name}), + ); + tls.dependOn(&step.step); + tls_run_fmt.dependOn(&step.step); + } + + fn createRun(self: *BuildRunner, name: []const u8, cmd: Config.Run) void { + var args = std.ArrayList([]const u8).init(self.b.allocator); + var it = std.mem.splitScalar(u8, cmd, ' '); + while (it.next()) |arg| { + if (arg.len > 0) args.append(arg) catch @panic("OOM"); + } + + const run = self.b.addSystemCommand(args.items); + const tls = self.b.step( + self.b.fmt("run:{s}", .{name}), + self.b.fmt("Run the {s} command", .{name}), + ); + tls.dependOn(&run.step); + } + + fn wireAllImports(self: *BuildRunner, config: Config) !void { + if (config.modules) |modules| { + for (modules.keys(), modules.values()) |name, module| { + if (module.imports) |imports| { + const m = self.modules.get(name) orelse continue; + self.wireImports(m, imports); + } + } + } + // Wire imports for inline modules in executables/libraries/objects/tests + inline for (.{ config.executables, config.libraries, config.objects }) |maybe_map| { + if (maybe_map) |map| { + for (map.values()) |item| { + if (item.root_module == .module) { + if (item.root_module.module.imports) |imports| { + const name = item.root_module.module.name orelse continue; + const m = self.modules.get(name) orelse continue; + self.wireImports(m, imports); + } + } + } + } + } + if (config.tests) |tests| { + for (tests.values()) |t| { + if (t.root_module == .module) { + if (t.root_module.module.imports) |imports| { + const name = t.root_module.module.name orelse continue; + const m = self.modules.get(name) orelse continue; + self.wireImports(m, imports); + } + } + } + } + } + + fn wireImports(self: *BuildRunner, module: *std.Build.Module, imports: []const []const u8) void { + for (imports) |import_name| { + const resolved = self.resolveImport(import_name); + module.addImport(import_name, resolved); + } + } + + fn resolveImport(self: *BuildRunner, import_name: []const u8) *std.Build.Module { + if (self.modules.get(import_name)) |m| return m; + if (self.options_modules.get(import_name)) |m| return m; + var parts = std.mem.splitScalar(u8, import_name, ':'); + const first = parts.first(); + if (self.dependencies.get(first)) |dep| { + const module_name = if (parts.next()) |rest| rest else first; + return dep.module(module_name); + } + @panic(self.b.fmt("zbuild: unresolved import '{s}'", .{import_name})); + } + + fn resolveLazyPath(self: *BuildRunner, path: []const u8) std.Build.LazyPath { + var parts = std.mem.splitScalar(u8, path, ':'); + const first = parts.first(); + if (self.dependencies.get(first)) |dep| { + const next = parts.next() orelse return dep.namedLazyPath(first); + if (parts.next()) |last| { + return dep.namedWriteFiles(next).getDirectory().path(self.b, last); + } + return dep.namedLazyPath(next); + } + return self.b.path(path); + } + + fn resolveTarget(self: *BuildRunner, target_str: []const u8) std.Build.ResolvedTarget { + if (std.mem.eql(u8, target_str, "native")) return self.target; + return self.b.resolveTargetQuery( + std.Target.Query.parse(.{ .arch_os_abi = target_str }) catch @panic("invalid target"), + ); + } +}; diff --git a/src/cmd_sync.zig b/src/cmd_sync.zig index d589bec..cbccc26 100644 --- a/src/cmd_sync.zig +++ b/src/cmd_sync.zig @@ -4,11 +4,39 @@ const Allocator = std.mem.Allocator; const fatal = std.process.fatal; const GlobalOptions = @import("GlobalOptions.zig"); const Config = @import("Config.zig"); -const syncBuildFile = @import("sync_build_file.zig").syncBuildFile; + +const static_build_zig = + \\const std = @import("std"); + \\const zbuild = @import("zbuild"); + \\ + \\pub fn build(b: *std.Build) void { + \\ zbuild.configureBuild(b) catch |err| { + \\ std.log.err("zbuild: {}", .{err}); + \\ }; + \\} + \\ +; pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config) !void { + _ = gpa; + _ = arena; + _ = config; if (global_opts.no_sync) { fatal("--no-sync is incompatible with the sync command", .{}); } - try syncBuildFile(gpa, arena, config, global_opts, .{ .out_dir = global_opts.project_dir }); + + var opened_dir: ?std.fs.Dir = null; + defer if (opened_dir) |*d| d.close(); + + const dir = if (global_opts.project_dir.len > 0 and !mem.eql(u8, global_opts.project_dir, ".")) blk: { + opened_dir = try std.fs.cwd().openDir(global_opts.project_dir, .{}); + break :blk opened_dir.?; + } else std.fs.cwd(); + + dir.writeFile(.{ + .sub_path = "build.zig", + .data = static_build_zig, + }) catch |err| { + fatal("failed to write build.zig: {s}", .{@errorName(err)}); + }; } diff --git a/src/main.zig b/src/main.zig index 75f0e8e..6e4ef70 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,7 +5,8 @@ const Allocator = std.mem.Allocator; const mem = std.mem; pub const Config = @import("Config.zig"); -pub const ConfigBuildgen = @import("ConfigBuildgen.zig"); +pub const build_runner = @import("build_runner.zig"); +pub const configureBuild = build_runner.configureBuild; pub const Args = @import("Args.zig"); pub const GlobalOptions = @import("GlobalOptions.zig"); diff --git a/src/sync_build_file.zig b/src/sync_build_file.zig deleted file mode 100644 index 6435de0..0000000 --- a/src/sync_build_file.zig +++ /dev/null @@ -1,38 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const Config = @import("Config.zig"); -const ConfigBuildgen = @import("ConfigBuildgen.zig"); -const GlobalOptions = @import("GlobalOptions.zig"); -const runZigFmt = @import("run_zig.zig").runZigFmt; - -pub const SyncBuildFileOpts = struct { - out_dir: ?[]const u8 = null, - build_file: ?[]const u8 = null, -}; - -const max_bytes_zbuild_file = 16_000; - -pub fn syncBuildFile(gpa: Allocator, arena: Allocator, config: Config, global_opts: GlobalOptions, opts: SyncBuildFileOpts) !void { - const build_root_directory = std.fs.cwd(); - - const out_dir = if (opts.out_dir) |o| try build_root_directory.openDir(o, .{}) else build_root_directory; - const out_file = try out_dir.createFile(opts.build_file orelse "build.zig", .{ .truncate = true }); - errdefer out_file.close(); - const writer = out_file.writer().any(); - - var buildgen = ConfigBuildgen.init(arena, config, writer); - defer buildgen.deinit(); - - try buildgen.write(); - - // after writing, close the file and format it with `zig fmt`, which modifies files in-place - out_file.close(); - try runZigFmt( - gpa, - arena, - .{ .cwd = global_opts.project_dir, .stderr_behavior = .Ignore, .stdout_behavior = .Ignore }, - global_opts.getZigEnv(), - &[_][]const u8{opts.build_file orelse "build.zig"}, - ); -} diff --git a/test/sync.zig b/test/sync.zig index 5a0a70a..2466f80 100644 --- a/test/sync.zig +++ b/test/sync.zig @@ -3,9 +3,6 @@ const Allocator = std.mem.Allocator; const zbuild = @import("zbuild"); -/// set to false to help debug the generated build.zig file -const remove_build_file = true; - const cwd = "test"; const test_cases = &[_][]const u8{ @@ -17,37 +14,38 @@ const test_cases = &[_][]const u8{ "fixtures/basic6.build.zig.zon", }; -fn maybeCleanup(should_cleanup: bool) void { - if (should_cleanup) { - const dir = std.fs.cwd().openDir(cwd, .{}) catch return; - dir.deleteFile("build.zig") catch return; - dir.deleteFile("build.zig.zon") catch return; - } +fn cleanup() void { + const dir = std.fs.cwd().openDir(cwd, .{}) catch return; + dir.deleteFile("build.zig") catch {}; } -/// - Load the zbuild file -/// - Generate the build and manifest files -/// - Run `zig build --help` -fn testSync(gpa: Allocator, arena: Allocator, should_cleanup: bool, global_opts: zbuild.GlobalOptions) !void { - defer maybeCleanup(should_cleanup); +/// Test that each fixture can be parsed and that sync writes a valid build.zig +fn testSync(gpa: Allocator, arena: Allocator, global_opts: zbuild.GlobalOptions) !void { + defer cleanup(); + // Phase 1: Verify the config parses without error const config = try zbuild.Config.parseFromFile(arena, global_opts.zbuild_file, null); - try zbuild.build.exec( - gpa, - arena, - global_opts, - config, - .{ - .kind = .build, - .args = &[1][]const u8{"--help"}, - .stderr_behavior = .Ignore, - .stdout_behavior = .Ignore, - }, - ); + // Phase 2: Run sync to generate build.zig + try zbuild.sync.exec(gpa, arena, global_opts, config); + + // Phase 3: Verify build.zig was written with the static template + var opened_dir: ?std.fs.Dir = null; + defer if (opened_dir) |*d| d.close(); + + const dir = if (global_opts.project_dir.len > 0 and !std.mem.eql(u8, global_opts.project_dir, ".")) blk: { + opened_dir = try std.fs.cwd().openDir(global_opts.project_dir, .{}); + break :blk opened_dir.?; + } else std.fs.cwd(); + + const build_zig = try dir.readFileAlloc(gpa, "build.zig", 4096); + defer gpa.free(build_zig); + + // Verify it contains the zbuild import + try std.testing.expect(std.mem.indexOf(u8, build_zig, "zbuild.configureBuild") != null); } -test "zbuild build --help" { +test "zbuild sync generates static build.zig" { const allocator = std.testing.allocator; for (test_cases) |test_case| { @@ -69,6 +67,6 @@ test "zbuild build --help" { const global_opts = try zbuild.GlobalOptions.parseArgs(allocator, &args); defer global_opts.deinit(allocator); - try testSync(allocator, arena, remove_build_file, global_opts); + try testSync(allocator, arena, global_opts); } } From 87871b1a17decad1b7000ff88429249aff8a3e8c Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 09:11:57 -0400 Subject: [PATCH 06/62] test: add comprehensive Config parser unit tests 16 inline tests covering: minimal config, modules, executables (inline and named module refs), dependencies (with hash/lazy/args), libraries, tests, runs, options, options_modules, fmts, module imports, description/ keywords, and error cases (missing required fields, invalid versions). Co-Authored-By: Claude Opus 4.6 --- src/Config.zig | 419 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index f059423..2734732 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -1164,3 +1164,422 @@ fn Serializer(Writer: type) type { } }; } + +// -- Tests -- + +fn testParse(source: [:0]const u8) !Config { + const gpa = std.testing.allocator; + + var ast = try std.zig.Ast.parse(gpa, source, .zon); + defer ast.deinit(gpa); + + var zoir = try std.zig.ZonGen.generate(gpa, ast, .{}); + defer zoir.deinit(gpa); + + if (zoir.hasCompileErrors()) return error.ParseZoir; + + return parseFromZoir(gpa, "", zoir, ast, null); +} + +fn testParseFail(source: [:0]const u8) !void { + const gpa = std.testing.allocator; + + var ast = try std.zig.Ast.parse(gpa, source, .zon); + defer ast.deinit(gpa); + + var zoir = try std.zig.ZonGen.generate(gpa, ast, .{}); + defer zoir.deinit(gpa); + + if (zoir.hasCompileErrors()) return; // expected failure + + _ = parseFromZoir(gpa, "", zoir, ast, null) catch return; + return error.ExpectedParseFailure; +} + +test "parse minimal config" { + const config = try testParse( + \\.{ + \\ .name = .basic, + \\ .version = "0.1.0", + \\ .fingerprint = 0x90797553773ca567, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\} + ); + try std.testing.expectEqualStrings("basic", config.name); + try std.testing.expectEqualStrings("0.1.0", config.version); + try std.testing.expectEqual(@as(u64, 0x90797553773ca567), config.fingerprint); + try std.testing.expectEqualStrings("0.14.0", config.minimum_zig_version); + try std.testing.expectEqual(@as(usize, 1), config.paths.len); + try std.testing.expectEqualStrings("src", config.paths[0]); + + // Optional fields should be null + try std.testing.expect(config.description == null); + try std.testing.expect(config.modules == null); + try std.testing.expect(config.executables == null); + try std.testing.expect(config.dependencies == null); +} + +test "parse config with module" { + const config = try testParse( + \\.{ + \\ .name = .mylib, + \\ .version = "1.0.0", + \\ .fingerprint = 0x1234567890abcdef, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .modules = .{ + \\ .core = .{ + \\ .root_source_file = "src/core.zig", + \\ .link_libc = true, + \\ .optimize = .ReleaseFast, + \\ }, + \\ }, + \\} + ); + + const modules = config.modules orelse return error.ExpectedModules; + try std.testing.expectEqual(@as(usize, 1), modules.count()); + const core = modules.get("core") orelse return error.ExpectedCoreModule; + try std.testing.expectEqualStrings("src/core.zig", core.root_source_file.?); + try std.testing.expectEqual(true, core.link_libc.?); + try std.testing.expectEqual(std.builtin.OptimizeMode.ReleaseFast, core.optimize.?); + try std.testing.expect(core.strip == null); + try std.testing.expect(core.target == null); +} + +test "parse config with executable and inline module" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "0.2.0", + \\ .fingerprint = 0xabcdef1234567890, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .executables = .{ + \\ .main = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/main.zig", + \\ }, + \\ }, + \\ }, + \\} + ); + + const exes = config.executables orelse return error.ExpectedExecutables; + try std.testing.expectEqual(@as(usize, 1), exes.count()); + const main_exe = exes.get("main") orelse return error.ExpectedMainExe; + try std.testing.expectEqual(ModuleLink.module, std.meta.activeTag(main_exe.root_module)); + try std.testing.expectEqualStrings("src/main.zig", main_exe.root_module.module.root_source_file.?); +} + +test "parse config with executable referencing named module" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "0.2.0", + \\ .fingerprint = 0xabcdef1234567890, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .executables = .{ + \\ .main = .{ + \\ .root_module = .core, + \\ }, + \\ }, + \\} + ); + + const exes = config.executables orelse return error.ExpectedExecutables; + const main_exe = exes.get("main") orelse return error.ExpectedMainExe; + try std.testing.expectEqual(ModuleLink.name, std.meta.activeTag(main_exe.root_module)); + try std.testing.expectEqualStrings("core", main_exe.root_module.name); +} + +test "parse config with dependency" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "0.1.0", + \\ .fingerprint = 0x1111111111111111, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .dependencies = .{ + \\ .zlib = .{ + \\ .url = "https://example.com/zlib.tar.gz", + \\ .hash = "abc123", + \\ .lazy = true, + \\ }, + \\ .local_dep = .{ + \\ .path = "../other", + \\ }, + \\ }, + \\} + ); + + const deps = config.dependencies orelse return error.ExpectedDependencies; + try std.testing.expectEqual(@as(usize, 2), deps.count()); + + const zlib = deps.get("zlib") orelse return error.ExpectedZlib; + try std.testing.expect(zlib.typ == .url); + try std.testing.expectEqualStrings("https://example.com/zlib.tar.gz", zlib.value); + try std.testing.expectEqualStrings("abc123", zlib.hash.?); + try std.testing.expectEqual(true, zlib.lazy.?); + + const local = deps.get("local_dep") orelse return error.ExpectedLocalDep; + try std.testing.expect(local.typ == .path); + try std.testing.expectEqualStrings("../other", local.value); + try std.testing.expect(local.hash == null); + try std.testing.expect(local.lazy == null); +} + +test "parse config with library" { + const config = try testParse( + \\.{ + \\ .name = .mylib, + \\ .version = "1.0.0", + \\ .fingerprint = 0x2222222222222222, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .libraries = .{ + \\ .mylib = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/lib.zig", + \\ }, + \\ .version = "2.0.0", + \\ .linkage = .dynamic, + \\ }, + \\ }, + \\} + ); + + const libs = config.libraries orelse return error.ExpectedLibraries; + const lib = libs.get("mylib") orelse return error.ExpectedMylib; + try std.testing.expectEqualStrings("2.0.0", lib.version.?); + try std.testing.expectEqual(std.builtin.LinkMode.dynamic, lib.linkage.?); +} + +test "parse config with test section" { + const config = try testParse( + \\.{ + \\ .name = .mylib, + \\ .version = "1.0.0", + \\ .fingerprint = 0x3333333333333333, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .tests = .{ + \\ .unit = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/test.zig", + \\ }, + \\ .filters = .{"specific_test"}, + \\ }, + \\ }, + \\} + ); + + const tests = config.tests orelse return error.ExpectedTests; + const unit = tests.get("unit") orelse return error.ExpectedUnit; + try std.testing.expectEqual(@as(usize, 1), unit.filters.len); + try std.testing.expectEqualStrings("specific_test", unit.filters[0]); +} + +test "parse config with runs" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x4444444444444444, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .runs = .{ + \\ .docs = "echo 'hello'", + \\ }, + \\} + ); + + const runs = config.runs orelse return error.ExpectedRuns; + const docs = runs.get("docs") orelse return error.ExpectedDocs; + try std.testing.expectEqualStrings("echo 'hello'", docs.*); +} + +test "parse config with options" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x5555555555555555, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .options = .{ + \\ .verbose = .{ + \\ .type = "bool", + \\ .default = false, + \\ .description = "Enable verbose output", + \\ }, + \\ .threads = .{ + \\ .type = "usize", + \\ .default = 4, + \\ }, + \\ }, + \\} + ); + + const opts = config.options orelse return error.ExpectedOptions; + try std.testing.expectEqual(@as(usize, 2), opts.count()); + + const verbose = opts.get("verbose") orelse return error.ExpectedVerbose; + try std.testing.expect(verbose == .bool); + try std.testing.expectEqual(false, verbose.bool.default.?); + + const threads = opts.get("threads") orelse return error.ExpectedThreads; + try std.testing.expect(threads == .int); + try std.testing.expectEqual(@as(i64, 4), threads.int.default.?); +} + +test "parse config with options_modules" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x6666666666666666, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .options_modules = .{ + \\ .build_options = .{ + \\ .debug_mode = .{ + \\ .type = "bool", + \\ .default = false, + \\ }, + \\ }, + \\ }, + \\} + ); + + const opt_modules = config.options_modules orelse return error.ExpectedOptionsModules; + try std.testing.expectEqual(@as(usize, 1), opt_modules.count()); + const build_opts = opt_modules.get("build_options") orelse return error.ExpectedBuildOptions; + try std.testing.expectEqual(@as(usize, 1), build_opts.count()); +} + +test "parse config with module imports" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x7777777777777777, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .modules = .{ + \\ .core = .{ + \\ .root_source_file = "src/core.zig", + \\ .imports = .{ .utils, "other_dep" }, + \\ }, + \\ }, + \\} + ); + + const modules = config.modules orelse return error.ExpectedModules; + const core = modules.get("core") orelse return error.ExpectedCore; + const imports = core.imports orelse return error.ExpectedImports; + try std.testing.expectEqual(@as(usize, 2), imports.len); + try std.testing.expectEqualStrings("utils", imports[0]); + try std.testing.expectEqualStrings("other_dep", imports[1]); +} + +test "parse fails on missing required field" { + try testParseFail( + \\.{ + \\ .name = .basic, + \\ .version = "0.1.0", + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\} + ); +} + +test "parse fails on invalid version string" { + try testParseFail( + \\.{ + \\ .name = .basic, + \\ .version = "not_a_version", + \\ .fingerprint = 0x1234567890abcdef, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\} + ); +} + +test "parse config with description and keywords" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x8888888888888888, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .description = "A test application", + \\ .keywords = .{ "test", "app" }, + \\} + ); + + try std.testing.expectEqualStrings("A test application", config.description.?); + const keywords = config.keywords.?; + try std.testing.expectEqual(@as(usize, 2), keywords.len); + try std.testing.expectEqualStrings("test", keywords[0]); + try std.testing.expectEqualStrings("app", keywords[1]); +} + +test "parse config with dependency args" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x9999999999999999, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .dependencies = .{ + \\ .dep = .{ + \\ .path = "../dep", + \\ .args = .{ + \\ .enable_feature = true, + \\ .count = 42, + \\ .name = "hello", + \\ }, + \\ }, + \\ }, + \\} + ); + + const deps = config.dependencies orelse return error.ExpectedDeps; + const dep = deps.get("dep") orelse return error.ExpectedDep; + const args = dep.args orelse return error.ExpectedArgs; + try std.testing.expectEqual(@as(usize, 3), args.count()); + + const enable = args.get("enable_feature") orelse return error.ExpectedArg; + try std.testing.expect(enable == .bool); + try std.testing.expectEqual(true, enable.bool); +} + +test "parse config with fmts" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0xaaaaaaaaaaaaaaaa, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .fmts = .{ + \\ .check = .{ + \\ .paths = .{"src"}, + \\ .check = true, + \\ }, + \\ }, + \\} + ); + + const fmts = config.fmts orelse return error.ExpectedFmts; + const check = fmts.get("check") orelse return error.ExpectedCheck; + try std.testing.expectEqual(true, check.check.?); + const fmt_paths = check.paths orelse return error.ExpectedPaths; + try std.testing.expectEqual(@as(usize, 1), fmt_paths.len); +} From 5756186d6f07163061a37a54cb8345139e8cfd72 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 09:15:47 -0400 Subject: [PATCH 07/62] fix: complete serializer + add round-trip tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - Implement serialization of libraries, objects, tests, fmts, runs sections (previously commented out, issue 2.7) - Implement enum and enum_list option serialization (previously TODO stubs, issue 2.8) - Add hash and lazy fields to dependency serialization (issue 2.10) Tests: - 8 round-trip tests that parse → serialize → re-parse and verify structural equivalence for each section type Co-Authored-By: Claude Opus 4.6 --- src/Config.zig | 426 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 385 insertions(+), 41 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 2734732..9fa6509 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -954,41 +954,41 @@ fn Serializer(Writer: type) type { } try exes.end(); } - // if (self.config.libraries) |libraries| { - // const libs = try top_level.beginStructField("libraries", .{}); - // for (libraries.keys(), libraries.values()) |name, item| { - // try self.serializeLibrary(libs, name, item); - // } - // try libs.end(); - // } - // if (self.config.objects) |objects| { - // const objs = try top_level.beginStructField("objects", .{}); - // for (objects.keys(), objects.values()) |name, item| { - // try self.serializeObject(objs, name, item); - // } - // try objs.end(); - // } - // if (self.config.tests) |tests| { - // const tsts = try top_level.beginStructField("tests", .{}); - // for (tests.keys(), tests.values()) |name, item| { - // try self.serializeTest(tsts, name, item); - // } - // try tsts.end(); - // } - // if (self.config.fmts) |fmts| { - // const f = try top_level.beginStructField("fmts", .{}); - // for (fmts.keys(), fmts.values()) |name, item| { - // try self.serializeFmt(f, name, item); - // } - // try f.end(); - // } - // if (self.config.runs) |runs| { - // const r = try top_level.beginStructField("runs", .{}); - // for (runs.keys(), runs.values()) |name, item| { - // try self.serializeRun(r, name, item); - // } - // try r.end(); - // } + if (self.config.libraries) |libraries| { + var libs = try top_level.beginStructField("libraries", .{}); + for (libraries.keys(), libraries.values()) |name, item| { + try serializeLibrary(&libs, name, item); + } + try libs.end(); + } + if (self.config.objects) |objects| { + var objs = try top_level.beginStructField("objects", .{}); + for (objects.keys(), objects.values()) |name, item| { + try serializeObject(&objs, name, item); + } + try objs.end(); + } + if (self.config.tests) |tests_map| { + var tsts = try top_level.beginStructField("tests", .{}); + for (tests_map.keys(), tests_map.values()) |name, item| { + try serializeTest(&tsts, name, item); + } + try tsts.end(); + } + if (self.config.fmts) |fmts| { + var f = try top_level.beginStructField("fmts", .{}); + for (fmts.keys(), fmts.values()) |name, item| { + try f.field(name, item, .{ .emit_default_optional_fields = false }); + } + try f.end(); + } + if (self.config.runs) |runs| { + var r = try top_level.beginStructField("runs", .{}); + for (runs.keys(), runs.values()) |name, item| { + try r.field(name, item, .{}); + } + try r.end(); + } try top_level.end(); } @@ -1002,6 +1002,12 @@ fn Serializer(Writer: type) type { .path => try inner.field("path", item.value, .{}), .url => try inner.field("url", item.value, .{}), } + if (item.hash) |hash| { + try inner.field("hash", hash, .{}); + } + if (item.lazy) |lazy| { + try inner.field("lazy", lazy, .{}); + } if (item.args) |args| { var args_inner = try inner.beginStructField("args", .{}); for (args.keys(), args.values()) |arg_name, arg_value| { @@ -1084,14 +1090,44 @@ fn Serializer(Writer: type) type { try outer.field(name, b, .{ .emit_default_optional_fields = false }); }, .@"enum" => |e| { - _ = e; - // TODO - // try opts.field(name, e, .{ .emit_default_optional_fields = false }); + var inner = try outer.beginStructField(name, .{}); + try inner.field("type", e.type, .{}); + if (e.description) |desc| { + try inner.field("description", desc, .{}); + } + if (e.default) |default| { + try inner.fieldPrefix("default"); + try inner.container.serializer.writer.print(".{}", .{std.zig.fmtId(default)}); + } + var opts_arr = try inner.beginArrayField("enum_options", .{}); + for (e.enum_options) |opt| { + try opts_arr.fieldPrefix(); + try opts_arr.container.serializer.writer.print(".{}", .{std.zig.fmtId(opt)}); + } + try opts_arr.end(); + try inner.end(); }, .enum_list => |el| { - _ = el; - // TODO - // try opts.field(name, el, .{ .emit_default_optional_fields = false }); + var inner = try outer.beginStructField(name, .{}); + try inner.field("type", el.type, .{}); + if (el.description) |desc| { + try inner.field("description", desc, .{}); + } + if (el.default) |defaults| { + var default_arr = try inner.beginArrayField("default", .{}); + for (defaults) |d| { + try default_arr.fieldPrefix(); + try default_arr.container.serializer.writer.print(".{}", .{std.zig.fmtId(d)}); + } + try default_arr.end(); + } + var opts_arr = try inner.beginArrayField("enum_options", .{}); + for (el.enum_options) |opt| { + try opts_arr.fieldPrefix(); + try opts_arr.container.serializer.writer.print(".{}", .{std.zig.fmtId(opt)}); + } + try opts_arr.end(); + try inner.end(); }, .string => |s| { try outer.field(name, s, .{ .emit_default_optional_fields = false }); @@ -1162,6 +1198,129 @@ fn Serializer(Writer: type) type { } try inner.end(); } + + fn serializeLibrary( + outer: *std.zon.stringify.Serializer(Writer).Struct, + name: []const u8, + item: Library, + ) !void { + var inner = try outer.beginStructField(name, .{}); + if (item.name) |n| { + try inner.field("name", n, .{}); + } + if (item.version) |version| { + try inner.field("version", version, .{}); + } + switch (item.root_module) { + .name => |n| { + try inner.field("root_module", n, .{}); + }, + .module => |m| { + try serializeModule(&inner, "root_module", m); + }, + } + if (item.linkage) |linkage| { + try inner.field("linkage", linkage, .{}); + } + if (item.max_rss) |max_rss| { + try inner.field("max_rss", max_rss, .{}); + } + if (item.use_llvm) |use_llvm| { + try inner.field("use_llvm", use_llvm, .{}); + } + if (item.use_lld) |use_lld| { + try inner.field("use_lld", use_lld, .{}); + } + if (item.zig_lib_dir) |zig_lib_dir| { + try inner.field("zig_lib_dir", zig_lib_dir, .{}); + } + if (item.win32_manifest) |win32_manifest| { + try inner.field("win32_manifest", win32_manifest, .{}); + } + if (item.linker_allow_shlib_undefined) |v| { + try inner.field("linker_allow_shlib_undefined", v, .{}); + } + if (item.dest_sub_path) |dest_sub_path| { + try inner.field("dest_sub_path", dest_sub_path, .{}); + } + if (item.depends_on) |depends_on| { + try inner.field("depends_on", depends_on, .{}); + } + try inner.end(); + } + + fn serializeObject( + outer: *std.zon.stringify.Serializer(Writer).Struct, + name: []const u8, + item: Object, + ) !void { + var inner = try outer.beginStructField(name, .{}); + if (item.name) |n| { + try inner.field("name", n, .{}); + } + switch (item.root_module) { + .name => |n| { + try inner.field("root_module", n, .{}); + }, + .module => |m| { + try serializeModule(&inner, "root_module", m); + }, + } + if (item.max_rss) |max_rss| { + try inner.field("max_rss", max_rss, .{}); + } + if (item.use_llvm) |use_llvm| { + try inner.field("use_llvm", use_llvm, .{}); + } + if (item.use_lld) |use_lld| { + try inner.field("use_lld", use_lld, .{}); + } + if (item.zig_lib_dir) |zig_lib_dir| { + try inner.field("zig_lib_dir", zig_lib_dir, .{}); + } + if (item.depends_on) |depends_on| { + try inner.field("depends_on", depends_on, .{}); + } + try inner.end(); + } + + fn serializeTest( + outer: *std.zon.stringify.Serializer(Writer).Struct, + name: []const u8, + item: Test, + ) !void { + var inner = try outer.beginStructField(name, .{}); + if (item.name) |n| { + try inner.field("name", n, .{}); + } + switch (item.root_module) { + .name => |n| { + try inner.field("root_module", n, .{}); + }, + .module => |m| { + try serializeModule(&inner, "root_module", m); + }, + } + if (item.max_rss) |max_rss| { + try inner.field("max_rss", max_rss, .{}); + } + if (item.use_llvm) |use_llvm| { + try inner.field("use_llvm", use_llvm, .{}); + } + if (item.use_lld) |use_lld| { + try inner.field("use_lld", use_lld, .{}); + } + if (item.zig_lib_dir) |zig_lib_dir| { + try inner.field("zig_lib_dir", zig_lib_dir, .{}); + } + if (item.filters.len > 0) { + try inner.field("filters", item.filters, .{}); + } + if (item.test_runner) |test_runner| { + try inner.field("test_runner", test_runner, .{}); + } + try inner.end(); + } }; } @@ -1583,3 +1742,188 @@ test "parse config with fmts" { const fmt_paths = check.paths orelse return error.ExpectedPaths; try std.testing.expectEqual(@as(usize, 1), fmt_paths.len); } + +// -- Serializer round-trip tests -- + +fn testSerializeRoundTrip(source: [:0]const u8) !void { + const gpa = std.testing.allocator; + + // Phase 1: Parse original + const config = try testParse(source); + + // Phase 2: Serialize to string + var buf = std.ArrayList(u8).init(gpa); + defer buf.deinit(); + try serialize(config, buf.writer()); + + // Phase 3: Re-parse the serialized output + const serialized = try buf.toOwnedSliceSentinel(0); + defer gpa.free(serialized); + + const config2 = try testParse(serialized); + + // Phase 4: Compare key fields + try std.testing.expectEqualStrings(config.name, config2.name); + try std.testing.expectEqualStrings(config.version, config2.version); + try std.testing.expectEqual(config.fingerprint, config2.fingerprint); + try std.testing.expectEqualStrings(config.minimum_zig_version, config2.minimum_zig_version); + try std.testing.expectEqual(config.paths.len, config2.paths.len); + + // Compare optional sections presence + try std.testing.expectEqual(config.modules != null, config2.modules != null); + try std.testing.expectEqual(config.executables != null, config2.executables != null); + try std.testing.expectEqual(config.libraries != null, config2.libraries != null); + try std.testing.expectEqual(config.objects != null, config2.objects != null); + try std.testing.expectEqual(config.tests != null, config2.tests != null); + try std.testing.expectEqual(config.fmts != null, config2.fmts != null); + try std.testing.expectEqual(config.runs != null, config2.runs != null); + try std.testing.expectEqual(config.dependencies != null, config2.dependencies != null); + + // Compare counts where present + if (config.modules) |m| try std.testing.expectEqual(m.count(), config2.modules.?.count()); + if (config.executables) |e| try std.testing.expectEqual(e.count(), config2.executables.?.count()); + if (config.libraries) |l| try std.testing.expectEqual(l.count(), config2.libraries.?.count()); + if (config.tests) |t| try std.testing.expectEqual(t.count(), config2.tests.?.count()); + if (config.runs) |r| try std.testing.expectEqual(r.count(), config2.runs.?.count()); + if (config.dependencies) |d| try std.testing.expectEqual(d.count(), config2.dependencies.?.count()); +} + +test "serialize round-trip: minimal config" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .basic, + \\ .version = "0.1.0", + \\ .fingerprint = 0x90797553773ca567, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\} + ); +} + +test "serialize round-trip: config with modules" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .mylib, + \\ .version = "1.0.0", + \\ .fingerprint = 0x1234567890abcdef, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .modules = .{ + \\ .core = .{ + \\ .root_source_file = "src/core.zig", + \\ .link_libc = true, + \\ }, + \\ }, + \\} + ); +} + +test "serialize round-trip: config with executables" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .myapp, + \\ .version = "0.2.0", + \\ .fingerprint = 0xabcdef1234567890, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .executables = .{ + \\ .main = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/main.zig", + \\ }, + \\ }, + \\ }, + \\} + ); +} + +test "serialize round-trip: config with libraries" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .mylib, + \\ .version = "1.0.0", + \\ .fingerprint = 0x2222222222222222, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .libraries = .{ + \\ .mylib = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/lib.zig", + \\ }, + \\ .version = "2.0.0", + \\ }, + \\ }, + \\} + ); +} + +test "serialize round-trip: config with tests" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .mylib, + \\ .version = "1.0.0", + \\ .fingerprint = 0x3333333333333333, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .tests = .{ + \\ .unit = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/test.zig", + \\ }, + \\ }, + \\ }, + \\} + ); +} + +test "serialize round-trip: config with runs" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0x4444444444444444, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .runs = .{ + \\ .docs = "echo hello", + \\ }, + \\} + ); +} + +test "serialize round-trip: config with dependencies including hash and lazy" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .myapp, + \\ .version = "0.1.0", + \\ .fingerprint = 0x1111111111111111, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .dependencies = .{ + \\ .zlib = .{ + \\ .url = "https://example.com/zlib.tar.gz", + \\ .hash = "abc123", + \\ .lazy = true, + \\ }, + \\ }, + \\} + ); +} + +test "serialize round-trip: config with fmts" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0xaaaaaaaaaaaaaaaa, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .fmts = .{ + \\ .check = .{ + \\ .paths = .{"src"}, + \\ .check = true, + \\ }, + \\ }, + \\} + ); +} From 7f07f50a9f7e0da15d0971ad30d88ccf3c8fb664 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 09:17:54 -0400 Subject: [PATCH 08/62] fix: wire depends_on for executables/libraries/objects The depends_on field was parsed but never used in the build graph. Now build_runner tracks install steps in a map and adds step dependencies in a final pass after all artifacts are created. Also adds parser + round-trip tests for depends_on. Co-Authored-By: Claude Opus 4.6 --- src/Config.zig | 53 ++++++++++++++++++++++++++++++++++++++++++++ src/build_runner.zig | 31 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index 9fa6509..ecd0740 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -1910,6 +1910,59 @@ test "serialize round-trip: config with dependencies including hash and lazy" { ); } +test "parse config with depends_on" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0xbbbbbbbbbbbbbbbb, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .executables = .{ + \\ .server = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/server.zig", + \\ }, + \\ .depends_on = .{ .proto_lib }, + \\ }, + \\ }, + \\ .libraries = .{ + \\ .proto_lib = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/proto.zig", + \\ }, + \\ }, + \\ }, + \\} + ); + + const exes = config.executables orelse return error.ExpectedExes; + const server = exes.get("server") orelse return error.ExpectedServer; + const depends_on = server.depends_on orelse return error.ExpectedDependsOn; + try std.testing.expectEqual(@as(usize, 1), depends_on.len); + try std.testing.expectEqualStrings("proto_lib", depends_on[0]); +} + +test "serialize round-trip: config with depends_on" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0xbbbbbbbbbbbbbbbb, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .executables = .{ + \\ .server = .{ + \\ .root_module = .{ + \\ .root_source_file = "src/server.zig", + \\ }, + \\ .depends_on = .{ .proto_lib }, + \\ }, + \\ }, + \\} + ); +} + test "serialize round-trip: config with fmts" { try testSerializeRoundTrip( \\.{ diff --git a/src/build_runner.zig b/src/build_runner.zig index b3a023f..0b34e1c 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -21,6 +21,7 @@ fn configureWithConfig(b: *std.Build, config: Config) !void { .modules = std.StringHashMap(*std.Build.Module).init(b.allocator), .dependencies = std.StringHashMap(*std.Build.Dependency).init(b.allocator), .options_modules = std.StringHashMap(*std.Build.Module).init(b.allocator), + .install_steps = std.StringHashMap(*std.Build.Step).init(b.allocator), }; // Phase 1: Create options modules @@ -112,6 +113,9 @@ fn configureWithConfig(b: *std.Build, config: Config) !void { // Phase 10: Wire imports for all modules try runner.wireAllImports(config); + + // Phase 11: Wire depends_on for artifacts + runner.wireDependsOn(config); } const BuildRunner = struct { @@ -122,6 +126,7 @@ const BuildRunner = struct { modules: std.StringHashMap(*std.Build.Module), dependencies: std.StringHashMap(*std.Build.Dependency), options_modules: std.StringHashMap(*std.Build.Module), + install_steps: std.StringHashMap(*std.Build.Step), fn createModule(self: *BuildRunner, module: Config.Module, name: []const u8) !*std.Build.Module { const m = self.b.createModule(.{ @@ -268,6 +273,7 @@ const BuildRunner = struct { ); tls_install.dependOn(&install.step); self.b.getInstallStep().dependOn(&install.step); + try self.install_steps.put(name, &install.step); const run = self.b.addRunArtifact(artifact); if (self.b.args) |args| run.addArgs(args); @@ -307,6 +313,7 @@ const BuildRunner = struct { ); tls_install.dependOn(&install.step); self.b.getInstallStep().dependOn(&install.step); + try self.install_steps.put(name, &install.step); } fn createObject(self: *BuildRunner, name: []const u8, obj: Config.Object) !void { @@ -328,6 +335,7 @@ const BuildRunner = struct { ); tls_install.dependOn(&install.step); self.b.getInstallStep().dependOn(&install.step); + try self.install_steps.put(name, &install.step); } fn createTest(self: *BuildRunner, name: []const u8, t: Config.Test, tls_run_test: *std.Build.Step) !void { @@ -431,6 +439,29 @@ const BuildRunner = struct { } } + fn wireDependsOn(self: *BuildRunner, config: Config) void { + inline for (.{ + config.executables, + config.libraries, + config.objects, + }) |maybe_map| { + if (maybe_map) |map| { + for (map.keys(), map.values()) |name, item| { + if (@field(item, "depends_on")) |deps| { + const this_step = self.install_steps.get(name) orelse continue; + for (deps) |dep_name| { + const dep_step = self.install_steps.get(dep_name) orelse { + std.log.warn("zbuild: depends_on references unknown artifact '{s}'", .{dep_name}); + continue; + }; + this_step.dependOn(dep_step); + } + } + } + } + } + } + fn wireImports(self: *BuildRunner, module: *std.Build.Module, imports: []const []const u8) void { for (imports) |import_name| { const resolved = self.resolveImport(import_name); From cdbc8a43ce6a3f01c3098df5c8297f41ea8d7690 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 09:21:43 -0400 Subject: [PATCH 09/62] fix: ZigEnv exit code, --no-sync loop, write_files parser, Args test Bug fixes: - ZigEnv: change 'and' to 'or' in exit code check so non-zero exits and signal terminations are properly detected (issue 1.3) - GlobalOptions: add args.next() when consuming --no-sync flag to prevent infinite loop (issue 1.4) - Config: implement write_files parser (was a stub that silently discarded all write_files entries) (issue 1.1) - Args: fix test calling non-existent Args.parse, should be Args.initFromString (issue 3.9) Tests: - GlobalOptions: --no-sync flag consumption (verifies no infinite loop) - Config: write_files parsing with file and dir items - Config: write_files round-trip serialization Co-Authored-By: Claude Opus 4.6 --- src/Args.zig | 2 +- src/Config.zig | 121 +++++++++++++++++++++++++++++++++++++++++- src/GlobalOptions.zig | 25 +++++++++ src/ZigEnv.zig | 2 +- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/Args.zig b/src/Args.zig index 536afc1..cd25eae 100644 --- a/src/Args.zig +++ b/src/Args.zig @@ -75,7 +75,7 @@ const test_cases = &[_]TestCase{ test "parse" { const allocator = std.testing.allocator; for (test_cases) |tc| { - var args = try Args.parse(allocator, tc.input); + var args = try Args.initFromString(allocator, tc.input); defer args.deinit(); const actual = args.args.items; diff --git a/src/Config.zig b/src/Config.zig index ecd0740..8b10656 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -387,7 +387,7 @@ const Parser = struct { } else if (std.mem.eql(u8, field_name, "dependencies")) { config.dependencies = try self.parseHashMap(Dependency, parseDependency, field_value); } else if (std.mem.eql(u8, field_name, "write_files")) { - // stub — pre-existing incomplete feature + config.write_files = try self.parseHashMap(WriteFile, parseWriteFile, field_value); } else if (std.mem.eql(u8, field_name, "options")) { config.options = try self.parseHashMap(Option, parseOption, field_value); } else if (std.mem.eql(u8, field_name, "options_modules")) { @@ -619,6 +619,60 @@ const Parser = struct { return try self.parseString(index); } + fn parseWriteFile(self: *Parser, index: std.zig.Zoir.Node.Index) Error!WriteFile { + const n = try self.parseStructLiteral(index); + var wf = WriteFile{}; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "private")) { + wf.private = try self.parseT(bool, field_value); + } else if (std.mem.eql(u8, field_name, "items")) { + wf.items = try self.parseHashMap(WriteFile.Path, parseWriteFilePath, field_value); + } + } + return wf; + } + + fn parseWriteFilePath(self: *Parser, index: std.zig.Zoir.Node.Index) Error!WriteFile.Path { + const n = try self.parseStructLiteral(index); + var path_str: ?[]const u8 = null; + var type_str: ?[]const u8 = null; + var exclude_extensions: ?[][]const u8 = null; + var include_extensions: ?[][]const u8 = null; + for (n.names, 0..) |name, i| { + const field_name = name.get(self.zoir); + const field_value = n.vals.at(@intCast(i)); + if (std.mem.eql(u8, field_name, "type")) { + type_str = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "path")) { + path_str = try self.parseString(field_value); + } else if (std.mem.eql(u8, field_name, "exclude_extensions")) { + exclude_extensions = try self.parseStringOrEnumSlice(field_value); + } else if (std.mem.eql(u8, field_name, "include_extensions")) { + include_extensions = try self.parseStringOrEnumSlice(field_value); + } + } + const t = type_str orelse { + try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); + }; + const p = path_str orelse { + try self.returnParseError("missing required field 'path'", index.getAstNode(self.zoir)); + }; + if (std.mem.eql(u8, t, "file")) { + return .{ .file = .{ .type = t, .path = p } }; + } else if (std.mem.eql(u8, t, "dir")) { + return .{ .dir = .{ + .type = t, + .path = p, + .exclude_extensions = exclude_extensions, + .include_extensions = include_extensions, + } }; + } else { + try self.returnParseErrorFmt("invalid write_file type '{s}'", .{t}, index.getAstNode(self.zoir)); + } + } + // -- Layer 4: Custom parsers -- fn parseDependency(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency { @@ -1980,3 +2034,68 @@ test "serialize round-trip: config with fmts" { \\} ); } + +test "parse config with write_files" { + const config = try testParse( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0xcccccccccccccccc, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .write_files = .{ + \\ .generated = .{ + \\ .items = .{ + \\ .config_h = .{ + \\ .type = "file", + \\ .path = "config.h", + \\ }, + \\ .assets = .{ + \\ .type = "dir", + \\ .path = "assets", + \\ .exclude_extensions = .{".tmp"}, + \\ }, + \\ }, + \\ }, + \\ }, + \\} + ); + + const wf = config.write_files orelse return error.ExpectedWriteFiles; + const generated = wf.get("generated") orelse return error.ExpectedGenerated; + const items = generated.items orelse return error.ExpectedItems; + try std.testing.expectEqual(@as(usize, 2), items.count()); + + const config_h = items.get("config_h") orelse return error.ExpectedConfigH; + try std.testing.expect(config_h == .file); + try std.testing.expectEqualStrings("config.h", config_h.file.path); + + const assets = items.get("assets") orelse return error.ExpectedAssets; + try std.testing.expect(assets == .dir); + try std.testing.expectEqualStrings("assets", assets.dir.path); + const excl = assets.dir.exclude_extensions orelse return error.ExpectedExclude; + try std.testing.expectEqual(@as(usize, 1), excl.len); + try std.testing.expectEqualStrings(".tmp", excl[0]); +} + +test "serialize round-trip: config with write_files" { + try testSerializeRoundTrip( + \\.{ + \\ .name = .myapp, + \\ .version = "1.0.0", + \\ .fingerprint = 0xcccccccccccccccc, + \\ .minimum_zig_version = "0.14.0", + \\ .paths = .{"src"}, + \\ .write_files = .{ + \\ .generated = .{ + \\ .items = .{ + \\ .config_h = .{ + \\ .type = "file", + \\ .path = "config.h", + \\ }, + \\ }, + \\ }, + \\ }, + \\} + ); +} diff --git a/src/GlobalOptions.zig b/src/GlobalOptions.zig index 181af38..13b9bfd 100644 --- a/src/GlobalOptions.zig +++ b/src/GlobalOptions.zig @@ -72,6 +72,7 @@ pub fn parseArgs(allocator: Allocator, args: *Args) !GlobalOptions { GlobalArgs.zbuild_file else if (mem.eql(u8, arg, "--no-sync")) { opts.no_sync = true; + _ = args.next(); continue; } else { break :iter; @@ -112,6 +113,30 @@ pub fn parseArgs(allocator: Allocator, args: *Args) !GlobalOptions { return opts; } +test "--no-sync flag is consumed without infinite loop" { + const allocator = std.testing.allocator; + var args = try Args.initFromString(allocator, "--no-sync sync"); + defer args.deinit(); + const opts = try parseArgs(allocator, &args); + defer opts.deinit(allocator); + + try std.testing.expectEqual(true, opts.no_sync); + // After parsing global opts, "sync" should be the next arg (the command) + try std.testing.expectEqualStrings("sync", args.next().?); +} + +test "--no-sync flag with other global options" { + const allocator = std.testing.allocator; + var args = try Args.initFromString(allocator, "--project-dir /tmp --no-sync build"); + defer args.deinit(); + const opts = try parseArgs(allocator, &args); + defer opts.deinit(allocator); + + try std.testing.expectEqual(true, opts.no_sync); + try std.testing.expectEqualStrings("/tmp", opts.project_dir); + try std.testing.expectEqualStrings("build", args.next().?); +} + pub fn getZigEnv(self: GlobalOptions) ZigEnv { return .{ .zig_exe = self.zig_exe, diff --git a/src/ZigEnv.zig b/src/ZigEnv.zig index 96e9fa5..3244de0 100644 --- a/src/ZigEnv.zig +++ b/src/ZigEnv.zig @@ -30,7 +30,7 @@ pub fn parse(allocator: std.mem.Allocator) !@This() { defer allocator.free(result.stdout); defer allocator.free(result.stderr); - if (result.term != .Exited and result.term.Exited != 0) { + if (result.term != .Exited or result.term.Exited != 0) { return error.UnexpectedExitCode; } From 3af55fa2d1d04de23b3317c1fd889aef6c08acbf Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 09:25:01 -0400 Subject: [PATCH 10/62] fix: wip_bundle memory leak and returnParseErrorFmt owned flag - main.zig: add defer wip_bundle.deinit() so the error bundle's internal allocations are freed on the success path (issue 3.4) - Config.zig: returnParseErrorFmt now sets .owned = true since the message is heap-allocated via allocPrint (issue 3.10) Co-Authored-By: Claude Opus 4.6 --- src/Config.zig | 13 ++++++++++++- src/main.zig | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 8b10656..9d18f50 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -903,7 +903,18 @@ const Parser = struct { fn returnParseErrorFmt(self: *Parser, comptime fmt: []const u8, args: anytype, node_index: std.zig.Ast.Node.Index) Error!noreturn { const message = try std.fmt.allocPrint(self.gpa, fmt, args); - try self.returnParseError(message, node_index); + self.status.* = .{ + .ast = self.ast, + .zoir = self.zoir, + .type_check = .{ + .message = message, + .owned = true, + .token = self.ast.firstToken(node_index), + .offset = 0, + .note = null, + }, + }; + return error.ParseZon; } fn returnParseError(self: *Parser, message: []const u8, node_index: std.zig.Ast.Node.Index) Error!noreturn { diff --git a/src/main.zig b/src/main.zig index 6e4ef70..c978b1d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -105,6 +105,7 @@ fn mainArgs(gpa: Allocator, arena: Allocator, args: *Args) !void { var wip_bundle: std.zig.ErrorBundle.Wip = undefined; try wip_bundle.init(gpa); + defer wip_bundle.deinit(); const config = Config.parseFromFile(arena, global_opts.zbuild_file, &wip_bundle) catch |err| switch (err) { error.FileNotFound => { fatal("no build.zig.zon file found", .{}); From efdd1b30c75e8f2f5f1b78dc5e0a223b318cade9 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 10:07:41 -0400 Subject: [PATCH 11/62] refactor: convert zbuild from CLI tool to library-only dependency zbuild no longer ships a CLI binary. Users consume it as a standard Zig dependency via build.zig.zon and call zbuild.configureBuild(b) from their build.zig. This eliminates ~1,100 lines of CLI indirection that was just wrapping zig build/fetch/init commands. Deleted: Args, GlobalOptions, ZigEnv, Package, run_zig, cmd_build, cmd_fetch, cmd_init, cmd_sync, test/sync, test/fixtures. Co-Authored-By: Claude Opus 4.6 --- build.zig | 34 +---- src/Args.zig | 86 ------------- src/GlobalOptions.zig | 148 ---------------------- src/Package.zig | 192 ----------------------------- src/ZigEnv.zig | 60 --------- src/cmd_build.zig | 105 ---------------- src/cmd_fetch.zig | 97 --------------- src/cmd_init.zig | 92 -------------- src/cmd_sync.zig | 42 ------- src/main.zig | 172 ++------------------------ src/run_zig.zig | 128 ------------------- test/fixtures/basic1.build.zig.zon | 12 -- test/fixtures/basic2.build.zig.zon | 35 ------ test/fixtures/basic3.build.zig.zon | 19 --- test/fixtures/basic4.build.zig.zon | 23 ---- test/fixtures/basic5.build.zig.zon | 33 ----- test/fixtures/basic6.build.zig.zon | 17 --- test/sync.zig | 72 ----------- 18 files changed, 12 insertions(+), 1355 deletions(-) delete mode 100644 src/Args.zig delete mode 100644 src/GlobalOptions.zig delete mode 100644 src/Package.zig delete mode 100644 src/ZigEnv.zig delete mode 100644 src/cmd_build.zig delete mode 100644 src/cmd_fetch.zig delete mode 100644 src/cmd_init.zig delete mode 100644 src/cmd_sync.zig delete mode 100644 src/run_zig.zig delete mode 100644 test/fixtures/basic1.build.zig.zon delete mode 100644 test/fixtures/basic2.build.zig.zon delete mode 100644 test/fixtures/basic3.build.zig.zon delete mode 100644 test/fixtures/basic4.build.zig.zon delete mode 100644 test/fixtures/basic5.build.zig.zon delete mode 100644 test/fixtures/basic6.build.zig.zon delete mode 100644 test/sync.zig diff --git a/build.zig b/build.zig index a917e01..ac142ce 100644 --- a/build.zig +++ b/build.zig @@ -12,48 +12,16 @@ pub fn build(b: *std.Build) void { }); b.modules.put(b.dupe("zbuild"), zbuild_module) catch @panic("OOM"); - // zbuild executable - const exe = b.addExecutable(.{ - .name = "zbuild", - .root_module = zbuild_module, - }); - const install_exe = b.addInstallArtifact(exe, .{}); - b.getInstallStep().dependOn(&install_exe.step); - - const run_exe = b.addRunArtifact(exe); - if (b.args) |args| run_exe.addArgs(args); - const run_step = b.step("run:zbuild", "Run the zbuild executable"); - run_step.dependOn(&run_exe.step); - // Tests const tls_run_test = b.step("test", "Run all tests"); - // zbuild unit tests const test_zbuild = b.addTest(.{ .name = "zbuild", .root_module = zbuild_module, .filters = b.option([]const []const u8, "zbuild.filters", "zbuild test filters") orelse &.{}, }); const run_test_zbuild = b.addRunArtifact(test_zbuild); - const tls_test_zbuild = b.step("test:zbuild", "Run the zbuild test"); + const tls_test_zbuild = b.step("test:zbuild", "Run the zbuild tests"); tls_test_zbuild.dependOn(&run_test_zbuild.step); tls_run_test.dependOn(&run_test_zbuild.step); - - // sync integration test - const sync_module = b.createModule(.{ - .root_source_file = b.path("test/sync.zig"), - .target = target, - .optimize = optimize, - }); - sync_module.addImport("zbuild", zbuild_module); - - const test_sync = b.addTest(.{ - .name = "sync", - .root_module = sync_module, - .filters = b.option([]const []const u8, "sync.filters", "sync test filters") orelse &.{}, - }); - const run_test_sync = b.addRunArtifact(test_sync); - const tls_test_sync = b.step("test:sync", "Run the sync test"); - tls_test_sync.dependOn(&run_test_sync.step); - tls_run_test.dependOn(&run_test_sync.step); } diff --git a/src/Args.zig b/src/Args.zig deleted file mode 100644 index cd25eae..0000000 --- a/src/Args.zig +++ /dev/null @@ -1,86 +0,0 @@ -//! Parse, iterate over command-line arguments -const std = @import("std"); - -const Args = @This(); - -args: std.ArrayList([]const u8), -index: usize = 0, - -pub fn deinit(self: Args) void { - for (self.args.items) |arg| { - self.args.allocator.free(arg); - } - self.args.deinit(); -} - -pub fn initFromProcessArgs(allocator: std.mem.Allocator) !Args { - var it = try std.process.argsWithAllocator(allocator); - defer it.deinit(); - return try initFromIterator(allocator, &it); -} - -pub fn initFromString(allocator: std.mem.Allocator, input: []const u8) !Args { - var it = try std.process.ArgIteratorGeneral(.{ .single_quotes = true }).init(allocator, input); - defer it.deinit(); - return try initFromIterator(allocator, &it); -} - -pub fn initFromIterator(allocator: std.mem.Allocator, it: anytype) !Args { - var args = std.ArrayList([]const u8).init(allocator); - while (it.next()) |arg| { - try args.append(try allocator.dupe(u8, arg)); - } - - return .{ .args = args }; -} - -pub fn next(self: *Args) ?[]const u8 { - if (self.index == self.args.items.len) return null; - - const arg = self.args.items[self.index]; - self.index += 1; - return arg; -} - -pub fn peek(self: *Args) ?[]const u8 { - if (self.index == self.args.items.len) return null; - - return self.args.items[self.index]; -} - -pub fn rest(self: *Args) []const []const u8 { - return self.args.items[self.index..]; -} - -const TestCase = struct { - input: []const u8, - expected: []const []const u8, -}; -const test_cases = &[_]TestCase{ - .{ - .input = - \\name=Alice "age=30 years" "quoted \"text\"" - , - .expected = &[_][]const u8{ - \\name=Alice - , - \\age=30 years - , - \\quoted "text" - , - }, - }, -}; - -test "parse" { - const allocator = std.testing.allocator; - for (test_cases) |tc| { - var args = try Args.initFromString(allocator, tc.input); - defer args.deinit(); - - const actual = args.args.items; - const expected = tc.expected; - - try std.testing.expectEqualDeep(expected, actual); - } -} diff --git a/src/GlobalOptions.zig b/src/GlobalOptions.zig deleted file mode 100644 index 13b9bfd..0000000 --- a/src/GlobalOptions.zig +++ /dev/null @@ -1,148 +0,0 @@ -//! Collects options from the environment, `zig env`, and command line arguments. - -const std = @import("std"); -const mem = std.mem; -const Allocator = std.mem.Allocator; -const Args = @import("Args.zig"); -const ZigEnv = @import("ZigEnv.zig"); -const fatal = std.process.fatal; - -const GlobalOptions = @This(); - -zig_exe: []const u8, -lib_dir: []const u8, -std_dir: []const u8, -global_cache_dir: []const u8, -version: []const u8, - -project_dir: []const u8, -zbuild_file: []const u8, -no_sync: bool, - -const GlobalArgs = enum { - zig_exe, - lib_dir, - std_dir, - global_cache_dir, - project_dir, - zbuild_file, - no_sync, -}; - -pub fn deinit(self: GlobalOptions, allocator: Allocator) void { - allocator.free(self.zig_exe); - allocator.free(self.lib_dir); - allocator.free(self.std_dir); - allocator.free(self.global_cache_dir); - allocator.free(self.version); - allocator.free(self.project_dir); - allocator.free(self.zbuild_file); -} - -pub fn parseArgs(allocator: Allocator, args: *Args) !GlobalOptions { - const zig_env = try ZigEnv.parse(allocator); - - var opts = GlobalOptions{ - .zig_exe = zig_env.zig_exe, - .lib_dir = zig_env.lib_dir, - .std_dir = zig_env.std_dir, - .global_cache_dir = zig_env.global_cache_dir, - .version = zig_env.version, - .project_dir = try allocator.dupe(u8, "."), - .zbuild_file = try allocator.dupe(u8, "build.zig.zon"), - .no_sync = false, - }; - - iter: while (args.peek()) |arg| { - if (mem.eql(u8, arg, "-h") or mem.eql(u8, arg, "--help")) { - return opts; - } - - const arg_type = if (mem.eql(u8, arg, "--zig-exe")) - GlobalArgs.zig_exe - else if (mem.eql(u8, arg, "--global-cache-dir")) - GlobalArgs.global_cache_dir - else if (mem.eql(u8, arg, "--zig-lib-dir")) - GlobalArgs.lib_dir - else if (mem.eql(u8, arg, "--zig-std-dir")) - GlobalArgs.std_dir - else if (mem.eql(u8, arg, "--project-dir")) - GlobalArgs.project_dir - else if (mem.eql(u8, arg, "--zbuild-file")) - GlobalArgs.zbuild_file - else if (mem.eql(u8, arg, "--no-sync")) { - opts.no_sync = true; - _ = args.next(); - continue; - } else { - break :iter; - }; - - _ = args.next(); - const arg_next = args.next() orelse fatal("expected argument after '{s}'", .{arg}); - const arg_value = try allocator.dupe(u8, arg_next); - switch (arg_type) { - .zig_exe => { - allocator.free(opts.zig_exe); - opts.zig_exe = arg_value; - }, - .global_cache_dir => { - allocator.free(opts.global_cache_dir); - opts.global_cache_dir = arg_value; - }, - .lib_dir => { - allocator.free(opts.lib_dir); - opts.lib_dir = arg_value; - }, - .std_dir => { - allocator.free(opts.std_dir); - opts.std_dir = arg_value; - }, - .project_dir => { - allocator.free(opts.project_dir); - opts.project_dir = arg_value; - }, - .zbuild_file => { - allocator.free(opts.zbuild_file); - opts.zbuild_file = arg_value; - }, - else => unreachable, - } - } - - return opts; -} - -test "--no-sync flag is consumed without infinite loop" { - const allocator = std.testing.allocator; - var args = try Args.initFromString(allocator, "--no-sync sync"); - defer args.deinit(); - const opts = try parseArgs(allocator, &args); - defer opts.deinit(allocator); - - try std.testing.expectEqual(true, opts.no_sync); - // After parsing global opts, "sync" should be the next arg (the command) - try std.testing.expectEqualStrings("sync", args.next().?); -} - -test "--no-sync flag with other global options" { - const allocator = std.testing.allocator; - var args = try Args.initFromString(allocator, "--project-dir /tmp --no-sync build"); - defer args.deinit(); - const opts = try parseArgs(allocator, &args); - defer opts.deinit(allocator); - - try std.testing.expectEqual(true, opts.no_sync); - try std.testing.expectEqualStrings("/tmp", opts.project_dir); - try std.testing.expectEqualStrings("build", args.next().?); -} - -pub fn getZigEnv(self: GlobalOptions) ZigEnv { - return .{ - .zig_exe = self.zig_exe, - .lib_dir = self.lib_dir, - .std_dir = self.std_dir, - .global_cache_dir = self.global_cache_dir, - .version = self.version, - }; -} diff --git a/src/Package.zig b/src/Package.zig deleted file mode 100644 index ece4685..0000000 --- a/src/Package.zig +++ /dev/null @@ -1,192 +0,0 @@ -//! mostly copy-pasted from zig/src/Package.zig -const std = @import("std"); -const assert = std.debug.assert; - -pub const multihash_len = 1 + 1 + Hash.Algo.digest_length; -pub const multihash_hex_digest_len = 2 * multihash_len; -pub const MultiHashHexDigest = [multihash_hex_digest_len]u8; - -pub const Fingerprint = packed struct(u64) { - id: u32, - checksum: u32, - - pub fn generate(name: []const u8) Fingerprint { - return .{ - .id = std.crypto.random.intRangeLessThan(u32, 1, 0xffffffff), - .checksum = std.hash.Crc32.hash(name), - }; - } - - pub fn validate(n: Fingerprint, name: []const u8) bool { - switch (n.id) { - 0x00000000, 0xffffffff => return false, - else => return std.hash.Crc32.hash(name) == n.checksum, - } - } - - pub fn int(n: Fingerprint) u64 { - return @bitCast(n); - } -}; - -/// A user-readable, file system safe hash that identifies an exact package -/// snapshot, including file contents. -/// -/// The hash is not only to prevent collisions but must resist attacks where -/// the adversary fully controls the contents being hashed. Thus, it contains -/// a full SHA-256 digest. -/// -/// This data structure can be used to store the legacy hash format too. Legacy -/// hash format is scheduled to be removed after 0.14.0 is tagged. -/// -/// There's also a third way this structure is used. When using path rather than -/// hash, a unique hash is still needed, so one is computed based on the path. -pub const Hash = struct { - /// Maximum size of a package hash. Unused bytes at the end are - /// filled with zeroes. - bytes: [max_len]u8, - - pub const Algo = std.crypto.hash.sha2.Sha256; - pub const Digest = [Algo.digest_length]u8; - - /// Example: "nnnn-vvvv-hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" - pub const max_len = 32 + 1 + 32 + 1 + (32 + 32 + 200) / 6; - - pub fn fromSlice(s: []const u8) Hash { - assert(s.len <= max_len); - var result: Hash = undefined; - @memcpy(result.bytes[0..s.len], s); - @memset(result.bytes[s.len..], 0); - return result; - } - - pub fn toSlice(ph: *const Hash) []const u8 { - var end: usize = ph.bytes.len; - while (true) { - end -= 1; - if (ph.bytes[end] != 0) return ph.bytes[0 .. end + 1]; - } - } - - pub fn eql(a: *const Hash, b: *const Hash) bool { - return std.mem.eql(u8, &a.bytes, &b.bytes); - } - - /// Distinguishes whether the legacy multihash format is being stored here. - pub fn isOld(h: *const Hash) bool { - if (h.bytes.len < 2) return false; - const their_multihash_func = std.fmt.parseInt(u8, h.bytes[0..2], 16) catch return false; - if (@as(MultihashFunction, @enumFromInt(their_multihash_func)) != multihash_function) return false; - if (h.toSlice().len != multihash_hex_digest_len) return false; - return std.mem.indexOfScalar(u8, &h.bytes, '-') == null; - } - - test isOld { - const h: Hash = .fromSlice("1220138f4aba0c01e66b68ed9e1e1e74614c06e4743d88bc58af4f1c3dd0aae5fea7"); - try std.testing.expect(h.isOld()); - } - - /// Produces "$name-$semver-$hashplus". - /// * name is the name field from build.zig.zon, asserted to be at most 32 - /// bytes and assumed be a valid zig identifier - /// * semver is the version field from build.zig.zon, asserted to be at - /// most 32 bytes - /// * hashplus is the following 33-byte array, base64 encoded using -_ to make - /// it filesystem safe: - /// - (4 bytes) LE u32 Package ID - /// - (4 bytes) LE u32 total decompressed size in bytes, overflow saturated - /// - (25 bytes) truncated SHA-256 digest of hashed files of the package - pub fn init(digest: Digest, name: []const u8, ver: []const u8, id: u32, size: u32) Hash { - assert(name.len <= 32); - assert(ver.len <= 32); - var result: Hash = undefined; - var buf: std.ArrayListUnmanaged(u8) = .initBuffer(&result.bytes); - buf.appendSliceAssumeCapacity(name); - buf.appendAssumeCapacity('-'); - buf.appendSliceAssumeCapacity(ver); - buf.appendAssumeCapacity('-'); - var hashplus: [33]u8 = undefined; - std.mem.writeInt(u32, hashplus[0..4], id, .little); - std.mem.writeInt(u32, hashplus[4..8], size, .little); - hashplus[8..].* = digest[0..25].*; - _ = std.base64.url_safe_no_pad.Encoder.encode(buf.addManyAsArrayAssumeCapacity(44), &hashplus); - @memset(buf.unusedCapacitySlice(), 0); - return result; - } - - /// Produces a unique hash based on the path provided. The result should - /// not be user-visible. - pub fn initPath(sub_path: []const u8, is_global: bool) Hash { - var result: Hash = .{ .bytes = @splat(0) }; - var i: usize = 0; - if (is_global) { - result.bytes[0] = '/'; - i += 1; - } - if (i + sub_path.len <= result.bytes.len) { - @memcpy(result.bytes[i..][0..sub_path.len], sub_path); - return result; - } - var bin_digest: [Algo.digest_length]u8 = undefined; - Algo.hash(sub_path, &bin_digest, .{}); - _ = std.fmt.bufPrint(result.bytes[i..], "{}", .{std.fmt.fmtSliceHexLower(&bin_digest)}) catch unreachable; - return result; - } -}; - -pub const MultihashFunction = enum(u16) { - identity = 0x00, - sha1 = 0x11, - @"sha2-256" = 0x12, - @"sha2-512" = 0x13, - @"sha3-512" = 0x14, - @"sha3-384" = 0x15, - @"sha3-256" = 0x16, - @"sha3-224" = 0x17, - @"sha2-384" = 0x20, - @"sha2-256-trunc254-padded" = 0x1012, - @"sha2-224" = 0x1013, - @"sha2-512-224" = 0x1014, - @"sha2-512-256" = 0x1015, - @"blake2b-256" = 0xb220, - _, -}; - -pub const multihash_function: MultihashFunction = switch (Hash.Algo) { - std.crypto.hash.sha2.Sha256 => .@"sha2-256", - else => unreachable, -}; - -pub fn multiHashHexDigest(digest: Hash.Digest) MultiHashHexDigest { - const hex_charset = std.fmt.hex_charset; - - var result: MultiHashHexDigest = undefined; - - result[0] = hex_charset[@intFromEnum(multihash_function) >> 4]; - result[1] = hex_charset[@intFromEnum(multihash_function) & 15]; - - result[2] = hex_charset[Hash.Algo.digest_length >> 4]; - result[3] = hex_charset[Hash.Algo.digest_length & 15]; - - for (digest, 0..) |byte, i| { - result[4 + i * 2] = hex_charset[byte >> 4]; - result[5 + i * 2] = hex_charset[byte & 15]; - } - return result; -} - -comptime { - // We avoid unnecessary uleb128 code in hexDigest by asserting here the - // values are small enough to be contained in the one-byte encoding. - assert(@intFromEnum(multihash_function) < 127); - assert(Hash.Algo.digest_length < 127); -} - -test Hash { - const example_digest: Hash.Digest = .{ - 0xc7, 0xf5, 0x71, 0xb7, 0xb4, 0xe7, 0x6f, 0x3c, 0xdb, 0x87, 0x7a, 0x7f, 0xdd, 0xf9, 0x77, 0x87, - 0x9d, 0xd3, 0x86, 0xfa, 0x73, 0x57, 0x9a, 0xf7, 0x9d, 0x1e, 0xdb, 0x8f, 0x3a, 0xd9, 0xbd, 0x9f, - }; - const result: Hash = .init(example_digest, "nasm", "2.16.1-3", 0xcafebabe, 10 * 1024 * 1024); - try std.testing.expectEqualStrings("nasm-2.16.1-3-vrr-ygAAoADH9XG3tOdvPNuHen_d-XeHndOG-nNXmved", result.toSlice()); -} diff --git a/src/ZigEnv.zig b/src/ZigEnv.zig deleted file mode 100644 index 3244de0..0000000 --- a/src/ZigEnv.zig +++ /dev/null @@ -1,60 +0,0 @@ -//! Used to determine the current zig exe, version, and other information. -//! Simply calls `zig env` and parses the output - -const std = @import("std"); - -zig_exe: []const u8, -lib_dir: []const u8, -std_dir: []const u8, -global_cache_dir: []const u8, -version: []const u8, - -pub fn deinit(self: @This(), allocator: std.mem.Allocator) void { - allocator.free(self.zig_exe); - allocator.free(self.lib_dir); - allocator.free(self.std_dir); - allocator.free(self.global_cache_dir); - allocator.free(self.version); -} - -/// Simply calls `zig env` and parses the output -pub fn parse(allocator: std.mem.Allocator) !@This() { - var env_map = try std.process.getEnvMap(allocator); - defer env_map.deinit(); - - const result = try std.process.Child.run(.{ - .allocator = allocator, - .argv = &[_][]const u8{ "zig", "env" }, - .env_map = &env_map, - }); - defer allocator.free(result.stdout); - defer allocator.free(result.stderr); - - if (result.term != .Exited or result.term.Exited != 0) { - return error.UnexpectedExitCode; - } - - const env = try std.json.parseFromSlice(@This(), allocator, result.stdout, .{ .ignore_unknown_fields = true }); - defer env.deinit(); - - return .{ - .zig_exe = try allocator.dupe(u8, env.value.zig_exe), - .lib_dir = try allocator.dupe(u8, env.value.lib_dir), - .std_dir = try allocator.dupe(u8, env.value.std_dir), - .global_cache_dir = try allocator.dupe(u8, env.value.global_cache_dir), - .version = try allocator.dupe(u8, env.value.version), - }; -} - -test "ZigEnv.parse" { - const allocator = std.testing.allocator; - - const env = try parse(allocator); - defer env.deinit(allocator); - - std.debug.print("{s}\n", .{env.zig_exe}); - std.debug.print("{s}\n", .{env.lib_dir}); - std.debug.print("{s}\n", .{env.std_dir}); - std.debug.print("{s}\n", .{env.global_cache_dir}); - std.debug.print("{s}\n", .{env.version}); -} diff --git a/src/cmd_build.zig b/src/cmd_build.zig deleted file mode 100644 index d8b82f9..0000000 --- a/src/cmd_build.zig +++ /dev/null @@ -1,105 +0,0 @@ -const std = @import("std"); -const mem = std.mem; -const Allocator = mem.Allocator; -const fatal = std.process.fatal; - -const Config = @import("Config.zig"); -const Args = @import("Args.zig"); -const GlobalOptions = @import("GlobalOptions.zig"); -const sync = @import("cmd_sync.zig"); -const runZigBuild = @import("run_zig.zig").runZigBuild; - -/// Different kinds of zig build commands -pub const BuildKind = enum { - build, - install, - uninstall, - - build_exe, - build_lib, - build_obj, - build_test, - run, - - @"test", - fmt, -}; - -pub const BuildOpts = struct { - kind: BuildKind, - cmd: ?[]const u8 = null, - args: []const []const u8, - stderr_behavior: ?std.process.Child.StdIo = .Inherit, - stdout_behavior: ?std.process.Child.StdIo = .Inherit, -}; - -fn usage(kind: BuildKind) void { - _ = kind; -} - -fn list(kind: BuildKind, config: Config) void { - _ = kind; - _ = config; -} - -pub fn parseArgs(args: *Args, kind: BuildKind, config: Config) BuildOpts { - switch (kind) { - .build, .install, .uninstall => return .{ .kind = kind, .args = args.rest() }, - else => {}, - } - const arg = args.peek() orelse return .{ .kind = kind, .args = args.rest() }; - if (mem.eql(u8, arg, "--help") or mem.eql(u8, arg, "-h")) { - usage(kind); - return std.process.exit(0); - } else if (mem.eql(u8, arg, "--list") or mem.eql(u8, arg, "-l")) { - list(kind, config); - return std.process.exit(0); - } else if (mem.startsWith(u8, arg, "-")) { - if (kind == .build_exe or kind == .build_lib or kind == .build_obj or kind == .build_test) { - fatal("expected additional argument", .{}); - } - return .{ .kind = kind, .args = args.rest() }; - } else { - return .{ .kind = kind, .cmd = args.next() orelse fatal("expected command argument", .{}), .args = args.rest() }; - } -} - -pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config, opts: BuildOpts) !void { - if (!global_opts.no_sync) { - try sync.exec(gpa, arena, global_opts, config); - } - var step_name: [512]u8 = undefined; - const step = switch (opts.kind) { - .build => null, - .install => "install", - .uninstall => "uninstall", - - .build_exe => try std.fmt.bufPrint(&step_name, "build-exe:{s}", .{opts.cmd orelse unreachable}), - .build_lib => try std.fmt.bufPrint(&step_name, "build-lib:{s}", .{opts.cmd orelse unreachable}), - .build_obj => try std.fmt.bufPrint(&step_name, "build-obj:{s}", .{opts.cmd orelse unreachable}), - .build_test => try std.fmt.bufPrint(&step_name, "build-test:{s}", .{opts.cmd orelse unreachable}), - .run => try std.fmt.bufPrint(&step_name, "run:{s}", .{opts.cmd orelse unreachable}), - - .@"test" => if (opts.cmd) |c| - try std.fmt.bufPrint(&step_name, "test:{s}", .{c}) - else - "test", - .fmt => if (opts.cmd) |c| - try std.fmt.bufPrint(&step_name, "fmt:{s}", .{c}) - else - "fmt", - }; - - try runZigBuild( - gpa, - arena, - .{ - .cwd = global_opts.project_dir, - .stderr_behavior = opts.stderr_behavior orelse .Inherit, - .stdout_behavior = opts.stdout_behavior orelse .Inherit, - }, - global_opts.getZigEnv(), - step, - opts.args, - ); -} diff --git a/src/cmd_fetch.zig b/src/cmd_fetch.zig deleted file mode 100644 index 104138c..0000000 --- a/src/cmd_fetch.zig +++ /dev/null @@ -1,97 +0,0 @@ -const std = @import("std"); -const GlobalOptions = @import("GlobalOptions.zig"); -const Args = @import("Args.zig"); -const runZigFetch = @import("run_zig.zig").runZigFetch; -const Save = @import("run_zig.zig").ZigCmd.Fetch.Save; -const mem = std.mem; -const Allocator = mem.Allocator; -const cleanExit = std.process.cleanExit; -const fatal = std.process.fatal; - -const usage_fetch = - \\Usage: zbuild fetch [options] - \\Usage: zbuild fetch [options] - \\ - \\ Copy a package into the global cache and print its hash. - \\ must point to one of the following: - \\ - A git+http / git+https server for the package - \\ - A tarball file (with or without compression) containing - \\ package source - \\ - A git bundle file containing package source - \\ - \\Examples: - \\ - \\ zbuild fetch --save git+https://example.com/andrewrk/fun-example-tool.git - \\ zbuild fetch --save https://example.com/andrewrk/fun-example-tool/archive/refs/heads/master.tar.gz - \\ - \\Options: - \\ -h, --help Print this help and exit - \\ --global-cache-dir [path] Override path to global Zig cache directory - \\ --debug-hash Print verbose hash information to stdout - \\ --save Add the fetched package to build.zig.zon - \\ --save=[name] Add the fetched package to build.zig.zon as name - \\ --save-exact Add the fetched package to build.zig.zon, storing the URL verbatim - \\ --save-exact=[name] Add the fetched package to build.zig.zon as name, storing the URL verbatim - \\ -; - -pub const Opts = struct { - save: Save = .no, - debug_hash: bool = false, - global_cache_dir: ?[]const u8 = null, - path_or_url: []const u8, -}; - -pub fn parseArgs(args: *Args) !Opts { - var opts: Opts = .{ .path_or_url = undefined }; - var opt_path_or_url: ?[]const u8 = null; - while (args.next()) |arg| { - if (mem.startsWith(u8, arg, "-")) { - if (mem.eql(u8, arg, "-h") or mem.eql(u8, arg, "--help")) { - const stdout = std.io.getStdOut().writer(); - try stdout.writeAll(usage_fetch); - return std.process.exit(0); - } else if (mem.eql(u8, arg, "--global-cache-dir")) { - const value = args.next() orelse fatal("expected argument after '{s}'", .{arg}); - opts.global_cache_dir = value; - } else if (mem.eql(u8, arg, "--debug-hash")) { - opts.debug_hash = true; - } else if (mem.eql(u8, arg, "--save")) { - opts.save = .{ .yes = null }; - } else if (mem.startsWith(u8, arg, "--save=")) { - opts.save = .{ .yes = arg["--save=".len..] }; - } else if (mem.eql(u8, arg, "--save-exact")) { - opts.save = .{ .exact = null }; - } else if (mem.startsWith(u8, arg, "--save-exact=")) { - opts.save = .{ .exact = arg["--save-exact=".len..] }; - } else { - fatal("unrecognized parameter: '{s}'", .{arg}); - } - } else if (opt_path_or_url != null) { - fatal("unexpected extra parameter: '{s}'", .{arg}); - } else { - opt_path_or_url = arg; - } - } - opts.path_or_url = opt_path_or_url orelse fatal("missing url or path parameter", .{}); - return opts; -} - -pub fn exec( - gpa: Allocator, - arena: Allocator, - global_opts: GlobalOptions, - opts: Opts, -) !void { - try runZigFetch( - gpa, - arena, - .{ .cwd = global_opts.project_dir }, - global_opts.getZigEnv(), - opts.path_or_url, - opts.save, - ); - if (opts.save == .no) { - return cleanExit(); - } -} diff --git a/src/cmd_init.zig b/src/cmd_init.zig deleted file mode 100644 index 31a09cc..0000000 --- a/src/cmd_init.zig +++ /dev/null @@ -1,92 +0,0 @@ -const std = @import("std"); -const mem = std.mem; -const Allocator = std.mem.Allocator; -const GlobalOptions = @import("GlobalOptions.zig"); -const Config = @import("Config.zig"); -const sync = @import("cmd_sync.zig"); -const Package = @import("Package.zig"); - -pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions) !void { - const cwd = try std.fs.cwd().realpathAlloc(gpa, global_opts.project_dir); - defer gpa.free(cwd); - const name = try sanitizeExampleName(arena, std.fs.path.basename(cwd)); - const fingerprint = Package.Fingerprint.generate(name).int(); - - var paths = [_][]const u8{ "build.zig", "build.zig.zon", "src" }; - - var config = Config{ - .name = name, - .version = "0.1.0", - .minimum_zig_version = global_opts.version, - .fingerprint = fingerprint, - .paths = &paths, - }; - - try config.addExecutable(gpa, name, Config.Executable{ .root_module = .{ .module = .{ - .root_source_file = "src/main.zig", - } } }); - - const zbuild_filename = try std.fs.path.join(gpa, &[_][]const u8{ cwd, global_opts.zbuild_file }); - defer gpa.free(zbuild_filename); - try config.serializeToFile(zbuild_filename); - - const src_dirname = try std.fs.path.join(gpa, &[_][]const u8{ cwd, "src" }); - defer gpa.free(src_dirname); - std.fs.cwd().makeDir(src_dirname) catch |err| { - switch (err) { - error.PathAlreadyExists => {}, - else => return err, - } - }; - const main_filename = try std.fs.path.join(gpa, &[_][]const u8{ src_dirname, "main.zig" }); - const main_file = try std.fs.cwd().createFile(main_filename, .{}); - defer main_file.close(); - - try main_file.writeAll(main_bytes); - - if (!global_opts.no_sync) { - try sync.exec(gpa, arena, global_opts, config); - } -} - -const main_bytes = - \\//! By convention, main.zig is where your main function lives in the case that - \\//! you are building an executable. If you are making a library, the convention - \\//! is to delete this file and start with root.zig instead. - \\const std = @import("std"); - \\ - \\pub fn main() !void { - \\ // Prints to stderr (it's a shortcut based on `std.io.getStdErr()`) - \\ std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); - \\ - \\ // stdout is for the actual output of your application, for example if you - \\ // are implementing gzip, then only the compressed bytes should be sent to - \\ // stdout, not any debugging messages. - \\ const stdout_file = std.io.getStdOut().writer(); - \\ var bw = std.io.bufferedWriter(stdout_file); - \\ const stdout = bw.writer(); - \\ - \\ try stdout.print("Run `zbuild test` to run the tests.\n", .{}); - \\ - \\ try bw.flush(); // Don't forget to flush! - \\} - \\ -; - -fn sanitizeExampleName(arena: Allocator, bytes: []const u8) error{OutOfMemory}![]const u8 { - var result: std.ArrayListUnmanaged(u8) = .empty; - for (bytes, 0..) |byte, i| switch (byte) { - '0'...'9' => { - if (i == 0) try result.append(arena, '_'); - try result.append(arena, byte); - }, - '_', 'a'...'z', 'A'...'Z' => try result.append(arena, byte), - '-', '.', ' ' => try result.append(arena, '_'), - else => continue, - }; - if (!std.zig.isValidId(result.items)) return "foo"; - if (result.items.len > 64) - result.shrinkRetainingCapacity(64); - - return result.toOwnedSlice(arena); -} diff --git a/src/cmd_sync.zig b/src/cmd_sync.zig deleted file mode 100644 index cbccc26..0000000 --- a/src/cmd_sync.zig +++ /dev/null @@ -1,42 +0,0 @@ -const std = @import("std"); -const mem = std.mem; -const Allocator = std.mem.Allocator; -const fatal = std.process.fatal; -const GlobalOptions = @import("GlobalOptions.zig"); -const Config = @import("Config.zig"); - -const static_build_zig = - \\const std = @import("std"); - \\const zbuild = @import("zbuild"); - \\ - \\pub fn build(b: *std.Build) void { - \\ zbuild.configureBuild(b) catch |err| { - \\ std.log.err("zbuild: {}", .{err}); - \\ }; - \\} - \\ -; - -pub fn exec(gpa: Allocator, arena: Allocator, global_opts: GlobalOptions, config: Config) !void { - _ = gpa; - _ = arena; - _ = config; - if (global_opts.no_sync) { - fatal("--no-sync is incompatible with the sync command", .{}); - } - - var opened_dir: ?std.fs.Dir = null; - defer if (opened_dir) |*d| d.close(); - - const dir = if (global_opts.project_dir.len > 0 and !mem.eql(u8, global_opts.project_dir, ".")) blk: { - opened_dir = try std.fs.cwd().openDir(global_opts.project_dir, .{}); - break :blk opened_dir.?; - } else std.fs.cwd(); - - dir.writeFile(.{ - .sub_path = "build.zig", - .data = static_build_zig, - }) catch |err| { - fatal("failed to write build.zig: {s}", .{@errorName(err)}); - }; -} diff --git a/src/main.zig b/src/main.zig index c978b1d..5c371e3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,165 +1,15 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const process = std.process; -const Allocator = std.mem.Allocator; -const mem = std.mem; +//! zbuild — declarative build configuration for Zig projects. +//! +//! Add zbuild as a dependency, then call `configureBuild` from your build.zig: +//! +//! const zbuild = @import("zbuild"); +//! +//! pub fn build(b: *std.Build) void { +//! zbuild.configureBuild(b) catch |err| { +//! std.log.err("zbuild: {}", .{err}); +//! }; +//! } pub const Config = @import("Config.zig"); pub const build_runner = @import("build_runner.zig"); pub const configureBuild = build_runner.configureBuild; -pub const Args = @import("Args.zig"); -pub const GlobalOptions = @import("GlobalOptions.zig"); - -pub const init = @import("cmd_init.zig"); -pub const fetch = @import("cmd_fetch.zig"); -pub const build = @import("cmd_build.zig"); -pub const sync = @import("cmd_sync.zig"); -const fatal = std.process.fatal; - -pub const std_options: std.Options = .{ - .log_level = .info, -}; - -const usage = - \\Usage: zbuild [global_options] [command] [options] - \\ - \\Commands: - \\ - \\ init Initialize a Zig package in the current directory - \\ fetch Copy a package into global cache - \\ - \\ install Install all artifacts - \\ uninstall Uninstall all artifacts - \\ build Run `zig build` - \\ sync Sync build.zig and build.zig.zon - \\ build-exe Build an executable - \\ build-lib Build a library - \\ build-obj Build an object file - \\ build-test Build a test into an executable - \\ run Run an executable or run script - \\ test Perform unit testing - \\ fmt Format source code - \\ - \\ help Print this help and exit - \\ version Print version number and exit - \\ - \\Global Options: - \\ - \\ --zig-exe [path] Override path to Zig executable - \\ --global-cache-dir [path] Override path to global Zig cache directory - \\ --zig-lib-dir [path] Override path to Zig library directory - \\ --zig-std-dir [path] Override path to Zig standard library directory - \\ --project-dir [path] Override path to project directory - \\ --zbuild-file [path] Override path to zbuild file - \\ --no-sync Skip automatic synchronization of build.zig and build.zig.zon - \\ - \\General Options: - \\ - \\ -h, --help Print command-specific usage - \\ -; - -pub fn main() anyerror!void { - // Here we use an ArenaAllocator because a build is a short-lived, - // one shot program. We don't need to waste time freeing memory and finding places to squish - // bytes into. So we free everything all at once at the very end. - var gpa = std.heap.DebugAllocator(.{}){}; - const gpa_allocator = gpa.allocator(); - var arena_instance = std.heap.ArenaAllocator.init(gpa_allocator); - defer arena_instance.deinit(); - const arena = arena_instance.allocator(); - - var args = try Args.initFromProcessArgs(arena); - _ = args.next(); - - return mainArgs(gpa_allocator, arena, &args); -} - -fn mainArgs(gpa: Allocator, arena: Allocator, args: *Args) !void { - const first_arg = args.peek() orelse { - cmdUsage(); - fatal("expected command argument", .{}); - }; - - // commands that shouldn't ever have any side effects - if (mem.eql(u8, first_arg, "help") or mem.eql(u8, first_arg, "--help") or mem.eql(u8, first_arg, "-h")) { - cmdUsage(); - return; - } else if (mem.eql(u8, first_arg, "version")) { - std.log.info("zig version: {s}", .{builtin.zig_version_string}); - return; - } - - const global_opts = try GlobalOptions.parseArgs(gpa, args); - defer global_opts.deinit(gpa); - - const cmd = args.next() orelse { - cmdUsage(); - fatal("expected command argument", .{}); - }; - - if (mem.eql(u8, cmd, "init")) { - try init.exec(gpa, arena, global_opts); - return; - } - - var wip_bundle: std.zig.ErrorBundle.Wip = undefined; - try wip_bundle.init(gpa); - defer wip_bundle.deinit(); - const config = Config.parseFromFile(arena, global_opts.zbuild_file, &wip_bundle) catch |err| switch (err) { - error.FileNotFound => { - fatal("no build.zig.zon file found", .{}); - }, - error.OutOfMemory => { - fatal("out of memory", .{}); - }, - else => { - var error_bundle = try wip_bundle.toOwnedBundle(""); - error_bundle.renderToStdErr(.{ .ttyconf = .escape_codes }); - std.process.exit(1); - }, - }; - - if (mem.eql(u8, cmd, "sync")) { - try sync.exec(gpa, arena, global_opts, config); - } else if (mem.eql(u8, cmd, "fetch")) { - try fetch.exec( - gpa, - arena, - global_opts, - try fetch.parseArgs(args), - ); - } else { - var kind: build.BuildKind = undefined; - if (mem.eql(u8, cmd, "build")) { - kind = .build; - } else if (mem.eql(u8, cmd, "install")) { - kind = .install; - } else if (mem.eql(u8, cmd, "uninstall")) { - kind = .uninstall; - } else if (mem.eql(u8, cmd, "build-exe")) { - kind = .build_exe; - } else if (mem.eql(u8, cmd, "build-lib")) { - kind = .build_lib; - } else if (mem.eql(u8, cmd, "build-obj")) { - kind = .build_obj; - } else if (mem.eql(u8, cmd, "build-test")) { - kind = .build_test; - } else if (mem.eql(u8, cmd, "run")) { - kind = .run; - } else if (mem.eql(u8, cmd, "test")) { - kind = .@"test"; - } else if (mem.eql(u8, cmd, "fmt")) { - kind = .fmt; - } else { - cmdUsage(); - fatal("unknown command: {s}", .{cmd}); - } - const opts = build.parseArgs(args, kind, config); - try build.exec(gpa, arena, global_opts, config, opts); - } -} - -fn cmdUsage() void { - std.log.info("{s}", .{usage}); -} diff --git a/src/run_zig.zig b/src/run_zig.zig deleted file mode 100644 index 6fe5286..0000000 --- a/src/run_zig.zig +++ /dev/null @@ -1,128 +0,0 @@ -//! Simple wrapper around the `zig` executable. - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const ZigEnv = @import("ZigEnv.zig"); - -pub fn runZigBuild(gpa: Allocator, arena: Allocator, child_opts: ChildOpts, env: ZigEnv, step: ?[]const u8, args: []const []const u8) !void { - try runZig(gpa, arena, child_opts, env, .{ - .build = .{ - .step = step, - .args = args, - }, - }); -} - -pub fn runZigFetch(gpa: Allocator, arena: Allocator, child_opts: ChildOpts, env: ZigEnv, path_or_url: []const u8, save: ZigCmd.Fetch.Save) !void { - try runZig(gpa, arena, child_opts, env, .{ - .fetch = .{ - .path_or_url = path_or_url, - .save = save, - }, - }); -} - -pub fn runZigFmt(gpa: Allocator, arena: Allocator, child_opts: ChildOpts, env: ZigEnv, args: []const []const u8) !void { - try runZig(gpa, arena, child_opts, env, .{ - .fmt = .{ - .args = args, - }, - }); -} - -pub const ZigCmd = union(enum) { - build: Build, - fetch: Fetch, - fmt: Fmt, - - pub const Build = struct { - step: ?[]const u8, - args: []const []const u8, - }; - - pub const Fetch = struct { - path_or_url: []const u8, - save: Save = .no, - - pub const Save = union(enum) { - no, - yes: ?[]const u8, - exact: ?[]const u8, - }; - }; - - pub const Fmt = struct { - args: []const []const u8, - }; -}; - -pub const ChildOpts = struct { - cwd: ?[]const u8 = null, - stdout_behavior: std.process.Child.StdIo = .Inherit, - stderr_behavior: std.process.Child.StdIo = .Inherit, -}; - -pub fn runZig(gpa: Allocator, arena: Allocator, child_opts: ChildOpts, env: ZigEnv, cmd: ZigCmd) !void { - var argv = std.ArrayList([]const u8).init(gpa); - defer argv.deinit(); - - try argv.append(env.zig_exe); - - switch (cmd) { - .build => |build| { - try argv.append("build"); - if (build.step) |s| { - try argv.append(s); - } - try argv.appendSlice(build.args); - }, - .fetch => |fetch| { - try argv.append("fetch"); - try argv.append(fetch.path_or_url); - switch (fetch.save) { - .no => {}, - .yes => |name| { - if (name) |n| { - const arg = try std.fmt.allocPrint(arena, "--save={s}", .{n}); - try argv.append(arg); - } else { - try argv.append("--save"); - } - }, - .exact => |name| { - if (name) |n| { - const arg = try std.fmt.allocPrint(arena, "--save={s}", .{n}); - try argv.append(arg); - } else { - try argv.append("--save-exact"); - } - }, - } - }, - .fmt => |fmt| { - try argv.append("fmt"); - try argv.appendSlice(fmt.args); - }, - } - - var child = std.process.Child.init(argv.items, gpa); - child.cwd = child_opts.cwd; - child.stdout_behavior = child_opts.stdout_behavior; - child.stderr_behavior = child_opts.stderr_behavior; - - const term = try child.spawnAndWait(); - - switch (term) { - .Exited => |code| { - if (code == 0) { - return; - } else { - std.process.exit(code); - } - }, - else => {}, - } - - std.process.exit(1); -} diff --git a/test/fixtures/basic1.build.zig.zon b/test/fixtures/basic1.build.zig.zon deleted file mode 100644 index cb303a4..0000000 --- a/test/fixtures/basic1.build.zig.zon +++ /dev/null @@ -1,12 +0,0 @@ -.{ - .name = .basic, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{"src"}, - .modules = .{ - .module_0 = .{ - .root_source_file = "src/module_0/main.zig", - }, - }, -} diff --git a/test/fixtures/basic2.build.zig.zon b/test/fixtures/basic2.build.zig.zon deleted file mode 100644 index 520c66c..0000000 --- a/test/fixtures/basic2.build.zig.zon +++ /dev/null @@ -1,35 +0,0 @@ -.{ - .name = .basic, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{"src"}, - .modules = .{ - .module_0 = .{ - .root_source_file = "src/module_0/main.zig", - }, - .module_1 = .{ - .root_source_file = "src/module_1/main.zig", - .imports = .{.module_0}, - .target = "native", - .optimize = .ReleaseFast, - .link_libc = true, - .link_libcpp = true, - .single_threaded = true, - .strip = true, - .unwind_tables = .none, - .dwarf_format = .@"32", - .code_model = .default, - .stack_protector = true, - .stack_check = true, - .sanitize_c = true, - .sanitize_thread = true, - .fuzz = false, - .valgrind = true, - .pic = true, - .red_zone = true, - .omit_frame_pointer = true, - .error_tracing = true, - }, - }, -} diff --git a/test/fixtures/basic3.build.zig.zon b/test/fixtures/basic3.build.zig.zon deleted file mode 100644 index 4181508..0000000 --- a/test/fixtures/basic3.build.zig.zon +++ /dev/null @@ -1,19 +0,0 @@ -.{ - .name = .basic, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{"src"}, - .modules = .{ - .module_0 = .{ - .root_source_file = "src/module_0/main.zig", - .target = "native", - .optimize = .ReleaseFast, - }, - .module_1 = .{ - .root_source_file = "src/module_1/main.zig", - .target = "native", - .optimize = .ReleaseFast, - }, - }, -} diff --git a/test/fixtures/basic4.build.zig.zon b/test/fixtures/basic4.build.zig.zon deleted file mode 100644 index 60c06e6..0000000 --- a/test/fixtures/basic4.build.zig.zon +++ /dev/null @@ -1,23 +0,0 @@ -.{ - .name = .basic, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{"src"}, - .executables = .{ - .module_0 = .{ - .root_module = .{ - .name = "module_0_exe", - .root_source_file = "src/module_0/main.zig", - }, - }, - }, - .libraries = .{ - .module_0 = .{ - .root_module = .{ - .name = "module_0_lib", - .root_source_file = "src/module_0/main.zig", - }, - }, - }, -} diff --git a/test/fixtures/basic5.build.zig.zon b/test/fixtures/basic5.build.zig.zon deleted file mode 100644 index bd9cf78..0000000 --- a/test/fixtures/basic5.build.zig.zon +++ /dev/null @@ -1,33 +0,0 @@ -.{ - .name = .basic, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{"src"}, - .modules = .{ - .module_0 = .{ - .root_source_file = "src/module_0/main.zig", - .imports = .{.build_options}, - }, - }, - .executables = .{ - .exe_0 = .{ - .root_module = .{ - .root_source_file = "src/module_0/main.zig", - .imports = .{.build_options}, - }, - }, - }, - .options_modules = .{ - .build_options = .{ - .min_depth = .{ - .type = "usize", - .default = 0, - }, - .max_depth = .{ - .type = "usize", - .default = 100, - }, - }, - }, -} diff --git a/test/fixtures/basic6.build.zig.zon b/test/fixtures/basic6.build.zig.zon deleted file mode 100644 index b327568..0000000 --- a/test/fixtures/basic6.build.zig.zon +++ /dev/null @@ -1,17 +0,0 @@ -.{ - .name = .basic, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{"src"}, - .executables = .{ - .exe_0 = .{ - .root_module = .{ - .root_source_file = "src/module_0/main.zig", - }, - }, - }, - .runs = .{ - .docs = "echo 'Generating documentation...'", - }, -} diff --git a/test/sync.zig b/test/sync.zig deleted file mode 100644 index 2466f80..0000000 --- a/test/sync.zig +++ /dev/null @@ -1,72 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const zbuild = @import("zbuild"); - -const cwd = "test"; - -const test_cases = &[_][]const u8{ - "fixtures/basic1.build.zig.zon", - "fixtures/basic2.build.zig.zon", - "fixtures/basic3.build.zig.zon", - "fixtures/basic4.build.zig.zon", - "fixtures/basic5.build.zig.zon", - "fixtures/basic6.build.zig.zon", -}; - -fn cleanup() void { - const dir = std.fs.cwd().openDir(cwd, .{}) catch return; - dir.deleteFile("build.zig") catch {}; -} - -/// Test that each fixture can be parsed and that sync writes a valid build.zig -fn testSync(gpa: Allocator, arena: Allocator, global_opts: zbuild.GlobalOptions) !void { - defer cleanup(); - - // Phase 1: Verify the config parses without error - const config = try zbuild.Config.parseFromFile(arena, global_opts.zbuild_file, null); - - // Phase 2: Run sync to generate build.zig - try zbuild.sync.exec(gpa, arena, global_opts, config); - - // Phase 3: Verify build.zig was written with the static template - var opened_dir: ?std.fs.Dir = null; - defer if (opened_dir) |*d| d.close(); - - const dir = if (global_opts.project_dir.len > 0 and !std.mem.eql(u8, global_opts.project_dir, ".")) blk: { - opened_dir = try std.fs.cwd().openDir(global_opts.project_dir, .{}); - break :blk opened_dir.?; - } else std.fs.cwd(); - - const build_zig = try dir.readFileAlloc(gpa, "build.zig", 4096); - defer gpa.free(build_zig); - - // Verify it contains the zbuild import - try std.testing.expect(std.mem.indexOf(u8, build_zig, "zbuild.configureBuild") != null); -} - -test "zbuild sync generates static build.zig" { - const allocator = std.testing.allocator; - - for (test_cases) |test_case| { - var arena_alloc = std.heap.ArenaAllocator.init(allocator); - defer arena_alloc.deinit(); - const arena = arena_alloc.allocator(); - - const zbuild_file = try std.fs.path.join(allocator, &[_][]const u8{ cwd, test_case }); - defer allocator.free(zbuild_file); - - var args = try zbuild.Args.initFromString( - arena, - try std.fmt.allocPrint( - arena, - "--project-dir {s} --zbuild-file {s}", - .{ cwd, zbuild_file }, - ), - ); - const global_opts = try zbuild.GlobalOptions.parseArgs(allocator, &args); - defer global_opts.deinit(allocator); - - try testSync(allocator, arena, global_opts); - } -} From 97c82cb6169b569e9c864329ebb3cd26f03621f8 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 13:53:27 -0400 Subject: [PATCH 12/62] refactor: replace runtime parser with comptime @import("build.zig.zon") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete Config.zig entirely (2,112 lines — parser, serializer, IR types, tests). The Zig compiler now parses build.zig.zon via @import, and build_runner.zig walks the comptime anonymous struct directly using inline for + @hasField. This resolves the dependency args impedance mismatch: since the manifest is comptime-known, dependency .args flow through to b.dependency() naturally without needing a runtime→comptime bridge. Project shrinks from ~2,630 to ~583 lines of source. Co-Authored-By: Claude Opus 4.6 --- src/Config.zig | 2112 ------------------------------------------ src/build_runner.zig | 576 +++++++----- src/main.zig | 3 +- 3 files changed, 322 insertions(+), 2369 deletions(-) delete mode 100644 src/Config.zig diff --git a/src/Config.zig b/src/Config.zig deleted file mode 100644 index 9d18f50..0000000 --- a/src/Config.zig +++ /dev/null @@ -1,2112 +0,0 @@ -//! Configuration file (aka `zbuild.zon`) format for the Zig build system. -//! This file is meant to be a superset of the `build.zig.zon` manifest file format. - -const std = @import("std"); -const ArrayHashMap = std.StringArrayHashMap; - -const Config = @This(); - -name: []const u8, -version: []const u8, -fingerprint: u64, -minimum_zig_version: []const u8, -paths: [][]const u8, -description: ?[]const u8 = null, -keywords: ?[][]const u8 = null, -dependencies: ?ArrayHashMap(Dependency) = null, -write_files: ?ArrayHashMap(WriteFile) = null, -options: ?ArrayHashMap(Option) = null, -options_modules: ?ArrayHashMap(OptionsModule) = null, -modules: ?ArrayHashMap(Module) = null, -executables: ?ArrayHashMap(Executable) = null, -libraries: ?ArrayHashMap(Library) = null, -objects: ?ArrayHashMap(Object) = null, -tests: ?ArrayHashMap(Test) = null, -fmts: ?ArrayHashMap(Fmt) = null, -runs: ?ArrayHashMap(Run) = null, - -pub const Dependency = struct { - typ: enum { path, url }, - value: []const u8, - hash: ?[]const u8 = null, - lazy: ?bool = null, - args: ?ArrayHashMap(Arg) = null, - - const Arg = union(enum) { - bool: bool, - int: i64, - float: f64, - @"enum": []const u8, - string: []const u8, - null: void, - }; - -}; - -pub const Option = union(enum) { - bool: Bool, - int: Int, - float: Float, - @"enum": Enum, - enum_list: EnumList, - string: String, - list: List, - build_id: BuildId, - lazy_path: LazyPath, - lazy_path_list: LazyPathList, - - pub const Bool = struct { - default: ?bool = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const Int = struct { - default: ?i64 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const Float = struct { - default: ?f64 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const Enum = struct { - default: ?[]const u8 = null, - enum_options: [][]const u8, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const EnumList = struct { - default: ?[][]const u8 = null, - enum_options: [][]const u8, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const String = struct { - default: ?[]const u8 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const List = struct { - default: ?[][]const u8 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const BuildId = struct { - default: ?[]const u8 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const LazyPath = struct { - default: ?[]const u8 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - pub const LazyPathList = struct { - default: ?[][]const u8 = null, - type: []const u8, - description: ?[]const u8 = null, - }; - - pub fn isValidIntType(t: []const u8) bool { - return std.mem.eql(u8, t, "i8") or - std.mem.eql(u8, t, "u8") or - std.mem.eql(u8, t, "i16") or - std.mem.eql(u8, t, "u16") or - std.mem.eql(u8, t, "i32") or - std.mem.eql(u8, t, "u32") or - std.mem.eql(u8, t, "i64") or - std.mem.eql(u8, t, "u64") or - std.mem.eql(u8, t, "i128") or - std.mem.eql(u8, t, "u128") or - std.mem.eql(u8, t, "isize") or - std.mem.eql(u8, t, "usize") or - std.mem.eql(u8, t, "c_short") or - std.mem.eql(u8, t, "c_ushort") or - std.mem.eql(u8, t, "c_int") or - std.mem.eql(u8, t, "c_uint") or - std.mem.eql(u8, t, "c_long") or - std.mem.eql(u8, t, "c_ulong") or - std.mem.eql(u8, t, "c_longlong") or - std.mem.eql(u8, t, "c_ulonglong"); - } - - pub fn isValidFloatType(t: []const u8) bool { - return std.mem.eql(u8, t, "f16") or - std.mem.eql(u8, t, "f32") or - std.mem.eql(u8, t, "f64") or - std.mem.eql(u8, t, "f80") or - std.mem.eql(u8, t, "f128") or - std.mem.eql(u8, t, "c_longdouble"); - } -}; - -pub const OptionsModule = ArrayHashMap(Option); - -pub const WriteFile = struct { - private: ?bool = null, - items: ?ArrayHashMap(Path) = null, - - pub const Path = union(enum) { - file: File, - dir: Dir, - - pub const File = struct { - type: []const u8, - path: []const u8, - }; - - pub const Dir = struct { - type: []const u8, - path: []const u8, - exclude_extensions: ?[][]const u8 = null, - include_extensions: ?[][]const u8 = null, - }; - }; - -}; - -pub const Module = struct { - name: ?[]const u8 = null, - root_source_file: ?[]const u8 = null, - imports: ?[][]const u8 = null, - private: ?bool = null, - - target: ?[]const u8 = null, - optimize: ?std.builtin.OptimizeMode = null, - link_libc: ?bool = null, - link_libcpp: ?bool = null, - single_threaded: ?bool = null, - strip: ?bool = null, - unwind_tables: ?std.builtin.UnwindTables = null, - dwarf_format: ?std.dwarf.Format = null, - code_model: ?std.builtin.CodeModel = null, - stack_protector: ?bool = null, - stack_check: ?bool = null, - sanitize_c: ?bool = null, - sanitize_thread: ?bool = null, - fuzz: ?bool = null, - valgrind: ?bool = null, - pic: ?bool = null, - red_zone: ?bool = null, - omit_frame_pointer: ?bool = null, - error_tracing: ?bool = null, - include_paths: ?[][]const u8 = null, - link_libraries: ?[][]const u8 = null, - -}; - -pub const ModuleLink = union(enum) { - name: []const u8, - module: Module, - -}; - -pub const Executable = struct { - name: ?[]const u8 = null, - version: ?[]const u8 = null, - root_module: ModuleLink, - linkage: ?std.builtin.LinkMode = null, - max_rss: ?usize = null, - use_llvm: ?bool = null, - use_lld: ?bool = null, - zig_lib_dir: ?[]const u8 = null, - win32_manifest: ?[]const u8 = null, - - // install artifact options - dest_sub_path: ?[]const u8 = null, - - depends_on: ?[][]const u8 = null, - -}; - -pub const Library = struct { - name: ?[]const u8 = null, - version: ?[]const u8 = null, - root_module: ModuleLink, - linkage: ?std.builtin.LinkMode = null, - max_rss: ?usize = null, - use_llvm: ?bool = null, - use_lld: ?bool = null, - zig_lib_dir: ?[]const u8 = null, - win32_manifest: ?[]const u8 = null, - linker_allow_shlib_undefined: ?bool = null, - - // install artifact options - dest_sub_path: ?[]const u8 = null, - - depends_on: ?[][]const u8 = null, - -}; - -pub const Object = struct { - name: ?[]const u8 = null, - root_module: ModuleLink, - max_rss: ?usize = null, - use_llvm: ?bool = null, - use_lld: ?bool = null, - zig_lib_dir: ?[]const u8 = null, - - depends_on: ?[][]const u8 = null, - -}; - -pub const Test = struct { - name: ?[]const u8 = null, - root_module: ModuleLink, - max_rss: ?usize = null, - use_llvm: ?bool = null, - use_lld: ?bool = null, - zig_lib_dir: ?[]const u8 = null, - - filters: []const []const u8 = &.{}, - test_runner: ?[]const u8 = null, - -}; - -pub const Fmt = struct { - paths: ?[][]const u8 = null, - exclude_paths: ?[][]const u8 = null, - check: ?bool = false, - -}; - -pub const Run = []const u8; - - -pub fn addDependency(config: *Config, gpa: std.mem.Allocator, name: []const u8, dependency: Dependency) !void { - if (config.dependencies == null) { - config.dependencies = ArrayHashMap(Dependency).init(gpa); - } - try config.dependencies.?.put(name, dependency); -} - -pub fn addExecutable(config: *Config, gpa: std.mem.Allocator, name: []const u8, executable: Executable) !void { - if (config.executables == null) { - config.executables = ArrayHashMap(Executable).init(gpa); - } - try config.executables.?.put(name, executable); -} - -pub fn parseFromFile(gpa: std.mem.Allocator, zbuild_file: []const u8, wip_bundle: ?*std.zig.ErrorBundle.Wip) !Config { - const source = try std.fs.cwd().readFileAllocOptions(gpa, zbuild_file, 16_000, null, @alignOf(u8), 0); - defer gpa.free(source); - - var ast = try std.zig.Ast.parse(gpa, source, .zon); - defer ast.deinit(gpa); - - var zoir = try std.zig.ZonGen.generate(gpa, ast, .{}); - defer zoir.deinit(gpa); - - if (zoir.hasCompileErrors()) { - if (wip_bundle) |wip| { - try wip.addZoirErrorMessages(zoir, ast, source, zbuild_file); - } - - return error.ParseZoir; - } - - return parseFromZoir(gpa, zbuild_file, zoir, ast, wip_bundle); -} - -pub fn parseFromZoir(gpa: std.mem.Allocator, zbuild_file: []const u8, zoir: std.zig.Zoir, ast: std.zig.Ast, wip_bundle: ?*std.zig.ErrorBundle.Wip) Parser.Error!Config { - var status = std.zon.parse.Status{}; - var parser = Parser{ .gpa = gpa, .zoir = zoir, .ast = ast, .status = &status }; - - return parser.parse() catch |e| { - if (status.type_check) |err| { - const err_span = ast.tokenToSpan(err.token); - const err_loc = std.zig.findLineColumn(ast.source, err_span.main); - - if (wip_bundle) |wip| { - try wip.addRootErrorMessage(.{ - .msg = try wip.addString(err.message), - .src_loc = try wip.addSourceLocation(.{ - .src_path = try wip.addString(zbuild_file), - .line = @intCast(err_loc.line), - .column = @intCast(err_loc.column), - .span_start = err_span.start, - .span_end = err_span.end, - .span_main = err_span.main, - .source_line = try wip.addString(err_loc.source_line), - }), - }); - } - } - return e; - }; -} - -const Parser = struct { - gpa: std.mem.Allocator, - zoir: std.zig.Zoir, - ast: std.zig.Ast, - status: *std.zon.parse.Status, - - const Error = error{ OutOfMemory, ParseZon, NegativeIntoUnsigned, TargetTooSmall }; - - fn parse(self: *Parser) Error!Config { - var config = Config{ - .name = "", - .version = "", - .fingerprint = 0, - .minimum_zig_version = "", - .paths = &.{}, - }; - - var has_name = false; - var has_version = false; - var has_fingerprint = false; - var has_minimum_zig_version = false; - var has_paths = false; - - const r = try self.parseStructLiteral(.root); - for (r.names, 0..) |n, i| { - const field_name = n.get(self.zoir); - const field_value = r.vals.at(@intCast(i)); - - if (std.mem.eql(u8, field_name, "name")) { - has_name = true; - config.name = try self.parseEnumLiteral(field_value); - } else if (std.mem.eql(u8, field_name, "version")) { - has_version = true; - config.version = try self.parseVersionString(field_value); - } else if (std.mem.eql(u8, field_name, "fingerprint")) { - has_fingerprint = true; - config.fingerprint = try self.parseT(u64, field_value); - } else if (std.mem.eql(u8, field_name, "minimum_zig_version")) { - has_minimum_zig_version = true; - config.minimum_zig_version = try self.parseVersionString(field_value); - } else if (std.mem.eql(u8, field_name, "paths")) { - has_paths = true; - config.paths = try self.parseT([][]const u8, field_value); - } else if (std.mem.eql(u8, field_name, "description")) { - config.description = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "keywords")) { - config.keywords = try self.parseT(?[][]const u8, field_value); - } else if (std.mem.eql(u8, field_name, "dependencies")) { - config.dependencies = try self.parseHashMap(Dependency, parseDependency, field_value); - } else if (std.mem.eql(u8, field_name, "write_files")) { - config.write_files = try self.parseHashMap(WriteFile, parseWriteFile, field_value); - } else if (std.mem.eql(u8, field_name, "options")) { - config.options = try self.parseHashMap(Option, parseOption, field_value); - } else if (std.mem.eql(u8, field_name, "options_modules")) { - config.options_modules = try self.parseHashMap(OptionsModule, parseOptionsModule, field_value); - } else if (std.mem.eql(u8, field_name, "modules")) { - config.modules = try self.parseHashMap(Module, parseModule, field_value); - } else if (std.mem.eql(u8, field_name, "executables")) { - config.executables = try self.parseHashMap(Executable, parseExecutable, field_value); - } else if (std.mem.eql(u8, field_name, "libraries")) { - config.libraries = try self.parseHashMap(Library, parseLibrary, field_value); - } else if (std.mem.eql(u8, field_name, "objects")) { - config.objects = try self.parseHashMap(Object, parseObject, field_value); - } else if (std.mem.eql(u8, field_name, "tests")) { - config.tests = try self.parseHashMap(Test, parseTest, field_value); - } else if (std.mem.eql(u8, field_name, "fmts")) { - config.fmts = try self.parseHashMap(Fmt, parseFmt, field_value); - } else if (std.mem.eql(u8, field_name, "runs")) { - config.runs = try self.parseHashMap(Run, parseRun, field_value); - } else { - // Ignore unknown fields — this allows build.zig.zon standard fields - // that zbuild doesn't use (like Zig-added future fields) to pass through. - } - } - - if (!has_name) try self.returnParseError("missing required field 'name'", self.ast.rootDecls()[0]); - if (!has_version) try self.returnParseError("missing required field 'version'", self.ast.rootDecls()[0]); - if (!has_fingerprint) try self.returnParseError("missing required field 'fingerprint'", self.ast.rootDecls()[0]); - if (!has_minimum_zig_version) try self.returnParseError("missing required field 'minimum_zig_version'", self.ast.rootDecls()[0]); - if (!has_paths) try self.returnParseError("missing required field 'paths'", self.ast.rootDecls()[0]); - - return config; - } - - // -- Layer 2: HashMap parsing -- - - fn parseHashMap( - self: *Parser, - comptime V: type, - comptime parseItem: fn (*Parser, std.zig.Zoir.Node.Index) Error!V, - index: std.zig.Zoir.Node.Index, - ) Error!?ArrayHashMap(V) { - const node = index.get(self.zoir); - switch (node) { - .struct_literal => |n| { - var items = ArrayHashMap(V).init(self.gpa); - for (n.names, 0..) |name, i| { - const field_name = try self.gpa.dupe(u8, name.get(self.zoir)); - const field_value = n.vals.at(@intCast(i)); - try items.put(field_name, try parseItem(self, field_value)); - } - return items; - }, - .empty_literal => return null, - else => { - try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)); - }, - } - } - - // -- Layer 3: Types parsed with inline for + fromZoirNode -- - - fn parseModule(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Module { - const n = try self.parseStructLiteral(index); - var module = Module{}; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "imports")) { - module.imports = try self.parseStringOrEnumSlice(field_value); - } else if (std.mem.eql(u8, field_name, "name")) { - module.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "root_source_file")) { - module.root_source_file = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "target")) { - module.target = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "private")) { - module.private = try self.parseT(bool, field_value); - } else if (std.mem.eql(u8, field_name, "include_paths")) { - module.include_paths = try self.parseStringOrEnumSlice(field_value); - } else if (std.mem.eql(u8, field_name, "link_libraries")) { - module.link_libraries = try self.parseStringOrEnumSlice(field_value); - } else { - inline for (@typeInfo(Module).@"struct".fields) |field| { - if (std.mem.eql(u8, field_name, field.name)) { - @field(module, field.name) = try self.parseT(field.type, field_value); - break; - } - } - } - } - return module; - } - - fn parseExecutable(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Executable { - const n = try self.parseStructLiteral(index); - var exe = Executable{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "root_module")) { - exe.root_module = try self.parseModuleLink(field_value); - has_root_module = true; - } else if (std.mem.eql(u8, field_name, "name")) { - exe.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "version")) { - exe.version = try self.parseVersionString(field_value); - } else if (std.mem.eql(u8, field_name, "depends_on")) { - exe.depends_on = try self.parseStringOrEnumSlice(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - exe.zig_lib_dir = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "win32_manifest")) { - exe.win32_manifest = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { - exe.dest_sub_path = try self.parseString(field_value); - } else { - inline for (@typeInfo(Executable).@"struct".fields) |field| { - if (std.mem.eql(u8, field_name, field.name)) { - @field(exe, field.name) = try self.parseT(field.type, field_value); - break; - } - } - } - } - if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - return exe; - } - - fn parseLibrary(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Library { - const n = try self.parseStructLiteral(index); - var lib = Library{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "root_module")) { - lib.root_module = try self.parseModuleLink(field_value); - has_root_module = true; - } else if (std.mem.eql(u8, field_name, "name")) { - lib.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "version")) { - lib.version = try self.parseVersionString(field_value); - } else if (std.mem.eql(u8, field_name, "depends_on")) { - lib.depends_on = try self.parseStringOrEnumSlice(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - lib.zig_lib_dir = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "win32_manifest")) { - lib.win32_manifest = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "dest_sub_path")) { - lib.dest_sub_path = try self.parseString(field_value); - } else { - inline for (@typeInfo(Library).@"struct".fields) |field| { - if (std.mem.eql(u8, field_name, field.name)) { - @field(lib, field.name) = try self.parseT(field.type, field_value); - break; - } - } - } - } - if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - return lib; - } - - fn parseObject(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Object { - const n = try self.parseStructLiteral(index); - var obj = Object{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "root_module")) { - obj.root_module = try self.parseModuleLink(field_value); - has_root_module = true; - } else if (std.mem.eql(u8, field_name, "name")) { - obj.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "depends_on")) { - obj.depends_on = try self.parseStringOrEnumSlice(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - obj.zig_lib_dir = try self.parseString(field_value); - } else { - inline for (@typeInfo(Object).@"struct".fields) |field| { - if (std.mem.eql(u8, field_name, field.name)) { - @field(obj, field.name) = try self.parseT(field.type, field_value); - break; - } - } - } - } - if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - return obj; - } - - fn parseTest(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Test { - const n = try self.parseStructLiteral(index); - var t = Test{ .root_module = .{ .name = "" } }; - var has_root_module = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "root_module")) { - t.root_module = try self.parseModuleLink(field_value); - has_root_module = true; - } else if (std.mem.eql(u8, field_name, "name")) { - t.name = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "filters")) { - t.filters = try self.parseStringOrEnumSlice(field_value) orelse &.{}; - } else if (std.mem.eql(u8, field_name, "test_runner")) { - t.test_runner = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "zig_lib_dir")) { - t.zig_lib_dir = try self.parseString(field_value); - } else { - inline for (@typeInfo(Test).@"struct".fields) |field| { - if (std.mem.eql(u8, field_name, field.name)) { - @field(t, field.name) = try self.parseT(field.type, field_value); - break; - } - } - } - } - if (!has_root_module) try self.returnParseError("missing required field 'root_module'", index.getAstNode(self.zoir)); - return t; - } - - fn parseFmt(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Fmt { - return try self.parseT(Fmt, index); - } - - fn parseRun(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Run { - return try self.parseString(index); - } - - fn parseWriteFile(self: *Parser, index: std.zig.Zoir.Node.Index) Error!WriteFile { - const n = try self.parseStructLiteral(index); - var wf = WriteFile{}; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "private")) { - wf.private = try self.parseT(bool, field_value); - } else if (std.mem.eql(u8, field_name, "items")) { - wf.items = try self.parseHashMap(WriteFile.Path, parseWriteFilePath, field_value); - } - } - return wf; - } - - fn parseWriteFilePath(self: *Parser, index: std.zig.Zoir.Node.Index) Error!WriteFile.Path { - const n = try self.parseStructLiteral(index); - var path_str: ?[]const u8 = null; - var type_str: ?[]const u8 = null; - var exclude_extensions: ?[][]const u8 = null; - var include_extensions: ?[][]const u8 = null; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "type")) { - type_str = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "path")) { - path_str = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "exclude_extensions")) { - exclude_extensions = try self.parseStringOrEnumSlice(field_value); - } else if (std.mem.eql(u8, field_name, "include_extensions")) { - include_extensions = try self.parseStringOrEnumSlice(field_value); - } - } - const t = type_str orelse { - try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); - }; - const p = path_str orelse { - try self.returnParseError("missing required field 'path'", index.getAstNode(self.zoir)); - }; - if (std.mem.eql(u8, t, "file")) { - return .{ .file = .{ .type = t, .path = p } }; - } else if (std.mem.eql(u8, t, "dir")) { - return .{ .dir = .{ - .type = t, - .path = p, - .exclude_extensions = exclude_extensions, - .include_extensions = include_extensions, - } }; - } else { - try self.returnParseErrorFmt("invalid write_file type '{s}'", .{t}, index.getAstNode(self.zoir)); - } - } - - // -- Layer 4: Custom parsers -- - - fn parseDependency(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency { - const n = try self.parseStructLiteral(index); - var dep = Dependency{ .typ = undefined, .value = undefined }; - var has_type_field = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "path")) { - dep.typ = .path; - dep.value = try self.parseString(field_value); - has_type_field = true; - } else if (std.mem.eql(u8, field_name, "url")) { - dep.typ = .url; - dep.value = try self.parseString(field_value); - has_type_field = true; - } else if (std.mem.eql(u8, field_name, "hash")) { - dep.hash = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "lazy")) { - dep.lazy = try self.parseT(bool, field_value); - } else if (std.mem.eql(u8, field_name, "args")) { - dep.args = try self.parseHashMap(Dependency.Arg, parseDependencyArg, field_value); - } - } - if (!has_type_field) try self.returnParseError("missing required field 'path' or 'url'", index.getAstNode(self.zoir)); - return dep; - } - - fn parseDependencyArg(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Dependency.Arg { - const node = index.get(self.zoir); - switch (node) { - .true => return .{ .bool = true }, - .false => return .{ .bool = false }, - .int_literal => |i| return .{ .int = switch (i) { - .small => |s| s, - .big => |b| try b.toInt(i64), - } }, - .float_literal => |f| return .{ .float = @floatCast(f) }, - .enum_literal => |e| return .{ .@"enum" = try self.gpa.dupe(u8, e.get(self.zoir)) }, - .string_literal => |s| return .{ .string = try self.gpa.dupe(u8, s) }, - .null => return .{ .null = {} }, - else => try self.returnParseError("expected a bool, int, float, string literal, or enum literal", index.getAstNode(self.zoir)), - } - } - - fn parseModuleLink(self: *Parser, index: std.zig.Zoir.Node.Index) Error!ModuleLink { - const node = index.get(self.zoir); - switch (node) { - .struct_literal => return .{ .module = try self.parseModule(index) }, - .string_literal => |n| return .{ .name = try self.gpa.dupe(u8, n) }, - .enum_literal => |n| return .{ .name = try self.gpa.dupe(u8, n.get(self.zoir)) }, - else => try self.returnParseError("expected a string, enum literal, or struct literal", index.getAstNode(self.zoir)), - } - } - - fn parseOption(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option { - const n = try self.parseStructLiteral(index); - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "type")) { - const t = try self.parseString(field_value); - if (Option.isValidIntType(t)) { - return .{ .int = try self.parseT(Option.Int, index) }; - } else if (Option.isValidFloatType(t)) { - return .{ .float = try self.parseT(Option.Float, index) }; - } else if (std.mem.eql(u8, t, "bool")) { - return .{ .bool = try self.parseT(Option.Bool, index) }; - } else if (std.mem.eql(u8, t, "enum")) { - return .{ .@"enum" = try self.parseOptionEnum(index) }; - } else if (std.mem.eql(u8, t, "enum_list")) { - return .{ .enum_list = try self.parseOptionEnumList(index) }; - } else if (std.mem.eql(u8, t, "string")) { - return .{ .string = try self.parseT(Option.String, index) }; - } else if (std.mem.eql(u8, t, "list")) { - return .{ .list = try self.parseT(Option.List, index) }; - } else if (std.mem.eql(u8, t, "build_id")) { - return .{ .build_id = try self.parseT(Option.BuildId, index) }; - } else if (std.mem.eql(u8, t, "lazy_path")) { - return .{ .lazy_path = try self.parseT(Option.LazyPath, index) }; - } else if (std.mem.eql(u8, t, "lazy_path_list")) { - return .{ .lazy_path_list = try self.parseT(Option.LazyPathList, index) }; - } else { - try self.returnParseErrorFmt("invalid type '{s}'", .{t}, field_value.getAstNode(self.zoir)); - } - } - } - try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); - } - - fn parseOptionEnum(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option.Enum { - const n = try self.parseStructLiteral(index); - var option = Option.Enum{ .enum_options = &.{}, .type = "" }; - var has_type = false; - var has_enum_options = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "type")) { - has_type = true; - option.type = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "enum_options")) { - has_enum_options = true; - option.enum_options = try self.parseEnumLiteralSlice(field_value); - } else if (std.mem.eql(u8, field_name, "description")) { - option.description = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "default")) { - option.default = try self.parseEnumLiteral(field_value); - } - } - if (!has_type) try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); - if (!has_enum_options) try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); - return option; - } - - fn parseOptionEnumList(self: *Parser, index: std.zig.Zoir.Node.Index) Error!Option.EnumList { - const n = try self.parseStructLiteral(index); - var option = Option.EnumList{ .enum_options = &.{}, .type = "" }; - var has_type = false; - var has_enum_options = false; - for (n.names, 0..) |name, i| { - const field_name = name.get(self.zoir); - const field_value = n.vals.at(@intCast(i)); - if (std.mem.eql(u8, field_name, "type")) { - has_type = true; - option.type = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "enum_options")) { - has_enum_options = true; - option.enum_options = try self.parseEnumLiteralSlice(field_value); - } else if (std.mem.eql(u8, field_name, "description")) { - option.description = try self.parseString(field_value); - } else if (std.mem.eql(u8, field_name, "default")) { - option.default = try self.parseEnumLiteralSlice(field_value); - } - } - if (!has_type) try self.returnParseError("missing required field 'type'", index.getAstNode(self.zoir)); - if (!has_enum_options) try self.returnParseError("missing required field 'enum_options'", index.getAstNode(self.zoir)); - return option; - } - - fn parseOptionsModule(self: *Parser, index: std.zig.Zoir.Node.Index) Error!OptionsModule { - return (try self.parseHashMap(Option, parseOption, index)) orelse ArrayHashMap(Option).init(self.gpa); - } - - // -- Primitives -- - - fn parseT(self: *Parser, comptime T: type, index: std.zig.Zoir.Node.Index) Error!T { - @setEvalBranchQuota(2_000); - self.status.* = .{}; - return try std.zon.parse.fromZoirNode(T, self.gpa, self.ast, self.zoir, index, self.status, .{}); - } - - fn parseString(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { - const node = index.get(self.zoir); - switch (node) { - .string_literal => |n| return try self.gpa.dupe(u8, n), - else => try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)), - } - } - - fn parseEnumLiteral(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { - const node = index.get(self.zoir); - switch (node) { - .enum_literal => |n| return try self.gpa.dupe(u8, n.get(self.zoir)), - else => try self.returnParseError("expected an enum literal", index.getAstNode(self.zoir)), - } - } - - fn parseVersionString(self: *Parser, index: std.zig.Zoir.Node.Index) Error![]const u8 { - const node = index.get(self.zoir); - switch (node) { - .string_literal => |n| { - _ = std.SemanticVersion.parse(n) catch { - try self.returnParseError("invalid version string", index.getAstNode(self.zoir)); - }; - return try self.gpa.dupe(u8, n); - }, - else => try self.returnParseError("expected a string literal", index.getAstNode(self.zoir)), - } - } - - fn parseStringOrEnumSlice(self: *Parser, index: std.zig.Zoir.Node.Index) Error!?[][]const u8 { - const node = index.get(self.zoir); - switch (node) { - .array_literal => |a| { - const slice = try self.gpa.alloc([]const u8, a.len); - for (0..a.len) |i| { - const item = a.at(@intCast(i)); - const item_node = item.get(self.zoir); - slice[i] = switch (item_node) { - .string_literal => |s| try self.gpa.dupe(u8, s), - .enum_literal => |e| try self.gpa.dupe(u8, e.get(self.zoir)), - else => { - try self.returnParseError("expected string or enum literal", item.getAstNode(self.zoir)); - }, - }; - } - return slice; - }, - .empty_literal => return null, - else => try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)), - } - } - - fn parseEnumLiteralSlice(self: *Parser, index: std.zig.Zoir.Node.Index) Error![][]const u8 { - const node = index.get(self.zoir); - switch (node) { - .array_literal => |a| { - const slice = try self.gpa.alloc([]const u8, a.len); - for (0..a.len) |i| { - const item = a.at(@intCast(i)); - slice[i] = try self.parseEnumLiteral(item); - } - return slice; - }, - else => try self.returnParseError("expected an array literal", index.getAstNode(self.zoir)), - } - } - - fn parseStructLiteral(self: *Parser, index: std.zig.Zoir.Node.Index) Error!std.meta.TagPayload(std.zig.Zoir.Node, .struct_literal) { - const node = index.get(self.zoir); - switch (node) { - .struct_literal => |n| return n, - else => try self.returnParseError("expected a struct literal", index.getAstNode(self.zoir)), - } - } - - fn returnParseErrorFmt(self: *Parser, comptime fmt: []const u8, args: anytype, node_index: std.zig.Ast.Node.Index) Error!noreturn { - const message = try std.fmt.allocPrint(self.gpa, fmt, args); - self.status.* = .{ - .ast = self.ast, - .zoir = self.zoir, - .type_check = .{ - .message = message, - .owned = true, - .token = self.ast.firstToken(node_index), - .offset = 0, - .note = null, - }, - }; - return error.ParseZon; - } - - fn returnParseError(self: *Parser, message: []const u8, node_index: std.zig.Ast.Node.Index) Error!noreturn { - self.status.* = .{ - .ast = self.ast, - .zoir = self.zoir, - .type_check = .{ - .message = message, - .owned = false, - .token = self.ast.firstToken(node_index), - .offset = 0, - .note = null, - }, - }; - return error.ParseZon; - } -}; - -pub fn serialize(config: Config, writer: anytype) !void { - var serializer = Serializer(@TypeOf(writer)).init(config, writer); - try serializer.serialize(); -} - -pub fn serializeToFile(config: Config, file_path: []const u8) !void { - const file = try std.fs.cwd().createFile(file_path, .{}); - defer file.close(); - try serialize(config, file.writer()); -} - -fn Serializer(Writer: type) type { - return struct { - config: Config, - writer: Writer, - - const Self = @This(); - - pub fn init(config: Config, writer: Writer) Self { - return Self{ - .config = config, - .writer = writer, - }; - } - - pub fn serialize(self: *Self) !void { - var serializer = std.zon.stringify.serializer(self.writer, .{}); - var top_level = try serializer.beginStruct(.{}); - try top_level.field("name", self.config.name, .{}); - try top_level.field("version", self.config.version, .{}); - try top_level.fieldPrefix("fingerprint"); - try self.writer.print("0x{x:0>16}", .{self.config.fingerprint}); - try top_level.field("minimum_zig_version", self.config.minimum_zig_version, .{}); - try top_level.field("paths", self.config.paths, .{}); - if (self.config.description) |desc| { - try top_level.field("description", desc, .{}); - } - if (self.config.keywords) |keywords| { - try top_level.field("keywords", keywords, .{}); - } - if (self.config.dependencies) |dependencies| { - var deps = try top_level.beginStructField("dependencies", .{}); - for (dependencies.keys(), dependencies.values()) |name, item| { - try serializeDependency(&deps, name, item); - } - try deps.end(); - } - if (self.config.write_files) |write_files| { - var wf = try top_level.beginStructField("write_files", .{}); - for (write_files.keys(), write_files.values()) |name, item| { - try serializeWriteFile(&wf, name, item); - } - try wf.end(); - } - if (self.config.options) |options| { - var opts = try top_level.beginStructField("options", .{}); - for (options.keys(), options.values()) |name, item| { - try serializeOption(&opts, name, item); - } - try opts.end(); - } - if (self.config.options_modules) |options_modules| { - var opt_modules = try top_level.beginStructField("options_modules", .{}); - for (options_modules.keys(), options_modules.values()) |opt_module_name, opt_module| { - var opts = try opt_modules.beginStructField(opt_module_name, .{}); - for (opt_module.keys(), opt_module.values()) |name, item| { - try serializeOption(&opts, name, item); - } - try opts.end(); - } - try opt_modules.end(); - } - if (self.config.modules) |modules| { - var mods = try top_level.beginStructField("modules", .{}); - for (modules.keys(), modules.values()) |name, item| { - try serializeModule(&mods, name, item); - } - try mods.end(); - } - if (self.config.executables) |executables| { - var exes = try top_level.beginStructField("executables", .{}); - for (executables.keys(), executables.values()) |name, item| { - try serializeExecutable(&exes, name, item); - } - try exes.end(); - } - if (self.config.libraries) |libraries| { - var libs = try top_level.beginStructField("libraries", .{}); - for (libraries.keys(), libraries.values()) |name, item| { - try serializeLibrary(&libs, name, item); - } - try libs.end(); - } - if (self.config.objects) |objects| { - var objs = try top_level.beginStructField("objects", .{}); - for (objects.keys(), objects.values()) |name, item| { - try serializeObject(&objs, name, item); - } - try objs.end(); - } - if (self.config.tests) |tests_map| { - var tsts = try top_level.beginStructField("tests", .{}); - for (tests_map.keys(), tests_map.values()) |name, item| { - try serializeTest(&tsts, name, item); - } - try tsts.end(); - } - if (self.config.fmts) |fmts| { - var f = try top_level.beginStructField("fmts", .{}); - for (fmts.keys(), fmts.values()) |name, item| { - try f.field(name, item, .{ .emit_default_optional_fields = false }); - } - try f.end(); - } - if (self.config.runs) |runs| { - var r = try top_level.beginStructField("runs", .{}); - for (runs.keys(), runs.values()) |name, item| { - try r.field(name, item, .{}); - } - try r.end(); - } - try top_level.end(); - } - - fn serializeDependency( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Dependency, - ) !void { - var inner = try outer.beginStructField(name, .{}); - switch (item.typ) { - .path => try inner.field("path", item.value, .{}), - .url => try inner.field("url", item.value, .{}), - } - if (item.hash) |hash| { - try inner.field("hash", hash, .{}); - } - if (item.lazy) |lazy| { - try inner.field("lazy", lazy, .{}); - } - if (item.args) |args| { - var args_inner = try inner.beginStructField("args", .{}); - for (args.keys(), args.values()) |arg_name, arg_value| { - switch (arg_value) { - .bool => |b| { - try args_inner.field(arg_name, b, .{}); - }, - .int => |i| { - try args_inner.field(arg_name, i, .{}); - }, - .float => |f| { - try args_inner.field(arg_name, f, .{}); - }, - .string => |s| { - try args_inner.field(arg_name, s, .{}); - }, - .@"enum" => |e| { - try args_inner.fieldPrefix(arg_name); - try args_inner.container.serializer.writer.print(".{}", .{std.zig.fmtId(e)}); - }, - .null => { - try args_inner.field(arg_name, null, .{}); - }, - } - } - try args_inner.end(); - } - try inner.end(); - } - - fn serializeWriteFile( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: WriteFile, - ) !void { - var inner = try outer.beginStructField(name, .{}); - if (item.private) |p| { - try inner.field("private", p, .{}); - } - if (item.items) |items| { - var items_struct = try inner.beginStructField("items", .{}); - for (items.keys(), items.values()) |item_name, item_value| { - var item_struct = try items_struct.beginStructField(item_name, .{}); - switch (item_value) { - .file => |f| { - try item_struct.field("type", f.type, .{}); - try item_struct.field("path", f.path, .{}); - }, - .dir => |d| { - try item_struct.field("type", d.type, .{}); - try item_struct.field("path", d.path, .{}); - if (d.exclude_extensions) |exclude_extensions| { - try item_struct.field("exclude_extensions", exclude_extensions, .{}); - } - if (d.include_extensions) |include_extensions| { - try item_struct.field("include_extensions", include_extensions, .{}); - } - }, - } - try item_struct.end(); - } - try items_struct.end(); - } - try inner.end(); - } - - fn serializeOption( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Option, - ) !void { - switch (item) { - .int => |i| { - try outer.field(name, i, .{ .emit_default_optional_fields = false }); - }, - .float => |f| { - try outer.field(name, f, .{ .emit_default_optional_fields = false }); - }, - .bool => |b| { - try outer.field(name, b, .{ .emit_default_optional_fields = false }); - }, - .@"enum" => |e| { - var inner = try outer.beginStructField(name, .{}); - try inner.field("type", e.type, .{}); - if (e.description) |desc| { - try inner.field("description", desc, .{}); - } - if (e.default) |default| { - try inner.fieldPrefix("default"); - try inner.container.serializer.writer.print(".{}", .{std.zig.fmtId(default)}); - } - var opts_arr = try inner.beginArrayField("enum_options", .{}); - for (e.enum_options) |opt| { - try opts_arr.fieldPrefix(); - try opts_arr.container.serializer.writer.print(".{}", .{std.zig.fmtId(opt)}); - } - try opts_arr.end(); - try inner.end(); - }, - .enum_list => |el| { - var inner = try outer.beginStructField(name, .{}); - try inner.field("type", el.type, .{}); - if (el.description) |desc| { - try inner.field("description", desc, .{}); - } - if (el.default) |defaults| { - var default_arr = try inner.beginArrayField("default", .{}); - for (defaults) |d| { - try default_arr.fieldPrefix(); - try default_arr.container.serializer.writer.print(".{}", .{std.zig.fmtId(d)}); - } - try default_arr.end(); - } - var opts_arr = try inner.beginArrayField("enum_options", .{}); - for (el.enum_options) |opt| { - try opts_arr.fieldPrefix(); - try opts_arr.container.serializer.writer.print(".{}", .{std.zig.fmtId(opt)}); - } - try opts_arr.end(); - try inner.end(); - }, - .string => |s| { - try outer.field(name, s, .{ .emit_default_optional_fields = false }); - }, - .list => |l| { - try outer.field(name, l, .{ .emit_default_optional_fields = false }); - }, - .build_id => |b| { - try outer.field(name, b, .{ .emit_default_optional_fields = false }); - }, - .lazy_path => |lp| { - try outer.field(name, lp, .{ .emit_default_optional_fields = false }); - }, - .lazy_path_list => |lpl| { - try outer.field(name, lpl, .{ .emit_default_optional_fields = false }); - }, - } - } - - fn serializeModule( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Module, - ) !void { - try outer.field(name, item, .{ .emit_default_optional_fields = false }); - } - - fn serializeExecutable( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Executable, - ) !void { - var inner = try outer.beginStructField(name, .{}); - if (item.name) |n| { - try inner.field("name", n, .{}); - } - if (item.version) |version| { - try inner.field("version", version, .{}); - } - switch (item.root_module) { - .name => |n| { - try inner.field("root_module", n, .{}); - }, - .module => |m| { - try serializeModule(&inner, "root_module", m); - }, - } - if (item.linkage) |linkage| { - try inner.field("linkage", linkage, .{}); - } - if (item.max_rss) |max_rss| { - try inner.field("max_rss", max_rss, .{}); - } - if (item.use_llvm) |use_llvm| { - try inner.field("use_llvm", use_llvm, .{}); - } - if (item.use_lld) |use_lld| { - try inner.field("use_lld", use_lld, .{}); - } - if (item.zig_lib_dir) |zig_lib_dir| { - try inner.field("zig_lib_dir", zig_lib_dir, .{}); - } - if (item.win32_manifest) |win32_manifest| { - try inner.field("win32_manifest", win32_manifest, .{}); - } - if (item.depends_on) |depends_on| { - try inner.field("depends_on", depends_on, .{}); - } - try inner.end(); - } - - fn serializeLibrary( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Library, - ) !void { - var inner = try outer.beginStructField(name, .{}); - if (item.name) |n| { - try inner.field("name", n, .{}); - } - if (item.version) |version| { - try inner.field("version", version, .{}); - } - switch (item.root_module) { - .name => |n| { - try inner.field("root_module", n, .{}); - }, - .module => |m| { - try serializeModule(&inner, "root_module", m); - }, - } - if (item.linkage) |linkage| { - try inner.field("linkage", linkage, .{}); - } - if (item.max_rss) |max_rss| { - try inner.field("max_rss", max_rss, .{}); - } - if (item.use_llvm) |use_llvm| { - try inner.field("use_llvm", use_llvm, .{}); - } - if (item.use_lld) |use_lld| { - try inner.field("use_lld", use_lld, .{}); - } - if (item.zig_lib_dir) |zig_lib_dir| { - try inner.field("zig_lib_dir", zig_lib_dir, .{}); - } - if (item.win32_manifest) |win32_manifest| { - try inner.field("win32_manifest", win32_manifest, .{}); - } - if (item.linker_allow_shlib_undefined) |v| { - try inner.field("linker_allow_shlib_undefined", v, .{}); - } - if (item.dest_sub_path) |dest_sub_path| { - try inner.field("dest_sub_path", dest_sub_path, .{}); - } - if (item.depends_on) |depends_on| { - try inner.field("depends_on", depends_on, .{}); - } - try inner.end(); - } - - fn serializeObject( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Object, - ) !void { - var inner = try outer.beginStructField(name, .{}); - if (item.name) |n| { - try inner.field("name", n, .{}); - } - switch (item.root_module) { - .name => |n| { - try inner.field("root_module", n, .{}); - }, - .module => |m| { - try serializeModule(&inner, "root_module", m); - }, - } - if (item.max_rss) |max_rss| { - try inner.field("max_rss", max_rss, .{}); - } - if (item.use_llvm) |use_llvm| { - try inner.field("use_llvm", use_llvm, .{}); - } - if (item.use_lld) |use_lld| { - try inner.field("use_lld", use_lld, .{}); - } - if (item.zig_lib_dir) |zig_lib_dir| { - try inner.field("zig_lib_dir", zig_lib_dir, .{}); - } - if (item.depends_on) |depends_on| { - try inner.field("depends_on", depends_on, .{}); - } - try inner.end(); - } - - fn serializeTest( - outer: *std.zon.stringify.Serializer(Writer).Struct, - name: []const u8, - item: Test, - ) !void { - var inner = try outer.beginStructField(name, .{}); - if (item.name) |n| { - try inner.field("name", n, .{}); - } - switch (item.root_module) { - .name => |n| { - try inner.field("root_module", n, .{}); - }, - .module => |m| { - try serializeModule(&inner, "root_module", m); - }, - } - if (item.max_rss) |max_rss| { - try inner.field("max_rss", max_rss, .{}); - } - if (item.use_llvm) |use_llvm| { - try inner.field("use_llvm", use_llvm, .{}); - } - if (item.use_lld) |use_lld| { - try inner.field("use_lld", use_lld, .{}); - } - if (item.zig_lib_dir) |zig_lib_dir| { - try inner.field("zig_lib_dir", zig_lib_dir, .{}); - } - if (item.filters.len > 0) { - try inner.field("filters", item.filters, .{}); - } - if (item.test_runner) |test_runner| { - try inner.field("test_runner", test_runner, .{}); - } - try inner.end(); - } - }; -} - -// -- Tests -- - -fn testParse(source: [:0]const u8) !Config { - const gpa = std.testing.allocator; - - var ast = try std.zig.Ast.parse(gpa, source, .zon); - defer ast.deinit(gpa); - - var zoir = try std.zig.ZonGen.generate(gpa, ast, .{}); - defer zoir.deinit(gpa); - - if (zoir.hasCompileErrors()) return error.ParseZoir; - - return parseFromZoir(gpa, "", zoir, ast, null); -} - -fn testParseFail(source: [:0]const u8) !void { - const gpa = std.testing.allocator; - - var ast = try std.zig.Ast.parse(gpa, source, .zon); - defer ast.deinit(gpa); - - var zoir = try std.zig.ZonGen.generate(gpa, ast, .{}); - defer zoir.deinit(gpa); - - if (zoir.hasCompileErrors()) return; // expected failure - - _ = parseFromZoir(gpa, "", zoir, ast, null) catch return; - return error.ExpectedParseFailure; -} - -test "parse minimal config" { - const config = try testParse( - \\.{ - \\ .name = .basic, - \\ .version = "0.1.0", - \\ .fingerprint = 0x90797553773ca567, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\} - ); - try std.testing.expectEqualStrings("basic", config.name); - try std.testing.expectEqualStrings("0.1.0", config.version); - try std.testing.expectEqual(@as(u64, 0x90797553773ca567), config.fingerprint); - try std.testing.expectEqualStrings("0.14.0", config.minimum_zig_version); - try std.testing.expectEqual(@as(usize, 1), config.paths.len); - try std.testing.expectEqualStrings("src", config.paths[0]); - - // Optional fields should be null - try std.testing.expect(config.description == null); - try std.testing.expect(config.modules == null); - try std.testing.expect(config.executables == null); - try std.testing.expect(config.dependencies == null); -} - -test "parse config with module" { - const config = try testParse( - \\.{ - \\ .name = .mylib, - \\ .version = "1.0.0", - \\ .fingerprint = 0x1234567890abcdef, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .modules = .{ - \\ .core = .{ - \\ .root_source_file = "src/core.zig", - \\ .link_libc = true, - \\ .optimize = .ReleaseFast, - \\ }, - \\ }, - \\} - ); - - const modules = config.modules orelse return error.ExpectedModules; - try std.testing.expectEqual(@as(usize, 1), modules.count()); - const core = modules.get("core") orelse return error.ExpectedCoreModule; - try std.testing.expectEqualStrings("src/core.zig", core.root_source_file.?); - try std.testing.expectEqual(true, core.link_libc.?); - try std.testing.expectEqual(std.builtin.OptimizeMode.ReleaseFast, core.optimize.?); - try std.testing.expect(core.strip == null); - try std.testing.expect(core.target == null); -} - -test "parse config with executable and inline module" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "0.2.0", - \\ .fingerprint = 0xabcdef1234567890, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .executables = .{ - \\ .main = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/main.zig", - \\ }, - \\ }, - \\ }, - \\} - ); - - const exes = config.executables orelse return error.ExpectedExecutables; - try std.testing.expectEqual(@as(usize, 1), exes.count()); - const main_exe = exes.get("main") orelse return error.ExpectedMainExe; - try std.testing.expectEqual(ModuleLink.module, std.meta.activeTag(main_exe.root_module)); - try std.testing.expectEqualStrings("src/main.zig", main_exe.root_module.module.root_source_file.?); -} - -test "parse config with executable referencing named module" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "0.2.0", - \\ .fingerprint = 0xabcdef1234567890, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .executables = .{ - \\ .main = .{ - \\ .root_module = .core, - \\ }, - \\ }, - \\} - ); - - const exes = config.executables orelse return error.ExpectedExecutables; - const main_exe = exes.get("main") orelse return error.ExpectedMainExe; - try std.testing.expectEqual(ModuleLink.name, std.meta.activeTag(main_exe.root_module)); - try std.testing.expectEqualStrings("core", main_exe.root_module.name); -} - -test "parse config with dependency" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "0.1.0", - \\ .fingerprint = 0x1111111111111111, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .dependencies = .{ - \\ .zlib = .{ - \\ .url = "https://example.com/zlib.tar.gz", - \\ .hash = "abc123", - \\ .lazy = true, - \\ }, - \\ .local_dep = .{ - \\ .path = "../other", - \\ }, - \\ }, - \\} - ); - - const deps = config.dependencies orelse return error.ExpectedDependencies; - try std.testing.expectEqual(@as(usize, 2), deps.count()); - - const zlib = deps.get("zlib") orelse return error.ExpectedZlib; - try std.testing.expect(zlib.typ == .url); - try std.testing.expectEqualStrings("https://example.com/zlib.tar.gz", zlib.value); - try std.testing.expectEqualStrings("abc123", zlib.hash.?); - try std.testing.expectEqual(true, zlib.lazy.?); - - const local = deps.get("local_dep") orelse return error.ExpectedLocalDep; - try std.testing.expect(local.typ == .path); - try std.testing.expectEqualStrings("../other", local.value); - try std.testing.expect(local.hash == null); - try std.testing.expect(local.lazy == null); -} - -test "parse config with library" { - const config = try testParse( - \\.{ - \\ .name = .mylib, - \\ .version = "1.0.0", - \\ .fingerprint = 0x2222222222222222, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .libraries = .{ - \\ .mylib = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/lib.zig", - \\ }, - \\ .version = "2.0.0", - \\ .linkage = .dynamic, - \\ }, - \\ }, - \\} - ); - - const libs = config.libraries orelse return error.ExpectedLibraries; - const lib = libs.get("mylib") orelse return error.ExpectedMylib; - try std.testing.expectEqualStrings("2.0.0", lib.version.?); - try std.testing.expectEqual(std.builtin.LinkMode.dynamic, lib.linkage.?); -} - -test "parse config with test section" { - const config = try testParse( - \\.{ - \\ .name = .mylib, - \\ .version = "1.0.0", - \\ .fingerprint = 0x3333333333333333, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .tests = .{ - \\ .unit = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/test.zig", - \\ }, - \\ .filters = .{"specific_test"}, - \\ }, - \\ }, - \\} - ); - - const tests = config.tests orelse return error.ExpectedTests; - const unit = tests.get("unit") orelse return error.ExpectedUnit; - try std.testing.expectEqual(@as(usize, 1), unit.filters.len); - try std.testing.expectEqualStrings("specific_test", unit.filters[0]); -} - -test "parse config with runs" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x4444444444444444, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .runs = .{ - \\ .docs = "echo 'hello'", - \\ }, - \\} - ); - - const runs = config.runs orelse return error.ExpectedRuns; - const docs = runs.get("docs") orelse return error.ExpectedDocs; - try std.testing.expectEqualStrings("echo 'hello'", docs.*); -} - -test "parse config with options" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x5555555555555555, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .options = .{ - \\ .verbose = .{ - \\ .type = "bool", - \\ .default = false, - \\ .description = "Enable verbose output", - \\ }, - \\ .threads = .{ - \\ .type = "usize", - \\ .default = 4, - \\ }, - \\ }, - \\} - ); - - const opts = config.options orelse return error.ExpectedOptions; - try std.testing.expectEqual(@as(usize, 2), opts.count()); - - const verbose = opts.get("verbose") orelse return error.ExpectedVerbose; - try std.testing.expect(verbose == .bool); - try std.testing.expectEqual(false, verbose.bool.default.?); - - const threads = opts.get("threads") orelse return error.ExpectedThreads; - try std.testing.expect(threads == .int); - try std.testing.expectEqual(@as(i64, 4), threads.int.default.?); -} - -test "parse config with options_modules" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x6666666666666666, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .options_modules = .{ - \\ .build_options = .{ - \\ .debug_mode = .{ - \\ .type = "bool", - \\ .default = false, - \\ }, - \\ }, - \\ }, - \\} - ); - - const opt_modules = config.options_modules orelse return error.ExpectedOptionsModules; - try std.testing.expectEqual(@as(usize, 1), opt_modules.count()); - const build_opts = opt_modules.get("build_options") orelse return error.ExpectedBuildOptions; - try std.testing.expectEqual(@as(usize, 1), build_opts.count()); -} - -test "parse config with module imports" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x7777777777777777, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .modules = .{ - \\ .core = .{ - \\ .root_source_file = "src/core.zig", - \\ .imports = .{ .utils, "other_dep" }, - \\ }, - \\ }, - \\} - ); - - const modules = config.modules orelse return error.ExpectedModules; - const core = modules.get("core") orelse return error.ExpectedCore; - const imports = core.imports orelse return error.ExpectedImports; - try std.testing.expectEqual(@as(usize, 2), imports.len); - try std.testing.expectEqualStrings("utils", imports[0]); - try std.testing.expectEqualStrings("other_dep", imports[1]); -} - -test "parse fails on missing required field" { - try testParseFail( - \\.{ - \\ .name = .basic, - \\ .version = "0.1.0", - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\} - ); -} - -test "parse fails on invalid version string" { - try testParseFail( - \\.{ - \\ .name = .basic, - \\ .version = "not_a_version", - \\ .fingerprint = 0x1234567890abcdef, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\} - ); -} - -test "parse config with description and keywords" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x8888888888888888, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .description = "A test application", - \\ .keywords = .{ "test", "app" }, - \\} - ); - - try std.testing.expectEqualStrings("A test application", config.description.?); - const keywords = config.keywords.?; - try std.testing.expectEqual(@as(usize, 2), keywords.len); - try std.testing.expectEqualStrings("test", keywords[0]); - try std.testing.expectEqualStrings("app", keywords[1]); -} - -test "parse config with dependency args" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x9999999999999999, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .dependencies = .{ - \\ .dep = .{ - \\ .path = "../dep", - \\ .args = .{ - \\ .enable_feature = true, - \\ .count = 42, - \\ .name = "hello", - \\ }, - \\ }, - \\ }, - \\} - ); - - const deps = config.dependencies orelse return error.ExpectedDeps; - const dep = deps.get("dep") orelse return error.ExpectedDep; - const args = dep.args orelse return error.ExpectedArgs; - try std.testing.expectEqual(@as(usize, 3), args.count()); - - const enable = args.get("enable_feature") orelse return error.ExpectedArg; - try std.testing.expect(enable == .bool); - try std.testing.expectEqual(true, enable.bool); -} - -test "parse config with fmts" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0xaaaaaaaaaaaaaaaa, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .fmts = .{ - \\ .check = .{ - \\ .paths = .{"src"}, - \\ .check = true, - \\ }, - \\ }, - \\} - ); - - const fmts = config.fmts orelse return error.ExpectedFmts; - const check = fmts.get("check") orelse return error.ExpectedCheck; - try std.testing.expectEqual(true, check.check.?); - const fmt_paths = check.paths orelse return error.ExpectedPaths; - try std.testing.expectEqual(@as(usize, 1), fmt_paths.len); -} - -// -- Serializer round-trip tests -- - -fn testSerializeRoundTrip(source: [:0]const u8) !void { - const gpa = std.testing.allocator; - - // Phase 1: Parse original - const config = try testParse(source); - - // Phase 2: Serialize to string - var buf = std.ArrayList(u8).init(gpa); - defer buf.deinit(); - try serialize(config, buf.writer()); - - // Phase 3: Re-parse the serialized output - const serialized = try buf.toOwnedSliceSentinel(0); - defer gpa.free(serialized); - - const config2 = try testParse(serialized); - - // Phase 4: Compare key fields - try std.testing.expectEqualStrings(config.name, config2.name); - try std.testing.expectEqualStrings(config.version, config2.version); - try std.testing.expectEqual(config.fingerprint, config2.fingerprint); - try std.testing.expectEqualStrings(config.minimum_zig_version, config2.minimum_zig_version); - try std.testing.expectEqual(config.paths.len, config2.paths.len); - - // Compare optional sections presence - try std.testing.expectEqual(config.modules != null, config2.modules != null); - try std.testing.expectEqual(config.executables != null, config2.executables != null); - try std.testing.expectEqual(config.libraries != null, config2.libraries != null); - try std.testing.expectEqual(config.objects != null, config2.objects != null); - try std.testing.expectEqual(config.tests != null, config2.tests != null); - try std.testing.expectEqual(config.fmts != null, config2.fmts != null); - try std.testing.expectEqual(config.runs != null, config2.runs != null); - try std.testing.expectEqual(config.dependencies != null, config2.dependencies != null); - - // Compare counts where present - if (config.modules) |m| try std.testing.expectEqual(m.count(), config2.modules.?.count()); - if (config.executables) |e| try std.testing.expectEqual(e.count(), config2.executables.?.count()); - if (config.libraries) |l| try std.testing.expectEqual(l.count(), config2.libraries.?.count()); - if (config.tests) |t| try std.testing.expectEqual(t.count(), config2.tests.?.count()); - if (config.runs) |r| try std.testing.expectEqual(r.count(), config2.runs.?.count()); - if (config.dependencies) |d| try std.testing.expectEqual(d.count(), config2.dependencies.?.count()); -} - -test "serialize round-trip: minimal config" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .basic, - \\ .version = "0.1.0", - \\ .fingerprint = 0x90797553773ca567, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\} - ); -} - -test "serialize round-trip: config with modules" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .mylib, - \\ .version = "1.0.0", - \\ .fingerprint = 0x1234567890abcdef, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .modules = .{ - \\ .core = .{ - \\ .root_source_file = "src/core.zig", - \\ .link_libc = true, - \\ }, - \\ }, - \\} - ); -} - -test "serialize round-trip: config with executables" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .myapp, - \\ .version = "0.2.0", - \\ .fingerprint = 0xabcdef1234567890, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .executables = .{ - \\ .main = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/main.zig", - \\ }, - \\ }, - \\ }, - \\} - ); -} - -test "serialize round-trip: config with libraries" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .mylib, - \\ .version = "1.0.0", - \\ .fingerprint = 0x2222222222222222, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .libraries = .{ - \\ .mylib = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/lib.zig", - \\ }, - \\ .version = "2.0.0", - \\ }, - \\ }, - \\} - ); -} - -test "serialize round-trip: config with tests" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .mylib, - \\ .version = "1.0.0", - \\ .fingerprint = 0x3333333333333333, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .tests = .{ - \\ .unit = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/test.zig", - \\ }, - \\ }, - \\ }, - \\} - ); -} - -test "serialize round-trip: config with runs" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0x4444444444444444, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .runs = .{ - \\ .docs = "echo hello", - \\ }, - \\} - ); -} - -test "serialize round-trip: config with dependencies including hash and lazy" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .myapp, - \\ .version = "0.1.0", - \\ .fingerprint = 0x1111111111111111, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .dependencies = .{ - \\ .zlib = .{ - \\ .url = "https://example.com/zlib.tar.gz", - \\ .hash = "abc123", - \\ .lazy = true, - \\ }, - \\ }, - \\} - ); -} - -test "parse config with depends_on" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0xbbbbbbbbbbbbbbbb, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .executables = .{ - \\ .server = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/server.zig", - \\ }, - \\ .depends_on = .{ .proto_lib }, - \\ }, - \\ }, - \\ .libraries = .{ - \\ .proto_lib = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/proto.zig", - \\ }, - \\ }, - \\ }, - \\} - ); - - const exes = config.executables orelse return error.ExpectedExes; - const server = exes.get("server") orelse return error.ExpectedServer; - const depends_on = server.depends_on orelse return error.ExpectedDependsOn; - try std.testing.expectEqual(@as(usize, 1), depends_on.len); - try std.testing.expectEqualStrings("proto_lib", depends_on[0]); -} - -test "serialize round-trip: config with depends_on" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0xbbbbbbbbbbbbbbbb, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .executables = .{ - \\ .server = .{ - \\ .root_module = .{ - \\ .root_source_file = "src/server.zig", - \\ }, - \\ .depends_on = .{ .proto_lib }, - \\ }, - \\ }, - \\} - ); -} - -test "serialize round-trip: config with fmts" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0xaaaaaaaaaaaaaaaa, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .fmts = .{ - \\ .check = .{ - \\ .paths = .{"src"}, - \\ .check = true, - \\ }, - \\ }, - \\} - ); -} - -test "parse config with write_files" { - const config = try testParse( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0xcccccccccccccccc, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .write_files = .{ - \\ .generated = .{ - \\ .items = .{ - \\ .config_h = .{ - \\ .type = "file", - \\ .path = "config.h", - \\ }, - \\ .assets = .{ - \\ .type = "dir", - \\ .path = "assets", - \\ .exclude_extensions = .{".tmp"}, - \\ }, - \\ }, - \\ }, - \\ }, - \\} - ); - - const wf = config.write_files orelse return error.ExpectedWriteFiles; - const generated = wf.get("generated") orelse return error.ExpectedGenerated; - const items = generated.items orelse return error.ExpectedItems; - try std.testing.expectEqual(@as(usize, 2), items.count()); - - const config_h = items.get("config_h") orelse return error.ExpectedConfigH; - try std.testing.expect(config_h == .file); - try std.testing.expectEqualStrings("config.h", config_h.file.path); - - const assets = items.get("assets") orelse return error.ExpectedAssets; - try std.testing.expect(assets == .dir); - try std.testing.expectEqualStrings("assets", assets.dir.path); - const excl = assets.dir.exclude_extensions orelse return error.ExpectedExclude; - try std.testing.expectEqual(@as(usize, 1), excl.len); - try std.testing.expectEqualStrings(".tmp", excl[0]); -} - -test "serialize round-trip: config with write_files" { - try testSerializeRoundTrip( - \\.{ - \\ .name = .myapp, - \\ .version = "1.0.0", - \\ .fingerprint = 0xcccccccccccccccc, - \\ .minimum_zig_version = "0.14.0", - \\ .paths = .{"src"}, - \\ .write_files = .{ - \\ .generated = .{ - \\ .items = .{ - \\ .config_h = .{ - \\ .type = "file", - \\ .path = "config.h", - \\ }, - \\ }, - \\ }, - \\ }, - \\} - ); -} diff --git a/src/build_runner.zig b/src/build_runner.zig index 0b34e1c..94a10b3 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -1,21 +1,23 @@ -//! Configures a Zig build graph from a zbuild Config. -//! Replaces string-concatenation codegen (ConfigBuildgen) with direct API calls. +//! Configures a Zig build graph from a comptime ZON manifest. +//! +//! The manifest is obtained via @import("build.zig.zon") in the user's build.zig: +//! +//! const zbuild = @import("zbuild"); +//! +//! pub fn build(b: *std.Build) void { +//! zbuild.configureBuild(b, @import("build.zig.zon")) catch |err| { +//! std.log.err("zbuild: {}", .{err}); +//! }; +//! } const std = @import("std"); -const Config = @import("Config.zig"); -pub fn configureBuild(b: *std.Build) !void { - const config = try Config.parseFromFile(b.allocator, "build.zig.zon", null); - try configureWithConfig(b, config); -} - -fn configureWithConfig(b: *std.Build, config: Config) !void { +pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); var runner = BuildRunner{ .b = b, - .config = config, .target = target, .optimize = optimize, .modules = std.StringHashMap(*std.Build.Module).init(b.allocator), @@ -24,103 +26,92 @@ fn configureWithConfig(b: *std.Build, config: Config) !void { .install_steps = std.StringHashMap(*std.Build.Step).init(b.allocator), }; - // Phase 1: Create options modules - if (config.options_modules) |options_modules| { - for (options_modules.keys(), options_modules.values()) |name, options| { - try runner.createOptionsModule(name, options); + // Phase 1: Resolve dependencies (comptime args forwarding) + if (@hasField(@TypeOf(manifest), "dependencies")) { + inline for (@typeInfo(@TypeOf(manifest.dependencies)).@"struct".fields) |field| { + const decl = @field(manifest.dependencies, field.name); + const dep = if (@hasField(@TypeOf(decl), "args")) + b.dependency(field.name, decl.args) + else + b.dependency(field.name, .{}); + try runner.dependencies.put(field.name, dep); } } - // Phase 2: Create dependencies - if (config.dependencies) |dependencies| { - for (dependencies.keys(), dependencies.values()) |name, dep| { - try runner.createDependency(name, dep); + // Phase 2: Create options modules + if (@hasField(@TypeOf(manifest), "options_modules")) { + inline for (@typeInfo(@TypeOf(manifest.options_modules)).@"struct".fields) |field| { + try runner.createOptionsModule(field.name, @field(manifest.options_modules, field.name)); } } // Phase 3: Create named modules - if (config.modules) |modules| { - for (modules.keys(), modules.values()) |name, module| { - const m = try runner.createModule(module, name); - if (!(module.private orelse true)) { - b.modules.put(b.dupe(name), m) catch @panic("OOM"); + if (@hasField(@TypeOf(manifest), "modules")) { + inline for (@typeInfo(@TypeOf(manifest.modules)).@"struct".fields) |field| { + const mod = @field(manifest.modules, field.name); + const m = runner.createModule(mod, field.name); + if (@hasField(@TypeOf(mod), "private")) { + if (!mod.private) { + b.modules.put(b.dupe(field.name), m) catch @panic("OOM"); + } } - try runner.modules.put(name, m); + try runner.modules.put(field.name, m); } } // Phase 4: Create executables - if (config.executables) |executables| { - for (executables.keys(), executables.values()) |name, exe| { - try runner.createExecutable(name, exe); + if (@hasField(@TypeOf(manifest), "executables")) { + inline for (@typeInfo(@TypeOf(manifest.executables)).@"struct".fields) |field| { + try runner.createExecutable(field.name, @field(manifest.executables, field.name)); } } // Phase 5: Create libraries - if (config.libraries) |libraries| { - for (libraries.keys(), libraries.values()) |name, lib| { - try runner.createLibrary(name, lib); + if (@hasField(@TypeOf(manifest), "libraries")) { + inline for (@typeInfo(@TypeOf(manifest.libraries)).@"struct".fields) |field| { + try runner.createLibrary(field.name, @field(manifest.libraries, field.name)); } } // Phase 6: Create objects - if (config.objects) |objects| { - for (objects.keys(), objects.values()) |name, obj| { - try runner.createObject(name, obj); + if (@hasField(@TypeOf(manifest), "objects")) { + inline for (@typeInfo(@TypeOf(manifest.objects)).@"struct".fields) |field| { + try runner.createObject(field.name, @field(manifest.objects, field.name)); } } // Phase 7: Create tests - var tls_run_test: ?*std.Build.Step = null; - - if (config.modules) |modules| { - if (modules.count() > 0 or (config.tests != null and config.tests.?.count() > 0)) { - tls_run_test = b.step("test", "Run all tests"); - } - for (modules.keys()) |name| { - if (config.tests == null or !config.tests.?.contains(name)) { - try runner.createTest(name, .{ - .root_module = .{ .name = name }, - .filters = &.{}, - }, tls_run_test.?); - } - } - } - - if (config.tests) |tests| { - if (tls_run_test == null) { - tls_run_test = b.step("test", "Run all tests"); - } - for (tests.keys(), tests.values()) |name, t| { - try runner.createTest(name, t, tls_run_test.?); + if (@hasField(@TypeOf(manifest), "tests")) { + const tls_run_test = b.step("test", "Run all tests"); + inline for (@typeInfo(@TypeOf(manifest.tests)).@"struct".fields) |field| { + try runner.createTest(field.name, @field(manifest.tests, field.name), tls_run_test); } } // Phase 8: Create fmts - if (config.fmts) |fmts| { + if (@hasField(@TypeOf(manifest), "fmts")) { const tls_run_fmt = b.step("fmt", "Run all fmts"); - for (fmts.keys(), fmts.values()) |name, fmt| { - try runner.createFmt(name, fmt, tls_run_fmt); + inline for (@typeInfo(@TypeOf(manifest.fmts)).@"struct".fields) |field| { + runner.createFmt(field.name, @field(manifest.fmts, field.name), tls_run_fmt); } } // Phase 9: Create runs - if (config.runs) |runs| { - for (runs.keys(), runs.values()) |name, run| { - runner.createRun(name, run); + if (@hasField(@TypeOf(manifest), "runs")) { + inline for (@typeInfo(@TypeOf(manifest.runs)).@"struct".fields) |field| { + runner.createRun(field.name, @field(manifest.runs, field.name)); } } - // Phase 10: Wire imports for all modules - try runner.wireAllImports(config); + // Phase 10: Wire imports + try runner.wireAllImports(manifest); - // Phase 11: Wire depends_on for artifacts - runner.wireDependsOn(config); + // Phase 11: Wire depends_on + runner.wireDependsOn(manifest); } const BuildRunner = struct { b: *std.Build, - config: Config, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, modules: std.StringHashMap(*std.Build.Module), @@ -128,39 +119,41 @@ const BuildRunner = struct { options_modules: std.StringHashMap(*std.Build.Module), install_steps: std.StringHashMap(*std.Build.Step), - fn createModule(self: *BuildRunner, module: Config.Module, name: []const u8) !*std.Build.Module { - const m = self.b.createModule(.{ - .root_source_file = if (module.root_source_file) |f| self.resolveLazyPath(f) else null, - .target = if (module.target) |t| self.resolveTarget(t) else self.target, - .optimize = module.optimize orelse self.optimize, - .link_libc = module.link_libc, - .link_libcpp = module.link_libcpp, - .single_threaded = module.single_threaded, - .strip = module.strip, - .unwind_tables = module.unwind_tables, - .dwarf_format = module.dwarf_format, - .code_model = module.code_model, - .error_tracing = module.error_tracing, - .omit_frame_pointer = module.omit_frame_pointer, - .pic = module.pic, - .red_zone = module.red_zone, - .sanitize_c = module.sanitize_c, - .sanitize_thread = module.sanitize_thread, - .stack_check = module.stack_check, - .stack_protector = module.stack_protector, - .fuzz = module.fuzz, - .valgrind = module.valgrind, - }); + // --- Module creation --- - if (module.include_paths) |paths| { - for (paths) |path| { - m.addIncludePath(self.resolveLazyPath(path)); + const module_passthrough_fields = .{ + "link_libc", "link_libcpp", "single_threaded", + "strip", "unwind_tables", "dwarf_format", + "code_model", "error_tracing", "omit_frame_pointer", + "pic", "red_zone", "sanitize_c", + "sanitize_thread", "stack_check", "stack_protector", + "fuzz", "valgrind", + }; + + fn createModule(self: *BuildRunner, comptime mod: anytype, name: []const u8) *std.Build.Module { + const Mod = @TypeOf(mod); + var opts: std.Build.Module.CreateOptions = .{ + .root_source_file = if (@hasField(Mod, "root_source_file")) self.resolveLazyPath(mod.root_source_file) else null, + .target = if (@hasField(Mod, "target")) self.resolveTarget(mod.target) else self.target, + .optimize = if (@hasField(Mod, "optimize")) mod.optimize else self.optimize, + }; + inline for (module_passthrough_fields) |fname| { + if (@hasField(Mod, fname)) { + @field(opts, fname) = @field(mod, fname); + } + } + const m = self.b.createModule(opts); + + if (@hasField(Mod, "include_paths")) { + inline for (@typeInfo(@TypeOf(mod.include_paths)).@"struct".fields) |field| { + m.addIncludePath(self.resolveLazyPath(@field(mod.include_paths, field.name))); } } - if (module.link_libraries) |libs| { - for (libs) |lib| { - var parts = std.mem.splitScalar(u8, lib, ':'); + if (@hasField(Mod, "link_libraries")) { + inline for (@typeInfo(@TypeOf(mod.link_libraries)).@"struct".fields) |field| { + const lib_spec: []const u8 = @field(mod.link_libraries, field.name); + var parts = std.mem.splitScalar(u8, lib_spec, ':'); const dep_name = parts.first(); const artifact_name = if (parts.next()) |rest| rest else dep_name; if (self.dependencies.get(dep_name)) |dep| { @@ -169,102 +162,58 @@ const BuildRunner = struct { } } - try self.modules.put(name, m); + self.modules.put(name, m) catch @panic("OOM"); return m; } - fn resolveModuleLink(self: *BuildRunner, link: Config.ModuleLink, fallback_name: []const u8) !*std.Build.Module { - switch (link) { - .name => |n| { - return self.modules.get(n) orelse { - std.log.err("zbuild: module '{s}' not found", .{n}); - return error.ModuleNotFound; - }; - }, - .module => |m| { - const name = m.name orelse fallback_name; - return try self.createModule(m, name); - }, + fn resolveModuleLink(self: *BuildRunner, comptime link: anytype, name: []const u8) !*std.Build.Module { + const ti = @typeInfo(@TypeOf(link)); + if (ti == .enum_literal) { + const mod_name = @tagName(link); + return self.modules.get(mod_name) orelse { + std.log.err("zbuild: module '{s}' not found", .{mod_name}); + return error.ModuleNotFound; + }; + } else if (ti == .pointer) { + const str: []const u8 = link; + return self.modules.get(str) orelse { + std.log.err("zbuild: module '{s}' not found", .{str}); + return error.ModuleNotFound; + }; + } else if (ti == .@"struct") { + const mod_name: []const u8 = if (@hasField(@TypeOf(link), "name")) link.name else name; + return self.createModule(link, mod_name); + } else { + @compileError("root_module must be a string, enum literal, or struct"); } } - fn createDependency(self: *BuildRunner, name: []const u8, dep: Config.Dependency) !void { - _ = dep; - const d = self.b.dependency(name, .{}); - try self.dependencies.put(name, d); - } - - fn createOptionsModule(self: *BuildRunner, name: []const u8, options: Config.OptionsModule) !void { - const opts = self.b.addOptions(); - for (options.keys(), options.values()) |opt_name, opt_value| { - self.addOption(opts, opt_name, opt_value); - } - const m = opts.createModule(); - try self.options_modules.put(name, m); - } + // --- Artifact creation --- - fn addOption(self: *BuildRunner, opts: *std.Build.Step.Options, name: []const u8, value: Config.Option) void { - _ = self; - switch (value) { - .bool => |v| { - const val = opts.step.owner.option(bool, name, v.description orelse ""); - opts.addOption(bool, name, val orelse v.default orelse false); - }, - .int => |v| { - const val = opts.step.owner.option(i64, name, v.description orelse ""); - opts.addOption(i64, name, val orelse v.default orelse 0); - }, - .float => |v| { - const val = opts.step.owner.option(f64, name, v.description orelse ""); - opts.addOption(f64, name, val orelse v.default orelse 0.0); - }, - .string => |v| { - const val = opts.step.owner.option([]const u8, name, v.description orelse ""); - if (val orelse v.default) |s| { - opts.addOption([]const u8, name, s); - } - }, - .list => |v| { - const val = opts.step.owner.option([]const []const u8, name, v.description orelse ""); - if (val orelse v.default) |l| { - opts.addOption([]const []const u8, name, l); - } - }, - .@"enum" => |v| { - const val = opts.step.owner.option([]const u8, name, v.description orelse ""); - if (val orelse v.default) |e| { - opts.addOption([]const u8, name, e); - } - }, - .enum_list => |v| { - const val = opts.step.owner.option([]const []const u8, name, v.description orelse ""); - if (val orelse v.default) |e| { - opts.addOption([]const []const u8, name, e); - } - }, - .build_id => {}, - .lazy_path => {}, - .lazy_path_list => {}, - } - } + const artifact_passthrough_fields = .{ "max_rss", "use_llvm", "use_lld" }; - fn createExecutable(self: *BuildRunner, name: []const u8, exe: Config.Executable) !void { + fn createExecutable(self: *BuildRunner, comptime name: []const u8, comptime exe: anytype) !void { + const Exe = @TypeOf(exe); const root_module = try self.resolveModuleLink(exe.root_module, name); - const artifact = self.b.addExecutable(.{ + var add_opts: std.Build.ExecutableOptions = .{ .name = name, - .version = if (exe.version) |v| std.SemanticVersion.parse(v) catch null else null, .root_module = root_module, - .linkage = exe.linkage, - .max_rss = exe.max_rss, - .use_llvm = exe.use_llvm, - .use_lld = exe.use_lld, - .zig_lib_dir = if (exe.zig_lib_dir) |d| self.resolveLazyPath(d) else null, - .win32_manifest = if (exe.win32_manifest) |d| self.resolveLazyPath(d) else null, - }); + .version = if (@hasField(Exe, "version")) std.SemanticVersion.parse(exe.version) catch null else null, + }; + inline for (artifact_passthrough_fields) |fname| { + if (@hasField(Exe, fname)) { + @field(add_opts, fname) = @field(exe, fname); + } + } + if (@hasField(Exe, "linkage")) add_opts.linkage = exe.linkage; + if (@hasField(Exe, "zig_lib_dir")) add_opts.zig_lib_dir = self.resolveLazyPath(exe.zig_lib_dir); + if (@hasField(Exe, "win32_manifest")) add_opts.win32_manifest = self.resolveLazyPath(exe.win32_manifest); + + const artifact = self.b.addExecutable(add_opts); const install = self.b.addInstallArtifact(artifact, .{ - .dest_sub_path = if (exe.dest_sub_path) |p| @ptrCast(p) else null, + .dest_sub_path = if (@hasField(Exe, "dest_sub_path")) @ptrCast(exe.dest_sub_path) else null, }); const tls_install = self.b.step( @@ -284,27 +233,32 @@ const BuildRunner = struct { tls_run.dependOn(&run.step); } - fn createLibrary(self: *BuildRunner, name: []const u8, lib: Config.Library) !void { + fn createLibrary(self: *BuildRunner, comptime name: []const u8, comptime lib: anytype) !void { + const Lib = @TypeOf(lib); const root_module = try self.resolveModuleLink(lib.root_module, name); - const artifact = self.b.addLibrary(.{ + var add_opts: std.Build.StaticLibraryOptions = .{ .name = name, - .version = if (lib.version) |v| std.SemanticVersion.parse(v) catch null else null, .root_module = root_module, - .linkage = lib.linkage, - .max_rss = lib.max_rss, - .use_llvm = lib.use_llvm, - .use_lld = lib.use_lld, - .zig_lib_dir = if (lib.zig_lib_dir) |d| self.resolveLazyPath(d) else null, - .win32_manifest = if (lib.win32_manifest) |d| self.resolveLazyPath(d) else null, - }); + .version = if (@hasField(Lib, "version")) std.SemanticVersion.parse(lib.version) catch null else null, + }; + inline for (artifact_passthrough_fields) |fname| { + if (@hasField(Lib, fname)) { + @field(add_opts, fname) = @field(lib, fname); + } + } + if (@hasField(Lib, "linkage")) add_opts.linkage = lib.linkage; + if (@hasField(Lib, "zig_lib_dir")) add_opts.zig_lib_dir = self.resolveLazyPath(lib.zig_lib_dir); + if (@hasField(Lib, "win32_manifest")) add_opts.win32_manifest = self.resolveLazyPath(lib.win32_manifest); - if (lib.linker_allow_shlib_undefined) |v| { - artifact.linker_allow_shlib_undefined = v; + const artifact = self.b.addLibrary(add_opts); + + if (@hasField(Lib, "linker_allow_shlib_undefined")) { + artifact.linker_allow_shlib_undefined = lib.linker_allow_shlib_undefined; } const install = self.b.addInstallArtifact(artifact, .{ - .dest_sub_path = if (lib.dest_sub_path) |p| @ptrCast(p) else null, + .dest_sub_path = if (@hasField(Lib, "dest_sub_path")) @ptrCast(lib.dest_sub_path) else null, }); const tls_install = self.b.step( @@ -316,17 +270,22 @@ const BuildRunner = struct { try self.install_steps.put(name, &install.step); } - fn createObject(self: *BuildRunner, name: []const u8, obj: Config.Object) !void { + fn createObject(self: *BuildRunner, comptime name: []const u8, comptime obj: anytype) !void { + const Obj = @TypeOf(obj); const root_module = try self.resolveModuleLink(obj.root_module, name); - const artifact = self.b.addObject(.{ + var add_opts: std.Build.ObjectOptions = .{ .name = name, .root_module = root_module, - .max_rss = obj.max_rss, - .use_llvm = obj.use_llvm, - .use_lld = obj.use_lld, - .zig_lib_dir = if (obj.zig_lib_dir) |d| self.resolveLazyPath(d) else null, - }); + }; + inline for (artifact_passthrough_fields) |fname| { + if (@hasField(Obj, fname)) { + @field(add_opts, fname) = @field(obj, fname); + } + } + if (@hasField(Obj, "zig_lib_dir")) add_opts.zig_lib_dir = self.resolveLazyPath(obj.zig_lib_dir); + + const artifact = self.b.addObject(add_opts); const install = self.b.addInstallArtifact(artifact, .{}); const tls_install = self.b.step( @@ -338,7 +297,8 @@ const BuildRunner = struct { try self.install_steps.put(name, &install.step); } - fn createTest(self: *BuildRunner, name: []const u8, t: Config.Test, tls_run_test: *std.Build.Step) !void { + fn createTest(self: *BuildRunner, comptime name: []const u8, comptime t: anytype, tls_run_test: *std.Build.Step) !void { + const T = @TypeOf(t); const root_module = try self.resolveModuleLink(t.root_module, name); const filters_option = self.b.option( @@ -347,15 +307,19 @@ const BuildRunner = struct { self.b.fmt("{s} test filters", .{name}), ); - const artifact = self.b.addTest(.{ + var add_opts: std.Build.TestOptions = .{ .name = name, .root_module = root_module, - .max_rss = t.max_rss, - .use_llvm = t.use_llvm, - .use_lld = t.use_lld, - .zig_lib_dir = if (t.zig_lib_dir) |d| self.resolveLazyPath(d) else null, - .filters = filters_option orelse if (t.filters.len > 0) t.filters else &.{}, - }); + .filters = filters_option orelse if (@hasField(T, "filters")) comptime toStringSlice(t.filters) else &.{}, + }; + inline for (artifact_passthrough_fields) |fname| { + if (@hasField(T, fname)) { + @field(add_opts, fname) = @field(t, fname); + } + } + if (@hasField(T, "zig_lib_dir")) add_opts.zig_lib_dir = self.resolveLazyPath(t.zig_lib_dir); + + const artifact = self.b.addTest(add_opts); const install = self.b.addInstallArtifact(artifact, .{}); const tls_install = self.b.step( @@ -373,11 +337,12 @@ const BuildRunner = struct { tls_run_test.dependOn(&run.step); } - fn createFmt(self: *BuildRunner, name: []const u8, fmt: Config.Fmt, tls_run_fmt: *std.Build.Step) !void { + fn createFmt(self: *BuildRunner, comptime name: []const u8, comptime fmt: anytype, tls_run_fmt: *std.Build.Step) void { + const Fmt = @TypeOf(fmt); const step = self.b.addFmt(.{ - .paths = fmt.paths orelse &.{}, - .exclude_paths = fmt.exclude_paths orelse &.{}, - .check = fmt.check orelse false, + .paths = if (@hasField(Fmt, "paths")) comptime toStringSlice(fmt.paths) else &.{}, + .exclude_paths = if (@hasField(Fmt, "exclude_paths")) comptime toStringSlice(fmt.exclude_paths) else &.{}, + .check = if (@hasField(Fmt, "check")) fmt.check else false, }); const tls = self.b.step( @@ -388,9 +353,10 @@ const BuildRunner = struct { tls_run_fmt.dependOn(&step.step); } - fn createRun(self: *BuildRunner, name: []const u8, cmd: Config.Run) void { + fn createRun(self: *BuildRunner, comptime name: []const u8, comptime cmd: anytype) void { + const cmd_str: []const u8 = cmd; var args = std.ArrayList([]const u8).init(self.b.allocator); - var it = std.mem.splitScalar(u8, cmd, ' '); + var it = std.mem.splitScalar(u8, cmd_str, ' '); while (it.next()) |arg| { if (arg.len > 0) args.append(arg) catch @panic("OOM"); } @@ -403,58 +369,111 @@ const BuildRunner = struct { tls.dependOn(&run.step); } - fn wireAllImports(self: *BuildRunner, config: Config) !void { - if (config.modules) |modules| { - for (modules.keys(), modules.values()) |name, module| { - if (module.imports) |imports| { - const m = self.modules.get(name) orelse continue; - self.wireImports(m, imports); - } + // --- Options modules --- + + fn createOptionsModule(self: *BuildRunner, comptime name: []const u8, comptime options: anytype) !void { + const opts = self.b.addOptions(); + inline for (@typeInfo(@TypeOf(options)).@"struct".fields) |field| { + self.addOption(opts, field.name, @field(options, field.name)); + } + const m = opts.createModule(); + try self.options_modules.put(name, m); + } + + fn addOption(self: *BuildRunner, opts: *std.Build.Step.Options, comptime name: []const u8, comptime opt: anytype) void { + _ = self; + const Opt = @TypeOf(opt); + const desc: []const u8 = if (@hasField(Opt, "description")) opt.description else ""; + const type_str = opt.type; + + if (comptime std.mem.eql(u8, type_str, "bool")) { + const default: bool = if (@hasField(Opt, "default")) opt.default else false; + const val = opts.step.owner.option(bool, name, desc); + opts.addOption(bool, name, val orelse default); + } else if (comptime std.mem.eql(u8, type_str, "string")) { + const val = opts.step.owner.option([]const u8, name, desc); + if (val orelse if (@hasField(Opt, "default")) @as(?[]const u8, opt.default) else null) |s| { + opts.addOption([]const u8, name, s); + } + } else if (comptime std.mem.eql(u8, type_str, "list")) { + const val = opts.step.owner.option([]const []const u8, name, desc); + if (val orelse if (@hasField(Opt, "default")) @as(?[]const []const u8, comptime toStringSlice(opt.default)) else null) |l| { + opts.addOption([]const []const u8, name, l); + } + } else if (comptime std.mem.eql(u8, type_str, "enum")) { + const val = opts.step.owner.option([]const u8, name, desc); + if (val orelse if (@hasField(Opt, "default")) @as(?[]const u8, @tagName(opt.default)) else null) |e| { + opts.addOption([]const u8, name, e); + } + } else if (comptime std.mem.eql(u8, type_str, "enum_list")) { + const val = opts.step.owner.option([]const []const u8, name, desc); + if (val orelse if (@hasField(Opt, "default")) @as(?[]const []const u8, comptime toEnumSlice(opt.default)) else null) |e| { + opts.addOption([]const []const u8, name, e); } + } else if (comptime isIntType(type_str)) { + const default: i64 = if (@hasField(Opt, "default")) opt.default else 0; + const val = opts.step.owner.option(i64, name, desc); + opts.addOption(i64, name, val orelse default); + } else if (comptime isFloatType(type_str)) { + const default: f64 = if (@hasField(Opt, "default")) opt.default else 0.0; + const val = opts.step.owner.option(f64, name, desc); + opts.addOption(f64, name, val orelse default); } - // Wire imports for inline modules in executables/libraries/objects/tests - inline for (.{ config.executables, config.libraries, config.objects }) |maybe_map| { - if (maybe_map) |map| { - for (map.values()) |item| { - if (item.root_module == .module) { - if (item.root_module.module.imports) |imports| { - const name = item.root_module.module.name orelse continue; - const m = self.modules.get(name) orelse continue; - self.wireImports(m, imports); - } + } + + // --- Import wiring --- + + fn wireAllImports(self: *BuildRunner, comptime manifest: anytype) !void { + if (@hasField(@TypeOf(manifest), "modules")) { + inline for (@typeInfo(@TypeOf(manifest.modules)).@"struct".fields) |field| { + const mod = @field(manifest.modules, field.name); + if (@hasField(@TypeOf(mod), "imports")) { + if (self.modules.get(field.name)) |m| { + self.wireModuleImports(m, mod.imports); } } } } - if (config.tests) |tests| { - for (tests.values()) |t| { - if (t.root_module == .module) { - if (t.root_module.module.imports) |imports| { - const name = t.root_module.module.name orelse continue; - const m = self.modules.get(name) orelse continue; - self.wireImports(m, imports); + + // Wire imports for inline modules in executables, libraries, objects, tests + inline for (.{ "executables", "libraries", "objects", "tests" }) |section| { + if (@hasField(@TypeOf(manifest), section)) { + inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { + const item = @field(@field(manifest, section), field.name); + if (@typeInfo(@TypeOf(item.root_module)) == .@"struct") { + if (@hasField(@TypeOf(item.root_module), "imports")) { + const mod_name: []const u8 = if (@hasField(@TypeOf(item.root_module), "name")) + item.root_module.name + else + field.name; + if (self.modules.get(mod_name)) |m| { + self.wireModuleImports(m, item.root_module.imports); + } + } } } } } } - fn wireDependsOn(self: *BuildRunner, config: Config) void { - inline for (.{ - config.executables, - config.libraries, - config.objects, - }) |maybe_map| { - if (maybe_map) |map| { - for (map.keys(), map.values()) |name, item| { - if (@field(item, "depends_on")) |deps| { - const this_step = self.install_steps.get(name) orelse continue; - for (deps) |dep_name| { - const dep_step = self.install_steps.get(dep_name) orelse { - std.log.warn("zbuild: depends_on references unknown artifact '{s}'", .{dep_name}); - continue; - }; - this_step.dependOn(dep_step); + fn wireModuleImports(self: *BuildRunner, module: *std.Build.Module, comptime imports: anytype) void { + inline for (@typeInfo(@TypeOf(imports)).@"struct".fields) |field| { + const import_name: []const u8 = @field(imports, field.name); + const resolved = self.resolveImport(import_name); + module.addImport(import_name, resolved); + } + } + + // --- depends_on wiring --- + + fn wireDependsOn(self: *BuildRunner, comptime manifest: anytype) void { + inline for (.{ "executables", "libraries", "objects" }) |section| { + if (@hasField(@TypeOf(manifest), section)) { + inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { + const item = @field(@field(manifest, section), field.name); + if (@hasField(@TypeOf(item), "depends_on")) { + if (self.install_steps.get(field.name)) |this_step| { + self.wireDependsOnList(this_step, item.depends_on); } } } @@ -462,13 +481,19 @@ const BuildRunner = struct { } } - fn wireImports(self: *BuildRunner, module: *std.Build.Module, imports: []const []const u8) void { - for (imports) |import_name| { - const resolved = self.resolveImport(import_name); - module.addImport(import_name, resolved); + fn wireDependsOnList(self: *BuildRunner, step: *std.Build.Step, comptime deps: anytype) void { + inline for (@typeInfo(@TypeOf(deps)).@"struct".fields) |field| { + const dep_name: []const u8 = @field(deps, field.name); + const dep_step = self.install_steps.get(dep_name) orelse { + std.log.warn("zbuild: depends_on references unknown artifact '{s}'", .{dep_name}); + continue; + }; + step.dependOn(dep_step); } } + // --- Resolution helpers (runtime) --- + fn resolveImport(self: *BuildRunner, import_name: []const u8) *std.Build.Module { if (self.modules.get(import_name)) |m| return m; if (self.options_modules.get(import_name)) |m| return m; @@ -501,3 +526,44 @@ const BuildRunner = struct { ); } }; + +// --- Comptime helpers --- + +fn toStringSlice(comptime tuple: anytype) []const []const u8 { + const fields = @typeInfo(@TypeOf(tuple)).@"struct".fields; + var result: [fields.len][]const u8 = undefined; + inline for (fields, 0..) |field, i| { + result[i] = @field(tuple, field.name); + } + const final = result; + return &final; +} + +fn toEnumSlice(comptime tuple: anytype) []const []const u8 { + const fields = @typeInfo(@TypeOf(tuple)).@"struct".fields; + var result: [fields.len][]const u8 = undefined; + inline for (fields, 0..) |field, i| { + result[i] = @tagName(@field(tuple, field.name)); + } + const final = result; + return &final; +} + +fn isIntType(comptime t: []const u8) bool { + return for ([_][]const u8{ + "i8", "u8", "i16", "u16", "i32", "u32", "i64", "u64", + "i128", "u128", "isize", "usize", + "c_short", "c_ushort", "c_int", "c_uint", + "c_long", "c_ulong", "c_longlong", "c_ulonglong", + }) |valid| { + if (std.mem.eql(u8, t, valid)) break true; + } else false; +} + +fn isFloatType(comptime t: []const u8) bool { + return for ([_][]const u8{ + "f16", "f32", "f64", "f80", "f128", "c_longdouble", + }) |valid| { + if (std.mem.eql(u8, t, valid)) break true; + } else false; +} diff --git a/src/main.zig b/src/main.zig index 5c371e3..46b3c86 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,11 +5,10 @@ //! const zbuild = @import("zbuild"); //! //! pub fn build(b: *std.Build) void { -//! zbuild.configureBuild(b) catch |err| { +//! zbuild.configureBuild(b, @import("build.zig.zon")) catch |err| { //! std.log.err("zbuild: {}", .{err}); //! }; //! } -pub const Config = @import("Config.zig"); pub const build_runner = @import("build_runner.zig"); pub const configureBuild = build_runner.configureBuild; From eb99205cb17ad81fe9046339712c3341c0f37962 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 14:05:51 -0400 Subject: [PATCH 13/62] fix: manifest validation, error handling, and test coverage - #1: Clean stale build.zig.zon (remove deleted exe/test refs, bump to 0.3.0) - #2: Remove @ptrCast for dest_sub_path (Zig coerces comptime strings) - #3: Default modules to public (export to b.modules unless private = true) - #4: @compileError for unknown option types + validateManifest for unknown top-level fields - #5: resolveImport returns error.ModuleNotFound instead of @panic - #6: Remove duplicate modules.put (createModule handles it, callers don't) - #7: Add 8 comptime helper tests (toStringSlice, toEnumSlice, isIntType, isFloatType, isKnownField, validateManifest) Co-Authored-By: Claude Opus 4.6 --- build.zig.zon | 20 +----- src/build_runner.zig | 151 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 134 insertions(+), 37 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 251e989..dc805c5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,25 +1,9 @@ .{ .name = .zbuild, - .version = "0.2.0", + .version = "0.3.0", .fingerprint = 0x60f98ac2bf5a915c, .minimum_zig_version = "0.14.0", .paths = .{ "build.zig", "build.zig.zon", "src" }, - .description = "An opinionated zig build tool", + .description = "Declarative build configuration for Zig projects", .dependencies = .{}, - .executables = .{ - .zbuild = .{ - .root_module = .{ - .root_source_file = "src/main.zig", - }, - }, - }, - .tests = .{ - .sync = .{ - .root_module = .{ - .private = true, - .root_source_file = "test/sync.zig", - .imports = .{.zbuild}, - }, - }, - }, } diff --git a/src/build_runner.zig b/src/build_runner.zig index 94a10b3..948dc6e 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -13,6 +13,8 @@ const std = @import("std"); pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { + comptime validateManifest(manifest); + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -50,12 +52,10 @@ pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { inline for (@typeInfo(@TypeOf(manifest.modules)).@"struct".fields) |field| { const mod = @field(manifest.modules, field.name); const m = runner.createModule(mod, field.name); - if (@hasField(@TypeOf(mod), "private")) { - if (!mod.private) { - b.modules.put(b.dupe(field.name), m) catch @panic("OOM"); - } + const is_private = @hasField(@TypeOf(mod), "private") and mod.private; + if (!is_private) { + b.modules.put(b.dupe(field.name), m) catch @panic("OOM"); } - try runner.modules.put(field.name, m); } } @@ -110,6 +110,41 @@ pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { runner.wireDependsOn(manifest); } +// --- Manifest validation --- + +const zbuild_fields = .{ + "modules", "executables", "libraries", "objects", + "tests", "fmts", "runs", "options_modules", + "dependencies", +}; + +const zig_manifest_fields = .{ + "name", "version", "fingerprint", + "minimum_zig_version", "paths", "description", + "keywords", +}; + +fn validateManifest(comptime manifest: anytype) void { + const fields = @typeInfo(@TypeOf(manifest)).@"struct".fields; + inline for (fields) |field| { + if (!isKnownField(field.name)) { + @compileError("unknown field '" ++ field.name ++ "' in build.zig.zon"); + } + } +} + +fn isKnownField(comptime name: []const u8) bool { + inline for (zbuild_fields) |f| { + if (std.mem.eql(u8, name, f)) return true; + } + inline for (zig_manifest_fields) |f| { + if (std.mem.eql(u8, name, f)) return true; + } + return false; +} + +// --- BuildRunner --- + const BuildRunner = struct { b: *std.Build, target: std.Build.ResolvedTarget, @@ -119,6 +154,8 @@ const BuildRunner = struct { options_modules: std.StringHashMap(*std.Build.Module), install_steps: std.StringHashMap(*std.Build.Step), + const Error = error{ OutOfMemory, ModuleNotFound }; + // --- Module creation --- const module_passthrough_fields = .{ @@ -166,7 +203,7 @@ const BuildRunner = struct { return m; } - fn resolveModuleLink(self: *BuildRunner, comptime link: anytype, name: []const u8) !*std.Build.Module { + fn resolveModuleLink(self: *BuildRunner, comptime link: anytype, name: []const u8) Error!*std.Build.Module { const ti = @typeInfo(@TypeOf(link)); if (ti == .enum_literal) { const mod_name = @tagName(link); @@ -192,7 +229,7 @@ const BuildRunner = struct { const artifact_passthrough_fields = .{ "max_rss", "use_llvm", "use_lld" }; - fn createExecutable(self: *BuildRunner, comptime name: []const u8, comptime exe: anytype) !void { + fn createExecutable(self: *BuildRunner, comptime name: []const u8, comptime exe: anytype) Error!void { const Exe = @TypeOf(exe); const root_module = try self.resolveModuleLink(exe.root_module, name); @@ -213,7 +250,7 @@ const BuildRunner = struct { const artifact = self.b.addExecutable(add_opts); const install = self.b.addInstallArtifact(artifact, .{ - .dest_sub_path = if (@hasField(Exe, "dest_sub_path")) @ptrCast(exe.dest_sub_path) else null, + .dest_sub_path = if (@hasField(Exe, "dest_sub_path")) exe.dest_sub_path else null, }); const tls_install = self.b.step( @@ -233,7 +270,7 @@ const BuildRunner = struct { tls_run.dependOn(&run.step); } - fn createLibrary(self: *BuildRunner, comptime name: []const u8, comptime lib: anytype) !void { + fn createLibrary(self: *BuildRunner, comptime name: []const u8, comptime lib: anytype) Error!void { const Lib = @TypeOf(lib); const root_module = try self.resolveModuleLink(lib.root_module, name); @@ -258,7 +295,7 @@ const BuildRunner = struct { } const install = self.b.addInstallArtifact(artifact, .{ - .dest_sub_path = if (@hasField(Lib, "dest_sub_path")) @ptrCast(lib.dest_sub_path) else null, + .dest_sub_path = if (@hasField(Lib, "dest_sub_path")) lib.dest_sub_path else null, }); const tls_install = self.b.step( @@ -270,7 +307,7 @@ const BuildRunner = struct { try self.install_steps.put(name, &install.step); } - fn createObject(self: *BuildRunner, comptime name: []const u8, comptime obj: anytype) !void { + fn createObject(self: *BuildRunner, comptime name: []const u8, comptime obj: anytype) Error!void { const Obj = @TypeOf(obj); const root_module = try self.resolveModuleLink(obj.root_module, name); @@ -297,7 +334,7 @@ const BuildRunner = struct { try self.install_steps.put(name, &install.step); } - fn createTest(self: *BuildRunner, comptime name: []const u8, comptime t: anytype, tls_run_test: *std.Build.Step) !void { + fn createTest(self: *BuildRunner, comptime name: []const u8, comptime t: anytype, tls_run_test: *std.Build.Step) Error!void { const T = @TypeOf(t); const root_module = try self.resolveModuleLink(t.root_module, name); @@ -418,18 +455,20 @@ const BuildRunner = struct { const default: f64 = if (@hasField(Opt, "default")) opt.default else 0.0; const val = opts.step.owner.option(f64, name, desc); opts.addOption(f64, name, val orelse default); + } else { + @compileError("unknown option type '" ++ type_str ++ "'"); } } // --- Import wiring --- - fn wireAllImports(self: *BuildRunner, comptime manifest: anytype) !void { + fn wireAllImports(self: *BuildRunner, comptime manifest: anytype) Error!void { if (@hasField(@TypeOf(manifest), "modules")) { inline for (@typeInfo(@TypeOf(manifest.modules)).@"struct".fields) |field| { const mod = @field(manifest.modules, field.name); if (@hasField(@TypeOf(mod), "imports")) { if (self.modules.get(field.name)) |m| { - self.wireModuleImports(m, mod.imports); + try self.wireModuleImports(m, mod.imports); } } } @@ -447,7 +486,7 @@ const BuildRunner = struct { else field.name; if (self.modules.get(mod_name)) |m| { - self.wireModuleImports(m, item.root_module.imports); + try self.wireModuleImports(m, item.root_module.imports); } } } @@ -456,10 +495,10 @@ const BuildRunner = struct { } } - fn wireModuleImports(self: *BuildRunner, module: *std.Build.Module, comptime imports: anytype) void { + fn wireModuleImports(self: *BuildRunner, module: *std.Build.Module, comptime imports: anytype) Error!void { inline for (@typeInfo(@TypeOf(imports)).@"struct".fields) |field| { const import_name: []const u8 = @field(imports, field.name); - const resolved = self.resolveImport(import_name); + const resolved = try self.resolveImport(import_name); module.addImport(import_name, resolved); } } @@ -494,7 +533,7 @@ const BuildRunner = struct { // --- Resolution helpers (runtime) --- - fn resolveImport(self: *BuildRunner, import_name: []const u8) *std.Build.Module { + fn resolveImport(self: *BuildRunner, import_name: []const u8) Error!*std.Build.Module { if (self.modules.get(import_name)) |m| return m; if (self.options_modules.get(import_name)) |m| return m; var parts = std.mem.splitScalar(u8, import_name, ':'); @@ -503,7 +542,8 @@ const BuildRunner = struct { const module_name = if (parts.next()) |rest| rest else first; return dep.module(module_name); } - @panic(self.b.fmt("zbuild: unresolved import '{s}'", .{import_name})); + std.log.err("zbuild: unresolved import '{s}'", .{import_name}); + return error.ModuleNotFound; } fn resolveLazyPath(self: *BuildRunner, path: []const u8) std.Build.LazyPath { @@ -567,3 +607,76 @@ fn isFloatType(comptime t: []const u8) bool { if (std.mem.eql(u8, t, valid)) break true; } else false; } + +// --- Tests --- + +test "toStringSlice" { + const result = comptime toStringSlice(.{ "hello", "world" }); + try std.testing.expectEqual(2, result.len); + try std.testing.expectEqualStrings("hello", result[0]); + try std.testing.expectEqualStrings("world", result[1]); +} + +test "toStringSlice empty" { + const result = comptime toStringSlice(.{}); + try std.testing.expectEqual(0, result.len); +} + +test "toEnumSlice" { + const result = comptime toEnumSlice(.{ .debug, .info, .warn }); + try std.testing.expectEqual(3, result.len); + try std.testing.expectEqualStrings("debug", result[0]); + try std.testing.expectEqualStrings("info", result[1]); + try std.testing.expectEqualStrings("warn", result[2]); +} + +test "isIntType" { + try std.testing.expect(comptime isIntType("i32")); + try std.testing.expect(comptime isIntType("u64")); + try std.testing.expect(comptime isIntType("usize")); + try std.testing.expect(comptime isIntType("c_int")); + try std.testing.expect(!comptime isIntType("f32")); + try std.testing.expect(!comptime isIntType("bool")); + try std.testing.expect(!comptime isIntType("string")); +} + +test "isFloatType" { + try std.testing.expect(comptime isFloatType("f32")); + try std.testing.expect(comptime isFloatType("f64")); + try std.testing.expect(comptime isFloatType("c_longdouble")); + try std.testing.expect(!comptime isFloatType("i32")); + try std.testing.expect(!comptime isFloatType("bool")); +} + +test "isKnownField" { + try std.testing.expect(comptime isKnownField("modules")); + try std.testing.expect(comptime isKnownField("executables")); + try std.testing.expect(comptime isKnownField("name")); + try std.testing.expect(comptime isKnownField("dependencies")); + try std.testing.expect(!comptime isKnownField("bogus")); + try std.testing.expect(!comptime isKnownField("")); +} + +test "validateManifest accepts minimal manifest" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + }); +} + +test "validateManifest accepts zbuild fields" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .modules = .{}, + .executables = .{}, + .tests = .{}, + .dependencies = .{}, + }); +} From f98629638d48e90ae666e0eb0cbadc9988483128 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 14:26:24 -0400 Subject: [PATCH 14/62] feat: replace field-name validation with cross-reference validation Instead of rejecting unknown top-level fields (which breaks forward compatibility with new Zig versions), validate semantic cross-references: root_module refs point to declared modules, depends_on refs point to declared artifacts, and imports reference modules/options_modules/deps. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 231 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 199 insertions(+), 32 deletions(-) diff --git a/src/build_runner.zig b/src/build_runner.zig index 948dc6e..bdac284 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -111,38 +111,130 @@ pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { } // --- Manifest validation --- +// +// Cross-reference checks run at comptime so typos in module names, +// dependency references, and artifact names become compile errors. -const zbuild_fields = .{ - "modules", "executables", "libraries", "objects", - "tests", "fmts", "runs", "options_modules", - "dependencies", -}; +fn validateManifest(comptime manifest: anytype) void { + // Validate root_module name references point to declared modules + inline for (.{ "executables", "libraries", "objects", "tests" }) |section| { + if (@hasField(@TypeOf(manifest), section)) { + inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { + const item = @field(@field(manifest, section), field.name); + validateRootModuleRef(manifest, item.root_module, section, field.name); + } + } + } -const zig_manifest_fields = .{ - "name", "version", "fingerprint", - "minimum_zig_version", "paths", "description", - "keywords", -}; + // Validate depends_on references point to declared artifacts + inline for (.{ "executables", "libraries", "objects" }) |section| { + if (@hasField(@TypeOf(manifest), section)) { + inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { + const item = @field(@field(manifest, section), field.name); + if (@hasField(@TypeOf(item), "depends_on")) { + validateDependsOn(manifest, item.depends_on, section, field.name); + } + } + } + } -fn validateManifest(comptime manifest: anytype) void { - const fields = @typeInfo(@TypeOf(manifest)).@"struct".fields; - inline for (fields) |field| { - if (!isKnownField(field.name)) { - @compileError("unknown field '" ++ field.name ++ "' in build.zig.zon"); + // Validate imports reference declared modules, options_modules, or dependencies + if (@hasField(@TypeOf(manifest), "modules")) { + inline for (@typeInfo(@TypeOf(manifest.modules)).@"struct".fields) |field| { + const mod = @field(manifest.modules, field.name); + if (@hasField(@TypeOf(mod), "imports")) { + validateImports(manifest, mod.imports, "modules", field.name); + } + } + } + inline for (.{ "executables", "libraries", "objects", "tests" }) |section| { + if (@hasField(@TypeOf(manifest), section)) { + inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { + const item = @field(@field(manifest, section), field.name); + if (@typeInfo(@TypeOf(item.root_module)) == .@"struct") { + if (@hasField(@TypeOf(item.root_module), "imports")) { + validateImports(manifest, item.root_module.imports, section, field.name); + } + } + } + } + } +} + +fn validateRootModuleRef(comptime manifest: anytype, comptime root_module: anytype, comptime section: []const u8, comptime name: []const u8) void { + const ti = @typeInfo(@TypeOf(root_module)); + const ref_name = if (ti == .enum_literal) + @tagName(root_module) + else if (ti == .pointer) + @as([]const u8, root_module) + else + return; // struct = inline module, nothing to cross-reference + + if (!hasModule(manifest, ref_name)) { + @compileError(section ++ " '" ++ name ++ "': root_module references unknown module '" ++ ref_name ++ "'"); + } +} + +fn validateDependsOn(comptime manifest: anytype, comptime deps: anytype, comptime section: []const u8, comptime name: []const u8) void { + inline for (@typeInfo(@TypeOf(deps)).@"struct".fields) |field| { + const dep_name = toComptimeString(@field(deps, field.name)); + if (!hasArtifact(manifest, dep_name)) { + @compileError(section ++ " '" ++ name ++ "': depends_on references unknown artifact '" ++ dep_name ++ "'"); + } + } +} + +fn validateImports(comptime manifest: anytype, comptime imports: anytype, comptime section: []const u8, comptime name: []const u8) void { + inline for (@typeInfo(@TypeOf(imports)).@"struct".fields) |field| { + const import_name = toComptimeString(@field(imports, field.name)); + if (!isImportable(manifest, import_name)) { + @compileError(section ++ " '" ++ name ++ "': import references unknown target '" ++ import_name ++ "'"); + } + } +} + +fn hasModule(comptime manifest: anytype, comptime name: []const u8) bool { + if (@hasField(@TypeOf(manifest), "modules")) { + if (@hasField(@TypeOf(manifest.modules), name)) return true; + } + return false; +} + +fn hasArtifact(comptime manifest: anytype, comptime name: []const u8) bool { + inline for (.{ "executables", "libraries", "objects" }) |section| { + if (@hasField(@TypeOf(manifest), section)) { + if (@hasField(@TypeOf(@field(manifest, section)), name)) return true; } } + return false; } -fn isKnownField(comptime name: []const u8) bool { - inline for (zbuild_fields) |f| { - if (std.mem.eql(u8, name, f)) return true; +fn isImportable(comptime manifest: anytype, comptime name: []const u8) bool { + if (hasModule(manifest, name)) return true; + if (@hasField(@TypeOf(manifest), "options_modules")) { + if (@hasField(@TypeOf(manifest.options_modules), name)) return true; } - inline for (zig_manifest_fields) |f| { - if (std.mem.eql(u8, name, f)) return true; + if (@hasField(@TypeOf(manifest), "dependencies")) { + const base = comptimeBaseName(name); + if (@hasField(@TypeOf(manifest.dependencies), base)) return true; } return false; } +fn comptimeBaseName(comptime name: []const u8) []const u8 { + for (name, 0..) |c, i| { + if (c == ':') return name[0..i]; + } + return name; +} + +fn toComptimeString(comptime val: anytype) []const u8 { + const ti = @typeInfo(@TypeOf(val)); + if (ti == .enum_literal) return @tagName(val); + if (ti == .pointer) return val; + @compileError("expected string or enum literal"); +} + // --- BuildRunner --- const BuildRunner = struct { @@ -648,13 +740,64 @@ test "isFloatType" { try std.testing.expect(!comptime isFloatType("bool")); } -test "isKnownField" { - try std.testing.expect(comptime isKnownField("modules")); - try std.testing.expect(comptime isKnownField("executables")); - try std.testing.expect(comptime isKnownField("name")); - try std.testing.expect(comptime isKnownField("dependencies")); - try std.testing.expect(!comptime isKnownField("bogus")); - try std.testing.expect(!comptime isKnownField("")); +test "hasModule" { + const manifest = .{ + .modules = .{ + .core = .{ .root_source_file = "src/core.zig" }, + .utils = .{ .root_source_file = "src/utils.zig" }, + }, + }; + try std.testing.expect(comptime hasModule(manifest, "core")); + try std.testing.expect(comptime hasModule(manifest, "utils")); + try std.testing.expect(!comptime hasModule(manifest, "missing")); + // No modules section at all + try std.testing.expect(!comptime hasModule(.{}, "anything")); +} + +test "hasArtifact" { + const manifest = .{ + .executables = .{ .myapp = .{ .root_module = .{ .root_source_file = "src/main.zig" } } }, + .libraries = .{ .mylib = .{ .root_module = .{ .root_source_file = "src/lib.zig" } } }, + }; + try std.testing.expect(comptime hasArtifact(manifest, "myapp")); + try std.testing.expect(comptime hasArtifact(manifest, "mylib")); + try std.testing.expect(!comptime hasArtifact(manifest, "missing")); +} + +test "isImportable" { + const manifest = .{ + .modules = .{ + .core = .{ .root_source_file = "src/core.zig" }, + }, + .options_modules = .{ + .config = .{ .some_flag = .{ .type = "bool" } }, + }, + .dependencies = .{ + .zlib = .{}, + }, + }; + // Module is importable + try std.testing.expect(comptime isImportable(manifest, "core")); + // Options module is importable + try std.testing.expect(comptime isImportable(manifest, "config")); + // Dependency is importable (plain name) + try std.testing.expect(comptime isImportable(manifest, "zlib")); + // Dependency sub-module is importable (colon-separated) + try std.testing.expect(comptime isImportable(manifest, "zlib:zlib")); + // Unknown is not importable + try std.testing.expect(!comptime isImportable(manifest, "missing")); +} + +test "comptimeBaseName" { + try std.testing.expectEqualStrings("zlib", comptime comptimeBaseName("zlib")); + try std.testing.expectEqualStrings("zlib", comptime comptimeBaseName("zlib:zlib")); + try std.testing.expectEqualStrings("foo", comptime comptimeBaseName("foo:bar:baz")); + try std.testing.expectEqualStrings("", comptime comptimeBaseName("")); +} + +test "toComptimeString" { + try std.testing.expectEqualStrings("hello", comptime toComptimeString("hello")); + try std.testing.expectEqualStrings("world", comptime toComptimeString(.world)); } test "validateManifest accepts minimal manifest" { @@ -667,16 +810,40 @@ test "validateManifest accepts minimal manifest" { }); } -test "validateManifest accepts zbuild fields" { +test "validateManifest accepts valid cross-references" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .modules = .{ + .core = .{ .root_source_file = "src/core.zig" }, + }, + .executables = .{ + .myapp = .{ + .root_module = .core, + }, + }, + .libraries = .{ + .mylib = .{ + .root_module = .{ + .root_source_file = "src/lib.zig", + .imports = .{.core}, + }, + }, + }, + }); +} + +test "validateManifest accepts unknown top-level fields" { + // Forward compatibility: unknown fields should NOT cause errors comptime validateManifest(.{ .name = .myproject, .version = "0.1.0", .fingerprint = 0x1234, .minimum_zig_version = "0.14.0", .paths = .{"."}, - .modules = .{}, - .executables = .{}, - .tests = .{}, - .dependencies = .{}, + .some_future_zig_field = "should be ignored", }); } From eb795ebefc12a1e07427c8656e256061b145ab78 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 21:28:47 -0400 Subject: [PATCH 15/62] docs: add runs field redesign spec Dual-form syntax (bare tuple for simple commands, struct with cmd + env/cwd/stdio/stdin/depends_on for complex ones), comptime validation, and cmd: step prefix to avoid collision with executable run: steps. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-14-runs-redesign.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-14-runs-redesign.md diff --git a/docs/superpowers/specs/2026-03-14-runs-redesign.md b/docs/superpowers/specs/2026-03-14-runs-redesign.md new file mode 100644 index 0000000..b929d54 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-runs-redesign.md @@ -0,0 +1,99 @@ +# Runs Field Redesign + +## Problem + +The current `runs` implementation accepts a single string per command and splits it on spaces at runtime (`std.mem.splitScalar`). This is: + +- **Fragile** — arguments with spaces are impossible to express +- **Inconsistent** — the rest of the manifest is comptime-native tuples/structs, but runs does runtime string parsing +- **Limited** — no way to set environment variables, working directory, stdin, stdio mode, or step ordering + +## Design + +### Dual-form ZON syntax + +**Short form** — bare tuple of strings for simple commands: + +```zig +.runs = .{ + .fmt = .{ "zig", "fmt", "src" }, + .check = .{ "zig", "build", "test" }, +}, +``` + +**Long form** — struct with `cmd` plus optional configuration fields: + +```zig +.runs = .{ + .deploy = .{ + .cmd = .{ "./scripts/deploy.sh", "--env", "staging" }, + .cwd = "scripts", + .env = .{ + .NODE_ENV = "production", + .VERBOSE = "1", + }, + .inherit_stdio = true, + .stdin = "input data here", + .depends_on = .{ .mylib }, + }, +}, +``` + +### Form detection + +`@hasField(@TypeOf(val), "cmd")` distinguishes long form (struct with `cmd` field) from short form (bare tuple). + +### Long form fields + +| Field | Type | Default | Maps to | +|-------|------|---------|---------| +| `cmd` | tuple of strings | required | `addSystemCommand` args via `toStringSlice` | +| `cwd` | string | omitted (inherit) | `run.setCwd(resolveLazyPath(...))` | +| `env` | struct of key=value strings | omitted (inherit) | `run.setEnvironmentVariable` per field | +| `inherit_stdio` | bool | `false` | `run.stdio = .inherit` when true | +| `stdin` | string | omitted | `run.setStdIn(.{ .bytes = stdin })` — ZON string passes directly as `[]const u8` | +| `stdin_file` | string (path) | omitted | `run.setStdIn(.{ .lazy_path = resolveLazyPath(...) })` | +| `depends_on` | tuple of enum literals | omitted | `run.step.dependOn` on named artifact steps | + +### Constraints + +- `stdin` and `stdin_file` are mutually exclusive. Both present → `@compileError`. +- `depends_on` entries must reference declared artifacts (executables, libraries, objects). Invalid references → `@compileError` via cross-reference validation. +- Run names must not collide with executable names, since executables use `run:` steps. Runs use a distinct `cmd:` step prefix to avoid this. + +## Implementation + +### `createRun` rewrite + +Replace the runtime string-split implementation with comptime-aware dual-form dispatch: + +1. Detect form via `@hasField(@TypeOf(cmd), "cmd")` +2. Extract args tuple (from `cmd.cmd` or `cmd` directly) +3. Convert to slice via `comptime toStringSlice(args_tuple)` and pass to `addSystemCommand` +4. Apply optional long-form fields when present using `@hasField` checks +5. Create the `cmd:` top-level step (distinct from `run:` used by executables) +6. Wire `depends_on` inside `createRun` itself: look up artifacts from `self.install_steps`, call `run.step.dependOn`. Use warn-and-skip pattern (consistent with `wireDependsOnList`) for runtime lookup misses. + +`createRun` signature stays `void` (no error return), consistent with the existing pattern. + +### Validation + +Add a new validation block in `validateManifest` specifically for runs (separate from the artifact validation, since runs don't have `root_module`): + +- Iterate `manifest.runs` fields +- For long-form entries (`@hasField("cmd")`): validate `depends_on` references against declared artifacts, check `stdin`/`stdin_file` mutual exclusion +- Short-form entries: no validation needed (just a tuple of strings) + +### Error handling + +- Invalid cross-references: `@compileError` with descriptive message (comptime) +- `stdin` + `stdin_file` conflict: `@compileError("runs '': stdin and stdin_file are mutually exclusive")` +- Allocation failures: `@panic("OOM")` (consistent with rest of codebase) + +## Testing + +- Short form: tuple of strings creates system command step +- Long form: struct with `cmd` + `env` + `cwd` applies all options +- Validation: `depends_on` referencing unknown artifact → compile error (documented constraint, not testable in `test` blocks) +- `stdin`/`stdin_file` mutual exclusion → compile error (documented constraint) +- Unknown fields in a run struct are silently ignored (consistent with other sections). Conflicting known fields (`stdin` + `stdin_file`) produce a `@compileError`. From f4a5da2098fba3360f3ca2983e108a5cb4226ac3 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 21:38:32 -0400 Subject: [PATCH 16/62] docs: add runs field redesign implementation plan Three tasks: validation, createRun rewrite, and tests. Covers dual-form syntax, stdin/stdin_file exclusion, depends_on wiring, and cmd: step prefix to avoid executable collision. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-14-runs-redesign.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-14-runs-redesign.md diff --git a/docs/superpowers/plans/2026-03-14-runs-redesign.md b/docs/superpowers/plans/2026-03-14-runs-redesign.md new file mode 100644 index 0000000..e24f188 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-runs-redesign.md @@ -0,0 +1,215 @@ +# Runs Field Redesign Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the runtime string-splitting `runs` implementation with a comptime dual-form syntax (bare tuple or struct with `cmd` + options). + +**Architecture:** Detect short form (bare tuple) vs long form (struct with `cmd` field) at comptime via `@hasField`. Convert args via `toStringSlice`, apply optional fields (`cwd`, `env`, `inherit_stdio`, `stdin`, `stdin_file`, `depends_on`) using `@hasField` checks. Add comptime cross-reference validation for `depends_on` and mutual exclusion for `stdin`/`stdin_file`. + +**Tech Stack:** Zig 0.14, comptime metaprogramming, `std.Build.Step.Run` + +--- + +## Chunk 1: Implementation + +### Task 1: Add runs validation to `validateManifest` + +**Files:** +- Modify: `src/build_runner.zig:118-162` (inside `validateManifest`) + +- [ ] **Step 1: Add the runs validation block** + +Add after the existing imports validation block (after line 161, before the closing `}`): + +```zig + // Validate runs fields + if (@hasField(@TypeOf(manifest), "runs")) { + inline for (@typeInfo(@TypeOf(manifest.runs)).@"struct".fields) |field| { + const run = @field(manifest.runs, field.name); + if (@hasField(@TypeOf(run), "cmd")) { + // Long form — validate depends_on and stdin/stdin_file exclusion + if (@hasField(@TypeOf(run), "depends_on")) { + validateDependsOn(manifest, run.depends_on, "runs", field.name); + } + if (@hasField(@TypeOf(run), "stdin") and @hasField(@TypeOf(run), "stdin_file")) { + @compileError("runs '" ++ field.name ++ "': stdin and stdin_file are mutually exclusive"); + } + } + } + } +``` + +- [ ] **Step 2: Run tests to verify nothing broke** + +Run: `zig build test` +Expected: All 13 tests pass (no runs validation exercised yet, just structural addition). + +- [ ] **Step 3: Commit** + +```bash +git add src/build_runner.zig +git commit -m "feat: add comptime validation for runs fields" +``` + +### Task 2: Rewrite `createRun` with dual-form support + +**Files:** +- Modify: `src/build_runner.zig:485-499` (replace `createRun`) + +- [ ] **Step 1: Replace `createRun` implementation** + +Replace the entire `createRun` function (lines 485-499) with: + +```zig + fn createRun(self: *BuildRunner, comptime name: []const u8, comptime cmd: anytype) void { + const is_long_form = @hasField(@TypeOf(cmd), "cmd"); + const args_tuple = if (is_long_form) cmd.cmd else cmd; + const run = self.b.addSystemCommand(comptime toStringSlice(args_tuple)); + + // Long form options + if (is_long_form) { + if (@hasField(@TypeOf(cmd), "cwd")) + run.setCwd(self.resolveLazyPath(cmd.cwd)); + + if (@hasField(@TypeOf(cmd), "env")) { + inline for (@typeInfo(@TypeOf(cmd.env)).@"struct".fields) |field| { + run.setEnvironmentVariable(field.name, @field(cmd.env, field.name)); + } + } + + if (@hasField(@TypeOf(cmd), "inherit_stdio")) { + if (cmd.inherit_stdio) run.stdio = .inherit; + } + + if (@hasField(@TypeOf(cmd), "stdin")) + run.setStdIn(.{ .bytes = cmd.stdin }); + + if (@hasField(@TypeOf(cmd), "stdin_file")) + run.setStdIn(.{ .lazy_path = self.resolveLazyPath(cmd.stdin_file) }); + } + + const tls = self.b.step( + self.b.fmt("cmd:{s}", .{name}), + self.b.fmt("Run the {s} command", .{name}), + ); + tls.dependOn(&run.step); + + // Wire depends_on + if (is_long_form and @hasField(@TypeOf(cmd), "depends_on")) { + inline for (@typeInfo(@TypeOf(cmd.depends_on)).@"struct".fields) |field| { + const dep_name = comptime toComptimeString(@field(cmd.depends_on, field.name)); + if (self.install_steps.get(dep_name)) |dep_step| { + run.step.dependOn(dep_step); + } else { + std.log.warn("zbuild: runs '{s}' depends_on references unknown artifact '{s}'", .{ name, dep_name }); + } + } + } + } +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `zig build test` +Expected: All 13 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/build_runner.zig +git commit -m "feat: rewrite createRun with dual-form comptime support" +``` + +### Task 3: Add tests + +**Files:** +- Modify: `src/build_runner.zig` (test section at end of file) + +**Note:** We cannot test the full `createRun` in unit tests because it requires a real `std.Build` instance. We can test comptime validation paths. The `stdin`/`stdin_file` mutual exclusion produces a `@compileError`, which Zig test blocks cannot catch — this is a documented constraint, not a coverage gap. + +**Breaking change:** The step prefix changes from `run:` to `cmd:`. Anyone using `zig build run:` for system-command runs will need to use `cmd:` instead. Executable `run:` steps are unaffected. + +- [ ] **Step 1: Add validation tests for runs** + +Add after the existing `validateManifest accepts unknown top-level fields` test: + +```zig +test "validateManifest accepts short-form runs" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .runs = .{ + .fmt = .{ "zig", "fmt", "src" }, + }, + }); +} + +test "validateManifest accepts long-form runs" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .executables = .{ + .myapp = .{ .root_module = .{ .root_source_file = "src/main.zig" } }, + }, + .runs = .{ + .deploy = .{ + .cmd = .{ "./deploy.sh" }, + .cwd = "scripts", + .env = .{ .NODE_ENV = "production" }, + .depends_on = .{.myapp}, + }, + }, + }); +} + +test "validateManifest accepts run and executable with same name" { + // cmd: prefix for runs vs run: for executables avoids collision + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .executables = .{ + .deploy = .{ .root_module = .{ .root_source_file = "src/main.zig" } }, + }, + .runs = .{ + .deploy = .{ "echo", "deploying" }, + }, + }); +} + +test "validateManifest accepts runs with unknown fields" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .runs = .{ + .deploy = .{ + .cmd = .{ "./deploy.sh" }, + .some_future_field = "ignored", + }, + }, + }); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `zig build test` +Expected: All 17 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/build_runner.zig +git commit -m "test: add validation tests for runs dual-form syntax" +``` From e870576140cd6b3db4ede27d0361b738ff19e114 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 21:48:22 -0400 Subject: [PATCH 17/62] feat: add comptime validation for runs fields Cross-reference depends_on against declared artifacts and enforce stdin/stdin_file mutual exclusion at compile time. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/build_runner.zig b/src/build_runner.zig index bdac284..6b4f445 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -159,6 +159,22 @@ fn validateManifest(comptime manifest: anytype) void { } } } + + // Validate runs fields + if (@hasField(@TypeOf(manifest), "runs")) { + inline for (@typeInfo(@TypeOf(manifest.runs)).@"struct".fields) |field| { + const run = @field(manifest.runs, field.name); + if (@hasField(@TypeOf(run), "cmd")) { + // Long form — validate depends_on and stdin/stdin_file exclusion + if (@hasField(@TypeOf(run), "depends_on")) { + validateDependsOn(manifest, run.depends_on, "runs", field.name); + } + if (@hasField(@TypeOf(run), "stdin") and @hasField(@TypeOf(run), "stdin_file")) { + @compileError("runs '" ++ field.name ++ "': stdin and stdin_file are mutually exclusive"); + } + } + } + } } fn validateRootModuleRef(comptime manifest: anytype, comptime root_module: anytype, comptime section: []const u8, comptime name: []const u8) void { From 6a446cfcabcdbb176346881c9cfadcf70e2fa44d Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 21:48:53 -0400 Subject: [PATCH 18/62] feat: rewrite createRun with dual-form comptime support Short form (bare tuple) for simple commands, long form (struct with cmd + cwd/env/inherit_stdio/stdin/stdin_file/depends_on) for complex ones. Replaces runtime string splitting with comptime toStringSlice. Step prefix changes from run: to cmd: to avoid executable collision. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/build_runner.zig b/src/build_runner.zig index 6b4f445..7c9eefa 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -499,19 +499,49 @@ const BuildRunner = struct { } fn createRun(self: *BuildRunner, comptime name: []const u8, comptime cmd: anytype) void { - const cmd_str: []const u8 = cmd; - var args = std.ArrayList([]const u8).init(self.b.allocator); - var it = std.mem.splitScalar(u8, cmd_str, ' '); - while (it.next()) |arg| { - if (arg.len > 0) args.append(arg) catch @panic("OOM"); + const is_long_form = @hasField(@TypeOf(cmd), "cmd"); + const args_tuple = if (is_long_form) cmd.cmd else cmd; + const run = self.b.addSystemCommand(comptime toStringSlice(args_tuple)); + + // Long form options + if (is_long_form) { + if (@hasField(@TypeOf(cmd), "cwd")) + run.setCwd(self.resolveLazyPath(cmd.cwd)); + + if (@hasField(@TypeOf(cmd), "env")) { + inline for (@typeInfo(@TypeOf(cmd.env)).@"struct".fields) |field| { + run.setEnvironmentVariable(field.name, @field(cmd.env, field.name)); + } + } + + if (@hasField(@TypeOf(cmd), "inherit_stdio")) { + if (cmd.inherit_stdio) run.stdio = .inherit; + } + + if (@hasField(@TypeOf(cmd), "stdin")) + run.setStdIn(.{ .bytes = cmd.stdin }); + + if (@hasField(@TypeOf(cmd), "stdin_file")) + run.setStdIn(.{ .lazy_path = self.resolveLazyPath(cmd.stdin_file) }); } - const run = self.b.addSystemCommand(args.items); const tls = self.b.step( - self.b.fmt("run:{s}", .{name}), + self.b.fmt("cmd:{s}", .{name}), self.b.fmt("Run the {s} command", .{name}), ); tls.dependOn(&run.step); + + // Wire depends_on + if (is_long_form and @hasField(@TypeOf(cmd), "depends_on")) { + inline for (@typeInfo(@TypeOf(cmd.depends_on)).@"struct".fields) |field| { + const dep_name = comptime toComptimeString(@field(cmd.depends_on, field.name)); + if (self.install_steps.get(dep_name)) |dep_step| { + run.step.dependOn(dep_step); + } else { + std.log.warn("zbuild: runs '{s}' depends_on references unknown artifact '{s}'", .{ name, dep_name }); + } + } + } } // --- Options modules --- From eeb6dfbc8c549056f0cc85b860b56c50bd793ab2 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sat, 14 Mar 2026 21:49:28 -0400 Subject: [PATCH 19/62] test: add validation tests for runs dual-form syntax Covers short-form tuples, long-form structs with depends_on/env/cwd, run+executable name coexistence, and forward-compat unknown fields. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/build_runner.zig b/src/build_runner.zig index 7c9eefa..33d7603 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -893,3 +893,69 @@ test "validateManifest accepts unknown top-level fields" { .some_future_zig_field = "should be ignored", }); } + +test "validateManifest accepts short-form runs" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .runs = .{ + .fmt = .{ "zig", "fmt", "src" }, + }, + }); +} + +test "validateManifest accepts long-form runs" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .executables = .{ + .myapp = .{ .root_module = .{ .root_source_file = "src/main.zig" } }, + }, + .runs = .{ + .deploy = .{ + .cmd = .{ "./deploy.sh" }, + .cwd = "scripts", + .env = .{ .NODE_ENV = "production" }, + .depends_on = .{.myapp}, + }, + }, + }); +} + +test "validateManifest accepts run and executable with same name" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .executables = .{ + .deploy = .{ .root_module = .{ .root_source_file = "src/main.zig" } }, + }, + .runs = .{ + .deploy = .{ "echo", "deploying" }, + }, + }); +} + +test "validateManifest accepts runs with unknown fields" { + comptime validateManifest(.{ + .name = .myproject, + .version = "0.1.0", + .fingerprint = 0x1234, + .minimum_zig_version = "0.14.0", + .paths = .{"."}, + .runs = .{ + .deploy = .{ + .cmd = .{ "./deploy.sh" }, + .some_future_field = "ignored", + }, + }, + }); +} From 5153832aa96c9866bbb4c7f715c83d695394abbe Mon Sep 17 00:00:00 2001 From: Cayman Date: Sun, 15 Mar 2026 21:37:14 -0400 Subject: [PATCH 20/62] refactor: consolidate validateManifest and extract installAndRegister Merge three separate artifact-section loops in validateManifest into a single pass that validates root_module refs, depends_on, and imports per item. Extract the repeated install-artifact-and-register pattern into installAndRegister helper used by all three artifact creators. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 89 +++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 58 deletions(-) diff --git a/src/build_runner.zig b/src/build_runner.zig index 33d7603..2fbdce7 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -116,62 +116,40 @@ pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { // dependency references, and artifact names become compile errors. fn validateManifest(comptime manifest: anytype) void { - // Validate root_module name references point to declared modules + // Validate artifact sections: root_module refs, depends_on, and inline module imports inline for (.{ "executables", "libraries", "objects", "tests" }) |section| { if (@hasField(@TypeOf(manifest), section)) { inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { const item = @field(@field(manifest, section), field.name); validateRootModuleRef(manifest, item.root_module, section, field.name); - } - } - } - - // Validate depends_on references point to declared artifacts - inline for (.{ "executables", "libraries", "objects" }) |section| { - if (@hasField(@TypeOf(manifest), section)) { - inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { - const item = @field(@field(manifest, section), field.name); - if (@hasField(@TypeOf(item), "depends_on")) { + if (@hasField(@TypeOf(item), "depends_on")) validateDependsOn(manifest, item.depends_on, section, field.name); + if (@typeInfo(@TypeOf(item.root_module)) == .@"struct") { + if (@hasField(@TypeOf(item.root_module), "imports")) + validateImports(manifest, item.root_module.imports, section, field.name); } } } } - // Validate imports reference declared modules, options_modules, or dependencies + // Validate named module imports if (@hasField(@TypeOf(manifest), "modules")) { inline for (@typeInfo(@TypeOf(manifest.modules)).@"struct".fields) |field| { const mod = @field(manifest.modules, field.name); - if (@hasField(@TypeOf(mod), "imports")) { + if (@hasField(@TypeOf(mod), "imports")) validateImports(manifest, mod.imports, "modules", field.name); - } - } - } - inline for (.{ "executables", "libraries", "objects", "tests" }) |section| { - if (@hasField(@TypeOf(manifest), section)) { - inline for (@typeInfo(@TypeOf(@field(manifest, section))).@"struct".fields) |field| { - const item = @field(@field(manifest, section), field.name); - if (@typeInfo(@TypeOf(item.root_module)) == .@"struct") { - if (@hasField(@TypeOf(item.root_module), "imports")) { - validateImports(manifest, item.root_module.imports, section, field.name); - } - } - } } } - // Validate runs fields + // Validate runs: depends_on refs and stdin/stdin_file exclusion if (@hasField(@TypeOf(manifest), "runs")) { inline for (@typeInfo(@TypeOf(manifest.runs)).@"struct".fields) |field| { const run = @field(manifest.runs, field.name); if (@hasField(@TypeOf(run), "cmd")) { - // Long form — validate depends_on and stdin/stdin_file exclusion - if (@hasField(@TypeOf(run), "depends_on")) { + if (@hasField(@TypeOf(run), "depends_on")) validateDependsOn(manifest, run.depends_on, "runs", field.name); - } - if (@hasField(@TypeOf(run), "stdin") and @hasField(@TypeOf(run), "stdin_file")) { + if (@hasField(@TypeOf(run), "stdin") and @hasField(@TypeOf(run), "stdin_file")) @compileError("runs '" ++ field.name ++ "': stdin and stdin_file are mutually exclusive"); - } } } } @@ -357,18 +335,10 @@ const BuildRunner = struct { const artifact = self.b.addExecutable(add_opts); - const install = self.b.addInstallArtifact(artifact, .{ + try self.installAndRegister("build-exe", "executable", name, artifact, .{ .dest_sub_path = if (@hasField(Exe, "dest_sub_path")) exe.dest_sub_path else null, }); - const tls_install = self.b.step( - self.b.fmt("build-exe:{s}", .{name}), - self.b.fmt("Install the {s} executable", .{name}), - ); - tls_install.dependOn(&install.step); - self.b.getInstallStep().dependOn(&install.step); - try self.install_steps.put(name, &install.step); - const run = self.b.addRunArtifact(artifact); if (self.b.args) |args| run.addArgs(args); const tls_run = self.b.step( @@ -402,17 +372,9 @@ const BuildRunner = struct { artifact.linker_allow_shlib_undefined = lib.linker_allow_shlib_undefined; } - const install = self.b.addInstallArtifact(artifact, .{ + try self.installAndRegister("build-lib", "library", name, artifact, .{ .dest_sub_path = if (@hasField(Lib, "dest_sub_path")) lib.dest_sub_path else null, }); - - const tls_install = self.b.step( - self.b.fmt("build-lib:{s}", .{name}), - self.b.fmt("Install the {s} library", .{name}), - ); - tls_install.dependOn(&install.step); - self.b.getInstallStep().dependOn(&install.step); - try self.install_steps.put(name, &install.step); } fn createObject(self: *BuildRunner, comptime name: []const u8, comptime obj: anytype) Error!void { @@ -432,14 +394,7 @@ const BuildRunner = struct { const artifact = self.b.addObject(add_opts); - const install = self.b.addInstallArtifact(artifact, .{}); - const tls_install = self.b.step( - self.b.fmt("build-obj:{s}", .{name}), - self.b.fmt("Install the {s} object", .{name}), - ); - tls_install.dependOn(&install.step); - self.b.getInstallStep().dependOn(&install.step); - try self.install_steps.put(name, &install.step); + try self.installAndRegister("build-obj", "object", name, artifact, .{}); } fn createTest(self: *BuildRunner, comptime name: []const u8, comptime t: anytype, tls_run_test: *std.Build.Step) Error!void { @@ -544,6 +499,24 @@ const BuildRunner = struct { } } + fn installAndRegister( + self: *BuildRunner, + comptime prefix: []const u8, + comptime label: []const u8, + comptime name: []const u8, + artifact: *std.Build.Step.Compile, + install_opts: std.Build.Step.InstallArtifact.Options, + ) Error!void { + const install = self.b.addInstallArtifact(artifact, install_opts); + const tls = self.b.step( + self.b.fmt(prefix ++ ":{s}", .{name}), + self.b.fmt("Install the {s} " ++ label, .{name}), + ); + tls.dependOn(&install.step); + self.b.getInstallStep().dependOn(&install.step); + try self.install_steps.put(name, &install.step); + } + // --- Options modules --- fn createOptionsModule(self: *BuildRunner, comptime name: []const u8, comptime options: anytype) !void { From 28a8c9eae759c7b2c4b43ddf4c2395b03e1a02b9 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sun, 15 Mar 2026 21:45:16 -0400 Subject: [PATCH 21/62] refactor: unify string extraction and depends_on wiring patterns Use toComptimeString consistently for extracting strings from ZON tuples (wireDependsOnList, wireModuleImports). Have createRun reuse wireDependsOnList instead of duplicating the depends_on logic inline. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/build_runner.zig b/src/build_runner.zig index 2fbdce7..63c02b0 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -487,16 +487,8 @@ const BuildRunner = struct { tls.dependOn(&run.step); // Wire depends_on - if (is_long_form and @hasField(@TypeOf(cmd), "depends_on")) { - inline for (@typeInfo(@TypeOf(cmd.depends_on)).@"struct".fields) |field| { - const dep_name = comptime toComptimeString(@field(cmd.depends_on, field.name)); - if (self.install_steps.get(dep_name)) |dep_step| { - run.step.dependOn(dep_step); - } else { - std.log.warn("zbuild: runs '{s}' depends_on references unknown artifact '{s}'", .{ name, dep_name }); - } - } - } + if (is_long_form and @hasField(@TypeOf(cmd), "depends_on")) + self.wireDependsOnList(&run.step, cmd.depends_on); } fn installAndRegister( @@ -608,7 +600,7 @@ const BuildRunner = struct { fn wireModuleImports(self: *BuildRunner, module: *std.Build.Module, comptime imports: anytype) Error!void { inline for (@typeInfo(@TypeOf(imports)).@"struct".fields) |field| { - const import_name: []const u8 = @field(imports, field.name); + const import_name = comptime toComptimeString(@field(imports, field.name)); const resolved = try self.resolveImport(import_name); module.addImport(import_name, resolved); } @@ -633,7 +625,7 @@ const BuildRunner = struct { fn wireDependsOnList(self: *BuildRunner, step: *std.Build.Step, comptime deps: anytype) void { inline for (@typeInfo(@TypeOf(deps)).@"struct".fields) |field| { - const dep_name: []const u8 = @field(deps, field.name); + const dep_name = comptime toComptimeString(@field(deps, field.name)); const dep_step = self.install_steps.get(dep_name) orelse { std.log.warn("zbuild: depends_on references unknown artifact '{s}'", .{dep_name}); continue; From 0ba87be93d846df407976ddcb60b52fa7153d565 Mon Sep 17 00:00:00 2001 From: Cayman Date: Sun, 15 Mar 2026 23:21:52 -0400 Subject: [PATCH 22/62] refactor: comptime string splitting for link_libraries Replace runtime splitScalar with comptime comptimeBaseName/comptimeAfterSep for link_libraries colon syntax. Also use toComptimeString so link_libraries accepts enum literals (consistent with imports and depends_on). Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/build_runner.zig b/src/build_runner.zig index 63c02b0..de661f5 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -222,6 +222,13 @@ fn comptimeBaseName(comptime name: []const u8) []const u8 { return name; } +fn comptimeAfterSep(comptime name: []const u8) []const u8 { + for (name, 0..) |c, i| { + if (c == ':') return name[i + 1 ..]; + } + return name; +} + fn toComptimeString(comptime val: anytype) []const u8 { const ti = @typeInfo(@TypeOf(val)); if (ti == .enum_literal) return @tagName(val); @@ -275,10 +282,9 @@ const BuildRunner = struct { if (@hasField(Mod, "link_libraries")) { inline for (@typeInfo(@TypeOf(mod.link_libraries)).@"struct".fields) |field| { - const lib_spec: []const u8 = @field(mod.link_libraries, field.name); - var parts = std.mem.splitScalar(u8, lib_spec, ':'); - const dep_name = parts.first(); - const artifact_name = if (parts.next()) |rest| rest else dep_name; + const lib_spec = comptime toComptimeString(@field(mod.link_libraries, field.name)); + const dep_name = comptime comptimeBaseName(lib_spec); + const artifact_name = comptime comptimeAfterSep(lib_spec); if (self.dependencies.get(dep_name)) |dep| { m.linkLibrary(dep.artifact(artifact_name)); } @@ -806,6 +812,13 @@ test "comptimeBaseName" { try std.testing.expectEqualStrings("", comptime comptimeBaseName("")); } +test "comptimeAfterSep" { + try std.testing.expectEqualStrings("zlib", comptime comptimeAfterSep("zlib")); + try std.testing.expectEqualStrings("zlib", comptime comptimeAfterSep("dep:zlib")); + try std.testing.expectEqualStrings("bar:baz", comptime comptimeAfterSep("foo:bar:baz")); + try std.testing.expectEqualStrings("", comptime comptimeAfterSep("")); +} + test "toComptimeString" { try std.testing.expectEqualStrings("hello", comptime toComptimeString("hello")); try std.testing.expectEqualStrings("world", comptime toComptimeString(.world)); From 7a3e00a8474a452f413acc8f9544a15ec2abe5df Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:17:31 -0400 Subject: [PATCH 23/62] feat: add built-in help step with project build information MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configureBuild now takes a comptime Options struct with a help_step field (default: "help"). When set, zig build help prints a formatted overview of the project's modules, artifacts, tests, runs, options, and dependencies — all derived from the manifest at comptime. The Options struct provides a natural extension point for future configuration without breaking the API. Breaking: configureBuild signature changed to accept a third opts parameter. Callers must add .{} as the third argument. Co-Authored-By: Claude Opus 4.6 --- src/build_runner.zig | 275 ++++++++++++++++++++++++++++++++++++++++++- src/main.zig | 1 + 2 files changed, 274 insertions(+), 2 deletions(-) diff --git a/src/build_runner.zig b/src/build_runner.zig index de661f5..732c75f 100644 --- a/src/build_runner.zig +++ b/src/build_runner.zig @@ -5,14 +5,19 @@ //! const zbuild = @import("zbuild"); //! //! pub fn build(b: *std.Build) void { -//! zbuild.configureBuild(b, @import("build.zig.zon")) catch |err| { +//! zbuild.configureBuild(b, @import("build.zig.zon"), .{}) catch |err| { //! std.log.err("zbuild: {}", .{err}); //! }; //! } const std = @import("std"); -pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { +pub const Options = struct { + /// Step name for the help command, or null to disable. Default: "help". + help_step: ?[]const u8 = "help", +}; + +pub fn configureBuild(b: *std.Build, comptime manifest: anytype, comptime opts: Options) !void { comptime validateManifest(manifest); const target = b.standardTargetOptions(.{}); @@ -108,6 +113,25 @@ pub fn configureBuild(b: *std.Build, comptime manifest: anytype) !void { // Phase 11: Wire depends_on runner.wireDependsOn(manifest); + + // Phase 12: Add help step + if (opts.help_step) |step_name| { + const help = b.allocator.create(std.Build.Step) catch @panic("OOM"); + const S = struct { + fn make(_: *std.Build.Step, _: std.Progress.Node) anyerror!void { + const stdout = std.io.getStdOut().writer(); + try stdout.writeAll(comptime buildHelpText(manifest)); + } + }; + help.* = std.Build.Step.init(.{ + .id = .custom, + .name = "help", + .owner = b, + .makeFn = S.make, + }); + const tls = b.step(step_name, "Show project build information"); + tls.dependOn(help); + } } // --- Manifest validation --- @@ -236,6 +260,186 @@ fn toComptimeString(comptime val: anytype) []const u8 { @compileError("expected string or enum literal"); } +// --- Help text generation --- + +fn buildHelpText(comptime manifest: anytype) []const u8 { + var text: []const u8 = ""; + + // Header + if (@hasField(@TypeOf(manifest), "name")) + text = text ++ @tagName(manifest.name); + if (@hasField(@TypeOf(manifest), "version")) + text = text ++ " v" ++ manifest.version; + if (@hasField(@TypeOf(manifest), "description")) + text = text ++ " — " ++ manifest.description; + text = text ++ "\n"; + + // Modules + if (@hasField(@TypeOf(manifest), "modules")) { + const fields = @typeInfo(@TypeOf(manifest.modules)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nModules:\n"; + inline for (fields) |field| { + const mod = @field(manifest.modules, field.name); + text = text ++ " " ++ comptimePad(field.name, 22); + if (@hasField(@TypeOf(mod), "root_source_file")) + text = text ++ mod.root_source_file; + text = text ++ "\n"; + } + } + } + + // Executables + if (@hasField(@TypeOf(manifest), "executables")) { + const fields = @typeInfo(@TypeOf(manifest.executables)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nExecutables:" ++ comptimePad("", 10) ++ "zig build run:\n"; + inline for (fields) |field| { + const exe = @field(manifest.executables, field.name); + text = text ++ " " ++ comptimePad(field.name, 22) ++ describeRootModule(exe.root_module) ++ "\n"; + } + } + } + + // Libraries + if (@hasField(@TypeOf(manifest), "libraries")) { + const fields = @typeInfo(@TypeOf(manifest.libraries)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nLibraries:" ++ comptimePad("", 12) ++ "zig build build-lib:\n"; + inline for (fields) |field| { + const lib = @field(manifest.libraries, field.name); + text = text ++ " " ++ comptimePad(field.name, 22) ++ describeRootModule(lib.root_module) ++ "\n"; + } + } + } + + // Objects + if (@hasField(@TypeOf(manifest), "objects")) { + const fields = @typeInfo(@TypeOf(manifest.objects)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nObjects:" ++ comptimePad("", 14) ++ "zig build build-obj:\n"; + inline for (fields) |field| { + const obj = @field(manifest.objects, field.name); + text = text ++ " " ++ comptimePad(field.name, 22) ++ describeRootModule(obj.root_module) ++ "\n"; + } + } + } + + // Tests + if (@hasField(@TypeOf(manifest), "tests")) { + const fields = @typeInfo(@TypeOf(manifest.tests)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nTests:" ++ comptimePad("", 16) ++ "zig build test: | zig build test\n"; + inline for (fields) |field| { + const t = @field(manifest.tests, field.name); + text = text ++ " " ++ comptimePad(field.name, 22) ++ describeRootModule(t.root_module) ++ "\n"; + } + } + } + + // Fmts + if (@hasField(@TypeOf(manifest), "fmts")) { + const fields = @typeInfo(@TypeOf(manifest.fmts)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nFmts:" ++ comptimePad("", 17) ++ "zig build fmt: | zig build fmt\n"; + inline for (fields) |field| { + const fmt = @field(manifest.fmts, field.name); + text = text ++ " " ++ comptimePad(field.name, 22); + if (@hasField(@TypeOf(fmt), "paths")) + text = text ++ "paths: " ++ comptimeJoinTuple(fmt.paths); + text = text ++ "\n"; + } + } + } + + // Runs + if (@hasField(@TypeOf(manifest), "runs")) { + const fields = @typeInfo(@TypeOf(manifest.runs)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nRuns:" ++ comptimePad("", 17) ++ "zig build cmd:\n"; + inline for (fields) |field| { + const run = @field(manifest.runs, field.name); + text = text ++ " " ++ comptimePad(field.name, 22) ++ describeRunCmd(run) ++ "\n"; + } + } + } + + // Options modules + if (@hasField(@TypeOf(manifest), "options_modules")) { + const mod_fields = @typeInfo(@TypeOf(manifest.options_modules)).@"struct".fields; + if (mod_fields.len > 0) { + text = text ++ "\nOptions:" ++ comptimePad("", 14) ++ "-D=\n"; + inline for (mod_fields) |mod_field| { + const options = @field(manifest.options_modules, mod_field.name); + inline for (@typeInfo(@TypeOf(options)).@"struct".fields) |opt_field| { + const opt = @field(options, opt_field.name); + text = text ++ " " ++ comptimePad(mod_field.name ++ "." ++ opt_field.name, 22); + text = text ++ opt.type; + if (@hasField(@TypeOf(opt), "default")) + text = text ++ " (default: " ++ describeValue(opt.default) ++ ")"; + if (@hasField(@TypeOf(opt), "description")) + text = text ++ " — " ++ opt.description; + text = text ++ "\n"; + } + } + } + } + + // Dependencies + if (@hasField(@TypeOf(manifest), "dependencies")) { + const fields = @typeInfo(@TypeOf(manifest.dependencies)).@"struct".fields; + if (fields.len > 0) { + text = text ++ "\nDependencies:\n"; + inline for (fields) |field| { + text = text ++ " " ++ field.name ++ "\n"; + } + } + } + + return text; +} + +fn comptimePad(comptime s: []const u8, comptime width: usize) []const u8 { + if (s.len >= width) return s ++ " "; + const padding = [1]u8{' '} ** (width - s.len); + return s ++ &padding; +} + +fn describeRootModule(comptime root_module: anytype) []const u8 { + const ti = @typeInfo(@TypeOf(root_module)); + if (ti == .enum_literal) return "module: " ++ @tagName(root_module); + if (ti == .pointer) return "module: " ++ @as([]const u8, root_module); + // Inline module struct + if (@hasField(@TypeOf(root_module), "root_source_file")) + return root_module.root_source_file; + return "(inline module)"; +} + +fn describeRunCmd(comptime cmd: anytype) []const u8 { + if (@hasField(@TypeOf(cmd), "cmd")) return comptimeJoinTuple(cmd.cmd); + return comptimeJoinTuple(cmd); +} + +fn comptimeJoinTuple(comptime tuple: anytype) []const u8 { + const fields = @typeInfo(@TypeOf(tuple)).@"struct".fields; + var result: []const u8 = ""; + inline for (fields, 0..) |field, i| { + if (i > 0) result = result ++ " "; + result = result ++ @field(tuple, field.name); + } + return result; +} + +fn describeValue(comptime val: anytype) []const u8 { + const ti = @typeInfo(@TypeOf(val)); + if (ti == .enum_literal) return @tagName(val); + if (ti == .pointer) return val; + if (ti == .bool) return if (val) "true" else "false"; + if (ti == .comptime_int or ti == .int) return std.fmt.comptimePrint("{d}", .{val}); + if (ti == .comptime_float or ti == .float) return std.fmt.comptimePrint("{d}", .{val}); + return "..."; +} + // --- BuildRunner --- const BuildRunner = struct { @@ -824,6 +1028,73 @@ test "toComptimeString" { try std.testing.expectEqualStrings("world", comptime toComptimeString(.world)); } +test "buildHelpText minimal" { + const text = comptime buildHelpText(.{ + .name = .myproject, + .version = "0.1.0", + }); + try std.testing.expect(std.mem.indexOf(u8, text, "myproject v0.1.0") != null); +} + +test "buildHelpText full manifest" { + const text = comptime buildHelpText(.{ + .name = .myproject, + .version = "1.0.0", + .description = "A test project", + .modules = .{ + .core = .{ .root_source_file = "src/core.zig" }, + }, + .executables = .{ + .myapp = .{ .root_module = .core }, + }, + .tests = .{ + .unit = .{ .root_module = .core }, + }, + .runs = .{ + .fmt = .{ "zig", "fmt", "src" }, + .deploy = .{ .cmd = .{ "./deploy.sh", "--prod" } }, + }, + .options_modules = .{ + .config = .{ + .verbose = .{ .type = "bool", .default = false, .description = "Verbose output" }, + }, + }, + .dependencies = .{ + .zlib = .{}, + }, + }); + try std.testing.expect(std.mem.indexOf(u8, text, "myproject v1.0.0 — A test project") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Modules:") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "core") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "src/core.zig") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Executables:") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "module: core") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Tests:") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Runs:") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "zig fmt src") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "./deploy.sh --prod") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Options:") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "config.verbose") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "bool") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "default: false") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Verbose output") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Dependencies:") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "zlib") != null); +} + +test "comptimePad" { + try std.testing.expectEqualStrings("hi ", comptime comptimePad("hi", 6)); + try std.testing.expectEqualStrings("toolong ", comptime comptimePad("toolong", 4)); +} + +test "describeValue" { + try std.testing.expectEqualStrings("true", comptime describeValue(true)); + try std.testing.expectEqualStrings("false", comptime describeValue(false)); + try std.testing.expectEqualStrings("hello", comptime describeValue("hello")); + try std.testing.expectEqualStrings("info", comptime describeValue(.info)); + try std.testing.expectEqualStrings("42", comptime describeValue(42)); +} + test "validateManifest accepts minimal manifest" { comptime validateManifest(.{ .name = .myproject, diff --git a/src/main.zig b/src/main.zig index 46b3c86..0f1b08f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,3 +12,4 @@ pub const build_runner = @import("build_runner.zig"); pub const configureBuild = build_runner.configureBuild; +pub const Options = build_runner.Options; From 4806866726224ccacc16dccf8b4dc78115c68ec7 Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:28:44 -0400 Subject: [PATCH 24/62] docs: add documentation overhaul design spec B+C hybrid approach: README as landing page, docs/ for schema reference and motivation, examples/ as compilable annotated projects. Nukes all stale docs from the CLI tool era. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-16-docs-overhaul-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-docs-overhaul-design.md diff --git a/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md b/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md new file mode 100644 index 0000000..558dda6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md @@ -0,0 +1,158 @@ +# Documentation Overhaul Design + +## Problem + +All existing documentation describes the old zbuild (a CLI tool that generated `build.zig` from `zbuild.zon`). The project has been rewritten as a library using `@import("build.zig.zon")` + comptime metaprogramming. Every doc file is stale. There are no examples. There is no schema reference. A user cannot learn how to use zbuild from the existing docs. + +## Audience + +- **Beginners:** Want a simpler build experience. Need a quickstart and copy-paste examples. +- **Experienced Zig developers:** Know `build.zig` well. Need a schema reference, the rationale, and confidence they can escape back to manual code. + +## Approach + +B + C hybrid: README as landing page, `docs/` for reference material, `examples/` as compilable annotated projects that double as documentation and integration tests. + +## File Structure + +### Delete + +- `docs/MOTIVATION.md` — stale (references CLI tool) +- `docs/TODO.md` — stale (references old parser/Config.zig) +- `docs/AdvancedFeatures.md` — documents dropped `write_files` feature +- `docs/STRUCTURAL_ISSUES.md` — documents bugs in deleted code +- `docs/superpowers/` — internal working documents, not user-facing + +### Create + +``` +README.md ← rewrite from scratch +docs/ + schema.md ← complete ZON schema reference + motivation.md ← why zbuild, as a library +examples/ + simple/ + build.zig ← 5-line zbuild integration + build.zig.zon ← minimal: one executable + src/main.zig ← hello world + full/ + build.zig ← zbuild with custom Options + build.zig.zon ← modules, options, tests, runs, fmts + src/main.zig ← uses the module + src/lib.zig ← a module with a function + src/test.zig ← test that imports the module +``` + +### Keep unchanged + +- `build.zig`, `build.zig.zon`, `src/` — zbuild's own build + +--- + +## README.md + +Target: ~120 lines. A user decides whether to adopt zbuild within 30 seconds. + +### Structure + +1. **One-liner:** "Declarative build configuration for Zig projects." +2. **The pitch** (3-4 sentences): What it is, the key insight (`@import("build.zig.zon")` + comptime), the escape hatch (works alongside manual `build.zig`). +3. **Before/after:** 25 lines of `build.zig` vs 10 lines of ZON. The money shot. +4. **Quickstart:** Add zbuild as a dependency, write the 5-line `build.zig`, add fields to `build.zig.zon`, `zig build`. No CLI install — it's just a Zig dependency. +5. **Feature list:** Bullet points — modules, executables, libraries, tests, fmts, runs, options modules, dependency args, comptime validation, built-in help step. +6. **Links:** `docs/schema.md` for full reference, `examples/` for working projects. +7. **Requirements:** Zig 0.14+ +8. **License:** MIT + +No contributing section (repo URL not finalized). No installation section (it's a library dependency, not a binary). + +--- + +## docs/schema.md + +Target: ~300 lines. The complete reference. An experienced dev looks up any field and knows exactly what it does, what it maps to in `std.Build`, and the default. + +### Structure + +1. **Intro:** One paragraph — complete reference for zbuild's manifest fields. Unknown fields are silently ignored (forward compat). + +2. **Section-by-section reference** with field tables per manifest section: + + **`modules`** — `root_source_file`, `target`, `optimize`, `imports`, `link_libraries`, `include_paths`, `private`, all passthrough fields (`link_libc`, `link_libcpp`, `single_threaded`, `strip`, `unwind_tables`, `dwarf_format`, `code_model`, `error_tracing`, `omit_frame_pointer`, `pic`, `red_zone`, `sanitize_c`, `sanitize_thread`, `stack_check`, `stack_protector`, `fuzz`, `valgrind`). Root module link syntax (enum literal, string, inline struct). Colon syntax for `link_libraries`. + + **`executables`** — `root_module` (three forms), `version`, `linkage`, `dest_sub_path`, `depends_on`, passthrough fields (`max_rss`, `use_llvm`, `use_lld`), `zig_lib_dir`, `win32_manifest`. Steps: `build-exe:`, `run:`. + + **`libraries`** — Same as executables plus `linker_allow_shlib_undefined`. Steps: `build-lib:`. + + **`objects`** — Simpler subset (no version/linkage/dest_sub_path). Steps: `build-obj:`. + + **`tests`** — `root_module`, `filters`. Steps: `test:`, aggregate `test`. CLI override: `-D.filters=...`. + + **`fmts`** — `paths`, `exclude_paths`, `check`. Steps: `fmt:`, aggregate `fmt`. + + **`runs`** — Dual-form syntax. Short form: bare tuple of strings. Long form: struct with `cmd`, `cwd`, `env`, `inherit_stdio`, `stdin`, `stdin_file`, `depends_on`. Steps: `cmd:`. + + **`options_modules`** — Types: `bool`, `string`, `list`, `enum`, `enum_list`, int types, float types. Fields: `type`, `default`, `description`. How to `@import` the resulting module. + + **`dependencies`** — `args` field for forwarding comptime dependency args. + +3. **LazyPath resolution** — Colon syntax (`dep:path`, `dep:writefile:path`). How `resolveLazyPath` dispatches. + +4. **Comptime validation** — What gets checked (root_module refs, depends_on refs, import refs, stdin/stdin_file mutual exclusion). What doesn't (unknown fields silently ignored). + +### Field table format + +| Field | Type | Default | Maps to | +|-------|------|---------|---------| +| `root_source_file` | string | — | `Module.CreateOptions.root_source_file` | + +--- + +## docs/motivation.md + +Target: ~80 lines. Readable in 2 minutes. + +### Structure + +1. **The problem** (5-6 lines): `build.zig` is powerful but verbose. Common patterns require 20+ lines per target. Scales poorly, intimidating for newcomers. +2. **The insight** (3-4 lines): `@import("build.zig.zon")` + comptime. Compiler is the parser, type system is the schema, `@compileError` is validation. Zero runtime parsing. +3. **Before/after:** Side-by-side comparison with commentary. +4. **What zbuild is NOT:** Not a replacement for `build.zig`. Handles the declarative 90%. Mix with manual code. Escape hatch always available. +5. **Inspiration:** Cargo, npm. Unlike those, zbuild rides on top of the build system rather than replacing it. + +--- + +## examples/simple/ + +Minimum viable zbuild project. One executable, no dependencies, no modules. + +- **`build.zig.zon`:** Project metadata, one executable with inline root_module, zbuild as path dependency (`../..`). +- **`build.zig`:** Import zbuild, call `configureBuild(b, @import("build.zig.zon"), .{})`, error handler. +- **`src/main.zig`:** Hello world. + +No comments explaining comptime mechanics. Just the smallest thing that works. + +Must compile with `zig build` from the example directory. + +## examples/full/ + +Showcases every zbuild feature. Heavily commented ZON. + +- **`build.zig.zon`:** modules (with imports, link_libc), executables (referencing named module), libraries, tests, fmts, runs (short + long form), options_modules. zbuild as path dependency. +- **`build.zig`:** zbuild with custom `Options` (renamed help step). +- **`src/lib.zig`:** Module with an exported function. +- **`src/main.zig`:** Imports the module. +- **`src/test.zig`:** Test that imports the module. + +Dependencies section omitted from compilable code (no real URL). Shown as a commented example explaining "uncomment when you have a real dep." + +Must compile with `zig build` from the example directory. + +--- + +## Constraints + +- Both examples use `zbuild` as a `.path = "../.."` dependency so they compile from the repo checkout. +- Examples must actually compile. They serve as integration tests. +- Schema reference is derived from the actual code in `build_runner.zig`. If the code changes, the schema doc must be updated. +- Unknown manifest fields are silently ignored for forward compatibility. The schema doc only documents zbuild-recognized fields. From 1641c16afe6fe4a5589721b8c3102f653f8b7edb Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:30:51 -0400 Subject: [PATCH 25/62] docs: fix spec review issues in docs overhaul design Add missing fields: zig_lib_dir/win32_manifest for libraries, zig_lib_dir for objects, passthrough/zig_lib_dir for tests, build-test step. Clarify target/optimize value types, link_libraries vs LazyPath colon syntax, LazyPath three-part form, inline module name override, help step metadata. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-16-docs-overhaul-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md b/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md index 558dda6..c82f5b4 100644 --- a/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md +++ b/docs/superpowers/specs/2026-03-16-docs-overhaul-design.md @@ -59,7 +59,7 @@ Target: ~120 lines. A user decides whether to adopt zbuild within 30 seconds. 2. **The pitch** (3-4 sentences): What it is, the key insight (`@import("build.zig.zon")` + comptime), the escape hatch (works alongside manual `build.zig`). 3. **Before/after:** 25 lines of `build.zig` vs 10 lines of ZON. The money shot. 4. **Quickstart:** Add zbuild as a dependency, write the 5-line `build.zig`, add fields to `build.zig.zon`, `zig build`. No CLI install — it's just a Zig dependency. -5. **Feature list:** Bullet points — modules, executables, libraries, tests, fmts, runs, options modules, dependency args, comptime validation, built-in help step. +5. **Feature list:** Bullet points — modules, executables, libraries, tests, fmts, runs, options modules, dependency args, comptime validation, built-in help step (reads `name`, `version`, `description` from standard ZON metadata). 6. **Links:** `docs/schema.md` for full reference, `examples/` for working projects. 7. **Requirements:** Zig 0.14+ 8. **License:** MIT @@ -78,15 +78,15 @@ Target: ~300 lines. The complete reference. An experienced dev looks up any fiel 2. **Section-by-section reference** with field tables per manifest section: - **`modules`** — `root_source_file`, `target`, `optimize`, `imports`, `link_libraries`, `include_paths`, `private`, all passthrough fields (`link_libc`, `link_libcpp`, `single_threaded`, `strip`, `unwind_tables`, `dwarf_format`, `code_model`, `error_tracing`, `omit_frame_pointer`, `pic`, `red_zone`, `sanitize_c`, `sanitize_thread`, `stack_check`, `stack_protector`, `fuzz`, `valgrind`). Root module link syntax (enum literal, string, inline struct). Colon syntax for `link_libraries`. + **`modules`** — `root_source_file`, `target` (string: `"native"` or arch-os-abi triple like `"x86_64-linux-gnu"`), `optimize` (enum literal: `.Debug`, `.ReleaseSafe`, `.ReleaseFast`, `.ReleaseSmall`), `imports`, `link_libraries`, `include_paths`, `private`, all passthrough fields (`link_libc`, `link_libcpp`, `single_threaded`, `strip`, `unwind_tables`, `dwarf_format`, `code_model`, `error_tracing`, `omit_frame_pointer`, `pic`, `red_zone`, `sanitize_c`, `sanitize_thread`, `stack_check`, `stack_protector`, `fuzz`, `valgrind`). Root module link syntax (enum literal, string, inline struct with optional `name` override). `link_libraries` colon syntax: `"dep_name:artifact_name"` (resolves a library artifact from a dependency; distinct from LazyPath resolution). - **`executables`** — `root_module` (three forms), `version`, `linkage`, `dest_sub_path`, `depends_on`, passthrough fields (`max_rss`, `use_llvm`, `use_lld`), `zig_lib_dir`, `win32_manifest`. Steps: `build-exe:`, `run:`. + **`executables`** — `root_module` (three forms: enum literal reference, string reference, inline struct with optional `name` override), `version`, `linkage`, `dest_sub_path`, `depends_on`, passthrough fields (`max_rss`, `use_llvm`, `use_lld`), `zig_lib_dir`, `win32_manifest`. Steps: `build-exe:`, `run:`. - **`libraries`** — Same as executables plus `linker_allow_shlib_undefined`. Steps: `build-lib:`. + **`libraries`** — Same as executables plus `linker_allow_shlib_undefined`, `zig_lib_dir`, `win32_manifest`. Steps: `build-lib:`. - **`objects`** — Simpler subset (no version/linkage/dest_sub_path). Steps: `build-obj:`. + **`objects`** — Simpler subset (no version/linkage/dest_sub_path/win32_manifest). Supports `zig_lib_dir` and passthrough fields. Steps: `build-obj:`. - **`tests`** — `root_module`, `filters`. Steps: `test:`, aggregate `test`. CLI override: `-D.filters=...`. + **`tests`** — `root_module`, `filters`, passthrough fields (`max_rss`, `use_llvm`, `use_lld`), `zig_lib_dir`. Steps: `test:`, `build-test:`, aggregate `test`. CLI override: `-D.filters=...`. **`fmts`** — `paths`, `exclude_paths`, `check`. Steps: `fmt:`, aggregate `fmt`. @@ -96,7 +96,7 @@ Target: ~300 lines. The complete reference. An experienced dev looks up any fiel **`dependencies`** — `args` field for forwarding comptime dependency args. -3. **LazyPath resolution** — Colon syntax (`dep:path`, `dep:writefile:path`). How `resolveLazyPath` dispatches. +3. **LazyPath resolution** — Colon syntax for path strings: `"path"` (local), `"dep:path"` (named lazy path from dependency), `"dep::"` (file within a dependency's named WriteFiles step). How `resolveLazyPath` dispatches. 4. **Comptime validation** — What gets checked (root_module refs, depends_on refs, import refs, stdin/stdin_file mutual exclusion). What doesn't (unknown fields silently ignored). From 256f23e45670e7dceda69b07e13cd515dafe1050 Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:38:18 -0400 Subject: [PATCH 26/62] docs: add documentation overhaul implementation plan 7 tasks: delete stale docs, write README/motivation/schema, create simple and full compilable examples, delete superpowers working docs. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-16-docs-overhaul.md | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-docs-overhaul.md diff --git a/docs/superpowers/plans/2026-03-16-docs-overhaul.md b/docs/superpowers/plans/2026-03-16-docs-overhaul.md new file mode 100644 index 0000000..885c5f5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-docs-overhaul.md @@ -0,0 +1,538 @@ +# Documentation Overhaul Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace all stale documentation with accurate docs for the library-based zbuild, including a README, schema reference, motivation doc, and two compilable example projects. + +**Architecture:** Delete everything from the CLI tool era. Write new docs from scratch. Ship two examples (`simple/` and `full/`) that compile against zbuild as a path dependency — these serve as both documentation and integration tests. + +**Tech Stack:** Markdown, Zig 0.14, ZON + +--- + +## Chunk 1: Cleanup and core docs + +### Task 1: Delete stale documentation + +**Files:** +- Delete: `docs/MOTIVATION.md` +- Delete: `docs/TODO.md` +- Delete: `docs/AdvancedFeatures.md` +- Delete: `docs/STRUCTURAL_ISSUES.md` + +**Note:** `docs/superpowers/` deletion is deferred to the final task since it contains this plan and the spec. + +- [ ] **Step 1: Remove stale doc files** + +```bash +rm docs/MOTIVATION.md docs/TODO.md docs/AdvancedFeatures.md docs/STRUCTURAL_ISSUES.md +``` + +- [ ] **Step 2: Commit** + +```bash +git add -u +git commit -m "docs: remove stale documentation from CLI tool era" +``` + +### Task 2: Write README.md + +**Files:** +- Create: `README.md` (rewrite from scratch) + +- [ ] **Step 1: Write the README** + +The README must contain these sections in order: + +1. **Title + one-liner**: "zbuild" / "Declarative build configuration for Zig projects." +2. **Pitch** (3-4 sentences): Library that configures `std.Build` from your `build.zig.zon` at comptime. Key insight: `@import("build.zig.zon")` gives the compiler access to the manifest as a typed struct — no runtime parsing, no codegen, no IR. Works alongside manual `build.zig` code — the escape hatch is always there. +3. **Before/after code comparison**: Show ~25 lines of manual `build.zig` (add module, add executable, install, run step, test) vs the equivalent ZON (~10 lines) + 5-line build.zig. Use the same example from `docs/motivation.md` but keep it concise. +4. **Quickstart** (numbered steps): + - Add zbuild as a dependency: `zig fetch --save=zbuild ` (or path dep for local) + - Create `build.zig`: + ```zig + const zbuild = @import("zbuild"); + const std = @import("std"); + pub fn build(b: *std.Build) void { + zbuild.configureBuild(b, @import("build.zig.zon"), .{}) catch |err| + std.log.err("zbuild: {}", .{err}); + } + ``` + - Add zbuild fields to `build.zig.zon` (show minimal executable example) + - `zig build`, `zig build run:`, `zig build test`, `zig build help` +5. **Features** (bullet list): modules, executables, libraries, objects, tests, fmts, runs (short + long form), options modules, dependency args forwarding, comptime cross-reference validation, built-in help step +6. **Links**: `docs/schema.md`, `docs/motivation.md`, `examples/` +7. **Requirements**: Zig 0.14+ +8. **License**: MIT + +Target: ~120 lines total. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: rewrite README for library-based zbuild" +``` + +### Task 3: Write docs/motivation.md + +**Files:** +- Create: `docs/motivation.md` + +- [ ] **Step 1: Write the motivation doc** + +Sections: + +1. **The problem** (~6 lines): Zig's `build.zig` is powerful but verbose. Adding one executable with install + run + test requires ~25 lines of boilerplate. Multiply by N targets and the build script becomes a maintenance burden. Newcomers face a steep learning curve. + +2. **The insight** (~4 lines): Zig 0.14 added `@import("build.zig.zon")`, which gives comptime access to the manifest as a typed anonymous struct. This means: the compiler is the parser, the type system is the schema, `@compileError` is the validation framework, and `inline for` over struct fields generates specialized code per manifest entry. Zero runtime parsing, zero codegen. + +3. **Before/after**: Full side-by-side showing manual `build.zig` (~25 lines for one exe + test) vs ZON manifest (~12 lines) + 5-line `build.zig`. Brief commentary: "zbuild eliminates the repetitive wiring. You declare what you want; the compiler generates the build graph." + +4. **What zbuild is NOT** (~4 lines): Not a replacement for `build.zig`. It handles the declarative 90% — the static build graph. For conditional logic, platform-specific targets, or custom build steps, write that code in `build.zig` alongside the `configureBuild` call. The escape hatch is always there. + +5. **Inspiration** (~3 lines): Cargo (`Cargo.toml`), npm (`package.json`). Unlike those, zbuild doesn't replace the build system — it rides on top of Zig's native build system. + +Target: ~80 lines. + +- [ ] **Step 2: Commit** + +```bash +git add docs/motivation.md +git commit -m "docs: add motivation doc explaining library approach" +``` + +### Task 4: Write docs/schema.md + +**Files:** +- Create: `docs/schema.md` + +- [ ] **Step 1: Write the schema reference** + +This is the largest doc. Structure: + +**Intro paragraph**: Complete reference for zbuild manifest fields added to `build.zig.zon`. Standard Zig fields (`name`, `version`, `fingerprint`, `minimum_zig_version`, `paths`, `description`, `dependencies`) are passed through to the Zig build system as normal. Fields not recognized by zbuild are silently ignored for forward compatibility. + +**Sections** (one heading per manifest section, each with a field table): + +**`modules`**: Reusable code units registered with the build system. + +| Field | Type | Default | Maps to | +|-------|------|---------|---------| +| `root_source_file` | string | — | `Module.CreateOptions.root_source_file` | +| `target` | string | host target | `"native"` or arch-os-abi triple (e.g. `"x86_64-linux-gnu"`) | +| `optimize` | enum literal | project default | `.Debug`, `.ReleaseSafe`, `.ReleaseFast`, `.ReleaseSmall` | +| `imports` | tuple of enum literals/strings | — | `module.addImport()` per entry | +| `link_libraries` | tuple of strings | — | `module.linkLibrary()` — format: `"dep_name"` or `"dep_name:artifact_name"` | +| `include_paths` | tuple of strings | — | `module.addIncludePath()` per entry | +| `private` | bool | `false` | When `true`, module is not exported to `b.modules` | +| `link_libc` | bool | omit | `Module.CreateOptions.link_libc` | +| *(16 more passthrough fields)* | bool/enum | omit | Direct passthrough to `Module.CreateOptions` | + +Document the three root_module forms: enum literal (`.mymod` — references a named module), string (`"mymod"` — same), inline struct (full module definition with optional `name` override). + +**`executables`**: Build targets that produce executable binaries. + +| Field | Type | Default | Maps to | +|-------|------|---------|---------| +| `root_module` | ref or struct | required | See root_module forms above | +| `version` | string | — | Parsed as `SemanticVersion` | +| `linkage` | enum literal | — | `.static` or `.dynamic` | +| `dest_sub_path` | string | — | `InstallArtifact.Options.dest_sub_path` | +| `depends_on` | tuple | — | Step ordering against other artifacts | +| `max_rss`, `use_llvm`, `use_lld` | bool/int | omit | Passthrough | +| `zig_lib_dir` | string | — | LazyPath resolved | +| `win32_manifest` | string | — | LazyPath resolved | + +Steps created: `build-exe:`, `run:`. + +**`libraries`**: Same fields as executables, plus `linker_allow_shlib_undefined`. Steps: `build-lib:`. + +**`objects`**: Subset — `root_module`, passthrough fields, `zig_lib_dir`. No version/linkage/dest_sub_path/win32_manifest. Steps: `build-obj:`. + +**`tests`**: `root_module`, `filters` (tuple of strings), passthrough fields, `zig_lib_dir`. Steps: `test:`, `build-test:`, aggregate `test`. CLI: `-D.filters=...` overrides manifest filters. + +**`fmts`**: + +| Field | Type | Default | Maps to | +|-------|------|---------|---------| +| `paths` | tuple of strings | `&.{}` | `addFmt(.paths)` | +| `exclude_paths` | tuple of strings | `&.{}` | `addFmt(.exclude_paths)` | +| `check` | bool | `false` | `addFmt(.check)` | + +Steps: `fmt:`, aggregate `fmt`. + +**`runs`**: Dual-form syntax. + +Short form: `.myrun = .{ "cmd", "arg1", "arg2" }` — bare tuple → `addSystemCommand`. + +Long form: + +| Field | Type | Default | Maps to | +|-------|------|---------|---------| +| `cmd` | tuple of strings | required | `addSystemCommand` args | +| `cwd` | string | inherit | `run.setCwd()` — LazyPath resolved | +| `env` | struct | inherit | `run.setEnvironmentVariable()` per field | +| `inherit_stdio` | bool | `false` | `run.stdio = .inherit` when true | +| `stdin` | string | — | `run.setStdIn(.{ .bytes = ... })` | +| `stdin_file` | string | — | `run.setStdIn(.{ .lazy_path = ... })` — mutually exclusive with `stdin` | +| `depends_on` | tuple | — | Step ordering against artifacts | + +Steps: `cmd:`. + +**`options_modules`**: Configurable build options exposed as importable modules. + +| Type string | Zig type | Default type | +|-------------|----------|--------------| +| `"bool"` | `bool` | `bool` | +| `"string"` | `[]const u8` | `[]const u8` | +| `"list"` | `[]const []const u8` | tuple of strings | +| `"enum"` | `[]const u8` | enum literal | +| `"enum_list"` | `[]const []const u8` | tuple of enum literals | +| `"i32"`, `"u64"`, etc. | corresponding int | int | +| `"f32"`, `"f64"`, etc. | corresponding float | float | + +Each option: `{ .type = "bool", .default = true, .description = "Enable feature" }`. Access in Zig: `const config = @import("config");` then `config.enable_feature`. + +**`dependencies`**: The `args` field forwards comptime arguments to `b.dependency()`. Example: `.mydep = .{ .args = .{ .enable_foo = true } }`. + +**LazyPath resolution**: String paths in fields like `root_source_file`, `cwd`, `zig_lib_dir` are resolved via colon syntax: +- `"src/main.zig"` — local file path +- `"dep:path"` — named lazy path from dependency `dep` +- `"dep:wf_name:path"` — file `path` within dependency `dep`'s named WriteFiles step `wf_name` + +**Comptime validation**: zbuild validates cross-references at compile time: +- `root_module` enum/string refs must point to a declared module +- `depends_on` refs must point to a declared artifact +- `imports` refs must point to a module, options_module, or dependency +- `stdin` + `stdin_file` on the same run → `@compileError` +- Unknown fields are silently ignored (forward compatibility) + +Target: ~300 lines. + +- [ ] **Step 2: Commit** + +```bash +git add docs/schema.md +git commit -m "docs: add complete ZON schema reference" +``` + +--- + +## Chunk 2: Examples + +### Task 5: Create examples/simple/ + +**Files:** +- Create: `examples/simple/build.zig.zon` +- Create: `examples/simple/build.zig` +- Create: `examples/simple/src/main.zig` + +- [ ] **Step 1: Write build.zig.zon** + +```zig +.{ + .name = .simple_example, + .version = "0.1.0", + .fingerprint = 0xaabbccdd00112233, + .minimum_zig_version = "0.14.0", + .paths = .{ "build.zig", "build.zig.zon", "src" }, + .description = "A minimal zbuild example", + .dependencies = .{ + .zbuild = .{ .path = "../.." }, + }, + .executables = .{ + .hello = .{ + .root_module = .{ + .root_source_file = "src/main.zig", + }, + }, + }, +} +``` + +- [ ] **Step 2: Write build.zig** + +```zig +const zbuild = @import("zbuild"); +const std = @import("std"); + +pub fn build(b: *std.Build) void { + zbuild.configureBuild(b, @import("build.zig.zon"), .{}) catch |err| + std.log.err("zbuild: {}", .{err}); +} +``` + +- [ ] **Step 3: Write src/main.zig** + +```zig +const std = @import("std"); + +pub fn main() void { + std.debug.print("Hello from zbuild!\n", .{}); +} +``` + +- [ ] **Step 4: Verify it compiles** + +```bash +cd examples/simple && zig build +``` + +Expected: builds successfully, produces `zig-out/bin/hello`. + +- [ ] **Step 5: Verify it runs** + +```bash +cd examples/simple && zig build run:hello +``` + +Expected: prints "Hello from zbuild!" + +- [ ] **Step 6: Verify help step works** + +```bash +cd examples/simple && zig build help +``` + +Expected: prints project info including "simple_example v0.1.0" + +- [ ] **Step 7: Commit** + +```bash +git add examples/simple/ +git commit -m "docs: add simple example project" +``` + +### Task 6: Create examples/full/ + +**Files:** +- Create: `examples/full/build.zig.zon` +- Create: `examples/full/build.zig` +- Create: `examples/full/src/lib.zig` +- Create: `examples/full/src/main.zig` +- Create: `examples/full/src/test.zig` + +- [ ] **Step 1: Write src/lib.zig** + +```zig +/// A simple math module to demonstrate zbuild's module system. +pub fn add(a: i32, b: i32) i32 { + return a + b; +} + +pub fn multiply(a: i32, b: i32) i32 { + return a * b; +} +``` + +- [ ] **Step 2: Write src/main.zig** + +```zig +const std = @import("std"); +const math = @import("math"); +const config = @import("config"); + +pub fn main() void { + const result = math.add(2, 3); + std.debug.print("2 + 3 = {d}\n", .{result}); + if (config.verbose) + std.debug.print("(verbose mode enabled)\n", .{}); +} +``` + +- [ ] **Step 3: Write src/test.zig** + +```zig +const std = @import("std"); +const math = @import("math"); + +test "add" { + try std.testing.expectEqual(@as(i32, 5), math.add(2, 3)); + try std.testing.expectEqual(@as(i32, 0), math.add(-1, 1)); +} + +test "multiply" { + try std.testing.expectEqual(@as(i32, 6), math.multiply(2, 3)); + try std.testing.expectEqual(@as(i32, 0), math.multiply(0, 42)); +} +``` + +- [ ] **Step 4: Write build.zig.zon** + +```zig +.{ + .name = .full_example, + .version = "1.0.0", + .fingerprint = 0x1122334455667788, + .minimum_zig_version = "0.14.0", + .paths = .{ "build.zig", "build.zig.zon", "src" }, + .description = "A comprehensive zbuild example showcasing all features", + + .dependencies = .{ + .zbuild = .{ .path = "../.." }, + // To add an external dependency: + // .zlib = .{ + // .url = "https://github.com/example/zlib-zig/archive/v1.0.0.tar.gz", + // .hash = "...", + // .args = .{ .shared = true }, // forwarded to b.dependency() at comptime + // }, + }, + + // --- Modules: reusable code units --- + // Modules are registered with the build system and can be referenced + // by name from executables, libraries, and tests via root_module. + .modules = .{ + .math = .{ + .root_source_file = "src/lib.zig", + }, + }, + + // --- Executables --- + // root_module can be an enum literal (.math) referencing a named module, + // a string ("math"), or an inline struct with a full module definition. + .executables = .{ + .demo = .{ + .root_module = .{ + .root_source_file = "src/main.zig", + .imports = .{ .math, .config }, + }, + }, + }, + + // --- Libraries --- + .libraries = .{ + .mathlib = .{ + .root_module = .math, + }, + }, + + // --- Tests --- + // Each test gets a test: step and joins the aggregate "test" step. + // Use -D.filters=... to filter specific tests from the CLI. + .tests = .{ + .unit = .{ + .root_module = .{ + .root_source_file = "src/test.zig", + .imports = .{.math}, + }, + }, + }, + + // --- Fmts --- + // Wraps zig fmt. Each entry gets fmt: and joins aggregate "fmt". + .fmts = .{ + .src = .{ + .paths = .{"src"}, + }, + }, + + // --- Runs --- + // Short form: bare tuple of strings. + // Long form: struct with cmd + optional cwd, env, depends_on, etc. + .runs = .{ + // Short form: bare tuple of strings + .@"echo-version" = .{ "echo", "full_example v1.0.0" }, + // Long form: struct with cmd + options + .greet = .{ + .cmd = .{ "echo", "hello from zbuild" }, + .env = .{ .GREETING = "hello" }, + .inherit_stdio = true, + }, + }, + + // --- Options modules --- + // Creates an importable module with build-time options. + // Access in Zig: const config = @import("config"); + .options_modules = .{ + .config = .{ + .verbose = .{ + .type = "bool", + .default = false, + .description = "Enable verbose output", + }, + }, + }, +} +``` + +- [ ] **Step 5: Write build.zig** + +```zig +const zbuild = @import("zbuild"); +const std = @import("std"); + +pub fn build(b: *std.Build) void { + zbuild.configureBuild(b, @import("build.zig.zon"), .{ + .help_step = "info", + }) catch |err| + std.log.err("zbuild: {}", .{err}); +} +``` + +- [ ] **Step 6: Verify it compiles** + +```bash +cd examples/full && zig build +``` + +Expected: builds successfully. + +- [ ] **Step 7: Verify executable runs** + +```bash +cd examples/full && zig build run:demo +``` + +Expected: prints "2 + 3 = 5" + +- [ ] **Step 8: Verify tests pass** + +```bash +cd examples/full && zig build test +``` + +Expected: tests pass. + +- [ ] **Step 9: Verify custom help step** + +```bash +cd examples/full && zig build info +``` + +Expected: prints project info with "full_example v1.0.0", lists modules, executables, libraries, tests, runs, options, etc. + +- [ ] **Step 10: Verify runs** + +```bash +cd examples/full && zig build cmd:echo-version +``` + +Expected: prints "full_example v1.0.0" + +- [ ] **Step 11: Commit** + +```bash +git add examples/full/ +git commit -m "docs: add full example project showcasing all features" +``` + +### Task 7: Delete docs/superpowers/ + +**Files:** +- Delete: `docs/superpowers/` (entire directory — specs and plans from development) + +**Note:** This is last because it contains the plan and spec used during implementation. + +- [ ] **Step 1: Remove the directory** + +```bash +rm -rf docs/superpowers/ +``` + +- [ ] **Step 2: Commit** + +```bash +git add -u +git commit -m "docs: remove internal superpowers working documents" +``` From 117c3a005d330c3a09056b9a9a3659fea31cfb78 Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:43:39 -0400 Subject: [PATCH 27/62] docs: remove stale documentation from CLI tool era MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete MOTIVATION.md, TODO.md, AdvancedFeatures.md, and STRUCTURAL_ISSUES.md — all describe the old CLI tool architecture that no longer exists. Co-Authored-By: Claude Opus 4.6 --- docs/AdvancedFeatures.md | 102 -------------- docs/MOTIVATION.md | 145 -------------------- docs/STRUCTURAL_ISSUES.md | 282 -------------------------------------- docs/TODO.md | 22 --- 4 files changed, 551 deletions(-) delete mode 100644 docs/AdvancedFeatures.md delete mode 100644 docs/MOTIVATION.md delete mode 100644 docs/STRUCTURAL_ISSUES.md delete mode 100644 docs/TODO.md diff --git a/docs/AdvancedFeatures.md b/docs/AdvancedFeatures.md deleted file mode 100644 index a0fd193..0000000 --- a/docs/AdvancedFeatures.md +++ /dev/null @@ -1,102 +0,0 @@ -# Advanced Features - -## WriteFiles: Managing Generated Files - -The `write_files` feature in `zbuild` allows you to define sets of files or directories to be copied or generated during the build process. This is particularly useful for managing assets, configuration files, or generated code that your project depends on. The `write_files` section in `zbuild.zon` lets you specify these resources declaratively, and `zbuild` will generate the corresponding `build.zig` code to handle them. - -### Configuration -The `write_files` field is an object where each key is a named set of files, and the value defines the files or directories to include. Each entry can be marked as `private` (accessible only within the build script) or public (installable as part of the build output). The `items` field specifies the source paths and their destinations. -Here’s an example `zbuild.zon` snippet: - -```zon -.{ - .name = .myproject, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{ "build.zig", "build.zig.zon", "src" }, - .write_files = .{ - .assets = .{ - .items = .{ - .@"logo.png" = .{ - .type = "file", - .path = "resources/logo.png", - }, - .config = .{ - .type = "dir", - .path = "resources/config", - .exclude_extensions = .{".tmp"}, - }, - }, - }, - }, - .executables = .{ - .myapp = .{ - .root_module = .{ - .root_source_file = "src/main.zig", - }, - }, - }, -} -``` - -#### How It Works -- `private`: If `true`, the files are added via `b.addWriteFiles()`, making them available only within the build script. If `false` or omitted, `b.addNamedWriteFiles()` is used, and the files are installable. -- `items`: A map of destination paths to source specifications: - - `file`: Copies a single file (e.g., `resources/logo.png` to `logo.png` in the write files directory). - - - `dir`: Copies a directory, with optional `exclude_extensions` or `include_extensions` filters. - -In the generated `build.zig`, this translates to: - -```zig -const write_files_assets = b.addNamedWriteFiles(); - -... - -_ = write_files_assets.addCopyFile(b.path("resources/logo.png"), "logo.png"); -_ = write_files_assets.addCopyDirectory(b.path("resources/config"), "config", .{ .exclude_extensions = &[_][]const u8{"tmp"} }); -``` - -## LazyPath Resolution: Flexible File References -`LazyPath` resolution enhances `zbuild` by allowing you to reference files dynamically across different sources (e.g., project files, `write_files`, `dependencies`, or `options`) using a colon-delimited syntax. This feature provides a flexible way to manage file paths without hardcoding them, making your build configuration more portable and maintainable. - -### Syntax -Paths in zbuild.zon can use the following format: -- Simple Path: `src/main.zig` – A direct filesystem path relative to the project root. -- Prefixed Path: `[:]` – Specifies the source of the path, such as the name of a write_files, dependency, or option. - -Supported sources include: -- writefiles: `:`: References a file or directory from a `write_files` set. -- dependency named lazy path: `:`: References a file from a dependency’s named lazy paths. -- dependency named writefiles: `::`: References a file from a dependency’s named write files. -- options: ``: References a `LazyPath` option value from the `options` section. - -### Configuration Example -Here’s an extended `zbuild.zon` showcasing `LazyPath` resolution: - -```zon -.{ - .name = .myproject, - .version = "0.1.0", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{ "build.zig", "build.zig.zon", "src" }, - .dependencies = .{ - .bar = .{ - .url = "git+https://github.com/org/bar", - }, - }, - .modules = .{ - .foo = .{ - .root_source_file = "bar:some_output", - }, - }, -} -``` - -#### How It Works -- `bar:some_output`: Resolves to the named lazy file `some_output` from the `bar` dependency. - -### Usage -- Extensibility: The `LazyPath` system supports additional sources like dependencies or options, making it adaptable to complex workflows. diff --git a/docs/MOTIVATION.md b/docs/MOTIVATION.md deleted file mode 100644 index e9b1d5f..0000000 --- a/docs/MOTIVATION.md +++ /dev/null @@ -1,145 +0,0 @@ -# Motivation for zbuild - -## Why zbuild? - -The Zig programming language offers a powerful and flexible build system integrated directly into its toolchain via `build.zig` files. This system, while highly customizable and programmatic, can become complex and verbose for larger projects or for developers who prefer a declarative approach to configuration. `zbuild` was created to address these challenges by providing an opinionated, ZON-based alternative to Zig's native build system, aiming to streamline the build process while retaining the power of Zig’s capabilities. - -The motivation behind `zbuild` stems from a desire to improve the developer experience for Zig projects, making it easier to define, manage, and share build configurations without sacrificing the language’s strengths. Here’s why `zbuild` exists and what it seeks to achieve: - ---- - -## Problems with the Status Quo - -### 1. Complexity of `build.zig` -Zig’s build system is a full-fledged programming environment written in Zig itself. While this offers unparalleled flexibility—allowing dynamic build logic, conditional compilation, and custom steps—it comes with a steep learning curve: -- Developers must write and maintain imperative Zig code for tasks like adding executables, libraries, or dependencies. -- Common build patterns (e.g., adding an executable with a test) require repetitive boilerplate code. -- Debugging build scripts can be challenging due to their programmatic nature. - -For small projects, this might be manageable, but as projects grow, the `build.zig` file can become a maintenance burden, especially for teams or newcomers unfamiliar with Zig’s build internals. - -### 2. Lack of Declarative Configuration -Many modern build tools (e.g., `Cargo` for Rust, `npm`/`package.json` for Node.js) offer declarative configuration files that define what to build rather than how to build it. Zig’s `build.zig` requires developers to specify both, which can feel overly hands-on for straightforward projects. There’s no built-in way to define a project’s structure in a simple, human-readable format without writing code. - -### 3. Inconsistent Build Patterns -Without a standardized structure, different Zig projects may adopt wildly different `build.zig` conventions. This inconsistency makes it harder for developers to: -- Quickly understand a new project’s build setup. -- Reuse build logic across projects. -- Share build configurations with the community. - -### 4. Limited Tooling Integration -While Zig’s build system is powerful, its programmatic nature doesn’t lend itself easily to integration with external tools like IDEs or CI/CD pipelines, which often expect structured configuration files (e.g., JSON or YAML) for validation, autocompletion, or automation. While ZON is still nascent, its smaller feature surface fits well. - ---- - -## Goals of zbuild - -`zbuild` aims to address these issues by introducing a layer of abstraction over Zig’s build system, guided by the following goals: - -### 1. Simplify Build Definition -By using a ZON-based configuration file (`zbuild.zon`), `zbuild` allows developers to declaratively specify their project’s components—dependencies, modules, executables, libraries, tests, and more—without writing Zig code. This reduces the cognitive load and makes build setup accessible to developers who may not be Zig experts. - -For example, instead of writing: - -```zig -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const module_myapp = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .target = b.standardTargetOptions(.{}), - }); - b.modules.put(b.dupe("myapp"), module_myapp) catch @panic("OOM"); - - const exe_myapp = b.addExecutable(.{ - .name = "myapp", - .root_module = module_myapp, - }); - - const install_exe_myapp = b.addInstallArtifact(exe_myapp, .{}); - const install_tls_exe_myapp = b.step("build-exe:myapp", "Install the myapp executable"); - install_tls_exe_myapp.dependOn(&install_exe_myapp.step); - b.getInstallStep().dependOn(&install_exe_myapp.step); - - const run_exe_myapp = b.addRunArtifact(exe_myapp); - if (b.args) |args| run_exe_myapp.addArgs(args); - const run_tls_exe_myapp = b.step("run:myapp", "Run the myapp executable"); - run_tls_exe_myapp.dependOn(&run_exe_myapp.step); - - const test_myapp = b.addTest(.{ - .name = "myapp", - .root_module = module_myapp, - }); - const install_test_myapp = b.addInstallArtifact(test_myapp, .{}); - const install_tls_test_myapp = b.step("build-test:myapp", "Install the myapp test"); - install_tls_test_myapp.dependOn(&install_test_myapp.step); - - const run_test_myapp = b.addRunArtifact(test_myapp); - const run_tls_test_myapp = b.step("test:myapp", "Run the myapp test"); - run_tls_test_myapp.dependOn(&run_test_myapp.step); -} -``` - - -You can define: -```zon -.{ - .name = .project, - .version = "0.1.0", - .fingerprint = 0xdeadbeefdeadbeef, - .minimum_zig_version = "0.14.0", - .paths = .{ "build.zig", "build.zig.zon", "src" }, - .executables = .{ - .myapp = .{ - .root_module = .{ - .root_source_file = "src/main.zig", - } - } - } -} -``` - -And let `zbuild` generate the necessary `build.zig`. - -### 2. Reduce Boilerplate -`zbuild` automates repetitive tasks like creating install steps, run commands, and tests for each target. It enforces a consistent structure, eliminating the need to manually replicate common patterns across projects. - -### 3. Enhance Readability and Maintainability -A ZON configuration is inherently more readable and diff-friendly than a programmatic script. It’s easier to see at a glance what a project builds, what it depends on, and how it’s configured. This also simplifies maintenance, as changes to the build setup are more predictable and less error-prone. - -### 4. Preserve Zig’s Power -While zbuild introduces a declarative layer, it doesn’t abandon Zig’s flexibility. It generates a build.zig file that can be customized further if needed, allowing developers to drop down to the native build system for advanced use cases. The generated file serves as a starting point, not a limitation. - -### 5. Foster a Standard Build Workflow -By providing an opinionated structure, zbuild encourages a consistent build workflow across Zig projects. This standardization can lower the barrier to entry for new contributors and make it easier to share build configurations or adopt best practices. - -## Usecases - -`zbuild` is particularly valuable for: -- **Beginners**: Developers new to Zig can define builds without learning the intricacies of build.zig. -- **Large Projects**: Teams managing multiple targets (executables, libraries, tests) benefit from a centralized, declarative configuration. -- **Open-Source Projects**: A readable `zbuild.zon` makes it easier for contributors to understand and modify the build setup. - -## Trade-Offs -Introducing `zbuild` comes with trade-offs: -- **Opinionation**: The tool enforces a specific structure, which may not suit every project’s needs. Developers requiring full control may prefer raw `build.zig`. -- **Maturity**: Incomplete features limit its scope, but the foundation is solid. - -For many, the benefits—simplicity, consistency, and tooling—outweigh these drawbacks. - -## Inspiration -`zbuild` draws inspiration from: -- **npm (Node.js)**: `package.json` provides a structured way to define scripts, dependencies, and metadata. -- **Cargo (Rust)**: A declarative Cargo.toml simplifies Rust project builds while retaining flexibility via `build.rs`. - -Unlike these tools, `zbuild` is tailored to Zig’s unique ecosystem, leveraging its native build system rather than replacing it. - -## Future Vision -`zbuild` aspires to become an indispensable companion in the Zig ecosystem, evolving beyond its current foundation to empower developers at every level. Its high-level ambitions include: - -- **Democratizing Zig Development**: Lower the barrier to entry with an intuitive, welcoming build experience, empowering newcomers and seasoned developers alike to embrace Zig’s potential. -- **Full Feature Parity**: Support all build.zig capabilities (e.g., depends_on, custom steps). -- **Seamless Build Excellence**: Evolve into a tool that effortlessly harnesses Zig’s full build capabilities, turning complexity into simplicity and fueling rapid project creation. -- **Thriving Zig Ecosystem**: Catalyze a flourishing community by streamlining dependency management and fostering collaboration, driving widespread adoption - -By alleviating the complexities of Zig’s build system while embracing its strengths, zbuild seeks to empower developers, making project setup and maintenance intuitive and efficient. diff --git a/docs/STRUCTURAL_ISSUES.md b/docs/STRUCTURAL_ISSUES.md deleted file mode 100644 index 3685fea..0000000 --- a/docs/STRUCTURAL_ISSUES.md +++ /dev/null @@ -1,282 +0,0 @@ -# Structural Issues - -A comprehensive audit of the zbuild codebase covering bugs, architectural gaps, and design improvements. - ---- - -## 1. Critical — Crashes, compile failures, or data corruption - -### 1.1 `write_files` parser is a stub -**Config.zig:648-649** - -The top-level parse branch for `write_files` is an empty comment: -```zig -} else if (std.mem.eql(u8, field_name, "write_files")) { - // config.write_files = ; -} -``` -Any `write_files` in zbuild.zon is silently discarded. - -### 1.2 `parseWriteFile` / `parseWriteFilePath` won't compile -**Config.zig:768-771, 785, 787** - -`parseT` is called with wrong arity (missing `index` argument). The loop in `parseWriteFile` iterates `n.names` without capturing the value index, so `field_value` is unavailable. These functions are unreachable (due to 1.1) but would fail to compile if called. - -### 1.3 `ZigEnv` exit code check is always false -**ZigEnv.zig:33** - -```zig -if (result.term != .Exited and result.term.Exited != 0) { -``` -`!= .Exited AND .Exited != 0` can never be simultaneously true. Should be `or`. Zig errors (signal termination, non-zero exit) are silently ignored. - -### 1.4 `--no-sync` causes infinite loop -**GlobalOptions.zig:73-76** - -The flag is set and `continue`s without calling `args.next()`, so the same arg is re-read forever. - -### 1.5 `cmd_fetch` accesses wrong union variant -**cmd_fetch.zig:152** - -`new_dep.location.url` is accessed unconditionally. For `.path` dependencies this is a tagged-union safety panic. - -### 1.6 Missing `.` before `include_extensions` in format string -**ConfigBuildgen.zig:285** - -```zig -\\... .exclude_extensions = {s}, include_extensions = {s} ... -``` -Missing leading dot generates invalid Zig: `.{ .exclude_extensions = ..., include_extensions = ... }`. - -### 1.7 Fingerprint serialization bug -**Config.zig:1352-1353** - -`{x}` format on a `[]const u8` string emits byte-hex instead of the fingerprint value. Should use `{s}`. - ---- - -## 2. High — Silent data loss or incorrect behavior - -### 2.1 `hash` and `lazy` never parsed from dependencies -**Config.zig:690-716** - -`parseDependency` has no branches for `hash` or `lazy`. Both are silently ignored, breaking URL dependency hashing and lazy fetch. - -### 2.2 `Library.version` not parsed -**Config.zig:1017-1053** - -No parse branch for `"version"`. Always null after parsing. Compare with `parseExecutable` which handles it. - -### 2.3 `Test.test_runner` not parsed -**Config.zig:1085-1113** - -Field exists in the struct but has no parse branch. Also not emitted in codegen (`writeTest`). - -### 2.4 Module `private` logic is inverted -**ConfigBuildgen.zig:506** - -`private orelse true` means all modules are exported to `b.modules` by default. A field named `private` defaulting to true (= exported) is semantically backwards. - -### 2.5 `depends_on` parsed but never emitted -**ConfigBuildgen.zig (entire)** - -`Executable.depends_on`, `Library.depends_on`, `Object.depends_on` are all parsed and freed in `deinit` but no `step.dependOn(...)` call is ever generated. - -### 2.6 Unused-variable detection only scans top-level modules -**ConfigBuildgen.zig:151-208** - -The logic that marks `target`, `optimize`, `dep_*`, and `options_module_*` as unused only iterates `self.modules`. Projects using only inline modules in executables (a common pattern) will get false `_ = dep_foo;` emissions — which break compilation when the same dep is used in an import. - -### 2.7 Serializer has libraries/objects/tests/fmts/runs commented out -**Config.zig:1408-1442** - -Five major sections are dead code. `Config.serialize()` silently drops them. - -### 2.8 `enum`/`enum_list` options are TODO stubs in serializer -**Config.zig:1537-1546** - -Both branches are no-ops. Options of these types are silently dropped during re-serialization. - -### 2.9 `description` and `keywords` not written to `build.zig.zon` -**sync_manifest.zig:75-87** - -The manifest template has no placeholders for these fields. Dropped on every sync. - -### 2.10 `hash`/`lazy` not serialized for dependencies -**Config.zig:1446-1484** - -Even if parsed (they aren't — see 2.1), the serializer doesn't write them. URL dependency round-trips lose their hash. - -### 2.11 Memory leak: `Module.deinit` doesn't free `include_paths` -**Config.zig:303-315** - -`include_paths: ?[][]const u8` is parsed and used in codegen but never freed. Each string in the slice and the slice itself leak. - -### 2.12 No rollback on fetch failure during manifest sync -**sync_manifest.zig:40-64** - -`build.zig.zon` is written before `zig fetch` runs. If fetch fails mid-loop, the manifest is left in an inconsistent state. - -### 2.13 `updateConfigDependency` treats zbuild.zon as build.zig.zon -**cmd_fetch.zig:162-244** - -Uses `Manifest.load` which is a `build.zig.zon` parser. Will fail if zbuild.zon contains zbuild-specific fields. - -### 2.14 `writeImport` uses wrong module ID when inline module has `.name` -**ConfigBuildgen.zig:918-930** - -When an inline module specifies a `.name`, the import code references `module_{exe_key}` but the module was defined as `module_{module_name}`. - ---- - -## 3. Medium — Memory leaks, inconsistencies, incomplete features - -### 3.1 `Executable.deinit` doesn't free `dest_sub_path` -**Config.zig:346-356** - -### 3.2 `Library.deinit` doesn't free `dest_sub_path` -**Config.zig:376-386** - -### 3.3 `parseObject` leaks field name -**Config.zig:1060** - -`gpa.dupe(u8, name.get(self.zoir))` without free. All other parsers use `name.get(self.zoir)` directly. - -### 3.4 `wip_bundle` never deinit'd on success path -**main.zig:105-119** - -No `defer wip_bundle.deinit(gpa)` before the parse call. Memory leak every successful run. - -### 3.5 `ast.deinit` called before `manifest.deinit` -**sync_manifest.zig:33-38** - -Fragile ordering leaves `m.ast` as a dangling reference between the two deinit calls. - -### 3.6 Opened `Dir` handles never closed -**sync_manifest.zig:24-27, cmd_fetch.zig:106, sync_build_file.zig:17-19** - -File descriptor leaks when `out_dir` is non-null. - -### 3.7 `zig fmt` errors suppressed -**sync_build_file.zig:32-37** - -Both stderr and stdout are `.Ignore`. Codegen bugs produce unformatted invalid files with no diagnostic. - -### 3.8 `usage` and `list` functions are empty stubs -**cmd_build.zig:36-43** - -`--help`/`--list` for build commands print nothing. - -### 3.9 `Args.zig` test references non-existent function -**Args.zig:78** - -Calls `Args.parse` but the function is named `initFromString`. Test won't compile. - -### 3.10 `returnParseError` sets `.owned = false` on heap-allocated message -**Config.zig:1306-1319** - -`allocPrint`'d message passed with `.owned = false` leaks the string. - -### 3.11 `dependencies_node == 0` used as sentinel -**Manifest.zig:46** - -`0` is a valid AST node index. When no dependencies field exists, `getNodeSource(0)` returns the entire file source. - ---- - -## 4. Low — Cosmetic or fragile design - -### 4.1 Shared `scratch` buffer is fragile -**ConfigBuildgen.zig:1022** - -`fmtId`, `resolveLazyPath`, `resolvedTarget`, `optimize`, `semanticVersion`, etc. all write to one threadlocal 4096-byte buffer. No live bug currently but any reordering or nesting will silently corrupt output. - -### 4.2 Run step description says "Run the {name} run" -**ConfigBuildgen.zig:858** - -Redundant "run" in user-facing string. - -### 4.3 `run:{name}` step name collision -**ConfigBuildgen.zig** - -Executables and custom runs both generate `run:{name}` steps. Same-name causes a Zig build panic. - -### 4.4 `version` command prints Zig version, not zbuild version -**main.zig:88** - -### 4.5 Two-phase sync writes `build.zig` before `build.zig.zon` -**cmd_sync.zig:14-15** - -If manifest sync fails, `build.zig` references deps not in the manifest. - -### 4.6 `include_extensions` defaults to `null` while `exclude_extensions` defaults to `&.{}` -**ConfigBuildgen.zig:292** - -Inconsistent codegen for the two sibling fields. - ---- - -## 5. Architectural Issues - -### 5.1 `Config.zig` is a 1600-line mega-file with four responsibilities - -It contains the data model, the ZON parser (~730 lines), the serializer (~280 lines), and deinit logic. These could be separate modules sharing the type definitions. - -### 5.2 No shared "artifact type" abstraction - -`Executable`, `Library`, `Object`, and `Test` share ~80% of fields (`root_module`, `max_rss`, `use_llvm`, `use_lld`, `zig_lib_dir`, `depends_on`...) but are four independent structs. This produces: - -- Four near-identical `parseX` functions (manual `if/else if` chains over the same fields) -- Four near-identical `writeX` codegen functions (same field-emission boilerplate) -- Four near-identical `deinit` methods - -A `CompileTarget` base struct with comptime composition would collapse ~400 lines. - -### 5.3 Parser uses no reflection — every field is manually listed twice - -Each field appears once in the struct definition and once in a hand-rolled `if/else if (std.mem.eql(...))` parse chain. No compile-time check keeps them in sync. This is the root cause of 2.1, 2.2, 2.3, 2.5, and 2.8 — fields defined in the struct but missing from the parser or serializer. - -A comptime `inline for` over `@typeInfo(T).@"struct".fields` would eliminate this entire class of bug. - -### 5.4 Serializer mirrors the parser's problems - -Also a manual field-by-field emission, ~60% complete. Same comptime reflection fix applies. - -### 5.5 Codegen has no intermediate representation - -`ConfigBuildgen` writes directly to a `Writer` via string concatenation. No AST or IR for the output means: - -- The unused-variable detection (5.2, 2.6) is a heuristic scan of the input Config, not structural analysis of the output -- The scratch buffer (4.1) exists because there's no arena for temporary codegen strings -- Cross-references require ad-hoc symbol tables (`self.modules`, `self.dependencies`, etc.) - -### 5.6 `writeImports` generic is a type switch - -**ConfigBuildgen.zig:236-244** - -```zig -const imports = switch (T) { - Config.Module => item.imports orelse continue, - else => blk: { ... switch (item.root_module) { ... } }, -}; -``` - -A comptime generic that switches on its type parameter isn't generic — it's coupled to the specific types that exist today. - -### 5.7 `deinit` ceremony repeats ~9 times - -`Config.deinit` has the same 3-line map-cleanup pattern for every collection. A single `fn deinitMap(comptime V, ...)` would replace all instances. - -### 5.8 Manifest is a parallel data model - -`Manifest` (for `build.zig.zon`) and `Config.Dependency` (for `zbuild.zon`) are parallel representations of the same dependency data. The bridge function `depEql` and the AST-splicing hack in `allocPrintManifest` paper over the gap. - -### 5.9 Commands have no shared interface - -Each `cmd_*.zig` exports an `exec` with a compatible-but-not-enforced signature. No dispatch table, no `Command` interface, no comptime validation. Dispatch in `main.zig` is a flat `mem.eql` chain. - -### 5.10 `strTupleLiteral` imported across module boundaries - -**sync_manifest.zig:6** - -`sync_manifest` imports `strTupleLiteral` from `ConfigBuildgen.zig`, coupling manifest generation to build file generation. This helper belongs in a shared utility module. diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index f3a48e9..0000000 --- a/docs/TODO.md +++ /dev/null @@ -1,22 +0,0 @@ -- [x] add test, fmt tls -- [x] add options to Config -- [x] single code path for Config processing - - always use codegen build.zig -- [x] compact union json parsing -- [x] fix how build_runner_path is set in cli - - will be resolved by only using build.zig -- [x] robust generate cli command - - write build.zig.zon - - read file / write directory flags -- [x] add init command -- [x] add fetch command -- [x] add named write files to Config -- [x] make ~~json~~zon parsing errors friendly - -- add named lazy paths to Config -- add fuzz to Config -- add additional module options, eg. addIncludePath -- conditionals? template strings? -- implement depends_on and test_runner -- lots of tests - From 9f7f49a27383ede059055918ff577d1a444dae0e Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:44:23 -0400 Subject: [PATCH 28/62] docs: rewrite README for library-based zbuild Pitch, before/after comparison, quickstart, feature list, and links to schema reference and examples. Replaces the stale CLI tool README. Co-Authored-By: Claude Opus 4.6 --- README.md | 302 ++++++++++++++++++------------------------------------ 1 file changed, 99 insertions(+), 203 deletions(-) diff --git a/README.md b/README.md index 4548e32..231a691 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,110 @@ # zbuild -An opinionated build tool for Zig projects. +Declarative build configuration for Zig projects. -## Introduction +## What is zbuild? -`zbuild` is a command-line build tool designed to simplify and enhance the build process for Zig projects. It leverages a ZON-based configuration file (`zbuild.zon`) to define project builds declaratively, generating a corresponding `build.zig` and `build.zig.zon` file that integrates seamlessly with Zig’s native build system. This approach reduces the complexity of writing and maintaining Zig build scripts manually, offering a structured alternative for managing dependencies, modules, executables, libraries, tests, and more. +zbuild is a Zig library that configures your entire `std.Build` graph from the fields in your `build.zig.zon`. Using Zig 0.14's `@import("build.zig.zon")`, the compiler reads your manifest as a typed struct at comptime — no runtime parsing, no codegen, no intermediate representation. The build graph is generated directly by the compiler. -Note: `zbuild` is under active development. Some features are incomplete or subject to change. Check the `docs/TODO.md` file for planned enhancements. +zbuild works alongside manual `build.zig` code. Use it for the declarative 90%, and write Zig for the rest. -## Features - -- **ZON-based Configuration**: Define your build in a `zbuild.zon` file instead of writing Zig code directly. -- **Automatic build.zig Generation**: Create a `build.zig` and `build.zig.zon` file from your configuration. -- **Comprehensive Build Support**: Manage dependencies, modules, executables, libraries, objects, tests, formatting, and run commands. -- **Command-Line Interface**: Execute common build tasks like compiling executables, running tests, and formatting code. - -## Installation - -Currently, zbuild must be built from source: - -1. Clone the repository: -```bash -git clone https://github.com/chainsafe/zbuild.git -cd zbuild -``` - -2. Build the executable: -```bash -zig build -Doptimize=ReleaseFast -``` - -3. (Optional) Install it globally: -```bash -zig build install --prefix ~/.local -``` - -A pre-built binary distribution is planned for future releases once sufficient feature-completeness is achieved. +## Before and after -## Usage +Without zbuild, a single executable with install, run, and test steps requires ~25 lines of `build.zig`: -`zbuild` provides a command-line interface with various commands to manage your Zig projects. Below is the general syntax and a list of available commands: - -``` -Usage: zbuild [global_options] [command] [options] +```zig +const std = @import("std"); +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + b.modules.put(b.dupe("myapp"), module) catch @panic("OOM"); + + const exe = b.addExecutable(.{ .name = "myapp", .root_module = module }); + const install = b.addInstallArtifact(exe, .{}); + b.step("build-exe:myapp", "Install myapp").dependOn(&install.step); + b.getInstallStep().dependOn(&install.step); + + const run = b.addRunArtifact(exe); + if (b.args) |args| run.addArgs(args); + b.step("run:myapp", "Run myapp").dependOn(&run.step); + + const test_exe = b.addTest(.{ .name = "myapp", .root_module = module }); + const run_test = b.addRunArtifact(test_exe); + b.step("test:myapp", "Run myapp tests").dependOn(&run_test.step); +} ``` -### Commands - -- `init`: Initialize a new Zig project with a basic `zbuild.zon`, `build.zig`, `build.zig.zon`, and `src/main.zig` in the current directory. -- `fetch`: Copy a package into the global cache and optionally add it to `zbuild.zon` and `build.zig.zon` -- `install`: Install all artifacts defined in `zbuild.zon`. -- `uninstall`: Uninstall all artifacts. -- `sync`: Synchronize `build.zig` and `build.zig.zon` with `zbuild.zon`. -- `build`: Run `zig build` with the generated `build.zig`. -- `build-exe `: Build a specific executable defined in `zbuild.zon`. -- `build-lib `: Build a specific library. -- `build-obj `: Build a specific object file. -- `build-test `: Build a specific test into an executable. -- `run `: Run an executable or a custom run script. -- `test [name]`: Run all tests or a specific test. -- `fmt [name]`: Format code for all or a specific formatting target. -- `help`: Print the help message and exit. -- `version`: Print the version number and exit. - -### Global Options - -- `--project-dir `: Set the project directory (default: `.`). -- `--zbuild-file `: Specify the configuration file (default: `zbuild.zon`). -- `--no-sync`: Skip automatic synchronization of `build.zig` and `build.zig.zon`. +With zbuild, the same thing is declared in `build.zig.zon`: -### General Options - -- `-h, --help`: Print command-specific usage. - -For command-specific options (e.g., `fetch`), use zbuild --help. - -## Configuration - -The `zbuild.zon` file is the heart of the zbuild system. It defines your project’s structure and build settings. Below is an example configuration: - -```zon -.{ - .name = .example_project, - .version = "1.2.3", - .description = "A comprehensive example", - .fingerprint = 0x90797553773ca567, - .minimum_zig_version = "0.14.0", - .paths = .{ "build.zig", "build.zig.zon", "src" }, - .keywords = .{"example"}, - .dependencies = .{ - .mathlib = .{ - .path = "deps/mathlib", - }, - .network = .{ - .url = "https://github.com/example/network/archive/v1.0.0.tar.gz", - }, - }, - .options_modules = .{ - .build_options = .{ - .max_depth = .{ - .type = "usize", - .default = 100, - }, - }, - }, - .modules = .{ - .utils = .{ - .root_source_file = "src/utils/main.zig", - .imports = .{.mathlib, .build_options}, - .link_libc = true, - }, - .core = .{ - .root_source_file = "src/core/core.zig", - .imports = .{.utils}, - }, - }, - .executables = .{ - .main_app = .{ - .root_module = .{ - .root_source_file = "src/main.zig", - .imports = .{.core, .network}, - }, - }, - }, - .libraries = .{ - .libmath = .{ - .version = "0.1.0", - .root_module = .utils, - .linkage = .static, - }, - }, - .tests = .{ - .unit_tests = .{ - .root_module = .{ - .root_source_file = "tests/unit.zig", - .imports = .{.core, .utils}, - }, +```zig +.executables = .{ + .myapp = .{ + .root_module = .{ + .root_source_file = "src/main.zig", }, }, - .fmts = .{ - .source = .{ - .paths = .{"src", "tests"}, - .exclude_paths = .{"src/generated"}, - .check = true, +}, +.tests = .{ + .myapp = .{ + .root_module = .{ + .root_source_file = "src/main.zig", }, }, - .runs = .{ - .start_server = "zig run src/server.zig", - .build_docs = "scripts/build_docs.sh", - }, -} +}, ``` -### Key Sections +And your entire `build.zig` becomes: -- `name`, `version`, `fingerprint`, `minimum_zig_version`, `paths`: Project metadata (required). -- `dependencies`: External packages (path or URL). -- `options_modules`: Configurable build options bundled into modules. -- `modules`: Reusable code units with optional imports and build settings. -- `executables`, `libraries`, `objects`: Build targets with root modules. -- `tests`: Test targets with optional filters. -- `fmts`: Code formatting rules. -- `runs`: Custom shell commands. +```zig +const zbuild = @import("zbuild"); +const std = @import("std"); -## Hello World Example +pub fn build(b: *std.Build) void { + zbuild.configureBuild(b, @import("build.zig.zon"), .{}) catch |err| + std.log.err("zbuild: {}", .{err}); +} +``` -Here’s a step-by-step example to create and build a simple Zig project with zbuild: +## Quickstart -1. Initialize the project: +**1. Add zbuild as a dependency:** ```bash -mkdir myproject -cd myproject -zbuild init +zig fetch --save=zbuild ``` -2. (Optional) Inspect `zbuild.zon` +**2. Create `build.zig`:** + +```zig +const zbuild = @import("zbuild"); +const std = @import("std"); -```zon +pub fn build(b: *std.Build) void { + zbuild.configureBuild(b, @import("build.zig.zon"), .{}) catch |err| + std.log.err("zbuild: {}", .{err}); +} +``` + +**3. Add zbuild fields to your `build.zig.zon`:** + +```zig .{ .name = .myproject, .version = "0.1.0", - .fingerprint = 0x, + .fingerprint = 0xaabbccdd00112233, .minimum_zig_version = "0.14.0", .paths = .{ "build.zig", "build.zig.zon", "src" }, + .dependencies = .{ + .zbuild = .{ .path = "path/to/zbuild" }, + }, .executables = .{ - .myproject = .{ + .myapp = .{ .root_module = .{ .root_source_file = "src/main.zig", }, @@ -194,62 +113,39 @@ zbuild init } ``` -3. Update `src/main.zig` -```zig -const std = @import("std"); -pub fn main() !void { - const allocator = std.heap.page_allocator; - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); - - const arg = if (args.len >= 2) args[1] else "zbuild"; - std.debug.print("Hello {s}!\n", .{arg}); -} -``` - -4. (Optional) Build the Executable: +**4. Build and run:** ```bash -zbuild build-exe myproject +zig build # build all artifacts +zig build run:myapp # run the executable +zig build test # run all tests +zig build help # show project build info ``` -This builds the `myproject` executable into `zig-out/bin`. - -5. Run the Executable: -```bash -zbuild run myproject -- world -``` - -Outputs: `Hello, world!` - -## Fetching Dependencies - -Add a dependency to your project: -```bash -zbuild fetch --save=example https://github.com/example/repo/archive/v1.0.0.tar.gz -``` - -This updates `zbuild.zon` with: -```zon -.dependencies = .{ - .example = { - .url = "https://github.com/example/repo/archive/v1.0.0.tar.gz, - } -} -``` - -And synchronizes `build.zig.zon` with the fetched hash. +## Features +- **Modules** — reusable code units with imports, include paths, and library linking +- **Executables** — with automatic install and run steps +- **Libraries** — static/dynamic with version and linkage control +- **Objects** — compiled object files +- **Tests** — with filters and an aggregate `test` step +- **Fmts** — `zig fmt` wrappers with path and exclusion control +- **Runs** — system commands in short form (tuple) or long form (struct with env, cwd, stdin, depends_on) +- **Options modules** — build-time options importable from Zig source code +- **Dependency args** — forward comptime arguments to `b.dependency()` calls +- **Comptime validation** — typos in module/artifact/import references become compile errors +- **Built-in help step** — `zig build help` shows a formatted overview of your project (reads `name`, `version`, `description` from your manifest) -## Contributing +## Documentation -Contributions are welcome! To contribute: +- **[Schema Reference](docs/schema.md)** — complete field-by-field reference for all zbuild manifest sections +- **[Motivation](docs/motivation.md)** — why zbuild exists and how it works +- **[Simple Example](examples/simple/)** — minimal project, one executable +- **[Full Example](examples/full/)** — all features: modules, tests, runs, options, fmts -1. Fork the repository on GitHub: https://github.com/chainsafe/zbuild. -2. Create a branch for your changes. -3. Submit a pull request with a clear description of your improvements. +## Requirements -Please open an issue first to discuss significant changes or report bugs. +Zig 0.14+ ## License From ee9253b4b4f391d41fbbb02b59e433d544e9ee6f Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:45:01 -0400 Subject: [PATCH 29/62] docs: add motivation doc explaining library approach Covers the problem (build.zig verbosity), the insight (@import + comptime), before/after comparison, and what zbuild is NOT. Co-Authored-By: Claude Opus 4.6 --- docs/motivation.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/motivation.md diff --git a/docs/motivation.md b/docs/motivation.md new file mode 100644 index 0000000..b10a873 --- /dev/null +++ b/docs/motivation.md @@ -0,0 +1,87 @@ +# Why zbuild? + +## The problem + +Zig's build system is powerful — it's a full programming environment written in Zig itself. But that power comes with verbosity. Adding a single executable with install, run, and test steps requires ~25 lines of boilerplate. Multiply by N targets, wire in dependencies and modules, and the `build.zig` file becomes a maintenance burden. + +For newcomers, `build.zig` is one of the steepest parts of learning Zig. The build API is large, the patterns are repetitive, and small mistakes produce confusing errors. + +## The insight + +Zig 0.14 added `@import("build.zig.zon")`, which gives comptime access to the project manifest as a typed anonymous struct. This changes everything: + +- **The compiler is the parser.** No runtime ZON parsing, no custom IR, no serialization. +- **The type system is the schema.** Invalid field types are caught by the compiler. +- **`@compileError` is the validation framework.** Typos in module references become compile errors with descriptive messages. +- **`inline for` over struct fields** generates specialized code per manifest entry. Zero runtime overhead. + +## Before and after + +A typical `build.zig` for one executable with tests: + +```zig +const std = @import("std"); +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + b.modules.put(b.dupe("myapp"), module) catch @panic("OOM"); + + const exe = b.addExecutable(.{ .name = "myapp", .root_module = module }); + const install = b.addInstallArtifact(exe, .{}); + b.step("build-exe:myapp", "Install myapp").dependOn(&install.step); + b.getInstallStep().dependOn(&install.step); + + const run = b.addRunArtifact(exe); + if (b.args) |args| run.addArgs(args); + b.step("run:myapp", "Run myapp").dependOn(&run.step); + + const test_exe = b.addTest(.{ .name = "myapp", .root_module = module }); + const run_test = b.addRunArtifact(test_exe); + b.step("test:myapp", "Run myapp tests").dependOn(&run_test.step); +} +``` + +With zbuild, the same thing is declared in `build.zig.zon` alongside your normal project metadata: + +```zig +.executables = .{ + .myapp = .{ + .root_module = .{ .root_source_file = "src/main.zig" }, + }, +}, +.tests = .{ + .myapp = .{ + .root_module = .{ .root_source_file = "src/main.zig" }, + }, +}, +``` + +And `build.zig` becomes: + +```zig +const zbuild = @import("zbuild"); +const std = @import("std"); + +pub fn build(b: *std.Build) void { + zbuild.configureBuild(b, @import("build.zig.zon"), .{}) catch |err| + std.log.err("zbuild: {}", .{err}); +} +``` + +zbuild eliminates the repetitive wiring. You declare what you want; the compiler generates the build graph. + +## What zbuild is NOT + +zbuild is not a replacement for `build.zig`. It handles the declarative 90% — the static build graph that most projects need. For conditional logic, platform-specific targets, or custom build steps, write that code in `build.zig` alongside the `configureBuild` call. Since zbuild takes `*std.Build` and returns, you can do anything before or after it. + +The escape hatch is always there. + +## Inspiration + +zbuild draws from Cargo (`Cargo.toml`) and npm (`package.json`) — tools that let developers declare what to build rather than how to build it. Unlike those, zbuild doesn't replace the build system. It rides on top of Zig's native build system, generating the same `std.Build` calls you'd write by hand. From a1ad831f97b7bd1a86ab2773439d40f8ca2d991c Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 16 Mar 2026 11:46:10 -0400 Subject: [PATCH 30/62] docs: add complete ZON schema reference Field tables for all manifest sections: modules, executables, libraries, objects, tests, fmts, runs, options_modules, dependencies. Plus LazyPath resolution, comptime validation, and configureBuild options. Co-Authored-By: Claude Opus 4.6 --- docs/schema.md | 314 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/schema.md diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..bf8fa4d --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,314 @@ +# ZON Schema Reference + +This is the complete reference for zbuild's manifest fields. These fields are added to your standard `build.zig.zon` alongside Zig's own fields (`name`, `version`, `fingerprint`, `minimum_zig_version`, `paths`, `description`, `dependencies`). + +Fields not recognized by zbuild are silently ignored, ensuring forward compatibility with future Zig versions. + +## `modules` + +Reusable code units registered with the build system. Modules can be referenced by name from executables, libraries, and tests via their `root_module` field. + +```zig +.modules = .{ + .core = .{ + .root_source_file = "src/core.zig", + .imports = .{ .utils, .zlib }, + .link_libc = true, + }, +}, +``` + +### Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `root_source_file` | string | — | Path to the root source file | +| `target` | string | host | `"native"` or arch-os-abi triple (e.g. `"x86_64-linux-gnu"`) | +| `optimize` | enum literal | project default | `.Debug`, `.ReleaseSafe`, `.ReleaseFast`, `.ReleaseSmall` | +| `imports` | tuple | — | Modules, options_modules, or dependencies to import | +| `link_libraries` | tuple of strings | — | Dependency library artifacts to link (see below) | +| `include_paths` | tuple of strings | — | Include paths for C/C++ headers | +| `private` | bool | `false` | When `true`, not exported to `b.modules` | + +### Passthrough fields + +These map directly to `std.Build.Module.CreateOptions`: + +`link_libc`, `link_libcpp`, `single_threaded`, `strip`, `unwind_tables`, `dwarf_format`, `code_model`, `error_tracing`, `omit_frame_pointer`, `pic`, `red_zone`, `sanitize_c`, `sanitize_thread`, `stack_check`, `stack_protector`, `fuzz`, `valgrind` + +### `link_libraries` syntax + +Format: `"dep_name"` or `"dep_name:artifact_name"`. Resolves a library artifact from a declared dependency. This is distinct from LazyPath resolution. + +```zig +.link_libraries = .{ "zlib", "openssl:libssl" }, +``` + +### `imports` syntax + +Import entries can reference: +- **Named modules:** `.core` or `"core"` +- **Options modules:** `.config` +- **Dependencies:** `.zlib` (imports the dependency's default module) +- **Dependency sub-modules:** `"zlib:zlib"` (imports a specific module from a dependency) + +## `executables` + +Build targets that produce executable binaries. Each entry creates `build-exe:` (install) and `run:` (execute) steps. + +```zig +.executables = .{ + .myapp = .{ + .root_module = .core, // reference a named module + .version = "1.0.0", + }, +}, +``` + +### Root module forms + +The `root_module` field accepts three forms: + +1. **Enum literal** — references a named module: `.root_module = .core` +2. **String** — same as enum literal: `.root_module = "core"` +3. **Inline struct** — defines the module inline, with an optional `name` override: + ```zig + .root_module = .{ + .root_source_file = "src/main.zig", + .imports = .{ .core, .config }, + .name = "custom_name", // optional: overrides the registered module name + }, + ``` + +### Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `root_module` | ref or struct | required | See root module forms above | +| `version` | string | — | Semantic version (e.g. `"1.0.0"`) | +| `linkage` | enum literal | — | `.static` or `.dynamic` | +| `dest_sub_path` | string | — | Custom install subdirectory | +| `depends_on` | tuple | — | Artifacts that must build first | +| `max_rss` | int | — | Maximum RSS for the build step | +| `use_llvm` | bool | — | Use LLVM backend | +| `use_lld` | bool | — | Use LLD linker | +| `zig_lib_dir` | string | — | Custom Zig lib directory (LazyPath resolved) | +| `win32_manifest` | string | — | Win32 manifest file (LazyPath resolved) | + +## `libraries` + +Same fields as executables, plus `linker_allow_shlib_undefined`. Each entry creates a `build-lib:` step. + +```zig +.libraries = .{ + .mylib = .{ + .root_module = .core, + .linkage = .static, + .version = "0.1.0", + }, +}, +``` + +| Additional Field | Type | Default | Description | +|------------------|------|---------|-------------| +| `linker_allow_shlib_undefined` | bool | — | Allow undefined symbols in shared libs | + +## `objects` + +Compiled object files. Simpler subset — no `version`, `linkage`, `dest_sub_path`, or `win32_manifest`. Each entry creates a `build-obj:` step. + +```zig +.objects = .{ + .myobj = .{ + .root_module = .core, + }, +}, +``` + +Supported fields: `root_module`, `max_rss`, `use_llvm`, `use_lld`, `zig_lib_dir`. + +## `tests` + +Test targets. Each entry creates `test:` (run) and `build-test:` (install) steps. All tests also join the aggregate `test` step. + +```zig +.tests = .{ + .unit = .{ + .root_module = .{ + .root_source_file = "src/test.zig", + .imports = .{.core}, + }, + .filters = .{ "specific_test_name" }, + }, +}, +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `root_module` | ref or struct | required | See root module forms | +| `filters` | tuple of strings | — | Test name filters | +| `max_rss` | int | — | Maximum RSS | +| `use_llvm` | bool | — | Use LLVM backend | +| `use_lld` | bool | — | Use LLD linker | +| `zig_lib_dir` | string | — | Custom Zig lib directory | + +Filters can be overridden from the CLI: `-D.filters=specific_test`. + +## `fmts` + +Wraps `zig fmt`. Each entry creates `fmt:` and joins the aggregate `fmt` step. + +```zig +.fmts = .{ + .src = .{ + .paths = .{ "src", "tests" }, + .exclude_paths = .{ "src/generated" }, + .check = true, + }, +}, +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `paths` | tuple of strings | `&.{}` | Paths to format | +| `exclude_paths` | tuple of strings | `&.{}` | Paths to exclude | +| `check` | bool | `false` | Check formatting without modifying | + +## `runs` + +System commands. Each entry creates a `cmd:` step. Two syntax forms: + +### Short form + +Bare tuple of strings — becomes `addSystemCommand` args: + +```zig +.runs = .{ + .fmt = .{ "zig", "fmt", "src" }, +}, +``` + +### Long form + +Struct with `cmd` plus optional fields: + +```zig +.runs = .{ + .deploy = .{ + .cmd = .{ "./scripts/deploy.sh", "--env", "staging" }, + .cwd = "scripts", + .env = .{ .NODE_ENV = "production" }, + .inherit_stdio = true, + .depends_on = .{.myapp}, + }, +}, +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `cmd` | tuple of strings | required | Command and arguments | +| `cwd` | string | inherit | Working directory (LazyPath resolved) | +| `env` | struct | inherit | Environment variables (field name = key, value = value) | +| `inherit_stdio` | bool | `false` | Forward stdio to terminal | +| `stdin` | string | — | Bytes piped to stdin | +| `stdin_file` | string | — | File piped to stdin (LazyPath resolved) | +| `depends_on` | tuple | — | Artifacts that must build first | + +`stdin` and `stdin_file` are mutually exclusive. + +## `options_modules` + +Configurable build options exposed as importable Zig modules. Users set values via `-D.