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