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