blue_lang_runtime/uses.rs
1//! `use("name")` — how a blue program consumes a bidama.
2//!
3//! ## The gap this closes
4//!
5//! Before this, blue's packaging had a manifest (`Bluefile`), a resolver
6//! (`blue-lang-pkg`), a git registry and a nix derivation per package — and
7//! **no way for a program to consume any of it**. `retsu/Bluefile` declared
8//! `needs("kazu", "^0.1")` while `retsu`'s source never referenced a single
9//! thing from kazu, because the language had no import form at all. The
10//! dependency graph was described by four layers and traversed by none.
11//!
12//! That is the difference between packaging that exists and packaging that
13//! works, and it is why this is a load-bearing addition rather than a
14//! convenience: a distribution whose packages cannot see each other is a
15//! directory of unrelated files wearing a distribution's name.
16//!
17//! ## Why a pipeline pass and not a builtin
18//!
19//! A `register_fn` native takes `(&[Value], &mut H, Span)`. It cannot define
20//! anything, because it never sees the interpreter — so `use` implemented as a
21//! builtin could load a package's text and would have nowhere to put its
22//! definitions. Resolution therefore happens on the FORM tree, before
23//! evaluation: a `use` is replaced by the forms of the package it names, and
24//! the whole program is then checked and run as one unit.
25//!
26//! That ordering is deliberate and worth stating, because it decides a real
27//! behaviour: the type checker sees the imported code, so a package that fails
28//! `blue-lang-check` fails at the point its consumer imports it rather than at
29//! whatever later moment its code first ran.
30//!
31//! ## Why the loader is a trait
32//!
33//! Loading a package means reading a filesystem, and this crate compiles to
34//! `wasm32-unknown-unknown` with zero host imports (`blue-lang-wasm`). A
35//! direct `std::fs` call here would break that target for every consumer,
36//! including ones that never call `use`.
37//!
38//! So the capability is injected — [`Loader`] here, the real filesystem
39//! implementation in `blue-lang-pkg`, which is native-only. This is the
40//! fleet's mockable-`Environment` seam, and it buys the usual thing: every
41//! test below drives the whole resolution pass against an in-memory loader,
42//! with no temp directories and no fixture files on disk.
43//!
44//! ## Why this module also owns file identity
45//!
46//! Splicing several files into one program is exactly the act that destroys a
47//! byte offset's meaning, so the record of where each form came from is kept by
48//! the pass that does the splicing rather than reconstructed downstream by
49//! something that no longer has the sources. [`ResolvedProgram`] is that
50//! record, and [`ResolvedProgram::locate`] spends it.
51
52use std::collections::BTreeSet;
53use std::path::{Path, PathBuf};
54
55use tatara_lisp::{Sexp, Span, Spanned};
56
57/// Supplies the source of a named bidama.
58///
59/// One method, because resolution needs exactly one thing: given a name, the
60/// blue source that name refers to. *Where* it came from — a working tree, a
61/// nix store path, a git object, memory — is the implementation's business and
62/// deliberately invisible here.
63pub trait Loader {
64 /// The `.b` sources of `name`, as `(label, source)` pairs.
65 ///
66 /// The label is for diagnostics only; nothing keys on it. A package with
67 /// several files returns several pairs, and their relative order is the
68 /// implementation's to fix — [`FsLoader`](../../blue_lang_pkg/load_path/index.html)
69 /// sorts by filename so a load is reproducible rather than
70 /// directory-order-dependent.
71 ///
72 /// `Err` is a human-readable reason the package could not be loaded. It
73 /// must name what was looked for, because "package not found" without a
74 /// name sends the reader grepping a distribution to find which one.
75 fn load(&self, name: &str) -> Result<Vec<(String, String)>, String>;
76
77 /// The bidama an ENTRY file at `path` belongs to, if it belongs to one:
78 /// the `package(name, …)` of the Bluefile beside it. `blue test
79 /// bidamas/retsu/retsu.b` checks and runs retsu's own definitions, under
80 /// retsu's namespace, exactly as an importer sees them.
81 ///
82 /// A file with no bidama around it (a script, `spec/*.b`) is in the root
83 /// namespace, and so is every entry a loader without a filesystem is
84 /// handed, which is this default.
85 fn entry_package(&self, _path: &Path) -> Option<String> {
86 None
87 }
88
89 /// The bidamas `package`'s Bluefile `needs`, when the loader can read it.
90 /// `None` when it cannot, and the check stage then has nothing to compare
91 /// the package's `use` forms against (B0019).
92 ///
93 /// `entry_dir` is the directory of the entry file, for the entry's own
94 /// bidama, which a loader may not find under its roots.
95 fn needs(&self, _package: &str, _entry_dir: Option<&Path>) -> Option<BTreeSet<String>> {
96 None
97 }
98
99 /// Bidama `package`'s Bluefile version, when the loader can read it —
100 /// what a `legacy_names` window is measured against.
101 fn version(&self, _package: &str, _entry_dir: Option<&Path>) -> Option<String> {
102 None
103 }
104}
105
106/// A loader that resolves nothing, and says so.
107///
108/// The default for [`run`](crate::pipeline::run), so a program using `use` in
109/// a context with no packaging configured gets a typed error naming the
110/// package — not a silently-undefined function that fails much later as an
111/// unbound symbol pointing at innocent code.
112pub struct NoLoader;
113
114impl Loader for NoLoader {
115 fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
116 Err(format!(
117 "cannot load bidama \"{name}\": no loader is installed. A program \
118 that uses packages must run with one — `blue_lang_pkg::LoadPath` \
119 reads BLUE_PATH, which `nix develop` and the bidama derivations \
120 populate."
121 ))
122 }
123}
124
125/// Identity of one source file inside a [`ResolvedProgram`].
126///
127/// Opaque on purpose. It means nothing outside the program that minted it, and
128/// the only way to spend it is [`ResolvedProgram::file`] — a caller that could
129/// compute a file index is a caller that can compute the *wrong* one, which is
130/// the failure this whole type exists to make impossible.
131#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
132pub struct FileId(usize);
133
134impl FileId {
135 /// The file's position in its program's file table: a key, never an
136 /// index a caller may compute.
137 #[must_use]
138 pub fn index(self) -> usize {
139 self.0
140 }
141}
142
143/// One file that contributed forms to a [`ResolvedProgram`].
144#[derive(Clone, Debug)]
145pub struct SourceFile {
146 /// Its handle in the program that owns it.
147 pub id: FileId,
148 /// Where it came from.
149 ///
150 /// `None` only for an entry program handed to the pipeline as bare text —
151 /// an embedder, a test, a WASM host. An imported file always has one,
152 /// because a [`Loader`] cannot name a package without naming a place.
153 pub path: Option<PathBuf>,
154 /// The bidama this file belongs to — the name `use` was given, or for the
155 /// entry file its [`Loader::entry_package`] — or `None` for a file in the
156 /// root namespace. The namespace its definitions are resolved under
157 /// (`blue_lang_check::Namespace::Bidama`).
158 pub package: Option<String>,
159 /// The file's text.
160 ///
161 /// Kept, not dropped after parsing, because a [`Span`] is a byte range and
162 /// nothing else: turning one into `line:col` needs the exact string it
163 /// indexes. Before this, `resolve_uses` read a package's source and let it
164 /// fall out of scope one line later, which is why an imported type error
165 /// had no position to report.
166 pub text: String,
167}
168
169/// The program the pipeline was handed, and what to call it.
170///
171/// The path travels *with* the text rather than beside it, so a caller cannot
172/// name one file and hand over another's source — the pairing is what every
173/// position in the entry file is resolved against.
174#[derive(Clone, Copy, Debug)]
175pub struct Entry<'a> {
176 /// The file the text was read from, if it was read from one.
177 pub path: Option<&'a Path>,
178 /// The source itself.
179 pub text: &'a str,
180}
181
182impl<'a> Entry<'a> {
183 /// An entry program with no file behind it.
184 #[must_use]
185 pub fn anonymous(text: &'a str) -> Self {
186 Self { path: None, text }
187 }
188}
189
190/// A program with its imports spliced in, and the file each top-level form
191/// came from.
192///
193/// ## Why file identity is per TOP-LEVEL FORM
194///
195/// [`resolve_uses`] splices whole files' worth of top-level forms: a `use` is
196/// replaced by every form of the package it names, in order. So a boundary
197/// between two files is always a boundary between two top-level forms, and
198/// every node beneath a top-level form came from exactly one file — the one
199/// that form came from. **A `FileId` per top-level form is therefore exactly as
200/// precise as a `FileId` per node**, at a fraction of the cost and with nothing
201/// upstream to change.
202///
203/// That last part is the point. `Span` lives in tatara-lisp and carries no file
204/// identity by a documented decision — "Spans are not portable across source
205/// inputs — they are meaningful only relative to the string that produced them,
206/// which the caller is responsible for holding onto." blue is the caller. This
207/// is blue holding onto them, beside the span rather than inside it.
208///
209/// ## Why the fields are private
210///
211/// `forms` and `owner` are parallel, and `files[i].id` is `FileId(i)`. Both
212/// invariants are maintained by `push` and `intern`, the only places either
213/// vector grows, and by [`retain`](Self::retain), the only place either
214/// shrinks. Public fields would make `program.forms.retain(...)` — the exact
215/// call `pipeline::run_in_surface` makes to drop `test` blocks — shift every
216/// form off its owner by one and report every subsequent diagnostic against the
217/// wrong file, silently.
218#[derive(Debug)]
219pub struct ResolvedProgram {
220 forms: Vec<Spanned>,
221 owner: Vec<FileId>,
222 files: Vec<SourceFile>,
223 imports: Vec<(FileId, Import)>,
224 needs: std::collections::BTreeMap<String, BTreeSet<String>>,
225 versions: std::collections::BTreeMap<String, String>,
226}
227
228impl ResolvedProgram {
229 /// The entry program's own file. Always present; always first.
230 pub const ENTRY: FileId = FileId(0);
231
232 fn new(entry: Entry<'_>, package: Option<String>) -> Self {
233 let mut program = Self {
234 forms: Vec::new(),
235 owner: Vec::new(),
236 files: Vec::new(),
237 imports: Vec::new(),
238 needs: std::collections::BTreeMap::new(),
239 versions: std::collections::BTreeMap::new(),
240 };
241 let id = program.intern(
242 entry.path.map(Path::to_path_buf),
243 package,
244 entry.text.to_owned(),
245 );
246 debug_assert_eq!(id, Self::ENTRY);
247 program
248 }
249
250 /// Record a file's text and hand back its handle.
251 fn intern(&mut self, path: Option<PathBuf>, package: Option<String>, text: String) -> FileId {
252 let id = FileId(self.files.len());
253 self.files.push(SourceFile {
254 id,
255 path,
256 package,
257 text,
258 });
259 id
260 }
261
262 /// Append a top-level form together with the file it came from.
263 ///
264 /// The ONE place `forms` and `owner` grow, so they cannot grow apart.
265 fn push(&mut self, form: Spanned, owner: FileId) {
266 self.forms.push(form);
267 self.owner.push(owner);
268 }
269
270 /// Every top-level form, in evaluation order.
271 #[must_use]
272 pub fn forms(&self) -> &[Spanned] {
273 &self.forms
274 }
275
276 /// The same forms with their spans projected away, for the stages that do
277 /// not report positions — erasure, evaluation, the test harness. A `use`
278 /// form is a declaration the resolver consumed, and projects to `nil`, so
279 /// every index still names its top-level form.
280 #[must_use]
281 pub fn sexps(&self) -> Vec<Sexp> {
282 self.forms
283 .iter()
284 .map(|f| {
285 if use_target(f).is_some() || blue_lang_syntax::scope::legacy_target(f).is_some() {
286 Sexp::Nil
287 } else {
288 f.to_sexp()
289 }
290 })
291 .collect()
292 }
293
294 /// Each loaded bidama's Bluefile `needs`, where the loader read one.
295 #[must_use]
296 pub fn needs(&self) -> &std::collections::BTreeMap<String, BTreeSet<String>> {
297 &self.needs
298 }
299
300 /// Each loaded bidama's Bluefile version, where the loader read one.
301 #[must_use]
302 pub fn versions(&self) -> &std::collections::BTreeMap<String, String> {
303 &self.versions
304 }
305
306 /// Every `use` form, with the file that wrote it.
307 #[must_use]
308 pub fn imports(&self) -> &[(FileId, Import)] {
309 &self.imports
310 }
311
312 /// Every file that contributed, entry first.
313 #[must_use]
314 pub fn files(&self) -> &[SourceFile] {
315 &self.files
316 }
317
318 /// Which file the `top_level`-th form came from.
319 #[must_use]
320 pub fn owner_of(&self, top_level: usize) -> Option<FileId> {
321 self.owner.get(top_level).copied()
322 }
323
324 /// A file by its handle.
325 #[must_use]
326 pub fn file(&self, id: FileId) -> Option<&SourceFile> {
327 self.files.get(id.0)
328 }
329
330 /// Drop the top-level forms `keep` rejects, taking their owners with them.
331 ///
332 /// The lockstep is the whole reason this method exists rather than a public
333 /// `forms` field: `Vec::retain` on one of two parallel vectors is a silent
334 /// mis-attribution, not a compile error.
335 pub fn retain(&mut self, keep: impl Fn(&Spanned) -> bool) {
336 // Zipped rather than two `Vec::retain` calls over the same predicate:
337 // this way the pairing is carried by the iterator instead of by two
338 // traversals agreeing, and there is no order assumption to be wrong
339 // about.
340 let paired = std::mem::take(&mut self.forms)
341 .into_iter()
342 .zip(std::mem::take(&mut self.owner));
343 for (form, owner) in paired {
344 if keep(&form) {
345 self.push(form, owner);
346 }
347 }
348 }
349
350 /// Resolve a diagnostic's position against the file it actually came from.
351 ///
352 /// `top_level` is `blue_lang_check::Diagnostic::top_level` — the join key.
353 /// An index with no owner (a diagnostic that escaped stamping) renders
354 /// without a position rather than borrowing the entry file's: a missing
355 /// position costs the reader a search, a wrong one sends them to innocent
356 /// code and is believed.
357 #[must_use]
358 pub fn locate<'a>(&'a self, top_level: usize, span: Span, message: &'a str) -> Located<'a> {
359 let Some(file) = self.owner_of(top_level).and_then(|id| self.file(id)) else {
360 return Located {
361 origin: Origin::Unresolved,
362 line_col: None,
363 message,
364 };
365 };
366 Located {
367 origin: file.path.as_deref().map_or(Origin::Anonymous, Origin::File),
368 // A synthetic span indexes nothing, so it resolves to no line —
369 // `Span::line_col` would happily answer for `usize::MAX` by walking
370 // off the end and returning the last position in the file.
371 //
372 // **And so would a span that is real but belongs to a DIFFERENT
373 // file**, which is the case a runtime error can reach and a check
374 // error cannot: a check diagnostic's span is a node inside the very
375 // top-level form it is stamped with, so it is in range by
376 // construction, while a raise inside a callee carries the callee's
377 // offsets and the executing top-level form may be someone else's.
378 // An out-of-range offset is proof the span is not this file's, and
379 // `line_col` answers it anyway with the file's last position — a
380 // fabricated `path:line:col` a reader has every reason to believe.
381 // Refuse instead, which is `EvalError::render`'s own rule
382 // (`span.end > src.len()` → no source context) applied at the one
383 // place blue owns the file table.
384 //
385 // In range is NECESSARY, not sufficient: two files long enough to
386 // share an offset can still trade a plausible number. That residue
387 // is `RunError::Eval`'s stated limit and wants a call stack, not a
388 // wider guard here.
389 line_col: (!span.is_synthetic() && span.end <= file.text.len())
390 .then(|| Span::line_col(&file.text, span.start)),
391 message,
392 }
393 }
394}
395
396/// What a position is being reported against.
397#[derive(Clone, Copy, Debug)]
398enum Origin<'a> {
399 File(&'a Path),
400 /// Source handed over as text, with no file behind it.
401 Anonymous,
402 /// No owning file could be found. Distinct from [`Origin::Anonymous`] on
403 /// purpose: "you gave me unnamed text" and "I lost track of where this came
404 /// from" are different admissions, and collapsing them would hide the
405 /// second inside the first.
406 Unresolved,
407}
408
409/// A diagnostic rendered against the file it came from: `path:line:col: text`.
410///
411/// A typed `Display` rather than a `format!` at the call site, per ★★ TYPED
412/// EMISSION — the shape every editor and every `cc` already knows how to jump
413/// to, produced by exactly one `write!`.
414#[derive(Clone, Copy, Debug)]
415pub struct Located<'a> {
416 origin: Origin<'a>,
417 line_col: Option<(usize, usize)>,
418 message: &'a str,
419}
420
421impl std::fmt::Display for Located<'_> {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 match self.origin {
424 Origin::File(p) => write!(f, "{}", p.display())?,
425 Origin::Anonymous => f.write_str("<anonymous>")?,
426 Origin::Unresolved => f.write_str("<unknown file>")?,
427 }
428 if let Some((line, col)) = self.line_col {
429 write!(f, ":{line}:{col}")?;
430 }
431 write!(f, ": {}", self.message)
432 }
433}
434
435pub use blue_lang_syntax::scope::{use_target, Import};
436
437/// Is this form a lowered `test` block?
438///
439/// `test "…" … end` lowers to `(deftest …)`, which only the test harness
440/// binds. Public because two callers need it and for opposite reasons: the
441/// resolver drops IMPORTED tests (a dependency's tests are not the importer's
442/// to run), and `pipeline::run` drops ALL of them (a `test` block is a
443/// declaration for the harness, not code to execute — without this,
444/// `blue run` on any file that contains its own tests dies on an unbound
445/// `deftest`, which is every package in this distribution).
446pub fn is_test_form(form: &Spanned) -> bool {
447 let Some(items) = form.as_list() else {
448 return false;
449 };
450 matches!(items.first().and_then(Spanned::as_symbol), Some("deftest"))
451}
452
453/// Replace every `use(...)` with the forms of the package it names.
454///
455/// Transitive by construction: a loaded package's own `use` calls are resolved
456/// the same way, depth-first, so a consumer names its direct dependency and
457/// gets the closure.
458///
459/// **A package is loaded at most once.** Two importers of one package must
460/// share its definitions — loading twice would re-evaluate them, which is at
461/// best wasted work and at worst two distinct copies of anything stateful.
462/// That same visited-set is what makes a dependency CYCLE terminate: the
463/// second visit is a no-op rather than infinite recursion, so a cyclic
464/// distribution loads and runs instead of hanging.
465///
466/// The result carries **which file each top-level form came from** — see
467/// [`ResolvedProgram`] for why that is per top-level form and not per node.
468///
469/// # Errors
470///
471/// Returns the loader's message, prefixed with the import chain that reached
472/// it, when a package cannot be loaded or its source cannot be parsed.
473pub fn resolve_uses(
474 forms: Vec<Spanned>,
475 entry: Entry<'_>,
476 loader: &dyn Loader,
477) -> Result<ResolvedProgram, String> {
478 let package = entry.path.and_then(|p| loader.entry_package(p));
479 let mut seen = BTreeSet::new();
480 // The entry's own bidama is already loaded: it IS the entry. A cycle back
481 // to it must not splice a second copy of its definitions in.
482 if let Some(p) = &package {
483 seen.insert(p.clone());
484 }
485 let mut out = ResolvedProgram::new(entry, package.clone());
486 if let Some(p) = &package {
487 if let Some(n) = loader.needs(p, entry.path.and_then(Path::parent)) {
488 out.needs.insert(p.clone(), n);
489 }
490 if let Some(v) = loader.version(p, entry.path.and_then(Path::parent)) {
491 out.versions.insert(p.clone(), v);
492 }
493 }
494 expand(
495 forms,
496 ResolvedProgram::ENTRY,
497 loader,
498 &mut out,
499 &mut seen,
500 &[],
501 )?;
502 Ok(out)
503}
504
505fn expand(
506 forms: Vec<Spanned>,
507 owner: FileId,
508 loader: &dyn Loader,
509 out: &mut ResolvedProgram,
510 seen: &mut BTreeSet<String>,
511 chain: &[String],
512) -> Result<(), String> {
513 for form in forms {
514 let Some(import) = use_target(&form) else {
515 // The file boundary is erased HERE — this is the append that used
516 // to make every form indistinguishable from every other. Each one
517 // now carries the file it came from, which is the whole fix.
518 out.push(form, owner);
519 continue;
520 };
521 let name = import.package.clone();
522 out.imports.push((owner, import));
523 if !seen.insert(name.clone()) {
524 // Loaded already: the declaration stays, for the check stage.
525 out.push(form, owner);
526 continue;
527 }
528
529 let sources = loader.load(&name).map_err(|e| describe(chain, &name, &e))?;
530 if let Some(n) = loader.needs(&name, None) {
531 out.needs.insert(name.clone(), n);
532 }
533 if let Some(v) = loader.version(&name, None) {
534 out.versions.insert(name.clone(), v);
535 }
536 let mut inner_chain = chain.to_vec();
537 inner_chain.push(name.clone());
538
539 for (label, src) in sources {
540 // `parse_program_tree`, not `parse_program`. The spanless door
541 // discarded every imported position one line after the text
542 // arrived, so an imported type error had nothing to report but a
543 // message — see `ResolvedProgram`.
544 let parsed = blue_lang_syntax::parse_program_tree(&src)
545 .map_err(|e| describe(chain, &name, &format!("{label}: {e}")))?;
546 // An imported package's TEST blocks do not come along.
547 //
548 // A dependency's tests are not the importer's to run, and trying
549 // is not merely untidy — it is a hard failure. `test` lowers to
550 // `deftest`, which only the test harness binds, so the ordinary
551 // evaluator sees an unbound symbol. Measured, the moment kazu
552 // gained test blocks: every program importing it died with
553 // `unbound symbol: deftest`, pointing at a line the importer never
554 // wrote.
555 //
556 // Dropping them here also makes the distribution gate honest for
557 // free: `blue test kikagaku.b` now reports kikagaku's tests
558 // rather than kikagaku's plus everything it transitively imports.
559 let parsed: Vec<Spanned> = parsed.into_iter().filter(|f| !is_test_form(f)).collect();
560 // Interned AFTER parsing, so a package that does not parse never
561 // becomes a file in the table — and BEFORE the recursion, because
562 // every form below belongs to this file, not to the importer's.
563 let id = out.intern(Some(PathBuf::from(label)), Some(name.clone()), src);
564 expand(parsed, id, loader, out, seen, &inner_chain)?;
565 }
566 // The declaration itself, after the forms it brought in.
567 out.push(form, owner);
568 }
569 Ok(())
570}
571
572/// Prefix a failure with the import chain that reached it.
573///
574/// A transitive failure otherwise names only the leaf, and the reader has no
575/// way to tell which of their own imports pulled it in — the exact question
576/// they need answered to fix it.
577fn describe(chain: &[String], name: &str, reason: &str) -> String {
578 if chain.is_empty() {
579 return reason.to_owned();
580 }
581 format!("while loading {} -> {name}: {reason}", chain.join(" -> "))
582}
583
584#[cfg(test)]
585pub(crate) mod tests {
586 use super::*;
587 use std::collections::BTreeMap;
588
589 /// An in-memory distribution — the whole pass runs with no filesystem.
590 pub(crate) struct MemLoader(pub(crate) BTreeMap<&'static str, &'static str>);
591
592 impl Loader for MemLoader {
593 fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
594 self.0
595 .get(name)
596 .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
597 .ok_or_else(|| format!("no bidama named \"{name}\""))
598 }
599 }
600
601 fn parse(src: &str) -> Vec<Spanned> {
602 blue_lang_syntax::parse_program_tree(src).expect("test source must parse")
603 }
604
605 /// Resolve a program that came from nowhere in particular.
606 fn resolve(src: &str, loader: &dyn Loader) -> Result<ResolvedProgram, String> {
607 resolve_uses(parse(src), Entry::anonymous(src), loader)
608 }
609
610 #[test]
611 fn a_use_is_replaced_by_the_packages_forms() {
612 let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n n * 2\nend")]));
613 let out = resolve("use(\"kazu\")\ndouble(21)", &loader).expect("resolves");
614 // The `use` itself stays, for the check stage to point at, and never
615 // reaches the evaluator, where `use` is not a defined function: the
616 // evaluated projection has it as `nil`.
617 assert_eq!(
618 out.forms()
619 .iter()
620 .filter(|f| super::use_target(f).is_some())
621 .count(),
622 1
623 );
624 assert!(
625 !out.sexps().iter().any(|f| f.to_string().contains("use")),
626 "a use form would reach the evaluator as an unbound function: {:?}",
627 out.sexps()
628 );
629 assert!(
630 out.forms().len() > 1,
631 "the package's definitions must be spliced in, not dropped: {:?}",
632 out.forms()
633 );
634 }
635
636 #[test]
637 fn imports_are_transitive() {
638 let loader = MemLoader(BTreeMap::from([
639 ("retsu", "use(\"kazu\")\ndef sum2(a, b)\n a + b\nend"),
640 ("kazu", "def double(n)\n n * 2\nend"),
641 ]));
642 let out = resolve("use(\"retsu\")", &loader).expect("resolves");
643 // A consumer names retsu only; kazu arrives because retsu needs it.
644 assert!(
645 out.forms().len() >= 2,
646 "the transitive dependency did not arrive: {:?}",
647 out.forms()
648 );
649 }
650
651 #[test]
652 fn a_package_is_loaded_at_most_once() {
653 let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n n * 2\nend")]));
654 let once = resolve("use(\"kazu\")", &loader).expect("resolves");
655 let twice = resolve("use(\"kazu\")\nuse(\"kazu\")", &loader).expect("resolves");
656 let definitions = |p: &ResolvedProgram| {
657 p.forms()
658 .iter()
659 .filter(|f| super::use_target(f).is_none())
660 .count()
661 };
662 assert_eq!(
663 definitions(&once),
664 definitions(&twice),
665 "importing a package twice duplicated its definitions; two \
666 importers of one package must share it"
667 );
668 assert_eq!(
669 once.files().len(),
670 twice.files().len(),
671 "importing a package twice interned its source twice; the file \
672 table must have one entry per file, not one per import"
673 );
674 }
675
676 /// The property that keeps a cyclic distribution from hanging.
677 #[test]
678 fn a_dependency_cycle_terminates() {
679 let loader = MemLoader(BTreeMap::from([
680 ("a", "use(\"b\")\ndef fa()\n 1\nend"),
681 ("b", "use(\"a\")\ndef fb()\n 2\nend"),
682 ]));
683 let out = resolve("use(\"a\")", &loader).expect("a cycle must resolve");
684 assert!(
685 !out.forms().is_empty(),
686 "a cycle resolved to nothing: {:?}",
687 out.forms()
688 );
689 }
690
691 #[test]
692 fn a_missing_package_names_itself_and_the_chain() {
693 let loader = MemLoader(BTreeMap::from([("retsu", "use(\"nowhere\")")]));
694 let err = resolve("use(\"retsu\")", &loader).expect_err("must fail");
695 assert!(
696 err.contains("nowhere"),
697 "the error must name the missing package: {err}"
698 );
699 assert!(
700 err.contains("retsu"),
701 "the error must name the import that pulled it in, or the reader \
702 cannot tell which of their own imports is at fault: {err}"
703 );
704 }
705
706 #[test]
707 fn the_default_loader_refuses_by_name() {
708 let err = resolve("use(\"kazu\")", &NoLoader).expect_err("must fail");
709 assert!(
710 err.contains("kazu"),
711 "NoLoader must name what was asked for: {err}"
712 );
713 }
714
715 /// A non-`use` program must come out byte-identical.
716 ///
717 /// This pass runs on EVERY program, so a bug here would corrupt source
718 /// that never mentions a package.
719 /// An imported package's tests must NOT come along.
720 ///
721 /// Not a tidiness point: `deftest` is unbound outside the test harness, so
722 /// an inherited test block kills any program that imports a tested
723 /// package — which is every package in a distribution worth having.
724 #[test]
725 fn an_imported_packages_tests_are_not_inherited() {
726 let loader = MemLoader(BTreeMap::from([(
727 "kazu",
728 "def double(n)\n n * 2\nend\n\ntest \"doubles\"\n assert double(2) == 4\nend",
729 )]));
730 let out = resolve("use(\"kazu\")\ndouble(21)", &loader).expect("resolves");
731 assert!(
732 out.forms().iter().all(|f| !super::is_test_form(f)),
733 "an imported test block survived and would reach the evaluator as \
734 an unbound `deftest`: {:?}",
735 out.forms()
736 );
737 // The DEFINITIONS still arrive — dropping tests must not drop code.
738 assert!(
739 out.forms().len() >= 2,
740 "filtering tests also removed the package's definitions: {:?}",
741 out.forms()
742 );
743 }
744
745 /// But the ENTRY program keeps its own tests.
746 #[test]
747 fn the_entry_programs_own_tests_survive() {
748 let loader = MemLoader(BTreeMap::new());
749 let src = "def f(n)\n n\nend\n\ntest \"t\"\n assert f(1) == 1\nend";
750 let out = resolve(src, &loader).expect("resolves");
751 assert!(
752 out.forms().iter().any(super::is_test_form),
753 "the file's OWN tests were dropped; only imported ones should be: {:?}",
754 out.forms()
755 );
756 }
757
758 #[test]
759 fn a_program_without_imports_is_unchanged() {
760 let loader = MemLoader(BTreeMap::new());
761 let src = "def f(n)\n n + 1\nend\nf(1)";
762 let before: Vec<Sexp> = parse(src).iter().map(Spanned::to_sexp).collect();
763 let out = resolve(src, &loader).expect("resolves");
764 assert_eq!(format!("{before:?}"), format!("{:?}", out.sexps()));
765 }
766
767 // ---- file identity ---------------------------------------------------
768
769 /// **Every form's span indexes ITS OWN file, and the text proves it.**
770 ///
771 /// The independent evidence is the source itself: slice each top-level
772 /// form's span out of the file its owner names and re-parse the slice. If
773 /// the owner is wrong the bytes are some other file's, and the re-parsed
774 /// tree does not match — checked against the tree, which the file table had
775 /// no hand in producing.
776 ///
777 /// This is the contract `Span`'s own docs put on the caller — spans "are
778 /// meaningful only relative to the string that produced them" — asserted
779 /// rather than assumed.
780 ///
781 /// **Red run** (2026-08-12), `expand` pushing `ResolvedProgram::ENTRY` as
782 /// every form's owner instead of `owner`:
783 /// ```text
784 /// form 0: its own source does not re-parse: expected an expression, found
785 /// Eof at 25..25
786 /// ```
787 /// It trips at the re-parse rather than the comparison, because kazu's
788 /// byte range cut against the entry file's text lands mid-token — which is
789 /// the mis-attribution stated in bytes.
790 ///
791 /// **Second red run**, the ORIGINAL spanless `parse_program` restored in
792 /// `expand` (the bug this change fixes):
793 /// ```text
794 /// form 0 has no position at all — its file was parsed through the
795 /// spanless door
796 /// ```
797 #[test]
798 fn every_forms_span_indexes_the_file_it_came_from() {
799 let loader = MemLoader(BTreeMap::from([
800 ("retsu", "use(\"kazu\")\ndef sum2(a, b)\n a + b\nend"),
801 ("kazu", "def double(n)\n n * 2\nend"),
802 ]));
803 let out = resolve("use(\"retsu\")\nsum2(double(1), 2)", &loader).expect("resolves");
804 assert_eq!(
805 out.files().len(),
806 3,
807 "expected the entry plus retsu plus kazu: {:?}",
808 out.files()
809 );
810 for (i, form) in out.forms().iter().enumerate() {
811 let file = out
812 .owner_of(i)
813 .and_then(|id| out.file(id))
814 .unwrap_or_else(|| panic!("form {i} has no owning file"));
815 assert!(
816 !form.span.is_synthetic(),
817 "form {i} has no position at all — its file was parsed through \
818 the spanless door"
819 );
820 let slice = file
821 .text
822 .get(form.span.start..form.span.end)
823 .unwrap_or_else(|| {
824 panic!(
825 "form {i}'s span {:?} is not a range in its owner ({} bytes)",
826 form.span,
827 file.text.len()
828 )
829 });
830 let reparsed = blue_lang_syntax::parse_program_tree(slice)
831 .unwrap_or_else(|e| panic!("form {i}: its own source does not re-parse: {e}"));
832 assert_eq!(
833 reparsed.iter().map(Spanned::to_sexp).collect::<Vec<_>>(),
834 vec![form.to_sexp()],
835 "form {i} sliced out of its owner ({}) re-parses to a different \
836 tree; slice was {slice:?}",
837 file.path
838 .as_deref()
839 .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string())
840 );
841 }
842 // Anti-vacuity: an empty program passes the loop above.
843 assert!(out.forms().len() >= 3, "{:?}", out.forms());
844 }
845
846 /// Dropping a form takes its owner with it.
847 ///
848 /// The parallel-vector failure, asserted directly: after `retain` removes
849 /// the entry file's `test` block, every surviving form must still resolve
850 /// to the file it came from. Checked through the same slice-and-re-parse
851 /// evidence, because "the lengths still match" would pass on a program
852 /// where every owner shifted by one.
853 ///
854 /// **Red run** (2026-08-12), `retain` filtering `self.forms` only and
855 /// leaving `self.owner` whole:
856 /// ```text
857 /// after retain, form 0 does not belong to the file it is attributed to:
858 /// slice "test \"t\"\n assert 1 == 1\n" of <anonymous>
859 /// left: []
860 /// right: [List([Atom(Symbol("define")), …double…])]
861 /// ```
862 /// The surviving forms kept the DROPPED form's owner, so kazu's `double`
863 /// was attributed to the entry file and sliced out of it.
864 #[test]
865 fn retain_drops_a_forms_owner_with_it() {
866 let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n n * 2\nend")]));
867 let src = "test \"t\"\n assert 1 == 1\nend\nuse(\"kazu\")\ndouble(21)";
868 let mut out = resolve(src, &loader).expect("resolves");
869 assert!(
870 out.forms().iter().any(super::is_test_form),
871 "the fixture must contain the test block this drops"
872 );
873 out.retain(|f| !super::is_test_form(f));
874 assert!(!out.forms().iter().any(super::is_test_form));
875 for (i, form) in out.forms().iter().enumerate() {
876 let file = out
877 .owner_of(i)
878 .and_then(|id| out.file(id))
879 .unwrap_or_else(|| panic!("form {i} lost its owner"));
880 let slice = &file.text[form.span.start..form.span.end];
881 let reparsed = blue_lang_syntax::parse_program_tree(slice)
882 .map(|f| f.iter().map(Spanned::to_sexp).collect::<Vec<_>>())
883 .unwrap_or_default();
884 assert_eq!(
885 reparsed,
886 vec![form.to_sexp()],
887 "after retain, form {i} does not belong to the file it is \
888 attributed to: slice {slice:?} of {}",
889 file.path
890 .as_deref()
891 .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string())
892 );
893 }
894 // `double`, the `use` declaration, and the call.
895 assert_eq!(out.forms().len(), 3, "{:?}", out.forms());
896 }
897
898 /// An unresolvable index reports no position rather than the entry file's.
899 #[test]
900 fn an_unstamped_diagnostic_gets_no_position_rather_than_a_wrong_one() {
901 let loader = MemLoader(BTreeMap::new());
902 let out = resolve("def f(n)\n n\nend", &loader).expect("resolves");
903 let rendered = out
904 .locate(
905 blue_lang_check::Diagnostic::UNSTAMPED,
906 tatara_lisp::Span::new(0, 1),
907 "something went wrong",
908 )
909 .to_string();
910 assert_eq!(rendered, "<unknown file>: something went wrong");
911 assert!(
912 !rendered.contains(":1:1"),
913 "an unowned diagnostic borrowed a position from somewhere: {rendered}"
914 );
915 }
916}