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