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    #[error("runtime error: {0}")]
52    Eval(String),
53    /// A `use("name")` could not be resolved.
54    ///
55    /// Its own variant rather than folded into `Parse`, because the reader's
56    /// next action is different: a parse error is in the source in front of
57    /// them, an import error is in their packaging — a missing bidama, a
58    /// BLUE_PATH that does not contain it, or no loader at all.
59    #[error("import error: {0}")]
60    Import(String),
61}
62
63/// What a run produced, plus what the checker did on the way.
64#[derive(Debug)]
65pub struct Run {
66    pub value: Value,
67    /// Nodes the type walk visited. Zero for a fully untyped program — this
68    /// is what makes "no annotations, no analysis" a *measurement* rather
69    /// than a claim.
70    pub visited: usize,
71    /// Declarations that carried an annotation.
72    pub typed_decls: usize,
73    /// Boundaries where typed code meets untyped code.
74    pub seams: usize,
75}
76
77/// Parse blue source to tatara-lisp forms.
78pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
79    parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
80}
81
82/// [`parse`] with the parser's nesting bound supplied by the caller.
83///
84/// The bound exists so a stack overflow — which `catch_unwind` cannot catch —
85/// arrives as a typed `Err` instead. It is a *limit*, not a dialect: raising
86/// it changes no program's meaning, which is exactly why it is safe to expose
87/// as configuration (`blue-lang-cli`'s `config` module holds the rule).
88pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
89    blue_lang_syntax::parse_program_with_depth(src, max_depth)
90        .map_err(|e| RunError::Parse(e.to_string()))
91}
92
93/// [`parse_with_depth`] keeping **every node's** source span.
94///
95/// The door for anything that will report a position to a human. It exists here,
96/// beside the spanless one, so a caller that wants spans still parses under the
97/// CONFIGURED nesting bound — a separate `blue_lang_syntax` call would be the
98/// second door `parse_with_depth`'s own docs exist to prevent, with
99/// `max_expr_depth` true of some subcommands and not others.
100pub fn parse_tree_with_depth(
101    src: &str,
102    max_depth: usize,
103) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
104    blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
105        .map_err(|e| RunError::Parse(e.to_string()))
106}
107
108/// [`parse`] keeping **every node's** source span.
109///
110/// The spanned twin of [`parse`], at the same default bound — the door for
111/// anything downstream of a parse that will report a position, which since
112/// `use` learned to carry file identity is every path through
113/// [`run_in_surface`].
114pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
115    parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
116}
117
118/// Run blue source with no build inputs.
119pub fn run(src: &str) -> Result<Run, RunError> {
120    run_with_inputs(src, Inputs::new())
121}
122
123/// Run blue source, giving the macro phase access to verified build inputs.
124///
125/// `inputs` is already verified — [`Inputs`] cannot hold bytes that do not match
126/// their declared hash — so nothing here re-checks. The capability a macro gains
127/// is exactly "these hashed bytes", never a path.
128pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
129    run_with_loader(src, inputs, &crate::uses::NoLoader)
130}
131
132/// Run blue source with a loader, so `use("name")` can resolve.
133///
134/// Split from [`run_with_inputs`] rather than folded into it because loading a
135/// package reads a filesystem, and this crate has a `wasm32-unknown-unknown`
136/// consumer with zero host imports. The capability is injected by callers that
137/// have it — `blue_lang_pkg::LoadPath` is the real one — and absent by default,
138/// where a `use` is a typed error naming the package.
139pub fn run_with_loader(
140    src: &str,
141    inputs: Inputs,
142    loader: &dyn crate::uses::Loader,
143) -> Result<Run, RunError> {
144    run_in_surface(Entry::anonymous(src), inputs, loader, None)
145}
146
147/// Run blue source written in a `yakugo` surface.
148///
149/// The pack applies at PARSE time and nowhere else — by the time the checker
150/// sees the program it is canonical, so every stage below is identical whatever
151/// surface the author wrote in. That is what makes a surface a surface: it
152/// changes how a program is spelled and nothing about how it runs.
153///
154/// `entry` carries the source AND the file it was read from, because a type
155/// error has to be reported somewhere: a caller with a path should pass it, and
156/// one without ([`Entry::anonymous`]) gets diagnostics that say so rather than
157/// diagnostics that guess. Imported packages name themselves through the
158/// loader either way.
159///
160/// # Errors
161///
162/// As [`run_with_loader`].
163pub fn run_in_surface(
164    entry: Entry<'_>,
165    inputs: Inputs,
166    loader: &dyn crate::uses::Loader,
167    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
168) -> Result<Run, RunError> {
169    let forms = match surface {
170        Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
171            .map_err(|e| RunError::Parse(e.to_string()))?,
172        None => parse_tree(entry.text)?,
173    };
174
175    // RESOLVE imports first, so everything below sees ONE program.
176    //
177    // Before the check on purpose: imported code is type-checked at the point
178    // its consumer imports it, rather than at whatever later moment its code
179    // first runs. A package that does not typecheck should break its importer's
180    // build, not their production run.
181    //
182    // One program, but not one FILE: the result records which file each
183    // top-level form came from, which is what lets a diagnostic below name a
184    // place instead of only a problem.
185    let mut program = crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)?;
186
187    // `test` blocks are declarations for the harness, not code to run.
188    //
189    // Dropped here rather than in `resolve_uses`, because `blue test` calls
190    // the resolver and then NEEDS the entry file's blocks — so the two
191    // callers want different things and the split has to live at this level.
192    //
193    // Without this, `blue run` on a file containing its own tests fails with
194    // `unbound symbol: deftest`: every package in the bidama distribution
195    // carries tests, so every one of them was unrunnable.
196    //
197    // Through `retain`, which drops each form's owner with it. A plain filter
198    // over the forms alone would slide every later form onto the wrong file.
199    program.retain(|f| !crate::uses::is_test_form(f));
200
201    // CHECK, on the annotated tree — the only tree that has annotations.
202    //
203    // **On the REAL spanned tree, including every imported package's.** This
204    // was the one caller that checked a spanless lift, because `resolve_uses`
205    // flattened the entry file and its imports into one list and `Span` is a
206    // byte range with no file identity — so a real span here would have
207    // reported an imported package's error at that offset in the ENTRY file, a
208    // precise-looking answer pointing at unrelated code.
209    //
210    // The fix is not a wider `Span` (that type is upstream, and its own docs
211    // put file identity on the caller: spans "are meaningful only relative to
212    // the string that produced them, which the caller is responsible for
213    // holding onto"). blue holds onto it BESIDE the span, per top-level form —
214    // see `uses::ResolvedProgram`.
215    let outcome = blue_lang_check::check_program(program.forms());
216    if !outcome.ok() {
217        return Err(RunError::Types(
218            outcome
219                .diagnostics
220                .iter()
221                // `file:line:col: message`, resolved against the file the form
222                // actually came from. A typed `Display` builds it, per ★★ TYPED
223                // EMISSION — `locate` returns the renderer, not a string.
224                .map(|d| program.locate(d.top_level, d.span, &d.message).to_string())
225                .collect(),
226        ));
227    }
228
229    // ERASE, so the interpreter never sees a type.
230    //
231    // Spans are projected away here and stay away: a debugger frame from an
232    // imported package needs `erase_types` and `lower_to_spanned` to carry them
233    // through, which is a larger piece and is NOT built. What is fixed above is
234    // check-time positions.
235    let erased = erase_types(&program.sexps());
236
237    // LOWER to what the evaluator eats. This used to print the tree and read
238    // it back through `tatara_lisp::read_spanned` — a round trip through a
239    // lexer, over bytes blue had just written itself. See
240    // `crate::lower_to_spanned` for why that is a silent-miscompile path and
241    // not merely wasteful.
242    let spanned = crate::lower_to_spanned(&erased);
243
244    let mut interp = crate::interpreter_hostless();
245    crate::inputs::install_input_primitives(&mut interp, inputs);
246    let value = interp
247        .eval_program(&spanned, &mut ())
248        .map_err(|e| RunError::Eval(e.to_string()))?;
249
250    Ok(Run {
251        value,
252        visited: outcome.stats.visited,
253        typed_decls: outcome.stats.typed_decls,
254        seams: outcome.seams.len(),
255    })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn int(src: &str) -> i64 {
263        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
264            Value::Int(v) => v,
265            other => panic!("{src:?} produced {other:?}"),
266        }
267    }
268
269    /// **The sliding scale, as one assertion.** Annotating changes the
270    /// analysis and nothing else.
271    #[test]
272    fn annotating_buys_analysis_and_changes_nothing_else() {
273        let plain = run("def add(a, b)\n  a + b\nend\nadd(2, 3)").expect("plain");
274        let typed = run("def add(a: Int, b: Int) -> Int\n  a + b\nend\nadd(2, 3)").expect("typed");
275
276        assert!(matches!(plain.value, Value::Int(5)));
277        assert!(
278            matches!(typed.value, Value::Int(5)),
279            "the annotated program must compute the same answer"
280        );
281        assert_eq!(plain.visited, 0, "no annotations means no analysis");
282        assert!(
283            typed.visited > 0,
284            "an annotation must actually buy analysis, not just decorate"
285        );
286        assert_eq!(plain.typed_decls, 0);
287        assert_eq!(typed.typed_decls, 1);
288    }
289
290    /// **Checking happens before erasure.** This is the test that catches the
291    /// reordering: a program with a declared-type violation must be rejected,
292    /// and it can only be rejected while the annotations still exist.
293    #[test]
294    fn a_type_error_is_reported_and_the_program_does_not_run() {
295        let err = run("def add(a: Int, b: Int) -> Str\n  a + b\nend\nadd(1, 2)")
296            .expect_err("a declared Str return from an Int body must be rejected");
297        assert!(
298            matches!(err, RunError::Types(ref d) if !d.is_empty()),
299            "expected type diagnostics, got {err}"
300        );
301    }
302
303    /// And the untyped version of the same program runs, so the rejection
304    /// above is the annotation's doing rather than a parse failure.
305    #[test]
306    fn the_same_program_without_annotations_runs() {
307        assert_eq!(int("def add(a, b)\n  a + b\nend\nadd(1, 2)"), 3);
308    }
309
310    #[test]
311    fn a_parse_error_is_reported_as_one() {
312        assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
313    }
314
315    /// Every stage reports in its own vocabulary, so a failure names which
316    /// stage failed rather than surfacing as a generic error.
317    #[test]
318    fn a_runtime_error_is_reported_as_one() {
319        let err = run("no_such_function(1)").expect_err("unbound");
320        assert!(matches!(err, RunError::Eval(_)), "got {err}");
321    }
322
323    /// Stdlib and primitives are both reachable through the pipeline — the
324    /// gap that made `6 % 3` fail.
325    #[test]
326    fn the_pipeline_reaches_both_runtime_layers() {
327        assert_eq!(int("6 % 3"), 0);
328        assert_eq!(int("7 % 3"), 1);
329        assert_eq!(int("2 + 3 * 4"), 14);
330    }
331
332    /// **The deleted hop was a no-op on everything blue emits — so removing it
333    /// is a swap, not a behaviour change.**
334    ///
335    /// The old lowering printed the erased tree and read it back through
336    /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
337    /// paths land on the same tree, which is the equivalence the swap rests on.
338    /// It is stated as a *measurement over this corpus*, not as a theorem:
339    /// the round trip is not identity in general (that is precisely why it had
340    /// to go), it merely happened to be identity for the bytes blue emits.
341    #[test]
342    fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
343        let corpus = [
344            "def add(a, b)\n  a + b\nend\nadd(2, 3)",
345            "def fact(n)\n  if n < 2\n    1\n  else\n    n * fact(n - 1)\n  end\nend\nfact(5)",
346            "def f(a, b)\n  c = a + b\n  c * 2\nend\nf(1, 2)",
347            "defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)",
348            "\"a string with spaces, a ( and a )\"",
349            "def g(a: Int) -> Int\n  a + 1\nend\ng(1)",
350            "6 % 3",
351            "1.5 + 2.25",
352        ];
353        for src in corpus {
354            let erased = erase_types(&parse(src).expect("parse"));
355
356            let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
357                .iter()
358                .map(tatara_lisp::Spanned::to_sexp)
359                .collect();
360            assert_eq!(direct, erased, "the direct lowering must be the identity");
361
362            let text = erased
363                .iter()
364                .map(ToString::to_string)
365                .collect::<Vec<_>>()
366                .join("\n");
367            let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
368                .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
369                .iter()
370                .map(tatara_lisp::Spanned::to_sexp)
371                .collect();
372            assert_eq!(
373                round_tripped, erased,
374                "{src:?}: the old print-and-reparse path changed the tree"
375            );
376        }
377    }
378
379    /// Anti-vacuity for the test above: the round trip really is *not* the
380    /// identity in general, so agreeing on the corpus was a property of what
381    /// blue happens to emit rather than a property of the reader.
382    ///
383    /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
384    /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
385    /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
386    /// domain, and the failure is *silent*: a symbol containing a space prints
387    /// as two tokens, reads back as two symbols, and the result is a perfectly
388    /// well-formed tree with a different meaning. No error, nothing to catch.
389    ///
390    /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
391    /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
392    /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
393    /// in and one symbol out, so the brace-fusion reported in tatara *source*
394    /// is not what bites a printed tree. The silent pair is what makes this a
395    /// miscompile class rather than a noisy one.
396    #[test]
397    fn the_round_trip_is_not_the_identity_in_general() {
398        let tree = Sexp::List(vec![
399            Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
400            Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
401        ]);
402        let text = tree.to_string();
403        let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
404            .expect("it reads back cleanly — that IS the problem")
405            .iter()
406            .map(tatara_lisp::Spanned::to_sexp)
407            .collect();
408        assert_ne!(
409            back,
410            vec![tree.clone()],
411            "if print-then-read became inverse over symbols, the class would be \
412             closed upstream and this test should be deleted rather than relaxed"
413        );
414        // …and the direct lowering is unaffected by any of it.
415        let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
416            .iter()
417            .map(tatara_lisp::Spanned::to_sexp)
418            .collect();
419        assert_eq!(direct, vec![tree]);
420    }
421}
422
423#[cfg(test)]
424mod macro_tests {
425    use super::*;
426
427    fn int(src: &str) -> i64 {
428        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
429            Value::Int(v) => v,
430            other => panic!("{src:?} produced {other:?}"),
431        }
432    }
433
434    /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
435    #[test]
436    fn a_macro_expands_and_runs() {
437        assert_eq!(
438            int("defmacro double(x)\n  quote\n    unquote(x) + unquote(x)\n  end\nend\ndouble(21)"),
439            42
440        );
441    }
442
443    /// A macro receives *source forms*, not values — so it can duplicate its
444    /// argument, which a function cannot do without re-evaluating it.
445    #[test]
446    fn a_macro_operates_on_syntax_not_values() {
447        assert_eq!(
448            int("defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)"),
449            25,
450            "the argument form `2 + 3` must be substituted twice"
451        );
452    }
453
454    /// **A runaway macro is a typed error, not a dead compiler.** This is the
455    /// property that makes the metaprogramming surface safe to hand to a user.
456    #[test]
457    fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
458        let err =
459            run("defmacro forever(x)\n  quote\n    forever(unquote(x))\n  end\nend\nforever(1)")
460                .expect_err("a self-referential macro must be rejected");
461        let msg = err.to_string();
462        assert!(
463            msg.contains("forever") && msg.contains("expansion limit"),
464            "the error must name the macro and the limit: {msg}"
465        );
466    }
467}
468
469#[cfg(test)]
470mod input_tests {
471    use super::*;
472    use crate::inputs::{Declaration, Inputs};
473
474    /// A schema a macro will generate code from.
475    const SCHEMA: &[u8] = b"3";
476
477    fn with_schema(src: &str) -> Result<Run, RunError> {
478        let hash = Inputs::hash_of(SCHEMA);
479        let mut inputs = Inputs::new();
480        inputs
481            .bind(
482                &Declaration {
483                    name: "schema".to_string(),
484                    hash,
485                },
486                SCHEMA.to_vec(),
487            )
488            .expect("bind");
489        run_with_inputs(src, inputs)
490    }
491
492    fn decl_line() -> String {
493        let mut s = String::from("definput(\"schema\", \"");
494        s.push_str(&Inputs::hash_of(SCHEMA));
495        s.push_str("\")\n");
496        s
497    }
498
499    /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
500    /// the spec names it as gating blue's whole "stronger than Ruby's
501    /// metaprogramming" claim, because a macro that cannot read a schema cannot
502    /// generate code from one.
503    #[test]
504    fn a_macro_can_read_a_declared_build_input() {
505        let src = decl_line() + "input(\"schema\")";
506        let out = with_schema(&src).expect("run");
507        assert!(
508            matches!(out.value, Value::Str(ref s) if &**s == "3"),
509            "got {:?}",
510            out.value
511        );
512    }
513
514    /// **An undeclared input is an error, not a file read and not nil.**
515    /// Returning nil is how a macro generates an empty table and nobody notices
516    /// until runtime.
517    #[test]
518    fn an_undeclared_input_is_an_error() {
519        let err = with_schema("input(\"not_declared\")").expect_err("must fail");
520        let msg = err.to_string();
521        assert!(msg.contains("not_declared"), "must name it: {msg}");
522        assert!(msg.contains("definput"), "and say how to declare it: {msg}");
523    }
524
525    /// **There is no path-based read at all.** The capability is the absence of
526    /// the primitive, not a check inside one — so this is an unbound symbol.
527    ///
528    /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
529    /// cargo feature (CLI only) is the one declared exception: it is the
530    /// operator's own trusted host surface, and is asserted in
531    /// `sys_read_file_is_the_trusted_cli_only_exception` below.
532    #[cfg(not(feature = "sys"))]
533    #[test]
534    fn there_is_no_ambient_file_read() {
535        for attempt in [
536            "read_file(\"/etc/passwd\")",
537            "File(\"/etc/passwd\")",
538            "slurp(\"/etc/passwd\")",
539            "open(\"/etc/passwd\")",
540        ] {
541            let err = with_schema(attempt).expect_err("must not resolve");
542            assert!(
543                err.to_string().contains("unbound"),
544                "{attempt} must be UNBOUND — a capability removed by absence, \
545                 not guarded by a check: {err}"
546            );
547        }
548    }
549
550    /// With the `sys` feature compiled in, `read_file` IS bound — that is the
551    /// point of the feature. The doctrine does not move: this is the operator's
552    /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
553    /// future default-build change is heard, and assert that `input()` still
554    /// works beside it.
555    #[cfg(feature = "sys")]
556    #[test]
557    fn sys_read_file_is_the_trusted_cli_only_exception() {
558        let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
559        assert!(err.to_string().contains("unbound"), "{err}");
560        assert!(
561            with_schema("read_file(\"/etc/passwd\")").is_ok(),
562            "with `sys` on, read_file is the trusted CLI surface"
563        );
564        let out = with_schema("input(\"schema\")").expect("run");
565        assert!(
566            matches!(out.value, Value::Str(ref s) if &**s == "3"),
567            "input() still binds beside the sys surface: {:?}",
568            out.value
569        );
570    }
571
572    /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
573    /// — so the success above is the binding's doing.
574    #[test]
575    fn a_declared_input_with_no_bytes_supplied_fails() {
576        let src = decl_line() + "input(\"schema\")";
577        assert!(run(&src).is_err(), "no bytes were supplied");
578    }
579}
580
581#[cfg(test)]
582mod tier2_tests {
583    use super::*;
584    use crate::inputs::{Declaration, Inputs};
585
586    /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
587    /// declarations FROM A SCHEMA.**
588    ///
589    /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
590    /// installs a `NoLoader`, so a macro cannot read a schema — which gates
591    /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
592    /// Ruby's metaprogramming' claim."
593    ///
594    /// Here the schema supplies a *value the generated code depends on*, read at
595    /// expansion time. Ruby and Elixir can both do this — with the whole
596    /// filesystem open. blue does it through a name bound to a content hash.
597    #[test]
598    fn a_macro_generates_code_from_a_schema() {
599        let schema = b"7";
600        let mut inputs = Inputs::new();
601        inputs
602            .bind(
603                &Declaration {
604                    name: "arity".to_string(),
605                    hash: Inputs::hash_of(schema),
606                },
607                schema.to_vec(),
608            )
609            .expect("bind");
610
611        // The macro reads the input at EXPANSION time and splices the value it
612        // found into the code it emits.
613        let mut src = String::from("definput(\"arity\", \"");
614        src.push_str(&Inputs::hash_of(schema));
615        src.push_str("\")\n");
616        src.push_str(
617            "defmacro from_schema()\n  quote\n    unquote(to_int(input(\"arity\")))\n  end\nend\n\
618             from_schema() * 6",
619        );
620
621        let out = run_with_inputs(&src, inputs).expect("run");
622        assert!(
623            matches!(out.value, Value::Int(42)),
624            "the schema's 7 must reach the generated code: got {:?}",
625            out.value
626        );
627    }
628}