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
44use std::collections::BTreeSet;
45
46use tatara_lisp::{Atom, Sexp};
47
48/// Supplies the source of a named bidama.
49///
50/// One method, because resolution needs exactly one thing: given a name, the
51/// blue source that name refers to. *Where* it came from — a working tree, a
52/// nix store path, a git object, memory — is the implementation's business and
53/// deliberately invisible here.
54pub trait Loader {
55    /// The `.b` sources of `name`, as `(label, source)` pairs.
56    ///
57    /// The label is for diagnostics only; nothing keys on it. A package with
58    /// several files returns several pairs, and their relative order is the
59    /// implementation's to fix — [`FsLoader`](../../blue_lang_pkg/load_path/index.html)
60    /// sorts by filename so a load is reproducible rather than
61    /// directory-order-dependent.
62    ///
63    /// `Err` is a human-readable reason the package could not be loaded. It
64    /// must name what was looked for, because "package not found" without a
65    /// name sends the reader grepping a distribution to find which one.
66    fn load(&self, name: &str) -> Result<Vec<(String, String)>, String>;
67}
68
69/// A loader that resolves nothing, and says so.
70///
71/// The default for [`run`](crate::pipeline::run), so a program using `use` in
72/// a context with no packaging configured gets a typed error naming the
73/// package — not a silently-undefined function that fails much later as an
74/// unbound symbol pointing at innocent code.
75pub struct NoLoader;
76
77impl Loader for NoLoader {
78    fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
79        Err(format!(
80            "cannot load bidama \"{name}\": no loader is installed. A program \
81             that uses packages must run with one — `blue_lang_pkg::LoadPath` \
82             reads BLUE_PATH, which `nix develop` and the bidama derivations \
83             populate."
84        ))
85    }
86}
87
88/// Is this form a `use("name")` call? If so, the name.
89///
90/// Matches the *call* form only. `use "kazu"` without parentheses parses as
91/// two unrelated top-level atoms (blue has no paren-less call syntax), which
92/// would silently do nothing — so it is not treated as an import, and the
93/// bare symbol `use` then fails as an unbound name rather than being quietly
94/// ignored.
95fn use_target(form: &Sexp) -> Option<String> {
96    let Sexp::List(items) = form else {
97        return None;
98    };
99    let [head, arg] = items.as_slice() else {
100        return None;
101    };
102    match (head, arg) {
103        (Sexp::Atom(Atom::Symbol(s)), Sexp::Atom(Atom::Str(name))) if s == "use" => {
104            Some(name.clone())
105        }
106        _ => None,
107    }
108}
109
110/// Is this form a lowered `test` block?
111///
112/// `test "…" … end` lowers to `(deftest …)`, which only the test harness
113/// binds. Public because two callers need it and for opposite reasons: the
114/// resolver drops IMPORTED tests (a dependency's tests are not the importer's
115/// to run), and `pipeline::run` drops ALL of them (a `test` block is a
116/// declaration for the harness, not code to execute — without this,
117/// `blue run` on any file that contains its own tests dies on an unbound
118/// `deftest`, which is every package in this distribution).
119pub fn is_test_form(form: &Sexp) -> bool {
120    let Sexp::List(items) = form else {
121        return false;
122    };
123    matches!(items.first(), Some(Sexp::Atom(Atom::Symbol(s))) if s == "deftest")
124}
125
126/// Replace every `use(...)` with the forms of the package it names.
127///
128/// Transitive by construction: a loaded package's own `use` calls are resolved
129/// the same way, depth-first, so a consumer names its direct dependency and
130/// gets the closure.
131///
132/// **A package is loaded at most once.** Two importers of one package must
133/// share its definitions — loading twice would re-evaluate them, which is at
134/// best wasted work and at worst two distinct copies of anything stateful.
135/// That same visited-set is what makes a dependency CYCLE terminate: the
136/// second visit is a no-op rather than infinite recursion, so a cyclic
137/// distribution loads and runs instead of hanging.
138///
139/// # Errors
140///
141/// Returns the loader's message, prefixed with the import chain that reached
142/// it, when a package cannot be loaded or its source cannot be parsed.
143pub fn resolve_uses(forms: Vec<Sexp>, loader: &dyn Loader) -> Result<Vec<Sexp>, String> {
144    let mut seen = BTreeSet::new();
145    expand(forms, loader, &mut seen, &[])
146}
147
148fn expand(
149    forms: Vec<Sexp>,
150    loader: &dyn Loader,
151    seen: &mut BTreeSet<String>,
152    chain: &[String],
153) -> Result<Vec<Sexp>, String> {
154    let mut out = Vec::with_capacity(forms.len());
155    for form in forms {
156        let Some(name) = use_target(&form) else {
157            out.push(form);
158            continue;
159        };
160        if !seen.insert(name.clone()) {
161            continue;
162        }
163
164        let sources = loader.load(&name).map_err(|e| describe(chain, &name, &e))?;
165        let mut inner_chain = chain.to_vec();
166        inner_chain.push(name.clone());
167
168        for (label, src) in sources {
169            let parsed = blue_lang_syntax::parse_program(&src)
170                .map_err(|e| describe(chain, &name, &format!("{label}: {e}")))?;
171            // An imported package's TEST blocks do not come along.
172            //
173            // A dependency's tests are not the importer's to run, and trying
174            // is not merely untidy — it is a hard failure. `test` lowers to
175            // `deftest`, which only the test harness binds, so the ordinary
176            // evaluator sees an unbound symbol. Measured, the moment kazu
177            // gained test blocks: every program importing it died with
178            // `unbound symbol: deftest`, pointing at a line the importer never
179            // wrote.
180            //
181            // Dropping them here also makes the distribution gate honest for
182            // free: `blue test kikagaku.b` now reports kikagaku's tests
183            // rather than kikagaku's plus everything it transitively imports.
184            let parsed = parsed.into_iter().filter(|f| !is_test_form(f)).collect();
185            out.extend(expand(parsed, loader, seen, &inner_chain)?);
186        }
187    }
188    Ok(out)
189}
190
191/// Prefix a failure with the import chain that reached it.
192///
193/// A transitive failure otherwise names only the leaf, and the reader has no
194/// way to tell which of their own imports pulled it in — the exact question
195/// they need answered to fix it.
196fn describe(chain: &[String], name: &str, reason: &str) -> String {
197    if chain.is_empty() {
198        return reason.to_owned();
199    }
200    format!("while loading {} -> {name}: {reason}", chain.join(" -> "))
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::collections::BTreeMap;
207
208    /// An in-memory distribution — the whole pass runs with no filesystem.
209    struct MemLoader(BTreeMap<&'static str, &'static str>);
210
211    impl Loader for MemLoader {
212        fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
213            self.0
214                .get(name)
215                .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
216                .ok_or_else(|| format!("no bidama named \"{name}\""))
217        }
218    }
219
220    fn parse(src: &str) -> Vec<Sexp> {
221        blue_lang_syntax::parse_program(src).expect("test source must parse")
222    }
223
224    #[test]
225    fn a_use_is_replaced_by_the_packages_forms() {
226        let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n  n * 2\nend")]));
227        let out = resolve_uses(parse("use(\"kazu\")\ndouble(21)"), &loader).expect("resolves");
228        // The `use` itself is GONE — it is not a call that survives to the
229        // evaluator, where `use` is not a defined function.
230        assert!(
231            out.iter().all(|f| super::use_target(f).is_none()),
232            "a use form survived resolution and would reach the evaluator as \
233             an unbound function: {out:?}"
234        );
235        assert!(
236            out.len() > 1,
237            "the package's definitions must be spliced in, not dropped: {out:?}"
238        );
239    }
240
241    #[test]
242    fn imports_are_transitive() {
243        let loader = MemLoader(BTreeMap::from([
244            ("retsu", "use(\"kazu\")\ndef sum2(a, b)\n  a + b\nend"),
245            ("kazu", "def double(n)\n  n * 2\nend"),
246        ]));
247        let out = resolve_uses(parse("use(\"retsu\")"), &loader).expect("resolves");
248        // A consumer names retsu only; kazu arrives because retsu needs it.
249        assert!(
250            out.len() >= 2,
251            "the transitive dependency did not arrive: {out:?}"
252        );
253    }
254
255    #[test]
256    fn a_package_is_loaded_at_most_once() {
257        let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n  n * 2\nend")]));
258        let once = resolve_uses(parse("use(\"kazu\")"), &loader).expect("resolves");
259        let twice = resolve_uses(parse("use(\"kazu\")\nuse(\"kazu\")"), &loader).expect("resolves");
260        assert_eq!(
261            once.len(),
262            twice.len(),
263            "importing a package twice duplicated its definitions; two \
264             importers of one package must share it"
265        );
266    }
267
268    /// The property that keeps a cyclic distribution from hanging.
269    #[test]
270    fn a_dependency_cycle_terminates() {
271        let loader = MemLoader(BTreeMap::from([
272            ("a", "use(\"b\")\ndef fa()\n  1\nend"),
273            ("b", "use(\"a\")\ndef fb()\n  2\nend"),
274        ]));
275        let out = resolve_uses(parse("use(\"a\")"), &loader).expect("a cycle must resolve");
276        assert!(!out.is_empty(), "a cycle resolved to nothing: {out:?}");
277    }
278
279    #[test]
280    fn a_missing_package_names_itself_and_the_chain() {
281        let loader = MemLoader(BTreeMap::from([("retsu", "use(\"nowhere\")")]));
282        let err = resolve_uses(parse("use(\"retsu\")"), &loader).expect_err("must fail");
283        assert!(
284            err.contains("nowhere"),
285            "the error must name the missing package: {err}"
286        );
287        assert!(
288            err.contains("retsu"),
289            "the error must name the import that pulled it in, or the reader \
290             cannot tell which of their own imports is at fault: {err}"
291        );
292    }
293
294    #[test]
295    fn the_default_loader_refuses_by_name() {
296        let err = resolve_uses(parse("use(\"kazu\")"), &NoLoader).expect_err("must fail");
297        assert!(
298            err.contains("kazu"),
299            "NoLoader must name what was asked for: {err}"
300        );
301    }
302
303    /// A non-`use` program must come out byte-identical.
304    ///
305    /// This pass runs on EVERY program, so a bug here would corrupt source
306    /// that never mentions a package.
307    /// An imported package's tests must NOT come along.
308    ///
309    /// Not a tidiness point: `deftest` is unbound outside the test harness, so
310    /// an inherited test block kills any program that imports a tested
311    /// package — which is every package in a distribution worth having.
312    #[test]
313    fn an_imported_packages_tests_are_not_inherited() {
314        let loader = MemLoader(BTreeMap::from([(
315            "kazu",
316            "def double(n)\n  n * 2\nend\n\ntest \"doubles\"\n  assert double(2) == 4\nend",
317        )]));
318        let out = resolve_uses(parse("use(\"kazu\")\ndouble(21)"), &loader).expect("resolves");
319        assert!(
320            out.iter().all(|f| !super::is_test_form(f)),
321            "an imported test block survived and would reach the evaluator as \
322             an unbound `deftest`: {out:?}"
323        );
324        // The DEFINITIONS still arrive — dropping tests must not drop code.
325        assert!(
326            out.len() >= 2,
327            "filtering tests also removed the package's definitions: {out:?}"
328        );
329    }
330
331    /// But the ENTRY program keeps its own tests.
332    #[test]
333    fn the_entry_programs_own_tests_survive() {
334        let loader = MemLoader(BTreeMap::new());
335        let src = "def f(n)\n  n\nend\n\ntest \"t\"\n  assert f(1) == 1\nend";
336        let out = resolve_uses(parse(src), &loader).expect("resolves");
337        assert!(
338            out.iter().any(|f| super::is_test_form(f)),
339            "the file's OWN tests were dropped; only imported ones should be: {out:?}"
340        );
341    }
342
343    #[test]
344    fn a_program_without_imports_is_unchanged() {
345        let loader = MemLoader(BTreeMap::new());
346        let src = parse("def f(n)\n  n + 1\nend\nf(1)");
347        let out = resolve_uses(src.clone(), &loader).expect("resolves");
348        assert_eq!(format!("{src:?}"), format!("{out:?}"));
349    }
350}