← Playground docs/dev/COMPILER_INTERNALS.md
raw

nurlc internals — the map of the fused walk and its global state

This is the document to read before changing the compiler. It states how compiler/nurlc.nu is organised, which process-global tables exist, who writes them, and which invariants keep the single fused pass honest. The critique this answers (critic M2) was fair: the type rules live inside the code generators, so without this map every change begins as archaeology. The map is the pragmatic first step; a real AST/IR boundary remains the long-term direction and is deliberately not attempted in one leap — the bootstrap fixed point makes big-bang refactors expensive and every incremental step here is gated (see §5).

1. Shape

compiler/nurlc.nu is one self-contained file (~18.8k lines, no $ imports — it defines its own string/symtab/lexer helpers so stage0 can build it from nurlc_lastgood.ll with nothing but clang). There is **no AST and no IR data structure**: parsing, type checking, borrow checking and code generation are one recursive descent (parse_programgen_stmt / gen_expr / gen_*) that **emits LLVM IR text to stdout as it walks** (emit = nurl_print). What crosses statement/function boundaries is not a tree — it is the global state in §3.

Consequences to internalise before editing:

construct, next to the IR it emits. Changing a rule = finding every gen-site that encodes it.

a table now (that is what most globals are).

generic instantiations, drop thunks, DWARF metadata) are queued in tables and flushed at safe points.

2. Pipeline

main()
 ├─ CLI flags → g_borrowck / g_strict_borrowck / g_dbg_enabled / g_lint …
 ├─ allocate ~20 sym tables (fresh per process; nothing survives runs)
 ├─ nurl_print_buf_start            — the whole module is emitted into
 │                                    one buffering frame, not straight
 │                                    to stdout (see dce_emit_module)
 ├─ emit_header                     — module preamble, runtime declares
 ├─ scan_generic_structs (prepass)  — generic struct templates → tables
 ├─ scan_fn_sigs         (prepass)  — every fn signature, follows `$`
 │                                    imports; makes forward calls typed
 ├─ scan_type_names      (prepass)  — struct/enum names → kind table
 ├─ scan_dyn_types       (prepass)  — %Trait dyn-object vtables needed
 ├─ parse_program                   — THE fused walk (parse + typecheck
 │                                    + borrowck + memdrop + IR emit,
 │                                    one function at a time; deferred
 │                                    closures/generics flushed between
 │                                    top-level items)
 ├─ resolve_pending_escapes         — replay escape checks parked on
 │                                    forward/generic calls
 ├─ dbg_flush                       — queued !DI* metadata (--g)
 ├─ lint_report_unused_*            — --lint reports
 ├─ exit non-zero if g_err_count / g_bck_errors
 └─ dce_emit_module                 — stop the buffer; write out the
                                      globals plus the functions `main`
                                      can reach (--no-dce: all of them)

The four scan_* prepasses exist so the fused walk never needs a second look at the source: by the time parse_program runs, every signature, type name, template and dyn-trait requirement is already in a table.

dce_emit_module is the only stage that looks at the emitted IR as text. It indexes every define … } block, marks the ones reachable from main (roots: main, plus anything named between blocks — a @__vt.… vtable constant names its thunks at module scope), and prints the live sub-sequence. Working on the finished text rather than a source-level call graph is what keeps it indifferent to how a function came to exist: closures, monomorphisations, drop glue and dyn thunks are all just @name references by then. Nothing is rewritten or reordered, only dropped, and dropping something still referenced surfaces as an undefined symbol at link time.

3. Global state — families and owners

~70 globals fall into a dozen families. The generated appendix (§6) lists every one with its writers; this section is the mental model. "Sym table" below = the compiler's own scoped string→string map (nurl_sym_new/def/get, push/pop for scopes; strdup on both def and get — returned values are owned copies).

familykey globalsrole & lifetime
diagnosticsg_err_count, g_diag_recover_activeerror count + multi-error recovery mode; whole run
codegen cursorg_str_idx, g_did_ret, g_ret_forbidden, g_in_match_arm, g_defer_count, g_stmt_line/col/bare_*per-statement/-function flags; must be reset on the boundaries that own them (grep their writers before trusting a reset)
last-type channelg_last_type_ptrthe expression walk's implicit return value: every gen_expr sets it; the caller reads it. Signedness is carried in the type string itself (u8/u16/… stay distinct from i8/i16/… in this channel; readers use ty_is_unsigned, emission normalises via nurl_llty) — the former separate unsigned flag was removed in the A1 rework; see the comment at nurl_set_last_type
string literalsg_str_syms, g_str_idxinterned literal metadata, flushed as module constants
genericsg_generic_syms, g_generic_struct_syms, g_struct_inst_symsstored templates (token streams) + emitted-instantiation dedupe; whole run
traits/implsg_trait_syms, g_impl_{ret,name,trait,pos}_symsmethod dispatch keys method##llvm_type → return type / mangle / owning trait / declaration site (coherence)
dyn traitsg_dyn_needed, g_dyn_flat_*, g_super_obligationsaccumulator strings (not tables): vtables to emit + supertrait proof obligations
closuresg_closure_defs/types, g_func_count, g_type_count, g_*_emit_basedeferred closure bodies/types; the emit_base watermarks make the between-items flush idempotent
result typesg_res_type_syms! T E lowering metadata for try-propagation checking
borrow checkerg_bck (per-fn record list + warnset + ml_* lines), g_bck_depth, g_bck_closure_depth, g_bck_errorsstatements are RECORDED during the walk, analysed at function end (bck_analyzebck_walk_seq over a byte-per-binding lattice array; names are interned to dense ids at bck_explode, rv_<id> is the reverse map for diagnostics)
escape analysisg_fn_inout/sink/escapes/invoke_only/ret_param, g_pending_escapeinterprocedural summaries; g_pending_escape holds checks parked on forward/generic calls, replayed by resolve_pending_escapes
auto-dropmem_* journal (function-local handles) + g_auto_drop_stringsowned-resource journal driving scope-exit frees
DWARFg_dbg_*metadata id allocator + queued !DI* blobs; only live under --g
lintg_lint*usage recording; only under --lint
visibility/modulesg_vis_syms, g_pending_pubimport graph + pub tracking for cross-file access checks

Reset discipline: everything is initialised in main() and the process compiles exactly one program — there is no reuse between files except via $ imports walked in the same run. If you add a global, initialise it in main() next to its family and document the writer set in the appendix (regenerate it — §6).

4. Memory discipline

The compiler used to allocate arena-style — temporaries were simply not freed. As of 0.30.0 the self-compile leaks nothing, and tools/leakgate.sh (CI, zero tolerance) is what holds that. The invariant that made the arena era safe still holds and still must not be broken:

fresh owned allocation**. Emitted auto-drop frees a string temp that is passed directly as a call argument, so returning a borrowed pointer (e.g. a suffix of the input) is a use-after-free / invalid free waiting to happen. This was measured, attempted and reverted — see the M1 commit; nurl_llty's comment documents the same hazard.

strings by index instead of re-slicing remainders (__bck_st_get_at is the pattern), and build outputs in one exact-size buffer instead of concat-accumulating.

rather than abandoned, which is a property of its **ownership summary**. Three ways to lose it, all of which cost real memory before: returning ^ # s ( strdup … ) so the cast hides freshness from return-site inference (declare the function __ret_owned); defining a helper below its only caller, so no summary exists at the call site (hoist it, or rely on the forward-call ownership channel); and a mixed join — a borrowed parameter on one arm, a tracked local on the other — which vetoes the marker for the whole function. If you add a string-returning helper, check leakgate before you check the clock.

state-map rewrite) → 125 MB → 115.9 MB (lattice-array rewrite) → 18.5 MB (ownership campaign) → 24 MB (the module buffer the dead-function pass needs). tools/memgate.sh (CI) keeps it from regressing; if the gate fires on your PR, instrument first — per-site counters over nurl_str_slice/str_skip_word call sites found the last one in two runs.

5. Changing the compiler safely

  1. ./check.sh compiler/nurlc.nu — fast frontend syntax/type gate.
  2. Build and self-compare: the OLD binary and your NEW binary must emit

byte-identical IR for the same source unless your change is supposed to alter codegen (then goldens move and you explain why).

  1. ./build.sh — bootstrap fixed point (stage1 ≡ stage2) + the full

golden corpus. Borrow-checker changes: the borrow_* goldens are the behavioural spec.

  1. ./tools/memgate.sh — the peak-RSS budget.
  2. ./tools/dcegate.sh — proves the dead-function pass still drops

unreachable code AND still keeps the indirect routes (dyn vtable thunks, % Drop, closures, monomorphs). The corpus catches only the second half: a pass that kept everything would stay green.

  1. ./tools/leakgate.sh — the self-compile must leak nothing. Needs a

./build.sh --san --no-tests first; zero tolerance, no budget.

  1. Sanitizers run in CI (build.sh --san + run_san_tests.sh) — run

locally when touching drop/ownership codegen.

6. Appendix — every global and its writers (generated)

Regenerate after adding/renaming globals: python3 tools/gen_globals_map.py (rewrites this section in place).

globaldeclaredwritten byholds
g_auto_drop_strings:1491mainPhase 2B auto-drop-strings feature flag. Default ON. Compiler's own source uses patterns (strings stored via n
g_bck:872main
g_bck_closure_depth:881gen_closure_expr
g_bck_depth:875bck_block_enter, bck_block_exit, bck_fn_begindata (statement list etc.); allocated in main() only when --borrowck is set
g_bck_errors:885bck_emit_errorcapture hooks no-op so closure statements do not inline into the enclosing function's list (so closure scopes
g_bck_gen:876bck_analyze
g_bck_inn:880bck_analyze, bck_internintern table — bumped per bck_analyze so entries from earlier functions read as misses without any table clear
g_borrowck:853main── Borrow-checker state ───────────────────────────────────────── g_borrowck is 1 (ON) by default; `--no-borro
g_closure_defs:839main
g_closure_emit_base:843emit_closure_globals, main
g_closure_types:840main
g_dbg_blob_syms:969dbg_initmodule-flag id we might add later
g_dbg_cu_id:972dbg_init
g_dbg_current_file_id:1001gen_fn_decl_concretedefining path for the mono being emitted (the mono's lexer filename is the synthetic <generic …>). Set/resto
g_dbg_current_loc:975dbg_synth_begin, dbg_synth_end, gen_closure_expr, gen_fn_decl_concrete +1(0 outside any function)
g_dbg_current_subprogram:973dbg_synth_begin, dbg_synth_end, gen_closure_expr, gen_fn_decl_concrete
g_dbg_enabled:966main── DWARF debug-info state ─────────────────────────────────────── All zero/empty when --g is OFF; emit helpers
g_dbg_file_id:971dbg_initflushed at end-of-module by dbg_flush
g_dbg_file_syms:990dbg_inituses this instead of nurl_lex_line for the !DISubprogram source line. Set by emit_one_instantiation so per-m
g_dbg_next_id:967dbg_alloc_id
g_dbg_override_file:995emit_one_instantiationevery source file gets its own !DIFile and a subprogram debug-attributes to the file that DEFINES it (imports,
g_dbg_override_line:984emit_one_instantiationthe type for every local until Phase 6 lays down per-LLVM-type DIBasicType entries indexed by vt.
g_dbg_placeholder_ty:980dbg_initdbg_init and reused for every fn. Phase 6 will replace with per-fn signature types.
g_dbg_subroutine_ty:977dbg_initemit_dbg_eol then omits , !dbg !N)
g_dbg_type_syms:1019dbg_init
g_dce:21278main
g_dce_end:21284dce_emit_module, dce_free
g_dce_live:21285dce_emit_module, dce_free
g_dce_map:21288dce_emit_module, dce_free
g_dce_mod:21282dce_emit_moduleThe module text, as an integer cast of a BORROWED s. Deliberately not a : ~ s global: a mutable string glo
g_dce_qn:21287__dce_mark_name, dce_emit_module
g_dce_queue:21286dce_emit_module, dce_free
g_dce_start:21283dce_emit_module, dce_free
g_defer_count:759gen_defer, gen_fn_decl_concrete
g_diag_recover_active:92parse_program
g_did_ret:726gen_closure_expr, gen_cond, gen_defer, gen_fn_decl_concrete +5
g_dyn_flat_out:826__dyn_flat_add, __dyn_flat_resetScratch accumulators for dyn_flat_methods (a NURL fn returns one value, so the recursive supertrait walk threa
g_dyn_flat_seen:827__dyn_flat_add, __dyn_flat_reset
g_dyn_needed:823dyn_note_needed<Trait>__tparam → trait's generic type-var name (e.g. "T") <Trait>__defaults → space-separat
g_err_count:91__diag_abortMulti-error mode (rustc-style): while parse_program's per-declaration recovery frame is active (g_diag_recover
g_ffi_host_imports:860maing_ffi_host_imports is 1 when --ffi-host-imports is passed: external &-FFI libraries are then satisfied by
g_fn_escapes:923mainPer-function escaping-parameter map. g_fn_escapes[fname] is the space-separated list of 0-based indices of p
g_fn_inout:902mainPer-function inout-parameter map. g_fn_inout[fname] is the space-separated list of 0-based indices of `inout
g_fn_invoke_only:937mainPer-function invoke-only parameter map (closure-env reclamation, docs/MEMORY.md §7.4). `g_fn_invoke_only[fna
g_fn_pos_syms:789main
g_fn_ret_param:961mainPer-function returned-parameter map. g_fn_ret_param[fname] is the space-separated list of 0-based indices of
g_fn_sink:912mainPer-function sink-parameter map. g_fn_sink[fname] is the space-separated list of 0-based indices of sink p
g_fn_slice_decls:757gen_fn_decl_concrete, mem_slice_decl_addCheap per-function gate for the Phase 2D slice machinery: most functions declare no owned slices at all, and t
g_func_count:842gen_call_kwargs, gen_closure_expr, main, store_closure_func
g_generic_struct_syms:761main
g_generic_syms:760main
g_impl_name_syms:766main
g_impl_pos_syms:798main@ fname scan registration. Two files are free to each have a private __get-style helper IN THEIR OWN HEADS
g_impl_ret_syms:765main
g_impl_trait_syms:767main
g_in_match_arm:747gen_matchNon-zero while parsing a ?? match-arm body. The XOR-confusion warning in gen_ret keys off "a non-terminator
g_last_closure_nonsend:838gen_closure_exprrecord per impl block, verified after scan_fn_sigs once every impl across the program (incl. imports) is regis
g_last_type_ptr:3866nurl_set_last_type
g_lint:1531mainUnused-symbol lint (opt-in via --lint). Default OFF so ordinary builds — and the compiler's own bootstrap, w
g_lint_gen:1535lint_fn_begin
g_lint_reads:1534lint_init
g_lint_recording:1542main1 only during the main parse_program pass. Cleared before flush_deferred_instantiations so synthetic generic m
g_lint_syms:1532lint_init
g_lint_used:1533lint_init
g_mono_tparam_tys:1018emit_one_instantiationSpace-separated list of the concrete type-arguments substituted for a generic function's type parameters in th
g_owned_globals:730gen_const_declNames of the mutable string globals that own their buffer (each has a compiler-emitted <name>__nurlown flag)
g_pending_escape:949mainDeferred interprocedural-escape checks (docs/MEMORY.md §3 forward / generic boundary). A stack reference passe
g_pending_pub:1506parse_toplevel_decl, scan_fn_sigs, vis_take_pending_pubVisibility (grammar v2.0). Tracks the source-file of every @-defined function and per-file strict-mode opt-in.
g_priv_file_count:785priv_file_id
g_priv_file_ids:784main??/? arms). Consumers that NEED a value (a cast, a let/assign) die with this appended, so "produced no val
g_priv_owner_files:787main
g_priv_owner_ids:786main
g_priv_warned:788main
g_ptrtab:871main
g_res_type_syms:239main── Res-type NURL tracking (must be declared before parse_type_res) ── g_res_type_syms is initialized to a new
g_ret_forbidden:741gen_cond, gen_logical_or_bitwise_and, gen_logical_or_bitwise_or, gen_operand +1Cascade guard: 1 while parsing a VALUE OPERAND (a binary/unary/cast/ member operand, a call argument, a ?/`?
g_stmt_bare_lit:2625gen_stmtg_stmt_bare_lit — set by gen_stmt to 1 when the statement it just parsed was a bare numeric/string LITERAL in
g_stmt_bare_value:2638gen_stmtg_stmt_bare_value — the literal flag's general sibling (critic A2, the last silent prefix-arity cascade): set
g_stmt_col:2583gen_stmtg_stmt_col — column of the current statement's first token, captured alongside g_stmt_line. die_stmt anchors
g_stmt_line:2576gen_stmtg_stmt_line — source line of the statement gen_stmt is currently parsing. Read by gen_ident's "unexpected toke
g_str_idx:724emit_deferred_cstr, emit_str_global, gen_str_lit
g_str_syms:725main
g_strict_borrowck:870main--strict-borrowck (off by default) enables three additional checks: (1) aliased mutation through `. obj fiel
g_struct_inst_syms:764main<sname>__stparams → space-separated type-var names (e.g. "T" or "K V") <sname>__sbody → raw body source inc
g_super_obligations:828scan_impl_decl
g_trait_syms:805mainfirst registration. A $-imported impl is scanned once per importer, so the SAME (method, type) is registered
g_type_count:841main, store_closure_type
g_type_emit_base:844main
g_vis_syms:1507main
g_void_reason:768__void_reason_clear, gen_cond, gen_match, int_width