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