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);
}