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