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 doc_sources;
42pub mod docs;
43pub mod domain;
44#[cfg(feature = "sys")]
45pub mod entropy;
46pub mod erase;
47pub mod errors;
48pub mod host_surface;
49pub mod hosted;
50pub mod inputs;
51pub mod json;
52pub mod literal;
53pub mod memory;
54pub mod messages;
55#[cfg(feature = "sys")]
56pub mod net;
57pub mod nix;
58pub mod observe;
59pub mod paths;
60pub mod pipeline;
61pub mod project_rules;
62#[cfg(feature = "sys")]
63pub mod provide;
64pub mod receipt;
65pub mod session;
66pub mod spread;
67pub mod stdlib;
68#[cfg(feature = "sys")]
69pub mod sys;
70#[cfg(feature = "sys")]
71pub mod threshold;
72pub mod timings;
73pub mod uses;
74#[cfg(feature = "sys")]
75pub mod wire;
76pub mod xml;
77
78pub use erase::{erase_types, to_sexps};
79pub use inputs::{declarations, install_input_primitives, Declaration, InputError, Inputs};
80pub use pipeline::{
81 parse, parse_tree_with_depth, parse_with_depth, run, run_with_inputs, Run, RunError,
82};
83pub use stdlib::install_blue_stdlib;
84
85pub use blue_lang_proc::{Frame, HostIo, Identities, Places, Receipt};
86
87use tatara_lisp_eval::{install_full_stdlib_with, Interpreter};
88
89/// Build an interpreter with the complete blue runtime installed.
90///
91/// This is the *only* sanctioned way to obtain one. A caller that builds an
92/// `Interpreter` directly gets a partial runtime, and the failure shows up as
93/// an unbound symbol at the far end of a program.
94pub fn interpreter<H: 'static>(host: &mut H) -> Interpreter<H> {
95 let mut interp = Interpreter::new();
96 // The FULL substrate, not just `install_primitives`.
97 //
98 // blue called `install_primitives` + `install_lisp_stdlib_with` and got
99 // neither `install_hof` nor `install_map` — so `map`, `filter`, `fold` and
100 // every map literal were UNBOUND SYMBOLS. A language with no higher-order
101 // functions, in a workspace whose whole surface is Ruby's.
102 //
103 // Same shape as the stdlib gap this crate was created to fix: the substrate
104 // has layers, and naming them one at a time is how one gets missed. Call
105 // the composed installer.
106 install_full_stdlib_with(&mut interp, host);
107 // Layer 3: blue's own core — strings and number conversion, which
108 // tatara-lisp does not carry at all. See `stdlib` for why the
109 // character-counting semantics are blue's rather than the substrate's.
110 stdlib::install_blue_stdlib(&mut interp);
111 // Layer 4: blue's JSON surface — parse, stringify, read. Pure
112 // computation, no host imports, so it is installed unconditionally; the
113 // `wasm32-unknown-unknown` consumer keeps it. See `json` for why objects
114 // arrive as alists rather than Maps.
115 json::install_json_stdlib(&mut interp);
116 // Layer 4b: cryptography — BLAKE3 and Ed25519. Pure for the same reason
117 // JSON is: a deterministic function of its arguments, with the keypair's
118 // seed supplied by the caller rather than drawn from an entropy source,
119 // so it lowers to no import and the wasm consumer keeps it. See `crypto`.
120 crypto::install_crypto_stdlib(&mut interp);
121 nix::install_nix_stdlib(&mut interp);
122 xml::install_xml_stdlib(&mut interp);
123 // Layer 5: the host-side system surface — process, filesystem, env,
124 // clock, network. Feature-gated: every sys primitive is a host import, so the
125 // wasm consumer (which builds with `sys` OFF) keeps its zero-host-import
126 // surface by construction. Only the CLI turns it on.
127 #[cfg(feature = "sys")]
128 sys::install_sys_stdlib(&mut interp);
129 // Layer 6: processes. Bound here so every name oracle built from this
130 // interpreter (the check stage, the LSP, `blue reference`) knows them.
131 attach_node(&mut interp);
132 // Last, so installing the stdlib is not metered against the program.
133 apply_execution_bounds(&mut interp);
134 interp
135}
136
137/// Give `interp` a process node of its own: its program becomes the node's
138/// root, and `spawn` starts processes in this node and no other.
139///
140/// [`interpreter_hostless`] calls it on every fork, so two programs never
141/// share processes. A door that forks an interpreter per unit of work (a test
142/// per fork) calls it on each fork for the same reason.
143pub fn attach_node<H: 'static>(interp: &mut Interpreter<H>) {
144 dispatch::install(interp);
145 blue_lang_proc::install(interp, blue_lang_proc::Node::new(node_config()));
146}
147
148/// [`attach_node`] for a program that runs under `frame`: every process it
149/// starts is held to the same frame, so a host name the program may not call
150/// is refused in its children too. Without this a spawned child would run on
151/// the full runtime and a frame would be one `spawn` away from meaningless.
152pub fn attach_node_in_frame<H: 'static>(interp: &mut Interpreter<H>, frame: &blue_lang_waku::Waku) {
153 attach_node_held(interp, &Hold::from(frame.clone()));
154}
155
156/// [`attach_node_in_frame`] with the hold's providers bound in every child.
157pub fn attach_node_held<H: 'static>(interp: &mut Interpreter<H>, hold: &Hold) {
158 attach_node_accounted(interp, Some(hold), &Account::default());
159}
160
161/// Give `interp` a node whose processes are held to `hold`, when there is
162/// one, and give `account` of them: each child's host calls tally into its
163/// ledger, its identities name each trace's and receipt's definition, and
164/// with a receipts directory every held process writes a receipt there when
165/// an incarnation ends.
166pub fn attach_node_accounted<H: 'static>(
167 interp: &mut Interpreter<H>,
168 hold: Option<&Hold>,
169 account: &Account,
170) {
171 let mut config = node_config();
172 config.host_io = account.host_io.clone();
173 config.identities = account.identities.clone();
174 config.places = account.places.clone();
175 if let Some(hold) = hold {
176 config.frame = Some(hold.frame());
177 config.receipts = account.receipts.clone().map(receipt::sink);
178 config.state_root = Some(json::state_root);
179 let held = hold.clone();
180 let io = account.host_io.clone();
181 config.child = std::sync::Arc::new(move || {
182 let mut child = child_interpreter();
183 held.bind(&mut child, "this process's frame", &io);
184 child
185 });
186 }
187 dispatch::install(interp);
188 blue_lang_proc::install(interp, blue_lang_proc::Node::new(config));
189}
190
191/// The account a run gives of itself besides its value, filled while it runs
192/// and read after it ends, whether it returned or raised.
193#[derive(Clone, Default)]
194pub struct Account {
195 /// Host calls through granted host names, per process.
196 pub host_io: HostIo,
197 /// Each definition's `def_hash`, and the run's under [`receipt::RUN`].
198 pub identities: Identities,
199 /// How the identities are computed from the program, once it is lowered.
200 /// Without one, no trace or receipt names a `def_hash`, and no trace
201 /// names the file, line and column of a breaching call.
202 pub identify: Option<Identify>,
203 /// Each definition's file, read from the program when it identifies.
204 pub places: Places,
205 /// Where each held process writes its receipt; none are written without.
206 pub receipts: Option<std::path::PathBuf>,
207}
208
209impl Account {
210 /// Name `program`'s definitions in this account's identities, computed
211 /// by its `identify` from the `lowered` tree when first asked, with the
212 /// entry's `main` under [`receipt::RUN`]. `false`, and nothing deferred,
213 /// for an account that does not identify.
214 pub fn defer_identities(
215 &self,
216 program: &uses::ResolvedProgram,
217 lowered: std::sync::Arc<Vec<tatara_lisp::Spanned>>,
218 main: Option<String>,
219 ) -> bool {
220 let Some(identify) = self.identify else {
221 return false;
222 };
223 self.identities.defer(identify(program, lowered, main));
224 true
225 }
226}
227
228/// The table of a lowered program's identities, computed when first asked.
229pub type Identified =
230 Box<dyn FnOnce() -> std::collections::BTreeMap<String, String> + Send + 'static>;
231
232/// Given a checked program, its lowered tree and the key of the entry's
233/// `main` if it has one: each definition key with its `def_hash`, and
234/// [`receipt::RUN`] with the run's, computed only when first asked.
235/// `blue-lang-mondou` computes it; this crate only calls it.
236pub type Identify = fn(
237 &uses::ResolvedProgram,
238 std::sync::Arc<Vec<tatara_lisp::Spanned>>,
239 Option<String>,
240) -> Identified;
241
242/// What a run is held to: the frame it may reach the host through, and the
243/// capabilities pure providers stand in for.
244#[derive(Clone, Debug, PartialEq, Eq)]
245pub struct Hold {
246 pub frame: blue_lang_waku::Waku,
247 pub provisions: blue_lang_waku::Provisions,
248}
249
250impl From<blue_lang_waku::Waku> for Hold {
251 fn from(frame: blue_lang_waku::Waku) -> Self {
252 Self {
253 frame,
254 provisions: blue_lang_waku::Provisions::default(),
255 }
256 }
257}
258
259/// The name a scoped provider binds the host name `word` under. No blue
260/// source can write it, so only the references [`Hold::rebind_scoped`] moves
261/// onto it reach the provider.
262#[must_use]
263pub fn provided_name(word: &str) -> String {
264 format!("%provided/{word}")
265}
266
267impl Hold {
268 /// Every host capability neither the frame grants nor a whole-program
269 /// provider stands in for. A scoped provider's capability stays here:
270 /// code outside its packages gets the frame's answer.
271 #[must_use]
272 pub fn withheld(&self) -> Vec<blue_lang_waku::Capability> {
273 let reach = self.provisions.widen(&self.frame.reach);
274 blue_lang_waku::Capability::host_effects()
275 .into_iter()
276 .filter(|cap| !reach.grants(*cap))
277 .collect()
278 }
279
280 /// Every host capability a call can reach the host through: granted by
281 /// the frame, and no whole-program provider standing in for it. A scoped
282 /// provider's capability the frame grants is here: code outside its
283 /// packages reaches the host.
284 #[must_use]
285 pub fn granted(&self) -> Vec<blue_lang_waku::Capability> {
286 blue_lang_waku::Capability::host_effects()
287 .into_iter()
288 .filter(|cap| {
289 self.frame.reach.grants(*cap)
290 && self.provisions.get(*cap).is_none_or(|p| p.scope.is_some())
291 })
292 .collect()
293 }
294
295 /// Every host name neither the frame grants nor a whole-program provider
296 /// stands in for. A scoped provider's names stay here: code outside its
297 /// packages gets the frame's answer.
298 #[must_use]
299 pub fn withheld_host_names(&self) -> Vec<(&'static str, blue_lang_waku::Capability)> {
300 names_of(self.withheld())
301 }
302
303 /// Every host name a call can reach the host through.
304 #[must_use]
305 pub fn granted_host_names(&self) -> Vec<(&'static str, blue_lang_waku::Capability)> {
306 names_of(self.granted())
307 }
308
309 /// The hold as an artifact names it.
310 #[must_use]
311 pub fn frame(&self) -> Frame {
312 Frame {
313 granted: self.granted(),
314 provided: self.provisions.iter().cloned().collect(),
315 withheld: self.withheld(),
316 }
317 }
318
319 /// The frame block of a receipt for a run held here that made `host_io`
320 /// host calls: exactly `{"granted":[…],"withheld":[…],"host_io":n}`.
321 #[must_use]
322 pub fn frame_summary(&self, host_io: u64) -> serde_json::Value {
323 self.frame().block(host_io)
324 }
325
326 /// Bind the hold into `interp`: a breach for every withheld host name,
327 /// then each whole-program provider over its capability's names and each
328 /// scoped one under [`provided_name`], then a shim over every granted
329 /// host name that tallies each call into `io`. A withheld or provided
330 /// name gets no shim, so a pure frame tallies nothing. A whole-program
331 /// clock provider is also the clock the host's Flash rate reads.
332 pub fn bind<H: 'static>(&self, interp: &mut Interpreter<H>, who: &'static str, io: &HostIo) {
333 refuse_host_names(interp, &self.withheld_host_names(), who);
334 #[cfg(feature = "sys")]
335 if let Some(clock) = provide::provide(interp, &self.provisions) {
336 if self
337 .granted()
338 .contains(&blue_lang_waku::Capability::CommFlash)
339 {
340 wire::host_flash_on(interp, clock);
341 }
342 }
343 count_host_names(interp, &self.granted_host_names(), io);
344 }
345
346 /// Move each scoped provider's host names, where a file of a package it
347 /// serves writes them in `forms`, onto [`provided_name`]. A provider
348 /// serves the packages its `for:` names and every package they `use`, by
349 /// the imports `program` resolved; `forms` is `program`'s evaluated tree,
350 /// top-level form for top-level form. Returns the host names the entry
351 /// file may not write: [`Hold::withheld_host_names`] less those a scoped
352 /// provider serves the entry's own package.
353 pub fn rebind_scoped(
354 &self,
355 program: &uses::ResolvedProgram,
356 forms: &mut [tatara_lisp::Spanned],
357 ) -> Vec<(&'static str, blue_lang_waku::Capability)> {
358 let served: Vec<(
359 blue_lang_waku::Capability,
360 std::collections::BTreeSet<String>,
361 )> = self
362 .provisions
363 .scoped()
364 .map(|(cap, named)| (cap, program.reached_from(named)))
365 .collect();
366 let serves = |package: Option<&str>, cap: blue_lang_waku::Capability| {
367 package.is_some_and(|p| served.iter().any(|(c, s)| *c == cap && s.contains(p)))
368 };
369 if !served.is_empty() {
370 uses::rename_written(program, forms, &|package, name| {
371 let cap = blue_lang_waku::Provision::capability_of(name)?;
372 serves(package, cap).then(|| provided_name(name))
373 });
374 }
375 let entry = program
376 .file(uses::ResolvedProgram::ENTRY)
377 .and_then(|f| f.package.as_deref());
378 self.withheld_host_names()
379 .into_iter()
380 .filter(|(_, cap)| !serves(entry, *cap))
381 .collect()
382 }
383}
384
385fn names_of(
386 caps: Vec<blue_lang_waku::Capability>,
387) -> Vec<(&'static str, blue_lang_waku::Capability)> {
388 caps.into_iter()
389 .flat_map(|cap| cap.names().into_iter().map(move |n| (n, cap)))
390 .collect()
391}
392
393/// Shim each of `granted` that `interp` defines so a call that reaches it
394/// tallies one host call into `io`. The shim is registered under its own
395/// name and the host name rebound to it, so the original stays reachable by
396/// its name and reports its own arity errors.
397fn count_host_names<H: 'static>(
398 interp: &mut Interpreter<H>,
399 granted: &[(&'static str, blue_lang_waku::Capability)],
400 io: &HostIo,
401) {
402 for &(name, _) in granted {
403 let Some(original @ tatara_lisp_eval::Value::NativeFn(_)) = interp.lookup_global(name)
404 else {
405 continue;
406 };
407 let tatara_lisp_eval::Value::NativeFn(nf) = &original else {
408 continue;
409 };
410 let arity = nf.arity;
411 let io = io.clone();
412 let shim = format!("%io:{name}");
413 interp.register_higher_order_fn(
414 shim.clone(),
415 tatara_lisp_eval::ffi::Arity::Any,
416 move |args: &[tatara_lisp_eval::Value],
417 host: &mut H,
418 caller: &tatara_lisp_eval::ffi::Caller<H>,
419 span| {
420 if arity.check(args.len()).is_ok() {
421 io.tally();
422 }
423 caller.apply_value(&original, args.to_vec(), host, span)
424 },
425 );
426 if let Some(v) = interp.lookup_global(&shim) {
427 interp.define_global(name, v);
428 }
429 }
430}
431
432/// Every host name `frame` does not grant, with its capability.
433#[must_use]
434pub fn ungranted_host_names(
435 frame: &blue_lang_waku::Waku,
436) -> Vec<(&'static str, blue_lang_waku::Capability)> {
437 Hold::from(frame.clone()).withheld_host_names()
438}
439
440/// The kind of the fatal error a capability breach raises: the node's, so a
441/// crash trace classifies the breach this crate raises.
442pub const BREACH: &str = blue_lang_proc::violation::BREACH;
443
444/// The exit status a run that ended on a capability breach returns:
445/// `EX_NOPERM` (sysexits.h). `blue run` and an appliance both answer it.
446pub const BREACH_EXIT: u8 = 77;
447
448/// The fatal error a call to `name` raises when `who` does not grant `cap`.
449/// No `rescue` catches it: the run ends.
450#[must_use]
451pub fn breach(
452 name: &str,
453 cap: blue_lang_waku::Capability,
454 who: &str,
455 at: tatara_lisp::Span,
456) -> tatara_lisp_eval::EvalError {
457 tatara_lisp_eval::EvalError::fatal(
458 BREACH,
459 format!(
460 "`{name}` reaches the host ({}), which {who} does not grant",
461 cap.label()
462 ),
463 at,
464 )
465}
466
467/// Is `e` a capability breach?
468#[must_use]
469pub fn is_breach(e: &tatara_lisp_eval::EvalError) -> bool {
470 matches!(e, tatara_lisp_eval::EvalError::Fatal { kind, .. } if &**kind == BREACH)
471}
472
473/// Bind each of `refused` that `interp` defines to a breach naming `who`.
474pub fn refuse_host_names<H: 'static>(
475 interp: &mut Interpreter<H>,
476 refused: &[(&'static str, blue_lang_waku::Capability)],
477 who: &'static str,
478) {
479 refuse_host_names_noting(interp, refused, who, |_, _, _| {});
480}
481
482/// [`refuse_host_names`], calling `note` with the host, the capability and
483/// the name before each breach is raised.
484pub fn refuse_host_names_noting<H: 'static>(
485 interp: &mut Interpreter<H>,
486 refused: &[(&'static str, blue_lang_waku::Capability)],
487 who: &'static str,
488 note: fn(&mut H, blue_lang_waku::Capability, &'static str),
489) {
490 for &(name, cap) in refused {
491 if interp.lookup_global(name).is_none() {
492 continue;
493 }
494 interp.register_fn(
495 name,
496 tatara_lisp_eval::ffi::Arity::Any,
497 move |_: &[tatara_lisp_eval::Value], host: &mut H, span| {
498 note(host, cap, name);
499 Err(breach(name, cap, who, span))
500 },
501 );
502 }
503}
504
505/// The node every interpreter's processes run under: the execution bounds
506/// per incarnation, a scheduling quantum, and the host's clock when there is
507/// a host (a node with no host has a virtual clock: time passes only when
508/// every process waits).
509fn node_config() -> blue_lang_proc::NodeConfig {
510 let b = execution_bounds();
511 let mut config = blue_lang_proc::NodeConfig::new(std::sync::Arc::new(child_interpreter));
512 config.budget = tatara_lisp_eval::vm::Budget {
513 fuel: b.max_steps,
514 max_depth: Some(b.max_call_depth),
515 quantum: Some(blue_lang_proc::node::DEFAULT_QUANTUM),
516 };
517 config.meter = Some(memory::METER);
518 config.clock = if cfg!(feature = "sys") {
519 blue_lang_proc::ClockKind::Real
520 } else {
521 blue_lang_proc::ClockKind::Virtual
522 };
523 config
524}
525
526/// A fresh interpreter for a child process: the hostless base, forked. The
527/// node adds its child words to it.
528fn child_interpreter() -> Interpreter<()> {
529 let mut interp = match HOSTLESS_BASE.lock() {
530 Ok(base) => base.fork(),
531 Err(_) => interpreter(&mut ()),
532 };
533 apply_execution_bounds(&mut interp);
534 interp
535}
536
537/// The execution bounds every interpreter this crate builds runs under.
538static EXECUTION_BOUNDS: std::sync::OnceLock<ExecutionBounds> = std::sync::OnceLock::new();
539
540/// How much a blue program may do before it is refused: nested calls,
541/// evaluation steps and live bytes.
542///
543/// Each is a BOUND in the sense `blue-lang-cli`'s `config` module admits: a
544/// terminating program under bounds above its cost returns the same value, and
545/// one that exceeds either is refused with a catchable `depth-exceeded` or
546/// `fuel-exhausted` error naming the limit and the function — never a process
547/// abort, which is what exceeding the host stack was before tatara-lisp-eval
548/// 0.3.63. The walker and the VM run under the same bounds and refuse with the
549/// same message. The two memory bounds refuse with a fatal `memory-exhausted`
550/// ([`memory`]); a process past one exits a runaway.
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub struct ExecutionBounds {
553 /// Nested (non-tail) calls alive at once. Tail calls do not count.
554 pub max_call_depth: usize,
555 /// Evaluation steps per run; `None` is unbounded.
556 pub max_steps: Option<usize>,
557 /// Live bytes one evaluation of code its caller wrote may hold (`blue
558 /// run`, an appliance, each test of `blue test`); `None` is unbounded.
559 /// Each process it spawns is held to the same bound, on its own account.
560 pub max_alloc_bytes: Option<u64>,
561 /// Live bytes one evaluation may hold at a door that runs code it did not
562 /// write: a session (`eval`, `repl`, `serve`), live evaluation in the
563 /// editor, a project rule, a Bluefile. `None` is unbounded.
564 pub guest_max_alloc_bytes: Option<u64>,
565}
566
567impl ExecutionBounds {
568 /// Depth at tatara-lisp-eval's `DEFAULT_MAX_DEPTH`, steps unbounded.
569 ///
570 /// The two defaults differ for one reason: whether a program that works
571 /// today keeps working. A recursion past 100k frames never worked — it
572 /// aborted the process — so bounding depth removes no behaviour. A run
573 /// longer than any finite step count did work (simulations, tools on
574 /// nodes), so a default step bound would remove it; for a few hours of
575 /// 2026-09-29 blue shipped 50M (~3 s) and did exactly that. A host that
576 /// runs code it did not write, or a test that must see a runaway end,
577 /// sets `max_steps` itself.
578 ///
579 /// Memory follows the same test, door by door: a run holding more than
580 /// any finite count worked, so `max_alloc_bytes` is unbounded; a door
581 /// that runs code it did not write never promised that, so
582 /// `guest_max_alloc_bytes` is [`memory::GUEST_MAX_ALLOC_BYTES`].
583 pub const DEFAULT: Self = Self {
584 max_call_depth: tatara_lisp_eval::vm::DEFAULT_MAX_DEPTH,
585 max_steps: None,
586 max_alloc_bytes: None,
587 guest_max_alloc_bytes: Some(memory::GUEST_MAX_ALLOC_BYTES),
588 };
589}
590
591/// Name the bounds for this process. The CLI calls this once with the resolved
592/// `BlueConfig`, before any interpreter is built; an embedder that never calls
593/// it runs under [`ExecutionBounds::DEFAULT`]. Only the first call counts,
594/// like `sys::set_program_args`.
595pub fn set_execution_bounds(bounds: ExecutionBounds) {
596 let _ = EXECUTION_BOUNDS.set(bounds);
597}
598
599/// The bounds interpreters are built with.
600#[must_use]
601pub fn execution_bounds() -> ExecutionBounds {
602 EXECUTION_BOUNDS
603 .get()
604 .copied()
605 .unwrap_or(ExecutionBounds::DEFAULT)
606}
607
608fn apply_execution_bounds<H: 'static>(interp: &mut Interpreter<H>) {
609 let b = execution_bounds();
610 // No quantum, so the tree-walker refuses nothing of this budget.
611 let _ = interp.set_budget(tatara_lisp_eval::vm::Budget {
612 fuel: b.max_steps,
613 max_depth: Some(b.max_call_depth),
614 quantum: None,
615 });
616}
617
618/// The prebuilt hostless substrate, built once per process.
619///
620/// **Measured 2026-08-01, and this is the entire reason the fork exists.**
621/// Profiling a trivial blue program found the run dominated by ONE thing:
622///
623/// ```text
624/// parse 9.6 µs
625/// check 1.6 µs
626/// interpreter_hostless() 5 820 µs <- 98.4% of the run
627/// ```
628///
629/// Splitting that further: `Interpreter::new()` is 24 µs and
630/// `install_full_stdlib_with` is the rest — the stdlib was being rebuilt from
631/// scratch on every single `run()`, and blue's whole surface (`map`, `filter`,
632/// string ops) lives in it, so no program could avoid the cost.
633///
634/// `fork` already existed upstream for exactly this, and blue simply was not
635/// using it. tatara-lisp-eval's own `fork_cost.rs` opens with *"cheapness is
636/// the entire reason it exists"* — the capability was built, documented and
637/// guarded, and the consumer kept paying full price beside it.
638///
639/// | | per call |
640/// |---|---|
641/// | rebuild (`Interpreter::new` + `install_full_stdlib_with`) | **9.03 ms** |
642/// | `fork()` of a prebuilt base | **0.102 ms** |
643///
644/// **88× on the dominant cost.** This is purgatory's computation-layer clause
645/// made real: the stdlib is the most-reused computation in the language, and
646/// it was being discarded and recomputed rather than resurrected.
647///
648/// Isolation is `fork`'s contract, not an assumption made here — its
649/// correctness gates live upstream in `fork.rs`, and `fork_cost.rs` guards
650/// against a future change that deep-copies (which would keep every
651/// correctness test green while silently restoring the cost this removes).
652static HOSTLESS_BASE: std::sync::LazyLock<std::sync::Mutex<Interpreter<()>>> =
653 std::sync::LazyLock::new(|| {
654 std::sync::Mutex::new(timings::stage(timings::Stage::Runtime, || {
655 interpreter(&mut ())
656 }))
657 });
658
659/// The common host-free case.
660///
661/// Forks the process-wide base rather than rebuilding the stdlib. Falls back to
662/// a full build if the lock is poisoned: a poisoned lock means another thread
663/// panicked mid-fork, and answering a correct-but-slow interpreter beats
664/// propagating someone else's panic into an unrelated caller.
665pub fn interpreter_hostless() -> Interpreter<()> {
666 let mut interp = match HOSTLESS_BASE.lock() {
667 Ok(base) => base.fork(),
668 Err(_) => interpreter(&mut ()),
669 };
670 // Again after the fork: the base may have been built before the bounds
671 // were named, and a fork inherits whatever its base had.
672 apply_execution_bounds(&mut interp);
673 // A node per fork: the base's own node would be shared by every program.
674 attach_node(&mut interp);
675 interp
676}
677
678/// Lift spanless forms into what the evaluator eats.
679///
680/// **No longer on the `blue run` path, and that is the point.**
681/// [`erase::erase_types`] now takes and returns `Spanned`, so
682/// `pipeline::run_in_surface` hands the evaluator the *parser's own* tree with
683/// the author's positions on it — there is nothing left to lift. This
684/// function survives for a caller that genuinely holds spanless `Sexp` and
685/// has no position to preserve, and as the subject of the two tests below
686/// that pin why the hop it replaced was unsafe.
687///
688/// `Interpreter::eval_program` takes `&[Spanned]`; blue's stages produced
689/// `Sexp`. Two callers bridged that gap by *printing the tree and reading it
690/// back* — `forms.map(ToString::to_string).join("\n")` into
691/// `tatara_lisp::read_spanned`. Both stopped, and the round trip is gone from
692/// the pipeline.
693///
694/// **Why it had to go.** Printing a tree we already hold and re-parsing it puts
695/// the reader's lexer between blue and its own output, for nothing — and the
696/// printer and the reader are **not inverses**. `Atom::Str`'s `Display` escapes
697/// its payload and its own docs explain at length why; the `Atom::Symbol` arm
698/// is a bare `write_str`. So a symbol whose text carries a separator prints as
699/// several tokens and reads back as several symbols: a well-formed tree with a
700/// different meaning, no error raised. Measured 2026-08-02 —
701/// `pipeline::tests::the_round_trip_is_not_the_identity_in_general` pins which
702/// separators are silent and which are loud. The only thing that kept this from
703/// biting was blue happening not to emit those bytes. A stage that never
704/// serialises cannot be mis-read.
705///
706/// **What is given up: spans.** The old path's spans pointed into the
707/// re-printed lisp text, a buffer no human ever wrote and no diagnostic could
708/// usefully cite — they were positions in blue's own output, not in the
709/// author's source. `Span::synthetic` says the same thing honestly.
710///
711/// **Carrying real blue-source spans through erasure was the separate piece of
712/// work, and it is done — but NOT the way this doc predicted.** It said the
713/// work "would start from `Spanned::from_sexp_at`". It did not, and could not:
714/// `from_sexp_at` is a *lift*, stamping ONE span across a whole subtree, so
715/// starting there would have replaced a tree of synthetic spans with a tree of
716/// identical wrong ones. The actual shape was to stop projecting in the first
717/// place — erasure walks `Spanned` and keeps each node's own span, because it
718/// only ever deletes (see [`erase`]). **A pointer at the nearest-looking API is
719/// how a reader is sent to the wrong starting point**; the note is corrected
720/// here rather than deleted, because the wrong lead was load-bearing enough to
721/// be worth naming.
722#[must_use]
723pub fn lower_to_spanned(forms: &[tatara_lisp::Sexp]) -> Vec<tatara_lisp::Spanned> {
724 forms
725 .iter()
726 .map(tatara_lisp::Spanned::from_sexp_synthetic)
727 .collect()
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use tatara_lisp_eval::Value;
734
735 fn eval(src: &str) -> Value {
736 let forms = tatara_lisp::read_spanned(src).expect("read");
737 let mut interp = interpreter_hostless();
738 interp.eval_program(&forms, &mut ()).expect("eval")
739 }
740
741 /// Every layer is present. A test that only checked layer 1 is exactly
742 /// what let the stdlib gap survive.
743 #[test]
744 fn both_layers_are_installed() {
745 // Layer 1: a Rust primitive.
746 assert!(matches!(eval("(+ 1 2)"), Value::Int(3)));
747 // Layer 2: a stdlib definition, which is the layer that was missing.
748 assert!(matches!(eval("(mod 7 3)"), Value::Int(1)));
749 assert!(matches!(eval("(inc 41)"), Value::Int(42)));
750 assert!(matches!(eval("(first (list 9 8))"), Value::Int(9)));
751 // Layer 3: blue's own core — string and number conversion.
752 assert!(matches!(eval(r#"(length "héllo")"#), Value::Int(5)));
753 // Layer 4: the JSON surface.
754 assert!(matches!(
755 eval(r#"(json_parse "{\"outcome\":\"ok\"}")"#),
756 Value::List(_)
757 ));
758 // Layer 4b: cryptography.
759 assert!(matches!(eval(r#"(blake3_hex "")"#), Value::Str(_)));
760 }
761
762 #[cfg(feature = "sys")]
763 mod held {
764 use super::super::*;
765 use blue_lang_waku::{Capability, Provided, Provision, Provisions};
766 use tatara_lisp_eval::Value;
767
768 fn held(host: &[Capability], provided: &[Provided]) -> (Hold, Interpreter<()>, HostIo) {
769 let mut provisions = Provisions::default();
770 for p in provided {
771 provisions.add(p.clone()).expect("one provider each");
772 }
773 let hold = Hold {
774 frame: session::frame_granting(host),
775 provisions,
776 };
777 let mut interp = interpreter_hostless();
778 let io = HostIo::new();
779 hold.bind(&mut interp, "this test's frame", &io);
780 (hold, interp, io)
781 }
782
783 fn call(interp: &mut Interpreter<()>, src: &str) -> Result<Value, String> {
784 let forms = tatara_lisp::read_spanned(src).expect("read");
785 interp
786 .eval_program(&forms, &mut ())
787 .map_err(|e| e.to_string())
788 }
789
790 #[test]
791 fn a_pure_hold_shims_no_name_so_nothing_can_tally() {
792 let (hold, mut interp, io) = held(&[], &[]);
793 assert!(hold.granted_host_names().is_empty());
794 for (name, _) in hold.withheld_host_names() {
795 assert!(
796 interp.lookup_global(&format!("%io:{name}")).is_none(),
797 "`{name}` is withheld, so it has no counting shim"
798 );
799 }
800 assert!(call(&mut interp, "(getenv \"HOME\")").is_err());
801 assert!(call(&mut interp, "(now_ms)").is_err());
802 assert_eq!(io.total(), 0);
803 }
804
805 #[test]
806 fn a_granted_name_tallies_each_call_and_a_provided_or_withheld_one_none() {
807 let (hold, mut interp, io) = held(
808 &[Capability::Environment],
809 &[Provision::DeterministicStepped {
810 at_ms: 10,
811 step_ms: 1,
812 }
813 .into()],
814 );
815 assert_eq!(hold.granted(), [Capability::Environment]);
816 call(&mut interp, "(getenv \"HOME\")").expect("granted");
817 call(&mut interp, "(argv)").expect("granted");
818 assert_eq!(io.total(), 2, "two calls through granted names");
819 assert_eq!(
820 call(&mut interp, "(now_ms)").expect("provided").to_string(),
821 "10"
822 );
823 assert!(call(&mut interp, "(read_file \"/etc/hosts\")").is_err());
824 assert_eq!(io.total(), 2, "a provider and a breach reach no host");
825 let wrong = call(&mut interp, "(getenv)").expect_err("arity");
826 assert!(
827 wrong.contains("getenv"),
828 "the original names itself: {wrong}"
829 );
830 assert_eq!(
831 io.total(),
832 2,
833 "a call refused for its arity reached no host"
834 );
835 }
836
837 #[test]
838 fn an_unrestricted_hold_shims_every_host_name_but_a_provided_one() {
839 let hold = Hold {
840 frame: blue_lang_waku::Waku::top(),
841 provisions: {
842 let mut p = Provisions::default();
843 p.add(Provision::NullSink).expect("one");
844 p
845 },
846 };
847 assert!(hold.withheld().is_empty());
848 assert!(!hold.granted().contains(&Capability::FileSystem));
849 assert_eq!(hold.granted().len(), Capability::host_effects().len() - 1);
850 let f = hold.frame();
851 assert_eq!(f.provided, [Provided::from(Provision::NullSink)]);
852 assert_eq!(f.granted, hold.granted());
853 }
854
855 #[test]
856 fn a_scoped_provider_binds_its_alias_uncounted_and_leaves_the_host_name_counted() {
857 let scoped = Provided {
858 provision: Provision::NullSink,
859 scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
860 };
861 let (hold, mut interp, io) =
862 held(&[Capability::FileSystem], std::slice::from_ref(&scoped));
863 assert_eq!(hold.granted(), [Capability::FileSystem]);
864 assert_eq!(hold.frame().provided, [scoped]);
865 let alias = provided_name("read_file");
866 assert!(
867 interp.lookup_global(&alias).is_some(),
868 "the scoped alias is bound"
869 );
870 let _ = call(&mut interp, &format!("({alias} \"/etc/hosts\")"));
871 assert_eq!(io.total(), 0, "the provider reaches no host");
872 call(&mut interp, "(read_file \"/etc/hosts\")").expect("granted");
873 assert_eq!(io.total(), 1, "code outside the scope reaches the host");
874 }
875
876 #[test]
877 fn a_host_flash_grant_reads_the_whole_program_stepped_clock_for_its_rate() {
878 let (_, mut interp, _) = held(
879 &[Capability::CommFlash],
880 &[Provision::DeterministicStepped {
881 at_ms: 0,
882 step_ms: 0,
883 }
884 .into()],
885 );
886 let big = "x".repeat(1279);
887 call(
888 &mut interp,
889 &format!("(begin (define w (flash_open \"127.0.0.1:0\")) (define big \"{big}\"))"),
890 )
891 .expect("granted");
892 for _ in 0..12 {
893 call(&mut interp, "(flash_put w \"127.0.0.1:9\" big)").expect("within the burst");
894 }
895 let refused =
896 call(&mut interp, "(flash_put w \"127.0.0.1:9\" big)").expect_err("past it");
897 assert!(
898 refused.contains(":rate_limited") && refused.contains(":wait_ms 16"),
899 "{refused}"
900 );
901 call(&mut interp, "(sleep_ms 16)").expect("provided");
902 assert_eq!(
903 call(&mut interp, "(flash_put w \"127.0.0.1:9\" big)")
904 .expect("a virtual sleep refills the host's bucket")
905 .to_string(),
906 "1280"
907 );
908 }
909
910 #[test]
911 fn the_frame_summary_is_exactly_granted_withheld_and_host_io() {
912 let (hold, _, _) = held(&[Capability::Clock], &[]);
913 assert_eq!(
914 blue_lang_proc::receipt::canonical(&hold.frame_summary(4)),
915 r#"{"granted":["clock"],"host_io":4,"withheld":["process","filesystem","environment","network","entropy","comm-flash","comm-scavenger","mpc-threshold"]}"#
916 );
917 }
918 }
919
920 /// Anti-vacuity: a bare interpreter really does LACK layer 2, so the test
921 /// above is measuring the runtime's contribution and not a property the
922 /// interpreter has for free.
923 #[test]
924 fn a_bare_interpreter_lacks_the_stdlib() {
925 let forms = tatara_lisp::read_spanned("(mod 7 3)").expect("read");
926 let mut bare = Interpreter::new();
927 tatara_lisp_eval::install_primitives(&mut bare);
928 assert!(
929 bare.eval_program(&forms, &mut ()).is_err(),
930 "if a bare interpreter already resolved `mod`, this crate would be measuring nothing"
931 );
932 }
933}