ajhahn.de
← Flash
Zig 574 lines
const std = @import("std");
const builtin = @import("builtin");

// Single source of truth for the project version: read from build.zig.zon
// and handed to the program via build_options, so `flashc --version` and the
// package manifest can never drift. Mirrors the FlashOS convention.
const version: []const u8 = @import("build.zig.zon").version;

// Hard pin: flashc is built and run on the host toolchain, and its whole
// purpose is to emit source for FlashOS, which itself pins one exact Zig.
// The compiler that produces that source pins the same one. Bumping is a
// deliberate act — install the new Zig, raise REQUIRED_ZIG_VERSION here and
// minimum_zig_version in build.zig.zon, fix any breakage, commit. The
// .zigversion file mirrors this for version managers (zigup / zvm / anyzig).
const REQUIRED_ZIG_VERSION = std.SemanticVersion{ .major = 0, .minor = 16, .patch = 0 };

comptime {
    const v = builtin.zig_version;
    const r = REQUIRED_ZIG_VERSION;
    if (v.major != r.major or v.minor != r.minor or v.patch != r.patch) {
        @compileError(std.fmt.comptimePrint(
            "flashc requires Zig {d}.{d}.{d} exactly. Found Zig {d}.{d}.{d}. " ++
                "To upgrade: bump REQUIRED_ZIG_VERSION in build.zig and " ++
                "minimum_zig_version in build.zig.zon, then fix breakage.",
            .{ r.major, r.minor, r.patch, v.major, v.minor, v.patch },
        ));
    }
}

// Build for the Flash compiler (`flashc`) — a host-native transpiler that
// lowers Flash source to Zig (the "Tier 0" backend).
// flashc itself is an ordinary host executable; only the code it eventually
// emits is freestanding.
//
// Layout:
//   * src/main.zig    — CLI driver: read .flash, run lex -> parse -> sema -> lower
//   * src/lexer.zig   — source -> token stream (implemented)
//   * src/token.zig   — token taxonomy
//   * src/parser.zig  — tokens -> AST, recursive descent (implemented)
//   * src/ast.zig     — node definitions
//   * src/sema.zig    — native binding/scope/mutability checker (implemented)
//   * src/eval.zig    — compile-time value/type pool (the evaluator's substrate)
//   * src/lower.zig   — AST -> Zig source text (implemented)
//   * src/fmt.zig     — AST -> canonical Flash source text (the formatter)
//   * selfhost/       — the compiler in Flash: the maintained source
//                       (src/*.zig is the frozen stage0 bootstrap seed)
//   * std/            — the Flash standard library (the `core` module),
//                       composed per stage like selfhost/
//   * tools/          — host-side build tooling (the diff-corpus and
//                       fixpoint harnesses)
//
// Steps:
//   * zig build               — build flashc into zig-out/bin
//   * zig build run -- FILE   — build then transpile FILE
//   * zig build test          — run the host unit suite (includes test-flash,
//                               test-selfhost, and test-std)
//   * zig build test-flash    — transpile and run the .flash test suite
//   * zig build test-selfhost — transpile and run the selfhost test suite
//   * zig build test-std      — transpile and run the std test suite
//   * zig build test-lsp      — transpile and run the LSP server test suite
//   * zig build test-driver   — assert the build driver's file outputs and exits
//   * zig build lsp           — build flashd, the Flash language server
//   * zig build stage1        — build flashc-stage1 (stage0 transpiles selfhost/)
//   * zig build stage2        — build flashc-stage2 (stage1 transpiles selfhost/)
//   * zig build diff-corpus   — assert stage0 and stage1 behave identically
//   * zig build fixpoint      — assert stage1 and stage2 emit identical Zig

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const opts = b.addOptions();
    opts.addOption([]const u8, "version", version);

    const exe = b.addExecutable(.{
        .name = "flashc",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    exe.root_module.addOptions("build_options", opts);
    b.installArtifact(exe);

    // `zig build run -- examples/hello.flash`
    const run = b.addRunArtifact(exe);
    run.step.dependOn(b.getInstallStep());
    if (b.args) |args| run.addArgs(args);
    const run_step = b.step("run", "Build flashc and transpile a .flash file");
    run_step.dependOn(&run.step);

    // `zig build test` — host unit suite. Each implemented pipeline stage
    // carries its own `test` block next to the code it tests; the suite grows
    // one module at a time as the pipeline lands. Each stage's root is built
    // as its own test artifact; a stage's tests run together with those of
    // the modules it imports, so earlier stages are re-exercised through the
    // later ones (cheap, and a useful cross-check).
    const test_step = b.step("test", "Run the host unit suite");
    const unit_roots = [_][]const u8{
        "src/lexer.zig",
        "src/parser.zig",
        "src/sema.zig",
        "src/eval.zig",
        "src/lower.zig",
        "src/fmt.zig",
    };
    for (unit_roots) |root| {
        const unit = b.addTest(.{
            .root_module = b.createModule(.{
                .root_source_file = b.path(root),
                .target = b.graph.host,
                .optimize = .Debug,
            }),
        });
        test_step.dependOn(&b.addRunArtifact(unit).step);
    }

    // Integration tests: drive the lexer over the real example sources in
    // examples/. The lexer and the embedded examples are wired in as modules
    // so the test file (in tests/) reaches them without a cross-directory
    // import. Grows as the pipeline lands.
    const lexer_mod = b.createModule(.{
        .root_source_file = b.path("src/lexer.zig"),
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const examples_mod = b.createModule(.{
        .root_source_file = b.path("examples/examples.zig"),
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const integration_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("tests/lex_examples.zig"),
            .target = b.graph.host,
            .optimize = .Debug,
            .imports = &.{
                .{ .name = "flash", .module = lexer_mod },
                .{ .name = "examples", .module = examples_mod },
            },
        }),
    });
    test_step.dependOn(&b.addRunArtifact(integration_tests).step);

    // fmt integration: format every example, asserting idempotence and the
    // comment multiset. Reuses the lexer + examples modules; `fmt` is rooted at
    // src/fmt.zig and pulls in the parser and lowering through its own imports.
    const fmt_mod = b.createModule(.{
        .root_source_file = b.path("src/fmt.zig"),
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const fmt_integration_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("tests/fmt_examples.zig"),
            .target = b.graph.host,
            .optimize = .Debug,
            .imports = &.{
                .{ .name = "fmt", .module = fmt_mod },
                .{ .name = "examples", .module = examples_mod },
            },
        }),
    });
    test_step.dependOn(&b.addRunArtifact(fmt_integration_tests).step);

    // Evaluator probe corpus: every pass/ probe under tests/eval/ must check
    // clean, every reject/ probe must produce exactly its marked diagnostics
    // (the `// expect-error:` contract in tests/eval_probes.zig). The checker
    // is reached through a module rooted at src/sema.zig, which re-exports the
    // parser for exactly this wiring.
    const sema_mod = b.createModule(.{
        .root_source_file = b.path("src/sema.zig"),
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const probes_mod = b.createModule(.{
        .root_source_file = b.path("tests/eval/probes.zig"),
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const eval_probe_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("tests/eval_probes.zig"),
            .target = b.graph.host,
            .optimize = .Debug,
            .imports = &.{
                .{ .name = "sema", .module = sema_mod },
                .{ .name = "probes", .module = probes_mod },
            },
        }),
    });
    test_step.dependOn(&b.addRunArtifact(eval_probe_tests).step);

    // `zig build test-flash` — the Flash-source test suite. Each .flash file
    // below is transpiled by the freshly built flashc (stdout captured as a
    // .zig root), and the emitted `test` blocks run as ordinary Zig tests.
    // This is the harness Flash code tests itself with: a compiler regression
    // that breaks the emitted Zig fails this step, and a failing Flash-level
    // expectation fails the build. Also wired into `zig build test`, so the
    // one gate covers both suites.
    const flash_test_roots = [_][]const u8{
        "tests/flash/basics.flash",
        "tests/flash/eval.flash",
    };
    const test_flash_step = b.step("test-flash", "Transpile and run the .flash test suite");
    for (flash_test_roots) |path| {
        const transpile = b.addRunArtifact(exe);
        transpile.addFileArg(b.path(path));
        const root = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{std.fs.path.stem(path)}),
        });
        const flash_tests = b.addTest(.{
            .root_module = b.createModule(.{
                .root_source_file = root,
                .target = b.graph.host,
                .optimize = .Debug,
            }),
        });
        test_flash_step.dependOn(&b.addRunArtifact(flash_tests).step);
    }
    test_step.dependOn(test_flash_step);

    // ---- the std library (the `core` module) ----
    //
    // std/*.flash is the Flash standard library, composed exactly like the
    // selfhost sources: each module is transpiled into a per-stage WriteFiles
    // directory of its own (so std filenames can never collide with selfhost
    // ones), and the module rooted at that directory's core.zig is attached
    // to consumers as the named import "core". Stage0 (src/) never imports
    // core — it is the frozen bootstrap seed. std sources stay on the
    // bootstrap surface: their membership in the diff-corpus below enforces
    // that stage0 can always compile them.
    const std_modules = [_][]const u8{
        "base",
        "mem",
        "list",
        "fmt",
        "math",
        "arena",
        "json",
        "core",
    };

    // `zig build test-std` — transpile each std module with stage0 and run
    // its Flash `test` blocks from its place in the composed directory, where
    // sibling imports resolve. Wired into `zig build test`, like the others.
    const core_stage0_dir = b.addWriteFiles();
    var core_stage0_root: ?std.Build.LazyPath = null;
    const test_std_step = b.step("test-std", "Transpile and run the std test suite");
    for (std_modules) |name| {
        const transpile = b.addRunArtifact(exe);
        transpile.addFileArg(b.path(b.fmt("std/{s}.flash", .{name})));
        const generated = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{name}),
        });
        const dest = core_stage0_dir.addCopyFile(generated, b.fmt("{s}.zig", .{name}));
        if (std.mem.eql(u8, name, "core")) core_stage0_root = dest;
        const std_tests = b.addTest(.{
            .root_module = b.createModule(.{
                .root_source_file = dest,
                .target = b.graph.host,
                .optimize = .Debug,
            }),
        });
        test_std_step.dependOn(&b.addRunArtifact(std_tests).step);
    }
    test_step.dependOn(test_std_step);

    // The stage0-composed core, packaged once per consumer flavour: the host
    // test artifacts (test-selfhost roots) and the stage1 binary.
    const core_host_mod = b.createModule(.{
        .root_source_file = core_stage0_root.?,
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const core_stage1_mod = b.createModule(.{
        .root_source_file = core_stage0_root.?,
        .target = target,
        .optimize = optimize,
    });

    // ---- self-host scaffolding ----
    //
    // The self-hosted compiler lives in selfhost/*.flash — the maintained
    // compiler source; the handwritten src/*.zig is the frozen stage0
    // bootstrap seed, kept so a clean clone builds with nothing but a Zig
    // toolchain. `stage1` composes a source directory: each selfhost module
    // is transpiled by the freshly built stage0 flashc and its generated Zig
    // joins the directory. Relative imports resolve inside the composed
    // directory unchanged, so the build needs no import rewriting. `stage2`
    // repeats the composition with the stage1 binary as the transpiler — the
    // self-applied build whose output the fixpoint gate certifies.
    const selfhost_modules = [_][]const u8{
        "support",
        "token",
        "lexer",
        "ast",
        "parser",
        "sema",
        "eval",
        "lower",
        "fmt",
        "main",
    };

    // `zig build test-selfhost` — transpile each selfhost module and run its
    // Flash `test` blocks from its place in the composed directory, where
    // sibling imports resolve. Wired into `zig build test`, like test-flash.
    const stage1_dir = b.addWriteFiles();
    var stage1_root: ?std.Build.LazyPath = null;
    const test_selfhost_step = b.step("test-selfhost", "Transpile and run the selfhost test suite");
    for (selfhost_modules) |name| {
        const transpile = b.addRunArtifact(exe);
        transpile.addFileArg(b.path(b.fmt("selfhost/{s}.flash", .{name})));
        const generated = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{name}),
        });
        const dest = stage1_dir.addCopyFile(generated, b.fmt("{s}.zig", .{name}));
        if (std.mem.eql(u8, name, "main")) stage1_root = dest;
        const selfhost_tests = b.addTest(.{
            .root_module = b.createModule(.{
                .root_source_file = dest,
                .target = b.graph.host,
                .optimize = .Debug,
            }),
        });
        // The driver reads the version through build_options, like the stage
        // binaries; the pipeline modules never touch it.
        if (std.mem.eql(u8, name, "main"))
            selfhost_tests.root_module.addOptions("build_options", opts);
        // Every root that reaches the composed support.zig must resolve
        // @import("core"); adding it where unused is harmless.
        selfhost_tests.root_module.addImport("core", core_host_mod);
        test_selfhost_step.dependOn(&b.addRunArtifact(selfhost_tests).step);
    }
    test_step.dependOn(test_selfhost_step);

    // `zig build stage1` — the compiler built from stage0-emitted Zig.
    const stage1_exe = b.addExecutable(.{
        .name = "flashc-stage1",
        .root_module = b.createModule(.{
            .root_source_file = stage1_root.?,
            .target = target,
            .optimize = optimize,
        }),
    });
    stage1_exe.root_module.addOptions("build_options", opts);
    stage1_exe.root_module.addImport("core", core_stage1_mod);
    const stage1_step = b.step("stage1", "Build flashc-stage1 from the composed selfhost sources");
    stage1_step.dependOn(&b.addInstallArtifact(stage1_exe, .{}).step);

    // `zig build stage2` — the compiler built from stage1-emitted Zig: the
    // self-hosted compiler compiling its own sources. Its core is likewise
    // stage1-composed: the same std sources, transpiled by stage1.
    const core_stage2_dir = b.addWriteFiles();
    var core_stage2_root: ?std.Build.LazyPath = null;
    for (std_modules) |name| {
        const transpile = b.addRunArtifact(stage1_exe);
        transpile.addFileArg(b.path(b.fmt("std/{s}.flash", .{name})));
        const generated = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{name}),
        });
        const dest = core_stage2_dir.addCopyFile(generated, b.fmt("{s}.zig", .{name}));
        if (std.mem.eql(u8, name, "core")) core_stage2_root = dest;
    }
    const core_stage2_mod = b.createModule(.{
        .root_source_file = core_stage2_root.?,
        .target = target,
        .optimize = optimize,
    });
    const stage2_dir = b.addWriteFiles();
    var stage2_root: ?std.Build.LazyPath = null;
    for (selfhost_modules) |name| {
        const transpile = b.addRunArtifact(stage1_exe);
        transpile.addFileArg(b.path(b.fmt("selfhost/{s}.flash", .{name})));
        const generated = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{name}),
        });
        const dest = stage2_dir.addCopyFile(generated, b.fmt("{s}.zig", .{name}));
        if (std.mem.eql(u8, name, "main")) stage2_root = dest;
    }
    const stage2_exe = b.addExecutable(.{
        .name = "flashc-stage2",
        .root_module = b.createModule(.{
            .root_source_file = stage2_root.?,
            .target = target,
            .optimize = optimize,
        }),
    });
    stage2_exe.root_module.addOptions("build_options", opts);
    stage2_exe.root_module.addImport("core", core_stage2_mod);
    const stage2_step = b.step("stage2", "Build flashc-stage2 from the stage1-composed selfhost sources");
    stage2_step.dependOn(&b.addInstallArtifact(stage2_exe, .{}).step);

    // ---- the LSP server (tools/lsp) ----
    //
    // The language server is a standalone Flash application — the first
    // real Flash program outside the compiler itself. It is NOT part of
    // the bootstrap: its sources are transpiled by the self-hosted stage1
    // binary (the shipped compiler), so they may use the full current
    // surface and never constrain the frozen stage0 seed. Composition
    // mirrors stage2: per-module transpile into one directory where
    // sibling imports resolve, with the stage1-emitted core attached as
    // the named import "core".
    const lsp_modules = [_][]const u8{
        "transport",
        "check",
        "server",
        "main",
    };

    // The selfhost frontend modules the server checks documents with —
    // transpiled (by stage1, like the LSP sources) into the same composed
    // directory so `use "parser"` resolves, but not test roots here: their
    // tests already run under test-selfhost.
    const lsp_frontend_modules = [_][]const u8{
        "support",
        "token",
        "lexer",
        "ast",
        "parser",
        "sema",
        "eval",
    };

    // `zig build test-lsp` — transpile each LSP module and run its Flash
    // `test` blocks. Wired into `zig build test`, like the other suites.
    const lsp_core_host_mod = b.createModule(.{
        .root_source_file = core_stage2_root.?,
        .target = b.graph.host,
        .optimize = .Debug,
    });
    const lsp_dir = b.addWriteFiles();
    var lsp_root: ?std.Build.LazyPath = null;
    const test_lsp_step = b.step("test-lsp", "Transpile and run the LSP server test suite");
    for (lsp_frontend_modules) |name| {
        const transpile = b.addRunArtifact(stage1_exe);
        transpile.addFileArg(b.path(b.fmt("selfhost/{s}.flash", .{name})));
        const generated = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{name}),
        });
        _ = lsp_dir.addCopyFile(generated, b.fmt("{s}.zig", .{name}));
    }
    for (lsp_modules) |name| {
        const transpile = b.addRunArtifact(stage1_exe);
        transpile.addFileArg(b.path(b.fmt("tools/lsp/{s}.flash", .{name})));
        const generated = transpile.captureStdOut(.{
            .basename = b.fmt("{s}.zig", .{name}),
        });
        const dest = lsp_dir.addCopyFile(generated, b.fmt("{s}.zig", .{name}));
        if (std.mem.eql(u8, name, "main")) lsp_root = dest;
        const lsp_tests = b.addTest(.{
            .root_module = b.createModule(.{
                .root_source_file = dest,
                .target = b.graph.host,
                .optimize = .Debug,
            }),
        });
        if (std.mem.eql(u8, name, "main"))
            lsp_tests.root_module.addOptions("build_options", opts);
        lsp_tests.root_module.addImport("core", lsp_core_host_mod);
        test_lsp_step.dependOn(&b.addRunArtifact(lsp_tests).step);
    }
    test_step.dependOn(test_lsp_step);

    // `zig build lsp` — build and install flashd, the language server.
    const lsp_exe = b.addExecutable(.{
        .name = "flashd",
        .root_module = b.createModule(.{
            .root_source_file = lsp_root.?,
            .target = target,
            .optimize = optimize,
        }),
    });
    lsp_exe.root_module.addOptions("build_options", opts);
    lsp_exe.root_module.addImport("core", core_stage2_mod);
    const lsp_step = b.step("lsp", "Build flashd, the Flash language server");
    lsp_step.dependOn(&b.addInstallArtifact(lsp_exe, .{}).step);

    // `zig build diff-corpus` — run stage0 and stage1 over every .flash file
    // in the corpus and assert byte-identical behaviour across transpile,
    // --dump-tokens, and fmt (including diagnostics and exit status on the
    // files the compiler rejects). This is the equality gate every hybrid
    // module swap must keep green.
    const diff_tool = b.addExecutable(.{
        .name = "diff-corpus",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/diff_corpus.zig"),
            .target = b.graph.host,
            .optimize = .Debug,
        }),
    });
    const diff_run = b.addRunArtifact(diff_tool);
    diff_run.setCwd(b.path("."));
    diff_run.addArtifactArg(exe);
    diff_run.addArtifactArg(stage1_exe);
    diff_run.addArgs(&.{
        "examples",
        "selfhost",
        "std",
        "tests/eval/pass",
        "tests/eval/reject",
        "tests/flash",
    });
    const diff_step = b.step("diff-corpus", "Assert stage0 and stage1 behave byte-identically over the corpus");
    diff_step.dependOn(&diff_run.step);

    // `zig build fixpoint` — the bootstrap fixpoint gate: stage1 and stage2
    // must emit byte-identical Zig for every selfhost module and example
    // (examples/register/ holds the post-v0.5 grammar samples — frozen stage0
    // cannot parse them, so they ride this gate and stay out of the
    // stage0-facing diff-corpus above), and
    // each binary must emit the same bytes across two consecutive runs. When
    // this holds the generation chain is closed — the compiler the Flash
    // sources describe reproduces itself exactly, and stage2 is the fixed
    // point: a stage3 built from stage2's output would be compiled from the
    // very bytes that built stage2.
    const fixpoint_tool = b.addExecutable(.{
        .name = "fixpoint",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/fixpoint.zig"),
            .target = b.graph.host,
            .optimize = .Debug,
        }),
    });
    const fixpoint_run = b.addRunArtifact(fixpoint_tool);
    fixpoint_run.setCwd(b.path("."));
    fixpoint_run.addArtifactArg(stage1_exe);
    fixpoint_run.addArtifactArg(stage2_exe);
    fixpoint_run.addArgs(&.{ "selfhost", "std", "examples", "examples/register" });
    const fixpoint_step = b.step("fixpoint", "Assert stage1 and stage2 emit byte-identical Zig, deterministically");
    fixpoint_step.dependOn(&fixpoint_run.step);

    // `zig build test-driver` — drive the stage1 binary's file-writing modes
    // (`-o`, `build`) over a throwaway tree under .zig-cache and assert the
    // written bytes, exit codes, and flag composition. Wired into
    // `zig build test`, like the other suites.
    const driver_tool = b.addExecutable(.{
        .name = "driver-test",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/driver_test.zig"),
            .target = b.graph.host,
            .optimize = .Debug,
        }),
    });
    const driver_run = b.addRunArtifact(driver_tool);
    driver_run.setCwd(b.path("."));
    driver_run.addArtifactArg(stage1_exe);
    const test_driver_step = b.step("test-driver", "Assert the build driver's file outputs, exit codes, and flag composition");
    test_driver_step.dependOn(&driver_run.step);
    test_step.dependOn(test_driver_step);

    // The COOKBOOK.md twin: transpile examples/register/cookbook.flash with
    // stage1 (it uses post-v0.5 grammar stage0 cannot parse) and run its
    // `test` blocks against the stage0-composed core, so every recipe on the
    // cookbook page stays code that compiles AND passes. Part of
    // `zig build test`; no step of its own.
    const cookbook_transpile = b.addRunArtifact(stage1_exe);
    cookbook_transpile.addFileArg(b.path("examples/register/cookbook.flash"));
    const cookbook_generated = cookbook_transpile.captureStdOut(.{
        .basename = "cookbook.zig",
    });
    const cookbook_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = cookbook_generated,
            .target = b.graph.host,
            .optimize = .Debug,
        }),
    });
    cookbook_tests.root_module.addImport("core", core_host_mod);
    test_step.dependOn(&b.addRunArtifact(cookbook_tests).step);
}