Skip to main content

rpc/
session.rs

1use std::cell::RefCell;
2use std::sync::{Arc, Mutex};
3
4use nomiscript::{
5    Compiler, Error as NomiError, Expr, HostFnSpec, Program, Reader, SymbolTable, Value,
6};
7use scripting::runtime::{
8    EngineError, EngineOpts, ModuleCache, build_engine, classify_runtime_error, decode_eval_result,
9};
10use thiserror::Error;
11use tracing::debug;
12use wasmtime::{AnyRef, Engine, Linker, Rooted, Store, Val};
13
14use crate::ctx::{EpochBumper, InterruptHandle, ScriptCtx};
15use crate::envelope::{
16    EnvelopeError, ErrorCode, Request, RequestId, Response, ResponsePayload, format_response,
17    parse_request,
18};
19
20const EPOCH_DEADLINE_TICKS: u64 = 1;
21
22/// Wasmtime Store data type for the rpc eval channel. Carries the
23/// per-session user context (`ScriptCtx`) so native fns reach
24/// `caller.data().ctx().user_id` directly. The legacy
25/// `EvalContext` capture-protocol companion field retired alongside
26/// the capture imports in P4 A6.c — `nomi-eval` now returns its
27/// final value via the function's `(ref null any)` return slot.
28///
29/// Native fns are async (`Linker::func_wrap_async`) — they `.await`
30/// `server::command::*` futures directly on whatever runtime drives the
31/// surrounding `Session::handle_form` call. No owned runtime, no
32/// `spawn_blocking`, no thread-local pool concerns.
33pub struct SessionData {
34    ctx: ScriptCtx,
35    /// Per-request stdout sink. `env.log` (PRINT/DISPLAY/NEWLINE) appends here so
36    /// the mREPL (`nms --slynk-port`) can surface script output as
37    /// `:write-string` separately from the final value. Shared with the owning
38    /// `Session`, which drains it per request. The non-mrepl paths (sshd / the
39    /// rpc text REPL) ignore it; `env.log` still tees to `tracing` for them.
40    output: Arc<Mutex<String>>,
41    /// Render-only draft accumulator. `Some` only on the restricted template
42    /// render path ([`crate::template`]); the draft natives mutate it and the
43    /// render entry point reads it back via `store.into_data()`. `None` on the
44    /// normal eval channel, where the draft natives are not even linked.
45    draft: Option<RefCell<crate::draft::TransactionDraft>>,
46}
47
48impl SessionData {
49    pub(crate) fn new(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
50        Self {
51            ctx,
52            output,
53            draft: None,
54        }
55    }
56
57    /// Builds session data with a draft accumulator armed — the render path's
58    /// constructor. Draft natives require `draft` to be `Some`.
59    pub(crate) fn for_render(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
60        Self {
61            ctx,
62            output,
63            draft: Some(RefCell::new(crate::draft::TransactionDraft::new())),
64        }
65    }
66
67    #[must_use]
68    pub fn ctx(&self) -> &ScriptCtx {
69        &self.ctx
70    }
71
72    /// Mutates the draft accumulator if armed (render path). Returns an error
73    /// on the normal eval path where no draft is present — a draft native
74    /// reaching a non-render Store is a wiring bug, surfaced as a trap.
75    pub fn with_draft<F>(&self, f: F) -> wasmtime::Result<()>
76    where
77        F: FnOnce(&mut crate::draft::TransactionDraft),
78    {
79        let cell = self
80            .draft
81            .as_ref()
82            .ok_or_else(|| wasmtime::Error::msg("draft native invoked outside render mode"))?;
83        f(&mut cell.borrow_mut());
84        Ok(())
85    }
86
87    /// Consumes the accumulated draft, if any. Called after a render run via
88    /// `store.into_data()`.
89    #[must_use]
90    pub fn into_draft(self) -> Option<crate::draft::TransactionDraft> {
91        self.draft.map(RefCell::into_inner)
92    }
93
94    /// Appends a script-output line to the per-request buffer. Called by the
95    /// `env.log` host fn. A poisoned lock is swallowed (output capture is
96    /// best-effort telemetry, never a reason to fail an eval).
97    pub fn push_output(&self, msg: &str) {
98        if let Ok(mut buf) = self.output.lock() {
99            buf.push_str(msg);
100        }
101    }
102}
103
104/// Per-channel evaluator. Owns mutable state across forms (defun-defined symbols
105/// persist between requests in the same session via `SymbolTable`; the wasm
106/// `ModuleCache` reuses compilations of structurally identical forms) and an
107/// interrupt handle that cooperatively short-circuits the next form on demand.
108///
109/// Eval pipeline: `nomiscript::Compiler::compile_with_mode(Eval)` emits a
110/// module exporting `nomi-eval`; the form's final value rides the function's
111/// `(ref null any)` return slot and the host walks it into an `EvalValue`
112/// via [`decode_eval_result`].
113pub struct Session {
114    ctx: ScriptCtx,
115    engine: Engine,
116    compiler: Compiler,
117    cache: ModuleCache,
118    symbols: SymbolTable,
119    interrupt: InterruptHandle,
120    /// Watermark of the highest interrupt generation already attributed to a
121    /// finished request (see [`Session::check_interrupt`] / [`Session::ack_interrupt`]).
122    /// A `C-g` counts only while `interrupt.generation() > interrupt_ack`, so one
123    /// signal aborts exactly one request and can never linger to poison a later
124    /// form.
125    interrupt_ack: u64,
126    /// Shared with each per-request `SessionData` so `env.log` output lands
127    /// where [`Session::handle_request`] can drain it.
128    output: Arc<Mutex<String>>,
129}
130
131/// A structured eval result: the captured script output plus the typed
132/// value/error payload. The SLYNK mREPL maps `output` → `:write-string` and
133/// `payload` → `:write-values` / `:evaluation-aborted`. `handle_form` (the text
134/// wire path) does not use this — it formats the payload alone.
135#[derive(Debug, Clone, PartialEq)]
136pub struct EvalOutcome {
137    pub output: String,
138    pub payload: ResponsePayload,
139}
140
141#[derive(Debug, Error)]
142pub enum SessionError {
143    #[error("engine init failed: {0}")]
144    Engine(#[from] EngineError),
145}
146
147impl Session {
148    pub fn new(ctx: ScriptCtx) -> Result<Self, SessionError> {
149        let engine = build_engine(EngineOpts::baseline().with_fuel())?;
150        let host_fns = crate::natives::all_compiler_specs();
151        let mut symbols = SymbolTable::with_builtins();
152        symbols.register_host_fns(&host_fns);
153        // Host-dependent prelude (ADR-0029): loaded only here, after the RPC
154        // host fns it calls are registered. The universal prelude already rode
155        // in via `with_builtins`.
156        crate::host_prelude::load(&mut symbols);
157        let mut session = Self {
158            ctx,
159            engine,
160            compiler: Compiler::with_host_fns(host_fns.clone()),
161            cache: ModuleCache::new(),
162            symbols,
163            interrupt: InterruptHandle::new(),
164            interrupt_ack: 0,
165            output: Arc::new(Mutex::new(String::new())),
166        };
167        // Phase 4: pre-warm the per-Session ModuleCache with the
168        // bare-call wasm for every zero-arg host fn that has a
169        // return type. The cache is keyed by full bytecode bytes so
170        // when a request like `(:id N :form (rpc-protocol-version))`
171        // lands, `cache.get_or_compile` finds the pre-compiled
172        // module and `handle_form`'s critical path is
173        // instantiate-only. Composed forms / arg-bearing calls /
174        // unseen forms still hit the cold compile path.
175        session.warm_bare_call_cache(&host_fns);
176        Ok(session)
177    }
178
179    /// Pre-compile and cache the bare-call wasm for every zero-arg
180    /// host fn whose result type is non-None. Skips no-arg fns with
181    /// `result: None` (their bare call would error at value position)
182    /// and skips arg-bearing fns (their wasm embeds the literal
183    /// args, so pre-warming would be wrong-keyed).
184    ///
185    /// Failures are silently ignored — pre-warming is an optimization,
186    /// not a correctness step. If a spec fails to compile here, the
187    /// runtime path will surface the same error when the form lands.
188    fn warm_bare_call_cache(&mut self, host_fns: &[HostFnSpec]) {
189        for spec in host_fns {
190            if !spec.params.is_empty() || spec.result.is_none() {
191                continue;
192            }
193            let form = Expr::List(vec![Expr::Symbol(spec.nomi_name.clone())]);
194            let program = Program::new(vec![form]);
195            let Ok((bytes, _ty)) = self
196                .compiler
197                .compile_eval_with_type(&program, &mut self.symbols)
198            else {
199                continue;
200            };
201            let _ = self.cache.get_or_compile(&self.engine, &bytes);
202        }
203    }
204
205    #[must_use]
206    pub fn ctx(&self) -> &ScriptCtx {
207        &self.ctx
208    }
209
210    #[must_use]
211    pub fn interrupt_handle(&self) -> InterruptHandle {
212        self.interrupt.clone()
213    }
214
215    /// Symbol names completing `prefix`, sorted and deduplicated, for the SLYNK
216    /// completion rex (`M-x sly-complete-symbol` / mREPL TAB). The reader folds
217    /// symbols with `make_ascii_uppercase` at read time, so the match uppercases
218    /// the prefix the SAME way (ASCII-only — matching the reader, not full
219    /// Unicode) and returns the canonical name (it re-reads identically). Skips
220    /// `(SETF …)` setf-place names and compiler-internal `$…` / `__…` symbols —
221    /// none are head symbols a user types. An empty `prefix` lists every
222    /// completable symbol.
223    #[must_use]
224    pub fn completions(&self, prefix: &str) -> Vec<String> {
225        let needle = prefix.to_ascii_uppercase();
226        let mut names: Vec<String> = self
227            .symbols
228            .iter()
229            .map(|(name, _)| name.as_str())
230            .filter(|name| {
231                !name.starts_with('$') && !name.starts_with("__") && !name.starts_with("(SETF")
232            })
233            .filter(|name| name.starts_with(&needle))
234            .map(str::to_owned)
235            .collect();
236        names.sort_unstable();
237        names.dedup();
238        names
239    }
240
241    /// Cooperative cancel handle for an in-flight `nomi-eval`. Clone
242    /// and hand to the transport layer: when the client sends a
243    /// cancel signal (e.g. emacs `C-g`), call `.bump()` and the
244    /// awaiting evaluation traps with `EngineError::EpochInterrupt`,
245    /// surfacing on the wire as `(:error (:code interrupted ...))`.
246    #[must_use]
247    pub fn epoch_bumper(&self) -> EpochBumper {
248        EpochBumper::new(self.engine.clone())
249    }
250
251    /// Number of pre-compiled wasm modules currently cached.
252    /// Used by tests to confirm the phase-4 pre-warm step ran and
253    /// that subsequent `handle_form` calls of cached forms don't
254    /// trigger a cold compile.
255    pub fn cache_size(&self) -> Result<usize, EngineError> {
256        self.cache.len()
257    }
258
259    pub async fn handle_form(&mut self, frame: &str) -> String {
260        let response = match self.evaluate(frame).await {
261            Ok(resp) => resp,
262            Err(err) => err.into_response(),
263        };
264        format_response(&response)
265    }
266
267    /// Structured eval of a single bare `form` (not an envelope frame): clears
268    /// the output buffer, runs the form, and returns the captured script output
269    /// alongside the typed value/error payload. The SLYNK mREPL uses this so it
270    /// can render `:write-string` (output) and `:write-values` /
271    /// `:evaluation-aborted` (payload) separately. `handle_form` is unchanged.
272    pub async fn handle_request(&mut self, source: &str) -> EvalOutcome {
273        if let Ok(mut buf) = self.output.lock() {
274            buf.clear();
275        }
276        let payload = match self.eval_source(source).await {
277            Ok(value) => ResponsePayload::Value(value),
278            Err(err) => err.into_response().payload,
279        };
280        let output = self
281            .output
282            .lock()
283            .map(|buf| buf.clone())
284            .unwrap_or_default();
285        EvalOutcome { output, payload }
286    }
287
288    /// Parses `source` to a single top-level form and evaluates it. Unlike
289    /// `handle_form`, the source is parsed to an AST directly (NOT interpolated
290    /// into an envelope string), so plist-shaped input can't hijack the
291    /// envelope: `1 :form (+ 2 3)` is rejected as "more than one form", not
292    /// silently re-read as a `(:id 1 :form …)` plist. The fixed `RequestId::Int(0)`
293    /// is unused downstream — the SLYNK layer tracks its own channel ids.
294    async fn eval_source(&mut self, source: &str) -> Result<Value, EvalFailure> {
295        let id = RequestId::Int(0);
296        let program = Reader::parse(source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
297        let mut exprs = program.exprs;
298        let form = match exprs.len() {
299            0 => return Ok(Value::Nil),
300            1 => exprs.remove(0),
301            _ => {
302                return Err(EvalFailure::Eval(
303                    id,
304                    NomiError::Compile("expected a single form".to_string()),
305                ));
306            }
307        };
308        self.eval_one_form(form).await
309    }
310
311    /// Reads `path`, evaluates every top-level form in source order (state —
312    /// `defun`s etc. — accumulates across forms, like the sshd channel), and
313    /// returns a short summary. Powers SLY's `M-x sly-load-file`
314    /// (`slynk:load-file`). Captured output across all forms is returned for the
315    /// caller to surface; a failing form aborts the load at that point.
316    pub async fn handle_file(&mut self, path: &str) -> EvalOutcome {
317        if let Ok(mut buf) = self.output.lock() {
318            buf.clear();
319        }
320        let payload = match self.load_path(path).await {
321            Ok(summary) => ResponsePayload::Value(Value::String(summary)),
322            Err(err) => err.into_response().payload,
323        };
324        let output = self
325            .output
326            .lock()
327            .map(|buf| buf.clone())
328            .unwrap_or_default();
329        EvalOutcome { output, payload }
330    }
331
332    /// Attributes all interrupts up to `observed` to the current request, so
333    /// they aren't counted again. Crucially the caller passes the SAME
334    /// generation it used to decide an interrupt is pending — never a fresh
335    /// reload — so a `C-g` that lands strictly after that observation point is
336    /// NOT folded into this request; it stays pending for the next form. The
337    /// guard keeps the watermark monotonic.
338    ///
339    /// Coalescing is intended: several `C-g`s observed together (generation
340    /// jumped by >1) cancel the one in-flight form, they do not pre-arm
341    /// cancellation of distinct future forms — that is the REPL `C-g` contract.
342    fn ack_interrupt(&mut self, observed: u64) {
343        if observed > self.interrupt_ack {
344            self.interrupt_ack = observed;
345        }
346    }
347
348    /// If an interrupt is pending, ack it (using the one generation snapshot
349    /// that decided it) and yield the interrupted error; otherwise `None`. The
350    /// single checkpoint used everywhere a form can abort on a `C-g` before the
351    /// Wasm call.
352    fn check_interrupt(&mut self, id: &RequestId) -> Option<EvalFailure> {
353        let observed = self.interrupt.generation();
354        (observed > self.interrupt_ack).then(|| {
355            self.ack_interrupt(observed);
356            EvalFailure::Interrupted(id.clone())
357        })
358    }
359
360    async fn load_path(&mut self, path: &str) -> Result<String, EvalFailure> {
361        let id = RequestId::Int(0);
362        // The synchronous file read + parse aren't covered by `run`'s checks, so
363        // guard them explicitly; a `C-g` during a later form is caught by that
364        // form's `run`, and the failing form aborts the whole load via `?`.
365        if let Some(err) = self.check_interrupt(&id) {
366            return Err(err);
367        }
368        let source = std::fs::read_to_string(path).map_err(|err| {
369            EvalFailure::Eval(
370                id.clone(),
371                NomiError::Compile(format!("cannot read {path}: {err}")),
372            )
373        })?;
374        if let Some(err) = self.check_interrupt(&id) {
375            return Err(err);
376        }
377        let program = Reader::parse(&source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
378        let count = program.exprs.len();
379        for form in program.exprs {
380            self.run(&Request {
381                id: id.clone(),
382                form,
383            })
384            .await?;
385        }
386        Ok(format!("loaded {path} ({count} forms)"))
387    }
388
389    /// Evaluates a single already-parsed form (mREPL input). The interrupt
390    /// pre-start check lives in `run`.
391    async fn eval_one_form(&mut self, form: Expr) -> Result<Value, EvalFailure> {
392        self.run(&Request {
393            id: RequestId::Int(0),
394            form,
395        })
396        .await
397    }
398
399    async fn evaluate(&mut self, frame: &str) -> Result<Response, EvalFailure> {
400        let request = parse_request(frame).map_err(EvalFailure::Envelope)?;
401        let value = self.run(&request).await?;
402        Ok(Response {
403            id: request.id,
404            payload: ResponsePayload::Value(value),
405        })
406    }
407
408    async fn run(&mut self, request: &Request) -> Result<Value, EvalFailure> {
409        debug!(user_id = %self.ctx.user_id, "evaluating form");
410        // A `C-g` that landed before this form started (pre-armed, or during an
411        // earlier form of a load) aborts here.
412        if let Some(err) = self.check_interrupt(&request.id) {
413            return Err(err);
414        }
415        let program = Program::new(vec![request.form.clone()]);
416        let (bytes, result_ty) = self
417            .compiler
418            .compile_eval_with_type(&program, &mut self.symbols)
419            .map_err(|err| EvalFailure::Eval(request.id.clone(), err))?;
420        let module = self
421            .cache
422            .get_or_compile(&self.engine, &bytes)
423            .map_err(|err| EvalFailure::Engine(request.id.clone(), err))?;
424
425        let mut linker: Linker<SessionData> = Linker::new(&self.engine);
426        crate::natives::link(&mut linker).map_err(|err| {
427            EvalFailure::Engine(
428                request.id.clone(),
429                EngineError::Instantiate(err.to_string()),
430            )
431        })?;
432
433        let mut store: Store<SessionData> = Store::new(
434            &self.engine,
435            SessionData::new(self.ctx.clone(), Arc::clone(&self.output)),
436        );
437        store.set_fuel(self.ctx.limits.fuel).map_err(|err| {
438            EvalFailure::Engine(request.id.clone(), EngineError::Fuel(err.to_string()))
439        })?;
440        store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
441
442        let instance = linker
443            .instantiate_async(&mut store, &module)
444            .await
445            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
446        let func = instance.get_func(&mut store, "nomi-eval").ok_or_else(|| {
447            EvalFailure::Engine(
448                request.id.clone(),
449                EngineError::MissingExport("nomi-eval".into()),
450            )
451        })?;
452        // An interrupt that arrived during compile/link (the epoch bump only
453        // cancels a running call) — abort before entering Wasm.
454        if let Some(err) = self.check_interrupt(&request.id) {
455            return Err(err);
456        }
457        let mut results = [Val::AnyRef(None)];
458        let call_result = func.call_async(&mut store, &[], &mut results).await;
459        // The reader bumps the epoch AND signals the interrupt together on
460        // `(:emacs-interrupt)`. If a C-g arrived during THIS call it cancelled it
461        // (epoch trap → `call_result` is `Err`); attribute it to this request so
462        // it can't also abort the next one. We ack only on the error path, so a
463        // C-g just after a clean success stays pending for the next form. This
464        // covers every terminal exit uniformly (epoch cancel, the
465        // out-of-fuel/runtime-trap-wins-the-race case) with no per-exit cleanup.
466        //
467        // Attribution cutoff (deliberate): the ack snapshot is taken AFTER the
468        // call returns, so a C-g landing in the sub-µs window between the call
469        // returning `Err` and this load is attributed to THIS (failing) form,
470        // not the next one. That is correct REPL semantics — at that instant the
471        // failing form is still the in-flight request (its error hasn't reached
472        // the client), so the user can't yet have meant the C-g for a later
473        // form. A C-g pressed after the error is surfaced necessarily arrives
474        // after `run` returns, advancing the generation again, and the next
475        // form's pre-start `check_interrupt` catches it. The exact instant of
476        // call return is unobservable, so this boundary is irreducible, not a
477        // lost interrupt.
478        if call_result.is_err() {
479            let observed = self.interrupt.generation();
480            self.ack_interrupt(observed);
481        }
482        call_result
483            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
484
485        let any: Option<Rooted<AnyRef>> = match &results[0] {
486            Val::AnyRef(a) => *a,
487            _ => {
488                return Err(EvalFailure::Engine(
489                    request.id.clone(),
490                    EngineError::Trap("nomi-eval did not return anyref".into()),
491                ));
492            }
493        };
494        let captured = decode_eval_result(&mut store, any, result_ty).map_err(|err| {
495            EvalFailure::Engine(
496                request.id.clone(),
497                EngineError::Trap(format!("decoding nomi-eval result: {err}")),
498            )
499        })?;
500        Ok(Value::from(captured))
501    }
502}
503
504enum EvalFailure {
505    Envelope(EnvelopeError),
506    Eval(RequestId, NomiError),
507    Engine(RequestId, EngineError),
508    Interrupted(RequestId),
509}
510
511impl EvalFailure {
512    fn into_response(self) -> Response {
513        match self {
514            EvalFailure::Envelope(err) => Response {
515                id: RequestId::Int(0),
516                payload: ResponsePayload::Error {
517                    code: envelope_error_code(&err),
518                    message: err.to_string(),
519                    detail: Some(format!("{err:?}")),
520                },
521            },
522            EvalFailure::Eval(id, err) => Response {
523                id,
524                payload: ResponsePayload::Error {
525                    code: nomi_error_code(&err),
526                    message: err.to_string(),
527                    detail: Some(format!("{err:?}")),
528                },
529            },
530            EvalFailure::Engine(id, err) => Response {
531                id,
532                payload: ResponsePayload::Error {
533                    code: engine_error_code(&err),
534                    message: err.to_string(),
535                    detail: Some(format!("{err:?}")),
536                },
537            },
538            EvalFailure::Interrupted(id) => Response {
539                id,
540                payload: ResponsePayload::Error {
541                    code: ErrorCode::new(ErrorCode::INTERRUPTED),
542                    message: "evaluation interrupted before start".into(),
543                    detail: None,
544                },
545            },
546        }
547    }
548}
549
550fn envelope_error_code(err: &EnvelopeError) -> ErrorCode {
551    let symbol = match err {
552        EnvelopeError::Parse(_) => ErrorCode::PARSE,
553        EnvelopeError::NotSingleExpr
554        | EnvelopeError::NotPlist
555        | EnvelopeError::MissingKey(_)
556        | EnvelopeError::InvalidValue(_, _) => ErrorCode::ARGS,
557    };
558    ErrorCode::new(symbol)
559}
560
561fn nomi_error_code(err: &NomiError) -> ErrorCode {
562    let symbol = match err {
563        NomiError::Parse(_) => ErrorCode::PARSE,
564        NomiError::Compile(_) | NomiError::UndefinedSymbol(_) => ErrorCode::COMPILE,
565        NomiError::Runtime(_) => ErrorCode::RUNTIME,
566        NomiError::Type { .. } | NomiError::Arity { .. } => ErrorCode::ARGS,
567    };
568    ErrorCode::new(symbol)
569}
570
571fn engine_error_code(err: &EngineError) -> ErrorCode {
572    match err {
573        EngineError::Compile(_) => ErrorCode::new(ErrorCode::COMPILE),
574        EngineError::OutOfFuel | EngineError::Trap(_) => ErrorCode::new(ErrorCode::RUNTIME),
575        EngineError::EpochInterrupt => ErrorCode::new(ErrorCode::INTERRUPTED),
576        EngineError::Instantiate(_) | EngineError::MissingExport(_) => {
577            ErrorCode::new(ErrorCode::SERVER)
578        }
579        EngineError::Fuel(_) | EngineError::Config(_) | EngineError::CachePoisoned => {
580            ErrorCode::new(ErrorCode::SERVER)
581        }
582        EngineError::NoConversion(_) => ErrorCode::new(ErrorCode::NO_CONVERSION),
583        // Commodity mismatch now arrives as a `ScriptRaised` carrying the
584        // reader-folded symbol `COMMODITY-MISMATCH` (ADR-0026): it `throw`s
585        // `$nomi_error` in-guest and the boundary wrapper bridges the uncaught
586        // throw to `__nomi_raise`. The code flows through verbatim like any
587        // script raise; the wire `:code` is the upper-cased symbol form.
588        EngineError::ScriptRaised { code, .. } => ErrorCode::new(code.clone()),
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use nomiscript::{Fraction, Reader};
596
597    async fn handle_form_smoke(frame: &str) -> String {
598        let ctx = ScriptCtx::new(uuid::Uuid::nil());
599        let mut session = Session::new(ctx).expect("Session::new");
600        session.handle_form(frame).await
601    }
602
603    fn parse_to_value(input: &str) -> Result<Value, NomiError> {
604        let program = Reader::parse(input)?;
605        let mut symbols = SymbolTable::with_builtins();
606        nomiscript::eval_program(&mut symbols, &program)
607    }
608
609    #[tokio::test]
610    async fn evaluates_arithmetic_and_returns_value_envelope() {
611        let response = handle_form_smoke("(:id 1 :form (+ 1 2))").await;
612        assert_eq!(response, "(:id 1 :value 3)");
613    }
614
615    #[tokio::test]
616    async fn evaluates_nested_arithmetic() {
617        let response = handle_form_smoke("(:id 5 :form (* (+ 1 2) (- 10 4)))").await;
618        assert_eq!(response, "(:id 5 :value 18)");
619    }
620
621    #[tokio::test]
622    async fn print_in_eval_mode_does_not_panic() {
623        // Regression: PRINT / DISPLAY / NEWLINE / DEBUG lower to `env.log`,
624        // which was script-mode-only. In eval mode the missing func index used
625        // to SIGABRT the compiler (`registry.rs` `HashMap[key]`). The eval-mode
626        // `log` import + the rpc `env.log` host fn now make it compile + run.
627        let response = handle_form_smoke("(:id 1 :form (print \"hi\"))").await;
628        assert!(response.contains(":id 1"), "got: {response}");
629        assert!(!response.contains(":code"), "must not error: {response}");
630    }
631
632    #[tokio::test]
633    async fn dolist_with_print_in_eval_mode_runs() {
634        // The exact shape from the Metro script that first surfaced the panic.
635        let response = handle_form_smoke("(:id 2 :form (dolist (x (list 1 2 3)) (print x)))").await;
636        assert!(response.contains(":id 2"), "got: {response}");
637        assert!(!response.contains(":code"), "must not error: {response}");
638    }
639
640    #[tokio::test]
641    async fn handle_request_captures_output_and_value() {
642        // `(print "hi")` writes "hi" to the per-request buffer (via env.log) and
643        // its own return value is nil; the structured outcome must carry the
644        // captured text in `output` AND the value payload — the SLYNK mREPL
645        // renders these as `:write-string` + `:write-values` respectively.
646        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
647        let outcome = session.handle_request("(print \"hi\")").await;
648        assert!(
649            outcome.output.contains("hi"),
650            "captured output should contain the printed text, got: {:?}",
651            outcome.output
652        );
653        assert!(
654            matches!(outcome.payload, ResponsePayload::Value(_)),
655            "payload should be a Value, got: {:?}",
656            outcome.payload
657        );
658    }
659
660    #[tokio::test]
661    async fn handle_request_value_only_has_empty_output() {
662        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
663        let outcome = session.handle_request("(+ 1 2)").await;
664        assert!(outcome.output.is_empty(), "got: {:?}", outcome.output);
665        assert_eq!(
666            outcome.payload,
667            ResponsePayload::Value(Value::Number(Fraction::from_integer(3)))
668        );
669    }
670
671    #[tokio::test]
672    async fn handle_request_rejects_plist_shaped_injection() {
673        // Adversarial review: the source must be parsed as a standalone AST, not
674        // interpolated into a `(:id 0 :form …)` envelope string — otherwise
675        // `1 :form (+ 2 3)` would re-read as a plist and eval to `1`. It must
676        // instead error (more than one top-level form), never silently return 1.
677        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
678        let outcome = session.handle_request("1 :form (+ 2 3)").await;
679        assert!(
680            matches!(outcome.payload, ResponsePayload::Error { .. }),
681            "plist-shaped input must error, got: {:?}",
682            outcome.payload
683        );
684    }
685
686    #[tokio::test]
687    async fn handle_request_interrupt_latch_aborts_before_eval() {
688        // An interrupt latched before the request (the SLYNK reader arms it on
689        // `(:emacs-interrupt)`) must abort the eval at the pre-start check, even
690        // for a form that would otherwise compile fine.
691        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
692        session.interrupt_handle().interrupt();
693        let outcome = session.handle_request("(+ 1 2)").await;
694        match outcome.payload {
695            ResponsePayload::Error { code, .. } => {
696                assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED);
697            }
698            other => panic!("expected interrupted error, got: {other:?}"),
699        }
700    }
701
702    #[tokio::test]
703    async fn handle_file_evaluates_all_forms_and_persists_state() {
704        // A file with a defun + a call to it: state accumulates across forms,
705        // and the load returns a summary value (not the last form's value).
706        let dir = std::env::temp_dir();
707        let path = dir.join(format!("nms_load_test_{}.nms", std::process::id()));
708        std::fs::write(&path, "(defun dbl (x) (* x 2))\n(dbl 21)\n").unwrap();
709        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
710        let outcome = session.handle_file(path.to_str().unwrap()).await;
711        std::fs::remove_file(&path).ok();
712        match outcome.payload {
713            ResponsePayload::Value(Value::String(s)) => {
714                assert!(s.contains("loaded"), "summary: {s}");
715                assert!(s.contains("2 forms"), "summary: {s}");
716            }
717            other => panic!("expected a load summary string, got: {other:?}"),
718        }
719    }
720
721    #[tokio::test]
722    async fn handle_file_missing_path_errors() {
723        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
724        let outcome = session.handle_file("/no/such/nms/file.nms").await;
725        assert!(
726            matches!(outcome.payload, ResponsePayload::Error { .. }),
727            "got: {:?}",
728            outcome.payload
729        );
730    }
731
732    #[tokio::test]
733    async fn handle_file_aborts_on_a_bad_form() {
734        let dir = std::env::temp_dir();
735        let path = dir.join(format!("nms_load_bad_{}.nms", std::process::id()));
736        std::fs::write(&path, "(+ 1 2)\n(undefined-symbol-here)\n").unwrap();
737        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
738        let outcome = session.handle_file(path.to_str().unwrap()).await;
739        std::fs::remove_file(&path).ok();
740        assert!(
741            matches!(outcome.payload, ResponsePayload::Error { .. }),
742            "a bad form must abort the load, got: {:?}",
743            outcome.payload
744        );
745    }
746
747    #[tokio::test]
748    async fn handle_file_honours_interrupt_armed_before_load() {
749        // A `C-g` that lands before/during the synchronous read+parse must abort
750        // the load (the pre-read latch check), not be ignored until the first
751        // form reaches the Wasm call.
752        let dir = std::env::temp_dir();
753        let path = dir.join(format!("nms_load_intr_{}.nms", std::process::id()));
754        std::fs::write(&path, "(+ 1 2)\n(+ 3 4)\n").unwrap();
755        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
756        session.interrupt_handle().interrupt();
757        let outcome = session.handle_file(path.to_str().unwrap()).await;
758        std::fs::remove_file(&path).ok();
759        match outcome.payload {
760            ResponsePayload::Error { code, .. } => {
761                assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED, "got: {code:?}");
762            }
763            other => panic!("interrupt should abort the load, got: {other:?}"),
764        }
765    }
766
767    #[tokio::test]
768    async fn handle_request_surfaces_error_payload() {
769        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
770        let outcome = session.handle_request("does-not-exist").await;
771        assert!(
772            matches!(outcome.payload, ResponsePayload::Error { .. }),
773            "payload should be an Error, got: {:?}",
774            outcome.payload
775        );
776    }
777
778    #[tokio::test]
779    async fn handle_request_clears_output_between_calls() {
780        // The buffer must not leak across requests: a print then a pure value.
781        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
782        let _ = session.handle_request("(print \"first\")").await;
783        let second = session.handle_request("(+ 1 1)").await;
784        assert!(
785            second.output.is_empty(),
786            "output leaked from prior request: {:?}",
787            second.output
788        );
789    }
790
791    #[tokio::test]
792    async fn returns_value_for_literal_form() {
793        let response = handle_form_smoke("(:id 9 :form 42)").await;
794        assert_eq!(response, "(:id 9 :value 42)");
795    }
796
797    #[tokio::test]
798    async fn returns_value_for_string_literal() {
799        let response = handle_form_smoke("(:id 9 :form \"hello\")").await;
800        assert_eq!(response, "(:id 9 :value \"hello\")");
801    }
802
803    #[test]
804    fn round_trips_bytes_through_eval() {
805        let value = parse_to_value("'#u8(1 2 3)").unwrap();
806        assert_eq!(value, Value::Bytes(vec![1, 2, 3]));
807    }
808
809    #[tokio::test]
810    async fn bad_envelope_emits_envelope_error() {
811        let response = handle_form_smoke("(:form (+ 1 2))").await;
812        assert!(response.contains(":code args"));
813        assert!(response.contains(":id 0"));
814    }
815
816    #[tokio::test]
817    async fn malformed_envelope_emits_parse_error() {
818        let response = handle_form_smoke("(((((").await;
819        assert!(response.contains(":code parse"));
820    }
821
822    #[tokio::test]
823    async fn undefined_symbol_emits_compile_error() {
824        let response = handle_form_smoke("(:id 7 :form does-not-exist)").await;
825        assert!(response.contains(":id 7"));
826        assert!(response.contains(":code compile"));
827    }
828
829    #[tokio::test]
830    async fn user_function_arity_violation_emits_args_error() {
831        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
832        let _ = session
833            .handle_form("(:id 1 :form (defun id-fn (x) x))")
834            .await;
835        let response = session.handle_form("(:id 2 :form (id-fn))").await;
836        assert!(response.contains(":id 2"));
837        assert!(response.contains(":code args"));
838    }
839
840    #[test]
841    fn completions_match_case_insensitively_and_skip_internal() {
842        let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
843        // Lower-case input matches the upper-case folded symbol; the canonical
844        // upper-case name is returned.
845        let defuns = session.completions("def");
846        assert!(defuns.contains(&"DEFUN".to_string()), "got: {defuns:?}");
847        assert!(
848            defuns.iter().all(|n| n.starts_with("DEF")),
849            "got: {defuns:?}"
850        );
851        // Sorted; no internal `$`/`__` or `(SETF …)` place names.
852        let all = session.completions("");
853        assert!(all.windows(2).all(|w| w[0] <= w[1]), "must be sorted");
854        assert!(
855            all.iter()
856                .all(|n| !n.starts_with('$') && !n.starts_with("__") && !n.starts_with("(SETF")),
857            "internal/setf symbols must be filtered: {all:?}"
858        );
859    }
860
861    #[tokio::test]
862    async fn completions_include_a_user_defined_symbol() {
863        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
864        let _ = session
865            .handle_form("(:id 1 :form (defun my-helper (x) x))")
866            .await;
867        // The defun's name is folded to upper-case; a lower-case prefix finds it.
868        let hits = session.completions("my-");
869        assert!(hits.contains(&"MY-HELPER".to_string()), "got: {hits:?}");
870    }
871
872    #[test]
873    fn completions_unknown_prefix_is_empty() {
874        let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
875        assert!(session.completions("zzz-no-such-symbol-").is_empty());
876    }
877
878    #[tokio::test]
879    async fn interrupt_before_form_short_circuits_with_interrupted() {
880        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
881        let handle = session.interrupt_handle();
882        handle.interrupt();
883        let response = session.handle_form("(:id 11 :form (+ 1 2))").await;
884        assert!(response.contains(":id 11"));
885        assert!(response.contains(":code interrupted"));
886    }
887
888    #[tokio::test]
889    async fn coalesced_interrupts_abort_one_form_each_in_order() {
890        // Two `C-g`s observed together before a form cancel THAT form and are
891        // coalesced (they do not pre-arm cancellation of a later one); a fresh,
892        // distinct `C-g` later still cancels the next form. This pins the REPL
893        // contract against both a "lost interrupt" and an "over-eager next-form
894        // abort" regression.
895        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
896        let handle = session.interrupt_handle();
897        handle.interrupt();
898        handle.interrupt(); // two presses before the form
899        let first = session.handle_form("(:id 60 :form (+ 1 2))").await;
900        assert!(
901            first.contains(":code interrupted"),
902            "first form must abort: {first}"
903        );
904        // Both presses were coalesced into the one abort — the next form is clean.
905        let second = session.handle_form("(:id 61 :form (+ 1 2))").await;
906        assert_eq!(
907            second, "(:id 61 :value 3)",
908            "next form coalesced-poisoned: {second}"
909        );
910        // A fresh, distinct press still aborts the following form.
911        handle.interrupt();
912        let third = session.handle_form("(:id 62 :form (+ 1 2))").await;
913        assert!(
914            third.contains(":code interrupted"),
915            "a distinct later interrupt must still abort: {third}"
916        );
917    }
918
919    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
920    async fn inflight_interrupt_does_not_poison_next_form() {
921        // The SLYNK reader arms BOTH the epoch bump and the interrupt signal on
922        // `(:emacs-interrupt)` (mod.rs). When that lands mid-eval the epoch trap
923        // cancels the running call; that interrupt generation must then be acked
924        // so the SAME `C-g` can't also abort the next request.
925        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
926        let bumper = session.epoch_bumper();
927        let interrupt = session.interrupt_handle();
928        let cancel_task = tokio::spawn(async move {
929            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
930            bumper.bump();
931            interrupt.interrupt();
932        });
933        let cancelled = session
934            .handle_form("(:id 30 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
935            .await;
936        cancel_task.await.unwrap();
937        assert!(
938            cancelled.contains(":code interrupted") || cancelled.contains(":code runtime"),
939            "in-flight eval should have been cancelled: {cancelled}"
940        );
941        // The next form must evaluate normally — the acked interrupt must not abort it.
942        let next = session.handle_form("(:id 31 :form (+ 1 2))").await;
943        assert_eq!(next, "(:id 31 :value 3)", "next form was poisoned: {next}");
944    }
945
946    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
947    async fn non_interrupt_failure_still_consumes_a_concurrent_interrupt() {
948        // Race: an interrupt is signalled WHILE a form is in flight, but the
949        // form loses to its own terminal error (out-of-fuel) before any epoch
950        // bump could win. The in-flight form is finished, so the interrupt must
951        // be acked on the error path too — otherwise it poisons the next form.
952        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
953        let interrupt = session.interrupt_handle();
954        let latch_task = tokio::spawn(async move {
955            // Latch only (no epoch bump): the form trips out-of-fuel on its own.
956            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
957            interrupt.interrupt();
958        });
959        let failed = session
960            .handle_form("(:id 50 :form (do ((i 0 (+ i 1))) ((>= i 100000000) i)))")
961            .await;
962        latch_task.await.unwrap();
963        assert!(
964            failed.contains(":code runtime") || failed.contains(":code interrupted"),
965            "in-flight form should fail terminally: {failed}"
966        );
967        let next = session.handle_form("(:id 51 :form (+ 1 2))").await;
968        assert_eq!(next, "(:id 51 :value 3)", "next form was poisoned: {next}");
969    }
970
971    #[tokio::test]
972    async fn interrupt_after_clean_eval_aborts_next_form() {
973        // The mirror of the above: after a form completes NORMALLY, an interrupt
974        // armed before the next form must still abort it. The in-flight cleanup
975        // only consumes the latch on an actual epoch-interrupt trap, so a `C-g`
976        // that lands post-completion is NOT swallowed.
977        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
978        let clean = session.handle_form("(:id 40 :form (+ 1 2))").await;
979        assert_eq!(clean, "(:id 40 :value 3)");
980        session.interrupt_handle().interrupt();
981        let interrupted = session.handle_form("(:id 41 :form (+ 4 5))").await;
982        assert!(
983            interrupted.contains(":code interrupted"),
984            "post-completion interrupt must abort the next form: {interrupted}"
985        );
986    }
987
988    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
989    async fn epoch_bumper_cancels_inflight_long_eval() {
990        // Concurrent cancel scenario: one task drives a CPU-bound eval,
991        // another task bumps the epoch shortly after. Verifies the
992        // engine clone really does share state across threads so emacs
993        // C-g can land mid-eval.
994        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
995        let bumper = session.epoch_bumper();
996        let bump_task = tokio::spawn(async move {
997            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
998            bumper.bump();
999        });
1000        // Tight loop that'd otherwise exhaust default fuel before
1001        // returning. Either fuel or epoch will fire — the assert below
1002        // tolerates both possibilities by checking the response is an
1003        // error envelope, but in practice the 20ms bump arrives first.
1004        let response = session
1005            .handle_form("(:id 22 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
1006            .await;
1007        bump_task.await.unwrap();
1008        assert!(response.contains(":id 22"), "{response}");
1009        assert!(
1010            response.contains(":code interrupted") || response.contains(":code runtime"),
1011            "{response}"
1012        );
1013    }
1014
1015    #[test]
1016    fn host_prelude_helper_is_loaded_and_compiles() {
1017        // ADR-0029 host-dependent prelude: split:list-for-transaction is loaded
1018        // on the Session path (after register_host_fns) and is callable. We
1019        // compile a form referencing it — proving load + name resolution + the
1020        // qualified native dispatch wire up — WITHOUT running it (it bottoms out
1021        // in the DB-backed list-splits-by-transaction native; execution is
1022        // covered by the db-gated integration test).
1023        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1024        assert!(
1025            session.symbols.contains("SPLIT:LIST-FOR-TRANSACTION"),
1026            "host prelude helper not registered"
1027        );
1028        // Compile (not run) a real call: lowering emits the qualified-name
1029        // dispatch + the native import, proving resolution wires up. Host fns
1030        // never execute at compile time, so no DB is touched.
1031        let program =
1032            Reader::parse("(split:list-for-transaction (car (list-transactions)))").expect("parse");
1033        if let Err(e) = session
1034            .compiler
1035            .compile_eval_with_type(&program, &mut session.symbols)
1036        {
1037            panic!("host prelude helper failed to compile: {e:?}");
1038        }
1039    }
1040
1041    #[tokio::test]
1042    async fn car_of_quoted_constant_list_compiles_on_eval_path() {
1043        // Regression: CAR/CDR of a quoted CONSTANT list folds on the codegen
1044        // path but used to trap on the eval-with-type (Session) path because
1045        // the stack handler called compile_for_stack on the quoted arg, which
1046        // has no Quote arm. The stack handlers now const-fold first.
1047        let response = handle_form_smoke("(:id 1 :form (car '(1 2 3)))").await;
1048        assert_eq!(response, "(:id 1 :value 1)");
1049    }
1050
1051    #[tokio::test]
1052    async fn car_of_quoted_heterogeneous_list_compiles_on_eval_path() {
1053        // Mixed number + string quoted list — the element is extracted by fold.
1054        let response = handle_form_smoke("(:id 1 :form (car '(7 \"x\")))").await;
1055        assert_eq!(response, "(:id 1 :value 7)");
1056    }
1057
1058    #[tokio::test]
1059    async fn car_of_cdr_of_quoted_constant_compiles_on_eval_path() {
1060        // CDR folds to the quoted tail, then CAR extracts its head — exercises
1061        // the cdr fold-first path feeding car.
1062        let response = handle_form_smoke("(:id 1 :form (car (cdr '(1 2 3))))").await;
1063        assert_eq!(response, "(:id 1 :value 2)");
1064    }
1065
1066    #[tokio::test]
1067    async fn cdr_of_quoted_constant_renders_tail_on_eval_path() {
1068        // The bare-CDR case (no enclosing CAR): folds to a quoted tail and
1069        // renders to its printed form rather than trapping.
1070        assert_eq!(
1071            handle_form_smoke("(:id 1 :form (cdr '(1 2 3)))").await,
1072            "(:id 1 :value \"(2 3)\")"
1073        );
1074        assert_eq!(
1075            handle_form_smoke("(:id 1 :form (cdr '(1)))").await,
1076            "(:id 1 :value NIL)"
1077        );
1078    }
1079
1080    #[tokio::test]
1081    async fn car_of_quoted_compound_and_symbol_heads_render_as_data() {
1082        // A compound or symbol head is quoted DATA, not code — it renders to
1083        // its printed form (not resolved as a call / variable).
1084        assert_eq!(
1085            handle_form_smoke("(:id 1 :form (car '((1 2) 3)))").await,
1086            "(:id 1 :value \"(1 2)\")"
1087        );
1088        assert_eq!(
1089            handle_form_smoke("(:id 1 :form (car '(x y)))").await,
1090            "(:id 1 :value \"X\")"
1091        );
1092    }
1093
1094    #[tokio::test]
1095    async fn reverse_of_constant_list_renders_on_eval_path() {
1096        // Regression (same class as CAR/CDR): REVERSE of a constant or
1097        // runtime-builder list folded but the stack handler rejected the
1098        // non-runtime-pair result on the eval-with-type path. Now it renders
1099        // the reversed datum on both surfaces.
1100        assert_eq!(
1101            handle_form_smoke("(:id 1 :form (reverse '(1 2 3)))").await,
1102            "(:id 1 :value \"(3 2 1)\")"
1103        );
1104        assert_eq!(
1105            handle_form_smoke("(:id 1 :form (reverse (list 1 2 3)))").await,
1106            "(:id 1 :value \"(3 2 1)\")"
1107        );
1108        // Composition still folds through to the element.
1109        assert_eq!(
1110            handle_form_smoke("(:id 1 :form (car (reverse '(1 2 3))))").await,
1111            "(:id 1 :value 3)"
1112        );
1113    }
1114
1115    #[tokio::test]
1116    async fn cons_onto_constant_list_renders_on_eval_path() {
1117        // Regression: CONS with a constant / runtime-builder list cdr trapped
1118        // in push_pair_cdr on the eval-with-type path. A fully-constant cons
1119        // now folds and renders the list datum; a dotted pair renders too.
1120        assert_eq!(
1121            handle_form_smoke("(:id 1 :form (cons 0 '(1 2 3)))").await,
1122            "(:id 1 :value \"(0 1 2 3)\")"
1123        );
1124        assert_eq!(
1125            handle_form_smoke("(:id 1 :form (cons 0 (list 1 2 3)))").await,
1126            "(:id 1 :value \"(0 1 2 3)\")"
1127        );
1128        assert_eq!(
1129            handle_form_smoke("(:id 1 :form (cons 1 2))").await,
1130            "(:id 1 :value \"(1 . 2)\")"
1131        );
1132    }
1133
1134    #[tokio::test]
1135    async fn append_of_constant_lists_renders_on_eval_path() {
1136        // Regression: all-constant APPEND folds to a quoted list; the stack
1137        // handler used to force it through runtime materialization (which can't
1138        // represent symbols) instead of rendering the folded datum.
1139        assert_eq!(
1140            handle_form_smoke("(:id 1 :form (append '(1 2) '(3)))").await,
1141            "(:id 1 :value \"(1 2 3)\")"
1142        );
1143        assert_eq!(
1144            handle_form_smoke("(:id 1 :form (append '(a b) '(c)))").await,
1145            "(:id 1 :value \"(A B C)\")"
1146        );
1147    }
1148
1149    #[tokio::test]
1150    async fn universal_prelude_helper_runs_end_to_end() {
1151        // The universal prelude is loaded on the Session path too; a math:*
1152        // helper executes through wasm and returns its value. No DB.
1153        let response = handle_form_smoke("(:id 9 :form (math:square 9))").await;
1154        assert_eq!(response, "(:id 9 :value 81)");
1155    }
1156
1157    #[tokio::test]
1158    async fn pp_form_at_value_position_returns_string() {
1159        // Exercises compile_pp_for_stack — the path nms / emacs see
1160        // when `(pp 42)` is the request form.
1161        let resp = handle_form_smoke("(:id 7 :form (pp 42))").await;
1162        assert!(resp.contains(":id 7"), "{resp}");
1163        assert!(resp.contains("\"42\""), "{resp}");
1164    }
1165
1166    #[tokio::test]
1167    async fn describe_form_at_value_position_returns_doc() {
1168        // compile_describe_for_stack — same shape.
1169        let resp = handle_form_smoke("(:id 8 :form (describe '+))").await;
1170        assert!(resp.contains(":id 8"), "{resp}");
1171        assert!(!resp.contains(":error"), "{resp}");
1172    }
1173
1174    #[tokio::test]
1175    async fn apropos_form_at_value_position_returns_list() {
1176        let resp = handle_form_smoke("(:id 9 :form (apropos \"entity\"))").await;
1177        assert!(resp.contains(":id 9"), "{resp}");
1178        assert!(!resp.contains(":error"), "{resp}");
1179    }
1180
1181    #[tokio::test]
1182    async fn deftest_form_at_value_position_returns_quoted_name() {
1183        let resp = handle_form_smoke("(:id 10 :form (deftest sanity (assert-equal 1 1)))").await;
1184        assert!(resp.contains(":id 10"), "{resp}");
1185        assert!(!resp.contains(":error"), "{resp}");
1186    }
1187
1188    #[tokio::test]
1189    async fn assert_equal_pass_form_at_value_position() {
1190        let resp = handle_form_smoke("(:id 11 :form (assert-equal 2 2))").await;
1191        assert!(resp.contains(":id 11"), "{resp}");
1192        assert!(!resp.contains(":error"), "{resp}");
1193    }
1194
1195    #[tokio::test]
1196    async fn assert_equal_fail_surfaces_as_error() {
1197        // assert_equal returns Err on mismatch; Session maps to
1198        // :code compile error envelope (it's a NomiError::Compile).
1199        let resp = handle_form_smoke("(:id 12 :form (assert-equal 1 2))").await;
1200        assert!(resp.contains(":id 12"), "{resp}");
1201        assert!(resp.contains(":error"), "{resp}");
1202    }
1203
1204    #[tokio::test]
1205    async fn coverage_dump_lists_called_natives() {
1206        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1207        let _ = session
1208            .handle_form("(:id 1 :form (rpc-protocol-version))")
1209            .await;
1210        let dump = session.handle_form("(:id 2 :form (coverage-dump))").await;
1211        assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
1212        assert!(dump.contains(":id 2"), "{dump}");
1213    }
1214
1215    #[tokio::test]
1216    async fn coverage_dump_reports_pre_warmed_natives() {
1217        // Session::new pre-compiles every zero-arg native fn (phase 4
1218        // fast-path stubs), so coverage-dump is non-empty even before
1219        // a user form lands. This is the desired semantic: it
1220        // reflects compile-time reference counts, including pre-warm
1221        // compilations, which is what the parity contract gates on.
1222        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1223        let dump = session.handle_form("(:id 1 :form (coverage-dump))").await;
1224        assert!(dump.contains(":id 1"), "{dump}");
1225        assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
1226    }
1227
1228    #[tokio::test]
1229    async fn interrupt_does_not_persist_across_forms() {
1230        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1231        session.interrupt_handle().interrupt();
1232        let _ = session.handle_form("(:id 11 :form (+ 1 2))").await;
1233        let response = session.handle_form("(:id 12 :form (+ 1 2))").await;
1234        assert_eq!(response, "(:id 12 :value 3)");
1235    }
1236
1237    #[tokio::test]
1238    async fn session_state_persists_across_forms() {
1239        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1240        let defun = session
1241            .handle_form("(:id 1 :form (defun double (x) (* 2 x)))")
1242            .await;
1243        assert!(defun.contains(":id 1"));
1244        let call = session.handle_form("(:id 2 :form (double 21))").await;
1245        assert_eq!(call, "(:id 2 :value 42)");
1246    }
1247
1248    #[tokio::test]
1249    async fn fraction_results_format_canonically() {
1250        // A fractional (Scalar) literal renders canonically as `n/d`. (Integer
1251        // `(/ 1 4)` is now Index division → 0 per ADR-0028; the canonical
1252        // fraction idiom is the `1/4` Scalar literal.)
1253        let response = handle_form_smoke("(:id 3 :form 1/4)").await;
1254        assert_eq!(response, "(:id 3 :value 1/4)");
1255    }
1256
1257    #[test]
1258    fn nomi_runtime_value_carries_through() {
1259        let value = parse_to_value("(+ 0.5 0.25)").unwrap();
1260        assert_eq!(value, Value::Number(Fraction::new(3, 4)));
1261    }
1262
1263    #[tokio::test]
1264    async fn calls_meta_native_from_nomiscript_source() {
1265        let response = handle_form_smoke("(:id 1 :form (rpc-protocol-version))").await;
1266        let expected_version = crate::natives::meta::PROTOCOL_VERSION;
1267        assert_eq!(response, format!("(:id 1 :value {expected_version})"));
1268    }
1269
1270    #[tokio::test]
1271    async fn calls_server_get_version_from_nomiscript_source() {
1272        let response = handle_form_smoke("(:id 1 :form (get-version))").await;
1273        // GIT_HASH is baked at server crate build time via env!. We don't
1274        // assert its exact value (changes per build) — just that the
1275        // envelope round-trips a non-empty :value string.
1276        assert!(
1277            response.starts_with("(:id 1 :value \""),
1278            "expected string response, got: {response}"
1279        );
1280        assert!(response.ends_with("\")"));
1281    }
1282
1283    #[tokio::test]
1284    async fn calls_server_get_build_date_from_nomiscript_source() {
1285        let response = handle_form_smoke("(:id 2 :form (get-build-date))").await;
1286        assert!(
1287            response.starts_with("(:id 2 :value \""),
1288            "expected string response, got: {response}"
1289        );
1290        assert!(response.ends_with("\")"));
1291    }
1292
1293    #[tokio::test]
1294    async fn cons_list_surfaces_as_printable_string() {
1295        // First WasmGC sub-slice: eval-mode `(cons ...)` chains now
1296        // capture through pending_string instead of erroring at compile
1297        // time. The result is a textual `(1 2 3)` value the emacs client
1298        // can (read) back into a real list. Heterogeneous car types ride
1299        // a follow-up slice once Pair/Vector/Closure/Struct share a
1300        // tagged union — today's cons cell stores i32 payloads only.
1301        let response = handle_form_smoke("(:id 12 :form (cons 1 (cons 2 (cons 3 nil))))").await;
1302        assert!(
1303            response.contains(":value \"(1 2 3)\""),
1304            "expected :value \"(1 2 3)\", got: {response}"
1305        );
1306    }
1307
1308    #[tokio::test]
1309    async fn count_native_cannot_mix_with_ratio_arithmetic() {
1310        // account-count returns i32 (a count / Index, not a Scalar). Mixing it
1311        // with a fractional Scalar literal must fail to compile — the design
1312        // forbids accidental arithmetic across the Index/Scalar strata. (An
1313        // integer literal like `10` is itself an Index now (ADR-0028), so
1314        // `(+ 10 (account-count))` is valid Index arithmetic; the genuine
1315        // stratum clash needs a fractional `1/2` Scalar operand.) The explicit
1316        // `index->scalar` bridge is the only legal crossing.
1317        let response = handle_form_smoke("(:id 11 :form (+ 1/2 (account-count)))").await;
1318        assert!(response.contains(":code compile"), "got: {response}");
1319        assert!(
1320            response.contains("scalar") && response.contains("index"),
1321            "expected Index/Scalar stratum-separation error, got: {response}"
1322        );
1323    }
1324
1325    #[tokio::test]
1326    async fn get_commodity_with_non_uuid_arg_falls_back_to_symbol_lookup() {
1327        // get-commodity now accepts a uuid OR a symbol (mirroring get-account's
1328        // name fallback), so a non-uuid arg is NO LONGER an "invalid uuid"
1329        // error — it's treated as a symbol and routed to a DB lookup. On this
1330        // no-DB smoke harness that lookup surfaces a runtime DB-access error
1331        // (not a parse error); a DB-backed test
1332        // (`get_commodity_resolves_by_symbol` in tests-integration) covers the
1333        // successful resolution.
1334        let response = handle_form_smoke("(:id 9 :form (get-commodity \"USD\"))").await;
1335        assert!(response.contains(":id 9"), "got: {response}");
1336        assert!(
1337            response.contains(":code runtime") && response.contains("get-commodity"),
1338            "expected a get-commodity runtime error (symbol path hits the DB), got: {response}"
1339        );
1340        assert!(
1341            !response.contains("invalid uuid"),
1342            "a non-uuid arg must no longer short-circuit as an invalid-uuid error: {response}"
1343        );
1344    }
1345
1346    #[test]
1347    fn meta_native_unknown_in_script_mode_compile() {
1348        // host_fns are only registered in eval-mode contexts; the compiler
1349        // built without with_host_fns shouldn't see them. Sanity that the
1350        // mode flag actually gates the registration.
1351        use nomiscript::CompileMode;
1352        let mut compiler = Compiler::new();
1353        let mut symbols = SymbolTable::with_builtins();
1354        let program = nomiscript::Reader::parse("(rpc-protocol-version)").unwrap();
1355        let result = compiler.compile_with_mode(&program, &mut symbols, CompileMode::Script);
1356        assert!(
1357            result.is_err(),
1358            "host fn should not be callable when compiler has no specs"
1359        );
1360    }
1361}