Skip to main content

scripting/
runtime.rs

1//! Shared wasmtime primitives used by every host of nomiscript-compiled
2//! modules: the entity-script `ScriptExecutor` and the rpc eval channel.
3//!
4//! Owns the engine config (WasmGC + epoch interruption + optional fuel),
5//! the per-bytecode `Module` cache, the trap-classification helper, and the
6//! `decode_eval_result` helper that walks the nomi-eval `(ref null any)`
7//! return value into a structured [`EvalValue`]. Higher-level consumers
8//! parameterize over the Store data type and assemble their own Linker on top.
9
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12
13use thiserror::Error;
14use uuid::Uuid;
15
16use crate::checked_ref::CheckedAnyRef;
17use wasmtime::{
18    AnyRef, AsContextMut, Caller, Config, Engine, FieldType, Linker, Module, Mutability, Rooted,
19    StorageType, Store, StructRef, StructRefPre, StructType, Val, ValType,
20};
21
22#[derive(Debug, Error)]
23pub enum EngineError {
24    #[error("engine config rejected: {0}")]
25    Config(String),
26    #[error("module cache lock poisoned")]
27    CachePoisoned,
28    #[error("module compilation failed: {0}")]
29    Compile(String),
30    #[error("module instantiation failed: {0}")]
31    Instantiate(String),
32    #[error("fuel configuration failed: {0}")]
33    Fuel(String),
34    #[error("missing export `{0}`")]
35    MissingExport(String),
36    #[error("fuel exhausted before completion")]
37    OutOfFuel,
38    #[error("epoch deadline reached before completion")]
39    EpochInterrupt,
40    /// `ConvertCommodity` raises this when no Price row links source
41    /// and target in either direction. Lifted into a dedicated variant
42    /// so clients can prompt the user to add a price row rather than
43    /// guess from a generic trap message.
44    #[error("no conversion: {0}")]
45    NoConversion(String),
46    /// A structured error raised in-guest, surfaced via the `__nomi_raise`
47    /// host fn (`Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`). Two
48    /// sources converge here (ADR-0026): a script `(error 'code "msg")`, and
49    /// an engine error like a commodity mismatch — both `throw $nomi_error`
50    /// in-guest, and the boundary wrapper around each host-invoked body
51    /// catches an uncaught throw and bridges it to `__nomi_raise`. The
52    /// classifier parses the marker prefix and surfaces the code symbol
53    /// (`COMMODITY-MISMATCH`, a script's own symbol, …) onto the wire
54    /// envelope's `:code` slot. Codes are reader-folded (upper-cased)
55    /// symbols, not free-form strings.
56    #[error("script raised {code}: {message}")]
57    ScriptRaised { code: String, message: String },
58    #[error("execution trapped: {0}")]
59    Trap(String),
60}
61
62/// Marker prefix the `__nomi_raise` host fn embeds in its wasmtime
63/// error message so the runtime classifier can recognise script-raised
64/// errors before the unreachable-trap branch fires. Kept here so the
65/// host-fn body and the classifier agree on the wire format.
66pub const NOMI_RAISE_MARKER: &str = "__nomi_raise:";
67
68/// Optional profiling strategy. JitDump is the Linux `perf record`
69/// flow; it writes a `jit-<pid>.dump` file the OS-level profiler can
70/// read. PerfMap is the simpler symbol-name-only Linux variant. Both
71/// require the `wasmtime/profiling` cargo feature, which we pull in
72/// via the `jitdump` feature flag on the scripting crate; non-Linux
73/// builds should leave this as `None`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum ProfilerStrategy {
76    #[default]
77    None,
78    JitDump,
79    PerfMap,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct EngineOpts {
84    pub fuel: bool,
85    pub profiler: ProfilerStrategy,
86}
87
88impl EngineOpts {
89    #[must_use]
90    pub const fn baseline() -> Self {
91        Self {
92            fuel: false,
93            profiler: ProfilerStrategy::None,
94        }
95    }
96
97    #[must_use]
98    pub const fn with_fuel(mut self) -> Self {
99        self.fuel = true;
100        self
101    }
102
103    #[must_use]
104    pub const fn with_profiler(mut self, strategy: ProfilerStrategy) -> Self {
105        self.profiler = strategy;
106        self
107    }
108}
109
110impl Default for EngineOpts {
111    fn default() -> Self {
112        Self::baseline()
113    }
114}
115
116pub fn build_engine(opts: EngineOpts) -> Result<Engine, EngineError> {
117    let mut config = Config::new();
118    config.wasm_gc(true);
119    config.wasm_function_references(true);
120    // Exception-handling proposal: `(error)` lowers to `throw $nomi_error`
121    // and `(handler-case)` / `(unwind-protect)` lower to `try_table`
122    // (Tier 3, ADR-0026). Engine traps (`OutOfFuel` / `EpochInterrupt`)
123    // are not wasm exceptions, so they bypass `try_table` and keep the
124    // per-Session deadline budget non-catchable.
125    config.wasm_exceptions(true);
126    config.epoch_interruption(true);
127    if opts.fuel {
128        config.consume_fuel(true);
129    }
130    match opts.profiler {
131        ProfilerStrategy::None => {}
132        ProfilerStrategy::JitDump => {
133            config.profiler(wasmtime::ProfilingStrategy::JitDump);
134        }
135        ProfilerStrategy::PerfMap => {
136            config.profiler(wasmtime::ProfilingStrategy::PerfMap);
137        }
138    }
139    Engine::new(&config).map_err(|e| EngineError::Config(e.to_string()))
140}
141
142pub fn compile_module(engine: &Engine, bytes: &[u8]) -> Result<Module, EngineError> {
143    Module::new(engine, bytes).map_err(|e| EngineError::Compile(e.to_string()))
144}
145
146pub fn compile_wat(engine: &Engine, source: &str) -> Result<Module, EngineError> {
147    Module::new(engine, source).map_err(|e| EngineError::Compile(e.to_string()))
148}
149
150/// Per-engine bytecode cache keyed by the full module bytes. Cloning a
151/// [`ModuleCache`] yields a handle into the same inner map; meant to be
152/// shared between long-lived hosts (Session, ScriptExecutor) and any per-form
153/// helpers that need the same cached compilation.
154#[derive(Debug, Default, Clone)]
155pub struct ModuleCache {
156    inner: Arc<Mutex<HashMap<Vec<u8>, Module>>>,
157}
158
159impl ModuleCache {
160    #[must_use]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    pub fn get_or_compile(&self, engine: &Engine, bytecode: &[u8]) -> Result<Module, EngineError> {
166        if let Some(module) = self.lookup(bytecode)? {
167            return Ok(module);
168        }
169        let module = compile_module(engine, bytecode)?;
170        self.store(bytecode, module.clone())?;
171        Ok(module)
172    }
173
174    fn lookup(&self, bytecode: &[u8]) -> Result<Option<Module>, EngineError> {
175        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
176        Ok(guard.get(bytecode).cloned())
177    }
178
179    fn store(&self, bytecode: &[u8], module: Module) -> Result<(), EngineError> {
180        let mut guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
181        guard.insert(bytecode.to_vec(), module);
182        Ok(())
183    }
184
185    pub fn is_empty(&self) -> Result<bool, EngineError> {
186        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
187        Ok(guard.is_empty())
188    }
189
190    pub fn len(&self) -> Result<usize, EngineError> {
191        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
192        Ok(guard.len())
193    }
194}
195
196/// Classifies a [`wasmtime::Error`] thrown during execution into a typed
197/// [`EngineError`]. Downcast to [`wasmtime::Trap`] handles the structured
198/// fuel/epoch cases; everything else falls through as `Trap(message)`.
199pub fn classify_runtime_error(err: &wasmtime::Error) -> EngineError {
200    if let Some(trap) = err.downcast_ref::<wasmtime::Trap>() {
201        match *trap {
202            wasmtime::Trap::OutOfFuel => return EngineError::OutOfFuel,
203            wasmtime::Trap::Interrupt => return EngineError::EpochInterrupt,
204            _ => {}
205        }
206    }
207    // Walk the error chain so host-fn `wasmtime::Error::msg("...")` causes
208    // (e.g. "get-commodity: invalid uuid '...'") surface alongside the
209    // wasmtime wrapper's "error while executing at wasm backtrace" header.
210    // `err.to_string()` alone only renders the outermost wrapper, which
211    // hides the diagnostic the host fn actually emitted.
212    let mut combined = err.to_string();
213    for cause in err.chain().skip(1) {
214        combined.push_str(": ");
215        combined.push_str(&cause.to_string());
216    }
217    // Script-raised errors must classify *before* the unreachable-trap
218    // branch: `(error 'code "msg")` lowers to `__nomi_raise` returning
219    // `Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`. Returning
220    // `Err` from a host fn never trips `unreachable`, so ADR-0014's
221    // single-unreachable invariant is preserved — but the chain walk
222    // above stitches the host-fn message into `combined`, and the
223    // marker prefix lets us recover the symbol/message without parsing
224    // wasmtime's wrapper text.
225    if let Some(raised) = parse_nomi_raise_marker(err) {
226        return raised;
227    }
228    if combined.contains("convert-commodity: no Price row")
229        || combined.contains("convert-commodity: inverse price has zero numerator")
230    {
231        return EngineError::NoConversion(combined);
232    }
233    EngineError::Trap(combined)
234}
235
236/// Walks the error chain for a `__nomi_raise:CODE:MSG` marker emitted by
237/// the `__nomi_raise` host fn. Returns the structured `ScriptRaised`
238/// variant when found, otherwise `None`. Searches the chain rather than
239/// the combined string so script-raised codes survive intact even when
240/// MSG itself contains literal `:` characters.
241fn parse_nomi_raise_marker(err: &wasmtime::Error) -> Option<EngineError> {
242    err.chain()
243        .map(|cause| cause.to_string())
244        .find_map(|cause_str| split_marker(&cause_str))
245}
246
247fn split_marker(text: &str) -> Option<EngineError> {
248    let rest = text.strip_prefix(NOMI_RAISE_MARKER)?;
249    let (code, message) = rest.split_once(':')?;
250    Some(EngineError::ScriptRaised {
251        code: code.to_string(),
252        message: message.to_string(),
253    })
254}
255
256/// Maps an `EngineError` to the `(code, message)` pair scripts and
257/// batch consumers see when a script fails — code is a kebab-case
258/// symbol (matching the wire envelope's `:code` slot for catch-each
259/// cells and `server::script` per-tx reports), message is the
260/// engine's own diagnostic string.
261///
262/// Engine-bound deadlines (`OutOfFuel`, `EpochInterrupt`) also have a
263/// mapping here so callers that *do* want to surface them to scripts
264/// (batch runners that don't care about catch-each's "engine deadlines
265/// aren't catchable" rule) can. catch-each filters the deadlines out
266/// before reaching this mapper.
267#[must_use]
268pub fn err_code_and_message(err: &EngineError) -> (String, String) {
269    match err {
270        EngineError::ScriptRaised { code, message } => (code.clone(), message.clone()),
271        EngineError::NoConversion(msg) => ("no-conversion".to_string(), msg.clone()),
272        EngineError::Trap(msg) => ("runtime".to_string(), msg.clone()),
273        EngineError::Compile(msg) => ("compile".to_string(), msg.clone()),
274        EngineError::Instantiate(msg) => ("runtime".to_string(), msg.clone()),
275        EngineError::Fuel(msg) => ("runtime".to_string(), msg.clone()),
276        EngineError::MissingExport(msg) => ("runtime".to_string(), msg.clone()),
277        EngineError::Config(msg) => ("runtime".to_string(), msg.clone()),
278        EngineError::CachePoisoned => (
279            "runtime".to_string(),
280            "module cache lock poisoned".to_string(),
281        ),
282        EngineError::OutOfFuel => ("runtime".to_string(), "fuel exhausted".to_string()),
283        EngineError::EpochInterrupt => ("runtime".to_string(), "epoch deadline".to_string()),
284    }
285}
286
287/// Allocates an ATOMIC `$commodity` value by re-entering the guest's exported
288/// `commodity_new` with the four i64 components (numer, denom, commodity_hi,
289/// commodity_lo). Since ADR-0028 E0 the `$commodity` struct carries a 5th
290/// `(ref null $unit_term)` field; the host must NOT construct that ref-bearing
291/// struct itself, so it delegates to the guest helper (which sets the term to
292/// null = atomic) — the same re-entry pattern as `alloc_pair_chain` / the
293/// entity allocators. Async because it calls back into the wasm instance.
294pub async fn alloc_commodity_ref<T>(
295    caller: &mut Caller<'_, T>,
296    numer: i64,
297    denom: i64,
298    commodity_id: Uuid,
299) -> wasmtime::Result<Rooted<StructRef>>
300where
301    T: Send,
302{
303    let commodity_new = caller
304        .get_export("commodity_new")
305        .and_then(|e| e.into_func())
306        .ok_or_else(|| {
307            wasmtime::Error::msg(
308                "module missing 'commodity_new' export — host commodity allocation \
309                 requires the nomiscript compiler skeleton's exported commodity_new",
310            )
311        })?;
312    let (hi, lo) = commodity_id.as_u64_pair();
313    let mut results = [Val::AnyRef(None)];
314    commodity_new
315        .call_async(
316            caller.as_context_mut(),
317            &[
318                Val::I64(numer),
319                Val::I64(denom),
320                Val::I64(hi as i64),
321                Val::I64(lo as i64),
322            ],
323            &mut results,
324        )
325        .await?;
326    match &results[0] {
327        Val::AnyRef(Some(any)) => any.checked_struct(caller.as_context_mut()),
328        Val::AnyRef(None) => Err(wasmtime::Error::msg("commodity_new returned null")),
329        _ => Err(wasmtime::Error::msg(
330            "commodity_new returned non-anyref Val variant",
331        )),
332    }
333}
334
335/// Allocates a `$i8_array` wasm array holding `bytes` and returns a rooted
336/// reference. Single allocation; callers can format UUID/name payloads into
337/// a reused `Vec<u8>` and ship the bytes without an intermediate `String`.
338/// Engine canonicalizes the i8 array type so the host-side allocation
339/// matches the guest's `(array i8)` declaration in
340/// `CompileContext::new_skeleton`.
341pub fn alloc_string_ref<T>(
342    caller: &mut Caller<'_, T>,
343    bytes: &[u8],
344) -> wasmtime::Result<Rooted<wasmtime::ArrayRef>> {
345    let engine = caller.engine().clone();
346    // Mutability must match the compiler's `register_type("i8_array")` field
347    // declaration (mutable: true) — engine canonicalization compares the
348    // mutability bit, so a `Const` array type would fail the guest's
349    // `ref.cast (ref $i8_array)` even though the storage type matches.
350    let ty = wasmtime::ArrayType::new(&engine, FieldType::new(Mutability::Var, StorageType::I8));
351    let pre = wasmtime::ArrayRefPre::new(caller.as_context_mut(), ty);
352    let vals: Vec<Val> = bytes.iter().map(|b| Val::I32(i32::from(*b))).collect();
353    wasmtime::ArrayRef::new_fixed(caller.as_context_mut(), &pre, &vals)
354}
355
356/// Allocates a `$ratio` wasm struct (2 i64 fields: numer, denom). Mirrors
357/// `alloc_commodity_ref` for the Ratio numeric stratum — used when a host
358/// fn returns a typed Ratio without going through the synthesized
359/// `ratio_new` wrap.
360pub fn alloc_ratio_ref<T>(
361    caller: &mut Caller<'_, T>,
362    numer: i64,
363    denom: i64,
364) -> wasmtime::Result<Rooted<StructRef>> {
365    let engine = caller.engine().clone();
366    let ty = StructType::new(
367        &engine,
368        std::iter::repeat_n(
369            FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)),
370            2,
371        ),
372    )?;
373    let pre = StructRefPre::new(caller.as_context_mut(), ty);
374    StructRef::new(
375        caller.as_context_mut(),
376        &pre,
377        &[Val::I64(numer), Val::I64(denom)],
378    )
379}
380
381/// Allocates an entity wasm struct (`$account`, `$commodity_entity`, etc) by
382/// re-entering the module's exported `alloc_<kind>` function. The host can't
383/// freshly construct an entity `StructType` via `StructType::new` — fields
384/// like `(ref null $i8_array)` reference concrete type indices that engine
385/// canonicalization compares by identity, so any abstract `anyref`-typed
386/// fresh declaration produces a structurally distinct (and uncastable) type.
387/// Re-entry through the guest's own allocator (registered in
388/// `CompileContext::register_entity_allocators`) sidesteps the issue: each
389/// call returns a struct ref of the exact `$<kind>` type the subsequent
390/// `ref.cast (ref $<kind>)` in the consuming form accepts.
391///
392/// Args are passed in declaration order matching the entity's struct
393/// field layout (see `CompileContext::new_skeleton`).
394pub async fn alloc_entity_via_export<T>(
395    caller: &mut Caller<'_, T>,
396    export_name: &str,
397    args: &[Val],
398) -> wasmtime::Result<Rooted<StructRef>>
399where
400    T: Send,
401{
402    let alloc = caller
403        .get_export(export_name)
404        .and_then(|e| e.into_func())
405        .ok_or_else(|| {
406            wasmtime::Error::msg(format!(
407                "module missing '{export_name}' export — host entity allocation requires \
408                 the nomiscript compiler skeleton's exported alloc_<kind> function"
409            ))
410        })?;
411    let mut results = [Val::AnyRef(None)];
412    alloc
413        .call_async(caller.as_context_mut(), args, &mut results)
414        .await?;
415    let new_entity_any = match &results[0] {
416        Val::AnyRef(any) => *any,
417        _ => {
418            return Err(wasmtime::Error::msg(format!(
419                "{export_name} returned non-anyref Val variant"
420            )));
421        }
422    };
423    new_entity_any
424        .ok_or_else(|| {
425            wasmtime::Error::msg(format!(
426                "{export_name} returned null when allocating entity"
427            ))
428        })?
429        .checked_struct(caller.as_context_mut())
430}
431
432/// Reads a `$i8_array` arg ref into a Rust `String`. `None` is returned for
433/// null refs (the wasm-level `(ref null $i8_array)` param's null state). The
434/// underlying byte storage is i8 (mutable per `register_type("i8_array")`),
435/// so each element is read via `array.get_u` semantics and assembled into a
436/// `Vec<u8>` then UTF-8 validated. Non-UTF-8 bytes surface as a structured
437/// trap rather than a silent replacement.
438pub fn read_string_arg<T>(
439    caller: &mut Caller<'_, T>,
440    arg: Option<Rooted<wasmtime::ArrayRef>>,
441) -> wasmtime::Result<Option<String>> {
442    let Some(arr) = arg else {
443        return Ok(None);
444    };
445    let len = arr.len(caller.as_context_mut())?;
446    let mut bytes = Vec::with_capacity(len as usize);
447    for i in 0..len {
448        let val = arr.get(caller.as_context_mut(), i)?;
449        let byte_i32 = val
450            .i32()
451            .ok_or_else(|| wasmtime::Error::msg("string arg element is not i32"))?;
452        bytes.push(byte_i32 as u8);
453    }
454    String::from_utf8(bytes)
455        .map(Some)
456        .map_err(|err| wasmtime::Error::msg(format!("string arg is not valid UTF-8: {err}")))
457}
458
459/// Reads a `$commodity` arg ref into its (numer, denom, commodity_id)
460/// components. Mirrors `read_string_arg` for the Commodity numeric stratum:
461/// fields 0-1 are numer/denom, fields 2-3 are the UUID halves. `None`
462/// returns for a null ref; bad shape surfaces as a structured trap.
463pub fn read_commodity_arg<T>(
464    caller: &mut Caller<'_, T>,
465    arg: Option<Rooted<StructRef>>,
466) -> wasmtime::Result<Option<(i64, i64, Uuid)>> {
467    let Some(s) = arg else {
468        return Ok(None);
469    };
470    let read_i64 = |c: &mut Caller<'_, T>, idx: usize| -> wasmtime::Result<i64> {
471        let v = s.field(c.as_context_mut(), idx)?;
472        v.i64()
473            .ok_or_else(|| wasmtime::Error::msg(format!("commodity field {idx} is not i64")))
474    };
475    let numer = read_i64(caller, 0)?;
476    let denom = read_i64(caller, 1)?;
477    let hi = read_i64(caller, 2)?;
478    let lo = read_i64(caller, 3)?;
479    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
480    Ok(Some((numer, denom, Uuid::from_u128(raw))))
481}
482
483/// Reads a `$ratio` arg ref into its `(numer, denom)` components. `None`
484/// returns for a null ref; a zero denominator is rejected as a structured trap
485/// so callers never divide by zero. Mirrors `read_commodity_arg` for the
486/// dimensionless Scalar stratum (a `draft-split` amount).
487pub fn read_ratio_arg<T>(
488    caller: &mut Caller<'_, T>,
489    arg: Option<Rooted<StructRef>>,
490) -> wasmtime::Result<Option<(i64, i64)>> {
491    let Some(s) = arg else {
492        return Ok(None);
493    };
494    let numer = s
495        .field(caller.as_context_mut(), 0)?
496        .i64()
497        .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
498    let denom = s
499        .field(caller.as_context_mut(), 1)?
500        .i64()
501        .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
502    if denom == 0 {
503        return Err(wasmtime::Error::msg("ratio has zero denominator"));
504    }
505    Ok(Some((numer, denom)))
506}
507
508/// Reads a named String field from an entity struct arg, resolving the field's
509/// slot index from [`nomiscript::entity_layout`] (the single source of struct
510/// slot order). `None` returns for a null ref. Errors if the kind has no
511/// layout, the named field is absent or non-String, or the slot is not an
512/// `$i8_array`. This is how the draft natives read e.g. an account's `id`
513/// (slot 0) from a `(get-account …)` entity ref passed as an argument.
514pub fn read_entity_string_field<T>(
515    caller: &mut Caller<'_, T>,
516    arg: Option<Rooted<StructRef>>,
517    kind: nomiscript::EntityKind,
518    field_name: &str,
519) -> wasmtime::Result<Option<String>> {
520    let Some(s) = arg else {
521        return Ok(None);
522    };
523    read_entity_string_field_ctx(caller.as_context_mut(), s, kind, field_name).map(Some)
524}
525
526/// Context-based core of [`read_entity_string_field`], split out so it is
527/// unit-testable without a `Caller` (tests hold a bare `Store`). Resolves the
528/// field's slot from the entity layout and reads it as an `$i8_array` string.
529pub fn read_entity_string_field_ctx(
530    mut store: impl AsContextMut,
531    entity: Rooted<StructRef>,
532    kind: nomiscript::EntityKind,
533    field_name: &str,
534) -> wasmtime::Result<String> {
535    let layout = nomiscript::entity_layout(kind)
536        .ok_or_else(|| wasmtime::Error::msg(format!("no entity layout for {kind:?}")))?;
537    let idx = layout
538        .fields
539        .iter()
540        .position(|f| f.name == field_name && f.kind == nomiscript::EntityFieldKind::String)
541        .ok_or_else(|| {
542            wasmtime::Error::msg(format!(
543                "entity {kind:?} has no String field named '{field_name}'"
544            ))
545        })?;
546    let arr = match entity.field(store.as_context_mut(), idx)? {
547        Val::AnyRef(Some(any)) => any.checked_array(store.as_context_mut())?,
548        _ => {
549            return Err(wasmtime::Error::msg(format!(
550                "entity {kind:?} field '{field_name}' (slot {idx}) is not an i8_array"
551            )));
552        }
553    };
554    let len = arr.len(store.as_context_mut())?;
555    let mut bytes = Vec::with_capacity(len as usize);
556    for i in 0..len {
557        let byte = arr
558            .get(store.as_context_mut(), i)?
559            .i32()
560            .ok_or_else(|| wasmtime::Error::msg("entity string element is not i32"))?;
561        bytes.push(byte as u8);
562    }
563    String::from_utf8(bytes)
564        .map_err(|err| wasmtime::Error::msg(format!("entity string field not utf-8: {err}")))
565}
566
567/// Folds an iterator of GC-ref elements into a `$pair` chain by re-entering
568/// the wasm module via its exported `pair_new` function. Returns the chain
569/// head, or `None` if the iterator is empty.
570///
571/// `$pair` is the self-recursive cell type (`{anyref car, ref null $pair
572/// cdr}`) declared by `CompileContext::new_skeleton`. The host can't freshly
573/// construct that StructType via `StructType::new` — the cdr field references
574/// the type itself, which `StructType::new` doesn't model. Instead this
575/// helper reaches into the module's own type system: `pair_new` is already
576/// emitted by the compiler skeleton as `register_function("pair_new", ...)`
577/// which adds it to the export table, so the host fn body can pull it from
578/// `Caller::get_export` and invoke it per element. Each call produces a
579/// `Rooted<StructRef>` of the exact `$pair` type that subsequent `ref.cast
580/// (ref null $pair)` operations in the guest accept.
581///
582/// Items are folded right-to-left so the first element of the iterator
583/// ends up at the chain head — `[a, b, c]` → `(a . (b . (c . nil)))`.
584pub async fn alloc_pair_chain<T>(
585    caller: &mut Caller<'_, T>,
586    items: impl IntoIterator<Item = Rooted<AnyRef>>,
587) -> wasmtime::Result<Option<Rooted<StructRef>>>
588where
589    T: Send,
590{
591    let pair_new = caller
592        .get_export("pair_new")
593        .and_then(|e| e.into_func())
594        .ok_or_else(|| {
595            wasmtime::Error::msg(
596                "module missing 'pair_new' export — host pair allocation requires \
597                 the nomiscript compiler skeleton's exported pair_new",
598            )
599        })?;
600
601    let items: Vec<Rooted<AnyRef>> = items.into_iter().collect();
602    let mut head: Option<Rooted<StructRef>> = None;
603    for item in items.into_iter().rev() {
604        let cdr_any = head.map(|p| p.to_anyref());
605        let mut results = [Val::AnyRef(None)];
606        pair_new
607            .call_async(
608                caller.as_context_mut(),
609                &[Val::AnyRef(Some(item)), Val::AnyRef(cdr_any)],
610                &mut results,
611            )
612            .await?;
613        let new_pair_any = match &results[0] {
614            Val::AnyRef(any) => *any,
615            _ => {
616                return Err(wasmtime::Error::msg(
617                    "pair_new returned non-anyref Val variant",
618                ));
619            }
620        };
621        head = Some(
622            new_pair_any
623                .ok_or_else(|| {
624                    wasmtime::Error::msg("pair_new returned null when chaining elements")
625                })?
626                .checked_struct(caller.as_context_mut())?,
627        );
628    }
629    Ok(head)
630}
631
632/// Instantiates `module` against an empty linker and calls a zero-arg export
633/// returning a single `i64`. Constraints (fuel cap, epoch deadline) come from
634/// the caller-supplied `Store`.
635pub fn call_i64_export<T>(
636    engine: &Engine,
637    store: &mut Store<T>,
638    module: &Module,
639    export: &str,
640) -> Result<i64, EngineError> {
641    let linker = Linker::<T>::new(engine);
642    let instance = linker
643        .instantiate(&mut *store, module)
644        .map_err(|e| classify_runtime_error(&e))?;
645    let func = instance
646        .get_typed_func::<(), i64>(&mut *store, export)
647        .map_err(|_| EngineError::MissingExport(export.to_string()))?;
648    func.call(&mut *store, ())
649        .map_err(|e| classify_runtime_error(&e))
650}
651
652/// Final value captured by an eval-mode module via the `nomi_capture_*` host
653/// fns. Mirrors the subset of [`nomiscript::WasmType`] variants the compiler
654/// emits as terminal stack types, plus a `Bytes` variant for native fns that
655/// marshal compound data (server-command results via `scripting-format`,
656/// chart SVGs, exported files, etc.). Cons/Vector/Closure/Struct still wait
657/// for the GC migration.
658#[derive(Debug, Clone, PartialEq)]
659pub enum EvalValue {
660    Nil,
661    T,
662    I32(i32),
663    Ratio {
664        numer: i64,
665        denom: i64,
666    },
667    /// Commodity-bearing amount: rational + originating commodity uuid.
668    /// Distinct from `Ratio` so cross-strata arithmetic is rejected by
669    /// the compiler before any wire round-trip. Wire form via
670    /// `format_value`: `(:commodity <ratio> :id "<uuid>")`.
671    Commodity {
672        numer: i64,
673        denom: i64,
674        commodity_hi: i64,
675        commodity_lo: i64,
676    },
677    String(String),
678    Bytes(Vec<u8>),
679}
680
681impl From<EvalValue> for nomiscript::Value {
682    fn from(value: EvalValue) -> Self {
683        match value {
684            EvalValue::Nil => nomiscript::Value::Nil,
685            EvalValue::T => nomiscript::Value::T,
686            EvalValue::I32(n) => {
687                nomiscript::Value::Number(nomiscript::Fraction::from_integer(i64::from(n)))
688            }
689            EvalValue::Ratio { numer, denom } => {
690                nomiscript::Value::Number(nomiscript::Fraction::new(numer, denom))
691            }
692            EvalValue::Commodity {
693                numer,
694                denom,
695                commodity_hi,
696                commodity_lo,
697            } => {
698                // Reassemble the 16-byte uuid from the wasm-side (hi, lo)
699                // i64 pair. Both halves get cast to u64 first so negative
700                // i64 patterns don't sign-extend into bogus high bits.
701                let raw = ((commodity_hi as u64 as u128) << 64) | (commodity_lo as u64 as u128);
702                nomiscript::Value::Commodity {
703                    amount: nomiscript::Fraction::new(numer, denom),
704                    commodity_id: uuid::Uuid::from_u128(raw),
705                }
706            }
707            EvalValue::String(s) => nomiscript::Value::String(s),
708            EvalValue::Bytes(b) => nomiscript::Value::Bytes(b),
709        }
710    }
711}
712
713/// Decodes nomi-eval's anyref return value into an [`EvalValue`] using
714/// the compile-time-known result type. `None` for the result_ty means
715/// the form was empty / definition-only and the host should see
716/// [`EvalValue::Nil`]. Numeric types (`I32`, `Ratio`, `Commodity`) and
717/// `StringRef` decode directly. `PairRef` walks the chain, decoding
718/// each car per its declared element type. `EntityRef` returns a
719/// placeholder until a downstream consumer needs the structured
720/// shape host-side. Takes any `AsContextMut` so it works with both
721/// `&mut Store<T>` (sync test paths) and `&mut Caller<'_, T>` (async
722/// host fn paths).
723pub fn decode_eval_result(
724    mut store: impl AsContextMut,
725    value: Option<Rooted<AnyRef>>,
726    result_ty: Option<nomiscript::WasmType>,
727) -> wasmtime::Result<EvalValue> {
728    let Some(ty) = result_ty else {
729        return Ok(EvalValue::Nil);
730    };
731    // Reference-typed results can legitimately be null: empty
732    // `pair<…>` from `list-accounts`, `Option<Rooted<…>>` returns
733    // surfacing not-found / missing-string cases. Surface those as
734    // `Nil` so consumers see an empty-shaped value rather than a
735    // trap. Primitive (I32) returns can't be null and stay strict.
736    let Some(any) = value else {
737        return match ty {
738            nomiscript::WasmType::I32 => Err(wasmtime::Error::msg(
739                "nomi-eval returned null for declared result type i32",
740            )),
741            _ => Ok(EvalValue::Nil),
742        };
743    };
744    decode_anyref(&mut store, any, ty)
745}
746
747/// An index result crosses nomi-eval as an `(n, 1)` ratio struct so all 32 bits
748/// survive the anyref slot.
749fn decode_index(mut store: impl AsContextMut, any: &Rooted<AnyRef>) -> wasmtime::Result<EvalValue> {
750    let numer = any
751        .checked_struct(&mut store)?
752        .field(&mut store, 0)?
753        .i64()
754        .ok_or_else(|| wasmtime::Error::msg("index result field 0 (numer) is not i64"))?;
755    i32::try_from(numer)
756        .map(EvalValue::I32)
757        .map_err(|_| wasmtime::Error::msg(format!("index result {numer} exceeds i32")))
758}
759
760fn decode_anyref(
761    mut store: impl AsContextMut,
762    any: Rooted<AnyRef>,
763    ty: nomiscript::WasmType,
764) -> wasmtime::Result<EvalValue> {
765    use nomiscript::WasmType;
766    match ty {
767        WasmType::I32 => decode_index(&mut store, &any),
768        WasmType::Bool => {
769            // A boolean result is i31-boxed like an i32, but decodes to nil /
770            // t (matches the const-fold path): 0 → Nil, nonzero → T.
771            let i31 = any.checked_i31(&mut store)?;
772            if i31.get_i32() == 0 {
773                Ok(EvalValue::Nil)
774            } else {
775                Ok(EvalValue::T)
776            }
777        }
778        WasmType::Ratio => {
779            let s = any.checked_struct(&mut store)?;
780            let numer = s
781                .field(&mut store, 0)?
782                .i64()
783                .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
784            let denom = s
785                .field(&mut store, 1)?
786                .i64()
787                .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
788            Ok(EvalValue::Ratio { numer, denom })
789        }
790        WasmType::Commodity => {
791            let s = any.checked_struct(&mut store)?;
792            let numer = s.field(&mut store, 0)?.i64().unwrap_or(0);
793            let denom = s.field(&mut store, 1)?.i64().unwrap_or(1);
794            // Field 4 is the unit term (ADR-0028). Null ⇒ ATOMIC single-currency
795            // money (id in fields 2-3). An empty term ⇒ DIMENSIONLESS (money ÷
796            // money, same currency) and decodes as a plain Number. A non-empty
797            // (compound) term — e.g. money×money — has no host wire form yet.
798            match s.field(&mut store, 4)? {
799                Val::AnyRef(None) => {
800                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
801                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
802                    Ok(EvalValue::Commodity {
803                        numer,
804                        denom,
805                        commodity_hi: hi,
806                        commodity_lo: lo,
807                    })
808                }
809                Val::AnyRef(Some(term)) => {
810                    let arr = term.checked_array(&mut store)?;
811                    if arr.len(&mut store)? == 0 {
812                        Ok(EvalValue::Ratio { numer, denom })
813                    } else {
814                        Err(wasmtime::Error::msg(
815                            "compound commodity (e.g. money × money) has no host \
816                             representation yet",
817                        ))
818                    }
819                }
820                _ => Err(wasmtime::Error::msg(
821                    "commodity field 4 (unit term) is not a ref",
822                )),
823            }
824        }
825        WasmType::StringRef => {
826            let arr = any.checked_array(&mut store)?;
827            let len = arr.len(&mut store)?;
828            let mut bytes = Vec::with_capacity(len as usize);
829            for i in 0..len {
830                let v = arr.get(&mut store, i)?;
831                let byte = v
832                    .i32()
833                    .ok_or_else(|| wasmtime::Error::msg("string element is not i32"))?;
834                bytes.push(byte as u8);
835            }
836            let s = String::from_utf8(bytes)
837                .map_err(|err| wasmtime::Error::msg(format!("not valid utf-8: {err}")))?;
838            Ok(EvalValue::String(s))
839        }
840        WasmType::PairRef(elem) => {
841            let head = render_pair_as_string(&mut store, any, elem)?;
842            Ok(EvalValue::String(head))
843        }
844        WasmType::EntityRef(kind) => {
845            let entity = any.checked_struct(&mut store)?;
846            Ok(EvalValue::String(render_entity(&mut store, entity, kind)?))
847        }
848        WasmType::Closure(_) => {
849            let _ = any;
850            Ok(EvalValue::String("<closure>".into()))
851        }
852        WasmType::AnyRef => {
853            // Heterogeneous payload (catch-each result cells, etc.).
854            // The host renderer cannot statically pick a decoder, so it
855            // surfaces a placeholder; the script-side accessor natives
856            // (`ok?` / `err-code` / etc.) are responsible for inspecting
857            // the contents.
858            let _ = any;
859            Ok(EvalValue::String("<anyref>".into()))
860        }
861    }
862}
863
864/// Walks a `$pair` chain and renders it as a Lisp-style list-of-cars
865/// textual form `( <car> <car> ... )`. Element decoding dispatches on
866/// the compile-time PairElement so each car gets its proper formatter.
867fn render_pair_as_string(
868    mut store: impl AsContextMut,
869    head_any: Rooted<AnyRef>,
870    elem: nomiscript::PairElement,
871) -> wasmtime::Result<String> {
872    let mut out = String::from("(");
873    let mut cur: Option<Rooted<StructRef>> = Some(head_any.checked_struct(&mut store)?);
874    let mut first = true;
875    while let Some(node) = cur {
876        if !first {
877            out.push(' ');
878        }
879        first = false;
880        let car_val = node.field(&mut store, 0)?;
881        let car_any = match car_val {
882            Val::AnyRef(Some(a)) => a,
883            Val::AnyRef(None) => {
884                out.push_str("nil");
885                let cdr_val = node.field(&mut store, 1)?;
886                cur = match cdr_val {
887                    Val::AnyRef(Some(a)) => Some(a.checked_struct(&mut store)?),
888                    _ => None,
889                };
890                continue;
891            }
892            _ => {
893                return Err(wasmtime::Error::msg("pair car is not anyref"));
894            }
895        };
896        let car_str = render_car(&mut store, car_any, elem)?;
897        out.push_str(&car_str);
898        let cdr_val = node.field(&mut store, 1)?;
899        cur = match cdr_val {
900            Val::AnyRef(Some(a)) => Some(a.checked_struct(&mut store)?),
901            _ => None,
902        };
903    }
904    out.push(')');
905    Ok(out)
906}
907
908fn render_car(
909    mut store: impl AsContextMut,
910    car_any: Rooted<AnyRef>,
911    elem: nomiscript::PairElement,
912) -> wasmtime::Result<String> {
913    use nomiscript::PairElement;
914    match elem {
915        PairElement::I32 => {
916            let i31 = car_any.checked_i31(&mut store)?;
917            Ok(i31.get_i32().to_string())
918        }
919        PairElement::Bool => {
920            // Shares the i31 car with I32, but renders as a truth value: 0 →
921            // `nil`, nonzero → `t` (matching the bool/nil wire convention).
922            let i31 = car_any.checked_i31(&mut store)?;
923            Ok(if i31.get_i32() == 0 { "nil" } else { "t" }.to_string())
924        }
925        PairElement::Ratio => {
926            let s = car_any.checked_struct(&mut store)?;
927            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
928            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
929            if d == 1 {
930                Ok(n.to_string())
931            } else {
932                Ok(format!("{n}/{d}"))
933            }
934        }
935        PairElement::Commodity => {
936            let s = car_any.checked_struct(&mut store)?;
937            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
938            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
939            // Field 4 (unit term, ADR-0028) decides the wire form — SAME rules
940            // as the top-level `decode_anyref` Commodity arm, so a compound
941            // money riding a `$pair` cell can't slip through as id-zero atomic
942            // money: null ⇒ atomic (id in fields 2-3), empty ⇒ dimensionless
943            // Number, non-empty (compound) ⇒ error (no host wire form yet).
944            match s.field(&mut store, 4)? {
945                Val::AnyRef(None) => {
946                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
947                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
948                    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
949                    let id = Uuid::from_u128(raw);
950                    if d == 1 {
951                        Ok(format!("(:commodity {n} :id \"{id}\")"))
952                    } else {
953                        Ok(format!("(:commodity {n}/{d} :id \"{id}\")"))
954                    }
955                }
956                Val::AnyRef(Some(term)) => {
957                    let arr = term.checked_array(&mut store)?;
958                    if arr.len(&mut store)? == 0 {
959                        Ok(if d == 1 {
960                            n.to_string()
961                        } else {
962                            format!("{n}/{d}")
963                        })
964                    } else {
965                        Err(wasmtime::Error::msg(
966                            "compound commodity (e.g. money × money) has no host \
967                             representation yet",
968                        ))
969                    }
970                }
971                _ => Err(wasmtime::Error::msg(
972                    "commodity field 4 (unit term) is not a ref",
973                )),
974            }
975        }
976        PairElement::StringRef => {
977            let arr = car_any.checked_array(&mut store)?;
978            let len = arr.len(&mut store)?;
979            let mut bytes = Vec::with_capacity(len as usize);
980            for i in 0..len {
981                let v = arr.get(&mut store, i)?;
982                bytes.push(v.i32().unwrap_or(0) as u8);
983            }
984            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
985            Ok(nomiscript::format_value(&nomiscript::Value::String(s)))
986        }
987        PairElement::Entity(kind) => {
988            let entity = car_any.checked_struct(&mut store)?;
989            render_entity(&mut store, entity, kind)
990        }
991        PairElement::AnyRef => Ok("<anyref>".into()),
992    }
993}
994
995/// Renders a typed entity struct as a readable plist —
996/// `(:commodity :id "…" :symbol "USD" :name "US Dollar")` — by reading each
997/// field at its `struct.get` slot per the single-source-of-truth
998/// [`nomiscript::entity_layout`] (generated from `entity_registry.org`). Field
999/// slot order in the layout matches the wasm struct exactly. A `Pair` field
1000/// renders as `()`-elided; the host has no element-type context to walk it.
1001fn render_entity(
1002    mut store: impl AsContextMut,
1003    entity: Rooted<StructRef>,
1004    kind: nomiscript::EntityKind,
1005) -> wasmtime::Result<String> {
1006    use nomiscript::EntityFieldKind;
1007
1008    let Some(layout) = nomiscript::entity_layout(kind) else {
1009        // No field layout (e.g. Condition isn't a server entity).
1010        return Ok(format!("(:{kind:?})"));
1011    };
1012    let mut out = format!("(:{}", layout.label);
1013    for (slot, field) in layout.fields.iter().enumerate() {
1014        let rendered = match field.kind {
1015            EntityFieldKind::String => read_string_slot(&mut store, entity, slot)?,
1016            EntityFieldKind::Ratio => read_ratio_slot(&mut store, entity, slot)?,
1017            EntityFieldKind::I32 => entity
1018                .field(&mut store, slot)?
1019                .i32()
1020                .unwrap_or(0)
1021                .to_string(),
1022            EntityFieldKind::Pair => "(...)".to_string(),
1023        };
1024        out.push_str(&format!(" :{} {rendered}", field.name));
1025    }
1026    out.push(')');
1027    Ok(out)
1028}
1029
1030/// Reads a `(ref null $i8_array)` string field at `slot`, rendered as a quoted
1031/// literal. A null/empty slot renders as `""`.
1032fn read_string_slot(
1033    mut store: impl AsContextMut,
1034    entity: Rooted<StructRef>,
1035    slot: usize,
1036) -> wasmtime::Result<String> {
1037    match entity.field(&mut store, slot)? {
1038        Val::AnyRef(Some(a)) => {
1039            let arr = a.checked_array(&mut store)?;
1040            let len = arr.len(&mut store)?;
1041            let mut bytes = Vec::with_capacity(len as usize);
1042            for i in 0..len {
1043                bytes.push(arr.get(&mut store, i)?.i32().unwrap_or(0) as u8);
1044            }
1045            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
1046            Ok(nomiscript::format_value(&nomiscript::Value::String(s)))
1047        }
1048        _ => Ok("\"\"".to_string()),
1049    }
1050}
1051
1052/// Reads a `(ref null $ratio)` field at `slot` (i64 numer/denom in slots 0/1 of
1053/// the ratio struct), rendered as `n` or `n/d`. A null slot renders as `0`.
1054fn read_ratio_slot(
1055    mut store: impl AsContextMut,
1056    entity: Rooted<StructRef>,
1057    slot: usize,
1058) -> wasmtime::Result<String> {
1059    match entity.field(&mut store, slot)? {
1060        Val::AnyRef(Some(a)) => {
1061            let s = a.checked_struct(&mut store)?;
1062            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
1063            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
1064            Ok(if d == 1 {
1065                n.to_string()
1066            } else {
1067                format!("{n}/{d}")
1068            })
1069        }
1070        _ => Ok("0".to_string()),
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests;