Datafun Compiler Guide

Reference for the datalove-datafun compiler architecture.

Contents

Crate Organization

Core Compiler Pipeline

Crate Responsibility
datalove-datafun-parser Lexer, parser, bracer
datalove-datafun-ast AST types (Statement, Expr, etc.)
datalove-datafun-common Types shared by resolve and tycheck: Type, TypeFunction, ParsedModuleGraph, DbClone, generics
datalove-datafun-resolve Name resolution (type aliases, function signatures)
datalove-datafun-tycheck Type checking, call resolution, type synthesis
datalove-datafun-sema Types shared by ownership analysis and lowering: ExprTypes, CallTargets, AnalysisError, DropSchedule
datalove-datafun-ownership Ownership analysis (move/borrow/drop tracking)
datalove-datafun-lower AST to IR lowering
datalove-datafun-const Const evaluation (CTFE), const inlining, dead code elimination
datalove-datafun-ir IR types (IrCodeUnit, IrType, ValueId, layout, registries)
datalove-datafun-intrinsics IntrinsicId and signatures for icall operations
datalove-datafun-compiler Salsa-tracked pipeline, Database, compile_modules(), lower_module_graph_with_evaluator(), specialization
datalove-datafun High-level facade, ModuleCompilationPipeline, ScriptCompiler, workspaces, rider build/load

Execution

Crate Responsibility
datalove-datafun-interp Interpreter, CallDispatcher, NativeFunctionTable, CTFE evaluator
datalove-datafun-cranelift Shared Cranelift utilities
datalove-datafun-cranelift-aot Cranelift AOT compilation
datalove-datafun-cranelift-jit JIT compilation and tiering dispatcher
datalove-datafun-c-aot AOT backend emitting C11 source linked against the runtime
datalove-rt The runtime: C ABI (c), Rust wrappers (rust), implementation (impls)
datalove-rtdt Runtime type descriptors (TyDesc), runtime-side layout, anypack

Supporting

Crate Responsibility
bcts (imported as bct) Base compiler toolkit: ModuleGraph, Module, ModuleId, source maps, text interning
datalove-datafun-pkg Package loading, PackageWorld, worldfile parsing, module resolution
datalove-pkg-manifest A package's manifest.toml, read by package_load and by sys/build.rs
datalove-rti The runtime's call table as riders see it, and ABI_VERSION
datalove-buildinfo BuildInfo: whether this binary was built from a checkout or a release
datalove-paths work_dir(), where native components are built
datalove-ct Compile-time utilities, query logging
datalove-diagnostic Diagnostic/error infrastructure
datalove-datalit Data literal types and typechecking
datalove-exampletest Snapshot test harness
datalove-worldgen Generator for random worldfiles that typecheck
datalove Thin facade crate holding a plain salsa Database

Drivers and tests

Crate Responsibility
datalove-cli Command line driver: build, run, AOT, native component linking
datalove-repl REPL inputs, commands and UI-agnostic app
datalove-script-engine Interactive scripting engine: script units in a session
datalove-repl-rat Ratatui REPL application and terminal
datalove-sys-packages (sys/) The system library as the binary carries it: embedded sources, linked riders. The library is the crate root, so it can be packaged
datalove-rider-sys-std (sys/std/rider) Native rider implementations for sys/std
datalove-tests Workspace-wide test suites
datalove-rt-tests Runtime tests, separated so the runtime need not depend on datalit
datalove-bench Divan benchmarks

The sys/ tree at the repository root is the standard library: sys/std/*.dfm modules, plus the sys/std/rider Rust crate and the rider.dli interface inside it. sys/std/rider is a workspace member like anything under crates/.

datalove-sys-packages sits above everything else: nothing in the compiler depends on it, so editing a .dfm recompiles no compiler crate. See The Shipped Binary.

Module Compilation Pipeline

Module compilation runs through five phases. Phases 1-4 are handled by compile_modules(), phase 5 by lower_module_graph_with_evaluator(). Each *_phase is its step with, in a parallel compile, the step's per-module queries computed ahead of it; see Parallel Execution.

Source Text
    |
    v
[Phase 1: Parse]
    |   parse_phase
    |   Per-module: parse_module_full [tracked]
    |   Rider sources arrive here as raw strings
    v   Output: ParsedModuleGraph (statements + resolved_riders)
    |
[Phase 2: Name Resolution]
    |   resolve_phase
    |   Per-module: resolve_module_names [tracked]
    v   Output: AllModuleNameResolutions
    |
[Phase 3: Typecheck]
    |   typecheck_phase
    |   Per-module: resolve_module_imports, typecheck_module [tracked]
    |   An importer asks resolve_module_exports of what it requires
    v   Output: ModuleGraphTypecheckResult
    |
[Phase 4: Ownership Analysis]
    |   ownership_phase
    |   Per-module: analyze_module [tracked]
    v   Output: ModuleGraphAnalysis
    |
[Phase 5: IR Lowering]
    |   lower_module_graph_with_evaluator
    v   Output: ModuleGraphLoweringResult

Errors propagate between phases: modules with typecheck errors skip ownership analysis and lowering.

Integer synthesis: bare integer and hex literals synthesize as int (bigint). With an expected type from context (binding annotation, function parameter, checked arithmetic operand), the literal checks against that type instead. Both datalove-datafun-tycheck and datalove-datalit follow this rule.

Float literals take their precision from context the same way, f64 being the fallback when nothing supplies one. Both defaults are the widest of their family, on the grounds that a type chosen without knowing what it is for should lose the least. A negation does not stop the expected type reaching the literal under it: the sign says nothing about the width, so -3.9 checks against f64 exactly as 3.9 does, and -5 is out of range for a u32 rather than merely the wrong type.

The two languages reach that by different routes - datalit carries a sign inside the literal token, datafun parses a negation as an operator over an unsigned literal - which is a place they can drift apart, and did. literal_type_equiv_tests holds them to the same answers.

Neither takes an integer literal where a float is expected, or the reverse. There is no implicit conversion between the two families; see Numeric Widening in the spec, and f64.from_int and int.from_f64 for the named conversions.

Token Gluing

Whitespace is significant inside an expression: 1 . 5 is not a float and a -b is two expressions. The rules are in botspec.md ยง2.4, Spacing; this is where they live in the code.

The lexer breaks words at character-class boundaries and never puts them back together, so 1.5 arrives as three tokens, and whitespace and comments are filtered out before either parser sees a token. That leaves the spans as the only evidence that two tokens touched. Two tokens are glued when the first one's span ends where the second's begins; a comment between them leaves a gap, which is the wanted answer for free.

All of it is in bcts/src/parser_util.rs, so that datafun and datalit give the same answer for the same spelling:

The literal reader runs before fixity is judged, and fixity only sees the operators it declined. That is why 2.5e-10 is one number while x-1 subtracts. There is no sign rule: eat_number takes a glued leading - whenever it is offered one, and whether it is offered one is the grammar's business. Datafun's prefix - claims the sign first in expression position; datalit has no prefix operator, so there the literal owning it is the only reading.

Written Said
1 . 5 a float is written without spaces in it (write it as 1.5)
2.5e - 10 an exponent is written without spaces in it (write it as 2.5e-10)
1u8 numeric suffixes are not supported (write a type hint, as in : u8 / 1)
a -1 this - is spaced as a prefix operator
p . 0, x ? this . / ? is written apart from what it applies to

The last two come from lopsided_operator in the datafun parser's expr.rs. Datalit has no operators to misplace, and a - there that no number follows is D050, "unexpected minus sign".

Phase 5: IR Lowering Detail

Lowering has five internal phases that handle const evaluation correctly:

Phase 5a: Lower all functions
    |   lower_all_module_functions walks the graph,
    |   lower_module_functions [tracked] does a module
    v   Reused for both CTFE and final assembly
    |
Phase 5b: Evaluate consts
    |   evaluate_all_module_consts (non-tracked)
    |   Uses CTFE evaluator with lowered functions
    |   Function-level consts qualified as "func_name::const_name"
    v   Output: HashMap<ModuleId, ModulePreResolvedConsts>
    |
Phase 5b': Inline evaluated const values
    |   const_inlined_module [tracked], per module
    |   Then the branches, blocks and code that leaves dead
    v   Output: the lowered IR, consts as `Const` instructions
    |
Phase 5c: Specialize comptime functions
    |   specialize_comptime_functions (non-tracked)
    |   One copy of a callee per instantiation; see below
    v   Output: the lowered IR, with the copies in it
    |
Phase 5d: Assemble and finish modules
    |   lower_module [tracked]
    |   Reuses pre-lowered functions
    |   optimize::finish per function: inline calls, dead code, promotion
    v   Output: ModuleGraphLoweringResult

The passes run in one order in both pipelines -- const inlining, specialization, inlining, dead code, promotion -- and the script compiler runs the last three through the same optimize::finish. See the plan for the passes.

Why this structure:

Both tracked phases key on the module, so an edit lowers the module that changed. What makes that hold is the arguments: reachable_func_ids gives each one the entries it can actually call rather than the whole world's, and the IR travels under a handle so neither phase copies a module to hand it on, or hashes one to look it up. The incremental_lowering tests assert the property directly.

ModuleLowered, the handle the phase passes around

Phase 5a hands back a ModuleLowered per module rather than the IR. It is a tracked struct with one untracked field, the ModuleId, and one #[tracked] field holding Arc<ModuleLoweredFunctions>. A tracked struct's identity is a hash of its untracked fields, so a handle's identity is a module's, and passing one to a query costs a word. The IR is read through the tracked field's own dependency edge, so a consumer still re-runs when the IR moves and still backdates when it does not.

That is what lets the passes between 5a and 5d be memoized at all. They are functions of the whole program's IR, and keying a query on the IR itself meant hashing every instruction to answer it -- which lower_module was paying, at around a tenth of an unchanged recompile.

lower_module_functions [tracked]  ->  ModuleLowered per module
merge_lowered [tracked]           ->  one ModuleLowered from a module's and a batch's
const_inlined_module [tracked]    ->  ModuleLowered for phase 5b' to rewrite
specialized_module [tracked]      ->  ModuleLowered for phase 5c to rewrite
lower_module [tracked]            ->  takes the handle, not the IR

A handle is passed through, not re-minted, when its module did not change. A tracked struct's identity map belongs to the query instance that created it, so a graph-wide pass minting handles would give every module a new id whenever any module was added -- and phase 5d is keyed on the handles, so every module in the world would assemble again. Nothing between 5a and 5d is graph-wide now: merge_lowered is keyed on a module's two handles, and a module no batch reached keeps its first handle. The module_memo fixtures are what catch a regression here.

Phase 5c is gated on a memoized question. module_has_comptime_calls is a tracked query per handle, and a program where no module makes one skips specialization without reading an instruction. Working out the plan otherwise means walking every function of every module on every compile.

Phase 5b is gated the same way. graph_declares_consts is tracked on the parsed graph; a program with no const anywhere has nothing for CTFE to evaluate, and the module registry built to have CTFE on hand is not free.

The registries come out of memos

There are two ModuleFunctionRegistrys per compile -- ctfe_module_registry, for const expressions that call across modules, and the one the pipeline hands to the interpreter -- and each has an entry per function in the world. Building them and dropping the previous pair was about two thirds of what an unchanged recompile of the system library cost, and about 44% of the synthetic one.

Both are tracked. The CTFE one is keyed on phase 5a's handles. The pipeline's, module_function_registry in datafun/src/pipeline/module_pipeline.rs, is keyed on the ModuleGraphLoweringResult, which is a tracked struct whose identity is the per-module results it holds -- so it is a word to hash and it moves exactly when some module's assembled IR does. The native rider units go in both, by add_native_rider_units in tracked_lower.rs: a const can call a native, directly or through a function, so the CTFE registry needs them too. The compiler core still never spells a linker symbol; NativeContext::new takes the rider and function names and the IR crate's native_symbol does the spelling, which is the native ABI's business. See the native ABI.

The pipeline's is capped at lru = 4. Its key moves on every edit, so the memo for the key before it is of no use to anyone, and uncapped it grew the database by about 140KB an edit. Eviction runs once per revision, so nothing is dropped underneath a compile, and the capacity only has to cover the one a recompile asks for plus the one an edit displaces.

SharedModuleContext borrows the function id lookup (func_id_lookup, also tracked) out of its memo rather than holding a copy of every function name in the world.

What is not tracked, and what stands in the way

lower_module_graph_with_evaluator is still a plain function, and the two const evaluations under it are not salsa queries. They cannot be, because they take a &mut dyn CtfeEvaluator: an evaluator is mutable state with no notion of equality, so it cannot be a memo key, and salsa refuses a type parameter on a tracked function, so making it generic would not help either. A tracked query would have to build its own evaluator, which means reaching the interpreter and the natives from inside one.

They are cached outside salsa instead, per module, by ConstCache in compiler/src/const_cache.rs, which the pipeline owns and passes down. A module's key is a content hash of its source and of everything it requires, transitively, plus the world's data files; a const can only call into what its module requires, so a module whose closure did not change evaluates to what it did before. The riders are hashed by the pipeline and a change to them, or to the natives, empties the cache. A module whose consts had an error is not kept. CompilerOptions::cache_consts turns it off, and DATALOVE_VERIFY_CONST_CACHE (or set_verify_const_cache) evaluates every hit anyway and panics on a difference; tests/const_cache_tests.rs runs that way, and also holds the cache to evaluating exactly the edited module and its requirers.

A compile where every module hits saves a little more than the evaluation. The evaluator is never used, and dropping one that has run is not free -- about 0.35ms on the store demo, which is the interpreter's own state going away. And the batches of functions waiting on consts and assembly key on the consts, where a reused Arc compares equal by pointer and a freshly evaluated one by walking the value; that was about 0.1ms of the store's two four-thousand-entry maps.

The script compiler's consts are not cached; each script unit evaluates its own as it compiles.

specialize_comptime_functions has the same obstacle for the same reason: it evaluates a comptime function's const bindings once the instantiation is known, so it needs the evaluator too. module_has_comptime_calls gates it rather than memoizing it, so a program that does specialize re-specializes on every compile.

Inlining needs other modules' bodies, and gets them in phase 5d rather than in a pass over the graph: lower_module is given InlineModules, the handles of the modules that define an inline function, interned so that an unchanged set is the same key. A module's inline functions are picked out once per compile, by inline_functions, a query on its handle, and one Inliner serves a whole module, so a callee is expanded once per module. Every module is keyed on the set, so an edit to a module defining an inline function assembles every module again; a key per function is the plan's next step.

Why nothing between 5a and 5d is graph-wide

A generic's calling convention is one descriptor per type parameter, read off its signature, so nothing about a callee's descriptors comes from its body and nothing is closed over the call graph. Erasure needed such a closure, a fixpoint keyed on every module at once (close_shapes_over_calls), and most of the work of making edits cheap went into keeping it from re-running; with generics by reference it is gone. Each function's descriptor table is computed from its own unit when it is lowered (descriptor_table).

The skip_const_inlining flag skips phases 5b and 5b', and the promotion in 5d, lowering const bindings as let bindings. Phase 5a still runs: a module-level const is resolved where its reference is lowered rather than by a later pass, so there is nothing there for the flag to skip. Used for testing CTFE accuracy. Callers of the high-level pipeline set CompilerOptions::const_inlining.

Const Parameter Specialization

Functions with const parameters are monomorphized during phase 5c:

Phase 5c: Specialize comptime functions
    |   specialize_comptime_functions
    |   Round, until no copy is made:
    |   - Scan the lowered IR for ComptimeCall, resolving each const
    |     argument against the Const instruction that defines it
    |   - Build one copy of the callee per instantiation not yet copied
    |   Then:
    |   - Point those call sites at the copy
    v   Output: the original functions, plus a copy per instantiation

Specialization is additive. The original function is kept, with the signature it was lowered with, and the copies are added beside it as name__ct0, name__ct1 and so on. A transform that rewrote the callee's signature in place could not do that: it would oblige the compiler to find and rewrite every call site at once, and a call site it missed would pass a const argument into a parameter that had become something else.

That matters because the module graph is not the whole program. Script units compile afterwards, one at a time, against modules already specialized, so a script line may name an instantiation no module call site asked for. ScriptCompiler::phase_specialize handles those separately, putting the copies in the script unit's own nested_units reached by CodeRef::Local -- the same vehicle a script-local function uses -- so the module it called into does not have to change. A call whose const argument did not survive as a constant, which is every one of them under skip_const_inlining, keeps its ComptimeCall and runs the original.

What a const argument may be. The name of a const binding, and nothing else -- not a literal, not an expression. The value has to be one the compiler already holds, and a binding is the one form that says so on its face; anything else would mean deciding case by case which shapes to see through, which is how a rule stops being one. It is also what makes monomorphization the right shape: every call site's value is known when the call is compiled. A const parameter counts, being a const binding within the body, which is what lets one comptime function pass its parameter to another.

Const parameters and type parameters combine, independently: specialization deletes the const parameters, and a copy keeps its type parameters, its type_param_count and its descriptor table, so it is as generic as its original. One copy per const instantiation serves every type instantiation, the call site handing over the descriptors it would have anyway. ComptimeCall carries bindings exactly as Call does, and the rewrite carries them across. The one refused case is a const parameter whose own type is a type parameter, const x: T (ComptimeParamOfGenericType, F077): specialization makes one copy per const value, and the copy is still generic, so the const has nowhere to go.

Where instantiations come from. They are read out of the IR. A const argument is passed by reference, as a const parameter is lowered, and the operand at a call site is the const itself: defined by a Const, or by a StaticRef carrying its value. So the values the rewrite looks for are the values the plan was built from, and the two cannot disagree. Specialization used to resolve them instead by the name of the const binding, which the typechecker recorded in a ComptimeCallSiteRegistry; that could disagree, because two function-level consts sharing a name in one module resolved to the same value. The registry is gone -- the typechecker still enforces that a const argument names a const binding, but records nothing.

Identity of the copies. compute_func_id_map assigns FuncIds from source statement order and cannot assign one here, since the copies are in nobody's source. They take ids after the highest source-derived one in their module. The name has to be a valid identifier, because both AOT backends use it as a linker symbol.

Const bindings inside a comptime function. One naming a const parameter has a value per instantiation rather than one, so phase 5b leaves it alone. Making a copy evaluates it: the parameters are seeded with what the instantiation passes, the same CTFE that evaluates every other const runs, and inline_function_consts writes the results into the copy -- which also turns a branch whose condition has become constant into a jump. So const means the same thing inside a comptime function as outside one, including through arithmetic and through calls.

Rounds. A comptime function that calls another only says what it passes once its own const parameters have been substituted, so copying can uncover instantiations the scan before it could not see. The scan and the copying therefore repeat until a round makes no copy.

Limit. MAX_INSTANTIATIONS caps a function at 64. Each instantiation is a whole copy in the object file, so going over is reported rather than paid. MAX_ROUNDS caps the rounds, as a backstop; running out leaves call sites unspecialized rather than wrong.

Call site handling: The lowering phase emits ComptimeCall instructions for calls to functions with const parameters. Specialization turns the ones it can place into a regular Call to the copy, leaving out the const arguments the copy does not take. They were borrowed, so nothing is dropped. A copy defines each const parameter at entry, as a StaticRef for a non-copy type and a Const otherwise.

Testing: The skip_specialization flag (like skip_const_inlining) allows differential testing - comparing specialized vs unspecialized output to verify correctness.

The union-branch approach, tried and dropped. The first implementation compiled one function per callee with a Switch on a tag, one arm per instantiation, on the theory that a branch is cheaper than a copy. It is not: each arm was a copy of the body (build_dispatch_blocks cloned every block once per instantiation), so it was monomorphization plus a switch on a value every call site passed as a constant, in one oversized symbol that inlined whole or not at all, and whose instantiations shared one call count in the JIT's tiering. What decided it, though, was that it rewrote the callee's signature in place, which obliged every call site in the program to be found and rewritten -- and script units compile later than that. A module function called from both a module and a script miscompiled exactly that way (fixture specialize_differential/023_module_and_script_call). ComptimeCall survives from it. If a call site is ever allowed to pass a value chosen at run time from a known set, the tag becomes the right shape for that case.

What remains. A function all of whose call sites were specialized keeps an original nobody calls. That is deliberate, since a later script line may call it, but it is dead weight in an AOT build, where there is no later line.

See Generics and Specialization for why this machinery is not a foundation for type parameters.

Field Projections

A field projection borrows part of an aggregate. It does not produce a value anyone owns, so reading one has to go through a reference:

That last distinction is why lower_operand and lower_expression_for_ref are not the same function: one wants what the reference points at, the other wants the reference. lower_operand used to send every projection through lower_expression, which moves the field out and records it as a temporary to drop -- freeing a field the aggregate still held. @ on a linear field went that way and produced free() called on untracked pointer, and on the way to it a copy_nonoverlapping whose ranges overlapped. Fixture: interp/949_field_proj_clone.

A tuple element is the same projection with a numeric selector rather than a name, and goes through the same code for all of it -- read, borrow, clone, ref/mut/out, assignment and nesting. interp/949_field_proj_clone runs the two side by side and they agree exactly.

Only a linear field takes that route in an operand position. A copy field keeps the direct GetField, having nothing to move out and nothing to free.

Index Projections

An index step is not the same operation and does not have the same problem. GetField copies the field's bytes -- shallow, and its own comment says only copy types may go that way. ListGet, MapGet and TensorGet call dtlv_rti_clone_local, so the element arrives owned and dropping it afterwards is right. That is why a[i]?@ has always been safe where p.a@ was not, and why routing index steps through the borrow path breaks the list_index_* fixtures: it turns a clone-on-read into a borrow.

The typechecker gates the two alike -- NonCopyIndexProjection unless ref_context -- so a linear element still needs @ or a borrow to be read. Writing through an index works for a list and a map, by mut, by out and by assignment (interp/950_index_write_forms).

In an operand position a non-copy index is borrowed like a field, so a[i]?@ clones once rather than cloning through ListGet and then cloning that. It holds for an element of a type parameter too: the reference is described by the element type's descriptor table entry, so reading through it reads the bytes as what they are. See Generics.

A copy element keeps the direct read: there is nothing to move out and nothing to free, so a borrow would buy nothing.

A tensor index is refused in all three, with ViewTypeMutBinding: it gives a view of a row rather than an element, and a view may not be bound mutably (interp/951_tensor_index_not_mutable). Reading and cloning one is fine.

Const Evaluation

Both pipelines evaluate consts through compiler/src/const_eval.rs: evaluate_const for one binding, evaluate_body_consts for a function body in order, and evaluate_instantiation_consts for a comptime function's body once an instantiation has given its const parameters values. What they evaluate against is a ConstEvalEnv: the typechecker's results, the evaluator, the functions lowered so far, and, in the module pipeline, the registry used to report a const that depends on a function still waiting on a module const (a cycle). Each binding takes one of three routes:

  1. Simple literals - booleans, integers, floats, strings, None extracted directly
  2. Const references - look up an already-evaluated const in the scope given
  3. Complex expressions - lower to a minimal script code unit, execute via CTFE, extract result

The unit for a complex expression carries every local function it can reach, so that a function it calls can call another. A const naming a const parameter has a value per instantiation rather than one; evaluate_body_consts defers it, and evaluate_instantiation_consts evaluates it with the parameter's value and the enclosing module's or script's consts in scope.

Generics

A generic function is compiled once. Inside it, a value whose type mentions a type parameter is a pointer at the value in its real layout, described by the function's descriptor table, which is built at run time from one descriptor per type parameter that the caller hands over. Generics: how it works is the full description: storage, calls, operations on a dynamic value, the runtime entries, and what each engine does. What follows is the orientation.

Nothing is converted at a boundary. A u32 passed for a T is a pointer at the u32, a (u8, u32) passed for a (T, u32) a pointer at the (u8, u32), and code outside generics is laid out and passed as it always was.

Types. A generic's parameter and return types are as written (IrType::from_type_hint_generic), with IrType::Param(i) for its ith type parameter, and so is every value and slot type in its body. A type mentioning one is dynamic (IrType::is_dynamic) and laid out as a pointer; its real layout is its descriptor's.

Descriptors. A generic takes one descriptor per type parameter after its ordinary parameters (FunctionContext::type_param_count), a count read off the signature and never the body. FunctionContext::descriptor_table lists every dynamic type the body uses; the function fills a table of their descriptors at entry, instantiating a template per entry (dtlv_rti_tydesc_instantiate) behind a one-entry cache keyed on the incoming descriptors. A call carries bindings, what it bound each of the callee's type parameters to, and hands over a static descriptor or its own table entry for each.

Storage. Each dynamic value and slot owns space on a shadow stack the runtime keeps, bumped once at entry and given back on return, and a move copies desc.size bytes into it. Whether an in argument is moved or copied is the argument's own descriptor's to say (TyDesc::PLAIN), since a copy value bound to a T is copied though T is not copy.

The pieces: Var in datalit::tycheck::Type, built only by datafun-resolve seeding the alias map; type_hint_mentions_param and from_type_hint_generic in lowering; bind_type_params and substitute_type_params in datafun-common::generics; descriptor_table in datafun-ir; and in each engine, a dynamic path per instruction, behind a gate in the compiled backends (check_dynamic_support) that refuses, by name, any it does not handle.

What it does not do -- bounds beyond float, fixedint and ord, so nothing can be done to a bare T but move it, drop it, clone it, print it and hand it back -- and why, is in What is refused. Read that before assuming something is a bug.

Native Riders

A rider is a set of native function signatures backed by a Rust crate. A module writes require rider <alias> and then imports the names it wants:

require rider std
import std.string_len

The signatures live in a .dli interface file (sys/std/rider/rider.dli), or in a rider <name> worldfile section for tests. Each is a native fun declaration, and they may be generic:

native fun string_len(ref self: string): index
native fun list_push<T>(mut self: [T], elem: T)

How it flows through the pipeline:

Execution:

Rider implementations follow the runtime C ABI: extern "C-unwind" fn(rt, arg0_ptr, arg0_tydesc, ..., result_out, result_tydesc) -> u8, returning 1 for Ok and 2 for Error.

The Runtime Kernel and What Belongs in a Rider

A package has at most one rider, shared by all its modules, and sys/std uses the same mechanism as any user package. The rule for what stays a dtlv_rti_* call the codegen emits by name, rather than a native function reached through the module system, is whether rider code itself needs it:

That migration is incomplete. The Cranelift backends still call constructors and domain operations as hard-coded runtime functions -- list_create, list_build_from_slice, list_push, string_from_bytes, the btreemap_* and btreeset_* builders, table_*, tensor_init, int_add and the rest of the bigint arithmetic -- mostly because literals and operators lower to them directly. Moving one means the codegen stops naming it and the module that wants it imports it.

The Shipped Binary

An installed datalove carries its standard library. repl, script and script --jit work with sys/ deleted and cargo off PATH. aot-compile does not: it emits a separate executable, and the runtime and riders that executable calls have to reach it as a library the linker is given, which is built with cargo.

The library did not always travel. The compiler used to find it with env!("CARGO_MANIFEST_DIR") at three sites, so an installed binary depended on the checkout it was compiled from still existing, unmoved. When it was missing the failure was silent - the REPL's engine thread died and every entry sat at "parsing..." forever.

Two things travel inside the binary, both assembled by sys/, which is the library and a crate at once:

What How Where it comes from
Module sources build.rs walks sys/, emits a table of include_str! sys/*/*.dfm
Rider interfaces The rider crate's INTERFACE constant; see below sys/*/rider/rider.dli
Rider functions datalove-rider-sys-std is a normal dependency; its generated symbols() gives addresses sys/std/rider

system_library() assembles them into a SystemLibrary, which is a PackageLibrary of sources plus natives: Vec<(String, *const ())>. Drivers hand it to WorkspaceDescriptor::from_system_library and to register_linked_natives. The raw addresses are not Send, which is why ThreadedExecutor::spawn takes fn() -> SystemLibrary and calls it on the worker thread rather than being handed the value.

The rider's crate is named, not embedded. build.rs writes each package's rider/ directory into the table as rider_crate_dir, by the same convention package_load uses when it reads a package off disk, and system_library() puts it in RiderDescriptor::crate_dir. So the std rider reaches an AOT-compiled program the way any other rider does: the compiler synthesizes a native component for whatever riders the module graph holds and builds it with cargo. See Native Riders.

That is what lets a program mix sys/ with a rider of its own. The binary used to link a prebuilt archive containing the std rider and nothing else, so a script importing a package with its own rider failed at link time with undefined symbols. datalove_paths::work_dir() gives the build somewhere to happen - $XDG_CACHE_HOME/datalove/work/<ABI_VERSION> - and cargo caches within it, so the component for a given rider set is rebuilt only when one of its rider crates changes.

What this costs in the tree

The embedding sits at the top of the crate graph on purpose. Had it gone where load_default_sys used to live, in datalove-datafun, every .dfm edit would invalidate the bottom of the stack: 22 s to rebuild what just test compiles, against 0 s before. From datalove-sys-packages, which only the CLI and a couple of test targets depend on, the same edit costs about 7 s, nearly all of it relinking the debug binary.

The loop that matters for stdlib work is untouched. std_engine_tests compiles sys/ off disk through WorkspaceDescriptor::load_sys_dir, so cargo test -p datalove-datafun --test std_engine_tests after editing a module recompiles nothing at all.

That only holds because the two copies cannot drift. sys/tests/embedded_matches_tree.rs asserts the embedded table is byte-identical to sys/, and that every native fun a rider interface declares is linked in. The REPL's engine_tests run against system_library(), so the suite covers the shipped path too.

Editing datalove-rt, datalove-rtdt or the rider crate no longer re-runs anything at build time. It invalidates the native components instead, which cargo rebuilds incrementally the next time a suite links one. Each work dir carries its own target directory, so the cost is per rider set rather than per build.

Checkout and Release Builds

The component rider_build synthesizes has to name the runtime crates and every rider as cargo dependencies, and there are exactly two ways to do it: by path, when the sources are beside us, or by an exactly pinned version (=x.y.z), when they are published. A range would only promise something semver-compatible, which is not the claim that matters when a rider and the runtime it loads into must agree on the layout of everything between them.

Nothing at run time can tell which applies, so datalove-buildinfo's build script decides while the answer is in front of it and bakes in a BuildInfo: Prod { version } or Local { git_sha, checkout }. It does not ask git whether it is in a repository -- a published crate unpacked or vendored inside somebody else's repository is in a checkout, just not this one. It walks up from its own manifest for a directory holding both sys/std and crates/datalove-buildinfo, which is this tree's shape and no other's, and uses git only for the revision, so a tarball of the tree is still the tree. git_sha is as of whenever datalove-buildinfo last compiled, not of this moment: watching .git/HEAD would relink everything above it on every commit, for a field nothing reads yet.

runtime_dep reads that for the runtime crates, which have no package to say where they came from. A rider's package does say: one with Rust source in its rider/ directory is named by path, and one carrying only its interface is named by the version its manifest asks for. A Local binary whose checkout has gone says so, naming the directory, rather than leaving cargo to report a missing path. datalove --version prints which kind of build it is.

So cargo install datalove-cli from a registry is meant to work, and the workspace is published to crates.io (0.1.0 at the time of writing). Every crate but the test and bench ones publishes, just publish-check dry-runs it, and just local-registry builds a registry out of the tree to try the release path against before anything is uploaded. datalove docs still reads env!("CARGO_MANIFEST_DIR"), being a repository tool.

The ABI Check

A rider library and the process loading it each compile their own copy of datalove-rti and datalove-rtdt, and nothing else would notice if the two disagreed: a rider would call a function with the wrong arguments or read a field at the wrong offset and carry on. So datalove-rti::ABI_VERSION hashes the shape of the runtime's call table and the sizes and offsets of the rtdt types that cross. It deliberately leaves out crate versions -- a release that moves no field should not invalidate a rider, and a version says nothing about whether index-64 is on.

rider_build writes DLR_ABI_VERSION into every dylib component it synthesizes, so no rider author does anything. load_rider_library reads it before looking up any dlr_* symbol and refuses a library whose value differs or is absent. Only a dylib is loaded, so only a dylib says. The shared work dir is named by the same value, so two datalove binaries sharing a cache do not build over each other. rider_abi_tests builds libraries that lie about their interface and checks both lies are refused.

Rider Manifests and Interfaces

A package with a rider has a manifest.toml whose [rider] section names the crate and its version, read by datalove-pkg-manifest. Unknown keys are refused rather than ignored, so a manifest written for a later datalove says so instead of half working, and a package with a rider and no manifest is an error rather than a guess. The manifest is what survives once the Rust source is stripped from a published datalove package.

The interface lives inside the rider crate, at rider/rider.dli, so the crate's own build.rs can read it within its package root. It reaches the compiler as Rust, not as a file: the crate exports INTERFACE = include_str!("../rider.dli"), and sys/build.rs writes rider_interface: Some(<crate>::INTERFACE) into the embedded table. The reason is a cargo behaviour worth knowing: a nested Cargo.toml silently removes its whole directory from the package around it, and include cannot override that. datalove-sys-packages is sys/, so sys/std/rider/ is never in it, and the interface arrives by dependency instead. embedded_matches_tree checks that route against the file on disk.

Script Compilation Pipeline

Scripts compile incrementally in REPL-style, accumulating exports across units.

[Phase 1: Parse + Resolve + Typecheck]
    |   Standard pipeline through typecheck
    v
[Phase 2: Lower functions]
    |   phase_lower_functions
    |   Lower all functions once
    v   Reused for CTFE and final assembly
    |
[Phase 3: Const evaluation]
    |   phase_const_eval
    |   evaluate_script_consts (script-level)
    |   evaluate_function_consts (function-level, uses script results)
    v
[Phase 4: Assemble IR]
    |   phase_assemble_ir
    |   Combines functions + module code
    |   inline_script_consts replaces const refs
    v   Output: IrCodeUnit with a Script context

Script units accumulate via AccumulatedLowerBindings:

Ownership across units

A unit moves out of the bindings earlier units own as it would out of its own: lowering emits Move for a consuming use that resolves to an ExternalValue/ExternalSlot, and the interpreter marks the earlier frame's value as holding nothing. What is typed at the REPL is a unit, so let b = a at the prompt empties a, and so does passing it to an in parameter. int is a bigint and not copy, so that includes integers. Naming a binding as a bare expression reads it where it lives and takes nothing.

Ownership analysis checks such moves with the machinery it uses for a unit's own bindings. ScriptCompiler passes it an ExternalBinding for every earlier binding the unit asked its environment for (externals_over), and each becomes a binding in the unit's top scope, ahead of the unit's own, so moving one twice, moving one in a loop, or moving one in only one branch is the same error it would be for a local. Lowering skips those binding ids (ScriptAnalysis::externals): it already has an operand for each. An external is never dropped by the unit, which is why live_bindings_for_return and the unit-end list leave them out.

What a unit does to the earlier bindings is part of its record: taken_externals for the ones it moved out of, written_externals for the ones it wrote by set or through a mut or out argument (with overwritten_externals the ones of those written on every way through the unit before anything read them), and revived_exports for the ones that held nothing and that it assigned to. A unit can also give away a binding it defined itself, which goes in dead_exports. dead_externals_over folds the records into the names a later unit may not use, and using one is D013. The reach of an edit needs UnitLowerRecord::takes and writes too, for rerunning a unit has to put back what it changed first; see script-reactivity-architecture.md. unit_end covers every non-Copy slot rather than only live ones, because a later unit can assign to a slot this one gave away, and the cleanup list is fixed at lowering time.

An expression unit that is a bare name computes nothing: lowering records the name in ScriptContext::result_name with no result value, and the executor reads that binding where it lives instead of copying it to print it.

Printing Types and Errors

A type a reader sees is written as datalove writes it, and one place spells that syntax out: TypeSyntax in datalove-datalit/src/type_syntax.rs, whose Display is the syntax -- [T], %{K = V}, ?T, (T,), { x: T }, enum { atom A, term B T }. Every form a type is held in maps itself onto it: TypeHint::syntax as written (an alias stays its name), datalit's Type::syntax as checked, PType's as half inferred (_ for what nothing decided), and datafun's Type::syntax, which adds functions as fun(A): R. type_to_string is that, printed. A new kind of type is a new TypeSyntax variant, which every form's mapping has to reach before it compiles, and what_is_printed_parses_back_the_same parses the printer's output and prints it again, so the printer cannot drift from the parser.

IrType has a syntax of its own, for IR dumps and the compiler's own faults: every compound a lowercase name with its parts in angle brackets (list<int>, tuple<u8, string>, param<0>), so an IR type is never taken for a datalove one. Nothing a user sees prints one. The REPL takes its types from the typechecker; see repl-architecture.md.

A type error is worded once. Each PendingDiagnostic is built into a diagnostic in one function, build in datafun-tycheck/src/emit.rs; emitting files it, and format_pending_diagnostics writes the same diagnostic as a line (line 3, col 7: error[F016]: ...). A TypeError travels without its diagnostic -- as the REPL's report of a unit that did not compile, and as a type error with no span to render -- and says itself with its Display. Where a report site words an error from the error alone, as report_place_error does, the message is that Display. A message names a type by its syntax, not as Option or list; where there is no type to show, it says what was wrong in words (ctx.error_worded), rather than putting the words where F016's types go.

IR Types

What follows is the part of the IR a reader of this guide needs in order to follow the pipeline. The complete reference -- every instruction and terminator, the layout rules, generics and descriptors, the passes and the invariants -- is The Datafun IR.

IDs

pub struct ValueId(pub u32);    // SSA value (immutable)
pub struct SlotId(pub u32);     // Mutable slot (var bindings)
pub struct ParamId(pub u32);    // Function parameter
pub struct BlockId(pub u32);    // Control flow block
pub struct FuncId(pub u32);     // Module-local function ID
pub struct CodeUnitId(pub u32); // Code unit, local to its containing scope
pub struct IrModuleId(pub u32); // Module number, by path (not salsa ModuleId)

CodeUnitId is the unified addressing scheme that replaced FuncId inside the IR. FuncId survives above it, in the compiler's FuncIdMap from (ModuleId, name) to (IrModuleId, FuncId); the two are numerically the same where both appear.

Code Unit References

pub enum CodeRef {
    Local(CodeUnitId),                             // Same module/unit
    External { unit: u32, id: CodeUnitId },        // Previous script unit
    Module { module: IrModuleId, id: CodeUnitId }, // Different module
}

Module uses numeric IrModuleId (not salsa ModuleId) for serializability.

Local is relative, and is not a name to remember a function by. It is a position in whichever unit's list is in scope, and every script unit numbers its own functions from zero, so Local(1) means a different function in each of them. Resolution is fine -- ExecutionContext holds one unit's list at a time, and only CodeRef::External swaps it -- but anything that remembers a function between calls has to say which unit as well. The JIT's compiled bodies did not, so a hot function in one REPL line was executed in place of a different function at the same id in the next. FuncIdentity (interp dispatch.rs) is what those key on now: it resolves Local against the scope unit, which also makes it agree with the External a later unit uses for the same function. ExecutionContext carries the unit for this, and the JIT keys its table and its code cells on FuncIdentity too. Fixture: interp/947_crossunit_local_id_reuse, which the tuned and chaos dispatcher suites run against the plain interpreter.

The same goes for anything that carries a reference out of the body it was written in. A JIT stub encodes its callee's FuncIdentity, resolved when the stub is built, because compiled code from one unit calls compiled code from an earlier one directly and the trampoline cannot know whose code a Local came from. That is covered by the last units of engines/999_call_into_earlier_unit.

IrCodeUnit

One type represents functions, script units and native functions. The context field decides execution semantics.

pub struct IrCodeUnit {
    pub id: CodeUnitId,
    pub name: String,

    // Body.
    pub blocks: Vec<IrBlock>,
    pub value_count: u32,
    pub slot_count: u32,
    pub value_types: Vec<IrType>,
    pub slot_types: Vec<IrType>,
    pub tracked_slots: Vec<SlotId>,
    pub const_values: Vec<(String, ValueId)>,
    pub symbols: SymbolTable,

    // Context.
    pub context: CodeUnitContext,

    // Units defined inside this one.
    pub nested_units: Vec<IrCodeUnit>,
}

pub enum CodeUnitContext {
    Function(FunctionContext),
    Script(ScriptContext),
    Native(NativeContext),
}
pub struct FunctionContext {
    pub params: Vec<ParamId>,
    pub param_modes: Vec<ParamMode>,
    pub param_types: Vec<IrType>,
    pub return_type: IrType,
    pub tracked_params: Vec<ParamId>,    // Out params needing runtime tracking
    pub type_param_count: u32,           // One descriptor each, after the parameters
    pub descriptor_table: Vec<IrType>,   // Dynamic types the body uses
}

pub struct ScriptContext {
    pub unit_end_values: Vec<ValueId>,
    pub unit_end_slots: Vec<SlotId>,
    pub result: Option<ValueId>,
    pub result_name: Option<String>,
    pub exports: Vec<(String, ExportBinding)>,
}

pub struct NativeContext {
    pub param_modes: Vec<ParamMode>,
    pub param_types: Vec<IrType>,
    pub return_type: IrType,
    pub symbol: String, // e.g. "dlr_std__list_push"
}

An IrModule is { functions: Vec<IrCodeUnit>, symbols: SymbolTable }. Lookup at runtime goes through ModuleFunctionRegistry (keyed by (IrModuleId, CodeUnitId)), UnitFunctionRegistry (indexed by script unit), or FunctionRegistry, which holds both.

Type Layout

datalove_datafun_ir::layout is the single authority on how an IrType is laid out. Both AOT backends and the CTFE evaluator compile against it, and datalove_rtdt::layout computes the same layouts from runtime type descriptors, which is what the runtime reads values back through.

Generated code writes at offsets from the first and the runtime reads at offsets from the second, so a disagreement corrupts values rather than failing a build. layout_conformance_tests walks a corpus of types and checks size, alignment, field offsets, variant payload offsets and tag payload offsets in both directions.

Nothing should open-code the arithmetic. Payload offsets have named functions in both authorities:

ir::layout rtdt::layout
enum variant enum_payload_offset(ty) enum_payload_offset(align)
?T option_payload_offset(ty) option_payload_offset(align)
!T result_payload_offset(ty) result_payload_offset(align)

Parameter Modes

pub enum ParamMode {
    In,  // Ownership transfers to callee
    Out, // Write-only, callee must initialize
    Ref, // Read-only borrow
    Mut, // Read-write borrow
}

Call sites repeat the mode: ExprFunctionCall.arg_modes holds the marker written before each argument, None meaning in. Typechecking rejects any disagreement with the callee's declared mode (F057), so later phases can read the mode off the call site alone. Ownership analysis does exactly that, which is why it needs no resolved call target to know how an argument is passed.

Reading a parameter is Operand::Param(p), not an instruction. Writing has four forms:

RefStore and RefSetField generalize these to any reference-like operand.

For out params, the caller destroys the existing value before the call, an instruction of its own: DropViaRef through a reference, DropTracked for a tracked binding or out param, Drop otherwise. The call itself does nothing to the destination.

Stores never destroy. Where the stored type owns something, lowering emits the destroy in front of the store (emit_drop_before_store in context.rs): Drop for a mut param, DropTracked for an out one, DropViaRef through a reference, DropField for a field. Slot stores already had theirs from store_slot.

Enum Instructions

// Read u32 discriminant tag from enum value. Borrows src (does not consume).
EnumDiscriminant { dest: ValueId, src: Operand }

// Move payload out of enum into dest. Consumes src.
EnumPayload { dest: ValueId, src: Operand, variant_index: u32 }

// Construct enum value with given variant and optional payload.
EnumVariant { dest: ValueId, variant_index: u32, payload: Option<Operand> }

Match lowering emits EnumDiscriminant to read the tag, then a chain of comparisons branching to arm blocks. Atom arms Drop the input; term arms use EnumPayload to extract the binding.

Intrinsics

icall name(args) compiles to a single machine operation with no call overhead. datalove-datafun-intrinsics defines IntrinsicId with stable discriminants for serialization, covering bitwise ops, shifts, bit counting and casts. The interpreter implements them in interp/src/intrinsics.rs; the compiled backends emit instructions directly.

Salsa Patterns

See salsa-patterns.md for how the four salsa kinds are used here, why expression tables are keyed on ExprKey rather than salsa ids, and how to measure whether a change memoizes.

Database

The compiler's database is in datalove-datafun-compiler/src/lib.rs:

#[salsa::db]
#[derive(Default, Clone)]
pub struct Database {
    storage: salsa::Storage<Self>,
}

Implements DbClone - shares Arc<Zalsa> global state, clones thread-local state. Database::recording(recorder) returns one that reports every query it runs; clones report to the same recorder, so work farmed out to rayon is recorded too.

bcts, datalove-datalit and the datalove facade each declare their own plain salsa Database as well. Compiler work uses the datafun one, re-exported as datalove_datafun::Database.

Tracked Functions

There are 127 of them, 43 of which keep a memo for a module compile. A table here went stale within a session, twice -- it listed fifteen and was six queries and one signature out of date when that was noticed -- so the inventory lives in salsa-architecture.md, grouped by granularity, with a command that regenerates it rather than a list to trust.

The thing to take from it: a query's granularity -- one memo, or one per module -- says what it is allowed to depend on. A graph-keyed query may look at each module and must not look at each function; a per-module query may look at its own module and must not look at the rest of the world. reachable_func_ids exists to make the second true of the two lowering queries. compile_scaling_tests holds both.

Graph-level functions aggregate per-module results and hold handles rather than copies. Each is still keyed on the whole graph, so an edit to one module re-runs every one of those walks; the walks are cheap because what they call is memoized, and they are what stands between here and pulling per module.

Incremental Compilation

Key principle: Module objects are created once and reused. Updates use set_source().

IncrementalModuleWorld:

Memoization behavior:

ModuleId is #[salsa::interned], so the same path gives the same id. It was an input once, when every ::new() made a distinct one, which is why the world holds Source handles across an edit rather than rebuilding from paths -- a Source is the input, and it is the only handle worth keeping.

Reusing a Compiled World

The unit of reuse is the pipeline, not the database. A ModuleCompilationPipeline owns the Source inputs every tracked query is keyed on, and descriptor.to_pipeline(db) makes new ones, so a fresh pipeline misses every memo however warm the database is. Measured in debug on sys/std: a fresh database and pipeline, the same database with a fresh pipeline, and a cloned database with a fresh pipeline all took about 756ms; the same pipeline compiling a second time took 84ms (and less since).

CompiledWorld (datafun/src/pipeline/compiled_world.rs) is that pair kept together: a Database and the pipeline whose inputs live in it. It is not a cache, just the state a caller holds if it wants salsa to do its job -- owned, no thread-local, dropped with its owner. std_engine_tests holds one per worker, and the REPL's Engine keeps its pipeline across a reset for the same reason.

Threads. Storage::clone keeps the Arc<Zalsa> -- every memo, interned value and tracked struct -- and makes a fresh ZalsaLocal, the per-thread query stack. So Database is Send and not Sync by design, and a clone is how another thread gets a handle onto the same work; the handle must be per-thread, what it points at need not be. Note that a clone alone buys nothing cold, since the cost is in the inputs. Compilation is therefore single-threaded per handle, while execution is free: IrCodeUnit and Arc<ModuleFunctionRegistry> are Send + Sync and carry nothing of salsa, so any number of executors, each with its own runtime, can run the same IR in parallel.

Two sources, so two worlds. There are two standard libraries and a process can want both: WorkspaceDescriptor::load_sys_dir reads sys/ off disk, which is what the stdlib suites use because they test it, and from_system_library uses the copy embedded in the binary, which is what the CLI and REPL use. They are not interchangeable (embedded_matches_tree exists for that reason), so whatever holds a world holds one per descriptor.

Persisting a compiled world across processes was sized and not attempted. Salsa's persistence feature would need about 132 items across ten crates annotated and made serializable, and can fail late on one unserializable field; compiling several scripts against one in-process CompiledWorld is the baseline it would have to beat.

Parallel Execution

The default, unless DATALOVE_PARALLEL=0 or rayon's pool has one thread (parallel_mode_from_env). All of it is one move: a query a sequential step goes on to ask once per module (or per chunk) is computed ahead of the step on rayon's pool, on database clones, so that the step finds every answer in its memo. Nothing a worker computes is used except through salsa, so the parallel compile is the sequential one, run sooner. The move is datalove_datafun_common::warm:

// Phase 3 in `compile.rs`.
pub fn typecheck_phase<'db>(db: &'db dyn DbClone, mode: ParallelMode, parsed_graph, name_resolution)
    -> ModuleGraphTypecheckResult<'db>
{
    let graph = parsed_graph.graph(db.as_salsa_db());
    warm(db, mode, || graph.iter_modules(db.as_salsa_db()), |db, module| {
        let _ = typecheck_graph_module(db, parsed_graph, name_resolution, module);
    });
    typecheck_module_graph(db.as_salsa_db(), parsed_graph, name_resolution)
}

warm does nothing under ParallelMode::Sequential, and works because dyn_clone() shares the global memoization cache.

A step and its warm-up ask with the same function. Here that is typecheck_graph_module, which typecheck_module_graph calls for each module too; for ownership it is analysis_work, the modules the step analyzes with the results it keys analyze_module on; for phase 5d assembly_work. A warm-up that keyed a query otherwise than its step would not be wrong, only work done twice, which is the failure nothing else would notice: parallel_warm_tests asks QueryRecorder which thread each per-module query ran on, and a warmed one has to have run on a worker.

The drivers own the parallelism; the phases know nothing of it. There are three: the dependency walk (extract_dependencies_with_mode, which parses a frontier of modules at a time), phases 1 to 4 (parse_phase, resolve_phase, typecheck_phase and ownership_phase in compile.rs, which compile_modules is made of, and the data files' chunks), and phase 5 (lower_module_graph_with_evaluator). A warm-up has to sit between the result its items come from and the step that reads them, so there is one per phase, and outside any query, since salsa does not track work run in parallel inside one and on wasm rayon would run it nested there.

What caps all of this is the sequential half. Each phase's aggregator walks the graph whatever was edited, so the speedup is bounded by what it does: parse sits near 2.7x on sixteen cores in the parallel bench and cannot go past it while the aggregator rebuilds. In the real pipeline phase 1's warm-up finds nothing to do, because package resolution has already parsed every module; in a parallel compile that parse is the parallel one, in extract_dependencies_with_mode. See where compile time goes. Thinning ModuleGraphTypecheckResult took typecheck from 1.57x to 1.78x for exactly that reason -- the restructure is what makes the parallelism worth having, not the other way round.

just test runs the suite in parallel compiles, being the default. just test-sequential runs it under DATALOVE_PARALLEL=0; CI does not, since the sequential compile is a subset of the parallel one, and October 2026's audit and LookupMap are what keep salsa's id order, the one thing the two could disagree on, out of output (see salsa-patterns.md).

A large collection literal is parallel within itself. A datalit list, map, set, table or tensor of 32-512KB or more, depending on the kind, is parsed in chunks, each a query of its own (bct::chunk, datalit::parser::parse_chunk), and in a parallel compile the compiler parses the data files' chunks on rayon before the typecheck that asks for them. A chunked parse is the whole parse, which just test-cut holds the suite to. A datafun literal is not cut: data that big belongs in a data file. See plan-parallel-collections.md.

just test-wasm checks that the parallel paths still work where there are no threads: it builds crates/datalove-wasm-smoke for wasm32-unknown-unknown, which compiles the system library and runs a script on the bytecode, and runs it under wasmi in both modes. Rayon's pool falls back to the calling thread when it cannot spawn one, so the parallel mode runs every into_par_iter there inline, against salsa clones on the thread that owns the database. The module imports wasm-bindgen's functions, because rustmax's portable profile turns on getrandom/js and chrono's wasmbind and datalove-datalit asks for getrandom/js itself; the test binds them to functions that trap, so nothing compiled or run may call them. Everything parallel happens before the IR exists, so the engine suites run only the reference and the bytecode there, by DATALOVE_TEST_ENGINES.

Ownership Analysis

Runs after typechecking, produces DropSchedule consumed by lowering. The types it produces live in datalove-datafun-sema so lowering can depend on them without depending on the analysis.

Tracking Categories

pub enum TrackingCategory {
    Copy,    // No tracking/drops needed
    Precise, // State statically known
    Tracked, // May vary at runtime (needs tracking byte)
}

Tracked bindings: exports, out params, conditional moves, mutable slots.

Match arms use ScopeKind::MatchArm. Branch consistency (D008) is generalized across all match arms: if a value is moved in one arm, it must be moved in all. if and match both check it in check_branches_agree, over the branches that fall through. Each branch's BranchEnd keeps its moved_at and assigned_at sites, so the error can point at the move or set that made the branch differ, and, for a set, at the move before the branches that it undoes.

Consts are borrowed. A const, function-level, module-level or script-level, and a const parameter are ref bindings: a move out of one of a non-copy type is CannotMoveConst (D003) unless written with @. A const the analysis declared is a binding with is_const. One declared outside the body is only a name, and OuterConsts says which: in a function body every name it cannot resolve, since a body sees only its parameters, what it binds, consts and functions, and a function is never named as a value; in a script or expression unit the earlier units' consts, from external_consts_over, because an earlier unit's let is a binding the unit may move out of rather than borrow. A const argument is not a move: the analysis reads call_targets to find the callee's const parameters and skips those arguments, and lowering hands the call a copy of its own (lower_comptime_arg), since the call consumes what it is given.

Because nothing consumes a const, every one of a non-copy type is built once and borrowed through a StaticRef. A module or script const named from a function is lowered that way directly. A function-local const, and a specialized copy's const parameter, is lowered as a value defined by a Const and promoted afterwards by promote_function_consts; see Consts Built Once.

Error Codes

Variants of AnalysisError in datalove-datafun-sema.

Code Variant Trigger
D001 UseAfterMove Using value after move
D002 DoubleMove Moving value twice
D003 CannotMoveBorrowed Moving ref/mut/out param
D003 CannotMoveConst Moving out of a const or const parameter without @
D004 CannotMutFromRef Passing ref to mut param
D005 ReadUninitialized Reading an out param or var binding before it is set
D006 OutParamNotInitialized Return without initializing out
D007 MoveInLoop Moving outer-scoped value in loop
D008 InconsistentBranchMove Value moved, or set again after a move, in one merging branch only
D009 OutParamPartialWrite Field write to out param
D010 AliasedMutableArgument Two arguments share a place root, one is mut/out
D011 CannotMutateImmutable let binding or in param passed as mut/out
D012 CannotMutateTemporary Non-place argument passed as mut/out
D013 UseAfterMoveInEarlierUnit Using a binding whose own script unit gave its value away
D014 InconsistentLoopExit Moved on one way out of a loop and not another
D015 WalkedCollectionChanged, WalkedCollectionAssigned The collection a for walks moved, passed mut/out, or assigned to or through

D001, D002, D007 and D013 carry an OwnershipRecoveryHint suggesting an @, as D003 does for a move out of a const. The parsers' own D0xx codes start at D017 and do not reuse any of these; see Datalit Grammar.

Drop Schedule

DropSchedule tells lowering where to emit Drop instructions:

pub struct DropSchedule {
    pub then_branch_exit: BTreeMap<usize, Vec<BindingId>>,
    pub else_branch_exit: BTreeMap<usize, Vec<BindingId>>,
    pub before_return: BTreeMap<usize, Vec<BindingId>>,
    pub before_try_return: BTreeMap<usize, Vec<BindingId>>,
    pub try_return_not_yet_moved: BTreeMap<usize, Vec<(u32, BindingId)>>,
    pub before_set_target_early_return: BTreeMap<usize, Vec<BindingId>>,
    pub loop_body_end: BTreeMap<usize, Vec<BindingId>>,
    pub before_break: BTreeMap<usize, Vec<BindingId>>,
    pub before_continue: BTreeMap<usize, Vec<BindingId>>,
    pub match_arm_exit: BTreeMap<(usize, usize), Vec<BindingId>>,
    pub stmt_order: Vec<StmtKey>,
}

before_set_target_early_return is computed before RHS moves are analyzed, so it includes the RHS binding, which is still live when a set-index bounds check fails.

before_try_return is what is live once the statement's expression has been evaluated, so it leaves out what the expression moves. But a ? can return before the move: in call put(mut xs[i]?, v) the call never takes v. try_return_not_yet_moved lists each binding the expression moves with the expression index of the place that moves it, and an early return drops one whose place lowering has not reached yet (LowerCtx::note_place_lowered). A binding whose place has been lowered but which a call has not yet taken is the lowering's own to drop: the call's pending scope holds it, as it holds an argument value.

AOT Tracking Bytes

Out params get tracking bytes in the frame:

let param_tracking_base = tracking_offset + tracked_values.len() + tracked_slots.len();
for (i, &pid) in tracked_params.iter().enumerate() {
    params[pid.0 as usize].tracking_byte = Some(param_tracking_base + i as u32);
}

Values: UNINIT = 0x00, LIVE = 0x01, MOVED = 0x02

Execution Backends

All of them consume IrCodeUnit and agree with ir::layout.

The runtime's C API (datalove-rt/src/c/mod.rs) is entirely dtlv_rti_*: calls only the compiler emits, which may use whatever ABI is convenient. The crate docs also describe a dtlv_rt_* family the language would call under a restricted ABI, but none exist yet. Everything but init takes a runtime handle, and every value pointer is followed by its tydesc. The tydescs are &rtdt::TyDesc, and the values rtdt::AnyRef, AnyMut, AnyOwned or AnyOut by what the function does with them, each a pointer that is never null. Both are passed as pointers, so the compiler's backends pass the same words as before, and a Rust caller says at the call what it is lending rather than the runtime taking a bare pointer on trust.

A value a function changes, takes over or writes overlaps no other value it is handed, as a &mut overlaps nothing; only AnyRefs may share one. For the arguments of a call the ownership checker sees to it (D010). The one way the language has of naming a place twice in a runtime call is set x += x, which lowering turns into the operator and a store, keeping the update in place for a value rooted elsewhere. An export handed such a value beside another begins with lend!, which panics on an overlap in a debug build and reborrows the value's bytes so that Miri reports any other way of reaching them; gen-rti refuses an export that leaves it out.

Leak Checking

The runtime allocator can record every live allocation, so that shutdown reports what leaked and every free checks that its pointer is known (double free, untracked pointer) and that its size, align and count match what was allocated. LeakCheckMode (datalove-rt/src/c/impls/alloc.rs) is read from DATALOVE_LEAK_CHECK -- ignore, warn, panic, panic-backtrace -- and defaults to Ignore, which skips all of it. The justfile sets LEAK_CHECK := "panic" on every recipe that runs tests, and children inherit it, so it reaches the executables the AOT backends build and the datalove binary the CLI tests spawn. A bare cargo test has it off.

It is one switch rather than two on purpose. The free-time checks read the same allocation map the leak report enumerates, so they cannot be kept without keeping the map, and the map is the whole cost: 13-17% of an allocation-heavy program. Keeping the checks in release was tried and saved nothing. Nor should a release pay it, because datalove is safe: a program cannot reach free with the wrong size, or free a pointer twice, by being written badly. Only the compiler can, by lowering a drop wrongly, and the test suite is where that is caught. On the suite itself the tracking costs nothing measurable.

Nothing fails if a recipe loses the variable; see Nothing proves the suite runs with leak checking on.

Performance Notes

Measured facts worth having before optimizing anything. Release builds; the numbers drift, the proportions less so. Open work from these measurements is in issues.md.

The release profile is opt-level = 2. Against 3 it measured the same on the interpreter and the JIT, 4% slower on typecheck-std, a smaller binary, and an 18% faster clean build and 22% faster incremental one. Against "s", which it replaced, it is 7-10% faster interpreting and 14% faster compiling sys/.

Startup is the standard library. A process starts in about 3ms and a trivial script --no-sys finishes in under 4ms; the same script with sys/ is 55-60ms. Narrowing to roots is what takes a script that uses little of the library well under that. The largest real program in the tree, botdocs/learn.dfs, adds under 10ms on top of the floor.

Compiling is allocation-bound. There is no hot spot -- the largest single symbol in a profile of compiling sys/ is about 2% -- and about 40% of the time is malloc, memcpy, hashbrown and the kernel faulting memory back in. mimalloc as the CLI's global allocator measured 1.33x on startup, with system time down 2.9x, and was not adopted; there is no #[global_allocator] in the tree. The runtime's own allocator is not part of that 40%: it mmaps its payloads directly.

Front end. Two lessons from profiling the lexer and parser recur. A #[salsa::tracked] field read looks like a field access and is a table lookup, so read fields once and carry the slices -- the lexer's peek and the bracer's iterator both went through salsa per character or per token. And alternate the variants within one run and compare medians; a block of A then a block of B measures the machine. Two attempts that were reverted: an ASCII fast path for classifying whitespace measured as nothing and would have been wrong, since char::is_ascii_whitespace rejects \x0B where char::is_whitespace accepts it; and a byte cursor for the lexer measured 1.48x on lexing alone and was not judged worth keeping. Carrying a newline flag on TokenKind::Whitespace, so split_lines need not resolve every whitespace token through salsa, is worth perhaps 1% and is open.

The JIT is about 85x on a tight arithmetic loop and 2-3.5x on call-heavy code. It costs a fixed startup (JitEngine::new: arena, ISA) and compiles synchronously at opt_level = "speed" in the dispatch that crossed the threshold. Stubs and native imports are made once per callee and kept (JitCompiler::stubs), and Cranelift's IR verifier runs only with debug assertions; making them per caller, and verifying, had been about a third of codegen. A loop in a function called once is compiled by OSR: the bytecode counts iterations at the loop's header, and past --jit-osr-threshold (default 1000) the frame goes on in code compiled from the header, which runs the rest of the call. A call from compiled code reads the callee's code cell and calls its code directly once it is compiled, and until then calls a stub with the callee's signature that goes through __jit_dispatch_call; going through the dispatcher every time had been over 80% of the time on recursive fib, 5x slower, and going through the stub to reach the code (a function of its own, called on every call) a further 19% of its instructions. Runtime functions and natives are imports, which cranelift-jit, never PIC, calls by loading the absolute address (movabs/call reg), so they reach anywhere without the indirect-call trampoline each once had, which cost sum, sieve and ordered 6-7%. The other direction, a call from the bytecode into compiled code, is planned at the call site like a call to a native (Plan::Jit in bytecode/mod.rs): the site asks the dispatcher once for a SitePolicy -- enter this code, count so many calls and then offer one, interpret, or offer every call -- and makes its calls by that without asking again. Offering every call through the general path had been about 30% of the store demo at a threshold of 100, ten times what the compiled code it called took, and with a dispatcher installed it had taken every other call, to natives and interpreted functions too, off its plan. About a third of stdlib-shaped execution is in the native runtime, which the JIT cannot speed up, so it is near its ceiling of about 1.8x there; arithmetic-shaped code is over 90% interpreter, which is where the 85x comes from. The interpreter's call path takes frames off a FrameStack (interp/src/frame.rs) and layouts from a LayoutCache (interp/src/layout.rs); reusing frames and layouts made it 1.5-1.8x faster on call-heavy code.

What keeps the JIT fast is checked by counts, not times. A fast path falling back to its slow one gives the same answers more slowly, which no output comparison sees. jit_path_tests in datalove-cli read --jit-stats and fail unless calls into compiled code are made from planned call sites and hot loops are entered by OSR, the nested case included.

benchvs keeps its timings. just record times every implementation of every benchmark -- the interpreter, the JIT at threshold 1 and tiered, AOT, Python and Julia -- splits datalove's runs into compiling and running, and writes them to target/benchvs/history stamped with the commit and the load average. just compare sets the latest two side by side, marking a change past 5% and exiting nonzero if anything got slower. just run is the same set under hyperfine, for reading.

The interpreter's dispatch is mostly call overhead. execute_instruction is one match over every instruction, and its prologue and epilogue -- six saved registers and a frame sized for the largest arm -- were a third of its time, paid once per IR instruction. execute_hot handles the dozen instructions most time goes to (constants, copies, arithmetic, slot loads and stores, intrinsics, option and result wrapping) inlined into the block loop, and everything else falls through. Moving an arm there is the lever: it took benchvs primes from 3.6s to 2.0s. The other lesson is that #[inline] is not enough for the accessors that loop calls -- read_operand, Frame::value and kin stayed out of line in a caller that size until they were #[inline(always)], and read_operand keeps only the three common operand kinds inline for the same reason. A copy of a runtime-sized value is a call to memcpy unless the size is matched to a constant first (copy_bytes). Together these took primes from 3.6s to 1.6s, sum from 135ms to 87ms and fib from 1.32s to 1.0s.

The interpreter's frames are compiled code's frames. IrLayout takes its offsets from ir::frame_layout::FrameLayout, tracking bytes included, so a value, slot or tracking byte is where compiled code for the same body keeps it -- the precondition for entering compiled code from the middle of an interpreted loop. What only the interpreter keeps comes after that layout's bytes, in an extension compiled code never sees: a pointer per parameter, a generic's type-parameter descriptors and descriptor table, and the liveness bytes where they are kept. A dynamic value, slot or reference is described by its table entry (IrLayout::value_entries, slot_entries). A frame is its bytes, and Frame is a pointer to them and the layout. Liveness works as it does in compiled code: a tracked slot or out parameter says from its tracking byte, and everything else is taken to hold something -- either it is precise, or, like an unwritten var n: u32 passed out, its type owns nothing and destroying it does nothing, which is why the analysis left it untracked. The per-binding flags the interpreter used to keep for everything (liveness bytes, now in the frame's extension) are kept only for script frames, which need them for REPL error recovery and for later units' reads, and for function frames in debug builds, where they check the precise answers and the reads -- so the suite still panics on reading an empty slot, and a release build does not pay for the check. On speed it is about even: fib and primes 3% faster, sum 6% slower. Sum's loop runs a tracked drop and a tracked store every iteration, and a tracking-byte check is a longer chain of dependent loads (frame, layout, tracking table, byte) than the flag it replaced: fewer instructions, lower IPC.

Three tiers of instruction. execute_hot (inlined into the block loop: the dozen instructions arithmetic spends its time in), execute_warm (not inlined but small: the instructions generic and library code is full of -- parameter stores, list bounds and element references, drops, clones) and execute_instruction (everything else, calls included). Entering execute_instruction costs a frame sized for its biggest arm, so an instruction that runs often belongs in one of the other two.

Frames come off a stack. Function frames are bumped onto a FrameStack, a list of chunks that never move, each frame preceded by a header saying where the top was and holding its layout. Before that a FramePool handed out boxed Frame structs with vectors for the parameters, shapes and reference descriptors; before the boxing it moved a 192-byte struct by value, two memcpy calls per call, and boxing took recursive fib from 848ms to 655ms. Script frames are ScriptFrames, which own their bytes and live in the FrameStore.

Bytecode calls do not recurse. A call from one bytecode body to another pushes the callee's frame and carries on in the same loop (run_body in bytecode/run.rs), saving the caller's registers in an Activation; a return restores them. A call site that resolves to a statically typed bytecode body keeps a Plan -- the callee's layout, bytecode and final parameter descriptors -- so that the call is a check, a push, the arguments and a switch. Two things learned doing it: pc += 1 in a release build with overflow checks costs the loop its registers (the panic path is enough), so the loop uses wrapping_add; and not recursing saves little by itself -- the saving is in what a loop that owns its frames can skip.

A call resolves its arguments into the callee's frame. execute_call_site pushes the callee's frame (FrameStack::push), sets each argument straight into it, offers the dispatcher the frame's parameters as they stand, and only if it declines makes the rest of the frame ready (Frame::enter). The parameters are one Value apiece so that they are the slice a dispatcher is handed, and until enter they carry the caller's descriptors, which is what a dispatcher has always been shown; enter gives owned ones the callee's. What every call needs to know about a parameter -- its mode, and whether the argument is moved, which meant walking its type -- is in the cached IrLayout. Before this a call built an argument Vec, nulled three parameter vectors only to overwrite them, cloned a list of shapes nothing read, and asked each parameter's mode three times; fib went from 0.98s to 0.83s. Natives have no frame and still take a list, as does the public call_in_context.

Entry Points

Module Compilation

Analysis only (compile.rs):

pub fn compile_modules<'db>(
    db: &'db dyn DbClone,
    input: ModuleCompilationInput<'db>,
    rider_sources: Vec<(String, String)>,
    mode: ParallelMode,
) -> ModuleCompilationOutput<'db>

Returns after phase 4. Check is_successful() before lowering.

With lowering (tracked_lower.rs):

pub fn lower_module_graph_with_evaluator<'db>(
    db: &'db dyn DbClone,
    parsed_graph: ParsedModuleGraph<'db>,
    typecheck_result: ModuleGraphTypecheckResult<'db>,
    ownership_analysis: ModuleGraphAnalysis<'db>,
    mode: ParallelMode,
    evaluator: &mut dyn CtfeEvaluator,
    skip_const_inlining: bool,
    skip_specialization: bool,
) -> ModuleGraphLoweringResult<'db>

High-Level API

let mut pipeline = ModuleCompilationPipeline::default();
pipeline.add_module(&db, "local", "pkg", "main", source);
let compiled = pipeline.compile(&db);
drop(compiled);

// An edit, then the same call again.
pipeline.update_source(&mut db, "local", "pkg", "main", new_source);
let compiled = pipeline.compile(&db);

There is one entry point for the first compile and every recompile. What makes a recompile incremental is the pipeline, which keeps the Source inputs its queries are keyed on, not the call. A second compile(&mut db) used to exist because ModuleGraph was an input that a recompile updated through setters; with the graph interned nothing in a compile writes, so it was removed.

ModuleCompilationPipeline::from_sections(&db, &sections, CompilerOptions::default()) builds one from parsed worldfile sections, which is how most tests construct it. compile_with_mode takes an explicit ParallelMode instead of reading DATALOVE_PARALLEL.

A pipeline's CompilerOptions are settled when it is built. Nothing recompiles under options it was not made with, so there are no setters for them, and a WorkspaceDelta that changes them says requires_new_pipeline rather than being applied to one that exists.

Data Files

A package's .dlt files travel beside its modules the whole way: the loader reads them into Package::data, a WorkspaceDescriptor keeps them in PackageDescriptor::data and diffs them into WorkspaceDelta::data_*, and the pipeline holds each as a Source in IncrementalModuleWorld, so an edit to one is a set_text like a module edit. They take no part in package resolution -- data has no requires -- and every one in the world rides on the ModuleGraph as data, by path, from where ScriptEnv takes them too.

require data lib/pkg/name: T is parsed as the const name: T whose value is an ExprFunKind::DataFile naming the path, so it is a const everywhere after the parser. The typechecker resolves the path against the graph's data (datafile.rs), checks the file against T with datalit's own checker (data_file_type, memoized), and records the files it resolved in the unit's typecheck result as data_files, which is where lowering finds them. datalove-datafun-lower/src/datafile.rs reads a checked file into a ConstValue led by the type, memoized on (Source, IrType) and handed out as the memo's own Arc, which every map of consts and every StaticRef then shares. A data const needs no evaluating, so const_eval::data_consts puts the module's or unit's data in scope before the first lowering.

Compiling From Roots

The world is every module a workspace holds, and a program using the system library would otherwise compile two dozen modules it may touch none of. Roots (datafun/src/incremental.rs) says which modules to start from:

pub enum Roots {
    All,                    // every module in the world
    From(BTreeSet<String>), // these, and what they reach by transitive `require`
}

It is a parameter of the graph build, not a mode: IncrementalModuleWorld::build_graph takes it, and package resolution walks from it too, so modules nothing reaches are not even parsed. Roots::All is bit-for-bit the compile there was before the choice existed, so the two are one path with a different argument. Because ModuleGraph is interned, the roots are part of the graph's identity, and changing them on a live pipeline (set_roots) is a different graph rather than a memo to invalidate; roots_tests holds that. On sys/ plus one module, a script requiring nothing went from about 43ms to under 7ms end to end.

It changes what is an error. A type error in a module nothing requires is not reported. So the line is that compiling from roots is for running a program, not for vouching for a world, and All stays the default:

A worldfile's own modules are always roots, whether the script reaches them or not: a module the author wrote in the file being compiled is part of what they asked to be compiled, and a library module they do not use is not. narrow_roots_to_script refuses to narrow -- leaving All -- when a require does not resolve, because pruning on a program whose requires are wrong would drop the module the diagnostic is about.

Narrowing again as a session goes is sound because of the numbering: a pipeline numbers each module by path, once, for its whole life (see ModuleIdTable in crates/datalove-datafun-common/src/lib.rs), so a module joining the graph moves no other module's number and leaves the units already lowered calling the modules they named.

Workspaces

WorkspaceDescriptor is an immutable snapshot of everything compilation reads: system library (absent under --no-sys), user libraries in shadowing order, CompilerOptions, and a work_dir the compiler may write to. It is cheap to clone and two of them can be diffed into a WorkspaceDelta for incremental recompilation. It is pure data; drivers (CLI, REPL, LSP) build one and feed it to ModuleCompilationPipeline.

Two constructors supply the system library, and neither goes looking for it:

// A driver that carries its own library, which is every shipped command.
let sys = datalove_sys_packages::system_library();
let descriptor = WorkspaceDescriptor::from_system_library(&sys);

// A test compiling the tree it lives in.
let descriptor = block_on(WorkspaceDescriptor::load_sys_dir(repo_root.join("sys")))?;

from_package_world and from_worldfile_sections build the other kinds.

work_dir is output, not input, so it takes no part in diff. Two workspaces compiled concurrently must not share one or their native components overwrite each other. Only workspaces that build riders from source need one; a descriptor built from a SystemLibrary has no rider crate directories and never writes anything.

Scripts are compiled against a workspace, not as part of it. A descriptor describes the module world a script can require and import from; the script itself -- a .dfs file, a REPL line -- goes to a ScriptCompiler made from the compiled modules, which keeps its own incremental state. A driver holds a descriptor and its script sessions separately. Nor is the execution mode in the descriptor, or whether riders are built as a dylib or a staticlib; those are decided by the driver that consumes it.

WorkspaceDelta::apply_to_pipeline applies module additions, removals and changes to a live pipeline, and refuses a delta that requires_new_pipeline (compiler options, or any rider change). No driver consumes deltas yet; see issues.md.

Script Compilation

let mut script_compiler = compiled.script_compiler_default(db).expect("modules compiled");
let result = script_compiler.compile_fragment(source);
// result.typecheck, result.ownership, result.lowering

script_compiler_default uses the interpreter as the CTFE evaluator, wired to the module registry so const expressions can call across modules; script_compiler(db, evaluator) takes a different one. Both return None if module compilation had errors.

ScriptCompilationResult has separate results for partial compilation (typecheck can succeed while lowering fails). compile_expr handles a single expression unit, compile_fragment a statement unit.

Test Patterns

Exampletest Framework

ExampleTestRunner::new(env!("CARGO_MANIFEST_DIR"), analyze_file)
    .fixture_subdir("ir_lower")
    .file_extension("world")
    .allow_errors(true)
    .run();

ExampleTestRunner::with_worker_context(dir, init, analyzer) hands each fixture a context built by init, for state that is expensive and cannot be shared between threads -- std_engine_tests keeps a CompiledWorld there (see Reusing a Compiled World). The runner keeps contexts in a pool, borrowing one per fixture and handing it back, rather than using rayon's map_init: map_init runs its initializer once per work split, not once per thread, and built 80 worlds for 143 fixtures. One context per par_chunks chunk builds the fewest but gives up per-fixture work stealing, so slow chunks straggle. The pool never holds more contexts than threads running at once.

Test Suites

Most live in crates/datalove-datafun/tests.

Suite Purpose
parser_tests Parse and emit AST + diagnostics (in the compiler crate)
tycheck_tests, tycheck_world_tests Typecheck results
ir_lower_tests IR lowering from worldfiles
ir_lower_script_tests Script IR lowering
ir_inline_tests Inlining transformations
ir_serial_tests IR serialization round-trip
engine_tests Every program in fixtures/engines/ on the IR walker, checked against its expected file, and on the bytecode, the bytecode without its optimization pass (bytecode/opt.rs), the JIT, a chaos dispatcher, a JIT that enters every function's loops early by OSR and compiles nothing else, Cranelift AOT, C AOT, and with const inlining or specialization off, each compared with the IR walker in process. See plan-engine-tests.md
aot_layout_tests Cranelift AOT layout compatibility
layout_conformance_tests ir::layout against rtdt::layout, both directions
native_rider_tests End-to-end native rider calls
std_engine_tests The sys/std library, compiled from sys/ on disk, its fixtures in fixtures/std_tests/ run on the IR walker and compared on the bytecode, the JIT under the IR walker and under the bytecode, the JIT entering every loop at its second iteration (OSR), and both AOT backends. The only suite that puts a rider call, a bigint or a type parameter through the C backend
embedded_matches_tree The embedded stdlib against sys/, and every declared native linked (in datalove-sys-packages)
module_memo_tests, incremental_memo_tests, no_op_recompile_tests, parse_firewall_tests Salsa memoization behavior
incremental_lowering_tests That an edit lowers the module that changed and an unchanged recompile runs no query at all. Asks QueryRecorder what salsa ran, which the module_memo fixtures' thread-local log cannot see across rayon
parallel_warm_tests That in a parallel compile every per-module query a step asks was computed ahead of it on a worker, so that no warm-up keys its query otherwise than its step. Asks QueryRecorder which thread each ran on
database_memory_tests Database growth
worldgen_tests, coverage_tests (in datalove-worldgen), worldgen_dual_tests (in datalove-tests) Generated worldfiles typecheck, cover the AST, and run the same both ways. See Worldgen Coverage

index-64

The index-64 feature makes index and offset 64 bits wide instead of 32. datalove-rtdt switches IndexRepr/OffsetRepr between u32/i32 and u64/i64, and INDEX_SIZE and INDEX_ALIGN between 4 and 8, which changes the layout of every collection, string and bigint header. The Cranelift backends take their width from INDEX_TYPE and INDEX_BITS in datafun-cranelift/src/index_types.rs. It is compile-time only, so a binary is one width or the other; ABI_VERSION covers it, so a rider built at the other width is refused.

just test-64 runs the suite with it. Where a fixture's output differs by width, .out.expected.64 takes precedence over .out.expected when the feature is on. Prefer fixtures that do not need one: ask for the edge rather than naming it -- bits() rather than 32, max_value() rather than 4294967295, comparing against an extreme rather than printing it. std_tests/106_index_math_parity, 107_index_edges and 108_offset_edges are written that way and share one expected file.

sys/std/index.dfm and offset.dfm derive their width the same way: const BITS: u32 = icall index_bits(), a nullary intrinsic reporting the configured width, with the extremes computed from it. They are const bindings, so CTFE folds them before any backend sees them. Both modules used to hardcode 32-bit constants, which under index-64 made max_value() and bits() disagree with the arithmetic around them.

Worldgen Coverage

datalove-worldgen generates random worldfiles that typecheck. coverage_tests.rs asks whether they cover the language, and takes its checklist from the compiler rather than from a list: every variant of Statement, ExprFunKind, BinOp, UnaryOp, ParamMode and datalit's TypeHint, rolled and matched by one macro with an exhaustive match, so a new variant is a compile error until someone says whether the generator writes it. Generated worldfiles are parsed with the real parser and walked -- counting with regular expressions over the text was tried first and was wrong in both directions. Anything never reached must be named in NOT_YET_GENERATED with a reason, and anything named there that is reached fails too, so the list cannot rot either way. ALL_SHAPES is a hand-written list for forms that are one variant with a field present or absent (loop and loop while, if with and without else), and can go stale.

What it does not tell you:

Worldfile Format

----------
rider testlib
----------
native fun int_add(a: i32, b: i32): i32

----------
module local/pkg/main
----------
require rider testlib
import testlib.int_add

fun main(): i32
    ret int_add(3, 4)
end fun

Section headers: module <lib>/<pkg>/<mod>, module-add, module-remove, module-change-ws, module-change-ast, module-change-ty, scriptunit-fragment [id], scriptunit-expr [id], data <lib>/<pkg>/<name>, rider <name>. The three module-change-* kinds drive the memoization tests. Script units are numbered from 1 in file order, and an id in a header must be that number. The reactive fixtures, which add scriptunit-edit, scriptunit-delete and scriptunit-insert, number an inserted unit after every unit before it in the file, as the REPL does.

Key Files

File Contents
compiler/src/lib.rs Database, DbClone impl, module exports
compiler/src/compile.rs compile_modules(), ModuleCompilationOutput
compiler/src/module_graph.rs Parsing pipeline, rider interface construction
compiler/src/tracked_lower.rs lower_module_graph_with_evaluator(), compute_func_id_map(), three-phase lowering
compiler/src/tracked_ownership_analysis.rs Salsa-tracked ownership analysis
compiler/src/tracked_script_lower.rs Script lowering, collect_const_graph()
compiler/src/tracked_script_ownership.rs Script ownership analysis
compiler/src/specialize.rs Const parameter specialization: collect_instantiations_into(), monomorphize_function(), rewrite_comptime_calls(), specialize_script_unit()
sema/src/lib.rs AnalysisError, DropSchedule, TrackingCategory, ExprTypes
ownership/src/lib.rs Drop/ownership analysis
lower/src/func.rs lower_function_for_module()
lower/src/script.rs Script unit lowering
lower/src/const_expr.rs lower_const_binding(), try_extract_literal()
const/src/eval.rs evaluate_prepared_const()
const/src/inline.rs inline_script_consts(), inline_module_functions()
ir/src/lib.rs IrCodeUnit, CodeRef, IrType, Instruction
ir/src/layout.rs The layout authority for IrType
ir/src/frame_layout.rs Frame layout shared by every backend, the interpreter's IrLayout included
ir/src/registry.rs ModuleFunctionRegistry, UnitFunctionRegistry
datafun/src/pipeline/mod.rs ModuleCompilationPipeline re-exports
datafun/src/pipeline/module_pipeline.rs The pipeline itself, add_native_rider_units()
datafun/src/pipeline/script_compiler.rs Script compilation pipeline
datafun/src/pipeline/workspace.rs WorkspaceDescriptor, WorkspaceDelta
datafun/src/pipeline/rider_build.rs Native component synthesis and cargo build, for riders found on disk
datafun/src/pipeline/rider_load.rs register_linked_natives, load_rider_library
cli/src/main.rs register_natives(), which wires both the interpreter table and the JIT
sys/build.rs Embeds sys/ module sources and names each rider's crate and INTERFACE
sys/src/lib.rs system_library()
datafun/src/pipeline/compiled_world.rs CompiledWorld
datafun/src/incremental.rs IncrementalModuleWorld, Roots
buildinfo/build.rs Decides BuildInfo
rt/src/c/impls/alloc.rs The runtime allocator, LeakCheckMode
bcts/src/parser_util.rs Token gluing, eat_number
sys/std/rider/build.rs Generates symbols() from rider.dli
interp/src/native.rs NativeFunctionTable, the C ABI bridge (call_c)
interp/src/dispatch.rs CallDispatcher, DispatchResult
cranelift-jit/src/optimizing.rs Tiering dispatcher, and chaos mode for testing
cranelift-jit/src/stats.rs JitStats: what was compiled, and the crossings between the jit and the interpreter