Skip to content

Architecture

Claude edited this page Sep 7, 2026 · 3 revisions

Architecture

2026-09 note: the tree-walking interpreter (interpreter.rs) and the live HTTP server (serve.rs) described in older versions of this page were removed entirely in a compiler-hardening pass — neither file exists in this tree anymore, and there is no interpreted fallback for anything codegen.rs doesn't yet reach. This page now describes the compiled-only pipeline as it actually is; see Honest Scope & Roadmap for what that removal traded away and what's landed since.

This page describes the real compiler pipeline as implemented in crates/compiler/src/, not an idealized diagram — every stage below is a named file you can open, and crates/compiler/src/INDEX.md is the maintained structural index into the largest of them (line numbers for every major function) — though check it against the real file if the two ever drift; this wiki page and that index are both prose, not the compiler itself.

The grammar

The parser (crates/compiler/src/parser.rs) is hand-written recursive descent with strictly one token of lookahead and no backtracking, anywhere — the operational definition of LL(1). Binary-operator precedence is handled by a precedence-climbing chain (parse_logic_or → parse_logic_and → parse_equality → parse_comparison → parse_additive → parse_multiplicative → parse_unary), not left-recursive grammar rules — that's what keeps the expression grammar LL(1)-parseable without a transformation step.

Why LL(1) is the property that matters, not an implementation detail

LL(1) parseability is a decidable, checkable property of the grammar itself — not a claim about code style. It's the property that makes grammar-constrained decoding possible: a sampler can walk the same grammar alongside the model's token stream and mask out every token that would violate it, guaranteeing the sampler, not the model's judgment, keeps generation syntactically valid. See LLM Integration for how this becomes a shipped GBNF artifact.

Independent cross-checks

Nirdosha's grammar claims are verified by external tools, not just asserted by re-reading the hand-written parser:

  • grammar_check/ feeds the grammar to lalrpop, an independent LALR(1) generator. This cross-check found something real: because the language has no statement separator (no semicolons, no significant newlines), the grammar as an abstract CFG is ambiguous wherever a token could either extend an expression or start a new statement. The parser resolves every such case with one deterministic rule — always extend the current expression over ending the statement (shift over reduce, no exception) — so return x followed on the next line by -y parses as return (x - y), not as two statements. The hand-written parser is unambiguous; the bare CFG is not, without this rule stated explicitly — a distinction only visible by running a second tool against it.
  • grammar_export/ hand-translates the grammar to GBNF (crates/compiler/nirdosha.gbnf), validated two ways: by llama-cpp-gbnf (the real llama.cpp parser, which caught a real bug during writing), and by running a corpus — every shipped .nir example plus rejection cases — through the GBNF and confirming it accepts/rejects exactly what the real compiler does.

EBNF shape

Top-level: item ::= fn_decl | struct_decl | enum_decl | screen_decl | dashboard_decl. The full EBNF lives in GRAMMAR.md. Notable choices: no statement separators (see above); keyword-heavy over symbol-heavy syntax (aids LLM reliability); construction is an ordinary call (a struct's name is its own positional constructor via Expr::Call, not a separate literal form); contextual keywords (field, action, tile, chart, role, claim) match only in their leading slot, staying ordinary identifiers everywhere else.

The compilation pipeline

Every subcommand but emit-ast (main.rs::typecheck_and_own/ typecheck_and_own_optional_main_with_ui_components) runs the same shared pipeline before doing anything backend-specific — a single compiled path, no interpreter branch:

source.nir
    │
    ▼
parser.rs          hand-written LL(1) recursive descent → AST (ast.rs)
    │
    ▼
typeck.rs           whole-program type checking (~5,100 lines)
    │               • two namespaces: type names vs. callable names
    │               • struct/enum exhaustiveness in `match`
    │               • effect(...) inference and mismatch detection
    │               • validate_fragment(): typecheck one expression
    │                 fragment in a given variable-type context —
    │                 the agent-facing incremental-validation primitive
    ▼
ownership.rs        affine/linear ownership checking (~855 lines)
    │               • move-tracking scope stack, branch-uniform cleanup
    │                 (an affine value moved on only *some* branches is
    │                 rejected everywhere after that point)
    │               • compute_free_map(): the deliverable codegen consumes
    │                 to know exactly which nir_free to emit, where
    ▼
refine.rs  +  smt.rs     bounds proving, tiered (~676 + ~699 lines)
    │               • Tier 1 (refine.rs): interval/range analysis —
    │                 always available, no external solver dependency
    │               • Tier 2 (smt.rs): real Z3-backed proof for facts
    │                 Tier 1 can't decide
    │               • codegen elides a runtime guard if *either* tier
    │                 already proved it safe; otherwise a guard is
    │                 inserted — never a silent gap
    ▼
codegen.rs (AOT, ~6,900 lines)
• check_supported(): walks the whole program and rejects — by name,
  with a stated reason — anything not yet compiled, rather than
  mis-compiling it or silently falling back to something interpreted
  (there is nothing left to fall back to). See "Why 'reject, don't
  mis-compile'" below.
• emits real LLVM IR via a hand-rolled Codegen driver, then invokes
  clang/the linker for a native binary
• fully unrolls Vector/Matrix ops at compile time (the linalg speed
  story, see Benchmarks)

One more pass sits alongside this core pipeline:

  • ui_gen.rs (~2,300 lines) — reads the typed program (it needs resolved struct fields and function signatures) and derives the CRUD + dashboard manifest nirdosha emit-ui renders into a static HTML/JS file. See The UI Engine — including why "static" is a real, current limitation, not a stylistic choice: the live, server-enforced half this diagram's older version credited to a serve.rs doesn't exist right now (that file, and the nirdosha serve subcommand, were removed with the interpreter; see Honest Scope & Roadmap).

Why "reject, don't mis-compile" is an architectural rule, not a policy

codegen.rs::check_supported is the single gate every not-yet-compiled construct passes through, and its purpose is structural: grep this function for the ground-truth "what compiles today" list — don't trust prose docs, including this wiki, to be perfectly in sync with it. A program using a not-yet-compiled builtin gets a named compile error from nirdosha build/emit-llvm, not a silently wrong binary, and — since the interpreter's removal — not a silent fallback to running interpreted either: it's a hard compile-time rejection with a stated reason, full stop. For an agent, this converts "did my program actually compile the way I intended" from a trust question into a checkable one: the failure mode for an unsupported construct is a structured diagnostic naming the construct, identical in shape to any other compile error (see LLM Integration).

Runtime kernels

runtime_kernels.rs (~825 lines) compiles as an isolated rustc --crate-type staticlib (no --extern), statically embedded into codegen.rs via include_bytes! and linked into every compiled binary — so a compiled Nirdosha program has no runtime dependency on the compiler installation that produced it. It carries the C-ABI surface codegen.rs calls by name (nir_det, nir_tcp_connect, nir_sha256_hex, nir_rand_f64, nir_alloc/nir_free, ...) plus a from-scratch SHA-256 implementation bit-verified against the standard's own test vectors — the same "native call costs what inlined IR costs" reasoning that justifies calling libm/printf directly rather than reimplementing them in IR.

Further reading

Clone this wiki locally