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