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