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