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 mut value = eval_prepared(&mut interp, &prepared, &mut ())?;
190 if let Some(v) = call_main(&mut interp, &prepared, &mut ())? {
191 value = v;
192 }
193 Ok(Run {
194 value,
195 visited: prepared.visited,
196 typed_decls: prepared.typed_decls,
197 seams: prepared.seams,
198 })
199}
200
201/// A run watched by a call observer, and the definitions the observer was
202/// built over.
203pub struct Observed {
204 pub definitions: std::sync::Arc<crate::observe::Definitions>,
205 /// The run itself: its value, or the error it stopped on.
206 pub outcome: Result<Run, RunError>,
207}
208
209/// [`run_in_surface`] with a call observer installed for the evaluation.
210/// `watch` builds the observer from the program's definitions once every stage
211/// before evaluation has passed; the profiler, the tracer and coverage are
212/// all built this way (`crate::observe::Recorder`).
213///
214/// # Errors
215///
216/// A parse, import or check error, which leaves nothing to run. A runtime
217/// error is the [`Observed::outcome`], so what ran before it is still read.
218pub fn run_observed(
219 entry: Entry<'_>,
220 inputs: Inputs,
221 loader: &dyn crate::uses::Loader,
222 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
223 watch: impl FnOnce(
224 &std::sync::Arc<crate::observe::Definitions>,
225 ) -> std::sync::Arc<dyn crate::observe::CallObserver>,
226) -> Result<Observed, RunError> {
227 let mut interp = crate::interpreter_hostless();
228 crate::inputs::install_input_primitives(&mut interp, inputs);
229 let prepared = prepare(entry, loader, surface, &builtin_names(&interp))?;
230 let definitions = std::sync::Arc::new(crate::observe::Definitions::of(
231 &prepared.program,
232 &prepared.erased,
233 ));
234 interp.set_call_observer(Some(watch(&definitions)));
235 let outcome = eval_prepared(&mut interp, &prepared, &mut ())
236 .and_then(|value| Ok(call_main(&mut interp, &prepared, &mut ())?.unwrap_or(value)))
237 .map(|value| Run {
238 value,
239 visited: prepared.visited,
240 typed_decls: prepared.typed_decls,
241 seams: prepared.seams,
242 });
243 interp.set_call_observer(None);
244 Ok(Observed {
245 definitions,
246 outcome,
247 })
248}
249
250/// A program past every stage before evaluation: parsed, its imports
251/// resolved, type-checked and erased. What [`run_in_surface`] and
252/// [`crate::hosted::load_hosted`] both evaluate, so the stage order this
253/// module exists to own is written once for both.
254pub(crate) struct Prepared {
255 program: crate::uses::ResolvedProgram,
256 erased: Vec<tatara_lisp::Spanned>,
257 visited: usize,
258 typed_decls: usize,
259 seams: usize,
260}
261
262impl Prepared {
263 /// The forms the evaluator will run: every file's, resolved and erased.
264 pub(crate) fn erased(&self) -> &[tatara_lisp::Spanned] {
265 &self.erased
266 }
267
268 /// The entry file's namespace.
269 pub(crate) fn entry_namespace(&self) -> Namespace {
270 let first = (0..self.program.forms().len())
271 .find(|i| self.program.owner_of(*i) == Some(crate::uses::ResolvedProgram::ENTRY));
272 match first {
273 Some(i) => namespace_of(&self.program, i),
274 None => match self
275 .program
276 .file(crate::uses::ResolvedProgram::ENTRY)
277 .and_then(|f| f.package.clone())
278 {
279 Some(p) => Namespace::Bidama(p),
280 None => Namespace::File(String::new()),
281 },
282 }
283 }
284}
285
286/// Parse, resolve, check and erase, in that order.
287///
288/// `builtins` is the name table of the interpreter that will evaluate the
289/// result ([`builtin_names`]); the check stage resolves the program's names
290/// against it.
291pub(crate) fn prepare(
292 entry: Entry<'_>,
293 loader: &dyn crate::uses::Loader,
294 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
295 builtins: &NameTable,
296) -> Result<Prepared, RunError> {
297 let mut program = parse_and_resolve(entry, loader, surface)?;
298
299 // `test` blocks are declarations for the harness, not code to run.
300 //
301 // Dropped here rather than in `resolve_uses`, because `blue test` calls
302 // the resolver and then NEEDS the entry file's blocks — so the two
303 // callers want different things and the split has to live at this level.
304 //
305 // Without this, `blue run` on a file containing its own tests fails with
306 // `unbound symbol: deftest`: every package in the bidama distribution
307 // carries tests, so every one of them was unrunnable.
308 //
309 // Through `retain`, which drops each form's owner with it. A plain filter
310 // over the forms alone would slide every later form onto the wrong file.
311 program.retain(|f| !crate::uses::is_test_form(f));
312
313 // CHECK, on the annotated tree — the only tree that has annotations.
314 //
315 // **On the REAL spanned tree, including every imported package's.** This
316 // was the one caller that checked a spanless lift, because `resolve_uses`
317 // flattened the entry file and its imports into one list and `Span` is a
318 // byte range with no file identity — so a real span here would have
319 // reported an imported package's error at that offset in the ENTRY file, a
320 // precise-looking answer pointing at unrelated code.
321 //
322 // The fix is not a wider `Span` (that type is upstream, and its own docs
323 // put file identity on the caller: spans "are meaningful only relative to
324 // the string that produced them, which the caller is responsible for
325 // holding onto"). blue holds onto it BESIDE the span, per top-level form —
326 // see `uses::ResolvedProgram`.
327 let (outcome, names) = check_stage(&program, builtins, false);
328 if !outcome.ok() {
329 return Err(RunError::Types(
330 outcome
331 .errors()
332 // `file:line:col: error[CODE]: message`, resolved against the
333 // file the form actually came from, then its help line.
334 .map(|d| render(&program, d))
335 .collect(),
336 ));
337 }
338
339 // ERASE, so the interpreter never sees a type — **on the spanned tree,
340 // and back out as one.**
341 //
342 // This used to be `erase_types(&program.sexps())` followed by a
343 // `lower_to_spanned` that stamped `Span::synthetic()` over every node, and
344 // the comment here said carrying real positions through was "a larger
345 // piece and is NOT built". It was not larger: erasure only ever DELETES
346 // nodes — the single node it invents is the `define` replacing
347 // `define-typed` — so there was never anything for a synthetic span to
348 // stand in for. See `crate::erase`.
349 //
350 // No lowering step follows. The tree the evaluator receives IS the tree the
351 // parser built, minus annotations, with the author's byte offsets intact.
352 let erased = erase_types(&lower(&program, &names));
353
354 Ok(Prepared {
355 program,
356 erased,
357 visited: outcome.stats.visited,
358 typed_decls: outcome.stats.typed_decls,
359 seams: outcome.seams.len(),
360 })
361}
362
363/// Parse the entry (in `surface`, if one is given) and splice in its imports.
364pub(crate) fn parse_and_resolve(
365 entry: Entry<'_>,
366 loader: &dyn crate::uses::Loader,
367 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
368) -> Result<crate::uses::ResolvedProgram, RunError> {
369 let forms = match surface {
370 Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
371 .map_err(|e| RunError::Parse(e.to_string()))?,
372 None => loader
373 .parse(entry.text)
374 .map_err(|e| RunError::Parse(e.to_string()))?,
375 };
376
377 // RESOLVE imports first, so everything below sees ONE program.
378 //
379 // Before the check on purpose: imported code is type-checked at the point
380 // its consumer imports it, rather than at whatever later moment its code
381 // first runs. A package that does not typecheck should break its importer's
382 // build, not their production run.
383 //
384 // One program, but not one FILE: the result records which file each
385 // top-level form came from, which is what lets a diagnostic below name a
386 // place instead of only a problem.
387 crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)
388}
389
390/// **The check stage**: every rule in `blue_lang_check::RULES`, over a
391/// resolved program, with waivers applied.
392///
393/// One function, called by every door — `run`, the hosted loader, `blue test`
394/// and `blue check` — so a rule added to the registry is enforced by all of
395/// them at once. That is what makes an architecture rule a pipeline error
396/// rather than an optional report: there is no door that skips it.
397///
398/// Warnings (unused bindings, unused waivers) are reported for the ENTRY
399/// file only; an imported package's warnings belong to its author. Errors
400/// are reported for every file, because an imported package with one is
401/// broken for its importer.
402pub(crate) fn check_stage(
403 program: &crate::uses::ResolvedProgram,
404 builtins: &NameTable,
405 with_tests: bool,
406) -> (blue_lang_check::Outcome, NameTable) {
407 use crate::uses::ResolvedProgram;
408 let forms = program.forms();
409 let is_entry = |i: usize| program.owner_of(i) == Some(ResolvedProgram::ENTRY);
410
411 // Rules B0003–B0005: the typing ladder.
412 let mut outcome = blue_lang_check::check_program(forms);
413
414 // Rules B0001–B0002: names. The program's own scopes come first (the
415 // entry file, then each bidama in import order), then the interpreter's.
416 let table = program_names(program, builtins);
417 let whole = |i: usize| with_tests && is_entry(i);
418 let (names, resolved) = blue_lang_check::names::check_names_whole(
419 forms,
420 &table,
421 &|i| namespace_of(program, i),
422 &is_entry,
423 &whole,
424 );
425 let stored = program
426 .file(ResolvedProgram::ENTRY)
427 .is_some_and(|f| f.path.is_some());
428 outcome.diagnostics.extend(names);
429 outcome.stats.names_resolved = resolved;
430 if stored {
431 let script = program
432 .file(ResolvedProgram::ENTRY)
433 .is_some_and(|f| f.package.is_none());
434 let found = blue_lang_check::form_rules::check(forms, &is_entry, script);
435 let silenced: Vec<tatara_lisp::Span> = found
436 .iter()
437 .filter(|d| d.code == blue_lang_check::Code::B0038)
438 .map(|d| d.span)
439 .collect();
440 outcome
441 .diagnostics
442 .retain(|d| !(d.code == blue_lang_check::Code::B0002 && silenced.contains(&d.span)));
443 outcome.diagnostics.extend(found);
444 }
445
446 // Waivers, per file: B0007 for a malformed one, B0008 for one that
447 // suppresses nothing.
448 let mut waivers = Vec::new();
449 for file in program.files() {
450 let spans: Vec<(usize, tatara_lisp::Span)> = forms
451 .iter()
452 .enumerate()
453 .filter(|(i, _)| program.owner_of(*i) == Some(file.id))
454 .map(|(i, f)| (i, f.span))
455 .collect();
456 let (found, malformed) = blue_lang_check::waiver::collect(&file.text, &spans);
457 waivers.extend(found);
458 outcome.diagnostics.extend(malformed);
459 }
460 outcome.waived = blue_lang_check::waiver::apply(&mut outcome.diagnostics, waivers, &is_entry);
461 if !stored {
462 outcome.diagnostics.retain(|d| {
463 !matches!(
464 d.code,
465 blue_lang_check::Code::B0023
466 | blue_lang_check::Code::B0036
467 | blue_lang_check::Code::B0037
468 )
469 });
470 }
471 let judge = blue_lang_check::evolution::FormJudge::from_env().unwrap_or_default();
472 outcome
473 .diagnostics
474 .retain(|d| judge.reports(d.code, is_entry(d.top_level)));
475 // A rule whose census has not reached zero is computed, and counted, but
476 // not enforced: its findings move to `census` (see `RULES`' `ratchet`).
477 let (enforced, census): (Vec<_>, Vec<_>) = std::mem::take(&mut outcome.diagnostics)
478 .into_iter()
479 .partition(|d| d.code.rule().active());
480 outcome.diagnostics = enforced;
481 outcome.census = census;
482 outcome
483 .diagnostics
484 .sort_by_key(|d| (d.top_level, d.span.start, d.code));
485 (outcome, table)
486}
487
488/// The whole name table the check stage resolves against: the program's
489/// own scopes (each file and bidama, by namespace) followed by `builtins`.
490///
491/// **A top-level call to a builtin macro can define names**: `defflow(slug,
492/// trim, downcase)` expands to `(define slug …)`, and nothing in the source
493/// spells `define`. Such a call is expanded here, on a hostless fork, and what
494/// its expansion defines is bound in the calling file's namespace. Only
495/// BUILTIN macros are expanded: their bodies are the runtime's own
496/// templates. A program's own `defmacro` is not run at check time — checking a
497/// file (and an editor checking on every keystroke) must not execute the code
498/// it checks — so names a program macro generates are not seen; none of the
499/// corpus's macros generates one (measured 2026-09-29).
500#[must_use]
501pub fn program_names(program: &crate::uses::ResolvedProgram, builtins: &NameTable) -> NameTable {
502 use blue_lang_check::Binding;
503 let mut table = NameTable::new();
504 table.add_program(program.forms(), |i| namespace_of(program, i));
505 let builtin_macro = |head: &str| {
506 builtins
507 .scopes()
508 .iter()
509 .any(|s| s.namespace == Namespace::Macro && s.get(head).is_some())
510 };
511 let mut expander: Option<tatara_lisp_eval::Interpreter<()>> = None;
512 for (i, form) in program.forms().iter().enumerate() {
513 let Some(head) = form
514 .as_list()
515 .and_then(|items| items.first())
516 .and_then(tatara_lisp::Spanned::as_symbol)
517 else {
518 continue;
519 };
520 if !builtin_macro(head) {
521 continue;
522 }
523 let interp = expander.get_or_insert_with(crate::interpreter_hostless);
524 // A failed expansion defines nothing here; the evaluator will report
525 // it, with its position, when the form runs.
526 let Ok(expanded) = interp.fully_expand(form, &mut ()) else {
527 continue;
528 };
529 let nullary = blue_lang_syntax::scope::nullary_definition(&expanded);
530 for (name, span, kind) in blue_lang_check::names::definitions_of(&expanded) {
531 table.define(
532 namespace_of(program, i),
533 Binding {
534 nullary: nullary == Some(name.as_str()),
535 name,
536 kind,
537 span: Some(span),
538 top_level: Some(i),
539 },
540 );
541 }
542 }
543 for scope in builtins.scopes() {
544 table.push(scope.clone());
545 }
546 table.spell_bare_like(builtins);
547 let form_file: Vec<usize> = (0..program.forms().len())
548 .map(|i| program.owner_of(i).map_or(usize::MAX, |f| f.index()))
549 .collect();
550 let mut imports: std::collections::BTreeMap<usize, blue_lang_check::names::FileImports> =
551 std::collections::BTreeMap::new();
552 for (file, import) in program.imports() {
553 let entry = imports.entry(file.index()).or_default();
554 entry.add(&import.package, import.names.iter().map(|(n, _)| n.clone()));
555 let at = import.span.start;
556 entry.use_at = Some(entry.use_at.map_or(at, |a| a.min(at)));
557 }
558 // A FACADE — a bidama that defines nothing and `use`s others (zenbu) —
559 // exists so a consumer can depend on "everything": `use("zenbu")` makes
560 // every member's QUALIFIER reachable (`toukei::median`), never a bare
561 // name. Followed to a fixpoint, so a facade of facades works too.
562 let facade_uses =
563 |pkg: &str,
564 imports: &std::collections::BTreeMap<usize, blue_lang_check::names::FileImports>| {
565 let defines = table.bidama(pkg).is_some_and(|s| !s.is_empty());
566 if defines {
567 return std::collections::BTreeSet::new();
568 }
569 program
570 .files()
571 .iter()
572 .filter(|f| f.package.as_deref() == Some(pkg))
573 .filter_map(|f| imports.get(&f.id.index()))
574 .flat_map(|i| i.uses.iter().cloned())
575 .collect::<std::collections::BTreeSet<String>>()
576 };
577 loop {
578 let mut grew = false;
579 let files: Vec<usize> = imports.keys().copied().collect();
580 for f in files {
581 let used: Vec<String> = imports[&f].uses.iter().cloned().collect();
582 for p in used {
583 for member in facade_uses(&p, &imports) {
584 if let Some(i) = imports.get_mut(&f) {
585 if i.uses.insert(member.clone()) {
586 i.via.insert(member, p.clone());
587 grew = true;
588 }
589 }
590 }
591 }
592 }
593 if !grew {
594 break;
595 }
596 }
597 let mut pkg_uses: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
598 std::collections::BTreeMap::new();
599 for (file, import) in program.imports() {
600 if let Some(p) = program.file(*file).and_then(|f| f.package.clone()) {
601 pkg_uses
602 .entry(p)
603 .or_default()
604 .insert(import.package.clone());
605 }
606 }
607 table.attach_files(form_file, imports);
608 table.attach_package_uses(pkg_uses);
609 table.attach_needs(program.needs().clone());
610 // Where each file lives and which distribution each bidama ships in: a
611 // bridge reference (B0021) is an error inside the bidama's own
612 // distribution and a warning outside it.
613 let canonical = |p: &std::path::Path| {
614 std::fs::canonicalize(p)
615 .unwrap_or_else(|_| p.to_path_buf())
616 .display()
617 .to_string()
618 };
619 let mut file_paths = std::collections::BTreeMap::new();
620 let mut pkg_roots = std::collections::BTreeMap::new();
621 for f in program.files() {
622 if let Some(path) = &f.path {
623 file_paths.insert(f.id.index(), canonical(path));
624 if let (Some(pkg), Some(root)) =
625 (&f.package, path.parent().and_then(std::path::Path::parent))
626 {
627 pkg_roots
628 .entry(pkg.clone())
629 .or_insert_with(|| canonical(root));
630 }
631 }
632 }
633 table.attach_paths(file_paths, pkg_roots);
634 let versions = program.versions().clone();
635 table.apply_legacy(program.forms(), &|i| namespace_of(program, i), &|p| {
636 versions.get(p).cloned()
637 });
638 table
639}
640
641/// The forms the evaluator runs, from a checked program: every `use`
642/// declaration inert (`nil`, so each index still names its top-level form)
643/// and every name at the key it runs under — the RESOLVED tree under
644/// per-bidama namespaces (`blue_lang_check::names::Resolved::resolved_tree`).
645///
646/// A definition runs as `retsu/first` (a script's as `%root/f`), a
647/// reference to it as the same key, and a builtin as its bare name. Keys
648/// cannot collide across namespaces, so a bidama's `first` no longer
649/// replaces the builtin for anyone who did not import it, and an importer's
650/// `helper` no longer replaces a bidama's own: the runtime binds exactly what
651/// the check stage resolved.
652#[must_use]
653pub fn lower(
654 program: &crate::uses::ResolvedProgram,
655 names: &NameTable,
656) -> Vec<tatara_lisp::Spanned> {
657 lower_with(program, names, true)
658}
659
660#[must_use]
661pub fn lower_with(
662 program: &crate::uses::ResolvedProgram,
663 names: &NameTable,
664 simplify_chains: bool,
665) -> Vec<tatara_lisp::Spanned> {
666 let mut resolved = blue_lang_check::names::resolve_program(program.forms(), names, &|i| {
667 namespace_of(program, i)
668 });
669 let simplified = if simplify_chains {
670 chain_simplified(program.forms(), &resolved)
671 } else {
672 None
673 };
674 let forms: &[tatara_lisp::Spanned] = simplified.as_deref().unwrap_or(program.forms());
675 if simplified.is_some() {
676 resolved =
677 blue_lang_check::names::resolve_program(forms, names, &|i| namespace_of(program, i));
678 }
679 resolved
680 .resolved_tree(forms, blue_lang_check::names::Rule::Namespaced)
681 .into_iter()
682 .zip(forms)
683 .enumerate()
684 .map(|(i, (resolved, written))| {
685 if crate::uses::use_target(written).is_some()
686 || blue_lang_syntax::scope::legacy_target(written).is_some()
687 {
688 tatara_lisp::Spanned::new(written.span, tatara_lisp::SpannedForm::Nil)
689 } else {
690 let file = program.owner_of(i).and_then(|id| program.file(id));
691 match file {
692 Some(f) => locate_raises(&resolved, &raise_site(f)),
693 None => resolved,
694 }
695 }
696 })
697 .collect()
698}
699
700fn chain_simplified(
701 forms: &[tatara_lisp::Spanned],
702 resolved: &blue_lang_check::names::Resolved,
703) -> Option<Vec<tatara_lisp::Spanned>> {
704 let respell: std::collections::HashMap<usize, blue_lang_check::respell::Respelling> = resolved
705 .respellings
706 .iter()
707 .filter(|(_, r)| matches!(r.respell, blue_lang_check::respell::Respell::Chain(_)))
708 .cloned()
709 .collect();
710 if respell.is_empty() {
711 return None;
712 }
713 let bare = std::collections::HashSet::new();
714 let canon = Canon {
715 bare: &bare,
716 respell: &respell,
717 };
718 Some(forms.iter().map(|f| canon.form(f)).collect())
719}
720
721fn raise_site(file: &crate::uses::SourceFile) -> impl Fn(tatara_lisp::Span) -> String + '_ {
722 move |span| {
723 let (line, _) =
724 blue_lang_syntax::Span::line_col(&file.text, span.start.min(file.text.len()));
725 let shown = file
726 .path
727 .as_ref()
728 .map_or_else(|| "<source>".to_string(), |p| p.display().to_string());
729 format!("{shown}:{line}")
730 }
731}
732
733fn locate_raises(
734 form: &tatara_lisp::Spanned,
735 site: &dyn Fn(tatara_lisp::Span) -> String,
736) -> tatara_lisp::Spanned {
737 use tatara_lisp::{Atom, Spanned, SpannedForm};
738 let inner = match &form.form {
739 SpannedForm::List(items) => {
740 let mut out: Vec<Spanned> = items.iter().map(|i| locate_raises(i, site)).collect();
741 let raising = items.first().and_then(Spanned::as_symbol) == Some("raise")
742 && (3..=4).contains(&items.len());
743 if raising {
744 out[0] = Spanned::new(
745 items[0].span,
746 SpannedForm::Atom(Atom::Symbol(crate::errors::RAISE_AT.to_string())),
747 );
748 out.insert(
749 1,
750 Spanned::new(items[0].span, SpannedForm::Atom(Atom::Str(site(form.span)))),
751 );
752 }
753 SpannedForm::List(out)
754 }
755 SpannedForm::Quote(_) | SpannedForm::Quasiquote(_) => return form.clone(),
756 other => other.clone(),
757 };
758 Spanned::new(form.span, inner)
759}
760
761/// The namespace top-level form `i` of `program` defines into.
762#[must_use]
763pub fn namespace_of(program: &crate::uses::ResolvedProgram, i: usize) -> Namespace {
764 let file = program.owner_of(i).and_then(|id| program.file(id));
765 match file {
766 Some(f) => match &f.package {
767 Some(p) => Namespace::Bidama(p.clone()),
768 None => Namespace::File(
769 f.path
770 .as_ref()
771 .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string()),
772 ),
773 },
774 None => Namespace::File("<unknown file>".to_string()),
775 }
776}
777
778/// Names only the test harness binds.
779///
780/// In scope for EVERY check, not only for test blocks. A bidama may carry a
781/// test helper — a plain `def` that asserts, called only from its `test`
782/// blocks (`heni`'s `hn_end_to_end` is one) — and every program importing that
783/// bidama would otherwise be refused for a function it never calls. The
784/// refusal would be scoped to the importer's whole program, not to the fault.
785/// The cost, stated: an `assert` outside any test that `blue run` actually
786/// reaches is caught at runtime (`blue-assert` is unbound there), not here.
787///
788/// `blue-lang-test` registers these on its own interpreter, and this crate
789/// cannot depend on it (it depends on this one), so the list lives beside the
790/// only name in it: the lowered `assert`, which `blue-lang-syntax` owns.
791pub const HARNESS_NAMES: &[&str] = &[blue_lang_syntax::LOWERED_ASSERT];
792
793/// The check stage's builtin scopes, read off the interpreter that will run
794/// the program: the test harness's names ([`HARNESS_NAMES`]), then its special
795/// forms, its macros, and every value it binds — the last three in the order
796/// the evaluator consults for a head.
797///
798/// Read from a live interpreter rather than a list, for the reason
799/// `Interpreter::resolve_head` gives: a list is a second copy of the truth and
800/// goes stale on the next installer that lands. An embedder's own
801/// primitives are in scope here because the embedder installs them first.
802#[must_use]
803pub fn builtin_names<H: 'static>(interp: &tatara_lisp_eval::Interpreter<H>) -> NameTable {
804 builtin_names_with(interp, &[])
805}
806
807#[must_use]
808pub fn builtin_names_with<H: 'static>(
809 interp: &tatara_lisp_eval::Interpreter<H>,
810 host: &[crate::host_surface::HostName],
811) -> NameTable {
812 use blue_lang_check::{Binding, Scope, ScopeKind};
813 use tatara_lisp_eval::HeadBinding;
814 let mut special = Scope::new(Namespace::SpecialForm);
815 let mut macros = Scope::new(Namespace::Macro);
816 let mut values = Scope::new(Namespace::Builtin);
817 // `reserved_head_names` reads the ROOT frame only, so a name installed on
818 // a fork (the input primitives, on every `run`) is invisible to it; those
819 // are asked about by name instead, which `resolve_head` answers through
820 // every frame.
821 let mut names: Vec<std::sync::Arc<str>> = interp.reserved_head_names().into_iter().collect();
822 names.extend(
823 crate::inputs::INPUT_PRIMITIVES
824 .iter()
825 .filter(|n| interp.resolve_head(n).is_some())
826 .map(|n| std::sync::Arc::from(*n)),
827 );
828 for name in names {
829 let (scope, kind) = match interp.resolve_head(&name) {
830 Some(HeadBinding::SpecialForm) => (&mut special, ScopeKind::SpecialForm),
831 Some(HeadBinding::Macro) => (&mut macros, ScopeKind::Macro),
832 _ => (&mut values, ScopeKind::Value),
833 };
834 let nullary = kind == ScopeKind::Value
835 && interp.lookup_global(&name).is_some_and(|v| {
836 nullary_value(&v)
837 || (crate::docs::doc_of(&name).is_some_and(|d| d.signature == &*name)
838 && matches!(
839 v,
840 tatara_lisp_eval::Value::NativeFn(_)
841 | tatara_lisp_eval::Value::Closure(_)
842 ))
843 });
844 scope.bind(Binding {
845 name: name.to_string(),
846 kind,
847 span: None,
848 top_level: None,
849 nullary,
850 });
851 }
852 for h in host {
853 values.bind(Binding {
854 name: h.name.clone(),
855 kind: ScopeKind::Value,
856 span: None,
857 top_level: None,
858 nullary: h.nullary(),
859 });
860 }
861 let mut table = NameTable::new();
862 let mut harness = Scope::new(Namespace::Harness);
863 for n in HARNESS_NAMES {
864 harness.bind(Binding {
865 name: (*n).to_string(),
866 kind: ScopeKind::Value,
867 span: None,
868 top_level: None,
869 nullary: false,
870 });
871 }
872 table.push(harness);
873 table.push(special);
874 table.push(macros);
875 table.push(values);
876 table.spell_bare_only(
877 crate::docs::NAMES
878 .iter()
879 .filter(|d| d.signature == d.name)
880 .map(|d| d.name.to_string())
881 .chain(host.iter().filter(|h| h.nullary()).map(|h| h.name.clone()))
882 .collect(),
883 );
884 table
885}
886
887#[must_use]
888pub fn nullary_value(value: &tatara_lisp_eval::Value) -> bool {
889 match value {
890 tatara_lisp_eval::Value::NativeFn(f) => {
891 matches!(f.arity, tatara_lisp_eval::ffi::Arity::Exact(0))
892 }
893 tatara_lisp_eval::Value::Closure(c) => c.params.is_empty() && c.rest.is_none(),
894 _ => false,
895 }
896}
897
898/// A diagnostic as a person reads it: `file:line:col: error[B0001]: message`,
899/// then an indented `help:` line when it has one, and a `fix:` line naming
900/// the one command that applies blue's fixer for it.
901#[must_use]
902pub fn render(program: &crate::uses::ResolvedProgram, d: &blue_lang_check::Diagnostic) -> String {
903 let head = d.to_string();
904 let mut out = program.locate(d.top_level, d.span, &head).to_string();
905 if let Some(help) = &d.help {
906 out.push_str("\n help: ");
907 out.push_str(help);
908 }
909 let file = program
910 .owner_of(d.top_level)
911 .and_then(|id| program.file(id))
912 .and_then(|f| f.path.as_ref());
913 if let Some(cmd) =
914 file.and_then(|p| blue_lang_check::evolution::fix_command(d, &p.display().to_string()))
915 {
916 out.push_str("\n fix: ");
917 out.push_str(&cmd);
918 }
919 out
920}
921
922/// What [`check_entry`] checks.
923#[derive(Clone, Copy, Debug, PartialEq, Eq)]
924pub enum Checking {
925 /// The program `blue run` would evaluate: test blocks dropped.
926 Program,
927 /// The program plus the entry file's test blocks, as `blue test` and
928 /// `blue check` see it.
929 WithTests,
930}
931
932/// A program through the check stage, with everything the stage found.
933pub struct Checked {
934 pub program: crate::uses::ResolvedProgram,
935 pub outcome: blue_lang_check::Outcome,
936 /// The name table the stage resolved against: every program scope, then
937 /// the builtins. What an editor completes from.
938 pub names: NameTable,
939}
940
941impl Checked {
942 /// The forms an evaluator runs: [`lower`]ed, then erased — the tree
943 /// `run` evaluates, for a door that brings its own evaluator (the VM).
944 #[must_use]
945 pub fn erased(&self) -> Vec<tatara_lisp::Spanned> {
946 erase_types(&lower(&self.program, &self.names))
947 }
948
949 /// What `blue test` hands the harness: the program [`lower`]ed, spans
950 /// projected away.
951 #[must_use]
952 pub fn evaluable(&self) -> Vec<tatara_lisp::Sexp> {
953 lower(&self.program, &self.names)
954 .iter()
955 .map(tatara_lisp::Spanned::to_sexp)
956 .collect()
957 }
958
959 /// Every non-local reference, resolved under both rules — today's flat
960 /// one and per-bidama namespaces — and the resolved trees built from them
961 /// (`blue ast --resolved`).
962 #[must_use]
963 pub fn resolve(&self) -> blue_lang_check::names::Resolved {
964 blue_lang_check::names::resolve_program(self.program.forms(), &self.names, &|i| {
965 namespace_of(&self.program, i)
966 })
967 }
968}
969
970impl Checked {
971 #[must_use]
972 pub fn call_explicit_forms(&self) -> Vec<tatara_lisp::Spanned> {
973 self.resolve().call_explicit_tree(self.program.forms())
974 }
975
976 #[must_use]
977 pub fn canonical_entry(&self, src: &str) -> Option<Vec<tatara_lisp::Spanned>> {
978 use crate::uses::ResolvedProgram;
979 let program = &self.program;
980 let entry: Vec<&tatara_lisp::Spanned> = program
981 .forms()
982 .iter()
983 .enumerate()
984 .filter(|(i, _)| program.owner_of(*i) == Some(ResolvedProgram::ENTRY))
985 .map(|(_, f)| f)
986 .collect();
987 let written = blue_lang_syntax::parse_program_tree(src).ok()?;
988 let same = written.len() == entry.len()
989 && written
990 .iter()
991 .zip(&entry)
992 .all(|(w, e)| w.to_sexp() == e.to_sexp() && w.span == e.span);
993 if !same {
994 return None;
995 }
996 let resolved = self.resolve();
997 let bare: std::collections::HashSet<usize> = resolved
998 .parenthesized_nullary_calls()
999 .into_iter()
1000 .filter(|r| program.owner_of(r.top_level) == Some(ResolvedProgram::ENTRY))
1001 .map(blue_lang_check::names::Reference::node_address)
1002 .collect();
1003 let waived: Vec<tatara_lisp::Span> = self
1004 .outcome
1005 .waived
1006 .iter()
1007 .map(|w| w.diagnostic.span)
1008 .collect();
1009 let respell: std::collections::HashMap<usize, blue_lang_check::respell::Respelling> =
1010 resolved
1011 .respellings
1012 .iter()
1013 .filter(|(_, r)| !waived.contains(&r.span))
1014 .cloned()
1015 .collect();
1016 let canon = Canon {
1017 bare: &bare,
1018 respell: &respell,
1019 };
1020 let forms: Vec<tatara_lisp::Spanned> = entry.into_iter().map(|f| canon.form(f)).collect();
1021 let script = program
1022 .file(ResolvedProgram::ENTRY)
1023 .is_some_and(|f| f.package.is_none());
1024 let forms = if script {
1025 blue_lang_check::form_rules::without_prefix(&forms).unwrap_or(forms)
1026 } else {
1027 forms
1028 };
1029 let forms = blue_lang_check::form_rules::without_main_guard(&forms).unwrap_or(forms);
1030 Some(blue_lang_check::form_rules::eta_reduced(&forms).unwrap_or(forms))
1031 }
1032}
1033
1034struct Canon<'a> {
1035 bare: &'a std::collections::HashSet<usize>,
1036 respell: &'a std::collections::HashMap<usize, blue_lang_check::respell::Respelling>,
1037}
1038
1039impl Canon<'_> {
1040 fn form(&self, form: &tatara_lisp::Spanned) -> tatara_lisp::Spanned {
1041 use tatara_lisp::{Spanned, SpannedForm};
1042 let inner = match &form.form {
1043 SpannedForm::List(items) => match items.as_slice() {
1044 [head] if self.bare.contains(&(std::ptr::from_ref(head) as usize)) => {
1045 return Spanned::new(form.span, head.form.clone());
1046 }
1047 [head, ..]
1048 if self
1049 .respell
1050 .contains_key(&(std::ptr::from_ref(head) as usize)) =>
1051 {
1052 let r = &self.respell[&(std::ptr::from_ref(head) as usize)];
1053 let walked: Vec<Spanned> = items.iter().map(|i| self.form(i)).collect();
1054 return blue_lang_check::respell::apply(form, &walked, r);
1055 }
1056 _ => SpannedForm::List(items.iter().map(|i| self.form(i)).collect()),
1057 },
1058 SpannedForm::Quote(i) => SpannedForm::Quote(Box::new(self.form(i))),
1059 SpannedForm::Quasiquote(i) => SpannedForm::Quasiquote(Box::new(self.form(i))),
1060 SpannedForm::Unquote(i) => SpannedForm::Unquote(Box::new(self.form(i))),
1061 SpannedForm::UnquoteSplice(i) => SpannedForm::UnquoteSplice(Box::new(self.form(i))),
1062 other => other.clone(),
1063 };
1064 Spanned::new(form.span, inner)
1065 }
1066}
1067
1068/// Parse, resolve and CHECK, without running: the door for `blue check`,
1069/// `blue test` and the language server.
1070///
1071/// Returns every diagnostic, errors and warnings alike, rather than failing on
1072/// the first error: a reader fixing a file wants the whole list, and an agent
1073/// repairing one wants it in one pass. Whether errors stop anything is the
1074/// caller's decision — `blue test` refuses to run tests over a program with
1075/// one, exactly as `run` refuses to evaluate it.
1076///
1077/// The name oracle is the interpreter `blue run` would build: hostless plus
1078/// input primitives.
1079///
1080/// # Errors
1081///
1082/// A parse or import error, which leaves no program to check.
1083pub fn check_entry(
1084 entry: Entry<'_>,
1085 loader: &dyn crate::uses::Loader,
1086 surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
1087 checking: Checking,
1088) -> Result<Checked, RunError> {
1089 let mut program = parse_and_resolve(entry, loader, surface)?;
1090 if checking == Checking::Program {
1091 program.retain(|f| !crate::uses::is_test_form(f));
1092 }
1093 let host = match entry.path {
1094 Some(path) => crate::host_surface::near(path)
1095 .map_err(|e| RunError::Import(e.to_string()))?
1096 .map(|s| s.functions)
1097 .unwrap_or_default(),
1098 None => Vec::new(),
1099 };
1100 let mut interp = crate::interpreter_hostless();
1101 crate::inputs::install_input_primitives(&mut interp, Inputs::new());
1102 let builtins = builtin_names_with(&interp, &host);
1103 let (outcome, names) = check_stage(&program, &builtins, checking == Checking::WithTests);
1104 Ok(Checked {
1105 program,
1106 outcome,
1107 names,
1108 })
1109}
1110
1111#[must_use]
1112pub fn spelling_code(spelling: blue_lang_syntax::Spelling) -> blue_lang_check::Code {
1113 use blue_lang_check::Code;
1114 use blue_lang_syntax::Spelling;
1115 match spelling {
1116 Spelling::LogicalSymbol => Code::B0025,
1117 Spelling::Unless => Code::B0026,
1118 Spelling::ElseIf => Code::B0027,
1119 Spelling::CallForm => Code::B0028,
1120 Spelling::KeywordRocket => Code::B0029,
1121 Spelling::ParameterList => Code::B0030,
1122 Spelling::OperatorSpelling => Code::B0031,
1123 Spelling::TrailingComma => Code::B0032,
1124 Spelling::NegatedComparison => Code::B0034,
1125 Spelling::ElseNil => Code::B0035,
1126 Spelling::TryCatch => Code::B0037,
1127 Spelling::Apply => Code::B0042,
1128 Spelling::MapPun => Code::B0046,
1129 Spelling::NilTest => Code::B0047,
1130 }
1131}
1132
1133#[must_use]
1134pub fn form_diagnostics(text: &str) -> Vec<blue_lang_check::Diagnostic> {
1135 match blue_lang_fmt::format_source_lossless(text) {
1136 Ok(formatted) => form_diagnostics_against(text, &formatted),
1137 Err(_) => Vec::new(),
1138 }
1139}
1140
1141#[must_use]
1142pub fn form_diagnostics_against(text: &str, formatted: &str) -> Vec<blue_lang_check::Diagnostic> {
1143 use blue_lang_check::{Code, Diagnostic};
1144 if formatted.trim_end() == text.trim_end() {
1145 return Vec::new();
1146 }
1147 let help = "`blue fmt --write FILE` writes it; no compile door writes a file (`BLUE_LAYOUT=rewrite` restores formatting in place)";
1148 let mut out: Vec<Diagnostic> = blue_lang_syntax::spelling_notes(text)
1149 .unwrap_or_default()
1150 .into_iter()
1151 .map(|n| {
1152 let message = if n.canonical.is_empty() {
1153 format!("`{}` is not written", n.written)
1154 } else {
1155 format!("`{}` is written `{}`", n.written, n.canonical)
1156 };
1157 Diagnostic::new(spelling_code(n.spelling), message, n.span)
1158 .at_top_level(0)
1159 .with_help(help)
1160 })
1161 .collect();
1162 out.extend(
1163 literal_diagnostics(text)
1164 .into_iter()
1165 .map(|d| d.with_help(help)),
1166 );
1167 if out.is_empty() {
1168 let at = text
1169 .lines()
1170 .zip(formatted.lines())
1171 .position(|(a, b)| a != b)
1172 .unwrap_or_else(|| text.lines().count().min(formatted.lines().count()));
1173 let start: usize = text.lines().take(at).map(|l| l.len() + 1).sum();
1174 let start = start.min(text.len());
1175 let end = text[start..].find('\n').map_or(text.len(), |e| start + e);
1176 out.push(
1177 Diagnostic::new(
1178 Code::B0024,
1179 format!("line {} is not laid out as blue writes it", at + 1),
1180 tatara_lisp::Span::new(start, end),
1181 )
1182 .at_top_level(0)
1183 .with_help(help),
1184 );
1185 }
1186 out.sort_by_key(|d| (d.span.start, d.code));
1187 out
1188}
1189
1190fn literal_diagnostics(text: &str) -> Vec<blue_lang_check::Diagnostic> {
1191 use blue_lang_syntax::{Atom, TokenKind};
1192 let Ok(toks) = blue_lang_syntax::lex(text) else {
1193 return Vec::new();
1194 };
1195 toks.iter()
1196 .filter_map(|t| {
1197 let atom = match &t.kind {
1198 TokenKind::Int(v) => Atom::Int(*v),
1199 TokenKind::Float(v) => Atom::Float(*v),
1200 TokenKind::Str(v) => Atom::Str(v.clone()),
1201 _ => return None,
1202 };
1203 let written = text.get(t.span.start..t.span.end)?;
1204 let canonical = blue_lang_fmt::render_literal(&atom);
1205 (written != canonical).then(|| {
1206 blue_lang_check::Diagnostic::new(
1207 blue_lang_check::Code::B0033,
1208 format!("`{written}` is written `{canonical}`"),
1209 t.span,
1210 )
1211 .at_top_level(0)
1212 })
1213 })
1214 .collect()
1215}
1216
1217/// A syntax error in `text` as a [`B0006`](blue_lang_check::Code::B0006)
1218/// diagnostic, so a tool reading `--format json` gets a parse failure in the
1219/// same shape as every other finding. `None` when `text` parses.
1220#[must_use]
1221pub fn syntax_diagnostic(text: &str) -> Option<blue_lang_check::Diagnostic> {
1222 let e = blue_lang_syntax::parse_program_tree(text).err()?;
1223 Some(
1224 blue_lang_check::Diagnostic::new(blue_lang_check::Code::B0006, e.message, e.span)
1225 .at_top_level(0),
1226 )
1227}
1228
1229/// A runtime message as the author wrote the names in it: a definition runs
1230/// under its key (`retsu/first`, `%root/f`), and a message naming one in
1231/// backticks names it `retsu::first`, or `f` for the author's own script.
1232#[must_use]
1233pub fn display_keys(message: &str) -> String {
1234 let mut out = String::with_capacity(message.len());
1235 let mut parts = message.split('`');
1236 if let Some(first) = parts.next() {
1237 out.push_str(first);
1238 }
1239 for (i, part) in parts.enumerate() {
1240 out.push('`');
1241 // Odd pieces (0-based here) are inside backticks.
1242 if i % 2 == 0 {
1243 out.push_str(&crate::observe::display_key(part));
1244 } else {
1245 out.push_str(part);
1246 }
1247 }
1248 out
1249}
1250
1251/// Evaluate a prepared program's top-level forms into `interp`, with `host`.
1252pub(crate) fn eval_prepared<H: 'static>(
1253 interp: &mut tatara_lisp_eval::Interpreter<H>,
1254 prepared: &Prepared,
1255 host: &mut H,
1256) -> Result<Value, RunError> {
1257 let Prepared {
1258 program, erased, ..
1259 } = prepared;
1260
1261 // RUN, one top-level form at a time, **counting them**.
1262 //
1263 // This is literally the loop `Interpreter::eval_program` runs internally
1264 // (tatara-lisp-eval `eval.rs`) — unrolled here for exactly one reason: the
1265 // INDEX. `resolve_uses` flattens N files into one form list and a `Span` is
1266 // a byte range with no file identity, so an offset alone is ambiguous
1267 // across files — 66 means something in every one of them. The index is the
1268 // join key back to `ResolvedProgram`'s file table, and `eval_program`
1269 // consumes it internally and hands back only the error.
1270 //
1271 // The same key, the same renderer and the same file table the type errors
1272 // above already use. A runtime error was the one diagnostic in this
1273 // function still reporting a bare message.
1274 let mut value = Value::Nil;
1275 for (top_level, form) in erased.iter().enumerate() {
1276 value = interp.eval_top_form(form, host).map_err(|e| {
1277 // The message, its position and the blue call stack, read off the
1278 // frames the error unwound through (`observe::Fault`). The message
1279 // is `short_message` rather than upstream's `Display`, whose
1280 // trailing ` at {span}` is a bare byte range with no file, through
1281 // `messages::describe`, which names the function a wrong argument
1282 // count was called on.
1283 RunError::Eval(Box::new(crate::observe::Fault::of(
1284 program,
1285 erased,
1286 top_level,
1287 &e,
1288 &interp.take_trace(),
1289 )))
1290 })?;
1291 }
1292 Ok(value)
1293}
1294
1295pub const MAIN: &str = "main";
1296
1297#[must_use]
1298pub fn entry_main(program: &crate::uses::ResolvedProgram) -> Option<(usize, tatara_lisp::Span)> {
1299 use crate::uses::ResolvedProgram;
1300 let stored = program
1301 .file(ResolvedProgram::ENTRY)
1302 .is_some_and(|f| f.path.is_some());
1303 if !stored {
1304 return None;
1305 }
1306 program.forms().iter().enumerate().find_map(|(i, f)| {
1307 if program.owner_of(i) != Some(ResolvedProgram::ENTRY) {
1308 return None;
1309 }
1310 let items = f.as_list()?;
1311 let sig = items.get(1)?.as_list()?;
1312 let named_main = matches!(
1313 items.first().and_then(tatara_lisp::Spanned::as_symbol),
1314 Some("define" | "define-typed")
1315 ) && sig.len() == 1
1316 && sig[0].as_symbol() == Some(MAIN);
1317 named_main.then_some((i, sig[0].span))
1318 })
1319}
1320
1321pub(crate) fn call_main<H: 'static>(
1322 interp: &mut tatara_lisp_eval::Interpreter<H>,
1323 prepared: &Prepared,
1324 host: &mut H,
1325) -> Result<Option<Value>, RunError> {
1326 let Some((top_level, span)) = entry_main(&prepared.program) else {
1327 return Ok(None);
1328 };
1329 let ns = namespace_of(&prepared.program, top_level);
1330 let call = tatara_lisp::Spanned::new(
1331 span,
1332 tatara_lisp::SpannedForm::List(vec![tatara_lisp::Spanned::new(
1333 span,
1334 tatara_lisp::SpannedForm::Atom(tatara_lisp::Atom::Symbol(blue_lang_check::names::key(
1335 &ns, MAIN,
1336 ))),
1337 )]),
1338 );
1339 interp.eval_top_form(&call, host).map(Some).map_err(|e| {
1340 RunError::Eval(Box::new(crate::observe::Fault::of(
1341 &prepared.program,
1342 &prepared.erased,
1343 top_level,
1344 &e,
1345 &interp.take_trace(),
1346 )))
1347 })
1348}
1349
1350/// **A runtime error names a place.** The gates for the last stage that
1351/// reported a bare message.
1352///
1353/// Each fixture is sized so that resolving the failing span against ANY file
1354/// but the right one lands somewhere else — a different path and a different
1355/// line — so a green run here is evidence about the join and not about the
1356/// renderer being called at all.
1357///
1358/// **Each raise is an unbound name, WAIVED.** Since the check stage resolves
1359/// names (B0001), an unbound call no longer reaches the evaluator; these
1360/// fixtures need exactly that runtime raise, so each carries
1361/// `# waive B0001: …` above the definition holding it — the one escape hatch,
1362/// used for what it is for. The waiver adds a line above each failing call,
1363/// which is why the positions below are one line later than their red runs
1364/// (recorded against the unwaived fixtures) show.
1365#[cfg(test)]
1366mod position_tests {
1367 use super::*;
1368 use crate::uses::{Entry, Loader};
1369 use std::collections::BTreeMap;
1370 use std::path::Path;
1371
1372 struct MemLoader(BTreeMap<&'static str, &'static str>);
1373
1374 impl Loader for MemLoader {
1375 fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
1376 self.0
1377 .get(name)
1378 .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
1379 .ok_or_else(|| format!("no bidama named \"{name}\""))
1380 }
1381 }
1382
1383 /// A package whose OWN top-level code raises, deep inside a file that is
1384 /// deliberately taller than either of the other two.
1385 ///
1386 /// Byte 66 — where `kore_wa_sonzai_shinai` starts — is past the end of the
1387 /// entry file (30 bytes) and past the end of `kotae.b` (21 bytes). So the
1388 /// only file this span can honestly resolve against is this one, and the
1389 /// other two now refuse to answer rather than walking off their ends.
1390 const BAKUHATSU: &str = "\
1391def tsukawanai_a()
1392 1
1393end
1394
1395def tsukawanai_b()
1396 2
1397end
1398
1399# waive B0001: the fixture needs a raise at runtime
1400def bakuhatsu()
1401 kore_wa_sonzai_shinai()
1402end
1403
1404bakuhatsu";
1405
1406 const KOTAE: &str = "def kotae()\n 42\nend";
1407
1408 /// The same failing call, but only ever reached by a CALLER in another
1409 /// file — the shape the call stack resolved (the last position test).
1410 const BAKUHATSU2: &str =
1411 "# waive B0001: a raise at runtime\ndef yobu()\n kore_wa_sonzai_shinai()\nend";
1412
1413 fn loader() -> MemLoader {
1414 MemLoader(BTreeMap::from([
1415 ("kotae", KOTAE),
1416 ("bakuhatsu", BAKUHATSU),
1417 ("bakuhatsu2", BAKUHATSU2),
1418 ]))
1419 }
1420
1421 fn run_named(path: &str, src: &str) -> RunError {
1422 run_in_surface(
1423 Entry {
1424 path: Some(Path::new(path)),
1425 text: src,
1426 },
1427 Inputs::new(),
1428 &loader(),
1429 None,
1430 )
1431 .expect_err("the fixture must raise")
1432 }
1433
1434 /// **The load-bearing gate: a raise inside an IMPORTED package reports the
1435 /// IMPORTED file's path and line.**
1436 ///
1437 /// Hand-computed. In `bakuhatsu.b` the failing call sits on line 11 at
1438 /// column 3, and the whole point of the fixture's height is that no other
1439 /// file in the program can produce that pair: the entry file has 2 lines
1440 /// and `kotae.b` has 3, so *any* mis-attribution is visible as a different
1441 /// path AND a different position, never as a coincidence.
1442 ///
1443 /// **Three red runs, one per edit this change makes** — each isolates a
1444 /// different half of the mechanism, which is what stops the gate being a
1445 /// tautology over "some renderer ran".
1446 ///
1447 /// 1. `eval_program` + `.map_err(|e| RunError::Eval(e.to_string()))`
1448 /// restored (the pre-change code):
1449 /// ```text
1450 /// left: "runtime error: unbound symbol: kore_wa_sonzai_shinai at 74..95"
1451 /// right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1452 /// ```
1453 /// No place at all — the state this change is fixing — and the trailing
1454 /// `at 74..95` is upstream's `Display`, an offset with no file, which is
1455 /// why the loop renders `short_message` instead.
1456 ///
1457 /// 2. The loop kept, but `0` passed to `locate` in place of `top_level`:
1458 /// ```text
1459 /// left: "runtime error: kotae.b: unbound symbol `kore_wa_sonzai_shinai`"
1460 /// right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1461 /// ```
1462 /// **This is the one that proves the INDEX is load-bearing** rather than
1463 /// the renderer: form 0 belongs to `kotae.b`, so the wrong file is named
1464 /// — and named without a position, because the guard in `locate` sees
1465 /// that byte 74 is not a range in a 21-byte file and declines to invent
1466 /// one. A fixture with one imported package could not tell these apart.
1467 ///
1468 /// 3. `crate::lower_to_spanned(&crate::to_sexps(&erased))` reinstated
1469 /// between erasure and the loop:
1470 /// ```text
1471 /// left: "runtime error: bakuhatsu.b: unbound symbol `kore_wa_sonzai_shinai`"
1472 /// right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1473 /// ```
1474 /// The right FILE with no position — which is exactly the shape of the
1475 /// bug: the index survived the lift and every span did not.
1476 /// kotae loads first so bakuhatsu's forms do not start at index 0: the
1477 /// order is the fixture, and the waiver says so (B0015 wants them sorted).
1478 const TWO_IMPORTS: &str =
1479 "use(\"kotae\")\n# waive B0015: kotae loads first on purpose\nuse(\"bakuhatsu\")\n";
1480
1481 #[test]
1482 fn a_raise_inside_an_imported_package_names_that_package() {
1483 let err = run_named("entry.b", TWO_IMPORTS);
1484 assert_eq!(
1485 err.to_string(),
1486 "runtime error: bakuhatsu.b:11:3: unbound symbol `kore_wa_sonzai_shinai`\n \
1487 at bakuhatsu::bakuhatsu (bakuhatsu.b:11:3)\n \
1488 at <top level> (bakuhatsu.b:14:1)"
1489 );
1490 }
1491
1492 /// Anti-vacuity for the fixture itself: the position asserted above is a
1493 /// FACT ABOUT `bakuhatsu.b`, checked against the source text rather than
1494 /// against the thing that produced it.
1495 ///
1496 /// Without this, `11:3` is just a number that happened to come out of the
1497 /// code under test, and a change that moved every reported line by one
1498 /// would move this assertion with it.
1499 #[test]
1500 fn line_11_column_3_of_the_fixture_is_the_failing_call() {
1501 let line = BAKUHATSU.lines().nth(10).expect("line 11 exists");
1502 assert_eq!(line, " kore_wa_sonzai_shinai()");
1503 assert_eq!(
1504 &line[2..],
1505 "kore_wa_sonzai_shinai()",
1506 "column 3 (1-indexed) is where the call starts"
1507 );
1508 // And the other two files cannot reach that far, which is what makes a
1509 // mis-attribution loud instead of plausible.
1510 let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
1511 assert!(
1512 offset > KOTAE.len(),
1513 "kotae.b could answer for byte {offset}"
1514 );
1515 assert!(
1516 offset > TWO_IMPORTS.len(),
1517 "the entry file could answer for byte {offset}"
1518 );
1519 }
1520
1521 /// **The `FileId(0)` path: a raise in the ENTRY file reports the entry
1522 /// file.** The single-file case, which is every program that imports
1523 /// nothing — and the one where the index and the span cannot disagree.
1524 ///
1525 /// **Red run** (2026-08-12), `eval_program` +
1526 /// `.map_err(|e| RunError::Eval(e.to_string()))` restored:
1527 /// ```text
1528 /// left: "runtime error: unbound symbol: nani_mo_nai at 17..28"
1529 /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1530 /// ```
1531 ///
1532 /// **Second red run**, `lower_to_spanned` reinstated after erasure — the
1533 /// one that isolates the SPAN half from the index half, since a
1534 /// single-file program cannot get its file wrong:
1535 /// ```text
1536 /// left: "runtime error: honmono.b: unbound symbol `nani_mo_nai`"
1537 /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
1538 /// ```
1539 #[test]
1540 fn a_raise_in_the_entry_file_names_the_entry_file() {
1541 let err = run_named(
1542 "honmono.b",
1543 "def f()\n 1\nend\n\n# waive B0001: a raise at runtime\nnani_mo_nai()\n",
1544 );
1545 assert_eq!(
1546 err.to_string(),
1547 "runtime error: honmono.b:6:1: unbound symbol `nani_mo_nai`"
1548 );
1549 }
1550
1551 /// A raise from a form that came through the SURFACE-level erasure still
1552 /// reports a real position — the annotated path, where erasure rewrites
1553 /// the node rather than passing it through.
1554 ///
1555 /// Separate from the two above because the erasure rewrite is the only
1556 /// place a node is built rather than kept, and a synthetic span there
1557 /// would be invisible to a fixture whose failing form is untouched.
1558 ///
1559 /// **Red run** (2026-08-12), `lower_to_spanned` reinstated after erasure:
1560 /// ```text
1561 /// left: "runtime error: chuu.b: unbound symbol `mada_nai`"
1562 /// right: "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
1563 /// ```
1564 #[test]
1565 fn a_raise_inside_an_annotated_def_still_names_its_line() {
1566 let err = run_named(
1567 "chuu.b",
1568 "# waive B0001: a raise at runtime\ndef f(a: Int) -> Int\n mada_nai(a)\nend\n\nf(1)\n",
1569 );
1570 assert_eq!(
1571 err.to_string(),
1572 "runtime error: chuu.b:3:3: unbound symbol `mada_nai`\n \
1573 at f (chuu.b:3:3)\n \
1574 at <top level> (chuu.b:6:1)"
1575 );
1576 }
1577
1578 /// **The limit this module used to state, closed by the call stack.**
1579 ///
1580 /// A top-level form in the entry file calls a function defined in an
1581 /// imported one, and the raise happens inside the callee. The executing
1582 /// form is the entry's and the span is the callee's bytes, so until the
1583 /// evaluator reported the frames an error unwound through, the best blue
1584 /// could do was name the caller's file with no position (the guard in
1585 /// `uses::ResolvedProgram::locate` refusing an offset it could not
1586 /// justify). The innermost frame's definition now names the file the
1587 /// offsets belong to, and the stack shows the call that led there.
1588 ///
1589 /// **Red run** (2026-10-10): `Fault::of` locating against the executing
1590 /// top-level form instead of the innermost frame's definition:
1591 /// ```text
1592 /// 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)"
1593 /// ```
1594 /// The old answer, kept by the guard: the caller's file and no position.
1595 #[test]
1596 fn a_raise_in_a_callee_from_another_file_names_the_callees_file() {
1597 // The comment is padding, and load-bearing: `bakuhatsu2.b`'s failing
1598 // call starts at byte 47 (after its waiver line) and ends at 70, and
1599 // this file is 57 bytes, so the start is in range here and the end is
1600 // not — a mis-attribution would still be refused rather than placed.
1601 let err = run_named(
1602 "yobidashi.b",
1603 "use(\"bakuhatsu2\")\n# pad: 47's in range\nbakuhatsu2::yobu\n",
1604 );
1605 assert_eq!(
1606 err.to_string(),
1607 "runtime error: bakuhatsu2.b:3:3: unbound symbol `kore_wa_sonzai_shinai`\n \
1608 at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n \
1609 at <top level> (yobidashi.b:3:1)"
1610 );
1611 }
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616 use super::*;
1617
1618 fn int(src: &str) -> i64 {
1619 match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
1620 Value::Int(v) => v,
1621 other => panic!("{src:?} produced {other:?}"),
1622 }
1623 }
1624
1625 /// **The sliding scale, as one assertion.** Annotating changes the
1626 /// analysis and nothing else.
1627 #[test]
1628 fn annotating_buys_analysis_and_changes_nothing_else() {
1629 let plain = run("def add(a, b)\n a + b\nend\nadd(2, 3)").expect("plain");
1630 let typed = run("def add(a: Int, b: Int) -> Int\n a + b\nend\nadd(2, 3)").expect("typed");
1631
1632 assert!(matches!(plain.value, Value::Int(5)));
1633 assert!(
1634 matches!(typed.value, Value::Int(5)),
1635 "the annotated program must compute the same answer"
1636 );
1637 assert_eq!(plain.visited, 0, "no annotations means no analysis");
1638 assert!(
1639 typed.visited > 0,
1640 "an annotation must actually buy analysis, not just decorate"
1641 );
1642 assert_eq!(plain.typed_decls, 0);
1643 assert_eq!(typed.typed_decls, 1);
1644 }
1645
1646 /// **Checking happens before erasure.** This is the test that catches the
1647 /// reordering: a program with a declared-type violation must be rejected,
1648 /// and it can only be rejected while the annotations still exist.
1649 #[test]
1650 fn a_type_error_is_reported_and_the_program_does_not_run() {
1651 let err = run("def add(a: Int, b: Int) -> Str\n a + b\nend\nadd(1, 2)")
1652 .expect_err("a declared Str return from an Int body must be rejected");
1653 assert!(
1654 matches!(err, RunError::Types(ref d) if !d.is_empty()),
1655 "expected type diagnostics, got {err}"
1656 );
1657 }
1658
1659 /// And the untyped version of the same program runs, so the rejection
1660 /// above is the annotation's doing rather than a parse failure.
1661 #[test]
1662 fn the_same_program_without_annotations_runs() {
1663 assert_eq!(int("def add(a, b)\n a + b\nend\nadd(1, 2)"), 3);
1664 }
1665
1666 #[test]
1667 fn a_parse_error_is_reported_as_one() {
1668 assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
1669 }
1670
1671 /// Every stage reports in its own vocabulary, so a failure names which
1672 /// stage failed rather than surfacing as a generic error.
1673 #[test]
1674 fn a_runtime_error_is_reported_as_one() {
1675 let err = run("throw(error(\"boom\"))").expect_err("a raise");
1676 assert!(matches!(err, RunError::Eval(_)), "got {err}");
1677 }
1678
1679 /// Stdlib and primitives are both reachable through the pipeline — the
1680 /// gap that made `6 % 3` fail.
1681 #[test]
1682 fn the_pipeline_reaches_both_runtime_layers() {
1683 assert_eq!(int("6 % 3"), 0);
1684 assert_eq!(int("7 % 3"), 1);
1685 assert_eq!(int("2 + 3 * 4"), 14);
1686 }
1687
1688 /// **The deleted hop was a no-op on everything blue emits — so removing it
1689 /// is a swap, not a behaviour change.**
1690 ///
1691 /// The old lowering printed the erased tree and read it back through
1692 /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
1693 /// paths land on the same tree, which is the equivalence the swap rests on.
1694 /// It is stated as a *measurement over this corpus*, not as a theorem:
1695 /// the round trip is not identity in general (that is precisely why it had
1696 /// to go), it merely happened to be identity for the bytes blue emits.
1697 #[test]
1698 fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
1699 let corpus = [
1700 "def add(a, b)\n a + b\nend\nadd(2, 3)",
1701 "def fact(n)\n if n < 2\n 1\n else\n n * fact(n - 1)\n end\nend\nfact(5)",
1702 "def f(a, b)\n c = a + b\n c * 2\nend\nf(1, 2)",
1703 "defmacro sq(e)\n quote\n unquote(e) * unquote(e)\n end\nend\nsq(2 + 3)",
1704 "\"a string with spaces, a ( and a )\"",
1705 "def g(a: Int) -> Int\n a + 1\nend\ng(1)",
1706 "6 % 3",
1707 "1.5 + 2.25",
1708 ];
1709 for src in corpus {
1710 // Projected to `Sexp` because that is what the deleted hop
1711 // operated on. Erasure itself no longer goes near it.
1712 let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
1713
1714 let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
1715 .iter()
1716 .map(tatara_lisp::Spanned::to_sexp)
1717 .collect();
1718 assert_eq!(direct, erased, "the direct lowering must be the identity");
1719
1720 let text = erased
1721 .iter()
1722 .map(ToString::to_string)
1723 .collect::<Vec<_>>()
1724 .join("\n");
1725 let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
1726 .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
1727 .iter()
1728 .map(tatara_lisp::Spanned::to_sexp)
1729 .collect();
1730 assert_eq!(
1731 round_tripped, erased,
1732 "{src:?}: the old print-and-reparse path changed the tree"
1733 );
1734 }
1735 }
1736
1737 /// Anti-vacuity for the test above: the round trip really is *not* the
1738 /// identity in general, so agreeing on the corpus was a property of what
1739 /// blue happens to emit rather than a property of the reader.
1740 ///
1741 /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
1742 /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
1743 /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
1744 /// domain, and the failure is *silent*: a symbol containing a space prints
1745 /// as two tokens, reads back as two symbols, and the result is a perfectly
1746 /// well-formed tree with a different meaning. No error, nothing to catch.
1747 ///
1748 /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
1749 /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
1750 /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
1751 /// in and one symbol out, so the brace-fusion reported in tatara *source*
1752 /// is not what bites a printed tree. The silent pair is what makes this a
1753 /// miscompile class rather than a noisy one.
1754 #[test]
1755 fn the_round_trip_is_not_the_identity_in_general() {
1756 let tree = Sexp::List(vec![
1757 Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
1758 Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
1759 ]);
1760 let text = tree.to_string();
1761 let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
1762 .expect("it reads back cleanly — that IS the problem")
1763 .iter()
1764 .map(tatara_lisp::Spanned::to_sexp)
1765 .collect();
1766 assert_ne!(
1767 back,
1768 vec![tree.clone()],
1769 "if print-then-read became inverse over symbols, the class would be \
1770 closed upstream and this test should be deleted rather than relaxed"
1771 );
1772 // …and the direct lowering is unaffected by any of it.
1773 let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
1774 .iter()
1775 .map(tatara_lisp::Spanned::to_sexp)
1776 .collect();
1777 assert_eq!(direct, vec![tree]);
1778 }
1779}
1780
1781#[cfg(test)]
1782mod macro_tests {
1783 use super::*;
1784
1785 fn int(src: &str) -> i64 {
1786 match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
1787 Value::Int(v) => v,
1788 other => panic!("{src:?} produced {other:?}"),
1789 }
1790 }
1791
1792 /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
1793 #[test]
1794 fn a_macro_expands_and_runs() {
1795 assert_eq!(
1796 int("defmacro double(x)\n quote\n unquote(x) + unquote(x)\n end\nend\ndouble(21)"),
1797 42
1798 );
1799 }
1800
1801 /// A macro receives *source forms*, not values — so it can duplicate its
1802 /// argument, which a function cannot do without re-evaluating it.
1803 #[test]
1804 fn a_macro_operates_on_syntax_not_values() {
1805 assert_eq!(
1806 int("defmacro sq(e)\n quote\n unquote(e) * unquote(e)\n end\nend\nsq(2 + 3)"),
1807 25,
1808 "the argument form `2 + 3` must be substituted twice"
1809 );
1810 }
1811
1812 /// **A runaway macro is a typed error, not a dead compiler.** This is the
1813 /// property that makes the metaprogramming surface safe to hand to a user.
1814 ///
1815 /// The assertion was `contains("expansion limit")` and now reads
1816 /// `contains("expansion")`, because the pipeline renders `short_message`
1817 /// rather than `Display`: upstream words the same fact as "exceeded 256
1818 /// expansion steps" instead of "exceeded the expansion limit of 256
1819 /// rewrites". The PROPERTY is unchanged and still asserted — the macro is
1820 /// named, and the failure is attributed to expansion rather than to
1821 /// evaluation. **This reword was the whole measured blast radius of that
1822 /// switch**, one test in the workspace.
1823 ///
1824 /// The number is deliberately NOT pinned. It is upstream's constant, this
1825 /// test owns none of it, and matching it would turn an upstream bump into
1826 /// a red run here that proves nothing about blue.
1827 ///
1828 /// The third clause is new capability rather than repair: the message now
1829 /// carries `<anonymous>:6:1`, so a runaway macro says WHERE it ran away.
1830 #[test]
1831 fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
1832 let err =
1833 run("defmacro forever(x)\n quote\n forever(unquote(x))\n end\nend\nforever(1)")
1834 .expect_err("a self-referential macro must be rejected");
1835 let msg = err.to_string();
1836 assert!(
1837 msg.contains("forever") && msg.contains("expansion"),
1838 "the error must name the macro and the limit it hit: {msg}"
1839 );
1840 assert!(
1841 msg.contains("<anonymous>:6:1"),
1842 "and must place the call that ran away — line 6 is `forever(1)`: {msg}"
1843 );
1844 }
1845}
1846
1847#[cfg(test)]
1848mod input_tests {
1849 use super::*;
1850 use crate::inputs::{Declaration, Inputs};
1851
1852 /// A schema a macro will generate code from.
1853 const SCHEMA: &[u8] = b"3";
1854
1855 fn with_schema(src: &str) -> Result<Run, RunError> {
1856 let hash = Inputs::hash_of(SCHEMA);
1857 let mut inputs = Inputs::new();
1858 inputs
1859 .bind(
1860 &Declaration {
1861 name: "schema".to_string(),
1862 hash,
1863 },
1864 SCHEMA.to_vec(),
1865 )
1866 .expect("bind");
1867 run_with_inputs(src, inputs)
1868 }
1869
1870 fn decl_line() -> String {
1871 let mut s = String::from("definput(\"schema\", \"");
1872 s.push_str(&Inputs::hash_of(SCHEMA));
1873 s.push_str("\")\n");
1874 s
1875 }
1876
1877 /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
1878 /// the spec names it as gating blue's whole "stronger than Ruby's
1879 /// metaprogramming" claim, because a macro that cannot read a schema cannot
1880 /// generate code from one.
1881 #[test]
1882 fn a_macro_can_read_a_declared_build_input() {
1883 let src = decl_line() + "input(\"schema\")";
1884 let out = with_schema(&src).expect("run");
1885 assert!(
1886 matches!(out.value, Value::Str(ref s) if &**s == "3"),
1887 "got {:?}",
1888 out.value
1889 );
1890 }
1891
1892 /// **An undeclared input is an error, not a file read and not nil.**
1893 /// Returning nil is how a macro generates an empty table and nobody notices
1894 /// until runtime.
1895 #[test]
1896 fn an_undeclared_input_is_an_error() {
1897 let err = with_schema("input(\"not_declared\")").expect_err("must fail");
1898 let msg = err.to_string();
1899 assert!(msg.contains("not_declared"), "must name it: {msg}");
1900 assert!(msg.contains("definput"), "and say how to declare it: {msg}");
1901 }
1902
1903 /// **There is no path-based read at all.** The capability is the absence of
1904 /// the primitive, not a check inside one — so this is an unbound symbol.
1905 ///
1906 /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
1907 /// cargo feature (CLI only) is the one declared exception: it is the
1908 /// operator's own trusted host surface, and is asserted in
1909 /// `sys_read_file_is_the_trusted_cli_only_exception` below.
1910 #[cfg(not(feature = "sys"))]
1911 #[test]
1912 fn there_is_no_ambient_file_read() {
1913 for attempt in [
1914 "read_file(\"/etc/passwd\")",
1915 "File(\"/etc/passwd\")",
1916 "slurp(\"/etc/passwd\")",
1917 "open(\"/etc/passwd\")",
1918 ] {
1919 let err = with_schema(attempt).expect_err("must not resolve");
1920 assert!(
1921 err.to_string().contains("unbound"),
1922 "{attempt} must be UNBOUND — a capability removed by absence, \
1923 not guarded by a check: {err}"
1924 );
1925 }
1926 }
1927
1928 /// With the `sys` feature compiled in, `read_file` IS bound — that is the
1929 /// point of the feature. The doctrine does not move: this is the operator's
1930 /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
1931 /// future default-build change is heard, and assert that `input()` still
1932 /// works beside it.
1933 #[cfg(feature = "sys")]
1934 #[test]
1935 fn sys_read_file_is_the_trusted_cli_only_exception() {
1936 let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
1937 assert!(err.to_string().contains("unbound"), "{err}");
1938 assert!(
1939 with_schema("read_file(\"/etc/passwd\")").is_ok(),
1940 "with `sys` on, read_file is the trusted CLI surface"
1941 );
1942 let out = with_schema("input(\"schema\")").expect("run");
1943 assert!(
1944 matches!(out.value, Value::Str(ref s) if &**s == "3"),
1945 "input() still binds beside the sys surface: {:?}",
1946 out.value
1947 );
1948 }
1949
1950 /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
1951 /// — so the success above is the binding's doing.
1952 #[test]
1953 fn a_declared_input_with_no_bytes_supplied_fails() {
1954 let src = decl_line() + "input(\"schema\")";
1955 assert!(run(&src).is_err(), "no bytes were supplied");
1956 }
1957}
1958
1959#[cfg(test)]
1960mod tier2_tests {
1961 use super::*;
1962 use crate::inputs::{Declaration, Inputs};
1963
1964 /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
1965 /// declarations FROM A SCHEMA.**
1966 ///
1967 /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
1968 /// installs a `NoLoader`, so a macro cannot read a schema — which gates
1969 /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
1970 /// Ruby's metaprogramming' claim."
1971 ///
1972 /// Here the schema supplies a *value the generated code depends on*, read at
1973 /// expansion time. Ruby and Elixir can both do this — with the whole
1974 /// filesystem open. blue does it through a name bound to a content hash.
1975 #[test]
1976 fn a_macro_generates_code_from_a_schema() {
1977 let schema = b"7";
1978 let mut inputs = Inputs::new();
1979 inputs
1980 .bind(
1981 &Declaration {
1982 name: "arity".to_string(),
1983 hash: Inputs::hash_of(schema),
1984 },
1985 schema.to_vec(),
1986 )
1987 .expect("bind");
1988
1989 // The macro reads the input at EXPANSION time and splices the value it
1990 // found into the code it emits.
1991 let mut src = String::from("definput(\"arity\", \"");
1992 src.push_str(&Inputs::hash_of(schema));
1993 src.push_str("\")\n");
1994 src.push_str(
1995 "defmacro from_schema()\n quote\n unquote(to_int(input(\"arity\")))\n end\nend\n\
1996 from_schema() * 6",
1997 );
1998
1999 let out = run_with_inputs(&src, inputs).expect("run");
2000 assert!(
2001 matches!(out.value, Value::Int(42)),
2002 "the schema's 7 must reach the generated code: got {:?}",
2003 out.value
2004 );
2005 }
2006}