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
34pub mod crypto;
35pub mod docs;
36pub mod domain;
37pub mod erase;
38pub mod hosted;
39pub mod inputs;
40pub mod json;
41pub mod literal;
42pub mod messages;
43#[cfg(feature = "sys")]
44pub mod net;
45pub mod pipeline;
46pub mod session;
47pub mod stdlib;
48#[cfg(feature = "sys")]
49pub mod sys;
50pub mod uses;
51
52pub use erase::{erase_types, to_sexps};
53pub use inputs::{declarations, install_input_primitives, Declaration, InputError, Inputs};
54pub use pipeline::{
55 parse, parse_tree_with_depth, parse_with_depth, run, run_with_inputs, Run, RunError,
56};
57pub use stdlib::install_blue_stdlib;
58
59use tatara_lisp_eval::{install_full_stdlib_with, Interpreter};
60
61/// Build an interpreter with the complete blue runtime installed.
62///
63/// This is the *only* sanctioned way to obtain one. A caller that builds an
64/// `Interpreter` directly gets a partial runtime, and the failure shows up as
65/// an unbound symbol at the far end of a program.
66pub fn interpreter<H: 'static>(host: &mut H) -> Interpreter<H> {
67 let mut interp = Interpreter::new();
68 // The FULL substrate, not just `install_primitives`.
69 //
70 // blue called `install_primitives` + `install_lisp_stdlib_with` and got
71 // neither `install_hof` nor `install_map` — so `map`, `filter`, `fold` and
72 // every map literal were UNBOUND SYMBOLS. A language with no higher-order
73 // functions, in a workspace whose whole surface is Ruby's.
74 //
75 // Same shape as the stdlib gap this crate was created to fix: the substrate
76 // has layers, and naming them one at a time is how one gets missed. Call
77 // the composed installer.
78 install_full_stdlib_with(&mut interp, host);
79 // Layer 3: blue's own core — strings and number conversion, which
80 // tatara-lisp does not carry at all. See `stdlib` for why the
81 // character-counting semantics are blue's rather than the substrate's.
82 stdlib::install_blue_stdlib(&mut interp);
83 // Layer 4: blue's JSON surface — parse, stringify, read. Pure
84 // computation, no host imports, so it is installed unconditionally; the
85 // `wasm32-unknown-unknown` consumer keeps it. See `json` for why objects
86 // arrive as alists rather than Maps.
87 json::install_json_stdlib(&mut interp);
88 // Layer 4b: cryptography — BLAKE3 and Ed25519. Pure for the same reason
89 // JSON is: a deterministic function of its arguments, with the keypair's
90 // seed supplied by the caller rather than drawn from an entropy source,
91 // so it lowers to no import and the wasm consumer keeps it. See `crypto`.
92 crypto::install_crypto_stdlib(&mut interp);
93 // Layer 5: the host-side system surface — process, filesystem, env,
94 // clock, network. Feature-gated: every sys primitive is a host import, so the
95 // wasm consumer (which builds with `sys` OFF) keeps its zero-host-import
96 // surface by construction. Only the CLI turns it on.
97 #[cfg(feature = "sys")]
98 sys::install_sys_stdlib(&mut interp);
99 // Last, so installing the stdlib is not metered against the program.
100 apply_execution_bounds(&mut interp);
101 interp
102}
103
104/// The execution bounds every interpreter this crate builds runs under.
105static EXECUTION_BOUNDS: std::sync::OnceLock<ExecutionBounds> = std::sync::OnceLock::new();
106
107/// How much a blue program may do before it is refused: nested calls and
108/// evaluation steps.
109///
110/// Both are BOUNDS in the sense `blue-lang-cli`'s `config` module admits: a
111/// terminating program under bounds above its cost returns the same value, and
112/// one that exceeds either is refused with a catchable `depth-exceeded` or
113/// `fuel-exhausted` error naming the limit and the function — never a process
114/// abort, which is what exceeding the host stack was before tatara-lisp-eval
115/// 0.3.63. The walker and the VM run under the same bounds and refuse with the
116/// same message.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct ExecutionBounds {
119 /// Nested (non-tail) calls alive at once. Tail calls do not count.
120 pub max_call_depth: usize,
121 /// Evaluation steps per run; `None` is unbounded.
122 pub max_steps: Option<usize>,
123}
124
125impl ExecutionBounds {
126 /// Depth at tatara-lisp-eval's `DEFAULT_MAX_DEPTH`, steps unbounded.
127 ///
128 /// The two defaults differ for one reason: whether a program that works
129 /// today keeps working. A recursion past 100k frames never worked — it
130 /// aborted the process — so bounding depth removes no behaviour. A run
131 /// longer than any finite step count did work (simulations, tools on
132 /// nodes), so a default step bound would remove it; for a few hours of
133 /// 2026-09-29 blue shipped 50M (~3 s) and did exactly that. A host that
134 /// runs code it did not write, or a test that must see a runaway end,
135 /// sets `max_steps` itself.
136 pub const DEFAULT: Self = Self {
137 max_call_depth: tatara_lisp_eval::vm::DEFAULT_MAX_DEPTH,
138 max_steps: None,
139 };
140}
141
142/// Name the bounds for this process. The CLI calls this once with the resolved
143/// `BlueConfig`, before any interpreter is built; an embedder that never calls
144/// it runs under [`ExecutionBounds::DEFAULT`]. Only the first call counts,
145/// like `sys::set_program_args`.
146pub fn set_execution_bounds(bounds: ExecutionBounds) {
147 let _ = EXECUTION_BOUNDS.set(bounds);
148}
149
150/// The bounds interpreters are built with.
151#[must_use]
152pub fn execution_bounds() -> ExecutionBounds {
153 EXECUTION_BOUNDS
154 .get()
155 .copied()
156 .unwrap_or(ExecutionBounds::DEFAULT)
157}
158
159fn apply_execution_bounds<H: 'static>(interp: &mut Interpreter<H>) {
160 let b = execution_bounds();
161 // No quantum, so the tree-walker refuses nothing of this budget.
162 let _ = interp.set_budget(tatara_lisp_eval::vm::Budget {
163 fuel: b.max_steps,
164 max_depth: Some(b.max_call_depth),
165 quantum: None,
166 });
167}
168
169/// The prebuilt hostless substrate, built once per process.
170///
171/// **Measured 2026-08-01, and this is the entire reason the fork exists.**
172/// Profiling a trivial blue program found the run dominated by ONE thing:
173///
174/// ```text
175/// parse 9.6 µs
176/// check 1.6 µs
177/// interpreter_hostless() 5 820 µs <- 98.4% of the run
178/// ```
179///
180/// Splitting that further: `Interpreter::new()` is 24 µs and
181/// `install_full_stdlib_with` is the rest — the stdlib was being rebuilt from
182/// scratch on every single `run()`, and blue's whole surface (`map`, `filter`,
183/// string ops) lives in it, so no program could avoid the cost.
184///
185/// `fork` already existed upstream for exactly this, and blue simply was not
186/// using it. tatara-lisp-eval's own `fork_cost.rs` opens with *"cheapness is
187/// the entire reason it exists"* — the capability was built, documented and
188/// guarded, and the consumer kept paying full price beside it.
189///
190/// | | per call |
191/// |---|---|
192/// | rebuild (`Interpreter::new` + `install_full_stdlib_with`) | **9.03 ms** |
193/// | `fork()` of a prebuilt base | **0.102 ms** |
194///
195/// **88× on the dominant cost.** This is purgatory's computation-layer clause
196/// made real: the stdlib is the most-reused computation in the language, and
197/// it was being discarded and recomputed rather than resurrected.
198///
199/// Isolation is `fork`'s contract, not an assumption made here — its
200/// correctness gates live upstream in `fork.rs`, and `fork_cost.rs` guards
201/// against a future change that deep-copies (which would keep every
202/// correctness test green while silently restoring the cost this removes).
203static HOSTLESS_BASE: std::sync::LazyLock<std::sync::Mutex<Interpreter<()>>> =
204 std::sync::LazyLock::new(|| std::sync::Mutex::new(interpreter(&mut ())));
205
206/// The common host-free case.
207///
208/// Forks the process-wide base rather than rebuilding the stdlib. Falls back to
209/// a full build if the lock is poisoned: a poisoned lock means another thread
210/// panicked mid-fork, and answering a correct-but-slow interpreter beats
211/// propagating someone else's panic into an unrelated caller.
212pub fn interpreter_hostless() -> Interpreter<()> {
213 let mut interp = match HOSTLESS_BASE.lock() {
214 Ok(base) => base.fork(),
215 Err(_) => interpreter(&mut ()),
216 };
217 // Again after the fork: the base may have been built before the bounds
218 // were named, and a fork inherits whatever its base had.
219 apply_execution_bounds(&mut interp);
220 interp
221}
222
223/// Lift spanless forms into what the evaluator eats.
224///
225/// **No longer on the `blue run` path, and that is the point.**
226/// [`erase::erase_types`] now takes and returns `Spanned`, so
227/// `pipeline::run_in_surface` hands the evaluator the *parser's own* tree with
228/// the author's positions on it — there is nothing left to lift. This
229/// function survives for a caller that genuinely holds spanless `Sexp` and
230/// has no position to preserve, and as the subject of the two tests below
231/// that pin why the hop it replaced was unsafe.
232///
233/// `Interpreter::eval_program` takes `&[Spanned]`; blue's stages produced
234/// `Sexp`. Two callers bridged that gap by *printing the tree and reading it
235/// back* — `forms.map(ToString::to_string).join("\n")` into
236/// `tatara_lisp::read_spanned`. Both stopped, and the round trip is gone from
237/// the pipeline.
238///
239/// **Why it had to go.** Printing a tree we already hold and re-parsing it puts
240/// the reader's lexer between blue and its own output, for nothing — and the
241/// printer and the reader are **not inverses**. `Atom::Str`'s `Display` escapes
242/// its payload and its own docs explain at length why; the `Atom::Symbol` arm
243/// is a bare `write_str`. So a symbol whose text carries a separator prints as
244/// several tokens and reads back as several symbols: a well-formed tree with a
245/// different meaning, no error raised. Measured 2026-08-02 —
246/// `pipeline::tests::the_round_trip_is_not_the_identity_in_general` pins which
247/// separators are silent and which are loud. The only thing that kept this from
248/// biting was blue happening not to emit those bytes. A stage that never
249/// serialises cannot be mis-read.
250///
251/// **What is given up: spans.** The old path's spans pointed into the
252/// re-printed lisp text, a buffer no human ever wrote and no diagnostic could
253/// usefully cite — they were positions in blue's own output, not in the
254/// author's source. `Span::synthetic` says the same thing honestly.
255///
256/// **Carrying real blue-source spans through erasure was the separate piece of
257/// work, and it is done — but NOT the way this doc predicted.** It said the
258/// work "would start from `Spanned::from_sexp_at`". It did not, and could not:
259/// `from_sexp_at` is a *lift*, stamping ONE span across a whole subtree, so
260/// starting there would have replaced a tree of synthetic spans with a tree of
261/// identical wrong ones. The actual shape was to stop projecting in the first
262/// place — erasure walks `Spanned` and keeps each node's own span, because it
263/// only ever deletes (see [`erase`]). **A pointer at the nearest-looking API is
264/// how a reader is sent to the wrong starting point**; the note is corrected
265/// here rather than deleted, because the wrong lead was load-bearing enough to
266/// be worth naming.
267#[must_use]
268pub fn lower_to_spanned(forms: &[tatara_lisp::Sexp]) -> Vec<tatara_lisp::Spanned> {
269 forms
270 .iter()
271 .map(tatara_lisp::Spanned::from_sexp_synthetic)
272 .collect()
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use tatara_lisp_eval::Value;
279
280 fn eval(src: &str) -> Value {
281 let forms = tatara_lisp::read_spanned(src).expect("read");
282 let mut interp = interpreter_hostless();
283 interp.eval_program(&forms, &mut ()).expect("eval")
284 }
285
286 /// Every layer is present. A test that only checked layer 1 is exactly
287 /// what let the stdlib gap survive.
288 #[test]
289 fn both_layers_are_installed() {
290 // Layer 1: a Rust primitive.
291 assert!(matches!(eval("(+ 1 2)"), Value::Int(3)));
292 // Layer 2: a stdlib definition, which is the layer that was missing.
293 assert!(matches!(eval("(mod 7 3)"), Value::Int(1)));
294 assert!(matches!(eval("(inc 41)"), Value::Int(42)));
295 assert!(matches!(eval("(first (list 9 8))"), Value::Int(9)));
296 // Layer 3: blue's own core — string and number conversion.
297 assert!(matches!(eval(r#"(length "héllo")"#), Value::Int(5)));
298 // Layer 4: the JSON surface.
299 assert!(matches!(
300 eval(r#"(json_parse "{\"outcome\":\"ok\"}")"#),
301 Value::List(_)
302 ));
303 // Layer 4b: cryptography.
304 assert!(matches!(eval(r#"(blake3_hex "")"#), Value::Str(_)));
305 }
306
307 /// Anti-vacuity: a bare interpreter really does LACK layer 2, so the test
308 /// above is measuring the runtime's contribution and not a property the
309 /// interpreter has for free.
310 #[test]
311 fn a_bare_interpreter_lacks_the_stdlib() {
312 let forms = tatara_lisp::read_spanned("(mod 7 3)").expect("read");
313 let mut bare = Interpreter::new();
314 tatara_lisp_eval::install_primitives(&mut bare);
315 assert!(
316 bare.eval_program(&forms, &mut ()).is_err(),
317 "if a bare interpreter already resolved `mod`, this crate would be measuring nothing"
318 );
319 }
320}