Skip to main content

blue_lang_runtime/
lib.rs

1//! The blue runtime — **one** definition of what a blue program runs against.
2//!
3//! Before this crate existed, every consumer hand-rolled its own
4//! `Interpreter::new()` + `install_primitives(...)` pair — three of them, in
5//! three crates — and they had silently drifted: none of them loaded the Lisp
6//! stdlib. So `6 % 3` lowered correctly to `(mod 6 3)`, `mod` was genuinely
7//! defined, and the program still died with `unbound symbol: mod`, because
8//! the definition lived in a stdlib nobody loaded.
9//!
10//! That is the duplication tax in its usual shape: the bug is not in any one
11//! copy, it is in there *being* copies. One function now owns the answer.
12//!
13//! ## Layers
14//!
15//! A blue interpreter is built in two layers, and both are required:
16//!
17//! 1. **Rust primitives** — arithmetic, comparison, list ops, I/O.
18//! 2. **The full tatara stdlib** — primitives, higher-order functions
19//!    (`map`/`filter`/`fold`), maps, channels, fibers, type-check, and
20//!    everything tatara defines in tatara-lisp itself
21//!    (`mod`, `rem`, `first`, `inc`, `even?`, the actor and transducer
22//!    helpers, …). Loading it is not optional garnish: blue's own operator
23//!    lowering depends on it.
24//! 3. **blue's own core** ([`stdlib`]) — strings and number conversion, which
25//!    tatara-lisp does not have in any form. A Ruby-surface language without
26//!    `length` or `upcase` is not usable, and the semantics are Ruby's
27//!    (characters, not bytes), which is why they are blue's and not the
28//!    substrate's.
29//! 4. **JSON** ([`json`]) and **cryptography** ([`crypto`]) — pure
30//!    computation, installed unconditionally, so the wasm consumer keeps them.
31//! 5. **The host layer** (`sys`, behind the `sys` feature) — every name in it
32//!    is a host import.
33//! 6. **Processes** ([`attach_node`]) — `spawn`, `send`, `receive` and the
34//!    rest of `blue_lang_proc::node`'s words, bound to a node of the
35//!    interpreter's own, so the program is that node's root process.
36
37pub mod alloc;
38pub mod collection;
39pub mod crypto;
40pub mod dispatch;
41pub mod docs;
42pub mod domain;
43#[cfg(feature = "sys")]
44pub mod entropy;
45pub mod erase;
46pub mod errors;
47pub mod host_surface;
48pub mod hosted;
49pub mod inputs;
50pub mod json;
51pub mod literal;
52pub mod messages;
53#[cfg(feature = "sys")]
54pub mod net;
55pub mod nix;
56pub mod observe;
57pub mod paths;
58pub mod pipeline;
59pub mod project_rules;
60pub mod session;
61pub mod spread;
62pub mod stdlib;
63#[cfg(feature = "sys")]
64pub mod sys;
65pub mod uses;
66
67pub use erase::{erase_types, to_sexps};
68pub use inputs::{declarations, install_input_primitives, Declaration, InputError, Inputs};
69pub use pipeline::{
70    parse, parse_tree_with_depth, parse_with_depth, run, run_with_inputs, Run, RunError,
71};
72pub use stdlib::install_blue_stdlib;
73
74use tatara_lisp_eval::{install_full_stdlib_with, Interpreter};
75
76/// Build an interpreter with the complete blue runtime installed.
77///
78/// This is the *only* sanctioned way to obtain one. A caller that builds an
79/// `Interpreter` directly gets a partial runtime, and the failure shows up as
80/// an unbound symbol at the far end of a program.
81pub fn interpreter<H: 'static>(host: &mut H) -> Interpreter<H> {
82    let mut interp = Interpreter::new();
83    // The FULL substrate, not just `install_primitives`.
84    //
85    // blue called `install_primitives` + `install_lisp_stdlib_with` and got
86    // neither `install_hof` nor `install_map` — so `map`, `filter`, `fold` and
87    // every map literal were UNBOUND SYMBOLS. A language with no higher-order
88    // functions, in a workspace whose whole surface is Ruby's.
89    //
90    // Same shape as the stdlib gap this crate was created to fix: the substrate
91    // has layers, and naming them one at a time is how one gets missed. Call
92    // the composed installer.
93    install_full_stdlib_with(&mut interp, host);
94    // Layer 3: blue's own core — strings and number conversion, which
95    // tatara-lisp does not carry at all. See `stdlib` for why the
96    // character-counting semantics are blue's rather than the substrate's.
97    stdlib::install_blue_stdlib(&mut interp);
98    // Layer 4: blue's JSON surface — parse, stringify, read. Pure
99    // computation, no host imports, so it is installed unconditionally; the
100    // `wasm32-unknown-unknown` consumer keeps it. See `json` for why objects
101    // arrive as alists rather than Maps.
102    json::install_json_stdlib(&mut interp);
103    // Layer 4b: cryptography — BLAKE3 and Ed25519. Pure for the same reason
104    // JSON is: a deterministic function of its arguments, with the keypair's
105    // seed supplied by the caller rather than drawn from an entropy source,
106    // so it lowers to no import and the wasm consumer keeps it. See `crypto`.
107    crypto::install_crypto_stdlib(&mut interp);
108    nix::install_nix_stdlib(&mut interp);
109    // Layer 5: the host-side system surface — process, filesystem, env,
110    // clock, network. Feature-gated: every sys primitive is a host import, so the
111    // wasm consumer (which builds with `sys` OFF) keeps its zero-host-import
112    // surface by construction. Only the CLI turns it on.
113    #[cfg(feature = "sys")]
114    sys::install_sys_stdlib(&mut interp);
115    // Layer 6: processes. Bound here so every name oracle built from this
116    // interpreter (the check stage, the LSP, `blue reference`) knows them.
117    attach_node(&mut interp);
118    // Last, so installing the stdlib is not metered against the program.
119    apply_execution_bounds(&mut interp);
120    interp
121}
122
123/// Give `interp` a process node of its own: its program becomes the node's
124/// root, and `spawn` starts processes in this node and no other.
125///
126/// [`interpreter_hostless`] calls it on every fork, so two programs never
127/// share processes. A door that forks an interpreter per unit of work (a test
128/// per fork) calls it on each fork for the same reason.
129pub fn attach_node<H: 'static>(interp: &mut Interpreter<H>) {
130    dispatch::install(interp);
131    blue_lang_proc::install(interp, blue_lang_proc::Node::new(node_config()));
132}
133
134/// [`attach_node`] for a program that runs under `frame`: every process it
135/// starts is held to the same frame, so a host name the program may not call
136/// is refused in its children too. Without this a spawned child would run on
137/// the full runtime and a frame would be one `spawn` away from meaningless.
138pub fn attach_node_in_frame<H: 'static>(interp: &mut Interpreter<H>, frame: &blue_lang_waku::Waku) {
139    let mut config = node_config();
140    let refused: Vec<(&'static str, &'static str)> = blue_lang_waku::Capability::host_effects()
141        .into_iter()
142        .filter(|cap| !frame.reach.grants(*cap))
143        .flat_map(|cap| cap.names().into_iter().map(move |n| (n, cap.label())))
144        .collect();
145    config.child = std::sync::Arc::new(move || {
146        let mut child = child_interpreter();
147        for (name, label) in &refused {
148            if child.lookup_global(name).is_none() {
149                continue;
150            }
151            let name: &'static str = name;
152            let label: &'static str = label;
153            child.register_fn(name, tatara_lisp_eval::ffi::Arity::Any, move |_: &[tatara_lisp_eval::Value], _: &mut (), span| {
154                Err(tatara_lisp_eval::EvalError::native_fn(
155                    name,
156                    format!("refused: `{name}` reaches the host ({label}), which this process's frame does not grant"),
157                    span,
158                ))
159            });
160        }
161        child
162    });
163    dispatch::install(interp);
164    blue_lang_proc::install(interp, blue_lang_proc::Node::new(config));
165}
166
167/// The node every interpreter's processes run under: the execution bounds
168/// per incarnation, a scheduling quantum, and the host's clock when there is
169/// a host (a node with no host has a virtual clock: time passes only when
170/// every process waits).
171fn node_config() -> blue_lang_proc::NodeConfig {
172    let b = execution_bounds();
173    let mut config = blue_lang_proc::NodeConfig::new(std::sync::Arc::new(child_interpreter));
174    config.budget = tatara_lisp_eval::vm::Budget {
175        fuel: b.max_steps,
176        max_depth: Some(b.max_call_depth),
177        quantum: Some(blue_lang_proc::node::DEFAULT_QUANTUM),
178    };
179    config.clock = if cfg!(feature = "sys") {
180        blue_lang_proc::ClockKind::Real
181    } else {
182        blue_lang_proc::ClockKind::Virtual
183    };
184    config
185}
186
187/// A fresh interpreter for a child process: the hostless base, forked. The
188/// node adds its child words to it.
189fn child_interpreter() -> Interpreter<()> {
190    let mut interp = match HOSTLESS_BASE.lock() {
191        Ok(base) => base.fork(),
192        Err(_) => interpreter(&mut ()),
193    };
194    apply_execution_bounds(&mut interp);
195    interp
196}
197
198/// The execution bounds every interpreter this crate builds runs under.
199static EXECUTION_BOUNDS: std::sync::OnceLock<ExecutionBounds> = std::sync::OnceLock::new();
200
201/// How much a blue program may do before it is refused: nested calls and
202/// evaluation steps.
203///
204/// Both are BOUNDS in the sense `blue-lang-cli`'s `config` module admits: a
205/// terminating program under bounds above its cost returns the same value, and
206/// one that exceeds either is refused with a catchable `depth-exceeded` or
207/// `fuel-exhausted` error naming the limit and the function — never a process
208/// abort, which is what exceeding the host stack was before tatara-lisp-eval
209/// 0.3.63. The walker and the VM run under the same bounds and refuse with the
210/// same message.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub struct ExecutionBounds {
213    /// Nested (non-tail) calls alive at once. Tail calls do not count.
214    pub max_call_depth: usize,
215    /// Evaluation steps per run; `None` is unbounded.
216    pub max_steps: Option<usize>,
217}
218
219impl ExecutionBounds {
220    /// Depth at tatara-lisp-eval's `DEFAULT_MAX_DEPTH`, steps unbounded.
221    ///
222    /// The two defaults differ for one reason: whether a program that works
223    /// today keeps working. A recursion past 100k frames never worked — it
224    /// aborted the process — so bounding depth removes no behaviour. A run
225    /// longer than any finite step count did work (simulations, tools on
226    /// nodes), so a default step bound would remove it; for a few hours of
227    /// 2026-09-29 blue shipped 50M (~3 s) and did exactly that. A host that
228    /// runs code it did not write, or a test that must see a runaway end,
229    /// sets `max_steps` itself.
230    pub const DEFAULT: Self = Self {
231        max_call_depth: tatara_lisp_eval::vm::DEFAULT_MAX_DEPTH,
232        max_steps: None,
233    };
234}
235
236/// Name the bounds for this process. The CLI calls this once with the resolved
237/// `BlueConfig`, before any interpreter is built; an embedder that never calls
238/// it runs under [`ExecutionBounds::DEFAULT`]. Only the first call counts,
239/// like `sys::set_program_args`.
240pub fn set_execution_bounds(bounds: ExecutionBounds) {
241    let _ = EXECUTION_BOUNDS.set(bounds);
242}
243
244/// The bounds interpreters are built with.
245#[must_use]
246pub fn execution_bounds() -> ExecutionBounds {
247    EXECUTION_BOUNDS
248        .get()
249        .copied()
250        .unwrap_or(ExecutionBounds::DEFAULT)
251}
252
253fn apply_execution_bounds<H: 'static>(interp: &mut Interpreter<H>) {
254    let b = execution_bounds();
255    // No quantum, so the tree-walker refuses nothing of this budget.
256    let _ = interp.set_budget(tatara_lisp_eval::vm::Budget {
257        fuel: b.max_steps,
258        max_depth: Some(b.max_call_depth),
259        quantum: None,
260    });
261}
262
263/// The prebuilt hostless substrate, built once per process.
264///
265/// **Measured 2026-08-01, and this is the entire reason the fork exists.**
266/// Profiling a trivial blue program found the run dominated by ONE thing:
267///
268/// ```text
269/// parse                     9.6 µs
270/// check                     1.6 µs
271/// interpreter_hostless()  5 820 µs   <- 98.4% of the run
272/// ```
273///
274/// Splitting that further: `Interpreter::new()` is 24 µs and
275/// `install_full_stdlib_with` is the rest — the stdlib was being rebuilt from
276/// scratch on every single `run()`, and blue's whole surface (`map`, `filter`,
277/// string ops) lives in it, so no program could avoid the cost.
278///
279/// `fork` already existed upstream for exactly this, and blue simply was not
280/// using it. tatara-lisp-eval's own `fork_cost.rs` opens with *"cheapness is
281/// the entire reason it exists"* — the capability was built, documented and
282/// guarded, and the consumer kept paying full price beside it.
283///
284/// | | per call |
285/// |---|---|
286/// | rebuild (`Interpreter::new` + `install_full_stdlib_with`) | **9.03 ms** |
287/// | `fork()` of a prebuilt base | **0.102 ms** |
288///
289/// **88× on the dominant cost.** This is purgatory's computation-layer clause
290/// made real: the stdlib is the most-reused computation in the language, and
291/// it was being discarded and recomputed rather than resurrected.
292///
293/// Isolation is `fork`'s contract, not an assumption made here — its
294/// correctness gates live upstream in `fork.rs`, and `fork_cost.rs` guards
295/// against a future change that deep-copies (which would keep every
296/// correctness test green while silently restoring the cost this removes).
297static HOSTLESS_BASE: std::sync::LazyLock<std::sync::Mutex<Interpreter<()>>> =
298    std::sync::LazyLock::new(|| std::sync::Mutex::new(interpreter(&mut ())));
299
300/// The common host-free case.
301///
302/// Forks the process-wide base rather than rebuilding the stdlib. Falls back to
303/// a full build if the lock is poisoned: a poisoned lock means another thread
304/// panicked mid-fork, and answering a correct-but-slow interpreter beats
305/// propagating someone else's panic into an unrelated caller.
306pub fn interpreter_hostless() -> Interpreter<()> {
307    let mut interp = match HOSTLESS_BASE.lock() {
308        Ok(base) => base.fork(),
309        Err(_) => interpreter(&mut ()),
310    };
311    // Again after the fork: the base may have been built before the bounds
312    // were named, and a fork inherits whatever its base had.
313    apply_execution_bounds(&mut interp);
314    // A node per fork: the base's own node would be shared by every program.
315    attach_node(&mut interp);
316    interp
317}
318
319/// Lift spanless forms into what the evaluator eats.
320///
321/// **No longer on the `blue run` path, and that is the point.**
322/// [`erase::erase_types`] now takes and returns `Spanned`, so
323/// `pipeline::run_in_surface` hands the evaluator the *parser's own* tree with
324/// the author's positions on it — there is nothing left to lift. This
325/// function survives for a caller that genuinely holds spanless `Sexp` and
326/// has no position to preserve, and as the subject of the two tests below
327/// that pin why the hop it replaced was unsafe.
328///
329/// `Interpreter::eval_program` takes `&[Spanned]`; blue's stages produced
330/// `Sexp`. Two callers bridged that gap by *printing the tree and reading it
331/// back* — `forms.map(ToString::to_string).join("\n")` into
332/// `tatara_lisp::read_spanned`. Both stopped, and the round trip is gone from
333/// the pipeline.
334///
335/// **Why it had to go.** Printing a tree we already hold and re-parsing it puts
336/// the reader's lexer between blue and its own output, for nothing — and the
337/// printer and the reader are **not inverses**. `Atom::Str`'s `Display` escapes
338/// its payload and its own docs explain at length why; the `Atom::Symbol` arm
339/// is a bare `write_str`. So a symbol whose text carries a separator prints as
340/// several tokens and reads back as several symbols: a well-formed tree with a
341/// different meaning, no error raised. Measured 2026-08-02 —
342/// `pipeline::tests::the_round_trip_is_not_the_identity_in_general` pins which
343/// separators are silent and which are loud. The only thing that kept this from
344/// biting was blue happening not to emit those bytes. A stage that never
345/// serialises cannot be mis-read.
346///
347/// **What is given up: spans.** The old path's spans pointed into the
348/// re-printed lisp text, a buffer no human ever wrote and no diagnostic could
349/// usefully cite — they were positions in blue's own output, not in the
350/// author's source. `Span::synthetic` says the same thing honestly.
351///
352/// **Carrying real blue-source spans through erasure was the separate piece of
353/// work, and it is done — but NOT the way this doc predicted.** It said the
354/// work "would start from `Spanned::from_sexp_at`". It did not, and could not:
355/// `from_sexp_at` is a *lift*, stamping ONE span across a whole subtree, so
356/// starting there would have replaced a tree of synthetic spans with a tree of
357/// identical wrong ones. The actual shape was to stop projecting in the first
358/// place — erasure walks `Spanned` and keeps each node's own span, because it
359/// only ever deletes (see [`erase`]). **A pointer at the nearest-looking API is
360/// how a reader is sent to the wrong starting point**; the note is corrected
361/// here rather than deleted, because the wrong lead was load-bearing enough to
362/// be worth naming.
363#[must_use]
364pub fn lower_to_spanned(forms: &[tatara_lisp::Sexp]) -> Vec<tatara_lisp::Spanned> {
365    forms
366        .iter()
367        .map(tatara_lisp::Spanned::from_sexp_synthetic)
368        .collect()
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use tatara_lisp_eval::Value;
375
376    fn eval(src: &str) -> Value {
377        let forms = tatara_lisp::read_spanned(src).expect("read");
378        let mut interp = interpreter_hostless();
379        interp.eval_program(&forms, &mut ()).expect("eval")
380    }
381
382    /// Every layer is present. A test that only checked layer 1 is exactly
383    /// what let the stdlib gap survive.
384    #[test]
385    fn both_layers_are_installed() {
386        // Layer 1: a Rust primitive.
387        assert!(matches!(eval("(+ 1 2)"), Value::Int(3)));
388        // Layer 2: a stdlib definition, which is the layer that was missing.
389        assert!(matches!(eval("(mod 7 3)"), Value::Int(1)));
390        assert!(matches!(eval("(inc 41)"), Value::Int(42)));
391        assert!(matches!(eval("(first (list 9 8))"), Value::Int(9)));
392        // Layer 3: blue's own core — string and number conversion.
393        assert!(matches!(eval(r#"(length "héllo")"#), Value::Int(5)));
394        // Layer 4: the JSON surface.
395        assert!(matches!(
396            eval(r#"(json_parse "{\"outcome\":\"ok\"}")"#),
397            Value::List(_)
398        ));
399        // Layer 4b: cryptography.
400        assert!(matches!(eval(r#"(blake3_hex "")"#), Value::Str(_)));
401    }
402
403    /// Anti-vacuity: a bare interpreter really does LACK layer 2, so the test
404    /// above is measuring the runtime's contribution and not a property the
405    /// interpreter has for free.
406    #[test]
407    fn a_bare_interpreter_lacks_the_stdlib() {
408        let forms = tatara_lisp::read_spanned("(mod 7 3)").expect("read");
409        let mut bare = Interpreter::new();
410        tatara_lisp_eval::install_primitives(&mut bare);
411        assert!(
412            bare.eval_program(&forms, &mut ()).is_err(),
413            "if a bare interpreter already resolved `mod`, this crate would be measuring nothing"
414        );
415    }
416}