Skip to main content

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