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
76impl RunError {
77    /// The exit status a program that stopped here returns:
78    /// [`crate::BREACH_EXIT`] for a capability breach, 1 for anything else.
79    #[must_use]
80    pub fn exit_status(&self) -> u8 {
81        match self {
82            RunError::Eval(fault) if fault.breach => crate::BREACH_EXIT,
83            _ => 1,
84        }
85    }
86}
87
88/// What a run produced, plus what the checker did on the way.
89#[derive(Debug)]
90pub struct Run {
91    pub value: Value,
92    /// Nodes the type walk visited. Zero for a fully untyped program — this
93    /// is what makes "no annotations, no analysis" a *measurement* rather
94    /// than a claim.
95    pub visited: usize,
96    /// Declarations that carried an annotation.
97    pub typed_decls: usize,
98    /// Boundaries where typed code meets untyped code.
99    pub seams: usize,
100}
101
102/// Parse blue source to tatara-lisp forms.
103pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
104    parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
105}
106
107/// [`parse`] with the parser's nesting bound supplied by the caller.
108///
109/// The bound exists so a stack overflow — which `catch_unwind` cannot catch —
110/// arrives as a typed `Err` instead. It is a *limit*, not a dialect: raising
111/// it changes no program's meaning, which is exactly why it is safe to expose
112/// as configuration (`blue-lang-cli`'s `config` module holds the rule).
113pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
114    blue_lang_syntax::parse_program_with_depth(src, max_depth)
115        .map_err(|e| RunError::Parse(e.to_string()))
116}
117
118/// [`parse_with_depth`] keeping **every node's** source span.
119///
120/// The door for anything that will report a position to a human. It exists here,
121/// beside the spanless one, so a caller that wants spans still parses under the
122/// CONFIGURED nesting bound — a separate `blue_lang_syntax` call would be the
123/// second door `parse_with_depth`'s own docs exist to prevent, with
124/// `max_expr_depth` true of some subcommands and not others.
125pub fn parse_tree_with_depth(
126    src: &str,
127    max_depth: usize,
128) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
129    blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
130        .map_err(|e| RunError::Parse(e.to_string()))
131}
132
133/// [`parse`] keeping **every node's** source span.
134///
135/// The spanned twin of [`parse`], at the same default bound — the door for
136/// anything downstream of a parse that will report a position, which since
137/// `use` learned to carry file identity is every path through
138/// [`run_in_surface`].
139pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
140    parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
141}
142
143/// Run blue source with no build inputs.
144pub fn run(src: &str) -> Result<Run, RunError> {
145    run_with_inputs(src, Inputs::new())
146}
147
148/// Run blue source, giving the macro phase access to verified build inputs.
149///
150/// `inputs` is already verified — [`Inputs`] cannot hold bytes that do not match
151/// their declared hash — so nothing here re-checks. The capability a macro gains
152/// is exactly "these hashed bytes", never a path.
153pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
154    run_with_loader(src, inputs, &crate::uses::NoLoader)
155}
156
157/// Run blue source with a loader, so `use("name")` can resolve.
158///
159/// Split from [`run_with_inputs`] rather than folded into it because loading a
160/// package reads a filesystem, and this crate has a `wasm32-unknown-unknown`
161/// consumer with zero host imports. The capability is injected by callers that
162/// have it — `blue_lang_pkg::LoadPath` is the real one — and absent by default,
163/// where a `use` is a typed error naming the package.
164pub fn run_with_loader(
165    src: &str,
166    inputs: Inputs,
167    loader: &dyn crate::uses::Loader,
168) -> Result<Run, RunError> {
169    run_in_surface(Entry::anonymous(src), inputs, loader, None)
170}
171
172/// Run blue source written in a `yakugo` surface.
173///
174/// The pack applies at PARSE time and nowhere else — by the time the checker
175/// sees the program it is canonical, so every stage below is identical whatever
176/// surface the author wrote in. That is what makes a surface a surface: it
177/// changes how a program is spelled and nothing about how it runs.
178///
179/// `entry` carries the source AND the file it was read from, because a type
180/// error has to be reported somewhere: a caller with a path should pass it, and
181/// one without ([`Entry::anonymous`]) gets diagnostics that say so rather than
182/// diagnostics that guess. Imported packages name themselves through the
183/// loader either way.
184///
185/// # Errors
186///
187/// As [`run_with_loader`].
188pub fn run_in_surface(
189    entry: Entry<'_>,
190    inputs: Inputs,
191    loader: &dyn crate::uses::Loader,
192    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
193) -> Result<Run, RunError> {
194    run_framed(entry, inputs, loader, surface, None)
195}
196
197/// [`run_in_surface`] held to `hold` when one is given: a host name it
198/// withholds, written in the entry file, refuses the program before it runs,
199/// and one reached through a bidama or a spawned process is a breach where it
200/// is called; a provided capability's names reach its provider. `None` runs
201/// with every capability.
202///
203/// # Errors
204///
205/// As [`run_with_loader`], and [`RunError::Types`] naming each host name the
206/// entry file writes that `hold` withholds.
207pub fn run_framed(
208    entry: Entry<'_>,
209    inputs: Inputs,
210    loader: &dyn crate::uses::Loader,
211    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
212    hold: Option<&crate::Hold>,
213) -> Result<Run, RunError> {
214    run_framed_accounted(
215        entry,
216        inputs,
217        loader,
218        surface,
219        hold,
220        &crate::Account::default(),
221    )
222}
223
224/// [`run_framed`] giving `account` of itself: the host calls the run and its
225/// processes make, and the identities of what it runs (`crate::Account`).
226///
227/// # Errors
228///
229/// As [`run_framed`].
230pub fn run_framed_accounted(
231    entry: Entry<'_>,
232    inputs: Inputs,
233    loader: &dyn crate::uses::Loader,
234    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
235    hold: Option<&crate::Hold>,
236    account: &crate::Account,
237) -> Result<Run, RunError> {
238    // The interpreter is built BEFORE the check stage, because it is the check
239    // stage's name oracle: every builtin, macro and special form a program may
240    // name is read off the interpreter that will run it, never off a list.
241    // Building it is not a stage; nothing is evaluated until `eval_prepared`.
242    let (mut interp, mut prepared) = prepared_on(entry, inputs, loader, surface)?;
243    hold_to_frame(&mut interp, &mut prepared, hold, account)?;
244    let mut value = crate::timings::stage(crate::timings::Stage::Eval, || {
245        eval_prepared(&mut interp, &prepared, &mut ())
246    })?;
247    if let Some(v) = crate::timings::stage(crate::timings::Stage::Main, || {
248        call_main(&mut interp, &prepared, &mut ())
249    })? {
250        value = v;
251    }
252    Ok(Run {
253        value,
254        visited: prepared.visited,
255        typed_decls: prepared.typed_decls,
256        seams: prepared.seams,
257    })
258}
259
260/// Bind `hold` into `interp` and its processes, with each scoped provider's
261/// packages moved onto it (`Hold::rebind_scoped`); a withheld name the entry
262/// file writes refuses the program before it runs. With an `account` that
263/// identifies, the program's identities are deferred to it and the node is
264/// attached again knowing them; without a hold or one, nothing changes.
265fn hold_to_frame(
266    interp: &mut tatara_lisp_eval::Interpreter<()>,
267    prepared: &mut Prepared,
268    hold: Option<&crate::Hold>,
269    account: &crate::Account,
270) -> Result<(), RunError> {
271    if let Some(identify) = account.identify {
272        account.identities.defer(identify(
273            &prepared.program,
274            std::sync::Arc::clone(&prepared.lowered),
275            main_key(prepared),
276        ));
277    }
278    let Some(hold) = hold else {
279        if account.identify.is_some() {
280            crate::attach_node_accounted(interp, None, account);
281        }
282        return Ok(());
283    };
284    let withheld = hold.rebind_scoped(&prepared.program, &mut prepared.erased);
285    let escapes = entry_escapes(&prepared.program, &withheld, &|cap| {
286        format!("this run does not allow; pass --allow {cap} to grant it")
287    });
288    if !escapes.is_empty() {
289        return Err(RunError::Types(escapes));
290    }
291    hold.bind(interp, "this run's frame", &account.host_io);
292    crate::attach_node_accounted(interp, Some(hold), account);
293    Ok(())
294}
295
296fn entry_escapes(
297    program: &crate::uses::ResolvedProgram,
298    refused: &[(&'static str, blue_lang_waku::Capability)],
299    grant: &dyn Fn(&str) -> String,
300) -> Vec<String> {
301    use crate::uses::ResolvedProgram;
302    let forms: Vec<tatara_lisp::Spanned> = program
303        .forms()
304        .iter()
305        .enumerate()
306        .filter(|(i, _)| program.owner_of(*i) == Some(ResolvedProgram::ENTRY))
307        .map(|(_, f)| f.clone())
308        .collect();
309    let label = |name: &str| {
310        refused
311            .iter()
312            .find(|(n, _)| *n == name)
313            .map(|(_, c)| c.label())
314    };
315    blue_lang_waku::free_names(&forms, &|name| label(name).is_none())
316        .into_iter()
317        .filter_map(|name| {
318            let cap = label(&name)?;
319            let at = program
320                .file(ResolvedProgram::ENTRY)
321                .and_then(|f| f.path.clone())
322                .map_or_else(|| "<entry>".to_string(), |p| p.display().to_string());
323            Some(format!(
324                "{at}: refused: `{name}` reaches the host ({cap}), which {}",
325                grant(cap)
326            ))
327        })
328        .collect()
329}
330
331/// The interpreter a program runs on, with `inputs` bound for its macros.
332fn runtime(inputs: Inputs) -> tatara_lisp_eval::Interpreter<()> {
333    let mut interp =
334        crate::timings::stage(crate::timings::Stage::Runtime, crate::interpreter_hostless);
335    crate::inputs::install_input_primitives(&mut interp, inputs);
336    interp
337}
338
339/// [`runtime`], and the program prepared against that interpreter's names.
340fn prepared_on(
341    entry: Entry<'_>,
342    inputs: Inputs,
343    loader: &dyn crate::uses::Loader,
344    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
345) -> Result<(tatara_lisp_eval::Interpreter<()>, Prepared), RunError> {
346    let interp = runtime(inputs);
347    let builtins = crate::timings::stage(crate::timings::Stage::Names, || builtin_names(&interp));
348    let prepared = prepare(entry, loader, surface, &builtins)?;
349    Ok((interp, prepared))
350}
351
352/// A program past every stage before evaluation, kept as data: the tree
353/// `def_hash` reads (`lowered`), the tree that runs (`erased`), one form per
354/// top-level form of `program` in both, and the runtime key of the entry
355/// file's `main`. What `blue bundle` seals into an appliance, and what
356/// [`run_compiled`] evaluates with no loader and no checker.
357pub struct Compiled {
358    pub program: crate::uses::ResolvedProgram,
359    pub lowered: Vec<tatara_lisp::Spanned>,
360    pub erased: Vec<tatara_lisp::Spanned>,
361    pub main: Option<String>,
362}
363
364/// Parse, resolve, check and erase `entry`, and stop before evaluation. Under
365/// a `hold`, a host name the entry file writes that the hold withholds
366/// refuses the program here, as [`run_framed`] refuses it before it runs.
367///
368/// # Errors
369///
370/// As [`run_framed`], with no runtime error: nothing is evaluated.
371pub fn compile(
372    entry: Entry<'_>,
373    loader: &dyn crate::uses::Loader,
374    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
375    hold: Option<&crate::Hold>,
376) -> Result<Compiled, RunError> {
377    let (_, prepared) = prepared_on(entry, Inputs::new(), loader, surface)?;
378    if let Some(hold) = hold {
379        let escapes = entry_escapes(&prepared.program, &hold.withheld_host_names(), &|cap| {
380            format!("this bundle's policy does not allow; declare allow(%i[{cap}]) in the project's Bluefile and run `blue lock`")
381        });
382        if !escapes.is_empty() {
383            return Err(RunError::Types(escapes));
384        }
385    }
386    let main = main_key(&prepared);
387    let Prepared {
388        program,
389        lowered,
390        erased,
391        ..
392    } = prepared;
393    Ok(Compiled {
394        program,
395        lowered: std::sync::Arc::unwrap_or_clone(lowered),
396        erased,
397        main,
398    })
399}
400
401/// Evaluate compiled forms under `hold`, then call `main` when one is named.
402/// The hold is not optional: a compiled program has no ambient run. `who`
403/// names the hold in a breach's message. Errors carry no position: the
404/// forms carry no source.
405///
406/// # Errors
407///
408/// [`RunError::Eval`] when a form or `main` raises, a breach included
409/// ([`crate::observe::Fault::breach`]).
410pub fn run_compiled(
411    erased: &[tatara_lisp::Spanned],
412    main: Option<&str>,
413    hold: &crate::Hold,
414    who: &'static str,
415) -> Result<Value, RunError> {
416    let mut interp = runtime(Inputs::new());
417    let account = crate::Account::default();
418    hold.bind(&mut interp, who, &account.host_io);
419    crate::attach_node_accounted(&mut interp, Some(hold), &account);
420    let fault = |e: &tatara_lisp_eval::EvalError| {
421        RunError::Eval(Box::new(crate::observe::Fault::unplaced(e, erased)))
422    };
423    let mut value = Value::Nil;
424    for form in erased {
425        value = interp.eval_top_form(form, &mut ()).map_err(|e| fault(&e))?;
426    }
427    if let Some(key) = main {
428        let at = tatara_lisp::Span::synthetic();
429        let call = tatara_lisp::Spanned::new(
430            at,
431            tatara_lisp::SpannedForm::List(vec![tatara_lisp::Spanned::new(
432                at,
433                tatara_lisp::SpannedForm::Atom(tatara_lisp::Atom::Symbol(key.to_string())),
434            )]),
435        );
436        value = interp
437            .eval_top_form(&call, &mut ())
438            .map_err(|e| fault(&e))?;
439    }
440    Ok(value)
441}
442
443/// A run watched by a call observer, and the definitions the observer was
444/// built over.
445pub struct Observed {
446    pub definitions: std::sync::Arc<crate::observe::Definitions>,
447    /// The run itself: its value, or the error it stopped on.
448    pub outcome: Result<Run, RunError>,
449}
450
451/// What a call observer is built from: the program past every stage before
452/// evaluation, its tree before erasure (what `def_hash` reads) and after it
453/// (what runs), and the definitions read off it.
454pub struct Watched<'a> {
455    pub definitions: &'a std::sync::Arc<crate::observe::Definitions>,
456    pub program: &'a crate::uses::ResolvedProgram,
457    pub lowered: &'a [tatara_lisp::Spanned],
458    pub erased: &'a [tatara_lisp::Spanned],
459}
460
461/// [`run_in_surface`] with a call observer installed for the evaluation.
462/// `watch` builds the observer once every stage before evaluation has
463/// passed; the profiler, the tracer, coverage and the call memo are all built
464/// this way (`crate::observe::Recorder`, `blue-lang-mondou`'s
465/// `recall::Recall`).
466///
467/// # Errors
468///
469/// A parse, import or check error, which leaves nothing to run. A runtime
470/// error is the [`Observed::outcome`], so what ran before it is still read.
471pub fn run_observed(
472    entry: Entry<'_>,
473    inputs: Inputs,
474    loader: &dyn crate::uses::Loader,
475    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
476    watch: impl FnOnce(&Watched<'_>) -> std::sync::Arc<dyn crate::observe::CallObserver>,
477) -> Result<Observed, RunError> {
478    run_observed_framed(entry, inputs, loader, surface, None, watch)
479}
480
481/// [`run_observed`] held to `frame` as [`run_framed`] holds a run.
482///
483/// # Errors
484///
485/// As [`run_observed`], and as [`run_framed`] for a host name the entry file
486/// writes that `frame` does not grant.
487pub fn run_observed_framed(
488    entry: Entry<'_>,
489    inputs: Inputs,
490    loader: &dyn crate::uses::Loader,
491    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
492    hold: Option<&crate::Hold>,
493    watch: impl FnOnce(&Watched<'_>) -> std::sync::Arc<dyn crate::observe::CallObserver>,
494) -> Result<Observed, RunError> {
495    run_observed_accounted(
496        entry,
497        inputs,
498        loader,
499        surface,
500        hold,
501        &crate::Account::default(),
502        watch,
503    )
504}
505
506/// [`run_observed_framed`] giving `account` of itself, as
507/// [`run_framed_accounted`] does.
508///
509/// # Errors
510///
511/// As [`run_observed_framed`].
512pub fn run_observed_accounted(
513    entry: Entry<'_>,
514    inputs: Inputs,
515    loader: &dyn crate::uses::Loader,
516    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
517    hold: Option<&crate::Hold>,
518    account: &crate::Account,
519    watch: impl FnOnce(&Watched<'_>) -> std::sync::Arc<dyn crate::observe::CallObserver>,
520) -> Result<Observed, RunError> {
521    let (mut interp, mut prepared) = prepared_on(entry, inputs, loader, surface)?;
522    hold_to_frame(&mut interp, &mut prepared, hold, account)?;
523    let definitions = std::sync::Arc::new(crate::observe::Definitions::of(
524        &prepared.program,
525        &prepared.erased,
526    ));
527    interp.set_call_observer(Some(watch(&Watched {
528        definitions: &definitions,
529        program: &prepared.program,
530        lowered: &prepared.lowered,
531        erased: &prepared.erased,
532    })));
533    let outcome = crate::timings::stage(crate::timings::Stage::Eval, || {
534        eval_prepared(&mut interp, &prepared, &mut ())
535    })
536    .and_then(|value| {
537        Ok(crate::timings::stage(crate::timings::Stage::Main, || {
538            call_main(&mut interp, &prepared, &mut ())
539        })?
540        .unwrap_or(value))
541    })
542    .map(|value| Run {
543        value,
544        visited: prepared.visited,
545        typed_decls: prepared.typed_decls,
546        seams: prepared.seams,
547    });
548    interp.set_call_observer(None);
549    Ok(Observed {
550        definitions,
551        outcome,
552    })
553}
554
555/// A program past every stage before evaluation: parsed, its imports
556/// resolved, type-checked and erased. What [`run_in_surface`] and
557/// [`crate::hosted::load_hosted`] both evaluate, so the stage order this
558/// module exists to own is written once for both.
559pub(crate) struct Prepared {
560    program: crate::uses::ResolvedProgram,
561    lowered: std::sync::Arc<Vec<tatara_lisp::Spanned>>,
562    erased: Vec<tatara_lisp::Spanned>,
563    visited: usize,
564    typed_decls: usize,
565    seams: usize,
566}
567
568impl Prepared {
569    /// The forms the evaluator will run: every file's, resolved and erased.
570    pub(crate) fn erased(&self) -> &[tatara_lisp::Spanned] {
571        &self.erased
572    }
573
574    /// The entry file's namespace.
575    pub(crate) fn entry_namespace(&self) -> Namespace {
576        let first = (0..self.program.forms().len())
577            .find(|i| self.program.owner_of(*i) == Some(crate::uses::ResolvedProgram::ENTRY));
578        match first {
579            Some(i) => namespace_of(&self.program, i),
580            None => match self
581                .program
582                .file(crate::uses::ResolvedProgram::ENTRY)
583                .and_then(|f| f.package.clone())
584            {
585                Some(p) => Namespace::Bidama(p),
586                None => Namespace::File(String::new()),
587            },
588        }
589    }
590}
591
592/// Parse, resolve, check and erase, in that order.
593///
594/// `builtins` is the name table of the interpreter that will evaluate the
595/// result ([`builtin_names`]); the check stage resolves the program's names
596/// against it.
597pub(crate) fn prepare(
598    entry: Entry<'_>,
599    loader: &dyn crate::uses::Loader,
600    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
601    builtins: &NameTable,
602) -> Result<Prepared, RunError> {
603    let mut program = parse_and_resolve(entry, loader, surface)?;
604
605    // `test` blocks are declarations for the harness, not code to run.
606    //
607    // Dropped here rather than in `resolve_uses`, because `blue test` calls
608    // the resolver and then NEEDS the entry file's blocks — so the two
609    // callers want different things and the split has to live at this level.
610    //
611    // Without this, `blue run` on a file containing its own tests fails with
612    // `unbound symbol: deftest`: every package in the bidama distribution
613    // carries tests, so every one of them was unrunnable.
614    //
615    // Through `retain`, which drops each form's owner with it. A plain filter
616    // over the forms alone would slide every later form onto the wrong file.
617    program.retain(|f| !crate::uses::is_test_form(f));
618
619    // CHECK, on the annotated tree — the only tree that has annotations.
620    //
621    // **On the REAL spanned tree, including every imported package's.** This
622    // was the one caller that checked a spanless lift, because `resolve_uses`
623    // flattened the entry file and its imports into one list and `Span` is a
624    // byte range with no file identity — so a real span here would have
625    // reported an imported package's error at that offset in the ENTRY file, a
626    // precise-looking answer pointing at unrelated code.
627    //
628    // The fix is not a wider `Span` (that type is upstream, and its own docs
629    // put file identity on the caller: spans "are meaningful only relative to
630    // the string that produced them, which the caller is responsible for
631    // holding onto"). blue holds onto it BESIDE the span, per top-level form —
632    // see `uses::ResolvedProgram`.
633    let (outcome, names) = crate::timings::stage(crate::timings::Stage::Check, || {
634        check_stage(&program, builtins, false)
635    });
636    if !outcome.ok() {
637        return Err(RunError::Types(
638            outcome
639                .errors()
640                // `file:line:col: error[CODE]: message`, resolved against the
641                // file the form actually came from, then its help line.
642                .map(|d| render(&program, d))
643                .collect(),
644        ));
645    }
646
647    // ERASE, so the interpreter never sees a type — **on the spanned tree,
648    // and back out as one.**
649    //
650    // This used to be `erase_types(&program.sexps())` followed by a
651    // `lower_to_spanned` that stamped `Span::synthetic()` over every node, and
652    // the comment here said carrying real positions through was "a larger
653    // piece and is NOT built". It was not larger: erasure only ever DELETES
654    // nodes — the single node it invents is the `define` replacing
655    // `define-typed` — so there was never anything for a synthetic span to
656    // stand in for. See `crate::erase`.
657    //
658    // No lowering step follows. The tree the evaluator receives IS the tree the
659    // parser built, minus annotations, with the author's byte offsets intact.
660    let (lowered, erased) = crate::timings::stage(crate::timings::Stage::Erase, || {
661        let lowered = lower(&program, &names);
662        let erased = erase_types(&lowered);
663        (lowered, erased)
664    });
665
666    Ok(Prepared {
667        program,
668        lowered: std::sync::Arc::new(lowered),
669        erased,
670        visited: outcome.stats.visited,
671        typed_decls: outcome.stats.typed_decls,
672        seams: outcome.seams.len(),
673    })
674}
675
676/// Parse the entry (in `surface`, if one is given) and splice in its imports.
677pub(crate) fn parse_and_resolve(
678    entry: Entry<'_>,
679    loader: &dyn crate::uses::Loader,
680    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
681) -> Result<crate::uses::ResolvedProgram, RunError> {
682    let forms = crate::timings::stage(crate::timings::Stage::Parse, || {
683        let mut forms = match surface {
684            Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
685                .map_err(|e| RunError::Parse(e.to_string()))?,
686            None => loader
687                .parse(entry.text)
688                .map_err(|e| RunError::Parse(e.to_string()))?,
689        };
690        // Every `@doc` example is a `test` block of the entry file: checked with
691        // the file's names, run by `blue test`, dropped by `run` with the others.
692        if surface.is_none() {
693            forms.extend(
694                blue_lang_syntax::doc::doctests(entry.text)
695                    .map_err(|e| RunError::Parse(e.to_string()))?,
696            );
697        }
698        Ok::<_, RunError>(forms)
699    })?;
700
701    // RESOLVE imports first, so everything below sees ONE program.
702    //
703    // Before the check on purpose: imported code is type-checked at the point
704    // its consumer imports it, rather than at whatever later moment its code
705    // first runs. A package that does not typecheck should break its importer's
706    // build, not their production run.
707    //
708    // One program, but not one FILE: the result records which file each
709    // top-level form came from, which is what lets a diagnostic below name a
710    // place instead of only a problem.
711    crate::timings::stage(crate::timings::Stage::Resolve, || {
712        crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)
713    })
714}
715
716/// **The check stage**: every rule in `blue_lang_check::RULES`, over a
717/// resolved program, with waivers applied.
718///
719/// One function, called by every door — `run`, the hosted loader, `blue test`
720/// and `blue check` — so a rule added to the registry is enforced by all of
721/// them at once. That is what makes an architecture rule a pipeline error
722/// rather than an optional report: there is no door that skips it.
723///
724/// Warnings (unused bindings, unused waivers) are reported for the ENTRY
725/// file only; an imported package's warnings belong to its author. Errors
726/// are reported for every file, because an imported package with one is
727/// broken for its importer.
728pub(crate) fn check_stage(
729    program: &crate::uses::ResolvedProgram,
730    builtins: &NameTable,
731    with_tests: bool,
732) -> (blue_lang_check::Outcome, NameTable) {
733    use crate::uses::ResolvedProgram;
734    let forms = program.forms();
735    let is_entry = |i: usize| program.owner_of(i) == Some(ResolvedProgram::ENTRY);
736
737    // Rules B0003–B0005: the typing ladder.
738    let mut outcome = blue_lang_check::check_program_in(forms, &|a, b| {
739        program.owner_of(a) == program.owner_of(b)
740    });
741    outcome
742        .diagnostics
743        .extend(data_law_diagnostics(program, &is_entry));
744
745    // Rules B0001–B0002: names. The program's own scopes come first (the
746    // entry file, then each bidama in import order), then the interpreter's.
747    let table = program_names(program, builtins);
748    let whole = |i: usize| with_tests && is_entry(i);
749    let (names, resolved) = blue_lang_check::names::check_names_whole(
750        forms,
751        &table,
752        &|i| namespace_of(program, i),
753        &is_entry,
754        &whole,
755    );
756    let stored = program
757        .file(ResolvedProgram::ENTRY)
758        .is_some_and(|f| f.path.is_some());
759    outcome.diagnostics.extend(names);
760    outcome.stats.names_resolved = resolved;
761    if stored {
762        let script = program
763            .file(ResolvedProgram::ENTRY)
764            .is_some_and(|f| f.package.is_none());
765        let found = blue_lang_check::form_rules::check(forms, &is_entry, script);
766        let silenced: Vec<tatara_lisp::Span> = found
767            .iter()
768            .filter(|d| d.code == blue_lang_check::Code::B0038)
769            .map(|d| d.span)
770            .collect();
771        outcome
772            .diagnostics
773            .retain(|d| !(d.code == blue_lang_check::Code::B0002 && silenced.contains(&d.span)));
774        outcome.diagnostics.extend(found);
775    }
776    outcome
777        .diagnostics
778        .extend(blue_lang_check::contracts::check(
779            forms,
780            &table,
781            &|i| namespace_of(program, i),
782            &is_entry,
783            &|i| {
784                program
785                    .owner_of(i)
786                    .and_then(|f| program.file(f))
787                    .map(|f| f.text.as_str())
788            },
789            crate::docs::signatures(),
790        ));
791
792    outcome.diagnostics.extend(doc_diagnostics(program));
793
794    // Waivers, per file: B0007 for a malformed one, B0008 for one that
795    // suppresses nothing.
796    let mut waivers = Vec::new();
797    for file in program.files() {
798        let spans: Vec<(usize, tatara_lisp::Span)> = forms
799            .iter()
800            .enumerate()
801            .filter(|(i, _)| program.owner_of(*i) == Some(file.id))
802            .map(|(i, f)| (i, f.span))
803            .collect();
804        let (found, malformed) = blue_lang_check::waiver::collect(&file.text, &spans);
805        waivers.extend(found);
806        outcome.diagnostics.extend(malformed);
807    }
808    outcome.waived = blue_lang_check::waiver::apply(&mut outcome.diagnostics, waivers, &is_entry);
809    if !stored {
810        outcome.diagnostics.retain(|d| {
811            !matches!(
812                d.code,
813                blue_lang_check::Code::B0023
814                    | blue_lang_check::Code::B0036
815                    | blue_lang_check::Code::B0037
816                    | blue_lang_check::Code::B0067
817                    | blue_lang_check::Code::B0061
818                    | blue_lang_check::Code::B0062
819                    | blue_lang_check::Code::B0063
820            )
821        });
822    }
823    let judge = blue_lang_check::evolution::FormJudge::from_env().unwrap_or_default();
824    outcome
825        .diagnostics
826        .retain(|d| judge.reports(d.code, is_entry(d.top_level)));
827    // A rule whose census has not reached zero is computed, and counted, but
828    // not enforced: its findings move to `census` (see `RULES`' `ratchet`).
829    let (enforced, census): (Vec<_>, Vec<_>) = std::mem::take(&mut outcome.diagnostics)
830        .into_iter()
831        .partition(|d| d.code.rule().active());
832    outcome.diagnostics = enforced;
833    outcome.census = census;
834    outcome
835        .diagnostics
836        .sort_by_key(|d| (d.top_level, d.span.start, d.code));
837    (outcome, table)
838}
839
840/// The documentation rules (B0056–B0060) over the entry file, when it is a
841/// bidama's: a bidama is judged at its own check, as its form is; an importer
842/// is not answerable for a dependency's documentation.
843fn doc_diagnostics(program: &crate::uses::ResolvedProgram) -> Vec<blue_lang_check::Diagnostic> {
844    use crate::uses::ResolvedProgram;
845    let Some(file) = program.file(ResolvedProgram::ENTRY) else {
846        return Vec::new();
847    };
848    let Some(package) = file.package.as_deref() else {
849        return Vec::new();
850    };
851    let forms: Vec<(usize, &tatara_lisp::Spanned)> = program
852        .forms()
853        .iter()
854        .enumerate()
855        .filter(|(i, f)| {
856            program.owner_of(*i) == Some(ResolvedProgram::ENTRY)
857                && !blue_lang_syntax::doc::is_doctest(f)
858        })
859        .collect();
860    let graph = file.path.as_deref().map(crate::doc_sources::graph_meanings);
861    let meaning = |name: &str| {
862        graph
863            .as_ref()
864            .and_then(|g| g.get(&(package.to_string(), name.to_string())).cloned())
865    };
866    let names_line = |name: &str| crate::docs::doc_of(name).map(|d| d.doc.to_string());
867    blue_lang_check::doc_rules::check_file(
868        &file.text,
869        &forms,
870        package,
871        &blue_lang_check::doc_rules::Sources {
872            meaning: &meaning,
873            names_line: &names_line,
874        },
875    )
876}
877
878fn data_law_diagnostics(
879    program: &crate::uses::ResolvedProgram,
880    is_entry: &dyn Fn(usize) -> bool,
881) -> Vec<blue_lang_check::Diagnostic> {
882    use blue_lang_check::data_laws;
883    let forms = program.forms();
884    let fragments = data_laws::Fragments::collect(forms);
885    let frontend = program.frontend();
886    let mut out = Vec::new();
887    let schema = match frontend.and_then(|f| f.table.as_ref()) {
888        Some((label, text)) => match data_laws::Schema::read(text) {
889            Ok(s) => Some(s),
890            Err(why) => {
891                if let Some((i, first)) = forms.iter().enumerate().find(|(i, _)| is_entry(*i)) {
892                    out.push(
893                        blue_lang_check::Diagnostic::new(
894                            blue_lang_check::Code::B0050,
895                            format!("the generated schema table {label} does not read: {why}"),
896                            first.span,
897                        )
898                        .at_top_level(i)
899                        .with_help("regenerate it: `nix run .#regen`"),
900                    );
901                }
902                None
903            }
904        },
905        None => None,
906    };
907    if fragments.is_empty() && schema.is_none() && !frontend.is_some_and(|f| f.view) {
908        return out;
909    }
910    out.extend(data_laws::check(
911        forms,
912        &data_laws::Context {
913            fragments: &fragments,
914            schema: schema.as_ref(),
915            is_entry,
916            view: frontend.is_some_and(|f| f.view),
917        },
918    ));
919    out
920}
921
922/// The whole name table the check stage resolves against: the program's
923/// own scopes (each file and bidama, by namespace) followed by `builtins`.
924///
925/// **A top-level call to a builtin macro can define names**: `defflow(slug,
926/// trim, downcase)` expands to `(define slug …)`, and nothing in the source
927/// spells `define`. Such a call is expanded here, on a hostless fork, and what
928/// its expansion defines is bound in the calling file's namespace. Only
929/// BUILTIN macros are expanded: their bodies are the runtime's own
930/// templates. A program's own `defmacro` is not run at check time — checking a
931/// file (and an editor checking on every keystroke) must not execute the code
932/// it checks — so names a program macro generates are not seen; none of the
933/// corpus's macros generates one (measured 2026-09-29).
934#[must_use]
935pub fn program_names(program: &crate::uses::ResolvedProgram, builtins: &NameTable) -> NameTable {
936    use blue_lang_check::Binding;
937    let mut table = NameTable::new();
938    table.add_program(program.forms(), |i| namespace_of(program, i));
939    let builtin_macro = |head: &str| {
940        builtins
941            .scopes()
942            .iter()
943            .any(|s| s.namespace == Namespace::Macro && s.get(head).is_some())
944    };
945    let mut expander: Option<tatara_lisp_eval::Interpreter<()>> = None;
946    for (i, form) in program.forms().iter().enumerate() {
947        let Some(head) = form
948            .as_list()
949            .and_then(|items| items.first())
950            .and_then(tatara_lisp::Spanned::as_symbol)
951        else {
952            continue;
953        };
954        if !builtin_macro(head) {
955            continue;
956        }
957        let interp = expander.get_or_insert_with(crate::interpreter_hostless);
958        // A failed expansion defines nothing here; the evaluator will report
959        // it, with its position, when the form runs.
960        let Ok(expanded) = interp.fully_expand(form, &mut ()) else {
961            continue;
962        };
963        let nullary = blue_lang_syntax::scope::nullary_definition(&expanded);
964        for (name, span, kind) in blue_lang_check::names::definitions_of(&expanded) {
965            table.define(
966                namespace_of(program, i),
967                Binding {
968                    nullary: nullary == Some(name.as_str()),
969                    name,
970                    kind,
971                    span: Some(span),
972                    top_level: Some(i),
973                },
974            );
975        }
976    }
977    for scope in builtins.scopes() {
978        table.push(scope.clone());
979    }
980    table.spell_bare_like(builtins);
981    let form_file: Vec<usize> = (0..program.forms().len())
982        .map(|i| program.owner_of(i).map_or(usize::MAX, |f| f.index()))
983        .collect();
984    let mut imports: std::collections::BTreeMap<usize, blue_lang_check::names::FileImports> =
985        std::collections::BTreeMap::new();
986    for (file, import) in program.imports() {
987        let entry = imports.entry(file.index()).or_default();
988        entry.add(&import.package, import.names.iter().map(|(n, _)| n.clone()));
989        let at = import.span.start;
990        entry.use_at = Some(entry.use_at.map_or(at, |a| a.min(at)));
991    }
992    // A FACADE — a bidama that defines nothing and `use`s others (zenbu) —
993    // exists so a consumer can depend on "everything": `use("zenbu")` makes
994    // every member's QUALIFIER reachable (`toukei::median`), never a bare
995    // name. Followed to a fixpoint, so a facade of facades works too.
996    let facade_uses =
997        |pkg: &str,
998         imports: &std::collections::BTreeMap<usize, blue_lang_check::names::FileImports>| {
999            let defines = table.bidama(pkg).is_some_and(|s| !s.is_empty());
1000            if defines {
1001                return std::collections::BTreeSet::new();
1002            }
1003            program
1004                .files()
1005                .iter()
1006                .filter(|f| f.package.as_deref() == Some(pkg))
1007                .filter_map(|f| imports.get(&f.id.index()))
1008                .flat_map(|i| i.uses.iter().cloned())
1009                .collect::<std::collections::BTreeSet<String>>()
1010        };
1011    loop {
1012        let mut grew = false;
1013        let files: Vec<usize> = imports.keys().copied().collect();
1014        for f in files {
1015            let used: Vec<String> = imports[&f].uses.iter().cloned().collect();
1016            for p in used {
1017                for member in facade_uses(&p, &imports) {
1018                    if let Some(i) = imports.get_mut(&f) {
1019                        if i.uses.insert(member.clone()) {
1020                            i.via.insert(member, p.clone());
1021                            grew = true;
1022                        }
1023                    }
1024                }
1025            }
1026        }
1027        if !grew {
1028            break;
1029        }
1030    }
1031    let mut pkg_uses: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
1032        std::collections::BTreeMap::new();
1033    for (file, import) in program.imports() {
1034        if let Some(p) = program.file(*file).and_then(|f| f.package.clone()) {
1035            pkg_uses
1036                .entry(p)
1037                .or_default()
1038                .insert(import.package.clone());
1039        }
1040    }
1041    table.attach_files(form_file, imports);
1042    table.attach_package_uses(pkg_uses);
1043    table.attach_needs(program.needs().clone());
1044    // Where each file lives and which distribution each bidama ships in: a
1045    // bridge reference (B0021) is an error inside the bidama's own
1046    // distribution and a warning outside it.
1047    let canonical = |p: &std::path::Path| {
1048        std::fs::canonicalize(p)
1049            .unwrap_or_else(|_| p.to_path_buf())
1050            .display()
1051            .to_string()
1052    };
1053    let mut file_paths = std::collections::BTreeMap::new();
1054    let mut pkg_roots = std::collections::BTreeMap::new();
1055    for f in program.files() {
1056        if let Some(path) = &f.path {
1057            file_paths.insert(f.id.index(), canonical(path));
1058            if let (Some(pkg), Some(root)) =
1059                (&f.package, path.parent().and_then(std::path::Path::parent))
1060            {
1061                pkg_roots
1062                    .entry(pkg.clone())
1063                    .or_insert_with(|| canonical(root));
1064            }
1065        }
1066    }
1067    table.attach_paths(file_paths, pkg_roots);
1068    let versions = program.versions().clone();
1069    table.apply_legacy(program.forms(), &|i| namespace_of(program, i), &|p| {
1070        versions.get(p).cloned()
1071    });
1072    table
1073}
1074
1075/// The forms the evaluator runs, from a checked program: every `use`
1076/// declaration inert (`nil`, so each index still names its top-level form)
1077/// and every name at the key it runs under — the RESOLVED tree under
1078/// per-bidama namespaces (`blue_lang_check::names::Resolved::resolved_tree`).
1079///
1080/// A definition runs as `retsu/first` (a script's as `%root/f`), a
1081/// reference to it as the same key, and a builtin as its bare name. Keys
1082/// cannot collide across namespaces, so a bidama's `first` no longer
1083/// replaces the builtin for anyone who did not import it, and an importer's
1084/// `helper` no longer replaces a bidama's own: the runtime binds exactly what
1085/// the check stage resolved.
1086#[must_use]
1087pub fn lower(
1088    program: &crate::uses::ResolvedProgram,
1089    names: &NameTable,
1090) -> Vec<tatara_lisp::Spanned> {
1091    lower_with(program, names, true)
1092}
1093
1094#[must_use]
1095pub fn lower_with(
1096    program: &crate::uses::ResolvedProgram,
1097    names: &NameTable,
1098    simplify_chains: bool,
1099) -> Vec<tatara_lisp::Spanned> {
1100    let mut resolved = blue_lang_check::names::resolve_program(program.forms(), names, &|i| {
1101        namespace_of(program, i)
1102    });
1103    let simplified = if simplify_chains {
1104        chain_simplified(program.forms(), &resolved)
1105    } else {
1106        None
1107    };
1108    let forms: &[tatara_lisp::Spanned] = simplified.as_deref().unwrap_or(program.forms());
1109    if simplified.is_some() {
1110        resolved =
1111            blue_lang_check::names::resolve_program(forms, names, &|i| namespace_of(program, i));
1112    }
1113    resolved
1114        .resolved_tree(forms, blue_lang_check::names::Rule::Namespaced)
1115        .into_iter()
1116        .zip(forms)
1117        .enumerate()
1118        .map(|(i, (resolved, written))| {
1119            if crate::uses::use_target(written).is_some()
1120                || blue_lang_syntax::scope::legacy_target(written).is_some()
1121            {
1122                tatara_lisp::Spanned::new(written.span, tatara_lisp::SpannedForm::Nil)
1123            } else {
1124                let file = program.owner_of(i).and_then(|id| program.file(id));
1125                match file {
1126                    Some(f) => locate_raises(&resolved, &raise_site(f)),
1127                    None => resolved,
1128                }
1129            }
1130        })
1131        .collect()
1132}
1133
1134fn chain_simplified(
1135    forms: &[tatara_lisp::Spanned],
1136    resolved: &blue_lang_check::names::Resolved,
1137) -> Option<Vec<tatara_lisp::Spanned>> {
1138    let respell: std::collections::HashMap<usize, blue_lang_check::respell::Respelling> = resolved
1139        .respellings
1140        .iter()
1141        .filter(|(_, r)| matches!(r.respell, blue_lang_check::respell::Respell::Chain(_)))
1142        .cloned()
1143        .collect();
1144    if respell.is_empty() {
1145        return None;
1146    }
1147    let bare = std::collections::HashSet::new();
1148    let canon = Canon {
1149        bare: &bare,
1150        respell: &respell,
1151    };
1152    Some(forms.iter().map(|f| canon.form(f)).collect())
1153}
1154
1155fn raise_site(file: &crate::uses::SourceFile) -> impl Fn(tatara_lisp::Span) -> String + '_ {
1156    move |span| {
1157        let (line, _) =
1158            blue_lang_syntax::Span::line_col(&file.text, span.start.min(file.text.len()));
1159        let shown = file
1160            .path
1161            .as_ref()
1162            .map_or_else(|| "<source>".to_string(), |p| p.display().to_string());
1163        format!("{shown}:{line}")
1164    }
1165}
1166
1167fn locate_raises(
1168    form: &tatara_lisp::Spanned,
1169    site: &dyn Fn(tatara_lisp::Span) -> String,
1170) -> tatara_lisp::Spanned {
1171    use tatara_lisp::{Atom, Spanned, SpannedForm};
1172    let inner = match &form.form {
1173        SpannedForm::List(items) => {
1174            let mut out: Vec<Spanned> = items.iter().map(|i| locate_raises(i, site)).collect();
1175            let raising = items.first().and_then(Spanned::as_symbol) == Some("raise")
1176                && (3..=4).contains(&items.len());
1177            if raising {
1178                out[0] = Spanned::new(
1179                    items[0].span,
1180                    SpannedForm::Atom(Atom::Symbol(crate::errors::RAISE_AT.to_string())),
1181                );
1182                out.insert(
1183                    1,
1184                    Spanned::new(items[0].span, SpannedForm::Atom(Atom::Str(site(form.span)))),
1185                );
1186            }
1187            SpannedForm::List(out)
1188        }
1189        SpannedForm::Quote(_) | SpannedForm::Quasiquote(_) => return form.clone(),
1190        other => other.clone(),
1191    };
1192    Spanned::new(form.span, inner)
1193}
1194
1195/// The namespace top-level form `i` of `program` defines into.
1196#[must_use]
1197pub fn namespace_of(program: &crate::uses::ResolvedProgram, i: usize) -> Namespace {
1198    let file = program.owner_of(i).and_then(|id| program.file(id));
1199    match file {
1200        Some(f) => match &f.package {
1201            Some(p) => Namespace::Bidama(p.clone()),
1202            None => Namespace::File(
1203                f.path
1204                    .as_ref()
1205                    .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string()),
1206            ),
1207        },
1208        None => Namespace::File("<unknown file>".to_string()),
1209    }
1210}
1211
1212/// Names only the test harness binds.
1213///
1214/// In scope for EVERY check, not only for test blocks. A bidama may carry a
1215/// test helper — a plain `def` that asserts, called only from its `test`
1216/// blocks (`heni`'s `hn_end_to_end` is one) — and every program importing that
1217/// bidama would otherwise be refused for a function it never calls. The
1218/// refusal would be scoped to the importer's whole program, not to the fault.
1219/// The cost, stated: an `assert` outside any test that `blue run` actually
1220/// reaches is caught at runtime (`blue-assert` is unbound there), not here.
1221///
1222/// `blue-lang-test` registers these on its own interpreter, and this crate
1223/// cannot depend on it (it depends on this one), so the list lives beside the
1224/// only name in it: the lowered `assert`, which `blue-lang-syntax` owns.
1225pub const HARNESS_NAMES: &[&str] = &[
1226    blue_lang_syntax::LOWERED_ASSERT,
1227    blue_lang_syntax::LOWERED_ASSERT_PANICS,
1228    blue_lang_syntax::LOWERED_TEST_META,
1229];
1230
1231/// The check stage's builtin scopes, read off the interpreter that will run
1232/// the program: the test harness's names ([`HARNESS_NAMES`]), then its special
1233/// forms, its macros, and every value it binds — the last three in the order
1234/// the evaluator consults for a head.
1235///
1236/// Read from a live interpreter rather than a list, for the reason
1237/// `Interpreter::resolve_head` gives: a list is a second copy of the truth and
1238/// goes stale on the next installer that lands. An embedder's own
1239/// primitives are in scope here because the embedder installs them first.
1240#[must_use]
1241pub fn builtin_names<H: 'static>(interp: &tatara_lisp_eval::Interpreter<H>) -> NameTable {
1242    builtin_names_with(interp, &[])
1243}
1244
1245#[must_use]
1246pub fn builtin_names_with<H: 'static>(
1247    interp: &tatara_lisp_eval::Interpreter<H>,
1248    host: &[crate::host_surface::HostName],
1249) -> NameTable {
1250    use blue_lang_check::{Binding, Scope, ScopeKind};
1251    use tatara_lisp_eval::HeadBinding;
1252    let mut special = Scope::new(Namespace::SpecialForm);
1253    let mut macros = Scope::new(Namespace::Macro);
1254    let mut values = Scope::new(Namespace::Builtin);
1255    // `reserved_head_names` reads the ROOT frame only, so a name installed on
1256    // a fork (the input primitives, on every `run`) is invisible to it; those
1257    // are asked about by name instead, which `resolve_head` answers through
1258    // every frame.
1259    let mut names: Vec<std::sync::Arc<str>> = interp.reserved_head_names().into_iter().collect();
1260    names.extend(
1261        crate::inputs::INPUT_PRIMITIVES
1262            .iter()
1263            .filter(|n| interp.resolve_head(n).is_some())
1264            .map(|n| std::sync::Arc::from(*n)),
1265    );
1266    for name in names {
1267        let (scope, kind) = match interp.resolve_head(&name) {
1268            Some(HeadBinding::SpecialForm) => (&mut special, ScopeKind::SpecialForm),
1269            Some(HeadBinding::Macro) => (&mut macros, ScopeKind::Macro),
1270            _ => (&mut values, ScopeKind::Value),
1271        };
1272        let nullary = kind == ScopeKind::Value
1273            && interp.lookup_global(&name).is_some_and(|v| {
1274                nullary_value(&v)
1275                    || (crate::docs::doc_of(&name).is_some_and(|d| d.signature == &*name)
1276                        && matches!(
1277                            v,
1278                            tatara_lisp_eval::Value::NativeFn(_)
1279                                | tatara_lisp_eval::Value::Closure(_)
1280                        ))
1281            });
1282        scope.bind(Binding {
1283            name: name.to_string(),
1284            kind,
1285            span: None,
1286            top_level: None,
1287            nullary,
1288        });
1289    }
1290    for h in host {
1291        values.bind(Binding {
1292            name: h.name.clone(),
1293            kind: ScopeKind::Value,
1294            span: None,
1295            top_level: None,
1296            nullary: h.nullary(),
1297        });
1298    }
1299    let mut table = NameTable::new();
1300    let mut harness = Scope::new(Namespace::Harness);
1301    for n in HARNESS_NAMES {
1302        harness.bind(Binding {
1303            name: (*n).to_string(),
1304            kind: ScopeKind::Value,
1305            span: None,
1306            top_level: None,
1307            nullary: false,
1308        });
1309    }
1310    table.push(harness);
1311    table.push(special);
1312    table.push(macros);
1313    table.push(values);
1314    table.spell_bare_only(
1315        crate::docs::NAMES
1316            .iter()
1317            .filter(|d| d.signature == d.name)
1318            .map(|d| d.name.to_string())
1319            .chain(host.iter().filter(|h| h.nullary()).map(|h| h.name.clone()))
1320            .collect(),
1321    );
1322    table
1323}
1324
1325#[must_use]
1326pub fn nullary_value(value: &tatara_lisp_eval::Value) -> bool {
1327    match value {
1328        tatara_lisp_eval::Value::NativeFn(f) => {
1329            matches!(f.arity, tatara_lisp_eval::ffi::Arity::Exact(0))
1330        }
1331        tatara_lisp_eval::Value::Closure(c) => c.params.is_empty() && c.rest.is_none(),
1332        _ => false,
1333    }
1334}
1335
1336/// A diagnostic as a person reads it: `file:line:col: error[B0001]: message`,
1337/// then an indented `help:` line when it has one, and a `fix:` line naming
1338/// the one command that applies blue's fixer for it.
1339#[must_use]
1340pub fn render(program: &crate::uses::ResolvedProgram, d: &blue_lang_check::Diagnostic) -> String {
1341    let head = d.to_string();
1342    let mut out = program.locate(d.top_level, d.span, &head).to_string();
1343    if let Some(help) = &d.help {
1344        out.push_str("\n  help: ");
1345        out.push_str(help);
1346    }
1347    let file = program
1348        .owner_of(d.top_level)
1349        .and_then(|id| program.file(id))
1350        .and_then(|f| f.path.as_ref());
1351    if let Some(cmd) =
1352        file.and_then(|p| blue_lang_check::evolution::fix_command(d, &p.display().to_string()))
1353    {
1354        out.push_str("\n  fix: ");
1355        out.push_str(&cmd);
1356    }
1357    out
1358}
1359
1360/// What [`check_entry`] checks.
1361#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1362pub enum Checking {
1363    /// The program `blue run` would evaluate: test blocks dropped.
1364    Program,
1365    /// The program plus the entry file's test blocks, as `blue test` and
1366    /// `blue check` see it.
1367    WithTests,
1368}
1369
1370/// A program through the check stage, with everything the stage found.
1371pub struct Checked {
1372    pub program: crate::uses::ResolvedProgram,
1373    pub outcome: blue_lang_check::Outcome,
1374    /// The name table the stage resolved against: every program scope, then
1375    /// the builtins. What an editor completes from.
1376    pub names: NameTable,
1377}
1378
1379impl Checked {
1380    /// The forms an evaluator runs: [`lower`]ed, then erased — the tree
1381    /// `run` evaluates, for a door that brings its own evaluator (the VM).
1382    #[must_use]
1383    pub fn erased(&self) -> Vec<tatara_lisp::Spanned> {
1384        erase_types(&lower(&self.program, &self.names))
1385    }
1386
1387    /// What `blue test` hands the harness: the program [`lower`]ed, spans
1388    /// projected away.
1389    #[must_use]
1390    pub fn evaluable(&self) -> Vec<tatara_lisp::Sexp> {
1391        lower(&self.program, &self.names)
1392            .iter()
1393            .map(tatara_lisp::Spanned::to_sexp)
1394            .collect()
1395    }
1396
1397    /// Every non-local reference, resolved under both rules — today's flat
1398    /// one and per-bidama namespaces — and the resolved trees built from them
1399    /// (`blue ast --resolved`).
1400    #[must_use]
1401    pub fn resolve(&self) -> blue_lang_check::names::Resolved {
1402        blue_lang_check::names::resolve_program(self.program.forms(), &self.names, &|i| {
1403            namespace_of(&self.program, i)
1404        })
1405    }
1406}
1407
1408impl Checked {
1409    #[must_use]
1410    pub fn call_explicit_forms(&self) -> Vec<tatara_lisp::Spanned> {
1411        self.resolve().call_explicit_tree(self.program.forms())
1412    }
1413
1414    #[must_use]
1415    pub fn canonical_entry(&self, src: &str) -> Option<Vec<tatara_lisp::Spanned>> {
1416        use crate::uses::ResolvedProgram;
1417        let program = &self.program;
1418        let entry: Vec<&tatara_lisp::Spanned> = program
1419            .forms()
1420            .iter()
1421            .enumerate()
1422            .filter(|(i, _)| program.owner_of(*i) == Some(ResolvedProgram::ENTRY))
1423            .map(|(_, f)| f)
1424            .filter(|f| !blue_lang_syntax::doc::is_doctest(f))
1425            .collect();
1426        let written = blue_lang_syntax::parse_program_tree(src).ok()?;
1427        let same = written.len() == entry.len()
1428            && written
1429                .iter()
1430                .zip(&entry)
1431                .all(|(w, e)| w.to_sexp() == e.to_sexp() && w.span == e.span);
1432        if !same {
1433            return None;
1434        }
1435        let resolved = self.resolve();
1436        let bare: std::collections::HashSet<usize> = resolved
1437            .parenthesized_nullary_calls()
1438            .into_iter()
1439            .filter(|r| program.owner_of(r.top_level) == Some(ResolvedProgram::ENTRY))
1440            .map(blue_lang_check::names::Reference::node_address)
1441            .collect();
1442        let waived: Vec<tatara_lisp::Span> = self
1443            .outcome
1444            .waived
1445            .iter()
1446            .map(|w| w.diagnostic.span)
1447            .collect();
1448        let respell: std::collections::HashMap<usize, blue_lang_check::respell::Respelling> =
1449            resolved
1450                .respellings
1451                .iter()
1452                .filter(|(_, r)| !waived.contains(&r.span))
1453                .cloned()
1454                .collect();
1455        let canon = Canon {
1456            bare: &bare,
1457            respell: &respell,
1458        };
1459        let forms: Vec<tatara_lisp::Spanned> = entry.into_iter().map(|f| canon.form(f)).collect();
1460        let script = program
1461            .file(ResolvedProgram::ENTRY)
1462            .is_some_and(|f| f.package.is_none());
1463        let forms = if script {
1464            blue_lang_check::form_rules::without_prefix(&forms).unwrap_or(forms)
1465        } else {
1466            forms
1467        };
1468        let forms = blue_lang_check::form_rules::without_main_guard(&forms).unwrap_or(forms);
1469        Some(blue_lang_check::form_rules::eta_reduced(&forms).unwrap_or(forms))
1470    }
1471}
1472
1473struct Canon<'a> {
1474    bare: &'a std::collections::HashSet<usize>,
1475    respell: &'a std::collections::HashMap<usize, blue_lang_check::respell::Respelling>,
1476}
1477
1478impl Canon<'_> {
1479    fn form(&self, form: &tatara_lisp::Spanned) -> tatara_lisp::Spanned {
1480        use tatara_lisp::{Spanned, SpannedForm};
1481        let inner = match &form.form {
1482            SpannedForm::List(items) => match items.as_slice() {
1483                [head] if self.bare.contains(&(std::ptr::from_ref(head) as usize)) => {
1484                    return Spanned::new(form.span, head.form.clone());
1485                }
1486                [head, ..]
1487                    if self
1488                        .respell
1489                        .contains_key(&(std::ptr::from_ref(head) as usize)) =>
1490                {
1491                    let r = &self.respell[&(std::ptr::from_ref(head) as usize)];
1492                    let walked: Vec<Spanned> = items.iter().map(|i| self.form(i)).collect();
1493                    return blue_lang_check::respell::apply(form, &walked, r);
1494                }
1495                _ => SpannedForm::List(items.iter().map(|i| self.form(i)).collect()),
1496            },
1497            SpannedForm::Quote(i) => SpannedForm::Quote(Box::new(self.form(i))),
1498            SpannedForm::Quasiquote(i) => SpannedForm::Quasiquote(Box::new(self.form(i))),
1499            SpannedForm::Unquote(i) => SpannedForm::Unquote(Box::new(self.form(i))),
1500            SpannedForm::UnquoteSplice(i) => SpannedForm::UnquoteSplice(Box::new(self.form(i))),
1501            other => other.clone(),
1502        };
1503        Spanned::new(form.span, inner)
1504    }
1505}
1506
1507/// Parse, resolve and CHECK, without running: the door for `blue check`,
1508/// `blue test` and the language server.
1509///
1510/// Returns every diagnostic, errors and warnings alike, rather than failing on
1511/// the first error: a reader fixing a file wants the whole list, and an agent
1512/// repairing one wants it in one pass. Whether errors stop anything is the
1513/// caller's decision — `blue test` refuses to run tests over a program with
1514/// one, exactly as `run` refuses to evaluate it.
1515///
1516/// The name oracle is the interpreter `blue run` would build: hostless plus
1517/// input primitives.
1518///
1519/// # Errors
1520///
1521/// A parse or import error, which leaves no program to check.
1522pub fn check_entry(
1523    entry: Entry<'_>,
1524    loader: &dyn crate::uses::Loader,
1525    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
1526    checking: Checking,
1527) -> Result<Checked, RunError> {
1528    let mut program = parse_and_resolve(entry, loader, surface)?;
1529    if checking == Checking::Program {
1530        program.retain(|f| !crate::uses::is_test_form(f));
1531    }
1532    let host = match entry.path {
1533        Some(path) => crate::host_surface::near(path)
1534            .map_err(|e| RunError::Import(e.to_string()))?
1535            .map(|s| s.functions)
1536            .unwrap_or_default(),
1537        None => Vec::new(),
1538    };
1539    let interp = runtime(Inputs::new());
1540    let builtins = crate::timings::stage(crate::timings::Stage::Names, || {
1541        builtin_names_with(&interp, &host)
1542    });
1543    let (outcome, names) = crate::timings::stage(crate::timings::Stage::Check, || {
1544        check_stage(&program, &builtins, checking == Checking::WithTests)
1545    });
1546    Ok(Checked {
1547        program,
1548        outcome,
1549        names,
1550    })
1551}
1552
1553#[must_use]
1554pub fn spelling_code(spelling: blue_lang_syntax::Spelling) -> blue_lang_check::Code {
1555    use blue_lang_check::Code;
1556    use blue_lang_syntax::Spelling;
1557    match spelling {
1558        Spelling::LogicalSymbol => Code::B0025,
1559        Spelling::Unless => Code::B0026,
1560        Spelling::ElseIf => Code::B0027,
1561        Spelling::CallForm => Code::B0028,
1562        Spelling::KeywordRocket => Code::B0029,
1563        Spelling::ParameterList => Code::B0030,
1564        Spelling::OperatorSpelling => Code::B0031,
1565        Spelling::TrailingComma => Code::B0032,
1566        Spelling::NegatedComparison => Code::B0034,
1567        Spelling::ElseNil => Code::B0035,
1568        Spelling::TryCatch => Code::B0037,
1569        Spelling::Apply => Code::B0042,
1570        Spelling::MapPun => Code::B0046,
1571        Spelling::NilTest => Code::B0047,
1572        Spelling::NewlineEscape => Code::B0065,
1573        Spelling::Index => Code::B0066,
1574        Spelling::TestDeclaration => Code::B0068,
1575    }
1576}
1577
1578#[must_use]
1579pub fn form_diagnostics(text: &str) -> Vec<blue_lang_check::Diagnostic> {
1580    match blue_lang_fmt::format_source_lossless(text) {
1581        Ok(formatted) => form_diagnostics_against(text, &formatted),
1582        Err(_) => Vec::new(),
1583    }
1584}
1585
1586#[must_use]
1587pub fn form_diagnostics_against(text: &str, formatted: &str) -> Vec<blue_lang_check::Diagnostic> {
1588    use blue_lang_check::{Code, Diagnostic};
1589    if formatted.trim_end() == text.trim_end() {
1590        return Vec::new();
1591    }
1592    let help = "`blue fmt --write FILE` writes it; no compile door writes a file (`BLUE_LAYOUT=rewrite` restores formatting in place)";
1593    let mut out: Vec<Diagnostic> = blue_lang_syntax::spelling_notes(text)
1594        .unwrap_or_default()
1595        .into_iter()
1596        .map(|n| {
1597            let message = if n.canonical.is_empty() {
1598                format!("`{}` is not written", n.written)
1599            } else {
1600                format!("`{}` is written `{}`", n.written, n.canonical)
1601            };
1602            Diagnostic::new(spelling_code(n.spelling), message, n.span)
1603                .at_top_level(0)
1604                .with_help(help)
1605        })
1606        .collect();
1607    out.extend(
1608        literal_diagnostics(text)
1609            .into_iter()
1610            .map(|d| d.with_help(help)),
1611    );
1612    if out.is_empty() {
1613        let at = text
1614            .lines()
1615            .zip(formatted.lines())
1616            .position(|(a, b)| a != b)
1617            .unwrap_or_else(|| text.lines().count().min(formatted.lines().count()));
1618        let start: usize = text.lines().take(at).map(|l| l.len() + 1).sum();
1619        let start = start.min(text.len());
1620        let end = text[start..].find('\n').map_or(text.len(), |e| start + e);
1621        out.push(
1622            Diagnostic::new(
1623                Code::B0024,
1624                format!("line {} is not laid out as blue writes it", at + 1),
1625                tatara_lisp::Span::new(start, end),
1626            )
1627            .at_top_level(0)
1628            .with_help(help),
1629        );
1630    }
1631    out.sort_by_key(|d| (d.span.start, d.code));
1632    out
1633}
1634
1635fn literal_diagnostics(text: &str) -> Vec<blue_lang_check::Diagnostic> {
1636    use blue_lang_syntax::{Atom, TokenKind};
1637    let Ok(toks) = blue_lang_syntax::lex(text) else {
1638        return Vec::new();
1639    };
1640    toks.iter()
1641        .filter_map(|t| {
1642            let atom = match &t.kind {
1643                TokenKind::Int(v) => Atom::Int(*v),
1644                TokenKind::Float(v) => Atom::Float(*v),
1645                TokenKind::Str(v) => Atom::Str(v.clone()),
1646                _ => return None,
1647            };
1648            let written = text.get(t.span.start..t.span.end)?;
1649            let canonical = blue_lang_fmt::render_literal(&atom);
1650            (written != canonical).then(|| {
1651                blue_lang_check::Diagnostic::new(
1652                    blue_lang_check::Code::B0033,
1653                    format!("`{written}` is written `{canonical}`"),
1654                    t.span,
1655                )
1656                .at_top_level(0)
1657            })
1658        })
1659        .collect()
1660}
1661
1662/// A syntax error in `text` as a [`B0006`](blue_lang_check::Code::B0006)
1663/// diagnostic, so a tool reading `--format json` gets a parse failure in the
1664/// same shape as every other finding. `None` when `text` parses.
1665#[must_use]
1666pub fn syntax_diagnostic(text: &str) -> Option<blue_lang_check::Diagnostic> {
1667    let e = blue_lang_syntax::parse_program_tree(text).err()?;
1668    Some(
1669        blue_lang_check::Diagnostic::new(blue_lang_check::Code::B0006, e.message, e.span)
1670            .at_top_level(0),
1671    )
1672}
1673
1674/// A runtime message as the author wrote the names in it: a definition runs
1675/// under its key (`retsu/first`, `%root/f`), and a message naming one in
1676/// backticks names it `retsu::first`, or `f` for the author's own script.
1677#[must_use]
1678pub fn display_keys(message: &str) -> String {
1679    let mut out = String::with_capacity(message.len());
1680    let mut parts = message.split('`');
1681    if let Some(first) = parts.next() {
1682        out.push_str(first);
1683    }
1684    for (i, part) in parts.enumerate() {
1685        out.push('`');
1686        // Odd pieces (0-based here) are inside backticks.
1687        if i % 2 == 0 {
1688            out.push_str(&crate::observe::display_key(part));
1689        } else {
1690            out.push_str(part);
1691        }
1692    }
1693    out
1694}
1695
1696/// Evaluate a prepared program's top-level forms into `interp`, with `host`.
1697pub(crate) fn eval_prepared<H: 'static>(
1698    interp: &mut tatara_lisp_eval::Interpreter<H>,
1699    prepared: &Prepared,
1700    host: &mut H,
1701) -> Result<Value, RunError> {
1702    let Prepared {
1703        program, erased, ..
1704    } = prepared;
1705
1706    // RUN, one top-level form at a time, **counting them**.
1707    //
1708    // This is literally the loop `Interpreter::eval_program` runs internally
1709    // (tatara-lisp-eval `eval.rs`) — unrolled here for exactly one reason: the
1710    // INDEX. `resolve_uses` flattens N files into one form list and a `Span` is
1711    // a byte range with no file identity, so an offset alone is ambiguous
1712    // across files — 66 means something in every one of them. The index is the
1713    // join key back to `ResolvedProgram`'s file table, and `eval_program`
1714    // consumes it internally and hands back only the error.
1715    //
1716    // The same key, the same renderer and the same file table the type errors
1717    // above already use. A runtime error was the one diagnostic in this
1718    // function still reporting a bare message.
1719    let mut value = Value::Nil;
1720    for (top_level, form) in erased.iter().enumerate() {
1721        value = interp.eval_top_form(form, host).map_err(|e| {
1722            // The message, its position and the blue call stack, read off the
1723            // frames the error unwound through (`observe::Fault`). The message
1724            // is `short_message` rather than upstream's `Display`, whose
1725            // trailing ` at {span}` is a bare byte range with no file, through
1726            // `messages::describe`, which names the function a wrong argument
1727            // count was called on.
1728            RunError::Eval(Box::new(crate::observe::Fault::of(
1729                program,
1730                erased,
1731                top_level,
1732                &e,
1733                &interp.take_trace(),
1734            )))
1735        })?;
1736    }
1737    Ok(value)
1738}
1739
1740pub const MAIN: &str = "main";
1741
1742#[must_use]
1743pub fn entry_main(program: &crate::uses::ResolvedProgram) -> Option<(usize, tatara_lisp::Span)> {
1744    use crate::uses::ResolvedProgram;
1745    let stored = program
1746        .file(ResolvedProgram::ENTRY)
1747        .is_some_and(|f| f.path.is_some());
1748    if !stored {
1749        return None;
1750    }
1751    program.forms().iter().enumerate().find_map(|(i, f)| {
1752        if program.owner_of(i) != Some(ResolvedProgram::ENTRY) {
1753            return None;
1754        }
1755        let items = f.as_list()?;
1756        let sig = items.get(1)?.as_list()?;
1757        let named_main = matches!(
1758            items.first().and_then(tatara_lisp::Spanned::as_symbol),
1759            Some("define" | "define-typed")
1760        ) && sig.len() == 1
1761            && sig[0].as_symbol() == Some(MAIN);
1762        named_main.then_some((i, sig[0].span))
1763    })
1764}
1765
1766/// The entry's `main`: its top-level form, where it is named, and the key
1767/// a call to it is written with.
1768fn main_call(prepared: &Prepared) -> Option<(usize, tatara_lisp::Span, String)> {
1769    let (top_level, span) = entry_main(&prepared.program)?;
1770    let ns = namespace_of(&prepared.program, top_level);
1771    Some((top_level, span, blue_lang_check::names::key(&ns, MAIN)))
1772}
1773
1774fn main_key(prepared: &Prepared) -> Option<String> {
1775    main_call(prepared).map(|(_, _, key)| key)
1776}
1777
1778pub(crate) fn call_main<H: 'static>(
1779    interp: &mut tatara_lisp_eval::Interpreter<H>,
1780    prepared: &Prepared,
1781    host: &mut H,
1782) -> Result<Option<Value>, RunError> {
1783    let Some((top_level, span, key)) = main_call(prepared) else {
1784        return Ok(None);
1785    };
1786    let call = tatara_lisp::Spanned::new(
1787        span,
1788        tatara_lisp::SpannedForm::List(vec![tatara_lisp::Spanned::new(
1789            span,
1790            tatara_lisp::SpannedForm::Atom(tatara_lisp::Atom::Symbol(key)),
1791        )]),
1792    );
1793    interp.eval_top_form(&call, host).map(Some).map_err(|e| {
1794        RunError::Eval(Box::new(crate::observe::Fault::of(
1795            &prepared.program,
1796            &prepared.erased,
1797            top_level,
1798            &e,
1799            &interp.take_trace(),
1800        )))
1801    })
1802}
1803
1804/// **A runtime error names a place.** The gates for the last stage that
1805/// reported a bare message.
1806///
1807/// Each fixture is sized so that resolving the failing span against ANY file
1808/// but the right one lands somewhere else — a different path and a different
1809/// line — so a green run here is evidence about the join and not about the
1810/// renderer being called at all.
1811///
1812/// **Each raise is an unbound name, WAIVED.** Since the check stage resolves
1813/// names (B0001), an unbound call no longer reaches the evaluator; these
1814/// fixtures need exactly that runtime raise, so each carries
1815/// `# waive B0001: …` above the definition holding it — the one escape hatch,
1816/// used for what it is for. The waiver adds a line above each failing call,
1817/// which is why the positions below are one line later than their red runs
1818/// (recorded against the unwaived fixtures) show.
1819#[cfg(test)]
1820mod position_tests {
1821    use super::*;
1822    use crate::uses::{Entry, Loader};
1823    use std::collections::BTreeMap;
1824    use std::path::Path;
1825
1826    struct MemLoader(BTreeMap<&'static str, &'static str>);
1827
1828    impl Loader for MemLoader {
1829        fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
1830            self.0
1831                .get(name)
1832                .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
1833                .ok_or_else(|| format!("no bidama named \"{name}\""))
1834        }
1835    }
1836
1837    /// A package whose OWN top-level code raises, deep inside a file that is
1838    /// deliberately taller than either of the other two.
1839    ///
1840    /// Byte 66 — where `kore_wa_sonzai_shinai` starts — is past the end of the
1841    /// entry file (30 bytes) and past the end of `kotae.b` (21 bytes). So the
1842    /// only file this span can honestly resolve against is this one, and the
1843    /// other two now refuse to answer rather than walking off their ends.
1844    const BAKUHATSU: &str = "\
1845def tsukawanai_a()
1846  1
1847end
1848
1849def tsukawanai_b()
1850  2
1851end
1852
1853# waive B0001: the fixture needs a raise at runtime
1854def bakuhatsu()
1855  kore_wa_sonzai_shinai()
1856end
1857
1858bakuhatsu";
1859
1860    const KOTAE: &str = "def kotae()\n  42\nend";
1861
1862    /// The same failing call, but only ever reached by a CALLER in another
1863    /// file — the shape the call stack resolved (the last position test).
1864    const BAKUHATSU2: &str =
1865        "# waive B0001: a raise at runtime\ndef yobu()\n  kore_wa_sonzai_shinai()\nend";
1866
1867    fn loader() -> MemLoader {
1868        MemLoader(BTreeMap::from([
1869            ("kotae", KOTAE),
1870            ("bakuhatsu", BAKUHATSU),
1871            ("bakuhatsu2", BAKUHATSU2),
1872        ]))
1873    }
1874
1875    fn run_named(path: &str, src: &str) -> RunError {
1876        run_in_surface(
1877            Entry {
1878                path: Some(Path::new(path)),
1879                text: src,
1880            },
1881            Inputs::new(),
1882            &loader(),
1883            None,
1884        )
1885        .expect_err("the fixture must raise")
1886    }
1887
1888    /// **The load-bearing gate: a raise inside an IMPORTED package reports the
1889    /// IMPORTED file's path and line.**
1890    ///
1891    /// Hand-computed. In `bakuhatsu.b` the failing call sits on line 11 at
1892    /// column 3, and the whole point of the fixture's height is that no other
1893    /// file in the program can produce that pair: the entry file has 2 lines
1894    /// and `kotae.b` has 3, so *any* mis-attribution is visible as a different
1895    /// path AND a different position, never as a coincidence.
1896    ///
1897    /// **Three red runs, one per edit this change makes** — each isolates a
1898    /// different half of the mechanism, which is what stops the gate being a
1899    /// tautology over "some renderer ran".
1900    ///
1901    /// 1. `eval_program` + `.map_err(|e| RunError::Eval(e.to_string()))`
1902    ///    restored (the pre-change code):
1903    ///    ```text
1904    ///    left:  "runtime error: unbound symbol: kore_wa_sonzai_shinai at 74..95"
1905    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1906    ///    ```
1907    ///    No place at all — the state this change is fixing — and the trailing
1908    ///    `at 74..95` is upstream's `Display`, an offset with no file, which is
1909    ///    why the loop renders `short_message` instead.
1910    ///
1911    /// 2. The loop kept, but `0` passed to `locate` in place of `top_level`:
1912    ///    ```text
1913    ///    left:  "runtime error: kotae.b: unbound symbol `kore_wa_sonzai_shinai`"
1914    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1915    ///    ```
1916    ///    **This is the one that proves the INDEX is load-bearing** rather than
1917    ///    the renderer: form 0 belongs to `kotae.b`, so the wrong file is named
1918    ///    — and named without a position, because the guard in `locate` sees
1919    ///    that byte 74 is not a range in a 21-byte file and declines to invent
1920    ///    one. A fixture with one imported package could not tell these apart.
1921    ///
1922    /// 3. `crate::lower_to_spanned(&crate::to_sexps(&erased))` reinstated
1923    ///    between erasure and the loop:
1924    ///    ```text
1925    ///    left:  "runtime error: bakuhatsu.b: unbound symbol `kore_wa_sonzai_shinai`"
1926    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1927    ///    ```
1928    ///    The right FILE with no position — which is exactly the shape of the
1929    ///    bug: the index survived the lift and every span did not.
1930    /// kotae loads first so bakuhatsu's forms do not start at index 0: the
1931    /// order is the fixture, and the waiver says so (B0015 wants them sorted).
1932    const TWO_IMPORTS: &str =
1933        "use(\"kotae\")\n# waive B0015: kotae loads first on purpose\nuse(\"bakuhatsu\")\n";
1934
1935    #[test]
1936    fn a_raise_inside_an_imported_package_names_that_package() {
1937        let err = run_named("entry.b", TWO_IMPORTS);
1938        assert_eq!(
1939            err.to_string(),
1940            "runtime error: bakuhatsu.b:11:3: unbound symbol `kore_wa_sonzai_shinai`\n  \
1941             at bakuhatsu::bakuhatsu (bakuhatsu.b:11:3)\n  \
1942             at <top level> (bakuhatsu.b:14:1)"
1943        );
1944    }
1945
1946    /// Anti-vacuity for the fixture itself: the position asserted above is a
1947    /// FACT ABOUT `bakuhatsu.b`, checked against the source text rather than
1948    /// against the thing that produced it.
1949    ///
1950    /// Without this, `11:3` is just a number that happened to come out of the
1951    /// code under test, and a change that moved every reported line by one
1952    /// would move this assertion with it.
1953    #[test]
1954    fn line_11_column_3_of_the_fixture_is_the_failing_call() {
1955        let line = BAKUHATSU.lines().nth(10).expect("line 11 exists");
1956        assert_eq!(line, "  kore_wa_sonzai_shinai()");
1957        assert_eq!(
1958            &line[2..],
1959            "kore_wa_sonzai_shinai()",
1960            "column 3 (1-indexed) is where the call starts"
1961        );
1962        // And the other two files cannot reach that far, which is what makes a
1963        // mis-attribution loud instead of plausible.
1964        let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
1965        assert!(
1966            offset > KOTAE.len(),
1967            "kotae.b could answer for byte {offset}"
1968        );
1969        assert!(
1970            offset > TWO_IMPORTS.len(),
1971            "the entry file could answer for byte {offset}"
1972        );
1973    }
1974
1975    /// **The `FileId(0)` path: a raise in the ENTRY file reports the entry
1976    /// file.** The single-file case, which is every program that imports
1977    /// nothing — and the one where the index and the span cannot disagree.
1978    ///
1979    /// **Red run** (2026-08-12), `eval_program` +
1980    /// `.map_err(|e| RunError::Eval(e.to_string()))` restored:
1981    /// ```text
1982    /// left:  "runtime error: unbound symbol: nani_mo_nai at 17..28"
1983    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1984    /// ```
1985    ///
1986    /// **Second red run**, `lower_to_spanned` reinstated after erasure — the
1987    /// one that isolates the SPAN half from the index half, since a
1988    /// single-file program cannot get its file wrong:
1989    /// ```text
1990    /// left:  "runtime error: honmono.b: unbound symbol `nani_mo_nai`"
1991    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1992    /// ```
1993    #[test]
1994    fn a_raise_in_the_entry_file_names_the_entry_file() {
1995        let err = run_named(
1996            "honmono.b",
1997            "def f()\n  1\nend\n\n# waive B0001: a raise at runtime\nnani_mo_nai()\n",
1998        );
1999        assert_eq!(
2000            err.to_string(),
2001            "runtime error: honmono.b:6:1: unbound symbol `nani_mo_nai`"
2002        );
2003    }
2004
2005    /// A raise from a form that came through the SURFACE-level erasure still
2006    /// reports a real position — the annotated path, where erasure rewrites
2007    /// the node rather than passing it through.
2008    ///
2009    /// Separate from the two above because the erasure rewrite is the only
2010    /// place a node is built rather than kept, and a synthetic span there
2011    /// would be invisible to a fixture whose failing form is untouched.
2012    ///
2013    /// **Red run** (2026-08-12), `lower_to_spanned` reinstated after erasure:
2014    /// ```text
2015    /// left:  "runtime error: chuu.b: unbound symbol `mada_nai`"
2016    /// right: "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
2017    /// ```
2018    #[test]
2019    fn a_raise_inside_an_annotated_def_still_names_its_line() {
2020        let err = run_named(
2021            "chuu.b",
2022            "# waive B0001: a raise at runtime\ndef f(a: Int) -> Int\n  mada_nai(a)\nend\n\nf(1)\n",
2023        );
2024        assert_eq!(
2025            err.to_string(),
2026            "runtime error: chuu.b:3:3: unbound symbol `mada_nai`\n  \
2027             at f (chuu.b:3:3)\n  \
2028             at <top level> (chuu.b:6:1)"
2029        );
2030    }
2031
2032    /// **The limit this module used to state, closed by the call stack.**
2033    ///
2034    /// A top-level form in the entry file calls a function defined in an
2035    /// imported one, and the raise happens inside the callee. The executing
2036    /// form is the entry's and the span is the callee's bytes, so until the
2037    /// evaluator reported the frames an error unwound through, the best blue
2038    /// could do was name the caller's file with no position (the guard in
2039    /// `uses::ResolvedProgram::locate` refusing an offset it could not
2040    /// justify). The innermost frame's definition now names the file the
2041    /// offsets belong to, and the stack shows the call that led there.
2042    ///
2043    /// **Red run** (2026-10-10): `Fault::of` locating against the executing
2044    /// top-level form instead of the innermost frame's definition:
2045    /// ```text
2046    /// 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)"
2047    /// ```
2048    /// The old answer, kept by the guard: the caller's file and no position.
2049    #[test]
2050    fn a_raise_in_a_callee_from_another_file_names_the_callees_file() {
2051        // The comment is padding, and load-bearing: `bakuhatsu2.b`'s failing
2052        // call starts at byte 47 (after its waiver line) and ends at 70, and
2053        // this file is 57 bytes, so the start is in range here and the end is
2054        // not — a mis-attribution would still be refused rather than placed.
2055        let err = run_named(
2056            "yobidashi.b",
2057            "use(\"bakuhatsu2\")\n# pad: 47's in range\nbakuhatsu2::yobu\n",
2058        );
2059        assert_eq!(
2060            err.to_string(),
2061            "runtime error: bakuhatsu2.b:3:3: unbound symbol `kore_wa_sonzai_shinai`\n  \
2062             at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n  \
2063             at <top level> (yobidashi.b:3:1)"
2064        );
2065    }
2066}
2067
2068#[cfg(test)]
2069mod tests {
2070    use super::*;
2071
2072    fn int(src: &str) -> i64 {
2073        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
2074            Value::Int(v) => v,
2075            other => panic!("{src:?} produced {other:?}"),
2076        }
2077    }
2078
2079    /// **The sliding scale, as one assertion.** Annotating changes the
2080    /// analysis and nothing else.
2081    #[test]
2082    fn annotating_buys_analysis_and_changes_nothing_else() {
2083        let plain = run("def add(a, b)\n  a + b\nend\nadd(2, 3)").expect("plain");
2084        let typed = run("def add(a: Int, b: Int) -> Int\n  a + b\nend\nadd(2, 3)").expect("typed");
2085
2086        assert!(matches!(plain.value, Value::Int(5)));
2087        assert!(
2088            matches!(typed.value, Value::Int(5)),
2089            "the annotated program must compute the same answer"
2090        );
2091        assert_eq!(plain.visited, 0, "no annotations means no analysis");
2092        assert!(
2093            typed.visited > 0,
2094            "an annotation must actually buy analysis, not just decorate"
2095        );
2096        assert_eq!(plain.typed_decls, 0);
2097        assert_eq!(typed.typed_decls, 1);
2098    }
2099
2100    /// **Checking happens before erasure.** This is the test that catches the
2101    /// reordering: a program with a declared-type violation must be rejected,
2102    /// and it can only be rejected while the annotations still exist.
2103    #[test]
2104    fn a_type_error_is_reported_and_the_program_does_not_run() {
2105        let err = run("def add(a: Int, b: Int) -> Str\n  a + b\nend\nadd(1, 2)")
2106            .expect_err("a declared Str return from an Int body must be rejected");
2107        assert!(
2108            matches!(err, RunError::Types(ref d) if !d.is_empty()),
2109            "expected type diagnostics, got {err}"
2110        );
2111    }
2112
2113    /// And the untyped version of the same program runs, so the rejection
2114    /// above is the annotation's doing rather than a parse failure.
2115    #[test]
2116    fn the_same_program_without_annotations_runs() {
2117        assert_eq!(int("def add(a, b)\n  a + b\nend\nadd(1, 2)"), 3);
2118    }
2119
2120    #[test]
2121    fn a_parse_error_is_reported_as_one() {
2122        assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
2123    }
2124
2125    /// Every stage reports in its own vocabulary, so a failure names which
2126    /// stage failed rather than surfacing as a generic error.
2127    #[test]
2128    fn a_runtime_error_is_reported_as_one() {
2129        let err = run("throw(error(\"boom\"))").expect_err("a raise");
2130        assert!(matches!(err, RunError::Eval(_)), "got {err}");
2131    }
2132
2133    /// Stdlib and primitives are both reachable through the pipeline — the
2134    /// gap that made `6 % 3` fail.
2135    #[test]
2136    fn the_pipeline_reaches_both_runtime_layers() {
2137        assert_eq!(int("6 % 3"), 0);
2138        assert_eq!(int("7 % 3"), 1);
2139        assert_eq!(int("2 + 3 * 4"), 14);
2140    }
2141
2142    /// **The deleted hop was a no-op on everything blue emits — so removing it
2143    /// is a swap, not a behaviour change.**
2144    ///
2145    /// The old lowering printed the erased tree and read it back through
2146    /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
2147    /// paths land on the same tree, which is the equivalence the swap rests on.
2148    /// It is stated as a *measurement over this corpus*, not as a theorem:
2149    /// the round trip is not identity in general (that is precisely why it had
2150    /// to go), it merely happened to be identity for the bytes blue emits.
2151    #[test]
2152    fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
2153        let corpus = [
2154            "def add(a, b)\n  a + b\nend\nadd(2, 3)",
2155            "def fact(n)\n  if n < 2\n    1\n  else\n    n * fact(n - 1)\n  end\nend\nfact(5)",
2156            "def f(a, b)\n  c = a + b\n  c * 2\nend\nf(1, 2)",
2157            "defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)",
2158            "\"a string with spaces, a ( and a )\"",
2159            "def g(a: Int) -> Int\n  a + 1\nend\ng(1)",
2160            "6 % 3",
2161            "1.5 + 2.25",
2162        ];
2163        for src in corpus {
2164            // Projected to `Sexp` because that is what the deleted hop
2165            // operated on. Erasure itself no longer goes near it.
2166            let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
2167
2168            let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
2169                .iter()
2170                .map(tatara_lisp::Spanned::to_sexp)
2171                .collect();
2172            assert_eq!(direct, erased, "the direct lowering must be the identity");
2173
2174            let text = erased
2175                .iter()
2176                .map(ToString::to_string)
2177                .collect::<Vec<_>>()
2178                .join("\n");
2179            let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
2180                .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
2181                .iter()
2182                .map(tatara_lisp::Spanned::to_sexp)
2183                .collect();
2184            assert_eq!(
2185                round_tripped, erased,
2186                "{src:?}: the old print-and-reparse path changed the tree"
2187            );
2188        }
2189    }
2190
2191    /// Anti-vacuity for the test above: the round trip really is *not* the
2192    /// identity in general, so agreeing on the corpus was a property of what
2193    /// blue happens to emit rather than a property of the reader.
2194    ///
2195    /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
2196    /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
2197    /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
2198    /// domain, and the failure is *silent*: a symbol containing a space prints
2199    /// as two tokens, reads back as two symbols, and the result is a perfectly
2200    /// well-formed tree with a different meaning. No error, nothing to catch.
2201    ///
2202    /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
2203    /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
2204    /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
2205    /// in and one symbol out, so the brace-fusion reported in tatara *source*
2206    /// is not what bites a printed tree. The silent pair is what makes this a
2207    /// miscompile class rather than a noisy one.
2208    #[test]
2209    fn the_round_trip_is_not_the_identity_in_general() {
2210        let tree = Sexp::List(vec![
2211            Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
2212            Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
2213        ]);
2214        let text = tree.to_string();
2215        let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
2216            .expect("it reads back cleanly — that IS the problem")
2217            .iter()
2218            .map(tatara_lisp::Spanned::to_sexp)
2219            .collect();
2220        assert_ne!(
2221            back,
2222            vec![tree.clone()],
2223            "if print-then-read became inverse over symbols, the class would be \
2224             closed upstream and this test should be deleted rather than relaxed"
2225        );
2226        // …and the direct lowering is unaffected by any of it.
2227        let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
2228            .iter()
2229            .map(tatara_lisp::Spanned::to_sexp)
2230            .collect();
2231        assert_eq!(direct, vec![tree]);
2232    }
2233}
2234
2235#[cfg(test)]
2236mod macro_tests {
2237    use super::*;
2238
2239    fn int(src: &str) -> i64 {
2240        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
2241            Value::Int(v) => v,
2242            other => panic!("{src:?} produced {other:?}"),
2243        }
2244    }
2245
2246    /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
2247    #[test]
2248    fn a_macro_expands_and_runs() {
2249        assert_eq!(
2250            int("defmacro double(x)\n  quote\n    unquote(x) + unquote(x)\n  end\nend\ndouble(21)"),
2251            42
2252        );
2253    }
2254
2255    /// A macro receives *source forms*, not values — so it can duplicate its
2256    /// argument, which a function cannot do without re-evaluating it.
2257    #[test]
2258    fn a_macro_operates_on_syntax_not_values() {
2259        assert_eq!(
2260            int("defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)"),
2261            25,
2262            "the argument form `2 + 3` must be substituted twice"
2263        );
2264    }
2265
2266    /// **A runaway macro is a typed error, not a dead compiler.** This is the
2267    /// property that makes the metaprogramming surface safe to hand to a user.
2268    ///
2269    /// The assertion was `contains("expansion limit")` and now reads
2270    /// `contains("expansion")`, because the pipeline renders `short_message`
2271    /// rather than `Display`: upstream words the same fact as "exceeded 256
2272    /// expansion steps" instead of "exceeded the expansion limit of 256
2273    /// rewrites". The PROPERTY is unchanged and still asserted — the macro is
2274    /// named, and the failure is attributed to expansion rather than to
2275    /// evaluation. **This reword was the whole measured blast radius of that
2276    /// switch**, one test in the workspace.
2277    ///
2278    /// The number is deliberately NOT pinned. It is upstream's constant, this
2279    /// test owns none of it, and matching it would turn an upstream bump into
2280    /// a red run here that proves nothing about blue.
2281    ///
2282    /// The third clause is new capability rather than repair: the message now
2283    /// carries `<anonymous>:6:1`, so a runaway macro says WHERE it ran away.
2284    #[test]
2285    fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
2286        let err =
2287            run("defmacro forever(x)\n  quote\n    forever(unquote(x))\n  end\nend\nforever(1)")
2288                .expect_err("a self-referential macro must be rejected");
2289        let msg = err.to_string();
2290        assert!(
2291            msg.contains("forever") && msg.contains("expansion"),
2292            "the error must name the macro and the limit it hit: {msg}"
2293        );
2294        assert!(
2295            msg.contains("<anonymous>:6:1"),
2296            "and must place the call that ran away — line 6 is `forever(1)`: {msg}"
2297        );
2298    }
2299}
2300
2301#[cfg(test)]
2302mod input_tests {
2303    use super::*;
2304    use crate::inputs::{Declaration, Inputs};
2305
2306    /// A schema a macro will generate code from.
2307    const SCHEMA: &[u8] = b"3";
2308
2309    fn with_schema(src: &str) -> Result<Run, RunError> {
2310        let hash = Inputs::hash_of(SCHEMA);
2311        let mut inputs = Inputs::new();
2312        inputs
2313            .bind(
2314                &Declaration {
2315                    name: "schema".to_string(),
2316                    hash,
2317                },
2318                SCHEMA.to_vec(),
2319            )
2320            .expect("bind");
2321        run_with_inputs(src, inputs)
2322    }
2323
2324    fn decl_line() -> String {
2325        let mut s = String::from("definput(\"schema\", \"");
2326        s.push_str(&Inputs::hash_of(SCHEMA));
2327        s.push_str("\")\n");
2328        s
2329    }
2330
2331    /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
2332    /// the spec names it as gating blue's whole "stronger than Ruby's
2333    /// metaprogramming" claim, because a macro that cannot read a schema cannot
2334    /// generate code from one.
2335    #[test]
2336    fn a_macro_can_read_a_declared_build_input() {
2337        let src = decl_line() + "input(\"schema\")";
2338        let out = with_schema(&src).expect("run");
2339        assert!(
2340            matches!(out.value, Value::Str(ref s) if &**s == "3"),
2341            "got {:?}",
2342            out.value
2343        );
2344    }
2345
2346    /// **An undeclared input is an error, not a file read and not nil.**
2347    /// Returning nil is how a macro generates an empty table and nobody notices
2348    /// until runtime.
2349    #[test]
2350    fn an_undeclared_input_is_an_error() {
2351        let err = with_schema("input(\"not_declared\")").expect_err("must fail");
2352        let msg = err.to_string();
2353        assert!(msg.contains("not_declared"), "must name it: {msg}");
2354        assert!(msg.contains("definput"), "and say how to declare it: {msg}");
2355    }
2356
2357    /// **There is no path-based read at all.** The capability is the absence of
2358    /// the primitive, not a check inside one — so this is an unbound symbol.
2359    ///
2360    /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
2361    /// cargo feature (CLI only) is the one declared exception: it is the
2362    /// operator's own trusted host surface, and is asserted in
2363    /// `sys_read_file_is_the_trusted_cli_only_exception` below.
2364    #[cfg(not(feature = "sys"))]
2365    #[test]
2366    fn there_is_no_ambient_file_read() {
2367        for attempt in [
2368            "read_file(\"/etc/passwd\")",
2369            "File(\"/etc/passwd\")",
2370            "slurp(\"/etc/passwd\")",
2371            "open(\"/etc/passwd\")",
2372        ] {
2373            let err = with_schema(attempt).expect_err("must not resolve");
2374            assert!(
2375                err.to_string().contains("unbound"),
2376                "{attempt} must be UNBOUND — a capability removed by absence, \
2377                 not guarded by a check: {err}"
2378            );
2379        }
2380    }
2381
2382    /// With the `sys` feature compiled in, `read_file` IS bound — that is the
2383    /// point of the feature. The doctrine does not move: this is the operator's
2384    /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
2385    /// future default-build change is heard, and assert that `input()` still
2386    /// works beside it.
2387    #[cfg(feature = "sys")]
2388    #[test]
2389    fn sys_read_file_is_the_trusted_cli_only_exception() {
2390        let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
2391        assert!(err.to_string().contains("unbound"), "{err}");
2392        assert!(
2393            with_schema("read_file(\"/etc/passwd\")").is_ok(),
2394            "with `sys` on, read_file is the trusted CLI surface"
2395        );
2396        let out = with_schema("input(\"schema\")").expect("run");
2397        assert!(
2398            matches!(out.value, Value::Str(ref s) if &**s == "3"),
2399            "input() still binds beside the sys surface: {:?}",
2400            out.value
2401        );
2402    }
2403
2404    /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
2405    /// — so the success above is the binding's doing.
2406    #[test]
2407    fn a_declared_input_with_no_bytes_supplied_fails() {
2408        let src = decl_line() + "input(\"schema\")";
2409        assert!(run(&src).is_err(), "no bytes were supplied");
2410    }
2411}
2412
2413#[cfg(test)]
2414mod tier2_tests {
2415    use super::*;
2416    use crate::inputs::{Declaration, Inputs};
2417
2418    /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
2419    /// declarations FROM A SCHEMA.**
2420    ///
2421    /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
2422    /// installs a `NoLoader`, so a macro cannot read a schema — which gates
2423    /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
2424    /// Ruby's metaprogramming' claim."
2425    ///
2426    /// Here the schema supplies a *value the generated code depends on*, read at
2427    /// expansion time. Ruby and Elixir can both do this — with the whole
2428    /// filesystem open. blue does it through a name bound to a content hash.
2429    #[test]
2430    fn a_macro_generates_code_from_a_schema() {
2431        let schema = b"7";
2432        let mut inputs = Inputs::new();
2433        inputs
2434            .bind(
2435                &Declaration {
2436                    name: "arity".to_string(),
2437                    hash: Inputs::hash_of(schema),
2438                },
2439                schema.to_vec(),
2440            )
2441            .expect("bind");
2442
2443        // The macro reads the input at EXPANSION time and splices the value it
2444        // found into the code it emits.
2445        let mut src = String::from("definput(\"arity\", \"");
2446        src.push_str(&Inputs::hash_of(schema));
2447        src.push_str("\")\n");
2448        src.push_str(
2449            "defmacro from_schema()\n  quote\n    unquote(to_int(input(\"arity\")))\n  end\nend\n\
2450             from_schema() * 6",
2451        );
2452
2453        let out = run_with_inputs(&src, inputs).expect("run");
2454        assert!(
2455            matches!(out.value, Value::Int(42)),
2456            "the schema's 7 must reach the generated code: got {:?}",
2457            out.value
2458        );
2459    }
2460}