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 crate::erase::erase_types;
20use crate::inputs::Inputs;
21use crate::uses::Entry;
22
23/// Why a run stopped short.
24#[derive(Debug, thiserror::Error)]
25pub enum RunError {
26    #[error("parse error: {0}")]
27    Parse(String),
28    /// The type checker rejected the program. Carries every diagnostic, not
29    /// just the first: a caller fixing one error wants to see the rest.
30    ///
31    /// Each one is already rendered `file:line:col: message` against the file
32    /// the offending form came from — which, in a program with imports, is
33    /// frequently not the file the user named. The rendering happens here
34    /// rather than at the consumer because here is where the file table exists;
35    /// see `uses::ResolvedProgram::locate`.
36    #[error("{} type error(s):\n{}", .0.len(), .0.join("\n"))]
37    Types(Vec<String>),
38    /// **No longer reachable, and that is the point.** This reported "blue
39    /// emitted a tree the reader could not read back" — a failure only a
40    /// print-then-reparse hop could have. [`crate::lower_to_spanned`] deleted
41    /// the hop, so there is nothing left to fail: the tree the evaluator gets
42    /// IS the tree erasure produced, not a re-reading of its text.
43    ///
44    /// Kept rather than removed because it is public API on a released crate
45    /// and a consumer may still match on it, per ★★ MODULARIZE, DON'T DELETE.
46    /// It is retired, not orphaned — if a future stage ever serialises again
47    /// it has a typed home. **Nothing constructs it today**; do not read its
48    /// presence as evidence the pipeline can still fail this way.
49    #[error("the emitted tatara-lisp could not be read back: {0}")]
50    Lower(String),
51    /// The program ran and raised.
52    ///
53    /// Rendered `file:line:col: message` against the file the failing form's
54    /// top-level form came from — the same `uses::ResolvedProgram::locate`
55    /// machinery, the same file table and the same join key the type errors
56    /// above use. Until erasure learned to carry spans this was a bare
57    /// message, because the evaluator was handed a tree whose every node had
58    /// been stamped `Span::synthetic()` on the way in.
59    ///
60    /// **The honest limit, stated so nobody reads more into a position than is
61    /// there.** The index names the top-level form that was EXECUTING; the span
62    /// names where the failing text SITS. Those agree on the file whenever the
63    /// failing code is in the same file as the top-level form that reached it —
64    /// which is every single-file program, and an imported package whose own
65    /// top-level code raises. They can disagree when a top-level form in file A
66    /// calls a function defined in file B and the raise happens inside B: the
67    /// path reported is A's. `locate` refuses to print a `line:col` it cannot
68    /// justify (the span must be a real range in that file), so the usual shape
69    /// of that case is a file with no position rather than a precise-looking
70    /// wrong one — but a same-length pair of files can still put a plausible
71    /// number on the wrong file. Closing it needs per-frame file identity at
72    /// the evaluator, which is a call stack blue does not have.
73    #[error("runtime error: {0}")]
74    Eval(String),
75    /// A `use("name")` could not be resolved.
76    ///
77    /// Its own variant rather than folded into `Parse`, because the reader's
78    /// next action is different: a parse error is in the source in front of
79    /// them, an import error is in their packaging — a missing bidama, a
80    /// BLUE_PATH that does not contain it, or no loader at all.
81    #[error("import error: {0}")]
82    Import(String),
83}
84
85/// What a run produced, plus what the checker did on the way.
86#[derive(Debug)]
87pub struct Run {
88    pub value: Value,
89    /// Nodes the type walk visited. Zero for a fully untyped program — this
90    /// is what makes "no annotations, no analysis" a *measurement* rather
91    /// than a claim.
92    pub visited: usize,
93    /// Declarations that carried an annotation.
94    pub typed_decls: usize,
95    /// Boundaries where typed code meets untyped code.
96    pub seams: usize,
97}
98
99/// Parse blue source to tatara-lisp forms.
100pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
101    parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
102}
103
104/// [`parse`] with the parser's nesting bound supplied by the caller.
105///
106/// The bound exists so a stack overflow — which `catch_unwind` cannot catch —
107/// arrives as a typed `Err` instead. It is a *limit*, not a dialect: raising
108/// it changes no program's meaning, which is exactly why it is safe to expose
109/// as configuration (`blue-lang-cli`'s `config` module holds the rule).
110pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
111    blue_lang_syntax::parse_program_with_depth(src, max_depth)
112        .map_err(|e| RunError::Parse(e.to_string()))
113}
114
115/// [`parse_with_depth`] keeping **every node's** source span.
116///
117/// The door for anything that will report a position to a human. It exists here,
118/// beside the spanless one, so a caller that wants spans still parses under the
119/// CONFIGURED nesting bound — a separate `blue_lang_syntax` call would be the
120/// second door `parse_with_depth`'s own docs exist to prevent, with
121/// `max_expr_depth` true of some subcommands and not others.
122pub fn parse_tree_with_depth(
123    src: &str,
124    max_depth: usize,
125) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
126    blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
127        .map_err(|e| RunError::Parse(e.to_string()))
128}
129
130/// [`parse`] keeping **every node's** source span.
131///
132/// The spanned twin of [`parse`], at the same default bound — the door for
133/// anything downstream of a parse that will report a position, which since
134/// `use` learned to carry file identity is every path through
135/// [`run_in_surface`].
136pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
137    parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
138}
139
140/// Run blue source with no build inputs.
141pub fn run(src: &str) -> Result<Run, RunError> {
142    run_with_inputs(src, Inputs::new())
143}
144
145/// Run blue source, giving the macro phase access to verified build inputs.
146///
147/// `inputs` is already verified — [`Inputs`] cannot hold bytes that do not match
148/// their declared hash — so nothing here re-checks. The capability a macro gains
149/// is exactly "these hashed bytes", never a path.
150pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
151    run_with_loader(src, inputs, &crate::uses::NoLoader)
152}
153
154/// Run blue source with a loader, so `use("name")` can resolve.
155///
156/// Split from [`run_with_inputs`] rather than folded into it because loading a
157/// package reads a filesystem, and this crate has a `wasm32-unknown-unknown`
158/// consumer with zero host imports. The capability is injected by callers that
159/// have it — `blue_lang_pkg::LoadPath` is the real one — and absent by default,
160/// where a `use` is a typed error naming the package.
161pub fn run_with_loader(
162    src: &str,
163    inputs: Inputs,
164    loader: &dyn crate::uses::Loader,
165) -> Result<Run, RunError> {
166    run_in_surface(Entry::anonymous(src), inputs, loader, None)
167}
168
169/// Run blue source written in a `yakugo` surface.
170///
171/// The pack applies at PARSE time and nowhere else — by the time the checker
172/// sees the program it is canonical, so every stage below is identical whatever
173/// surface the author wrote in. That is what makes a surface a surface: it
174/// changes how a program is spelled and nothing about how it runs.
175///
176/// `entry` carries the source AND the file it was read from, because a type
177/// error has to be reported somewhere: a caller with a path should pass it, and
178/// one without ([`Entry::anonymous`]) gets diagnostics that say so rather than
179/// diagnostics that guess. Imported packages name themselves through the
180/// loader either way.
181///
182/// # Errors
183///
184/// As [`run_with_loader`].
185pub fn run_in_surface(
186    entry: Entry<'_>,
187    inputs: Inputs,
188    loader: &dyn crate::uses::Loader,
189    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
190) -> Result<Run, RunError> {
191    let prepared = prepare(entry, loader, surface)?;
192    let mut interp = crate::interpreter_hostless();
193    crate::inputs::install_input_primitives(&mut interp, inputs);
194    let value = eval_prepared(&mut interp, &prepared, &mut ())?;
195    Ok(Run {
196        value,
197        visited: prepared.visited,
198        typed_decls: prepared.typed_decls,
199        seams: prepared.seams,
200    })
201}
202
203/// A program past every stage before evaluation: parsed, its imports
204/// resolved, type-checked and erased. What [`run_in_surface`] and
205/// [`crate::hosted::load_hosted`] both evaluate, so the stage order this
206/// module exists to own is written once for both.
207pub(crate) struct Prepared {
208    program: crate::uses::ResolvedProgram,
209    erased: Vec<tatara_lisp::Spanned>,
210    visited: usize,
211    typed_decls: usize,
212    seams: usize,
213}
214
215/// Parse, resolve, check and erase, in that order.
216pub(crate) fn prepare(
217    entry: Entry<'_>,
218    loader: &dyn crate::uses::Loader,
219    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
220) -> Result<Prepared, RunError> {
221    let forms = match surface {
222        Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
223            .map_err(|e| RunError::Parse(e.to_string()))?,
224        None => parse_tree(entry.text)?,
225    };
226
227    // RESOLVE imports first, so everything below sees ONE program.
228    //
229    // Before the check on purpose: imported code is type-checked at the point
230    // its consumer imports it, rather than at whatever later moment its code
231    // first runs. A package that does not typecheck should break its importer's
232    // build, not their production run.
233    //
234    // One program, but not one FILE: the result records which file each
235    // top-level form came from, which is what lets a diagnostic below name a
236    // place instead of only a problem.
237    let mut program = crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)?;
238
239    // `test` blocks are declarations for the harness, not code to run.
240    //
241    // Dropped here rather than in `resolve_uses`, because `blue test` calls
242    // the resolver and then NEEDS the entry file's blocks — so the two
243    // callers want different things and the split has to live at this level.
244    //
245    // Without this, `blue run` on a file containing its own tests fails with
246    // `unbound symbol: deftest`: every package in the bidama distribution
247    // carries tests, so every one of them was unrunnable.
248    //
249    // Through `retain`, which drops each form's owner with it. A plain filter
250    // over the forms alone would slide every later form onto the wrong file.
251    program.retain(|f| !crate::uses::is_test_form(f));
252
253    // CHECK, on the annotated tree — the only tree that has annotations.
254    //
255    // **On the REAL spanned tree, including every imported package's.** This
256    // was the one caller that checked a spanless lift, because `resolve_uses`
257    // flattened the entry file and its imports into one list and `Span` is a
258    // byte range with no file identity — so a real span here would have
259    // reported an imported package's error at that offset in the ENTRY file, a
260    // precise-looking answer pointing at unrelated code.
261    //
262    // The fix is not a wider `Span` (that type is upstream, and its own docs
263    // put file identity on the caller: spans "are meaningful only relative to
264    // the string that produced them, which the caller is responsible for
265    // holding onto"). blue holds onto it BESIDE the span, per top-level form —
266    // see `uses::ResolvedProgram`.
267    let outcome = blue_lang_check::check_program(program.forms());
268    if !outcome.ok() {
269        return Err(RunError::Types(
270            outcome
271                .diagnostics
272                .iter()
273                // `file:line:col: message`, resolved against the file the form
274                // actually came from. A typed `Display` builds it, per ★★ TYPED
275                // EMISSION — `locate` returns the renderer, not a string.
276                .map(|d| program.locate(d.top_level, d.span, &d.message).to_string())
277                .collect(),
278        ));
279    }
280
281    // ERASE, so the interpreter never sees a type — **on the spanned tree,
282    // and back out as one.**
283    //
284    // This used to be `erase_types(&program.sexps())` followed by a
285    // `lower_to_spanned` that stamped `Span::synthetic()` over every node, and
286    // the comment here said carrying real positions through was "a larger
287    // piece and is NOT built". It was not larger: erasure only ever DELETES
288    // nodes — the single node it invents is the `define` replacing
289    // `define-typed` — so there was never anything for a synthetic span to
290    // stand in for. See `crate::erase`.
291    //
292    // No lowering step follows. The tree the evaluator receives IS the tree the
293    // parser built, minus annotations, with the author's byte offsets intact.
294    let erased = erase_types(program.forms());
295
296    Ok(Prepared {
297        program,
298        erased,
299        visited: outcome.stats.visited,
300        typed_decls: outcome.stats.typed_decls,
301        seams: outcome.seams.len(),
302    })
303}
304
305/// Evaluate a prepared program's top-level forms into `interp`, with `host`.
306pub(crate) fn eval_prepared<H: 'static>(
307    interp: &mut tatara_lisp_eval::Interpreter<H>,
308    prepared: &Prepared,
309    host: &mut H,
310) -> Result<Value, RunError> {
311    let Prepared {
312        program, erased, ..
313    } = prepared;
314
315    // RUN, one top-level form at a time, **counting them**.
316    //
317    // This is literally the loop `Interpreter::eval_program` runs internally
318    // (tatara-lisp-eval `eval.rs`) — unrolled here for exactly one reason: the
319    // INDEX. `resolve_uses` flattens N files into one form list and a `Span` is
320    // a byte range with no file identity, so an offset alone is ambiguous
321    // across files — 66 means something in every one of them. The index is the
322    // join key back to `ResolvedProgram`'s file table, and `eval_program`
323    // consumes it internally and hands back only the error.
324    //
325    // The same key, the same renderer and the same file table the type errors
326    // above already use. A runtime error was the one diagnostic in this
327    // function still reporting a bare message.
328    let mut value = Value::Nil;
329    for (top_level, form) in erased.iter().enumerate() {
330        value = interp.eval_top_form(form, host).map_err(|e| {
331            // `span()` is `None` for the arms that genuinely have no position
332            // (`Reader`, `Halted`, `NotImplemented`). Synthetic is the honest
333            // stand-in — `locate` renders it as a file with no line rather
334            // than inventing one.
335            let at = e.span().unwrap_or_else(tatara_lisp::Span::synthetic);
336            // `short_message`, NOT `Display`. Upstream's `Display` ends every
337            // positioned arm with ` at {span}` — a raw byte range — and once
338            // blue prefixes a resolved `path:line:col` that suffix is the same
339            // fact told twice, the second time in a unit no reader can spend.
340            //
341            // Worse than redundant: a byte range is exactly the thing this
342            // whole file-identity apparatus exists to stop being reported on
343            // its own, because an offset means something different in every
344            // file. On the one case blue deliberately declines to place — a
345            // raise inside a callee from another file — `Display` would print
346            // the CALLEE's offsets beside the CALLER's path, re-creating the
347            // precise-looking wrong answer `locate`'s guard just refused.
348            // `short_message` is upstream's own "no source context" accessor,
349            // and blue supplies the context.
350            let message = e.short_message();
351            RunError::Eval(program.locate(top_level, at, &message).to_string())
352        })?;
353    }
354    Ok(value)
355}
356
357/// **A runtime error names a place.** The gates for the last stage that
358/// reported a bare message.
359///
360/// Each fixture is sized so that resolving the failing span against ANY file
361/// but the right one lands somewhere else — a different path and a different
362/// line — so a green run here is evidence about the join and not about the
363/// renderer being called at all.
364#[cfg(test)]
365mod position_tests {
366    use super::*;
367    use crate::uses::{Entry, Loader};
368    use std::collections::BTreeMap;
369    use std::path::Path;
370
371    struct MemLoader(BTreeMap<&'static str, &'static str>);
372
373    impl Loader for MemLoader {
374        fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
375            self.0
376                .get(name)
377                .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
378                .ok_or_else(|| format!("no bidama named \"{name}\""))
379        }
380    }
381
382    /// A package whose OWN top-level code raises, deep inside a file that is
383    /// deliberately taller than either of the other two.
384    ///
385    /// Byte 66 — where `kore_wa_sonzai_shinai` starts — is past the end of the
386    /// entry file (30 bytes) and past the end of `kotae.b` (21 bytes). So the
387    /// only file this span can honestly resolve against is this one, and the
388    /// other two now refuse to answer rather than walking off their ends.
389    const BAKUHATSU: &str = "\
390def tsukawanai_a()
391  1
392end
393
394def tsukawanai_b()
395  2
396end
397
398def bakuhatsu()
399  kore_wa_sonzai_shinai()
400end
401
402bakuhatsu()";
403
404    const KOTAE: &str = "def kotae()\n  42\nend";
405
406    /// The same failing call, but only ever reached by a CALLER in another
407    /// file — the shape that exercises `RunError::Eval`'s stated limit.
408    const BAKUHATSU2: &str = "def yobu()\n  kore_wa_sonzai_shinai()\nend";
409
410    fn loader() -> MemLoader {
411        MemLoader(BTreeMap::from([
412            ("kotae", KOTAE),
413            ("bakuhatsu", BAKUHATSU),
414            ("bakuhatsu2", BAKUHATSU2),
415        ]))
416    }
417
418    fn run_named(path: &str, src: &str) -> RunError {
419        run_in_surface(
420            Entry {
421                path: Some(Path::new(path)),
422                text: src,
423            },
424            Inputs::new(),
425            &loader(),
426            None,
427        )
428        .expect_err("the fixture must raise")
429    }
430
431    /// **The load-bearing gate: a raise inside an IMPORTED package reports the
432    /// IMPORTED file's path and line.**
433    ///
434    /// Hand-computed. In `bakuhatsu.b` the failing call sits on line 10 at
435    /// column 3, and the whole point of the fixture's height is that no other
436    /// file in the program can produce that pair: the entry file has 2 lines
437    /// and `kotae.b` has 3, so *any* mis-attribution is visible as a different
438    /// path AND a different position, never as a coincidence.
439    ///
440    /// **Three red runs, one per edit this change makes** — each isolates a
441    /// different half of the mechanism, which is what stops the gate being a
442    /// tautology over "some renderer ran".
443    ///
444    /// 1. `eval_program` + `.map_err(|e| RunError::Eval(e.to_string()))`
445    ///    restored (the pre-change code):
446    ///    ```text
447    ///    left:  "runtime error: unbound symbol: kore_wa_sonzai_shinai at 74..95"
448    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
449    ///    ```
450    ///    No place at all — the state this change is fixing — and the trailing
451    ///    `at 74..95` is upstream's `Display`, an offset with no file, which is
452    ///    why the loop renders `short_message` instead.
453    ///
454    /// 2. The loop kept, but `0` passed to `locate` in place of `top_level`:
455    ///    ```text
456    ///    left:  "runtime error: kotae.b: unbound symbol `kore_wa_sonzai_shinai`"
457    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
458    ///    ```
459    ///    **This is the one that proves the INDEX is load-bearing** rather than
460    ///    the renderer: form 0 belongs to `kotae.b`, so the wrong file is named
461    ///    — and named without a position, because the guard in `locate` sees
462    ///    that byte 74 is not a range in a 21-byte file and declines to invent
463    ///    one. A fixture with one imported package could not tell these apart.
464    ///
465    /// 3. `crate::lower_to_spanned(&crate::to_sexps(&erased))` reinstated
466    ///    between erasure and the loop:
467    ///    ```text
468    ///    left:  "runtime error: bakuhatsu.b: unbound symbol `kore_wa_sonzai_shinai`"
469    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
470    ///    ```
471    ///    The right FILE with no position — which is exactly the shape of the
472    ///    bug: the index survived the lift and every span did not.
473    #[test]
474    fn a_raise_inside_an_imported_package_names_that_package() {
475        let err = run_named("entry.b", "use(\"kotae\")\nuse(\"bakuhatsu\")\n");
476        assert_eq!(
477            err.to_string(),
478            "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
479        );
480    }
481
482    /// Anti-vacuity for the fixture itself: the position asserted above is a
483    /// FACT ABOUT `bakuhatsu.b`, checked against the source text rather than
484    /// against the thing that produced it.
485    ///
486    /// Without this, `10:3` is just a number that happened to come out of the
487    /// code under test, and a change that moved every reported line by one
488    /// would move this assertion with it.
489    #[test]
490    fn line_10_column_3_of_the_fixture_is_the_failing_call() {
491        let line = BAKUHATSU.lines().nth(9).expect("line 10 exists");
492        assert_eq!(line, "  kore_wa_sonzai_shinai()");
493        assert_eq!(
494            &line[2..],
495            "kore_wa_sonzai_shinai()",
496            "column 3 (1-indexed) is where the call starts"
497        );
498        // And the other two files cannot reach that far, which is what makes a
499        // mis-attribution loud instead of plausible.
500        let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
501        assert!(
502            offset > KOTAE.len(),
503            "kotae.b could answer for byte {offset}"
504        );
505        assert!(
506            offset > "use(\"kotae\")\nuse(\"bakuhatsu\")\n".len(),
507            "the entry file could answer for byte {offset}"
508        );
509    }
510
511    /// **The `FileId(0)` path: a raise in the ENTRY file reports the entry
512    /// file.** The single-file case, which is every program that imports
513    /// nothing — and the one where the index and the span cannot disagree.
514    ///
515    /// **Red run** (2026-08-12), `eval_program` +
516    /// `.map_err(|e| RunError::Eval(e.to_string()))` restored:
517    /// ```text
518    /// left:  "runtime error: unbound symbol: nani_mo_nai at 17..28"
519    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
520    /// ```
521    ///
522    /// **Second red run**, `lower_to_spanned` reinstated after erasure — the
523    /// one that isolates the SPAN half from the index half, since a
524    /// single-file program cannot get its file wrong:
525    /// ```text
526    /// left:  "runtime error: honmono.b: unbound symbol `nani_mo_nai`"
527    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
528    /// ```
529    #[test]
530    fn a_raise_in_the_entry_file_names_the_entry_file() {
531        let err = run_named("honmono.b", "def f()\n  1\nend\n\nnani_mo_nai()\n");
532        assert_eq!(
533            err.to_string(),
534            "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
535        );
536    }
537
538    /// A raise from a form that came through the SURFACE-level erasure still
539    /// reports a real position — the annotated path, where erasure rewrites
540    /// the node rather than passing it through.
541    ///
542    /// Separate from the two above because the erasure rewrite is the only
543    /// place a node is built rather than kept, and a synthetic span there
544    /// would be invisible to a fixture whose failing form is untouched.
545    ///
546    /// **Red run** (2026-08-12), `lower_to_spanned` reinstated after erasure:
547    /// ```text
548    /// left:  "runtime error: chuu.b: unbound symbol `mada_nai`"
549    /// right: "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
550    /// ```
551    #[test]
552    fn a_raise_inside_an_annotated_def_still_names_its_line() {
553        let err = run_named(
554            "chuu.b",
555            "def f(a: Int) -> Int\n  mada_nai(a)\nend\n\nf(1)\n",
556        );
557        assert_eq!(
558            err.to_string(),
559            "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
560        );
561    }
562
563    /// **The stated limit, pinned rather than left to be discovered.**
564    ///
565    /// A top-level form in the entry file calls a function defined in an
566    /// imported one, and the raise happens inside the callee. The index names
567    /// the executing form (the entry's), the span names the callee's bytes,
568    /// and the two disagree about the file. `locate`'s in-range guard is what
569    /// keeps that from rendering as a precise-looking `entry:line:col`: the
570    /// offset is not a range in the entry file, so no position is printed.
571    ///
572    /// This test asserts the CURRENT behaviour, including the part that is
573    /// wrong — the path is the caller's. It is here so the next author reads
574    /// the limit off a green suite instead of off a bug report. Closing it
575    /// needs per-frame file identity at the evaluator; see `RunError::Eval`.
576    ///
577    /// **Red run** (2026-08-12), the `span.end <= file.text.len()` guard
578    /// removed from `uses::ResolvedProgram::locate` — and it is the ONLY test
579    /// in the crate that moves, which is the same run's evidence that the
580    /// guard is a no-op on every check-time diagnostic:
581    /// ```text
582    /// left:  "runtime error: yobidashi.b:1:14: unbound symbol `kore_wa_sonzai_shinai`"
583    /// right: "runtime error: yobidashi.b: unbound symbol `kore_wa_sonzai_shinai`"
584    /// ```
585    /// `1:14` lands in the middle of `use("bakuhatsu2")` — the callee's start
586    /// offset happens to be in range for the caller's file even though its end
587    /// is not, so a wrong file gets a precise number pointing at innocent
588    /// code. Exactly the failure this repo's file-identity work exists to
589    /// refuse, which is why the guard tests BOTH ends.
590    #[test]
591    fn a_raise_in_a_callee_from_another_file_reports_no_position_rather_than_a_wrong_one() {
592        let err = run_named("yobidashi.b", "use(\"bakuhatsu2\")\nyobu()\n");
593        assert_eq!(
594            err.to_string(),
595            "runtime error: yobidashi.b: unbound symbol `kore_wa_sonzai_shinai`",
596            "the caller's file is named (the stated limit) but NOT with a \
597             position it cannot justify"
598        );
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn int(src: &str) -> i64 {
607        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
608            Value::Int(v) => v,
609            other => panic!("{src:?} produced {other:?}"),
610        }
611    }
612
613    /// **The sliding scale, as one assertion.** Annotating changes the
614    /// analysis and nothing else.
615    #[test]
616    fn annotating_buys_analysis_and_changes_nothing_else() {
617        let plain = run("def add(a, b)\n  a + b\nend\nadd(2, 3)").expect("plain");
618        let typed = run("def add(a: Int, b: Int) -> Int\n  a + b\nend\nadd(2, 3)").expect("typed");
619
620        assert!(matches!(plain.value, Value::Int(5)));
621        assert!(
622            matches!(typed.value, Value::Int(5)),
623            "the annotated program must compute the same answer"
624        );
625        assert_eq!(plain.visited, 0, "no annotations means no analysis");
626        assert!(
627            typed.visited > 0,
628            "an annotation must actually buy analysis, not just decorate"
629        );
630        assert_eq!(plain.typed_decls, 0);
631        assert_eq!(typed.typed_decls, 1);
632    }
633
634    /// **Checking happens before erasure.** This is the test that catches the
635    /// reordering: a program with a declared-type violation must be rejected,
636    /// and it can only be rejected while the annotations still exist.
637    #[test]
638    fn a_type_error_is_reported_and_the_program_does_not_run() {
639        let err = run("def add(a: Int, b: Int) -> Str\n  a + b\nend\nadd(1, 2)")
640            .expect_err("a declared Str return from an Int body must be rejected");
641        assert!(
642            matches!(err, RunError::Types(ref d) if !d.is_empty()),
643            "expected type diagnostics, got {err}"
644        );
645    }
646
647    /// And the untyped version of the same program runs, so the rejection
648    /// above is the annotation's doing rather than a parse failure.
649    #[test]
650    fn the_same_program_without_annotations_runs() {
651        assert_eq!(int("def add(a, b)\n  a + b\nend\nadd(1, 2)"), 3);
652    }
653
654    #[test]
655    fn a_parse_error_is_reported_as_one() {
656        assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
657    }
658
659    /// Every stage reports in its own vocabulary, so a failure names which
660    /// stage failed rather than surfacing as a generic error.
661    #[test]
662    fn a_runtime_error_is_reported_as_one() {
663        let err = run("no_such_function(1)").expect_err("unbound");
664        assert!(matches!(err, RunError::Eval(_)), "got {err}");
665    }
666
667    /// Stdlib and primitives are both reachable through the pipeline — the
668    /// gap that made `6 % 3` fail.
669    #[test]
670    fn the_pipeline_reaches_both_runtime_layers() {
671        assert_eq!(int("6 % 3"), 0);
672        assert_eq!(int("7 % 3"), 1);
673        assert_eq!(int("2 + 3 * 4"), 14);
674    }
675
676    /// **The deleted hop was a no-op on everything blue emits — so removing it
677    /// is a swap, not a behaviour change.**
678    ///
679    /// The old lowering printed the erased tree and read it back through
680    /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
681    /// paths land on the same tree, which is the equivalence the swap rests on.
682    /// It is stated as a *measurement over this corpus*, not as a theorem:
683    /// the round trip is not identity in general (that is precisely why it had
684    /// to go), it merely happened to be identity for the bytes blue emits.
685    #[test]
686    fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
687        let corpus = [
688            "def add(a, b)\n  a + b\nend\nadd(2, 3)",
689            "def fact(n)\n  if n < 2\n    1\n  else\n    n * fact(n - 1)\n  end\nend\nfact(5)",
690            "def f(a, b)\n  c = a + b\n  c * 2\nend\nf(1, 2)",
691            "defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)",
692            "\"a string with spaces, a ( and a )\"",
693            "def g(a: Int) -> Int\n  a + 1\nend\ng(1)",
694            "6 % 3",
695            "1.5 + 2.25",
696        ];
697        for src in corpus {
698            // Projected to `Sexp` because that is what the deleted hop
699            // operated on. Erasure itself no longer goes near it.
700            let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
701
702            let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
703                .iter()
704                .map(tatara_lisp::Spanned::to_sexp)
705                .collect();
706            assert_eq!(direct, erased, "the direct lowering must be the identity");
707
708            let text = erased
709                .iter()
710                .map(ToString::to_string)
711                .collect::<Vec<_>>()
712                .join("\n");
713            let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
714                .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
715                .iter()
716                .map(tatara_lisp::Spanned::to_sexp)
717                .collect();
718            assert_eq!(
719                round_tripped, erased,
720                "{src:?}: the old print-and-reparse path changed the tree"
721            );
722        }
723    }
724
725    /// Anti-vacuity for the test above: the round trip really is *not* the
726    /// identity in general, so agreeing on the corpus was a property of what
727    /// blue happens to emit rather than a property of the reader.
728    ///
729    /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
730    /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
731    /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
732    /// domain, and the failure is *silent*: a symbol containing a space prints
733    /// as two tokens, reads back as two symbols, and the result is a perfectly
734    /// well-formed tree with a different meaning. No error, nothing to catch.
735    ///
736    /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
737    /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
738    /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
739    /// in and one symbol out, so the brace-fusion reported in tatara *source*
740    /// is not what bites a printed tree. The silent pair is what makes this a
741    /// miscompile class rather than a noisy one.
742    #[test]
743    fn the_round_trip_is_not_the_identity_in_general() {
744        let tree = Sexp::List(vec![
745            Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
746            Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
747        ]);
748        let text = tree.to_string();
749        let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
750            .expect("it reads back cleanly — that IS the problem")
751            .iter()
752            .map(tatara_lisp::Spanned::to_sexp)
753            .collect();
754        assert_ne!(
755            back,
756            vec![tree.clone()],
757            "if print-then-read became inverse over symbols, the class would be \
758             closed upstream and this test should be deleted rather than relaxed"
759        );
760        // …and the direct lowering is unaffected by any of it.
761        let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
762            .iter()
763            .map(tatara_lisp::Spanned::to_sexp)
764            .collect();
765        assert_eq!(direct, vec![tree]);
766    }
767}
768
769#[cfg(test)]
770mod macro_tests {
771    use super::*;
772
773    fn int(src: &str) -> i64 {
774        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
775            Value::Int(v) => v,
776            other => panic!("{src:?} produced {other:?}"),
777        }
778    }
779
780    /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
781    #[test]
782    fn a_macro_expands_and_runs() {
783        assert_eq!(
784            int("defmacro double(x)\n  quote\n    unquote(x) + unquote(x)\n  end\nend\ndouble(21)"),
785            42
786        );
787    }
788
789    /// A macro receives *source forms*, not values — so it can duplicate its
790    /// argument, which a function cannot do without re-evaluating it.
791    #[test]
792    fn a_macro_operates_on_syntax_not_values() {
793        assert_eq!(
794            int("defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)"),
795            25,
796            "the argument form `2 + 3` must be substituted twice"
797        );
798    }
799
800    /// **A runaway macro is a typed error, not a dead compiler.** This is the
801    /// property that makes the metaprogramming surface safe to hand to a user.
802    ///
803    /// The assertion was `contains("expansion limit")` and now reads
804    /// `contains("expansion")`, because the pipeline renders `short_message`
805    /// rather than `Display`: upstream words the same fact as "exceeded 256
806    /// expansion steps" instead of "exceeded the expansion limit of 256
807    /// rewrites". The PROPERTY is unchanged and still asserted — the macro is
808    /// named, and the failure is attributed to expansion rather than to
809    /// evaluation. **This reword was the whole measured blast radius of that
810    /// switch**, one test in the workspace.
811    ///
812    /// The number is deliberately NOT pinned. It is upstream's constant, this
813    /// test owns none of it, and matching it would turn an upstream bump into
814    /// a red run here that proves nothing about blue.
815    ///
816    /// The third clause is new capability rather than repair: the message now
817    /// carries `<anonymous>:6:1`, so a runaway macro says WHERE it ran away.
818    #[test]
819    fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
820        let err =
821            run("defmacro forever(x)\n  quote\n    forever(unquote(x))\n  end\nend\nforever(1)")
822                .expect_err("a self-referential macro must be rejected");
823        let msg = err.to_string();
824        assert!(
825            msg.contains("forever") && msg.contains("expansion"),
826            "the error must name the macro and the limit it hit: {msg}"
827        );
828        assert!(
829            msg.contains("<anonymous>:6:1"),
830            "and must place the call that ran away — line 6 is `forever(1)`: {msg}"
831        );
832    }
833}
834
835#[cfg(test)]
836mod input_tests {
837    use super::*;
838    use crate::inputs::{Declaration, Inputs};
839
840    /// A schema a macro will generate code from.
841    const SCHEMA: &[u8] = b"3";
842
843    fn with_schema(src: &str) -> Result<Run, RunError> {
844        let hash = Inputs::hash_of(SCHEMA);
845        let mut inputs = Inputs::new();
846        inputs
847            .bind(
848                &Declaration {
849                    name: "schema".to_string(),
850                    hash,
851                },
852                SCHEMA.to_vec(),
853            )
854            .expect("bind");
855        run_with_inputs(src, inputs)
856    }
857
858    fn decl_line() -> String {
859        let mut s = String::from("definput(\"schema\", \"");
860        s.push_str(&Inputs::hash_of(SCHEMA));
861        s.push_str("\")\n");
862        s
863    }
864
865    /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
866    /// the spec names it as gating blue's whole "stronger than Ruby's
867    /// metaprogramming" claim, because a macro that cannot read a schema cannot
868    /// generate code from one.
869    #[test]
870    fn a_macro_can_read_a_declared_build_input() {
871        let src = decl_line() + "input(\"schema\")";
872        let out = with_schema(&src).expect("run");
873        assert!(
874            matches!(out.value, Value::Str(ref s) if &**s == "3"),
875            "got {:?}",
876            out.value
877        );
878    }
879
880    /// **An undeclared input is an error, not a file read and not nil.**
881    /// Returning nil is how a macro generates an empty table and nobody notices
882    /// until runtime.
883    #[test]
884    fn an_undeclared_input_is_an_error() {
885        let err = with_schema("input(\"not_declared\")").expect_err("must fail");
886        let msg = err.to_string();
887        assert!(msg.contains("not_declared"), "must name it: {msg}");
888        assert!(msg.contains("definput"), "and say how to declare it: {msg}");
889    }
890
891    /// **There is no path-based read at all.** The capability is the absence of
892    /// the primitive, not a check inside one — so this is an unbound symbol.
893    ///
894    /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
895    /// cargo feature (CLI only) is the one declared exception: it is the
896    /// operator's own trusted host surface, and is asserted in
897    /// `sys_read_file_is_the_trusted_cli_only_exception` below.
898    #[cfg(not(feature = "sys"))]
899    #[test]
900    fn there_is_no_ambient_file_read() {
901        for attempt in [
902            "read_file(\"/etc/passwd\")",
903            "File(\"/etc/passwd\")",
904            "slurp(\"/etc/passwd\")",
905            "open(\"/etc/passwd\")",
906        ] {
907            let err = with_schema(attempt).expect_err("must not resolve");
908            assert!(
909                err.to_string().contains("unbound"),
910                "{attempt} must be UNBOUND — a capability removed by absence, \
911                 not guarded by a check: {err}"
912            );
913        }
914    }
915
916    /// With the `sys` feature compiled in, `read_file` IS bound — that is the
917    /// point of the feature. The doctrine does not move: this is the operator's
918    /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
919    /// future default-build change is heard, and assert that `input()` still
920    /// works beside it.
921    #[cfg(feature = "sys")]
922    #[test]
923    fn sys_read_file_is_the_trusted_cli_only_exception() {
924        let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
925        assert!(err.to_string().contains("unbound"), "{err}");
926        assert!(
927            with_schema("read_file(\"/etc/passwd\")").is_ok(),
928            "with `sys` on, read_file is the trusted CLI surface"
929        );
930        let out = with_schema("input(\"schema\")").expect("run");
931        assert!(
932            matches!(out.value, Value::Str(ref s) if &**s == "3"),
933            "input() still binds beside the sys surface: {:?}",
934            out.value
935        );
936    }
937
938    /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
939    /// — so the success above is the binding's doing.
940    #[test]
941    fn a_declared_input_with_no_bytes_supplied_fails() {
942        let src = decl_line() + "input(\"schema\")";
943        assert!(run(&src).is_err(), "no bytes were supplied");
944    }
945}
946
947#[cfg(test)]
948mod tier2_tests {
949    use super::*;
950    use crate::inputs::{Declaration, Inputs};
951
952    /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
953    /// declarations FROM A SCHEMA.**
954    ///
955    /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
956    /// installs a `NoLoader`, so a macro cannot read a schema — which gates
957    /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
958    /// Ruby's metaprogramming' claim."
959    ///
960    /// Here the schema supplies a *value the generated code depends on*, read at
961    /// expansion time. Ruby and Elixir can both do this — with the whole
962    /// filesystem open. blue does it through a name bound to a content hash.
963    #[test]
964    fn a_macro_generates_code_from_a_schema() {
965        let schema = b"7";
966        let mut inputs = Inputs::new();
967        inputs
968            .bind(
969                &Declaration {
970                    name: "arity".to_string(),
971                    hash: Inputs::hash_of(schema),
972                },
973                schema.to_vec(),
974            )
975            .expect("bind");
976
977        // The macro reads the input at EXPANSION time and splices the value it
978        // found into the code it emits.
979        let mut src = String::from("definput(\"arity\", \"");
980        src.push_str(&Inputs::hash_of(schema));
981        src.push_str("\")\n");
982        src.push_str(
983            "defmacro from_schema()\n  quote\n    unquote(to_int(input(\"arity\")))\n  end\nend\n\
984             from_schema() * 6",
985        );
986
987        let out = run_with_inputs(&src, inputs).expect("run");
988        assert!(
989            matches!(out.value, Value::Int(42)),
990            "the schema's 7 must reach the generated code: got {:?}",
991            out.value
992        );
993    }
994}