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

            
10
use std::collections::HashMap;
11
use std::sync::{Arc, Mutex};
12

            
13
use thiserror::Error;
14
use uuid::Uuid;
15

            
16
use crate::checked_ref::CheckedAnyRef;
17
use 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)]
23
pub 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.
66
pub 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)]
75
pub enum ProfilerStrategy {
76
    #[default]
77
    None,
78
    JitDump,
79
    PerfMap,
80
}
81

            
82
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83
pub struct EngineOpts {
84
    pub fuel: bool,
85
    pub profiler: ProfilerStrategy,
86
}
87

            
88
impl EngineOpts {
89
    #[must_use]
90
46528
    pub const fn baseline() -> Self {
91
46528
        Self {
92
46528
            fuel: false,
93
46528
            profiler: ProfilerStrategy::None,
94
46528
        }
95
46528
    }
96

            
97
    #[must_use]
98
23824
    pub const fn with_fuel(mut self) -> Self {
99
23824
        self.fuel = true;
100
23824
        self
101
23824
    }
102

            
103
    #[must_use]
104
18727
    pub const fn with_profiler(mut self, strategy: ProfilerStrategy) -> Self {
105
18727
        self.profiler = strategy;
106
18727
        self
107
18727
    }
108
}
109

            
110
impl Default for EngineOpts {
111
    fn default() -> Self {
112
        Self::baseline()
113
    }
114
}
115

            
116
46528
pub fn build_engine(opts: EngineOpts) -> Result<Engine, EngineError> {
117
46528
    let mut config = Config::new();
118
46528
    config.wasm_gc(true);
119
46528
    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
46528
    config.wasm_exceptions(true);
126
46528
    config.epoch_interruption(true);
127
46528
    if opts.fuel {
128
23824
        config.consume_fuel(true);
129
23824
    }
130
46528
    match opts.profiler {
131
46467
        ProfilerStrategy::None => {}
132
        ProfilerStrategy::JitDump => {
133
            config.profiler(wasmtime::ProfilingStrategy::JitDump);
134
        }
135
61
        ProfilerStrategy::PerfMap => {
136
61
            config.profiler(wasmtime::ProfilingStrategy::PerfMap);
137
61
        }
138
    }
139
46528
    Engine::new(&config).map_err(|e| EngineError::Config(e.to_string()))
140
46528
}
141

            
142
272763
pub fn compile_module(engine: &Engine, bytes: &[u8]) -> Result<Module, EngineError> {
143
272763
    Module::new(engine, bytes).map_err(|e| EngineError::Compile(e.to_string()))
144
272763
}
145

            
146
71
pub fn compile_wat(engine: &Engine, source: &str) -> Result<Module, EngineError> {
147
71
    Module::new(engine, source).map_err(|e| EngineError::Compile(e.to_string()))
148
71
}
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)]
155
pub struct ModuleCache {
156
    inner: Arc<Mutex<HashMap<Vec<u8>, Module>>>,
157
}
158

            
159
impl ModuleCache {
160
    #[must_use]
161
43985
    pub fn new() -> Self {
162
43985
        Self::default()
163
43985
    }
164

            
165
275418
    pub fn get_or_compile(&self, engine: &Engine, bytecode: &[u8]) -> Result<Module, EngineError> {
166
275418
        if let Some(module) = self.lookup(bytecode)? {
167
3173
            return Ok(module);
168
272245
        }
169
272245
        let module = compile_module(engine, bytecode)?;
170
272123
        self.store(bytecode, module.clone())?;
171
272123
        Ok(module)
172
275418
    }
173

            
174
275418
    fn lookup(&self, bytecode: &[u8]) -> Result<Option<Module>, EngineError> {
175
275418
        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
176
275418
        Ok(guard.get(bytecode).cloned())
177
275418
    }
178

            
179
272123
    fn store(&self, bytecode: &[u8], module: Module) -> Result<(), EngineError> {
180
272123
        let mut guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
181
272123
        guard.insert(bytecode.to_vec(), module);
182
272123
        Ok(())
183
272123
    }
184

            
185
2
    pub fn is_empty(&self) -> Result<bool, EngineError> {
186
2
        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
187
2
        Ok(guard.is_empty())
188
2
    }
189

            
190
431
    pub fn len(&self) -> Result<usize, EngineError> {
191
431
        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
192
431
        Ok(guard.len())
193
431
    }
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)`.
199
3052
pub fn classify_runtime_error(err: &wasmtime::Error) -> EngineError {
200
3052
    if let Some(trap) = err.downcast_ref::<wasmtime::Trap>() {
201
490
        match *trap {
202
367
            wasmtime::Trap::OutOfFuel => return EngineError::OutOfFuel,
203
123
            wasmtime::Trap::Interrupt => return EngineError::EpochInterrupt,
204
            _ => {}
205
        }
206
2562
    }
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
2562
    let mut combined = err.to_string();
213
2562
    for cause in err.chain().skip(1) {
214
2318
        combined.push_str(": ");
215
2318
        combined.push_str(&cause.to_string());
216
2318
    }
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
2562
    if let Some(raised) = parse_nomi_raise_marker(err) {
226
1464
        return raised;
227
1098
    }
228
1098
    if combined.contains("convert-commodity: no Price row")
229
976
        || combined.contains("convert-commodity: inverse price has zero numerator")
230
    {
231
122
        return EngineError::NoConversion(combined);
232
976
    }
233
976
    EngineError::Trap(combined)
234
3052
}
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.
241
2562
fn parse_nomi_raise_marker(err: &wasmtime::Error) -> Option<EngineError> {
242
2562
    err.chain()
243
4880
        .map(|cause| cause.to_string())
244
4880
        .find_map(|cause_str| split_marker(&cause_str))
245
2562
}
246

            
247
4880
fn split_marker(text: &str) -> Option<EngineError> {
248
4880
    let rest = text.strip_prefix(NOMI_RAISE_MARKER)?;
249
1464
    let (code, message) = rest.split_once(':')?;
250
1464
    Some(EngineError::ScriptRaised {
251
1464
        code: code.to_string(),
252
1464
        message: message.to_string(),
253
1464
    })
254
4880
}
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]
268
493
pub fn err_code_and_message(err: &EngineError) -> (String, String) {
269
493
    match err {
270
368
        EngineError::ScriptRaised { code, message } => (code.clone(), message.clone()),
271
1
        EngineError::NoConversion(msg) => ("no-conversion".to_string(), msg.clone()),
272
1
        EngineError::Trap(msg) => ("runtime".to_string(), msg.clone()),
273
122
        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
1
        EngineError::OutOfFuel => ("runtime".to_string(), "fuel exhausted".to_string()),
283
        EngineError::EpochInterrupt => ("runtime".to_string(), "epoch deadline".to_string()),
284
    }
285
493
}
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.
294
282
pub async fn alloc_commodity_ref<T>(
295
282
    caller: &mut Caller<'_, T>,
296
282
    numer: i64,
297
282
    denom: i64,
298
282
    commodity_id: Uuid,
299
282
) -> wasmtime::Result<Rooted<StructRef>>
300
282
where
301
282
    T: Send,
302
282
{
303
282
    let commodity_new = caller
304
282
        .get_export("commodity_new")
305
282
        .and_then(|e| e.into_func())
306
282
        .ok_or_else(|| {
307
1
            wasmtime::Error::msg(
308
                "module missing 'commodity_new' export — host commodity allocation \
309
                 requires the nomiscript compiler skeleton's exported commodity_new",
310
            )
311
1
        })?;
312
281
    let (hi, lo) = commodity_id.as_u64_pair();
313
281
    let mut results = [Val::AnyRef(None)];
314
281
    commodity_new
315
281
        .call_async(
316
281
            caller.as_context_mut(),
317
281
            &[
318
281
                Val::I64(numer),
319
281
                Val::I64(denom),
320
281
                Val::I64(hi as i64),
321
281
                Val::I64(lo as i64),
322
281
            ],
323
281
            &mut results,
324
281
        )
325
281
        .await?;
326
281
    match &results[0] {
327
281
        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
282
}
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`.
341
8277
pub fn alloc_string_ref<T>(
342
8277
    caller: &mut Caller<'_, T>,
343
8277
    bytes: &[u8],
344
8277
) -> wasmtime::Result<Rooted<wasmtime::ArrayRef>> {
345
8277
    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
8277
    let ty = wasmtime::ArrayType::new(&engine, FieldType::new(Mutability::Var, StorageType::I8));
351
8277
    let pre = wasmtime::ArrayRefPre::new(caller.as_context_mut(), ty);
352
251382
    let vals: Vec<Val> = bytes.iter().map(|b| Val::I32(i32::from(*b))).collect();
353
8277
    wasmtime::ArrayRef::new_fixed(caller.as_context_mut(), &pre, &vals)
354
8277
}
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.
360
390
pub fn alloc_ratio_ref<T>(
361
390
    caller: &mut Caller<'_, T>,
362
390
    numer: i64,
363
390
    denom: i64,
364
390
) -> wasmtime::Result<Rooted<StructRef>> {
365
390
    let engine = caller.engine().clone();
366
390
    let ty = StructType::new(
367
390
        &engine,
368
390
        std::iter::repeat_n(
369
390
            FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)),
370
            2,
371
        ),
372
    )?;
373
390
    let pre = StructRefPre::new(caller.as_context_mut(), ty);
374
390
    StructRef::new(
375
390
        caller.as_context_mut(),
376
390
        &pre,
377
390
        &[Val::I64(numer), Val::I64(denom)],
378
    )
379
390
}
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`).
394
1352
pub async fn alloc_entity_via_export<T>(
395
1352
    caller: &mut Caller<'_, T>,
396
1352
    export_name: &str,
397
1352
    args: &[Val],
398
1352
) -> wasmtime::Result<Rooted<StructRef>>
399
1352
where
400
1352
    T: Send,
401
1352
{
402
1352
    let alloc = caller
403
1352
        .get_export(export_name)
404
1352
        .and_then(|e| e.into_func())
405
1352
        .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
1352
    let mut results = [Val::AnyRef(None)];
412
1352
    alloc
413
1352
        .call_async(caller.as_context_mut(), args, &mut results)
414
1352
        .await?;
415
1352
    let new_entity_any = match &results[0] {
416
1352
        Val::AnyRef(any) => *any,
417
        _ => {
418
            return Err(wasmtime::Error::msg(format!(
419
                "{export_name} returned non-anyref Val variant"
420
            )));
421
        }
422
    };
423
1352
    new_entity_any
424
1352
        .ok_or_else(|| {
425
            wasmtime::Error::msg(format!(
426
                "{export_name} returned null when allocating entity"
427
            ))
428
        })?
429
1352
        .checked_struct(caller.as_context_mut())
430
1352
}
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.
438
10339
pub fn read_string_arg<T>(
439
10339
    caller: &mut Caller<'_, T>,
440
10339
    arg: Option<Rooted<wasmtime::ArrayRef>>,
441
10339
) -> wasmtime::Result<Option<String>> {
442
10339
    let Some(arr) = arg else {
443
        return Ok(None);
444
    };
445
10339
    let len = arr.len(caller.as_context_mut())?;
446
10339
    let mut bytes = Vec::with_capacity(len as usize);
447
381981
    for i in 0..len {
448
381981
        let val = arr.get(caller.as_context_mut(), i)?;
449
381981
        let byte_i32 = val
450
381981
            .i32()
451
381981
            .ok_or_else(|| wasmtime::Error::msg("string arg element is not i32"))?;
452
381981
        bytes.push(byte_i32 as u8);
453
    }
454
10339
    String::from_utf8(bytes)
455
10339
        .map(Some)
456
10339
        .map_err(|err| wasmtime::Error::msg(format!("string arg is not valid UTF-8: {err}")))
457
10339
}
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.
463
54
pub fn read_commodity_arg<T>(
464
54
    caller: &mut Caller<'_, T>,
465
54
    arg: Option<Rooted<StructRef>>,
466
54
) -> wasmtime::Result<Option<(i64, i64, Uuid)>> {
467
54
    let Some(s) = arg else {
468
        return Ok(None);
469
    };
470
216
    let read_i64 = |c: &mut Caller<'_, T>, idx: usize| -> wasmtime::Result<i64> {
471
216
        let v = s.field(c.as_context_mut(), idx)?;
472
216
        v.i64()
473
216
            .ok_or_else(|| wasmtime::Error::msg(format!("commodity field {idx} is not i64")))
474
216
    };
475
54
    let numer = read_i64(caller, 0)?;
476
54
    let denom = read_i64(caller, 1)?;
477
54
    let hi = read_i64(caller, 2)?;
478
54
    let lo = read_i64(caller, 3)?;
479
54
    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
480
54
    Ok(Some((numer, denom, Uuid::from_u128(raw))))
481
54
}
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).
487
104
pub fn read_ratio_arg<T>(
488
104
    caller: &mut Caller<'_, T>,
489
104
    arg: Option<Rooted<StructRef>>,
490
104
) -> wasmtime::Result<Option<(i64, i64)>> {
491
104
    let Some(s) = arg else {
492
        return Ok(None);
493
    };
494
104
    let numer = s
495
104
        .field(caller.as_context_mut(), 0)?
496
104
        .i64()
497
104
        .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
498
104
    let denom = s
499
104
        .field(caller.as_context_mut(), 1)?
500
104
        .i64()
501
104
        .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
502
104
    if denom == 0 {
503
        return Err(wasmtime::Error::msg("ratio has zero denominator"));
504
104
    }
505
104
    Ok(Some((numer, denom)))
506
104
}
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.
514
234
pub fn read_entity_string_field<T>(
515
234
    caller: &mut Caller<'_, T>,
516
234
    arg: Option<Rooted<StructRef>>,
517
234
    kind: nomiscript::EntityKind,
518
234
    field_name: &str,
519
234
) -> wasmtime::Result<Option<String>> {
520
234
    let Some(s) = arg else {
521
26
        return Ok(None);
522
    };
523
208
    read_entity_string_field_ctx(caller.as_context_mut(), s, kind, field_name).map(Some)
524
234
}
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.
529
211
pub fn read_entity_string_field_ctx(
530
211
    mut store: impl AsContextMut,
531
211
    entity: Rooted<StructRef>,
532
211
    kind: nomiscript::EntityKind,
533
211
    field_name: &str,
534
211
) -> wasmtime::Result<String> {
535
211
    let layout = nomiscript::entity_layout(kind)
536
211
        .ok_or_else(|| wasmtime::Error::msg(format!("no entity layout for {kind:?}")))?;
537
211
    let idx = layout
538
211
        .fields
539
211
        .iter()
540
215
        .position(|f| f.name == field_name && f.kind == nomiscript::EntityFieldKind::String)
541
211
        .ok_or_else(|| {
542
1
            wasmtime::Error::msg(format!(
543
                "entity {kind:?} has no String field named '{field_name}'"
544
            ))
545
1
        })?;
546
210
    let arr = match entity.field(store.as_context_mut(), idx)? {
547
210
        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
210
    let len = arr.len(store.as_context_mut())?;
555
210
    let mut bytes = Vec::with_capacity(len as usize);
556
7505
    for i in 0..len {
557
7505
        let byte = arr
558
7505
            .get(store.as_context_mut(), i)?
559
7505
            .i32()
560
7505
            .ok_or_else(|| wasmtime::Error::msg("entity string element is not i32"))?;
561
7505
        bytes.push(byte as u8);
562
    }
563
210
    String::from_utf8(bytes)
564
210
        .map_err(|err| wasmtime::Error::msg(format!("entity string field not utf-8: {err}")))
565
211
}
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)))`.
584
1493
pub async fn alloc_pair_chain<T>(
585
1493
    caller: &mut Caller<'_, T>,
586
1493
    items: impl IntoIterator<Item = Rooted<AnyRef>>,
587
1493
) -> wasmtime::Result<Option<Rooted<StructRef>>>
588
1493
where
589
1493
    T: Send,
590
1493
{
591
1493
    let pair_new = caller
592
1493
        .get_export("pair_new")
593
1493
        .and_then(|e| e.into_func())
594
1493
        .ok_or_else(|| {
595
1
            wasmtime::Error::msg(
596
                "module missing 'pair_new' export — host pair allocation requires \
597
                 the nomiscript compiler skeleton's exported pair_new",
598
            )
599
1
        })?;
600

            
601
1492
    let items: Vec<Rooted<AnyRef>> = items.into_iter().collect();
602
1492
    let mut head: Option<Rooted<StructRef>> = None;
603
2231
    for item in items.into_iter().rev() {
604
2231
        let cdr_any = head.map(|p| p.to_anyref());
605
2231
        let mut results = [Val::AnyRef(None)];
606
2231
        pair_new
607
2231
            .call_async(
608
2231
                caller.as_context_mut(),
609
2231
                &[Val::AnyRef(Some(item)), Val::AnyRef(cdr_any)],
610
2231
                &mut results,
611
2231
            )
612
2231
            .await?;
613
2231
        let new_pair_any = match &results[0] {
614
2231
            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
2231
            new_pair_any
623
2231
                .ok_or_else(|| {
624
                    wasmtime::Error::msg("pair_new returned null when chaining elements")
625
                })?
626
2231
                .checked_struct(caller.as_context_mut())?,
627
        );
628
    }
629
1492
    Ok(head)
630
1493
}
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`.
635
4
pub fn call_i64_export<T>(
636
4
    engine: &Engine,
637
4
    store: &mut Store<T>,
638
4
    module: &Module,
639
4
    export: &str,
640
4
) -> Result<i64, EngineError> {
641
4
    let linker = Linker::<T>::new(engine);
642
4
    let instance = linker
643
4
        .instantiate(&mut *store, module)
644
4
        .map_err(|e| classify_runtime_error(&e))?;
645
4
    let func = instance
646
4
        .get_typed_func::<(), i64>(&mut *store, export)
647
4
        .map_err(|_| EngineError::MissingExport(export.to_string()))?;
648
3
    func.call(&mut *store, ())
649
3
        .map_err(|e| classify_runtime_error(&e))
650
4
}
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)]
659
pub 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

            
681
impl From<EvalValue> for nomiscript::Value {
682
27086
    fn from(value: EvalValue) -> Self {
683
27086
        match value {
684
3539
            EvalValue::Nil => nomiscript::Value::Nil,
685
1404
            EvalValue::T => nomiscript::Value::T,
686
6527
            EvalValue::I32(n) => {
687
6527
                nomiscript::Value::Number(nomiscript::Fraction::from_integer(i64::from(n)))
688
            }
689
366
            EvalValue::Ratio { numer, denom } => {
690
366
                nomiscript::Value::Number(nomiscript::Fraction::new(numer, denom))
691
            }
692
            EvalValue::Commodity {
693
183
                numer,
694
183
                denom,
695
183
                commodity_hi,
696
183
                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
183
                let raw = ((commodity_hi as u64 as u128) << 64) | (commodity_lo as u64 as u128);
702
183
                nomiscript::Value::Commodity {
703
183
                    amount: nomiscript::Fraction::new(numer, denom),
704
183
                    commodity_id: uuid::Uuid::from_u128(raw),
705
183
                }
706
            }
707
15067
            EvalValue::String(s) => nomiscript::Value::String(s),
708
            EvalValue::Bytes(b) => nomiscript::Value::Bytes(b),
709
        }
710
27086
    }
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).
723
1527
pub fn decode_eval_result(
724
1527
    mut store: impl AsContextMut,
725
1527
    value: Option<Rooted<AnyRef>>,
726
1527
    result_ty: Option<nomiscript::WasmType>,
727
1527
) -> wasmtime::Result<EvalValue> {
728
1527
    let Some(ty) = result_ty else {
729
8
        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
1519
    let Some(any) = value else {
737
89
        return match ty {
738
            nomiscript::WasmType::I32 => Err(wasmtime::Error::msg(
739
                "nomi-eval returned null for declared result type i32",
740
            )),
741
89
            _ => Ok(EvalValue::Nil),
742
        };
743
    };
744
1430
    decode_anyref(&mut store, any, ty)
745
1527
}
746

            
747
/// An index result crosses nomi-eval as an `(n, 1)` ratio struct so all 32 bits
748
/// survive the anyref slot.
749
488
fn decode_index(mut store: impl AsContextMut, any: &Rooted<AnyRef>) -> wasmtime::Result<EvalValue> {
750
488
    let numer = any
751
488
        .checked_struct(&mut store)?
752
488
        .field(&mut store, 0)?
753
488
        .i64()
754
488
        .ok_or_else(|| wasmtime::Error::msg("index result field 0 (numer) is not i64"))?;
755
488
    i32::try_from(numer)
756
488
        .map(EvalValue::I32)
757
488
        .map_err(|_| wasmtime::Error::msg(format!("index result {numer} exceeds i32")))
758
488
}
759

            
760
1430
fn decode_anyref(
761
1430
    mut store: impl AsContextMut,
762
1430
    any: Rooted<AnyRef>,
763
1430
    ty: nomiscript::WasmType,
764
1430
) -> wasmtime::Result<EvalValue> {
765
    use nomiscript::WasmType;
766
1430
    match ty {
767
488
        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
28
            let i31 = any.checked_i31(&mut store)?;
772
28
            if i31.get_i32() == 0 {
773
                Ok(EvalValue::Nil)
774
            } else {
775
28
                Ok(EvalValue::T)
776
            }
777
        }
778
        WasmType::Ratio => {
779
8
            let s = any.checked_struct(&mut store)?;
780
8
            let numer = s
781
8
                .field(&mut store, 0)?
782
8
                .i64()
783
8
                .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
784
8
            let denom = s
785
8
                .field(&mut store, 1)?
786
8
                .i64()
787
8
                .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
788
8
            Ok(EvalValue::Ratio { numer, denom })
789
        }
790
        WasmType::Commodity => {
791
7
            let s = any.checked_struct(&mut store)?;
792
7
            let numer = s.field(&mut store, 0)?.i64().unwrap_or(0);
793
7
            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
7
            match s.field(&mut store, 4)? {
799
                Val::AnyRef(None) => {
800
5
                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
801
5
                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
802
5
                    Ok(EvalValue::Commodity {
803
5
                        numer,
804
5
                        denom,
805
5
                        commodity_hi: hi,
806
5
                        commodity_lo: lo,
807
5
                    })
808
                }
809
2
                Val::AnyRef(Some(term)) => {
810
2
                    let arr = term.checked_array(&mut store)?;
811
2
                    if arr.len(&mut store)? == 0 {
812
1
                        Ok(EvalValue::Ratio { numer, denom })
813
                    } else {
814
1
                        Err(wasmtime::Error::msg(
815
1
                            "compound commodity (e.g. money × money) has no host \
816
1
                             representation yet",
817
1
                        ))
818
                    }
819
                }
820
                _ => Err(wasmtime::Error::msg(
821
                    "commodity field 4 (unit term) is not a ref",
822
                )),
823
            }
824
        }
825
        WasmType::StringRef => {
826
723
            let arr = any.checked_array(&mut store)?;
827
723
            let len = arr.len(&mut store)?;
828
723
            let mut bytes = Vec::with_capacity(len as usize);
829
25655
            for i in 0..len {
830
25655
                let v = arr.get(&mut store, i)?;
831
25655
                let byte = v
832
25655
                    .i32()
833
25655
                    .ok_or_else(|| wasmtime::Error::msg("string element is not i32"))?;
834
25655
                bytes.push(byte as u8);
835
            }
836
723
            let s = String::from_utf8(bytes)
837
723
                .map_err(|err| wasmtime::Error::msg(format!("not valid utf-8: {err}")))?;
838
723
            Ok(EvalValue::String(s))
839
        }
840
171
        WasmType::PairRef(elem) => {
841
171
            let head = render_pair_as_string(&mut store, any, elem)?;
842
170
            Ok(EvalValue::String(head))
843
        }
844
2
        WasmType::EntityRef(kind) => {
845
2
            let entity = any.checked_struct(&mut store)?;
846
2
            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
3
            let _ = any;
859
3
            Ok(EvalValue::String("<anyref>".into()))
860
        }
861
    }
862
1430
}
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.
867
171
fn render_pair_as_string(
868
171
    mut store: impl AsContextMut,
869
171
    head_any: Rooted<AnyRef>,
870
171
    elem: nomiscript::PairElement,
871
171
) -> wasmtime::Result<String> {
872
171
    let mut out = String::from("(");
873
171
    let mut cur: Option<Rooted<StructRef>> = Some(head_any.checked_struct(&mut store)?);
874
171
    let mut first = true;
875
410
    while let Some(node) = cur {
876
240
        if !first {
877
69
            out.push(' ');
878
177
        }
879
240
        first = false;
880
240
        let car_val = node.field(&mut store, 0)?;
881
240
        let car_any = match car_val {
882
237
            Val::AnyRef(Some(a)) => a,
883
            Val::AnyRef(None) => {
884
3
                out.push_str("nil");
885
3
                let cdr_val = node.field(&mut store, 1)?;
886
3
                cur = match cdr_val {
887
1
                    Val::AnyRef(Some(a)) => Some(a.checked_struct(&mut store)?),
888
2
                    _ => None,
889
                };
890
3
                continue;
891
            }
892
            _ => {
893
                return Err(wasmtime::Error::msg("pair car is not anyref"));
894
            }
895
        };
896
237
        let car_str = render_car(&mut store, car_any, elem)?;
897
236
        out.push_str(&car_str);
898
236
        let cdr_val = node.field(&mut store, 1)?;
899
236
        cur = match cdr_val {
900
68
            Val::AnyRef(Some(a)) => Some(a.checked_struct(&mut store)?),
901
168
            _ => None,
902
        };
903
    }
904
170
    out.push(')');
905
170
    Ok(out)
906
171
}
907

            
908
237
fn render_car(
909
237
    mut store: impl AsContextMut,
910
237
    car_any: Rooted<AnyRef>,
911
237
    elem: nomiscript::PairElement,
912
237
) -> wasmtime::Result<String> {
913
    use nomiscript::PairElement;
914
237
    match elem {
915
        PairElement::I32 => {
916
45
            let i31 = car_any.checked_i31(&mut store)?;
917
45
            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
7
            let i31 = car_any.checked_i31(&mut store)?;
923
7
            Ok(if i31.get_i32() == 0 { "nil" } else { "t" }.to_string())
924
        }
925
        PairElement::Ratio => {
926
24
            let s = car_any.checked_struct(&mut store)?;
927
24
            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
928
24
            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
929
24
            if d == 1 {
930
15
                Ok(n.to_string())
931
            } else {
932
9
                Ok(format!("{n}/{d}"))
933
            }
934
        }
935
        PairElement::Commodity => {
936
2
            let s = car_any.checked_struct(&mut store)?;
937
2
            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
938
2
            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
2
            match s.field(&mut store, 4)? {
945
                Val::AnyRef(None) => {
946
1
                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
947
1
                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
948
1
                    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
949
1
                    let id = Uuid::from_u128(raw);
950
1
                    if d == 1 {
951
1
                        Ok(format!("(:commodity {n} :id \"{id}\")"))
952
                    } else {
953
                        Ok(format!("(:commodity {n}/{d} :id \"{id}\")"))
954
                    }
955
                }
956
1
                Val::AnyRef(Some(term)) => {
957
1
                    let arr = term.checked_array(&mut store)?;
958
1
                    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
1
                        Err(wasmtime::Error::msg(
966
1
                            "compound commodity (e.g. money × money) has no host \
967
1
                             representation yet",
968
1
                        ))
969
                    }
970
                }
971
                _ => Err(wasmtime::Error::msg(
972
                    "commodity field 4 (unit term) is not a ref",
973
                )),
974
            }
975
        }
976
        PairElement::StringRef => {
977
69
            let arr = car_any.checked_array(&mut store)?;
978
69
            let len = arr.len(&mut store)?;
979
69
            let mut bytes = Vec::with_capacity(len as usize);
980
7964
            for i in 0..len {
981
7964
                let v = arr.get(&mut store, i)?;
982
7964
                bytes.push(v.i32().unwrap_or(0) as u8);
983
            }
984
69
            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
985
69
            Ok(nomiscript::format_value(&nomiscript::Value::String(s)))
986
        }
987
65
        PairElement::Entity(kind) => {
988
65
            let entity = car_any.checked_struct(&mut store)?;
989
65
            render_entity(&mut store, entity, kind)
990
        }
991
25
        PairElement::AnyRef => Ok("<anyref>".into()),
992
    }
993
237
}
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
/// renders as `()`-elided; the host has no element-type context to walk it.
68
fn render_entity(
68
    mut store: impl AsContextMut,
68
    entity: Rooted<StructRef>,
68
    kind: nomiscript::EntityKind,
68
) -> wasmtime::Result<String> {
    use nomiscript::EntityFieldKind;
68
    let Some(layout) = nomiscript::entity_layout(kind) else {
        // No field layout (e.g. Condition isn't a server entity).
        return Ok(format!("(:{kind:?})"));
    };
68
    let mut out = format!("(:{}", layout.label);
258
    for (slot, field) in layout.fields.iter().enumerate() {
258
        let rendered = match field.kind {
258
            EntityFieldKind::String => read_string_slot(&mut store, entity, slot)?,
            EntityFieldKind::Ratio => read_ratio_slot(&mut store, entity, slot)?,
            EntityFieldKind::I32 => entity
                .field(&mut store, slot)?
                .i32()
                .unwrap_or(0)
                .to_string(),
            EntityFieldKind::Pair => "(...)".to_string(),
        };
258
        out.push_str(&format!(" :{} {rendered}", field.name));
    }
68
    out.push(')');
68
    Ok(out)
68
}
/// Reads a `(ref null $i8_array)` string field at `slot`, rendered as a quoted
/// literal. A null/empty slot renders as `""`.
258
fn read_string_slot(
258
    mut store: impl AsContextMut,
258
    entity: Rooted<StructRef>,
258
    slot: usize,
258
) -> wasmtime::Result<String> {
258
    match entity.field(&mut store, slot)? {
178
        Val::AnyRef(Some(a)) => {
178
            let arr = a.checked_array(&mut store)?;
178
            let len = arr.len(&mut store)?;
178
            let mut bytes = Vec::with_capacity(len as usize);
3624
            for i in 0..len {
3624
                bytes.push(arr.get(&mut store, i)?.i32().unwrap_or(0) as u8);
            }
178
            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
178
            Ok(nomiscript::format_value(&nomiscript::Value::String(s)))
        }
80
        _ => Ok("\"\"".to_string()),
    }
258
}
/// Reads a `(ref null $ratio)` field at `slot` (i64 numer/denom in slots 0/1 of
/// the ratio struct), rendered as `n` or `n/d`. A null slot renders as `0`.
fn read_ratio_slot(
    mut store: impl AsContextMut,
    entity: Rooted<StructRef>,
    slot: usize,
) -> wasmtime::Result<String> {
    match entity.field(&mut store, slot)? {
        Val::AnyRef(Some(a)) => {
            let s = a.checked_struct(&mut store)?;
            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
            Ok(if d == 1 {
                n.to_string()
            } else {
                format!("{n}/{d}")
            })
        }
        _ => Ok("0".to_string()),
    }
}
#[cfg(test)]
mod tests;