Skip to main content

blue_lang_runtime/
pipeline.rs

1//! The blue pipeline: **parse → check → erase → run**, in that order, once.
2//!
3//! The order is the whole reason this module exists. Each stage is available
4//! separately for tools that want one, but the *default* path is a single
5//! function, because two of the four orderings are silently wrong:
6//!
7//! - **Erase before check** discards every annotation, so a program with type
8//!   errors passes. The checker sees `(define …)` and has nothing to check.
9//! - **Run before check** reports a type error after the side effects.
10//!
11//! Neither fails loudly. Both produce a green run on a program that should
12//! have been rejected. Leaving the order to each caller means every caller
13//! is one reordering away from turning the type checker off — so the order
14//! lives here, and callers ask for a *result*, not a sequence of steps.
15
16use tatara_lisp::Sexp;
17use tatara_lisp_eval::Value;
18
19use blue_lang_check::{NameTable, Namespace};
20
21use crate::erase::erase_types;
22use crate::inputs::Inputs;
23use crate::uses::Entry;
24
25/// Why a run stopped short.
26#[derive(Debug, thiserror::Error)]
27pub enum RunError {
28    #[error("parse error: {0}")]
29    Parse(String),
30    /// The check stage rejected the program — a type error, an unbound name,
31    /// or any other error-severity rule in `blue_lang_check::RULES`. Carries
32    /// every error, not just the first: a caller fixing one wants to see the
33    /// rest. (The variant keeps its first name because it is public API on a
34    /// released crate.)
35    ///
36    /// Each one is already rendered `file:line:col: message` against the file
37    /// the offending form came from — which, in a program with imports, is
38    /// frequently not the file the user named. The rendering happens here
39    /// rather than at the consumer because here is where the file table exists;
40    /// see `uses::ResolvedProgram::locate`.
41    #[error("{} error(s) in the check stage:\n{}", .0.len(), .0.join("\n"))]
42    Types(Vec<String>),
43    /// **No longer reachable, and that is the point.** This reported "blue
44    /// emitted a tree the reader could not read back" — a failure only a
45    /// print-then-reparse hop could have. [`crate::lower_to_spanned`] deleted
46    /// the hop, so there is nothing left to fail: the tree the evaluator gets
47    /// IS the tree erasure produced, not a re-reading of its text.
48    ///
49    /// Kept rather than removed because it is public API on a released crate
50    /// and a consumer may still match on it, per ★★ MODULARIZE, DON'T DELETE.
51    /// It is retired, not orphaned — if a future stage ever serialises again
52    /// it has a typed home. **Nothing constructs it today**; do not read its
53    /// presence as evidence the pipeline can still fail this way.
54    #[error("the emitted tatara-lisp could not be read back: {0}")]
55    Lower(String),
56    /// The program ran and raised.
57    ///
58    /// Rendered `file:line:col: message`, then the blue call stack the error
59    /// unwound through, innermost first (`crate::observe::Fault`). The
60    /// position is resolved against the file of the innermost frame's
61    /// definition, so a raise inside a callee from another file names the
62    /// callee's file and line; before the stack existed that case could only
63    /// name the caller's file, with no position.
64    #[error("runtime error: {0}")]
65    Eval(Box<crate::observe::Fault>),
66    /// A `use("name")` could not be resolved.
67    ///
68    /// Its own variant rather than folded into `Parse`, because the reader's
69    /// next action is different: a parse error is in the source in front of
70    /// them, an import error is in their packaging — a missing bidama, a
71    /// BLUE_PATH that does not contain it, or no loader at all.
72    #[error("import error: {0}")]
73    Import(String),
74}
75
76/// What a run produced, plus what the checker did on the way.
77#[derive(Debug)]
78pub struct Run {
79    pub value: Value,
80    /// Nodes the type walk visited. Zero for a fully untyped program — this
81    /// is what makes "no annotations, no analysis" a *measurement* rather
82    /// than a claim.
83    pub visited: usize,
84    /// Declarations that carried an annotation.
85    pub typed_decls: usize,
86    /// Boundaries where typed code meets untyped code.
87    pub seams: usize,
88}
89
90/// Parse blue source to tatara-lisp forms.
91pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
92    parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
93}
94
95/// [`parse`] with the parser's nesting bound supplied by the caller.
96///
97/// The bound exists so a stack overflow — which `catch_unwind` cannot catch —
98/// arrives as a typed `Err` instead. It is a *limit*, not a dialect: raising
99/// it changes no program's meaning, which is exactly why it is safe to expose
100/// as configuration (`blue-lang-cli`'s `config` module holds the rule).
101pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
102    blue_lang_syntax::parse_program_with_depth(src, max_depth)
103        .map_err(|e| RunError::Parse(e.to_string()))
104}
105
106/// [`parse_with_depth`] keeping **every node's** source span.
107///
108/// The door for anything that will report a position to a human. It exists here,
109/// beside the spanless one, so a caller that wants spans still parses under the
110/// CONFIGURED nesting bound — a separate `blue_lang_syntax` call would be the
111/// second door `parse_with_depth`'s own docs exist to prevent, with
112/// `max_expr_depth` true of some subcommands and not others.
113pub fn parse_tree_with_depth(
114    src: &str,
115    max_depth: usize,
116) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
117    blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
118        .map_err(|e| RunError::Parse(e.to_string()))
119}
120
121/// [`parse`] keeping **every node's** source span.
122///
123/// The spanned twin of [`parse`], at the same default bound — the door for
124/// anything downstream of a parse that will report a position, which since
125/// `use` learned to carry file identity is every path through
126/// [`run_in_surface`].
127pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
128    parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
129}
130
131/// Run blue source with no build inputs.
132pub fn run(src: &str) -> Result<Run, RunError> {
133    run_with_inputs(src, Inputs::new())
134}
135
136/// Run blue source, giving the macro phase access to verified build inputs.
137///
138/// `inputs` is already verified — [`Inputs`] cannot hold bytes that do not match
139/// their declared hash — so nothing here re-checks. The capability a macro gains
140/// is exactly "these hashed bytes", never a path.
141pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
142    run_with_loader(src, inputs, &crate::uses::NoLoader)
143}
144
145/// Run blue source with a loader, so `use("name")` can resolve.
146///
147/// Split from [`run_with_inputs`] rather than folded into it because loading a
148/// package reads a filesystem, and this crate has a `wasm32-unknown-unknown`
149/// consumer with zero host imports. The capability is injected by callers that
150/// have it — `blue_lang_pkg::LoadPath` is the real one — and absent by default,
151/// where a `use` is a typed error naming the package.
152pub fn run_with_loader(
153    src: &str,
154    inputs: Inputs,
155    loader: &dyn crate::uses::Loader,
156) -> Result<Run, RunError> {
157    run_in_surface(Entry::anonymous(src), inputs, loader, None)
158}
159
160/// Run blue source written in a `yakugo` surface.
161///
162/// The pack applies at PARSE time and nowhere else — by the time the checker
163/// sees the program it is canonical, so every stage below is identical whatever
164/// surface the author wrote in. That is what makes a surface a surface: it
165/// changes how a program is spelled and nothing about how it runs.
166///
167/// `entry` carries the source AND the file it was read from, because a type
168/// error has to be reported somewhere: a caller with a path should pass it, and
169/// one without ([`Entry::anonymous`]) gets diagnostics that say so rather than
170/// diagnostics that guess. Imported packages name themselves through the
171/// loader either way.
172///
173/// # Errors
174///
175/// As [`run_with_loader`].
176pub fn run_in_surface(
177    entry: Entry<'_>,
178    inputs: Inputs,
179    loader: &dyn crate::uses::Loader,
180    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
181) -> Result<Run, RunError> {
182    // The interpreter is built BEFORE the check stage, because it is the check
183    // stage's name oracle: every builtin, macro and special form a program may
184    // name is read off the interpreter that will run it, never off a list.
185    // Building it is not a stage; nothing is evaluated until `eval_prepared`.
186    let mut interp = crate::interpreter_hostless();
187    crate::inputs::install_input_primitives(&mut interp, inputs);
188    let prepared = prepare(entry, loader, surface, &builtin_names(&interp))?;
189    let value = eval_prepared(&mut interp, &prepared, &mut ())?;
190    Ok(Run {
191        value,
192        visited: prepared.visited,
193        typed_decls: prepared.typed_decls,
194        seams: prepared.seams,
195    })
196}
197
198/// A run watched by a call observer, and the definitions the observer was
199/// built over.
200pub struct Observed {
201    pub definitions: std::sync::Arc<crate::observe::Definitions>,
202    /// The run itself: its value, or the error it stopped on.
203    pub outcome: Result<Run, RunError>,
204}
205
206/// [`run_in_surface`] with a call observer installed for the evaluation.
207/// `watch` builds the observer from the program's definitions once every stage
208/// before evaluation has passed; the profiler, the tracer and coverage are
209/// all built this way (`crate::observe::Recorder`).
210///
211/// # Errors
212///
213/// A parse, import or check error, which leaves nothing to run. A runtime
214/// error is the [`Observed::outcome`], so what ran before it is still read.
215pub fn run_observed(
216    entry: Entry<'_>,
217    inputs: Inputs,
218    loader: &dyn crate::uses::Loader,
219    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
220    watch: impl FnOnce(
221        &std::sync::Arc<crate::observe::Definitions>,
222    ) -> std::sync::Arc<dyn crate::observe::CallObserver>,
223) -> Result<Observed, RunError> {
224    let mut interp = crate::interpreter_hostless();
225    crate::inputs::install_input_primitives(&mut interp, inputs);
226    let prepared = prepare(entry, loader, surface, &builtin_names(&interp))?;
227    let definitions = std::sync::Arc::new(crate::observe::Definitions::of(
228        &prepared.program,
229        &prepared.erased,
230    ));
231    interp.set_call_observer(Some(watch(&definitions)));
232    let outcome = eval_prepared(&mut interp, &prepared, &mut ()).map(|value| Run {
233        value,
234        visited: prepared.visited,
235        typed_decls: prepared.typed_decls,
236        seams: prepared.seams,
237    });
238    interp.set_call_observer(None);
239    Ok(Observed {
240        definitions,
241        outcome,
242    })
243}
244
245/// A program past every stage before evaluation: parsed, its imports
246/// resolved, type-checked and erased. What [`run_in_surface`] and
247/// [`crate::hosted::load_hosted`] both evaluate, so the stage order this
248/// module exists to own is written once for both.
249pub(crate) struct Prepared {
250    program: crate::uses::ResolvedProgram,
251    erased: Vec<tatara_lisp::Spanned>,
252    visited: usize,
253    typed_decls: usize,
254    seams: usize,
255}
256
257impl Prepared {
258    /// The forms the evaluator will run: every file's, resolved and erased.
259    pub(crate) fn erased(&self) -> &[tatara_lisp::Spanned] {
260        &self.erased
261    }
262
263    /// The entry file's namespace.
264    pub(crate) fn entry_namespace(&self) -> Namespace {
265        let first = (0..self.program.forms().len())
266            .find(|i| self.program.owner_of(*i) == Some(crate::uses::ResolvedProgram::ENTRY));
267        match first {
268            Some(i) => namespace_of(&self.program, i),
269            None => match self
270                .program
271                .file(crate::uses::ResolvedProgram::ENTRY)
272                .and_then(|f| f.package.clone())
273            {
274                Some(p) => Namespace::Bidama(p),
275                None => Namespace::File(String::new()),
276            },
277        }
278    }
279}
280
281/// Parse, resolve, check and erase, in that order.
282///
283/// `builtins` is the name table of the interpreter that will evaluate the
284/// result ([`builtin_names`]); the check stage resolves the program's names
285/// against it.
286pub(crate) fn prepare(
287    entry: Entry<'_>,
288    loader: &dyn crate::uses::Loader,
289    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
290    builtins: &NameTable,
291) -> Result<Prepared, RunError> {
292    let mut program = parse_and_resolve(entry, loader, surface)?;
293
294    // `test` blocks are declarations for the harness, not code to run.
295    //
296    // Dropped here rather than in `resolve_uses`, because `blue test` calls
297    // the resolver and then NEEDS the entry file's blocks — so the two
298    // callers want different things and the split has to live at this level.
299    //
300    // Without this, `blue run` on a file containing its own tests fails with
301    // `unbound symbol: deftest`: every package in the bidama distribution
302    // carries tests, so every one of them was unrunnable.
303    //
304    // Through `retain`, which drops each form's owner with it. A plain filter
305    // over the forms alone would slide every later form onto the wrong file.
306    program.retain(|f| !crate::uses::is_test_form(f));
307
308    // CHECK, on the annotated tree — the only tree that has annotations.
309    //
310    // **On the REAL spanned tree, including every imported package's.** This
311    // was the one caller that checked a spanless lift, because `resolve_uses`
312    // flattened the entry file and its imports into one list and `Span` is a
313    // byte range with no file identity — so a real span here would have
314    // reported an imported package's error at that offset in the ENTRY file, a
315    // precise-looking answer pointing at unrelated code.
316    //
317    // The fix is not a wider `Span` (that type is upstream, and its own docs
318    // put file identity on the caller: spans "are meaningful only relative to
319    // the string that produced them, which the caller is responsible for
320    // holding onto"). blue holds onto it BESIDE the span, per top-level form —
321    // see `uses::ResolvedProgram`.
322    let (outcome, names) = check_stage(&program, builtins, false);
323    if !outcome.ok() {
324        return Err(RunError::Types(
325            outcome
326                .errors()
327                // `file:line:col: error[CODE]: message`, resolved against the
328                // file the form actually came from, then its help line.
329                .map(|d| render(&program, d))
330                .collect(),
331        ));
332    }
333
334    // ERASE, so the interpreter never sees a type — **on the spanned tree,
335    // and back out as one.**
336    //
337    // This used to be `erase_types(&program.sexps())` followed by a
338    // `lower_to_spanned` that stamped `Span::synthetic()` over every node, and
339    // the comment here said carrying real positions through was "a larger
340    // piece and is NOT built". It was not larger: erasure only ever DELETES
341    // nodes — the single node it invents is the `define` replacing
342    // `define-typed` — so there was never anything for a synthetic span to
343    // stand in for. See `crate::erase`.
344    //
345    // No lowering step follows. The tree the evaluator receives IS the tree the
346    // parser built, minus annotations, with the author's byte offsets intact.
347    let erased = erase_types(&lower(&program, &names));
348
349    Ok(Prepared {
350        program,
351        erased,
352        visited: outcome.stats.visited,
353        typed_decls: outcome.stats.typed_decls,
354        seams: outcome.seams.len(),
355    })
356}
357
358/// Parse the entry (in `surface`, if one is given) and splice in its imports.
359pub(crate) fn parse_and_resolve(
360    entry: Entry<'_>,
361    loader: &dyn crate::uses::Loader,
362    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
363) -> Result<crate::uses::ResolvedProgram, RunError> {
364    let forms = match surface {
365        Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
366            .map_err(|e| RunError::Parse(e.to_string()))?,
367        None => loader
368            .parse(entry.text)
369            .map_err(|e| RunError::Parse(e.to_string()))?,
370    };
371
372    // RESOLVE imports first, so everything below sees ONE program.
373    //
374    // Before the check on purpose: imported code is type-checked at the point
375    // its consumer imports it, rather than at whatever later moment its code
376    // first runs. A package that does not typecheck should break its importer's
377    // build, not their production run.
378    //
379    // One program, but not one FILE: the result records which file each
380    // top-level form came from, which is what lets a diagnostic below name a
381    // place instead of only a problem.
382    crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)
383}
384
385/// **The check stage**: every rule in `blue_lang_check::RULES`, over a
386/// resolved program, with waivers applied.
387///
388/// One function, called by every door — `run`, the hosted loader, `blue test`
389/// and `blue check` — so a rule added to the registry is enforced by all of
390/// them at once. That is what makes an architecture rule a pipeline error
391/// rather than an optional report: there is no door that skips it.
392///
393/// Warnings (unused bindings, unused waivers) are reported for the ENTRY
394/// file only; an imported package's warnings belong to its author. Errors
395/// are reported for every file, because an imported package with one is
396/// broken for its importer.
397pub(crate) fn check_stage(
398    program: &crate::uses::ResolvedProgram,
399    builtins: &NameTable,
400    with_tests: bool,
401) -> (blue_lang_check::Outcome, NameTable) {
402    use crate::uses::ResolvedProgram;
403    let forms = program.forms();
404    let is_entry = |i: usize| program.owner_of(i) == Some(ResolvedProgram::ENTRY);
405
406    // Rules B0003–B0005: the typing ladder.
407    let mut outcome = blue_lang_check::check_program(forms);
408
409    // Rules B0001–B0002: names. The program's own scopes come first (the
410    // entry file, then each bidama in import order), then the interpreter's.
411    let table = program_names(program, builtins);
412    let whole = |i: usize| with_tests && is_entry(i);
413    let (names, resolved) = blue_lang_check::names::check_names_whole(
414        forms,
415        &table,
416        &|i| namespace_of(program, i),
417        &is_entry,
418        &whole,
419    );
420    let stored = program
421        .file(ResolvedProgram::ENTRY)
422        .is_some_and(|f| f.path.is_some());
423    outcome.diagnostics.extend(
424        names
425            .into_iter()
426            .filter(|d| stored || d.code != blue_lang_check::Code::B0023),
427    );
428    outcome.stats.names_resolved = resolved;
429
430    // Waivers, per file: B0007 for a malformed one, B0008 for one that
431    // suppresses nothing.
432    let mut waivers = Vec::new();
433    for file in program.files() {
434        let spans: Vec<(usize, tatara_lisp::Span)> = forms
435            .iter()
436            .enumerate()
437            .filter(|(i, _)| program.owner_of(*i) == Some(file.id))
438            .map(|(i, f)| (i, f.span))
439            .collect();
440        let (found, malformed) = blue_lang_check::waiver::collect(&file.text, &spans);
441        waivers.extend(found);
442        outcome.diagnostics.extend(malformed);
443    }
444    outcome.waived = blue_lang_check::waiver::apply(&mut outcome.diagnostics, waivers, &is_entry);
445    // A rule whose census has not reached zero is computed, and counted, but
446    // not enforced: its findings move to `census` (see `RULES`' `ratchet`).
447    let (enforced, census): (Vec<_>, Vec<_>) = std::mem::take(&mut outcome.diagnostics)
448        .into_iter()
449        .partition(|d| d.code.rule().active());
450    outcome.diagnostics = enforced;
451    outcome.census = census;
452    outcome
453        .diagnostics
454        .sort_by_key(|d| (d.top_level, d.span.start, d.code));
455    (outcome, table)
456}
457
458/// The whole name table the check stage resolves against: the program's
459/// own scopes (each file and bidama, by namespace) followed by `builtins`.
460///
461/// **A top-level call to a builtin macro can define names**: `defflow(slug,
462/// trim, downcase)` expands to `(define slug …)`, and nothing in the source
463/// spells `define`. Such a call is expanded here, on a hostless fork, and what
464/// its expansion defines is bound in the calling file's namespace. Only
465/// BUILTIN macros are expanded: their bodies are the runtime's own
466/// templates. A program's own `defmacro` is not run at check time — checking a
467/// file (and an editor checking on every keystroke) must not execute the code
468/// it checks — so names a program macro generates are not seen; none of the
469/// corpus's macros generates one (measured 2026-09-29).
470#[must_use]
471pub fn program_names(program: &crate::uses::ResolvedProgram, builtins: &NameTable) -> NameTable {
472    use blue_lang_check::Binding;
473    let mut table = NameTable::new();
474    table.add_program(program.forms(), |i| namespace_of(program, i));
475    let builtin_macro = |head: &str| {
476        builtins
477            .scopes()
478            .iter()
479            .any(|s| s.namespace == Namespace::Macro && s.get(head).is_some())
480    };
481    let mut expander: Option<tatara_lisp_eval::Interpreter<()>> = None;
482    for (i, form) in program.forms().iter().enumerate() {
483        let Some(head) = form
484            .as_list()
485            .and_then(|items| items.first())
486            .and_then(tatara_lisp::Spanned::as_symbol)
487        else {
488            continue;
489        };
490        if !builtin_macro(head) {
491            continue;
492        }
493        let interp = expander.get_or_insert_with(crate::interpreter_hostless);
494        // A failed expansion defines nothing here; the evaluator will report
495        // it, with its position, when the form runs.
496        let Ok(expanded) = interp.fully_expand(form, &mut ()) else {
497            continue;
498        };
499        let nullary = blue_lang_syntax::scope::nullary_definition(&expanded);
500        for (name, span, kind) in blue_lang_check::names::definitions_of(&expanded) {
501            table.define(
502                namespace_of(program, i),
503                Binding {
504                    nullary: nullary == Some(name.as_str()),
505                    name,
506                    kind,
507                    span: Some(span),
508                    top_level: Some(i),
509                },
510            );
511        }
512    }
513    for scope in builtins.scopes() {
514        table.push(scope.clone());
515    }
516    table.spell_bare_like(builtins);
517    let form_file: Vec<usize> = (0..program.forms().len())
518        .map(|i| program.owner_of(i).map_or(usize::MAX, |f| f.index()))
519        .collect();
520    let mut imports: std::collections::BTreeMap<usize, blue_lang_check::names::FileImports> =
521        std::collections::BTreeMap::new();
522    for (file, import) in program.imports() {
523        let entry = imports.entry(file.index()).or_default();
524        entry.add(&import.package, import.names.iter().map(|(n, _)| n.clone()));
525        let at = import.span.start;
526        entry.use_at = Some(entry.use_at.map_or(at, |a| a.min(at)));
527    }
528    // A FACADE — a bidama that defines nothing and `use`s others (zenbu) —
529    // exists so a consumer can depend on "everything": `use("zenbu")` makes
530    // every member's QUALIFIER reachable (`toukei::median`), never a bare
531    // name. Followed to a fixpoint, so a facade of facades works too.
532    let facade_uses =
533        |pkg: &str,
534         imports: &std::collections::BTreeMap<usize, blue_lang_check::names::FileImports>| {
535            let defines = table.bidama(pkg).is_some_and(|s| !s.is_empty());
536            if defines {
537                return std::collections::BTreeSet::new();
538            }
539            program
540                .files()
541                .iter()
542                .filter(|f| f.package.as_deref() == Some(pkg))
543                .filter_map(|f| imports.get(&f.id.index()))
544                .flat_map(|i| i.uses.iter().cloned())
545                .collect::<std::collections::BTreeSet<String>>()
546        };
547    loop {
548        let mut grew = false;
549        let files: Vec<usize> = imports.keys().copied().collect();
550        for f in files {
551            let used: Vec<String> = imports[&f].uses.iter().cloned().collect();
552            for p in used {
553                for member in facade_uses(&p, &imports) {
554                    if let Some(i) = imports.get_mut(&f) {
555                        if i.uses.insert(member.clone()) {
556                            i.via.insert(member, p.clone());
557                            grew = true;
558                        }
559                    }
560                }
561            }
562        }
563        if !grew {
564            break;
565        }
566    }
567    let mut pkg_uses: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
568        std::collections::BTreeMap::new();
569    for (file, import) in program.imports() {
570        if let Some(p) = program.file(*file).and_then(|f| f.package.clone()) {
571            pkg_uses
572                .entry(p)
573                .or_default()
574                .insert(import.package.clone());
575        }
576    }
577    table.attach_files(form_file, imports);
578    table.attach_package_uses(pkg_uses);
579    table.attach_needs(program.needs().clone());
580    // Where each file lives and which distribution each bidama ships in: a
581    // bridge reference (B0021) is an error inside the bidama's own
582    // distribution and a warning outside it.
583    let canonical = |p: &std::path::Path| {
584        std::fs::canonicalize(p)
585            .unwrap_or_else(|_| p.to_path_buf())
586            .display()
587            .to_string()
588    };
589    let mut file_paths = std::collections::BTreeMap::new();
590    let mut pkg_roots = std::collections::BTreeMap::new();
591    for f in program.files() {
592        if let Some(path) = &f.path {
593            file_paths.insert(f.id.index(), canonical(path));
594            if let (Some(pkg), Some(root)) =
595                (&f.package, path.parent().and_then(std::path::Path::parent))
596            {
597                pkg_roots
598                    .entry(pkg.clone())
599                    .or_insert_with(|| canonical(root));
600            }
601        }
602    }
603    table.attach_paths(file_paths, pkg_roots);
604    let versions = program.versions().clone();
605    table.apply_legacy(program.forms(), &|i| namespace_of(program, i), &|p| {
606        versions.get(p).cloned()
607    });
608    table
609}
610
611/// The forms the evaluator runs, from a checked program: every `use`
612/// declaration inert (`nil`, so each index still names its top-level form)
613/// and every name at the key it runs under — the RESOLVED tree under
614/// per-bidama namespaces (`blue_lang_check::names::Resolved::resolved_tree`).
615///
616/// A definition runs as `retsu/first` (a script's as `%root/f`), a
617/// reference to it as the same key, and a builtin as its bare name. Keys
618/// cannot collide across namespaces, so a bidama's `first` no longer
619/// replaces the builtin for anyone who did not import it, and an importer's
620/// `helper` no longer replaces a bidama's own: the runtime binds exactly what
621/// the check stage resolved.
622#[must_use]
623pub fn lower(
624    program: &crate::uses::ResolvedProgram,
625    names: &NameTable,
626) -> Vec<tatara_lisp::Spanned> {
627    let resolved = blue_lang_check::names::resolve_program(program.forms(), names, &|i| {
628        namespace_of(program, i)
629    });
630    resolved
631        .resolved_tree(program.forms(), blue_lang_check::names::Rule::Namespaced)
632        .into_iter()
633        .zip(program.forms())
634        .map(|(resolved, written)| {
635            if crate::uses::use_target(written).is_some()
636                || blue_lang_syntax::scope::legacy_target(written).is_some()
637            {
638                tatara_lisp::Spanned::new(written.span, tatara_lisp::SpannedForm::Nil)
639            } else {
640                resolved
641            }
642        })
643        .collect()
644}
645
646/// The namespace top-level form `i` of `program` defines into.
647#[must_use]
648pub fn namespace_of(program: &crate::uses::ResolvedProgram, i: usize) -> Namespace {
649    let file = program.owner_of(i).and_then(|id| program.file(id));
650    match file {
651        Some(f) => match &f.package {
652            Some(p) => Namespace::Bidama(p.clone()),
653            None => Namespace::File(
654                f.path
655                    .as_ref()
656                    .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string()),
657            ),
658        },
659        None => Namespace::File("<unknown file>".to_string()),
660    }
661}
662
663/// Names only the test harness binds.
664///
665/// In scope for EVERY check, not only for test blocks. A bidama may carry a
666/// test helper — a plain `def` that asserts, called only from its `test`
667/// blocks (`heni`'s `hn_end_to_end` is one) — and every program importing that
668/// bidama would otherwise be refused for a function it never calls. The
669/// refusal would be scoped to the importer's whole program, not to the fault.
670/// The cost, stated: an `assert` outside any test that `blue run` actually
671/// reaches is caught at runtime (`blue-assert` is unbound there), not here.
672///
673/// `blue-lang-test` registers these on its own interpreter, and this crate
674/// cannot depend on it (it depends on this one), so the list lives beside the
675/// only name in it: the lowered `assert`, which `blue-lang-syntax` owns.
676pub const HARNESS_NAMES: &[&str] = &[blue_lang_syntax::LOWERED_ASSERT];
677
678/// The check stage's builtin scopes, read off the interpreter that will run
679/// the program: the test harness's names ([`HARNESS_NAMES`]), then its special
680/// forms, its macros, and every value it binds — the last three in the order
681/// the evaluator consults for a head.
682///
683/// Read from a live interpreter rather than a list, for the reason
684/// `Interpreter::resolve_head` gives: a list is a second copy of the truth and
685/// goes stale on the next installer that lands. An embedder's own
686/// primitives are in scope here because the embedder installs them first.
687#[must_use]
688pub fn builtin_names<H: 'static>(interp: &tatara_lisp_eval::Interpreter<H>) -> NameTable {
689    use blue_lang_check::{Binding, Scope, ScopeKind};
690    use tatara_lisp_eval::HeadBinding;
691    let mut special = Scope::new(Namespace::SpecialForm);
692    let mut macros = Scope::new(Namespace::Macro);
693    let mut values = Scope::new(Namespace::Builtin);
694    // `reserved_head_names` reads the ROOT frame only, so a name installed on
695    // a fork (the input primitives, on every `run`) is invisible to it; those
696    // are asked about by name instead, which `resolve_head` answers through
697    // every frame.
698    let mut names: Vec<std::sync::Arc<str>> = interp.reserved_head_names().into_iter().collect();
699    names.extend(
700        crate::inputs::INPUT_PRIMITIVES
701            .iter()
702            .filter(|n| interp.resolve_head(n).is_some())
703            .map(|n| std::sync::Arc::from(*n)),
704    );
705    for name in names {
706        let (scope, kind) = match interp.resolve_head(&name) {
707            Some(HeadBinding::SpecialForm) => (&mut special, ScopeKind::SpecialForm),
708            Some(HeadBinding::Macro) => (&mut macros, ScopeKind::Macro),
709            _ => (&mut values, ScopeKind::Value),
710        };
711        let nullary = kind == ScopeKind::Value
712            && interp.lookup_global(&name).is_some_and(|v| {
713                nullary_value(&v)
714                    || (crate::docs::doc_of(&name).is_some_and(|d| d.signature == &*name)
715                        && matches!(
716                            v,
717                            tatara_lisp_eval::Value::NativeFn(_)
718                                | tatara_lisp_eval::Value::Closure(_)
719                        ))
720            });
721        scope.bind(Binding {
722            name: name.to_string(),
723            kind,
724            span: None,
725            top_level: None,
726            nullary,
727        });
728    }
729    let mut table = NameTable::new();
730    let mut harness = Scope::new(Namespace::Harness);
731    for n in HARNESS_NAMES {
732        harness.bind(Binding {
733            name: (*n).to_string(),
734            kind: ScopeKind::Value,
735            span: None,
736            top_level: None,
737            nullary: false,
738        });
739    }
740    table.push(harness);
741    table.push(special);
742    table.push(macros);
743    table.push(values);
744    table.spell_bare_only(
745        crate::docs::NAMES
746            .iter()
747            .filter(|d| d.signature == d.name)
748            .map(|d| d.name.to_string())
749            .collect(),
750    );
751    table
752}
753
754#[must_use]
755pub fn nullary_value(value: &tatara_lisp_eval::Value) -> bool {
756    match value {
757        tatara_lisp_eval::Value::NativeFn(f) => {
758            matches!(f.arity, tatara_lisp_eval::ffi::Arity::Exact(0))
759        }
760        tatara_lisp_eval::Value::Closure(c) => c.params.is_empty() && c.rest.is_none(),
761        _ => false,
762    }
763}
764
765/// A diagnostic as a person reads it: `file:line:col: error[B0001]: message`,
766/// then an indented `help:` line when it has one.
767#[must_use]
768pub fn render(program: &crate::uses::ResolvedProgram, d: &blue_lang_check::Diagnostic) -> String {
769    let head = d.to_string();
770    let mut out = program.locate(d.top_level, d.span, &head).to_string();
771    if let Some(help) = &d.help {
772        out.push_str("\n  help: ");
773        out.push_str(help);
774    }
775    out
776}
777
778/// What [`check_entry`] checks.
779#[derive(Clone, Copy, Debug, PartialEq, Eq)]
780pub enum Checking {
781    /// The program `blue run` would evaluate: test blocks dropped.
782    Program,
783    /// The program plus the entry file's test blocks, as `blue test` and
784    /// `blue check` see it.
785    WithTests,
786}
787
788/// A program through the check stage, with everything the stage found.
789pub struct Checked {
790    pub program: crate::uses::ResolvedProgram,
791    pub outcome: blue_lang_check::Outcome,
792    /// The name table the stage resolved against: every program scope, then
793    /// the builtins. What an editor completes from.
794    pub names: NameTable,
795}
796
797impl Checked {
798    /// The forms an evaluator runs: [`lower`]ed, then erased — the tree
799    /// `run` evaluates, for a door that brings its own evaluator (the VM).
800    #[must_use]
801    pub fn erased(&self) -> Vec<tatara_lisp::Spanned> {
802        erase_types(&lower(&self.program, &self.names))
803    }
804
805    /// What `blue test` hands the harness: the program [`lower`]ed, spans
806    /// projected away.
807    #[must_use]
808    pub fn evaluable(&self) -> Vec<tatara_lisp::Sexp> {
809        lower(&self.program, &self.names)
810            .iter()
811            .map(tatara_lisp::Spanned::to_sexp)
812            .collect()
813    }
814
815    /// Every non-local reference, resolved under both rules — today's flat
816    /// one and per-bidama namespaces — and the resolved trees built from them
817    /// (`blue ast --resolved`).
818    #[must_use]
819    pub fn resolve(&self) -> blue_lang_check::names::Resolved {
820        blue_lang_check::names::resolve_program(self.program.forms(), &self.names, &|i| {
821            namespace_of(&self.program, i)
822        })
823    }
824}
825
826impl Checked {
827    #[must_use]
828    pub fn call_explicit_forms(&self) -> Vec<tatara_lisp::Spanned> {
829        self.resolve().call_explicit_tree(self.program.forms())
830    }
831
832    #[must_use]
833    pub fn canonical_entry(&self, src: &str) -> Option<Vec<tatara_lisp::Spanned>> {
834        use crate::uses::ResolvedProgram;
835        let program = &self.program;
836        let entry: Vec<&tatara_lisp::Spanned> = program
837            .forms()
838            .iter()
839            .enumerate()
840            .filter(|(i, _)| program.owner_of(*i) == Some(ResolvedProgram::ENTRY))
841            .map(|(_, f)| f)
842            .collect();
843        let written = blue_lang_syntax::parse_program_tree(src).ok()?;
844        let same = written.len() == entry.len()
845            && written
846                .iter()
847                .zip(&entry)
848                .all(|(w, e)| w.to_sexp() == e.to_sexp() && w.span == e.span);
849        if !same {
850            return None;
851        }
852        let bare: std::collections::HashSet<usize> = self
853            .resolve()
854            .parenthesized_nullary_calls()
855            .into_iter()
856            .filter(|r| program.owner_of(r.top_level) == Some(ResolvedProgram::ENTRY))
857            .map(blue_lang_check::names::Reference::node_address)
858            .collect();
859        Some(
860            entry
861                .into_iter()
862                .map(|f| unparenthesize(f, &bare))
863                .collect(),
864        )
865    }
866}
867
868fn unparenthesize(
869    form: &tatara_lisp::Spanned,
870    bare: &std::collections::HashSet<usize>,
871) -> tatara_lisp::Spanned {
872    use tatara_lisp::{Spanned, SpannedForm};
873    let inner = match &form.form {
874        SpannedForm::List(items) => match items.as_slice() {
875            [head] if bare.contains(&(std::ptr::from_ref(head) as usize)) => {
876                return Spanned::new(form.span, head.form.clone());
877            }
878            _ => SpannedForm::List(items.iter().map(|i| unparenthesize(i, bare)).collect()),
879        },
880        SpannedForm::Quote(i) => SpannedForm::Quote(Box::new(unparenthesize(i, bare))),
881        SpannedForm::Quasiquote(i) => SpannedForm::Quasiquote(Box::new(unparenthesize(i, bare))),
882        SpannedForm::Unquote(i) => SpannedForm::Unquote(Box::new(unparenthesize(i, bare))),
883        SpannedForm::UnquoteSplice(i) => {
884            SpannedForm::UnquoteSplice(Box::new(unparenthesize(i, bare)))
885        }
886        other => other.clone(),
887    };
888    Spanned::new(form.span, inner)
889}
890
891/// Parse, resolve and CHECK, without running: the door for `blue check`,
892/// `blue test` and the language server.
893///
894/// Returns every diagnostic, errors and warnings alike, rather than failing on
895/// the first error: a reader fixing a file wants the whole list, and an agent
896/// repairing one wants it in one pass. Whether errors stop anything is the
897/// caller's decision — `blue test` refuses to run tests over a program with
898/// one, exactly as `run` refuses to evaluate it.
899///
900/// The name oracle is the interpreter `blue run` would build: hostless plus
901/// input primitives.
902///
903/// # Errors
904///
905/// A parse or import error, which leaves no program to check.
906pub fn check_entry(
907    entry: Entry<'_>,
908    loader: &dyn crate::uses::Loader,
909    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
910    checking: Checking,
911) -> Result<Checked, RunError> {
912    let mut program = parse_and_resolve(entry, loader, surface)?;
913    if checking == Checking::Program {
914        program.retain(|f| !crate::uses::is_test_form(f));
915    }
916    let mut interp = crate::interpreter_hostless();
917    crate::inputs::install_input_primitives(&mut interp, Inputs::new());
918    let builtins = builtin_names(&interp);
919    let (outcome, names) = check_stage(&program, &builtins, checking == Checking::WithTests);
920    Ok(Checked {
921        program,
922        outcome,
923        names,
924    })
925}
926
927/// A syntax error in `text` as a [`B0006`](blue_lang_check::Code::B0006)
928/// diagnostic, so a tool reading `--format json` gets a parse failure in the
929/// same shape as every other finding. `None` when `text` parses.
930#[must_use]
931pub fn syntax_diagnostic(text: &str) -> Option<blue_lang_check::Diagnostic> {
932    let e = blue_lang_syntax::parse_program_tree(text).err()?;
933    Some(
934        blue_lang_check::Diagnostic::new(blue_lang_check::Code::B0006, e.message, e.span)
935            .at_top_level(0),
936    )
937}
938
939/// A runtime message as the author wrote the names in it: a definition runs
940/// under its key (`retsu/first`, `%root/f`), and a message naming one in
941/// backticks names it `retsu::first`, or `f` for the author's own script.
942#[must_use]
943pub fn display_keys(message: &str) -> String {
944    let mut out = String::with_capacity(message.len());
945    let mut parts = message.split('`');
946    if let Some(first) = parts.next() {
947        out.push_str(first);
948    }
949    for (i, part) in parts.enumerate() {
950        out.push('`');
951        // Odd pieces (0-based here) are inside backticks.
952        if i % 2 == 0 {
953            out.push_str(&crate::observe::display_key(part));
954        } else {
955            out.push_str(part);
956        }
957    }
958    out
959}
960
961/// Evaluate a prepared program's top-level forms into `interp`, with `host`.
962pub(crate) fn eval_prepared<H: 'static>(
963    interp: &mut tatara_lisp_eval::Interpreter<H>,
964    prepared: &Prepared,
965    host: &mut H,
966) -> Result<Value, RunError> {
967    let Prepared {
968        program, erased, ..
969    } = prepared;
970
971    // RUN, one top-level form at a time, **counting them**.
972    //
973    // This is literally the loop `Interpreter::eval_program` runs internally
974    // (tatara-lisp-eval `eval.rs`) — unrolled here for exactly one reason: the
975    // INDEX. `resolve_uses` flattens N files into one form list and a `Span` is
976    // a byte range with no file identity, so an offset alone is ambiguous
977    // across files — 66 means something in every one of them. The index is the
978    // join key back to `ResolvedProgram`'s file table, and `eval_program`
979    // consumes it internally and hands back only the error.
980    //
981    // The same key, the same renderer and the same file table the type errors
982    // above already use. A runtime error was the one diagnostic in this
983    // function still reporting a bare message.
984    let mut value = Value::Nil;
985    for (top_level, form) in erased.iter().enumerate() {
986        value = interp.eval_top_form(form, host).map_err(|e| {
987            // The message, its position and the blue call stack, read off the
988            // frames the error unwound through (`observe::Fault`). The message
989            // is `short_message` rather than upstream's `Display`, whose
990            // trailing ` at {span}` is a bare byte range with no file, through
991            // `messages::describe`, which names the function a wrong argument
992            // count was called on.
993            RunError::Eval(Box::new(crate::observe::Fault::of(
994                program,
995                erased,
996                top_level,
997                &e,
998                &interp.take_trace(),
999            )))
1000        })?;
1001    }
1002    Ok(value)
1003}
1004
1005/// **A runtime error names a place.** The gates for the last stage that
1006/// reported a bare message.
1007///
1008/// Each fixture is sized so that resolving the failing span against ANY file
1009/// but the right one lands somewhere else — a different path and a different
1010/// line — so a green run here is evidence about the join and not about the
1011/// renderer being called at all.
1012///
1013/// **Each raise is an unbound name, WAIVED.** Since the check stage resolves
1014/// names (B0001), an unbound call no longer reaches the evaluator; these
1015/// fixtures need exactly that runtime raise, so each carries
1016/// `# waive B0001: …` above the definition holding it — the one escape hatch,
1017/// used for what it is for. The waiver adds a line above each failing call,
1018/// which is why the positions below are one line later than their red runs
1019/// (recorded against the unwaived fixtures) show.
1020#[cfg(test)]
1021mod position_tests {
1022    use super::*;
1023    use crate::uses::{Entry, Loader};
1024    use std::collections::BTreeMap;
1025    use std::path::Path;
1026
1027    struct MemLoader(BTreeMap<&'static str, &'static str>);
1028
1029    impl Loader for MemLoader {
1030        fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
1031            self.0
1032                .get(name)
1033                .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
1034                .ok_or_else(|| format!("no bidama named \"{name}\""))
1035        }
1036    }
1037
1038    /// A package whose OWN top-level code raises, deep inside a file that is
1039    /// deliberately taller than either of the other two.
1040    ///
1041    /// Byte 66 — where `kore_wa_sonzai_shinai` starts — is past the end of the
1042    /// entry file (30 bytes) and past the end of `kotae.b` (21 bytes). So the
1043    /// only file this span can honestly resolve against is this one, and the
1044    /// other two now refuse to answer rather than walking off their ends.
1045    const BAKUHATSU: &str = "\
1046def tsukawanai_a()
1047  1
1048end
1049
1050def tsukawanai_b()
1051  2
1052end
1053
1054# waive B0001: the fixture needs a raise at runtime
1055def bakuhatsu()
1056  kore_wa_sonzai_shinai()
1057end
1058
1059bakuhatsu";
1060
1061    const KOTAE: &str = "def kotae()\n  42\nend";
1062
1063    /// The same failing call, but only ever reached by a CALLER in another
1064    /// file — the shape the call stack resolved (the last position test).
1065    const BAKUHATSU2: &str =
1066        "# waive B0001: a raise at runtime\ndef yobu()\n  kore_wa_sonzai_shinai()\nend";
1067
1068    fn loader() -> MemLoader {
1069        MemLoader(BTreeMap::from([
1070            ("kotae", KOTAE),
1071            ("bakuhatsu", BAKUHATSU),
1072            ("bakuhatsu2", BAKUHATSU2),
1073        ]))
1074    }
1075
1076    fn run_named(path: &str, src: &str) -> RunError {
1077        run_in_surface(
1078            Entry {
1079                path: Some(Path::new(path)),
1080                text: src,
1081            },
1082            Inputs::new(),
1083            &loader(),
1084            None,
1085        )
1086        .expect_err("the fixture must raise")
1087    }
1088
1089    /// **The load-bearing gate: a raise inside an IMPORTED package reports the
1090    /// IMPORTED file's path and line.**
1091    ///
1092    /// Hand-computed. In `bakuhatsu.b` the failing call sits on line 11 at
1093    /// column 3, and the whole point of the fixture's height is that no other
1094    /// file in the program can produce that pair: the entry file has 2 lines
1095    /// and `kotae.b` has 3, so *any* mis-attribution is visible as a different
1096    /// path AND a different position, never as a coincidence.
1097    ///
1098    /// **Three red runs, one per edit this change makes** — each isolates a
1099    /// different half of the mechanism, which is what stops the gate being a
1100    /// tautology over "some renderer ran".
1101    ///
1102    /// 1. `eval_program` + `.map_err(|e| RunError::Eval(e.to_string()))`
1103    ///    restored (the pre-change code):
1104    ///    ```text
1105    ///    left:  "runtime error: unbound symbol: kore_wa_sonzai_shinai at 74..95"
1106    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1107    ///    ```
1108    ///    No place at all — the state this change is fixing — and the trailing
1109    ///    `at 74..95` is upstream's `Display`, an offset with no file, which is
1110    ///    why the loop renders `short_message` instead.
1111    ///
1112    /// 2. The loop kept, but `0` passed to `locate` in place of `top_level`:
1113    ///    ```text
1114    ///    left:  "runtime error: kotae.b: unbound symbol `kore_wa_sonzai_shinai`"
1115    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1116    ///    ```
1117    ///    **This is the one that proves the INDEX is load-bearing** rather than
1118    ///    the renderer: form 0 belongs to `kotae.b`, so the wrong file is named
1119    ///    — and named without a position, because the guard in `locate` sees
1120    ///    that byte 74 is not a range in a 21-byte file and declines to invent
1121    ///    one. A fixture with one imported package could not tell these apart.
1122    ///
1123    /// 3. `crate::lower_to_spanned(&crate::to_sexps(&erased))` reinstated
1124    ///    between erasure and the loop:
1125    ///    ```text
1126    ///    left:  "runtime error: bakuhatsu.b: unbound symbol `kore_wa_sonzai_shinai`"
1127    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1128    ///    ```
1129    ///    The right FILE with no position — which is exactly the shape of the
1130    ///    bug: the index survived the lift and every span did not.
1131    /// kotae loads first so bakuhatsu's forms do not start at index 0: the
1132    /// order is the fixture, and the waiver says so (B0015 wants them sorted).
1133    const TWO_IMPORTS: &str =
1134        "use(\"kotae\")\n# waive B0015: kotae loads first on purpose\nuse(\"bakuhatsu\")\n";
1135
1136    #[test]
1137    fn a_raise_inside_an_imported_package_names_that_package() {
1138        let err = run_named("entry.b", TWO_IMPORTS);
1139        assert_eq!(
1140            err.to_string(),
1141            "runtime error: bakuhatsu.b:11:3: unbound symbol `kore_wa_sonzai_shinai`\n  \
1142             at bakuhatsu::bakuhatsu (bakuhatsu.b:11:3)\n  \
1143             at <top level> (bakuhatsu.b:14:1)"
1144        );
1145    }
1146
1147    /// Anti-vacuity for the fixture itself: the position asserted above is a
1148    /// FACT ABOUT `bakuhatsu.b`, checked against the source text rather than
1149    /// against the thing that produced it.
1150    ///
1151    /// Without this, `11:3` is just a number that happened to come out of the
1152    /// code under test, and a change that moved every reported line by one
1153    /// would move this assertion with it.
1154    #[test]
1155    fn line_11_column_3_of_the_fixture_is_the_failing_call() {
1156        let line = BAKUHATSU.lines().nth(10).expect("line 11 exists");
1157        assert_eq!(line, "  kore_wa_sonzai_shinai()");
1158        assert_eq!(
1159            &line[2..],
1160            "kore_wa_sonzai_shinai()",
1161            "column 3 (1-indexed) is where the call starts"
1162        );
1163        // And the other two files cannot reach that far, which is what makes a
1164        // mis-attribution loud instead of plausible.
1165        let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
1166        assert!(
1167            offset > KOTAE.len(),
1168            "kotae.b could answer for byte {offset}"
1169        );
1170        assert!(
1171            offset > TWO_IMPORTS.len(),
1172            "the entry file could answer for byte {offset}"
1173        );
1174    }
1175
1176    /// **The `FileId(0)` path: a raise in the ENTRY file reports the entry
1177    /// file.** The single-file case, which is every program that imports
1178    /// nothing — and the one where the index and the span cannot disagree.
1179    ///
1180    /// **Red run** (2026-08-12), `eval_program` +
1181    /// `.map_err(|e| RunError::Eval(e.to_string()))` restored:
1182    /// ```text
1183    /// left:  "runtime error: unbound symbol: nani_mo_nai at 17..28"
1184    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1185    /// ```
1186    ///
1187    /// **Second red run**, `lower_to_spanned` reinstated after erasure — the
1188    /// one that isolates the SPAN half from the index half, since a
1189    /// single-file program cannot get its file wrong:
1190    /// ```text
1191    /// left:  "runtime error: honmono.b: unbound symbol `nani_mo_nai`"
1192    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1193    /// ```
1194    #[test]
1195    fn a_raise_in_the_entry_file_names_the_entry_file() {
1196        let err = run_named(
1197            "honmono.b",
1198            "def f()\n  1\nend\n\n# waive B0001: a raise at runtime\nnani_mo_nai()\n",
1199        );
1200        assert_eq!(
1201            err.to_string(),
1202            "runtime error: honmono.b:6:1: unbound symbol `nani_mo_nai`"
1203        );
1204    }
1205
1206    /// A raise from a form that came through the SURFACE-level erasure still
1207    /// reports a real position — the annotated path, where erasure rewrites
1208    /// the node rather than passing it through.
1209    ///
1210    /// Separate from the two above because the erasure rewrite is the only
1211    /// place a node is built rather than kept, and a synthetic span there
1212    /// would be invisible to a fixture whose failing form is untouched.
1213    ///
1214    /// **Red run** (2026-08-12), `lower_to_spanned` reinstated after erasure:
1215    /// ```text
1216    /// left:  "runtime error: chuu.b: unbound symbol `mada_nai`"
1217    /// right: "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
1218    /// ```
1219    #[test]
1220    fn a_raise_inside_an_annotated_def_still_names_its_line() {
1221        let err = run_named(
1222            "chuu.b",
1223            "# waive B0001: a raise at runtime\ndef f(a: Int) -> Int\n  mada_nai(a)\nend\n\nf(1)\n",
1224        );
1225        assert_eq!(
1226            err.to_string(),
1227            "runtime error: chuu.b:3:3: unbound symbol `mada_nai`\n  \
1228             at f (chuu.b:3:3)\n  \
1229             at <top level> (chuu.b:6:1)"
1230        );
1231    }
1232
1233    /// **The limit this module used to state, closed by the call stack.**
1234    ///
1235    /// A top-level form in the entry file calls a function defined in an
1236    /// imported one, and the raise happens inside the callee. The executing
1237    /// form is the entry's and the span is the callee's bytes, so until the
1238    /// evaluator reported the frames an error unwound through, the best blue
1239    /// could do was name the caller's file with no position (the guard in
1240    /// `uses::ResolvedProgram::locate` refusing an offset it could not
1241    /// justify). The innermost frame's definition now names the file the
1242    /// offsets belong to, and the stack shows the call that led there.
1243    ///
1244    /// **Red run** (2026-10-10): `Fault::of` locating against the executing
1245    /// top-level form instead of the innermost frame's definition:
1246    /// ```text
1247    /// left:  "runtime error: yobidashi.b: unbound symbol `kore_wa_sonzai_shinai`\n  at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n  at <top level> (yobidashi.b:3:1)"
1248    /// ```
1249    /// The old answer, kept by the guard: the caller's file and no position.
1250    #[test]
1251    fn a_raise_in_a_callee_from_another_file_names_the_callees_file() {
1252        // The comment is padding, and load-bearing: `bakuhatsu2.b`'s failing
1253        // call starts at byte 47 (after its waiver line) and ends at 70, and
1254        // this file is 57 bytes, so the start is in range here and the end is
1255        // not — a mis-attribution would still be refused rather than placed.
1256        let err = run_named(
1257            "yobidashi.b",
1258            "use(\"bakuhatsu2\")\n# pad: 47's in range\nbakuhatsu2::yobu\n",
1259        );
1260        assert_eq!(
1261            err.to_string(),
1262            "runtime error: bakuhatsu2.b:3:3: unbound symbol `kore_wa_sonzai_shinai`\n  \
1263             at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n  \
1264             at <top level> (yobidashi.b:3:1)"
1265        );
1266    }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272
1273    fn int(src: &str) -> i64 {
1274        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
1275            Value::Int(v) => v,
1276            other => panic!("{src:?} produced {other:?}"),
1277        }
1278    }
1279
1280    /// **The sliding scale, as one assertion.** Annotating changes the
1281    /// analysis and nothing else.
1282    #[test]
1283    fn annotating_buys_analysis_and_changes_nothing_else() {
1284        let plain = run("def add(a, b)\n  a + b\nend\nadd(2, 3)").expect("plain");
1285        let typed = run("def add(a: Int, b: Int) -> Int\n  a + b\nend\nadd(2, 3)").expect("typed");
1286
1287        assert!(matches!(plain.value, Value::Int(5)));
1288        assert!(
1289            matches!(typed.value, Value::Int(5)),
1290            "the annotated program must compute the same answer"
1291        );
1292        assert_eq!(plain.visited, 0, "no annotations means no analysis");
1293        assert!(
1294            typed.visited > 0,
1295            "an annotation must actually buy analysis, not just decorate"
1296        );
1297        assert_eq!(plain.typed_decls, 0);
1298        assert_eq!(typed.typed_decls, 1);
1299    }
1300
1301    /// **Checking happens before erasure.** This is the test that catches the
1302    /// reordering: a program with a declared-type violation must be rejected,
1303    /// and it can only be rejected while the annotations still exist.
1304    #[test]
1305    fn a_type_error_is_reported_and_the_program_does_not_run() {
1306        let err = run("def add(a: Int, b: Int) -> Str\n  a + b\nend\nadd(1, 2)")
1307            .expect_err("a declared Str return from an Int body must be rejected");
1308        assert!(
1309            matches!(err, RunError::Types(ref d) if !d.is_empty()),
1310            "expected type diagnostics, got {err}"
1311        );
1312    }
1313
1314    /// And the untyped version of the same program runs, so the rejection
1315    /// above is the annotation's doing rather than a parse failure.
1316    #[test]
1317    fn the_same_program_without_annotations_runs() {
1318        assert_eq!(int("def add(a, b)\n  a + b\nend\nadd(1, 2)"), 3);
1319    }
1320
1321    #[test]
1322    fn a_parse_error_is_reported_as_one() {
1323        assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
1324    }
1325
1326    /// Every stage reports in its own vocabulary, so a failure names which
1327    /// stage failed rather than surfacing as a generic error.
1328    #[test]
1329    fn a_runtime_error_is_reported_as_one() {
1330        let err = run("throw(error(\"boom\"))").expect_err("a raise");
1331        assert!(matches!(err, RunError::Eval(_)), "got {err}");
1332    }
1333
1334    /// Stdlib and primitives are both reachable through the pipeline — the
1335    /// gap that made `6 % 3` fail.
1336    #[test]
1337    fn the_pipeline_reaches_both_runtime_layers() {
1338        assert_eq!(int("6 % 3"), 0);
1339        assert_eq!(int("7 % 3"), 1);
1340        assert_eq!(int("2 + 3 * 4"), 14);
1341    }
1342
1343    /// **The deleted hop was a no-op on everything blue emits — so removing it
1344    /// is a swap, not a behaviour change.**
1345    ///
1346    /// The old lowering printed the erased tree and read it back through
1347    /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
1348    /// paths land on the same tree, which is the equivalence the swap rests on.
1349    /// It is stated as a *measurement over this corpus*, not as a theorem:
1350    /// the round trip is not identity in general (that is precisely why it had
1351    /// to go), it merely happened to be identity for the bytes blue emits.
1352    #[test]
1353    fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
1354        let corpus = [
1355            "def add(a, b)\n  a + b\nend\nadd(2, 3)",
1356            "def fact(n)\n  if n < 2\n    1\n  else\n    n * fact(n - 1)\n  end\nend\nfact(5)",
1357            "def f(a, b)\n  c = a + b\n  c * 2\nend\nf(1, 2)",
1358            "defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)",
1359            "\"a string with spaces, a ( and a )\"",
1360            "def g(a: Int) -> Int\n  a + 1\nend\ng(1)",
1361            "6 % 3",
1362            "1.5 + 2.25",
1363        ];
1364        for src in corpus {
1365            // Projected to `Sexp` because that is what the deleted hop
1366            // operated on. Erasure itself no longer goes near it.
1367            let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
1368
1369            let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
1370                .iter()
1371                .map(tatara_lisp::Spanned::to_sexp)
1372                .collect();
1373            assert_eq!(direct, erased, "the direct lowering must be the identity");
1374
1375            let text = erased
1376                .iter()
1377                .map(ToString::to_string)
1378                .collect::<Vec<_>>()
1379                .join("\n");
1380            let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
1381                .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
1382                .iter()
1383                .map(tatara_lisp::Spanned::to_sexp)
1384                .collect();
1385            assert_eq!(
1386                round_tripped, erased,
1387                "{src:?}: the old print-and-reparse path changed the tree"
1388            );
1389        }
1390    }
1391
1392    /// Anti-vacuity for the test above: the round trip really is *not* the
1393    /// identity in general, so agreeing on the corpus was a property of what
1394    /// blue happens to emit rather than a property of the reader.
1395    ///
1396    /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
1397    /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
1398    /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
1399    /// domain, and the failure is *silent*: a symbol containing a space prints
1400    /// as two tokens, reads back as two symbols, and the result is a perfectly
1401    /// well-formed tree with a different meaning. No error, nothing to catch.
1402    ///
1403    /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
1404    /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
1405    /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
1406    /// in and one symbol out, so the brace-fusion reported in tatara *source*
1407    /// is not what bites a printed tree. The silent pair is what makes this a
1408    /// miscompile class rather than a noisy one.
1409    #[test]
1410    fn the_round_trip_is_not_the_identity_in_general() {
1411        let tree = Sexp::List(vec![
1412            Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
1413            Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
1414        ]);
1415        let text = tree.to_string();
1416        let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
1417            .expect("it reads back cleanly — that IS the problem")
1418            .iter()
1419            .map(tatara_lisp::Spanned::to_sexp)
1420            .collect();
1421        assert_ne!(
1422            back,
1423            vec![tree.clone()],
1424            "if print-then-read became inverse over symbols, the class would be \
1425             closed upstream and this test should be deleted rather than relaxed"
1426        );
1427        // …and the direct lowering is unaffected by any of it.
1428        let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
1429            .iter()
1430            .map(tatara_lisp::Spanned::to_sexp)
1431            .collect();
1432        assert_eq!(direct, vec![tree]);
1433    }
1434}
1435
1436#[cfg(test)]
1437mod macro_tests {
1438    use super::*;
1439
1440    fn int(src: &str) -> i64 {
1441        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
1442            Value::Int(v) => v,
1443            other => panic!("{src:?} produced {other:?}"),
1444        }
1445    }
1446
1447    /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
1448    #[test]
1449    fn a_macro_expands_and_runs() {
1450        assert_eq!(
1451            int("defmacro double(x)\n  quote\n    unquote(x) + unquote(x)\n  end\nend\ndouble(21)"),
1452            42
1453        );
1454    }
1455
1456    /// A macro receives *source forms*, not values — so it can duplicate its
1457    /// argument, which a function cannot do without re-evaluating it.
1458    #[test]
1459    fn a_macro_operates_on_syntax_not_values() {
1460        assert_eq!(
1461            int("defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)"),
1462            25,
1463            "the argument form `2 + 3` must be substituted twice"
1464        );
1465    }
1466
1467    /// **A runaway macro is a typed error, not a dead compiler.** This is the
1468    /// property that makes the metaprogramming surface safe to hand to a user.
1469    ///
1470    /// The assertion was `contains("expansion limit")` and now reads
1471    /// `contains("expansion")`, because the pipeline renders `short_message`
1472    /// rather than `Display`: upstream words the same fact as "exceeded 256
1473    /// expansion steps" instead of "exceeded the expansion limit of 256
1474    /// rewrites". The PROPERTY is unchanged and still asserted — the macro is
1475    /// named, and the failure is attributed to expansion rather than to
1476    /// evaluation. **This reword was the whole measured blast radius of that
1477    /// switch**, one test in the workspace.
1478    ///
1479    /// The number is deliberately NOT pinned. It is upstream's constant, this
1480    /// test owns none of it, and matching it would turn an upstream bump into
1481    /// a red run here that proves nothing about blue.
1482    ///
1483    /// The third clause is new capability rather than repair: the message now
1484    /// carries `<anonymous>:6:1`, so a runaway macro says WHERE it ran away.
1485    #[test]
1486    fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
1487        let err =
1488            run("defmacro forever(x)\n  quote\n    forever(unquote(x))\n  end\nend\nforever(1)")
1489                .expect_err("a self-referential macro must be rejected");
1490        let msg = err.to_string();
1491        assert!(
1492            msg.contains("forever") && msg.contains("expansion"),
1493            "the error must name the macro and the limit it hit: {msg}"
1494        );
1495        assert!(
1496            msg.contains("<anonymous>:6:1"),
1497            "and must place the call that ran away — line 6 is `forever(1)`: {msg}"
1498        );
1499    }
1500}
1501
1502#[cfg(test)]
1503mod input_tests {
1504    use super::*;
1505    use crate::inputs::{Declaration, Inputs};
1506
1507    /// A schema a macro will generate code from.
1508    const SCHEMA: &[u8] = b"3";
1509
1510    fn with_schema(src: &str) -> Result<Run, RunError> {
1511        let hash = Inputs::hash_of(SCHEMA);
1512        let mut inputs = Inputs::new();
1513        inputs
1514            .bind(
1515                &Declaration {
1516                    name: "schema".to_string(),
1517                    hash,
1518                },
1519                SCHEMA.to_vec(),
1520            )
1521            .expect("bind");
1522        run_with_inputs(src, inputs)
1523    }
1524
1525    fn decl_line() -> String {
1526        let mut s = String::from("definput(\"schema\", \"");
1527        s.push_str(&Inputs::hash_of(SCHEMA));
1528        s.push_str("\")\n");
1529        s
1530    }
1531
1532    /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
1533    /// the spec names it as gating blue's whole "stronger than Ruby's
1534    /// metaprogramming" claim, because a macro that cannot read a schema cannot
1535    /// generate code from one.
1536    #[test]
1537    fn a_macro_can_read_a_declared_build_input() {
1538        let src = decl_line() + "input(\"schema\")";
1539        let out = with_schema(&src).expect("run");
1540        assert!(
1541            matches!(out.value, Value::Str(ref s) if &**s == "3"),
1542            "got {:?}",
1543            out.value
1544        );
1545    }
1546
1547    /// **An undeclared input is an error, not a file read and not nil.**
1548    /// Returning nil is how a macro generates an empty table and nobody notices
1549    /// until runtime.
1550    #[test]
1551    fn an_undeclared_input_is_an_error() {
1552        let err = with_schema("input(\"not_declared\")").expect_err("must fail");
1553        let msg = err.to_string();
1554        assert!(msg.contains("not_declared"), "must name it: {msg}");
1555        assert!(msg.contains("definput"), "and say how to declare it: {msg}");
1556    }
1557
1558    /// **There is no path-based read at all.** The capability is the absence of
1559    /// the primitive, not a check inside one — so this is an unbound symbol.
1560    ///
1561    /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
1562    /// cargo feature (CLI only) is the one declared exception: it is the
1563    /// operator's own trusted host surface, and is asserted in
1564    /// `sys_read_file_is_the_trusted_cli_only_exception` below.
1565    #[cfg(not(feature = "sys"))]
1566    #[test]
1567    fn there_is_no_ambient_file_read() {
1568        for attempt in [
1569            "read_file(\"/etc/passwd\")",
1570            "File(\"/etc/passwd\")",
1571            "slurp(\"/etc/passwd\")",
1572            "open(\"/etc/passwd\")",
1573        ] {
1574            let err = with_schema(attempt).expect_err("must not resolve");
1575            assert!(
1576                err.to_string().contains("unbound"),
1577                "{attempt} must be UNBOUND — a capability removed by absence, \
1578                 not guarded by a check: {err}"
1579            );
1580        }
1581    }
1582
1583    /// With the `sys` feature compiled in, `read_file` IS bound — that is the
1584    /// point of the feature. The doctrine does not move: this is the operator's
1585    /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
1586    /// future default-build change is heard, and assert that `input()` still
1587    /// works beside it.
1588    #[cfg(feature = "sys")]
1589    #[test]
1590    fn sys_read_file_is_the_trusted_cli_only_exception() {
1591        let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
1592        assert!(err.to_string().contains("unbound"), "{err}");
1593        assert!(
1594            with_schema("read_file(\"/etc/passwd\")").is_ok(),
1595            "with `sys` on, read_file is the trusted CLI surface"
1596        );
1597        let out = with_schema("input(\"schema\")").expect("run");
1598        assert!(
1599            matches!(out.value, Value::Str(ref s) if &**s == "3"),
1600            "input() still binds beside the sys surface: {:?}",
1601            out.value
1602        );
1603    }
1604
1605    /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
1606    /// — so the success above is the binding's doing.
1607    #[test]
1608    fn a_declared_input_with_no_bytes_supplied_fails() {
1609        let src = decl_line() + "input(\"schema\")";
1610        assert!(run(&src).is_err(), "no bytes were supplied");
1611    }
1612}
1613
1614#[cfg(test)]
1615mod tier2_tests {
1616    use super::*;
1617    use crate::inputs::{Declaration, Inputs};
1618
1619    /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
1620    /// declarations FROM A SCHEMA.**
1621    ///
1622    /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
1623    /// installs a `NoLoader`, so a macro cannot read a schema — which gates
1624    /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
1625    /// Ruby's metaprogramming' claim."
1626    ///
1627    /// Here the schema supplies a *value the generated code depends on*, read at
1628    /// expansion time. Ruby and Elixir can both do this — with the whole
1629    /// filesystem open. blue does it through a name bound to a content hash.
1630    #[test]
1631    fn a_macro_generates_code_from_a_schema() {
1632        let schema = b"7";
1633        let mut inputs = Inputs::new();
1634        inputs
1635            .bind(
1636                &Declaration {
1637                    name: "arity".to_string(),
1638                    hash: Inputs::hash_of(schema),
1639                },
1640                schema.to_vec(),
1641            )
1642            .expect("bind");
1643
1644        // The macro reads the input at EXPANSION time and splices the value it
1645        // found into the code it emits.
1646        let mut src = String::from("definput(\"arity\", \"");
1647        src.push_str(&Inputs::hash_of(schema));
1648        src.push_str("\")\n");
1649        src.push_str(
1650            "defmacro from_schema()\n  quote\n    unquote(to_int(input(\"arity\")))\n  end\nend\n\
1651             from_schema() * 6",
1652        );
1653
1654        let out = run_with_inputs(&src, inputs).expect("run");
1655        assert!(
1656            matches!(out.value, Value::Int(42)),
1657            "the schema's 7 must reach the generated code: got {:?}",
1658            out.value
1659        );
1660    }
1661}