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