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