blue_lang_runtime/pipeline.rs
1//! The blue pipeline: **parse → check → erase → run**, in that order, once.
2//!
3//! The order is the whole reason this module exists. Each stage is available
4//! separately for tools that want one, but the *default* path is a single
5//! function, because two of the four orderings are silently wrong:
6//!
7//! - **Erase before check** discards every annotation, so a program with type
8//! errors passes. The checker sees `(define …)` and has nothing to check.
9//! - **Run before check** reports a type error after the side effects.
10//!
11//! Neither fails loudly. Both produce a green run on a program that should
12//! have been rejected. Leaving the order to each caller means every caller
13//! is one reordering away from turning the type checker off — so the order
14//! lives here, and callers ask for a *result*, not a sequence of steps.
15
16use tatara_lisp::Sexp;
17use tatara_lisp_eval::Value;
18
19use blue_lang_check::{NameTable, Namespace};
20
21use crate::erase::erase_types;
22use crate::inputs::Inputs;
23use crate::uses::Entry;
24
25/// Why a run stopped short.
26#[derive(Debug, thiserror::Error)]
27pub enum RunError {
28 #[error("parse error: {0}")]
29 Parse(String),
30 /// The check stage rejected the program — a type error, an unbound name,
31 /// or any other error-severity rule in `blue_lang_check::RULES`. Carries
32 /// every error, not just the first: a caller fixing one wants to see the
33 /// rest. (The variant keeps its first name because it is public API on a
34 /// released crate.)
35 ///
36 /// Each one is already rendered `file:line:col: message` against the file
37 /// the offending form came from — which, in a program with imports, is
38 /// frequently not the file the user named. The rendering happens here
39 /// rather than at the consumer because here is where the file table exists;
40 /// see `uses::ResolvedProgram::locate`.
41 #[error("{} error(s) in the check stage:\n{}", .0.len(), .0.join("\n"))]
42 Types(Vec<String>),
43 /// **No longer reachable, and that is the point.** This reported "blue
44 /// emitted a tree the reader could not read back" — a failure only a
45 /// print-then-reparse hop could have. [`crate::lower_to_spanned`] deleted
46 /// the hop, so there is nothing left to fail: the tree the evaluator gets
47 /// IS the tree erasure produced, not a re-reading of its text.
48 ///
49 /// Kept rather than removed because it is public API on a released crate
50 /// and a consumer may still match on it, per ★★ MODULARIZE, DON'T DELETE.
51 /// It is retired, not orphaned — if a future stage ever serialises again
52 /// it has a typed home. **Nothing constructs it today**; do not read its
53 /// presence as evidence the pipeline can still fail this way.
54 #[error("the emitted tatara-lisp could not be read back: {0}")]
55 Lower(String),
56 /// The program ran and raised.
57 ///
58 /// Rendered `file:line:col: message`, then the blue call stack the error
59 /// unwound through, innermost first (`crate::observe::Fault`). The
60 /// position is resolved against the file of the innermost frame's
61 /// definition, so a raise inside a callee from another file names the
62 /// callee's file and line; before the stack existed that case could only
63 /// name the caller's file, with no position.
64 #[error("runtime error: {0}")]
65 Eval(Box<crate::observe::Fault>),
66 /// A `use("name")` could not be resolved.
67 ///
68 /// Its own variant rather than folded into `Parse`, because the reader's
69 /// next action is different: a parse error is in the source in front of
70 /// them, an import error is in their packaging — a missing bidama, a
71 /// BLUE_PATH that does not contain it, or no loader at all.
72 #[error("import error: {0}")]
73 Import(String),
74}
75
76/// What a run produced, plus what the checker did on the way.
77#[derive(Debug)]
78pub struct Run {
79 pub value: Value,
80 /// Nodes the type walk visited. Zero for a fully untyped program — this
81 /// is what makes "no annotations, no analysis" a *measurement* rather
82 /// than a claim.
83 pub visited: usize,
84 /// Declarations that carried an annotation.
85 pub typed_decls: usize,
86 /// Boundaries where typed code meets untyped code.
87 pub seams: usize,
88}
89
90/// Parse blue source to tatara-lisp forms.
91pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
92 parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
93}
94
95/// [`parse`] with the parser's nesting bound supplied by the caller.
96///
97/// The bound exists so a stack overflow — which `catch_unwind` cannot catch —
98/// arrives as a typed `Err` instead. It is a *limit*, not a dialect: raising
99/// it changes no program's meaning, which is exactly why it is safe to expose
100/// as configuration (`blue-lang-cli`'s `config` module holds the rule).
101pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
102 blue_lang_syntax::parse_program_with_depth(src, max_depth)
103 .map_err(|e| RunError::Parse(e.to_string()))
104}
105
106/// [`parse_with_depth`] keeping **every node's** source span.
107///
108/// The door for anything that will report a position to a human. It exists here,
109/// beside the spanless one, so a caller that wants spans still parses under the
110/// CONFIGURED nesting bound — a separate `blue_lang_syntax` call would be the
111/// second door `parse_with_depth`'s own docs exist to prevent, with
112/// `max_expr_depth` true of some subcommands and not others.
113pub fn parse_tree_with_depth(
114 src: &str,
115 max_depth: usize,
116) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
117 blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
118 .map_err(|e| RunError::Parse(e.to_string()))
119}
120
121/// [`parse`] keeping **every node's** source span.
122///
123/// The spanned twin of [`parse`], at the same default bound — the door for
124/// anything downstream of a parse that will report a position, which since
125/// `use` learned to carry file identity is every path through
126/// [`run_in_surface`].
127pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
128 parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
129}
130
131/// Run blue source with no build inputs.
132pub fn run(src: &str) -> Result<Run, RunError> {
133 run_with_inputs(src, Inputs::new())
134}
135
136/// Run blue source, giving the macro phase access to verified build inputs.
137///
138/// `inputs` is already verified — [`Inputs`] cannot hold bytes that do not match
139/// their declared hash — so nothing here re-checks. The capability a macro gains
140/// is exactly "these hashed bytes", never a path.
141pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
142 run_with_loader(src, inputs, &crate::uses::NoLoader)
143}
144
145/// Run blue source with a loader, so `use("name")` can resolve.
146///
147/// Split from [`run_with_inputs`] rather than folded into it because loading a
148/// package reads a filesystem, and this crate has a `wasm32-unknown-unknown`
149/// consumer with zero host imports. The capability is injected by callers that
150/// have it — `blue_lang_pkg::LoadPath` is the real one — and absent by default,
151/// where a `use` is a typed error naming the package.
152pub fn run_with_loader(
153 src: &str,
154 inputs: Inputs,
155 loader: &dyn crate::uses::Loader,
156) -> Result<Run, RunError> {
157 run_in_surface(Entry::anonymous(src), inputs, loader, None)
158}
159
160/// Run blue source written in a `yakugo` surface.
161///
162/// The pack applies at PARSE time and nowhere else — by the time the checker
163/// sees the program it is canonical, so every stage below is identical whatever
164/// surface the author wrote in. That is what makes a surface a surface: it
165/// changes how a program is spelled and nothing about how it runs.
166///
167/// `entry` carries the source AND the file it was read from, because a type
168/// error has to be reported somewhere: a caller with a path should pass it, and
169/// one without ([`Entry::anonymous`]) gets diagnostics that say so rather than
170/// diagnostics that guess. Imported packages name themselves through the
171/// loader either way.
172///
173/// # Errors
174///
175/// As [`run_with_loader`].
176pub fn run_in_surface(
177 entry: Entry<'_>,
178 inputs: Inputs,
179 loader: &dyn crate::uses::Loader,
180 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
181) -> Result<Run, RunError> {
182 // The interpreter is built BEFORE the check stage, because it is the check
183 // stage's name oracle: every builtin, macro and special form a program may
184 // name is read off the interpreter that will run it, never off a list.
185 // Building it is not a stage; nothing is evaluated until `eval_prepared`.
186 let mut interp = crate::interpreter_hostless();
187 crate::inputs::install_input_primitives(&mut interp, inputs);
188 let prepared = prepare(entry, loader, surface, &builtin_names(&interp))?;
189 let value = eval_prepared(&mut interp, &prepared, &mut ())?;
190 Ok(Run {
191 value,
192 visited: prepared.visited,
193 typed_decls: prepared.typed_decls,
194 seams: prepared.seams,
195 })
196}
197
198/// A run watched by a call observer, and the definitions the observer was
199/// built over.
200pub struct Observed {
201 pub definitions: std::sync::Arc<crate::observe::Definitions>,
202 /// The run itself: its value, or the error it stopped on.
203 pub outcome: Result<Run, RunError>,
204}
205
206/// [`run_in_surface`] with a call observer installed for the evaluation.
207/// `watch` builds the observer from the program's definitions once every stage
208/// before evaluation has passed; the profiler, the tracer and coverage are
209/// all built this way (`crate::observe::Recorder`).
210///
211/// # Errors
212///
213/// A parse, import or check error, which leaves nothing to run. A runtime
214/// error is the [`Observed::outcome`], so what ran before it is still read.
215pub fn run_observed(
216 entry: Entry<'_>,
217 inputs: Inputs,
218 loader: &dyn crate::uses::Loader,
219 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
220 watch: impl FnOnce(
221 &std::sync::Arc<crate::observe::Definitions>,
222 ) -> std::sync::Arc<dyn crate::observe::CallObserver>,
223) -> Result<Observed, RunError> {
224 let mut interp = crate::interpreter_hostless();
225 crate::inputs::install_input_primitives(&mut interp, inputs);
226 let prepared = prepare(entry, loader, surface, &builtin_names(&interp))?;
227 let definitions = std::sync::Arc::new(crate::observe::Definitions::of(
228 &prepared.program,
229 &prepared.erased,
230 ));
231 interp.set_call_observer(Some(watch(&definitions)));
232 let outcome = eval_prepared(&mut interp, &prepared, &mut ()).map(|value| Run {
233 value,
234 visited: prepared.visited,
235 typed_decls: prepared.typed_decls,
236 seams: prepared.seams,
237 });
238 interp.set_call_observer(None);
239 Ok(Observed {
240 definitions,
241 outcome,
242 })
243}
244
245/// A program past every stage before evaluation: parsed, its imports
246/// resolved, type-checked and erased. What [`run_in_surface`] and
247/// [`crate::hosted::load_hosted`] both evaluate, so the stage order this
248/// module exists to own is written once for both.
249pub(crate) struct Prepared {
250 program: crate::uses::ResolvedProgram,
251 erased: Vec<tatara_lisp::Spanned>,
252 visited: usize,
253 typed_decls: usize,
254 seams: usize,
255}
256
257impl Prepared {
258 /// The entry file's namespace.
259 pub(crate) fn entry_namespace(&self) -> Namespace {
260 let first = (0..self.program.forms().len())
261 .find(|i| self.program.owner_of(*i) == Some(crate::uses::ResolvedProgram::ENTRY));
262 match first {
263 Some(i) => namespace_of(&self.program, i),
264 None => match self
265 .program
266 .file(crate::uses::ResolvedProgram::ENTRY)
267 .and_then(|f| f.package.clone())
268 {
269 Some(p) => Namespace::Bidama(p),
270 None => Namespace::File(String::new()),
271 },
272 }
273 }
274}
275
276/// Parse, resolve, check and erase, in that order.
277///
278/// `builtins` is the name table of the interpreter that will evaluate the
279/// result ([`builtin_names`]); the check stage resolves the program's names
280/// against it.
281pub(crate) fn prepare(
282 entry: Entry<'_>,
283 loader: &dyn crate::uses::Loader,
284 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
285 builtins: &NameTable,
286) -> Result<Prepared, RunError> {
287 let mut program = parse_and_resolve(entry, loader, surface)?;
288
289 // `test` blocks are declarations for the harness, not code to run.
290 //
291 // Dropped here rather than in `resolve_uses`, because `blue test` calls
292 // the resolver and then NEEDS the entry file's blocks — so the two
293 // callers want different things and the split has to live at this level.
294 //
295 // Without this, `blue run` on a file containing its own tests fails with
296 // `unbound symbol: deftest`: every package in the bidama distribution
297 // carries tests, so every one of them was unrunnable.
298 //
299 // Through `retain`, which drops each form's owner with it. A plain filter
300 // over the forms alone would slide every later form onto the wrong file.
301 program.retain(|f| !crate::uses::is_test_form(f));
302
303 // CHECK, on the annotated tree — the only tree that has annotations.
304 //
305 // **On the REAL spanned tree, including every imported package's.** This
306 // was the one caller that checked a spanless lift, because `resolve_uses`
307 // flattened the entry file and its imports into one list and `Span` is a
308 // byte range with no file identity — so a real span here would have
309 // reported an imported package's error at that offset in the ENTRY file, a
310 // precise-looking answer pointing at unrelated code.
311 //
312 // The fix is not a wider `Span` (that type is upstream, and its own docs
313 // put file identity on the caller: spans "are meaningful only relative to
314 // the string that produced them, which the caller is responsible for
315 // holding onto"). blue holds onto it BESIDE the span, per top-level form —
316 // see `uses::ResolvedProgram`.
317 let (outcome, names) = check_stage(&program, builtins, false);
318 if !outcome.ok() {
319 return Err(RunError::Types(
320 outcome
321 .errors()
322 // `file:line:col: error[CODE]: message`, resolved against the
323 // file the form actually came from, then its help line.
324 .map(|d| render(&program, d))
325 .collect(),
326 ));
327 }
328
329 // ERASE, so the interpreter never sees a type — **on the spanned tree,
330 // and back out as one.**
331 //
332 // This used to be `erase_types(&program.sexps())` followed by a
333 // `lower_to_spanned` that stamped `Span::synthetic()` over every node, and
334 // the comment here said carrying real positions through was "a larger
335 // piece and is NOT built". It was not larger: erasure only ever DELETES
336 // nodes — the single node it invents is the `define` replacing
337 // `define-typed` — so there was never anything for a synthetic span to
338 // stand in for. See `crate::erase`.
339 //
340 // No lowering step follows. The tree the evaluator receives IS the tree the
341 // parser built, minus annotations, with the author's byte offsets intact.
342 let erased = erase_types(&lower(&program, &names));
343
344 Ok(Prepared {
345 program,
346 erased,
347 visited: outcome.stats.visited,
348 typed_decls: outcome.stats.typed_decls,
349 seams: outcome.seams.len(),
350 })
351}
352
353/// Parse the entry (in `surface`, if one is given) and splice in its imports.
354pub(crate) fn parse_and_resolve(
355 entry: Entry<'_>,
356 loader: &dyn crate::uses::Loader,
357 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
358) -> Result<crate::uses::ResolvedProgram, RunError> {
359 let forms = match surface {
360 Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
361 .map_err(|e| RunError::Parse(e.to_string()))?,
362 None => loader
363 .parse(entry.text)
364 .map_err(|e| RunError::Parse(e.to_string()))?,
365 };
366
367 // RESOLVE imports first, so everything below sees ONE program.
368 //
369 // Before the check on purpose: imported code is type-checked at the point
370 // its consumer imports it, rather than at whatever later moment its code
371 // first runs. A package that does not typecheck should break its importer's
372 // build, not their production run.
373 //
374 // One program, but not one FILE: the result records which file each
375 // top-level form came from, which is what lets a diagnostic below name a
376 // place instead of only a problem.
377 crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)
378}
379
380/// **The check stage**: every rule in `blue_lang_check::RULES`, over a
381/// resolved program, with waivers applied.
382///
383/// One function, called by every door — `run`, the hosted loader, `blue test`
384/// and `blue check` — so a rule added to the registry is enforced by all of
385/// them at once. That is what makes an architecture rule a pipeline error
386/// rather than an optional report: there is no door that skips it.
387///
388/// Warnings (unused bindings, unused waivers) are reported for the ENTRY
389/// file only; an imported package's warnings belong to its author. Errors
390/// are reported for every file, because an imported package with one is
391/// broken for its importer.
392pub(crate) fn check_stage(
393 program: &crate::uses::ResolvedProgram,
394 builtins: &NameTable,
395 with_tests: bool,
396) -> (blue_lang_check::Outcome, NameTable) {
397 use crate::uses::ResolvedProgram;
398 let forms = program.forms();
399 let is_entry = |i: usize| program.owner_of(i) == Some(ResolvedProgram::ENTRY);
400
401 // Rules B0003–B0005: the typing ladder.
402 let mut outcome = blue_lang_check::check_program(forms);
403
404 // Rules B0001–B0002: names. The program's own scopes come first (the
405 // entry file, then each bidama in import order), then the interpreter's.
406 let table = program_names(program, builtins);
407 let whole = |i: usize| with_tests && is_entry(i);
408 let (names, resolved) = blue_lang_check::names::check_names_whole(
409 forms,
410 &table,
411 &|i| namespace_of(program, i),
412 &is_entry,
413 &whole,
414 );
415 outcome.diagnostics.extend(names);
416 outcome.stats.names_resolved = resolved;
417
418 // Waivers, per file: B0007 for a malformed one, B0008 for one that
419 // suppresses nothing.
420 let mut waivers = Vec::new();
421 for file in program.files() {
422 let spans: Vec<(usize, tatara_lisp::Span)> = forms
423 .iter()
424 .enumerate()
425 .filter(|(i, _)| program.owner_of(*i) == Some(file.id))
426 .map(|(i, f)| (i, f.span))
427 .collect();
428 let (found, malformed) = blue_lang_check::waiver::collect(&file.text, &spans);
429 waivers.extend(found);
430 outcome.diagnostics.extend(malformed);
431 }
432 outcome.waived = blue_lang_check::waiver::apply(&mut outcome.diagnostics, waivers, &is_entry);
433 // A rule whose census has not reached zero is computed, and counted, but
434 // not enforced: its findings move to `census` (see `RULES`' `ratchet`).
435 let (enforced, census): (Vec<_>, Vec<_>) = std::mem::take(&mut outcome.diagnostics)
436 .into_iter()
437 .partition(|d| d.code.rule().active());
438 outcome.diagnostics = enforced;
439 outcome.census = census;
440 outcome
441 .diagnostics
442 .sort_by_key(|d| (d.top_level, d.span.start, d.code));
443 (outcome, table)
444}
445
446/// The whole name table the check stage resolves against: the program's
447/// own scopes (each file and bidama, by namespace) followed by `builtins`.
448///
449/// **A top-level call to a builtin macro can define names**: `defflow(slug,
450/// trim, downcase)` expands to `(define slug …)`, and nothing in the source
451/// spells `define`. Such a call is expanded here, on a hostless fork, and what
452/// its expansion defines is bound in the calling file's namespace. Only
453/// BUILTIN macros are expanded: their bodies are the runtime's own
454/// templates. A program's own `defmacro` is not run at check time — checking a
455/// file (and an editor checking on every keystroke) must not execute the code
456/// it checks — so names a program macro generates are not seen; none of the
457/// corpus's macros generates one (measured 2026-09-29).
458#[must_use]
459pub fn program_names(program: &crate::uses::ResolvedProgram, builtins: &NameTable) -> NameTable {
460 use blue_lang_check::Binding;
461 let mut table = NameTable::new();
462 table.add_program(program.forms(), |i| namespace_of(program, i));
463 let builtin_macro = |head: &str| {
464 builtins
465 .scopes()
466 .iter()
467 .any(|s| s.namespace == Namespace::Macro && s.get(head).is_some())
468 };
469 let mut expander: Option<tatara_lisp_eval::Interpreter<()>> = None;
470 for (i, form) in program.forms().iter().enumerate() {
471 let Some(head) = form
472 .as_list()
473 .and_then(|items| items.first())
474 .and_then(tatara_lisp::Spanned::as_symbol)
475 else {
476 continue;
477 };
478 if !builtin_macro(head) {
479 continue;
480 }
481 let interp = expander.get_or_insert_with(crate::interpreter_hostless);
482 // A failed expansion defines nothing here; the evaluator will report
483 // it, with its position, when the form runs.
484 let Ok(expanded) = interp.fully_expand(form, &mut ()) else {
485 continue;
486 };
487 for (name, span, kind) in blue_lang_check::names::definitions_of(&expanded) {
488 table.define(
489 namespace_of(program, i),
490 Binding {
491 name,
492 kind,
493 span: Some(span),
494 top_level: Some(i),
495 },
496 );
497 }
498 }
499 for scope in builtins.scopes() {
500 table.push(scope.clone());
501 }
502 let form_file: Vec<usize> = (0..program.forms().len())
503 .map(|i| program.owner_of(i).map_or(usize::MAX, |f| f.index()))
504 .collect();
505 let mut imports: std::collections::BTreeMap<usize, blue_lang_check::names::FileImports> =
506 std::collections::BTreeMap::new();
507 for (file, import) in program.imports() {
508 let entry = imports.entry(file.index()).or_default();
509 entry.add(&import.package, import.names.iter().map(|(n, _)| n.clone()));
510 let at = import.span.start;
511 entry.use_at = Some(entry.use_at.map_or(at, |a| a.min(at)));
512 }
513 // A FACADE — a bidama that defines nothing and `use`s others (zenbu) —
514 // exists so a consumer can depend on "everything": `use("zenbu")` makes
515 // every member's QUALIFIER reachable (`toukei::median`), never a bare
516 // name. Followed to a fixpoint, so a facade of facades works too.
517 let facade_uses =
518 |pkg: &str,
519 imports: &std::collections::BTreeMap<usize, blue_lang_check::names::FileImports>| {
520 let defines = table.bidama(pkg).is_some_and(|s| !s.is_empty());
521 if defines {
522 return std::collections::BTreeSet::new();
523 }
524 program
525 .files()
526 .iter()
527 .filter(|f| f.package.as_deref() == Some(pkg))
528 .filter_map(|f| imports.get(&f.id.index()))
529 .flat_map(|i| i.uses.iter().cloned())
530 .collect::<std::collections::BTreeSet<String>>()
531 };
532 loop {
533 let mut grew = false;
534 let files: Vec<usize> = imports.keys().copied().collect();
535 for f in files {
536 let used: Vec<String> = imports[&f].uses.iter().cloned().collect();
537 for p in used {
538 for member in facade_uses(&p, &imports) {
539 if let Some(i) = imports.get_mut(&f) {
540 if i.uses.insert(member.clone()) {
541 i.via.insert(member, p.clone());
542 grew = true;
543 }
544 }
545 }
546 }
547 }
548 if !grew {
549 break;
550 }
551 }
552 let mut pkg_uses: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
553 std::collections::BTreeMap::new();
554 for (file, import) in program.imports() {
555 if let Some(p) = program.file(*file).and_then(|f| f.package.clone()) {
556 pkg_uses
557 .entry(p)
558 .or_default()
559 .insert(import.package.clone());
560 }
561 }
562 table.attach_files(form_file, imports);
563 table.attach_package_uses(pkg_uses);
564 table.attach_needs(program.needs().clone());
565 // Where each file lives and which distribution each bidama ships in: a
566 // bridge reference (B0021) is an error inside the bidama's own
567 // distribution and a warning outside it.
568 let canonical = |p: &std::path::Path| {
569 std::fs::canonicalize(p)
570 .unwrap_or_else(|_| p.to_path_buf())
571 .display()
572 .to_string()
573 };
574 let mut file_paths = std::collections::BTreeMap::new();
575 let mut pkg_roots = std::collections::BTreeMap::new();
576 for f in program.files() {
577 if let Some(path) = &f.path {
578 file_paths.insert(f.id.index(), canonical(path));
579 if let (Some(pkg), Some(root)) =
580 (&f.package, path.parent().and_then(std::path::Path::parent))
581 {
582 pkg_roots
583 .entry(pkg.clone())
584 .or_insert_with(|| canonical(root));
585 }
586 }
587 }
588 table.attach_paths(file_paths, pkg_roots);
589 let versions = program.versions().clone();
590 table.apply_legacy(program.forms(), &|i| namespace_of(program, i), &|p| {
591 versions.get(p).cloned()
592 });
593 table
594}
595
596/// The forms the evaluator runs, from a checked program: every `use`
597/// declaration inert (`nil`, so each index still names its top-level form)
598/// and every name at the key it runs under — the RESOLVED tree under
599/// per-bidama namespaces (`blue_lang_check::names::Resolved::resolved_tree`).
600///
601/// A definition runs as `retsu/first` (a script's as `%root/f`), a
602/// reference to it as the same key, and a builtin as its bare name. Keys
603/// cannot collide across namespaces, so a bidama's `first` no longer
604/// replaces the builtin for anyone who did not import it, and an importer's
605/// `helper` no longer replaces a bidama's own: the runtime binds exactly what
606/// the check stage resolved.
607#[must_use]
608pub fn lower(
609 program: &crate::uses::ResolvedProgram,
610 names: &NameTable,
611) -> Vec<tatara_lisp::Spanned> {
612 let resolved = blue_lang_check::names::resolve_program(program.forms(), names, &|i| {
613 namespace_of(program, i)
614 });
615 resolved
616 .resolved_tree(program.forms(), blue_lang_check::names::Rule::Namespaced)
617 .into_iter()
618 .zip(program.forms())
619 .map(|(resolved, written)| {
620 if crate::uses::use_target(written).is_some()
621 || blue_lang_syntax::scope::legacy_target(written).is_some()
622 {
623 tatara_lisp::Spanned::new(written.span, tatara_lisp::SpannedForm::Nil)
624 } else {
625 resolved
626 }
627 })
628 .collect()
629}
630
631/// The namespace top-level form `i` of `program` defines into.
632#[must_use]
633pub fn namespace_of(program: &crate::uses::ResolvedProgram, i: usize) -> Namespace {
634 let file = program.owner_of(i).and_then(|id| program.file(id));
635 match file {
636 Some(f) => match &f.package {
637 Some(p) => Namespace::Bidama(p.clone()),
638 None => Namespace::File(
639 f.path
640 .as_ref()
641 .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string()),
642 ),
643 },
644 None => Namespace::File("<unknown file>".to_string()),
645 }
646}
647
648/// Names only the test harness binds.
649///
650/// In scope for EVERY check, not only for test blocks. A bidama may carry a
651/// test helper — a plain `def` that asserts, called only from its `test`
652/// blocks (`heni`'s `hn_end_to_end` is one) — and every program importing that
653/// bidama would otherwise be refused for a function it never calls. The
654/// refusal would be scoped to the importer's whole program, not to the fault.
655/// The cost, stated: an `assert` outside any test that `blue run` actually
656/// reaches is caught at runtime (`blue-assert` is unbound there), not here.
657///
658/// `blue-lang-test` registers these on its own interpreter, and this crate
659/// cannot depend on it (it depends on this one), so the list lives beside the
660/// only name in it: the lowered `assert`, which `blue-lang-syntax` owns.
661pub const HARNESS_NAMES: &[&str] = &[blue_lang_syntax::LOWERED_ASSERT];
662
663/// The check stage's builtin scopes, read off the interpreter that will run
664/// the program: the test harness's names ([`HARNESS_NAMES`]), then its special
665/// forms, its macros, and every value it binds — the last three in the order
666/// the evaluator consults for a head.
667///
668/// Read from a live interpreter rather than a list, for the reason
669/// `Interpreter::resolve_head` gives: a list is a second copy of the truth and
670/// goes stale on the next installer that lands. An embedder's own
671/// primitives are in scope here because the embedder installs them first.
672#[must_use]
673pub fn builtin_names<H: 'static>(interp: &tatara_lisp_eval::Interpreter<H>) -> NameTable {
674 use blue_lang_check::{Binding, Scope, ScopeKind};
675 use tatara_lisp_eval::HeadBinding;
676 let mut special = Scope::new(Namespace::SpecialForm);
677 let mut macros = Scope::new(Namespace::Macro);
678 let mut values = Scope::new(Namespace::Builtin);
679 // `reserved_head_names` reads the ROOT frame only, so a name installed on
680 // a fork (the input primitives, on every `run`) is invisible to it; those
681 // are asked about by name instead, which `resolve_head` answers through
682 // every frame.
683 let mut names: Vec<std::sync::Arc<str>> = interp.reserved_head_names().into_iter().collect();
684 names.extend(
685 crate::inputs::INPUT_PRIMITIVES
686 .iter()
687 .filter(|n| interp.resolve_head(n).is_some())
688 .map(|n| std::sync::Arc::from(*n)),
689 );
690 for name in names {
691 let (scope, kind) = match interp.resolve_head(&name) {
692 Some(HeadBinding::SpecialForm) => (&mut special, ScopeKind::SpecialForm),
693 Some(HeadBinding::Macro) => (&mut macros, ScopeKind::Macro),
694 _ => (&mut values, ScopeKind::Value),
695 };
696 scope.bind(Binding {
697 name: name.to_string(),
698 kind,
699 span: None,
700 top_level: None,
701 });
702 }
703 let mut table = NameTable::new();
704 let mut harness = Scope::new(Namespace::Harness);
705 for n in HARNESS_NAMES {
706 harness.bind(Binding {
707 name: (*n).to_string(),
708 kind: ScopeKind::Value,
709 span: None,
710 top_level: None,
711 });
712 }
713 table.push(harness);
714 table.push(special);
715 table.push(macros);
716 table.push(values);
717 table
718}
719
720/// A diagnostic as a person reads it: `file:line:col: error[B0001]: message`,
721/// then an indented `help:` line when it has one.
722#[must_use]
723pub fn render(program: &crate::uses::ResolvedProgram, d: &blue_lang_check::Diagnostic) -> String {
724 let head = d.to_string();
725 let mut out = program.locate(d.top_level, d.span, &head).to_string();
726 if let Some(help) = &d.help {
727 out.push_str("\n help: ");
728 out.push_str(help);
729 }
730 out
731}
732
733/// What [`check_entry`] checks.
734#[derive(Clone, Copy, Debug, PartialEq, Eq)]
735pub enum Checking {
736 /// The program `blue run` would evaluate: test blocks dropped.
737 Program,
738 /// The program plus the entry file's test blocks, as `blue test` and
739 /// `blue check` see it.
740 WithTests,
741}
742
743/// A program through the check stage, with everything the stage found.
744pub struct Checked {
745 pub program: crate::uses::ResolvedProgram,
746 pub outcome: blue_lang_check::Outcome,
747 /// The name table the stage resolved against: every program scope, then
748 /// the builtins. What an editor completes from.
749 pub names: NameTable,
750}
751
752impl Checked {
753 /// The forms an evaluator runs: [`lower`]ed, then erased — the tree
754 /// `run` evaluates, for a door that brings its own evaluator (the VM).
755 #[must_use]
756 pub fn erased(&self) -> Vec<tatara_lisp::Spanned> {
757 erase_types(&lower(&self.program, &self.names))
758 }
759
760 /// What `blue test` hands the harness: the program [`lower`]ed, spans
761 /// projected away.
762 #[must_use]
763 pub fn evaluable(&self) -> Vec<tatara_lisp::Sexp> {
764 lower(&self.program, &self.names)
765 .iter()
766 .map(tatara_lisp::Spanned::to_sexp)
767 .collect()
768 }
769
770 /// Every non-local reference, resolved under both rules — today's flat
771 /// one and per-bidama namespaces — and the resolved trees built from them
772 /// (`blue ast --resolved`).
773 #[must_use]
774 pub fn resolve(&self) -> blue_lang_check::names::Resolved {
775 blue_lang_check::names::resolve_program(self.program.forms(), &self.names, &|i| {
776 namespace_of(&self.program, i)
777 })
778 }
779}
780
781/// Parse, resolve and CHECK, without running: the door for `blue check`,
782/// `blue test` and the language server.
783///
784/// Returns every diagnostic, errors and warnings alike, rather than failing on
785/// the first error: a reader fixing a file wants the whole list, and an agent
786/// repairing one wants it in one pass. Whether errors stop anything is the
787/// caller's decision — `blue test` refuses to run tests over a program with
788/// one, exactly as `run` refuses to evaluate it.
789///
790/// The name oracle is the interpreter `blue run` would build: hostless plus
791/// input primitives.
792///
793/// # Errors
794///
795/// A parse or import error, which leaves no program to check.
796pub fn check_entry(
797 entry: Entry<'_>,
798 loader: &dyn crate::uses::Loader,
799 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
800 checking: Checking,
801) -> Result<Checked, RunError> {
802 let mut program = parse_and_resolve(entry, loader, surface)?;
803 if checking == Checking::Program {
804 program.retain(|f| !crate::uses::is_test_form(f));
805 }
806 let mut interp = crate::interpreter_hostless();
807 crate::inputs::install_input_primitives(&mut interp, Inputs::new());
808 let builtins = builtin_names(&interp);
809 let (outcome, names) = check_stage(&program, &builtins, checking == Checking::WithTests);
810 Ok(Checked {
811 program,
812 outcome,
813 names,
814 })
815}
816
817/// A syntax error in `text` as a [`B0006`](blue_lang_check::Code::B0006)
818/// diagnostic, so a tool reading `--format json` gets a parse failure in the
819/// same shape as every other finding. `None` when `text` parses.
820#[must_use]
821pub fn syntax_diagnostic(text: &str) -> Option<blue_lang_check::Diagnostic> {
822 let e = blue_lang_syntax::parse_program_tree(text).err()?;
823 Some(
824 blue_lang_check::Diagnostic::new(blue_lang_check::Code::B0006, e.message, e.span)
825 .at_top_level(0),
826 )
827}
828
829/// A runtime message as the author wrote the names in it: a definition runs
830/// under its key (`retsu/first`, `%root/f`), and a message naming one in
831/// backticks names it `retsu::first`, or `f` for the author's own script.
832#[must_use]
833pub fn display_keys(message: &str) -> String {
834 let mut out = String::with_capacity(message.len());
835 let mut parts = message.split('`');
836 if let Some(first) = parts.next() {
837 out.push_str(first);
838 }
839 for (i, part) in parts.enumerate() {
840 out.push('`');
841 // Odd pieces (0-based here) are inside backticks.
842 if i % 2 == 0 {
843 out.push_str(&crate::observe::display_key(part));
844 } else {
845 out.push_str(part);
846 }
847 }
848 out
849}
850
851/// Evaluate a prepared program's top-level forms into `interp`, with `host`.
852pub(crate) fn eval_prepared<H: 'static>(
853 interp: &mut tatara_lisp_eval::Interpreter<H>,
854 prepared: &Prepared,
855 host: &mut H,
856) -> Result<Value, RunError> {
857 let Prepared {
858 program, erased, ..
859 } = prepared;
860
861 // RUN, one top-level form at a time, **counting them**.
862 //
863 // This is literally the loop `Interpreter::eval_program` runs internally
864 // (tatara-lisp-eval `eval.rs`) — unrolled here for exactly one reason: the
865 // INDEX. `resolve_uses` flattens N files into one form list and a `Span` is
866 // a byte range with no file identity, so an offset alone is ambiguous
867 // across files — 66 means something in every one of them. The index is the
868 // join key back to `ResolvedProgram`'s file table, and `eval_program`
869 // consumes it internally and hands back only the error.
870 //
871 // The same key, the same renderer and the same file table the type errors
872 // above already use. A runtime error was the one diagnostic in this
873 // function still reporting a bare message.
874 let mut value = Value::Nil;
875 for (top_level, form) in erased.iter().enumerate() {
876 value = interp.eval_top_form(form, host).map_err(|e| {
877 // The message, its position and the blue call stack, read off the
878 // frames the error unwound through (`observe::Fault`). The message
879 // is `short_message` rather than upstream's `Display`, whose
880 // trailing ` at {span}` is a bare byte range with no file, through
881 // `messages::describe`, which names the function a wrong argument
882 // count was called on.
883 RunError::Eval(Box::new(crate::observe::Fault::of(
884 program,
885 erased,
886 top_level,
887 &e,
888 &interp.take_trace(),
889 )))
890 })?;
891 }
892 Ok(value)
893}
894
895/// **A runtime error names a place.** The gates for the last stage that
896/// reported a bare message.
897///
898/// Each fixture is sized so that resolving the failing span against ANY file
899/// but the right one lands somewhere else — a different path and a different
900/// line — so a green run here is evidence about the join and not about the
901/// renderer being called at all.
902///
903/// **Each raise is an unbound name, WAIVED.** Since the check stage resolves
904/// names (B0001), an unbound call no longer reaches the evaluator; these
905/// fixtures need exactly that runtime raise, so each carries
906/// `# waive B0001: …` above the definition holding it — the one escape hatch,
907/// used for what it is for. The waiver adds a line above each failing call,
908/// which is why the positions below are one line later than their red runs
909/// (recorded against the unwaived fixtures) show.
910#[cfg(test)]
911mod position_tests {
912 use super::*;
913 use crate::uses::{Entry, Loader};
914 use std::collections::BTreeMap;
915 use std::path::Path;
916
917 struct MemLoader(BTreeMap<&'static str, &'static str>);
918
919 impl Loader for MemLoader {
920 fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
921 self.0
922 .get(name)
923 .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
924 .ok_or_else(|| format!("no bidama named \"{name}\""))
925 }
926 }
927
928 /// A package whose OWN top-level code raises, deep inside a file that is
929 /// deliberately taller than either of the other two.
930 ///
931 /// Byte 66 — where `kore_wa_sonzai_shinai` starts — is past the end of the
932 /// entry file (30 bytes) and past the end of `kotae.b` (21 bytes). So the
933 /// only file this span can honestly resolve against is this one, and the
934 /// other two now refuse to answer rather than walking off their ends.
935 const BAKUHATSU: &str = "\
936def tsukawanai_a()
937 1
938end
939
940def tsukawanai_b()
941 2
942end
943
944# waive B0001: the fixture needs a raise at runtime
945def bakuhatsu()
946 kore_wa_sonzai_shinai()
947end
948
949bakuhatsu()";
950
951 const KOTAE: &str = "def kotae()\n 42\nend";
952
953 /// The same failing call, but only ever reached by a CALLER in another
954 /// file — the shape the call stack resolved (the last position test).
955 const BAKUHATSU2: &str =
956 "# waive B0001: a raise at runtime\ndef yobu()\n kore_wa_sonzai_shinai()\nend";
957
958 fn loader() -> MemLoader {
959 MemLoader(BTreeMap::from([
960 ("kotae", KOTAE),
961 ("bakuhatsu", BAKUHATSU),
962 ("bakuhatsu2", BAKUHATSU2),
963 ]))
964 }
965
966 fn run_named(path: &str, src: &str) -> RunError {
967 run_in_surface(
968 Entry {
969 path: Some(Path::new(path)),
970 text: src,
971 },
972 Inputs::new(),
973 &loader(),
974 None,
975 )
976 .expect_err("the fixture must raise")
977 }
978
979 /// **The load-bearing gate: a raise inside an IMPORTED package reports the
980 /// IMPORTED file's path and line.**
981 ///
982 /// Hand-computed. In `bakuhatsu.b` the failing call sits on line 11 at
983 /// column 3, and the whole point of the fixture's height is that no other
984 /// file in the program can produce that pair: the entry file has 2 lines
985 /// and `kotae.b` has 3, so *any* mis-attribution is visible as a different
986 /// path AND a different position, never as a coincidence.
987 ///
988 /// **Three red runs, one per edit this change makes** — each isolates a
989 /// different half of the mechanism, which is what stops the gate being a
990 /// tautology over "some renderer ran".
991 ///
992 /// 1. `eval_program` + `.map_err(|e| RunError::Eval(e.to_string()))`
993 /// restored (the pre-change code):
994 /// ```text
995 /// left: "runtime error: unbound symbol: kore_wa_sonzai_shinai at 74..95"
996 /// right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
997 /// ```
998 /// No place at all — the state this change is fixing — and the trailing
999 /// `at 74..95` is upstream's `Display`, an offset with no file, which is
1000 /// why the loop renders `short_message` instead.
1001 ///
1002 /// 2. The loop kept, but `0` passed to `locate` in place of `top_level`:
1003 /// ```text
1004 /// left: "runtime error: kotae.b: unbound symbol `kore_wa_sonzai_shinai`"
1005 /// right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1006 /// ```
1007 /// **This is the one that proves the INDEX is load-bearing** rather than
1008 /// the renderer: form 0 belongs to `kotae.b`, so the wrong file is named
1009 /// — and named without a position, because the guard in `locate` sees
1010 /// that byte 74 is not a range in a 21-byte file and declines to invent
1011 /// one. A fixture with one imported package could not tell these apart.
1012 ///
1013 /// 3. `crate::lower_to_spanned(&crate::to_sexps(&erased))` reinstated
1014 /// between erasure and the loop:
1015 /// ```text
1016 /// left: "runtime error: bakuhatsu.b: unbound symbol `kore_wa_sonzai_shinai`"
1017 /// right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1018 /// ```
1019 /// The right FILE with no position — which is exactly the shape of the
1020 /// bug: the index survived the lift and every span did not.
1021 /// kotae loads first so bakuhatsu's forms do not start at index 0: the
1022 /// order is the fixture, and the waiver says so (B0015 wants them sorted).
1023 const TWO_IMPORTS: &str =
1024 "use(\"kotae\")\n# waive B0015: kotae loads first on purpose\nuse(\"bakuhatsu\")\n";
1025
1026 #[test]
1027 fn a_raise_inside_an_imported_package_names_that_package() {
1028 let err = run_named("entry.b", TWO_IMPORTS);
1029 assert_eq!(
1030 err.to_string(),
1031 "runtime error: bakuhatsu.b:11:3: unbound symbol `kore_wa_sonzai_shinai`\n \
1032 at bakuhatsu::bakuhatsu (bakuhatsu.b:11:3)\n \
1033 at <top level> (bakuhatsu.b:14:1)"
1034 );
1035 }
1036
1037 /// Anti-vacuity for the fixture itself: the position asserted above is a
1038 /// FACT ABOUT `bakuhatsu.b`, checked against the source text rather than
1039 /// against the thing that produced it.
1040 ///
1041 /// Without this, `11:3` is just a number that happened to come out of the
1042 /// code under test, and a change that moved every reported line by one
1043 /// would move this assertion with it.
1044 #[test]
1045 fn line_11_column_3_of_the_fixture_is_the_failing_call() {
1046 let line = BAKUHATSU.lines().nth(10).expect("line 11 exists");
1047 assert_eq!(line, " kore_wa_sonzai_shinai()");
1048 assert_eq!(
1049 &line[2..],
1050 "kore_wa_sonzai_shinai()",
1051 "column 3 (1-indexed) is where the call starts"
1052 );
1053 // And the other two files cannot reach that far, which is what makes a
1054 // mis-attribution loud instead of plausible.
1055 let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
1056 assert!(
1057 offset > KOTAE.len(),
1058 "kotae.b could answer for byte {offset}"
1059 );
1060 assert!(
1061 offset > TWO_IMPORTS.len(),
1062 "the entry file could answer for byte {offset}"
1063 );
1064 }
1065
1066 /// **The `FileId(0)` path: a raise in the ENTRY file reports the entry
1067 /// file.** The single-file case, which is every program that imports
1068 /// nothing — and the one where the index and the span cannot disagree.
1069 ///
1070 /// **Red run** (2026-08-12), `eval_program` +
1071 /// `.map_err(|e| RunError::Eval(e.to_string()))` restored:
1072 /// ```text
1073 /// left: "runtime error: unbound symbol: nani_mo_nai at 17..28"
1074 /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1075 /// ```
1076 ///
1077 /// **Second red run**, `lower_to_spanned` reinstated after erasure — the
1078 /// one that isolates the SPAN half from the index half, since a
1079 /// single-file program cannot get its file wrong:
1080 /// ```text
1081 /// left: "runtime error: honmono.b: unbound symbol `nani_mo_nai`"
1082 /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1083 /// ```
1084 #[test]
1085 fn a_raise_in_the_entry_file_names_the_entry_file() {
1086 let err = run_named(
1087 "honmono.b",
1088 "def f()\n 1\nend\n\n# waive B0001: a raise at runtime\nnani_mo_nai()\n",
1089 );
1090 assert_eq!(
1091 err.to_string(),
1092 "runtime error: honmono.b:6:1: unbound symbol `nani_mo_nai`"
1093 );
1094 }
1095
1096 /// A raise from a form that came through the SURFACE-level erasure still
1097 /// reports a real position — the annotated path, where erasure rewrites
1098 /// the node rather than passing it through.
1099 ///
1100 /// Separate from the two above because the erasure rewrite is the only
1101 /// place a node is built rather than kept, and a synthetic span there
1102 /// would be invisible to a fixture whose failing form is untouched.
1103 ///
1104 /// **Red run** (2026-08-12), `lower_to_spanned` reinstated after erasure:
1105 /// ```text
1106 /// left: "runtime error: chuu.b: unbound symbol `mada_nai`"
1107 /// right: "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
1108 /// ```
1109 #[test]
1110 fn a_raise_inside_an_annotated_def_still_names_its_line() {
1111 let err = run_named(
1112 "chuu.b",
1113 "# waive B0001: a raise at runtime\ndef f(a: Int) -> Int\n mada_nai(a)\nend\n\nf(1)\n",
1114 );
1115 assert_eq!(
1116 err.to_string(),
1117 "runtime error: chuu.b:3:3: unbound symbol `mada_nai`\n \
1118 at f (chuu.b:3:3)\n \
1119 at <top level> (chuu.b:6:1)"
1120 );
1121 }
1122
1123 /// **The limit this module used to state, closed by the call stack.**
1124 ///
1125 /// A top-level form in the entry file calls a function defined in an
1126 /// imported one, and the raise happens inside the callee. The executing
1127 /// form is the entry's and the span is the callee's bytes, so until the
1128 /// evaluator reported the frames an error unwound through, the best blue
1129 /// could do was name the caller's file with no position (the guard in
1130 /// `uses::ResolvedProgram::locate` refusing an offset it could not
1131 /// justify). The innermost frame's definition now names the file the
1132 /// offsets belong to, and the stack shows the call that led there.
1133 ///
1134 /// **Red run** (2026-10-10): `Fault::of` locating against the executing
1135 /// top-level form instead of the innermost frame's definition:
1136 /// ```text
1137 /// left: "runtime error: yobidashi.b: unbound symbol `kore_wa_sonzai_shinai`\n at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n at <top level> (yobidashi.b:3:1)"
1138 /// ```
1139 /// The old answer, kept by the guard: the caller's file and no position.
1140 #[test]
1141 fn a_raise_in_a_callee_from_another_file_names_the_callees_file() {
1142 // The comment is padding, and load-bearing: `bakuhatsu2.b`'s failing
1143 // call starts at byte 47 (after its waiver line) and ends at 70, and
1144 // this file is 57 bytes, so the start is in range here and the end is
1145 // not — a mis-attribution would still be refused rather than placed.
1146 let err = run_named(
1147 "yobidashi.b",
1148 "use(\"bakuhatsu2\")\n# pad: 47 in range\nbakuhatsu2::yobu()\n",
1149 );
1150 assert_eq!(
1151 err.to_string(),
1152 "runtime error: bakuhatsu2.b:3:3: unbound symbol `kore_wa_sonzai_shinai`\n \
1153 at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n \
1154 at <top level> (yobidashi.b:3:1)"
1155 );
1156 }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162
1163 fn int(src: &str) -> i64 {
1164 match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
1165 Value::Int(v) => v,
1166 other => panic!("{src:?} produced {other:?}"),
1167 }
1168 }
1169
1170 /// **The sliding scale, as one assertion.** Annotating changes the
1171 /// analysis and nothing else.
1172 #[test]
1173 fn annotating_buys_analysis_and_changes_nothing_else() {
1174 let plain = run("def add(a, b)\n a + b\nend\nadd(2, 3)").expect("plain");
1175 let typed = run("def add(a: Int, b: Int) -> Int\n a + b\nend\nadd(2, 3)").expect("typed");
1176
1177 assert!(matches!(plain.value, Value::Int(5)));
1178 assert!(
1179 matches!(typed.value, Value::Int(5)),
1180 "the annotated program must compute the same answer"
1181 );
1182 assert_eq!(plain.visited, 0, "no annotations means no analysis");
1183 assert!(
1184 typed.visited > 0,
1185 "an annotation must actually buy analysis, not just decorate"
1186 );
1187 assert_eq!(plain.typed_decls, 0);
1188 assert_eq!(typed.typed_decls, 1);
1189 }
1190
1191 /// **Checking happens before erasure.** This is the test that catches the
1192 /// reordering: a program with a declared-type violation must be rejected,
1193 /// and it can only be rejected while the annotations still exist.
1194 #[test]
1195 fn a_type_error_is_reported_and_the_program_does_not_run() {
1196 let err = run("def add(a: Int, b: Int) -> Str\n a + b\nend\nadd(1, 2)")
1197 .expect_err("a declared Str return from an Int body must be rejected");
1198 assert!(
1199 matches!(err, RunError::Types(ref d) if !d.is_empty()),
1200 "expected type diagnostics, got {err}"
1201 );
1202 }
1203
1204 /// And the untyped version of the same program runs, so the rejection
1205 /// above is the annotation's doing rather than a parse failure.
1206 #[test]
1207 fn the_same_program_without_annotations_runs() {
1208 assert_eq!(int("def add(a, b)\n a + b\nend\nadd(1, 2)"), 3);
1209 }
1210
1211 #[test]
1212 fn a_parse_error_is_reported_as_one() {
1213 assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
1214 }
1215
1216 /// Every stage reports in its own vocabulary, so a failure names which
1217 /// stage failed rather than surfacing as a generic error.
1218 #[test]
1219 fn a_runtime_error_is_reported_as_one() {
1220 let err = run("throw(error(\"boom\"))").expect_err("a raise");
1221 assert!(matches!(err, RunError::Eval(_)), "got {err}");
1222 }
1223
1224 /// Stdlib and primitives are both reachable through the pipeline — the
1225 /// gap that made `6 % 3` fail.
1226 #[test]
1227 fn the_pipeline_reaches_both_runtime_layers() {
1228 assert_eq!(int("6 % 3"), 0);
1229 assert_eq!(int("7 % 3"), 1);
1230 assert_eq!(int("2 + 3 * 4"), 14);
1231 }
1232
1233 /// **The deleted hop was a no-op on everything blue emits — so removing it
1234 /// is a swap, not a behaviour change.**
1235 ///
1236 /// The old lowering printed the erased tree and read it back through
1237 /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
1238 /// paths land on the same tree, which is the equivalence the swap rests on.
1239 /// It is stated as a *measurement over this corpus*, not as a theorem:
1240 /// the round trip is not identity in general (that is precisely why it had
1241 /// to go), it merely happened to be identity for the bytes blue emits.
1242 #[test]
1243 fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
1244 let corpus = [
1245 "def add(a, b)\n a + b\nend\nadd(2, 3)",
1246 "def fact(n)\n if n < 2\n 1\n else\n n * fact(n - 1)\n end\nend\nfact(5)",
1247 "def f(a, b)\n c = a + b\n c * 2\nend\nf(1, 2)",
1248 "defmacro sq(e)\n quote\n unquote(e) * unquote(e)\n end\nend\nsq(2 + 3)",
1249 "\"a string with spaces, a ( and a )\"",
1250 "def g(a: Int) -> Int\n a + 1\nend\ng(1)",
1251 "6 % 3",
1252 "1.5 + 2.25",
1253 ];
1254 for src in corpus {
1255 // Projected to `Sexp` because that is what the deleted hop
1256 // operated on. Erasure itself no longer goes near it.
1257 let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
1258
1259 let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
1260 .iter()
1261 .map(tatara_lisp::Spanned::to_sexp)
1262 .collect();
1263 assert_eq!(direct, erased, "the direct lowering must be the identity");
1264
1265 let text = erased
1266 .iter()
1267 .map(ToString::to_string)
1268 .collect::<Vec<_>>()
1269 .join("\n");
1270 let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
1271 .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
1272 .iter()
1273 .map(tatara_lisp::Spanned::to_sexp)
1274 .collect();
1275 assert_eq!(
1276 round_tripped, erased,
1277 "{src:?}: the old print-and-reparse path changed the tree"
1278 );
1279 }
1280 }
1281
1282 /// Anti-vacuity for the test above: the round trip really is *not* the
1283 /// identity in general, so agreeing on the corpus was a property of what
1284 /// blue happens to emit rather than a property of the reader.
1285 ///
1286 /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
1287 /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
1288 /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
1289 /// domain, and the failure is *silent*: a symbol containing a space prints
1290 /// as two tokens, reads back as two symbols, and the result is a perfectly
1291 /// well-formed tree with a different meaning. No error, nothing to catch.
1292 ///
1293 /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
1294 /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
1295 /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
1296 /// in and one symbol out, so the brace-fusion reported in tatara *source*
1297 /// is not what bites a printed tree. The silent pair is what makes this a
1298 /// miscompile class rather than a noisy one.
1299 #[test]
1300 fn the_round_trip_is_not_the_identity_in_general() {
1301 let tree = Sexp::List(vec![
1302 Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
1303 Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
1304 ]);
1305 let text = tree.to_string();
1306 let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
1307 .expect("it reads back cleanly — that IS the problem")
1308 .iter()
1309 .map(tatara_lisp::Spanned::to_sexp)
1310 .collect();
1311 assert_ne!(
1312 back,
1313 vec![tree.clone()],
1314 "if print-then-read became inverse over symbols, the class would be \
1315 closed upstream and this test should be deleted rather than relaxed"
1316 );
1317 // …and the direct lowering is unaffected by any of it.
1318 let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
1319 .iter()
1320 .map(tatara_lisp::Spanned::to_sexp)
1321 .collect();
1322 assert_eq!(direct, vec![tree]);
1323 }
1324}
1325
1326#[cfg(test)]
1327mod macro_tests {
1328 use super::*;
1329
1330 fn int(src: &str) -> i64 {
1331 match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
1332 Value::Int(v) => v,
1333 other => panic!("{src:?} produced {other:?}"),
1334 }
1335 }
1336
1337 /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
1338 #[test]
1339 fn a_macro_expands_and_runs() {
1340 assert_eq!(
1341 int("defmacro double(x)\n quote\n unquote(x) + unquote(x)\n end\nend\ndouble(21)"),
1342 42
1343 );
1344 }
1345
1346 /// A macro receives *source forms*, not values — so it can duplicate its
1347 /// argument, which a function cannot do without re-evaluating it.
1348 #[test]
1349 fn a_macro_operates_on_syntax_not_values() {
1350 assert_eq!(
1351 int("defmacro sq(e)\n quote\n unquote(e) * unquote(e)\n end\nend\nsq(2 + 3)"),
1352 25,
1353 "the argument form `2 + 3` must be substituted twice"
1354 );
1355 }
1356
1357 /// **A runaway macro is a typed error, not a dead compiler.** This is the
1358 /// property that makes the metaprogramming surface safe to hand to a user.
1359 ///
1360 /// The assertion was `contains("expansion limit")` and now reads
1361 /// `contains("expansion")`, because the pipeline renders `short_message`
1362 /// rather than `Display`: upstream words the same fact as "exceeded 256
1363 /// expansion steps" instead of "exceeded the expansion limit of 256
1364 /// rewrites". The PROPERTY is unchanged and still asserted — the macro is
1365 /// named, and the failure is attributed to expansion rather than to
1366 /// evaluation. **This reword was the whole measured blast radius of that
1367 /// switch**, one test in the workspace.
1368 ///
1369 /// The number is deliberately NOT pinned. It is upstream's constant, this
1370 /// test owns none of it, and matching it would turn an upstream bump into
1371 /// a red run here that proves nothing about blue.
1372 ///
1373 /// The third clause is new capability rather than repair: the message now
1374 /// carries `<anonymous>:6:1`, so a runaway macro says WHERE it ran away.
1375 #[test]
1376 fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
1377 let err =
1378 run("defmacro forever(x)\n quote\n forever(unquote(x))\n end\nend\nforever(1)")
1379 .expect_err("a self-referential macro must be rejected");
1380 let msg = err.to_string();
1381 assert!(
1382 msg.contains("forever") && msg.contains("expansion"),
1383 "the error must name the macro and the limit it hit: {msg}"
1384 );
1385 assert!(
1386 msg.contains("<anonymous>:6:1"),
1387 "and must place the call that ran away — line 6 is `forever(1)`: {msg}"
1388 );
1389 }
1390}
1391
1392#[cfg(test)]
1393mod input_tests {
1394 use super::*;
1395 use crate::inputs::{Declaration, Inputs};
1396
1397 /// A schema a macro will generate code from.
1398 const SCHEMA: &[u8] = b"3";
1399
1400 fn with_schema(src: &str) -> Result<Run, RunError> {
1401 let hash = Inputs::hash_of(SCHEMA);
1402 let mut inputs = Inputs::new();
1403 inputs
1404 .bind(
1405 &Declaration {
1406 name: "schema".to_string(),
1407 hash,
1408 },
1409 SCHEMA.to_vec(),
1410 )
1411 .expect("bind");
1412 run_with_inputs(src, inputs)
1413 }
1414
1415 fn decl_line() -> String {
1416 let mut s = String::from("definput(\"schema\", \"");
1417 s.push_str(&Inputs::hash_of(SCHEMA));
1418 s.push_str("\")\n");
1419 s
1420 }
1421
1422 /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
1423 /// the spec names it as gating blue's whole "stronger than Ruby's
1424 /// metaprogramming" claim, because a macro that cannot read a schema cannot
1425 /// generate code from one.
1426 #[test]
1427 fn a_macro_can_read_a_declared_build_input() {
1428 let src = decl_line() + "input(\"schema\")";
1429 let out = with_schema(&src).expect("run");
1430 assert!(
1431 matches!(out.value, Value::Str(ref s) if &**s == "3"),
1432 "got {:?}",
1433 out.value
1434 );
1435 }
1436
1437 /// **An undeclared input is an error, not a file read and not nil.**
1438 /// Returning nil is how a macro generates an empty table and nobody notices
1439 /// until runtime.
1440 #[test]
1441 fn an_undeclared_input_is_an_error() {
1442 let err = with_schema("input(\"not_declared\")").expect_err("must fail");
1443 let msg = err.to_string();
1444 assert!(msg.contains("not_declared"), "must name it: {msg}");
1445 assert!(msg.contains("definput"), "and say how to declare it: {msg}");
1446 }
1447
1448 /// **There is no path-based read at all.** The capability is the absence of
1449 /// the primitive, not a check inside one — so this is an unbound symbol.
1450 ///
1451 /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
1452 /// cargo feature (CLI only) is the one declared exception: it is the
1453 /// operator's own trusted host surface, and is asserted in
1454 /// `sys_read_file_is_the_trusted_cli_only_exception` below.
1455 #[cfg(not(feature = "sys"))]
1456 #[test]
1457 fn there_is_no_ambient_file_read() {
1458 for attempt in [
1459 "read_file(\"/etc/passwd\")",
1460 "File(\"/etc/passwd\")",
1461 "slurp(\"/etc/passwd\")",
1462 "open(\"/etc/passwd\")",
1463 ] {
1464 let err = with_schema(attempt).expect_err("must not resolve");
1465 assert!(
1466 err.to_string().contains("unbound"),
1467 "{attempt} must be UNBOUND — a capability removed by absence, \
1468 not guarded by a check: {err}"
1469 );
1470 }
1471 }
1472
1473 /// With the `sys` feature compiled in, `read_file` IS bound — that is the
1474 /// point of the feature. The doctrine does not move: this is the operator's
1475 /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
1476 /// future default-build change is heard, and assert that `input()` still
1477 /// works beside it.
1478 #[cfg(feature = "sys")]
1479 #[test]
1480 fn sys_read_file_is_the_trusted_cli_only_exception() {
1481 let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
1482 assert!(err.to_string().contains("unbound"), "{err}");
1483 assert!(
1484 with_schema("read_file(\"/etc/passwd\")").is_ok(),
1485 "with `sys` on, read_file is the trusted CLI surface"
1486 );
1487 let out = with_schema("input(\"schema\")").expect("run");
1488 assert!(
1489 matches!(out.value, Value::Str(ref s) if &**s == "3"),
1490 "input() still binds beside the sys surface: {:?}",
1491 out.value
1492 );
1493 }
1494
1495 /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
1496 /// — so the success above is the binding's doing.
1497 #[test]
1498 fn a_declared_input_with_no_bytes_supplied_fails() {
1499 let src = decl_line() + "input(\"schema\")";
1500 assert!(run(&src).is_err(), "no bytes were supplied");
1501 }
1502}
1503
1504#[cfg(test)]
1505mod tier2_tests {
1506 use super::*;
1507 use crate::inputs::{Declaration, Inputs};
1508
1509 /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
1510 /// declarations FROM A SCHEMA.**
1511 ///
1512 /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
1513 /// installs a `NoLoader`, so a macro cannot read a schema — which gates
1514 /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
1515 /// Ruby's metaprogramming' claim."
1516 ///
1517 /// Here the schema supplies a *value the generated code depends on*, read at
1518 /// expansion time. Ruby and Elixir can both do this — with the whole
1519 /// filesystem open. blue does it through a name bound to a content hash.
1520 #[test]
1521 fn a_macro_generates_code_from_a_schema() {
1522 let schema = b"7";
1523 let mut inputs = Inputs::new();
1524 inputs
1525 .bind(
1526 &Declaration {
1527 name: "arity".to_string(),
1528 hash: Inputs::hash_of(schema),
1529 },
1530 schema.to_vec(),
1531 )
1532 .expect("bind");
1533
1534 // The macro reads the input at EXPANSION time and splices the value it
1535 // found into the code it emits.
1536 let mut src = String::from("definput(\"arity\", \"");
1537 src.push_str(&Inputs::hash_of(schema));
1538 src.push_str("\")\n");
1539 src.push_str(
1540 "defmacro from_schema()\n quote\n unquote(to_int(input(\"arity\")))\n end\nend\n\
1541 from_schema() * 6",
1542 );
1543
1544 let out = run_with_inputs(&src, inputs).expect("run");
1545 assert!(
1546 matches!(out.value, Value::Int(42)),
1547 "the schema's 7 must reach the generated code: got {:?}",
1548 out.value
1549 );
1550 }
1551}