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
22pub struct SessionData {
34 ctx: ScriptCtx,
35 output: Arc<Mutex<String>>,
41 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 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 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 #[must_use]
90 pub fn into_draft(self) -> Option<crate::draft::TransactionDraft> {
91 self.draft.map(RefCell::into_inner)
92 }
93
94 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
104pub struct Session {
114 ctx: ScriptCtx,
115 engine: Engine,
116 compiler: Compiler,
117 cache: ModuleCache,
118 symbols: SymbolTable,
119 interrupt: InterruptHandle,
120 interrupt_ack: u64,
126 output: Arc<Mutex<String>>,
129}
130
131#[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 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 session.warm_bare_call_cache(&host_fns);
176 Ok(session)
177 }
178
179 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 #[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 #[must_use]
247 pub fn epoch_bumper(&self) -> EpochBumper {
248 EpochBumper::new(self.engine.clone())
249 }
250
251 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 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 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 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 fn ack_interrupt(&mut self, observed: u64) {
343 if observed > self.interrupt_ack {
344 self.interrupt_ack = observed;
345 }
346 }
347
348 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 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 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 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 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 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 EngineError::ScriptRaised { code, .. } => ErrorCode::new(code.clone()),
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::ctx::ScriptLimits;
596 use nomiscript::{Fraction, Reader};
597
598 async fn handle_form_smoke(frame: &str) -> String {
599 let ctx = ScriptCtx::new(uuid::Uuid::nil());
600 let mut session = Session::new(ctx).expect("Session::new");
601 session.handle_form(frame).await
602 }
603
604 fn parse_to_value(input: &str) -> Result<Value, NomiError> {
605 let program = Reader::parse(input)?;
606 let mut symbols = SymbolTable::with_builtins();
607 nomiscript::eval_program(&mut symbols, &program)
608 }
609
610 #[tokio::test]
611 async fn evaluates_arithmetic_and_returns_value_envelope() {
612 let response = handle_form_smoke("(:id 1 :form (+ 1 2))").await;
613 assert_eq!(response, "(:id 1 :value 3)");
614 }
615
616 #[tokio::test]
617 async fn evaluates_nested_arithmetic() {
618 let response = handle_form_smoke("(:id 5 :form (* (+ 1 2) (- 10 4)))").await;
619 assert_eq!(response, "(:id 5 :value 18)");
620 }
621
622 #[tokio::test]
623 async fn print_in_eval_mode_does_not_panic() {
624 let response = handle_form_smoke("(:id 1 :form (print \"hi\"))").await;
629 assert!(response.contains(":id 1"), "got: {response}");
630 assert!(!response.contains(":code"), "must not error: {response}");
631 }
632
633 #[tokio::test]
634 async fn dolist_with_print_in_eval_mode_runs() {
635 let response = handle_form_smoke("(:id 2 :form (dolist (x (list 1 2 3)) (print x)))").await;
637 assert!(response.contains(":id 2"), "got: {response}");
638 assert!(!response.contains(":code"), "must not error: {response}");
639 }
640
641 #[tokio::test]
642 async fn handle_request_captures_output_and_value() {
643 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
648 let outcome = session.handle_request("(print \"hi\")").await;
649 assert!(
650 outcome.output.contains("hi"),
651 "captured output should contain the printed text, got: {:?}",
652 outcome.output
653 );
654 assert!(
655 matches!(outcome.payload, ResponsePayload::Value(_)),
656 "payload should be a Value, got: {:?}",
657 outcome.payload
658 );
659 }
660
661 #[tokio::test]
662 async fn handle_request_value_only_has_empty_output() {
663 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
664 let outcome = session.handle_request("(+ 1 2)").await;
665 assert!(outcome.output.is_empty(), "got: {:?}", outcome.output);
666 assert_eq!(
667 outcome.payload,
668 ResponsePayload::Value(Value::Number(Fraction::from_integer(3)))
669 );
670 }
671
672 #[tokio::test]
673 async fn handle_request_rejects_plist_shaped_injection() {
674 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
679 let outcome = session.handle_request("1 :form (+ 2 3)").await;
680 assert!(
681 matches!(outcome.payload, ResponsePayload::Error { .. }),
682 "plist-shaped input must error, got: {:?}",
683 outcome.payload
684 );
685 }
686
687 #[tokio::test]
688 async fn handle_request_interrupt_latch_aborts_before_eval() {
689 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
693 session.interrupt_handle().interrupt();
694 let outcome = session.handle_request("(+ 1 2)").await;
695 match outcome.payload {
696 ResponsePayload::Error { code, .. } => {
697 assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED);
698 }
699 other => panic!("expected interrupted error, got: {other:?}"),
700 }
701 }
702
703 #[tokio::test]
704 async fn handle_file_evaluates_all_forms_and_persists_state() {
705 let dir = std::env::temp_dir();
708 let path = dir.join(format!("nms_load_test_{}.nms", std::process::id()));
709 std::fs::write(&path, "(defun dbl (x) (* x 2))\n(dbl 21)\n").unwrap();
710 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
711 let outcome = session.handle_file(path.to_str().unwrap()).await;
712 std::fs::remove_file(&path).ok();
713 match outcome.payload {
714 ResponsePayload::Value(Value::String(s)) => {
715 assert!(s.contains("loaded"), "summary: {s}");
716 assert!(s.contains("2 forms"), "summary: {s}");
717 }
718 other => panic!("expected a load summary string, got: {other:?}"),
719 }
720 }
721
722 #[tokio::test]
723 async fn handle_file_missing_path_errors() {
724 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
725 let outcome = session.handle_file("/no/such/nms/file.nms").await;
726 assert!(
727 matches!(outcome.payload, ResponsePayload::Error { .. }),
728 "got: {:?}",
729 outcome.payload
730 );
731 }
732
733 #[tokio::test]
734 async fn handle_file_aborts_on_a_bad_form() {
735 let dir = std::env::temp_dir();
736 let path = dir.join(format!("nms_load_bad_{}.nms", std::process::id()));
737 std::fs::write(&path, "(+ 1 2)\n(undefined-symbol-here)\n").unwrap();
738 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
739 let outcome = session.handle_file(path.to_str().unwrap()).await;
740 std::fs::remove_file(&path).ok();
741 assert!(
742 matches!(outcome.payload, ResponsePayload::Error { .. }),
743 "a bad form must abort the load, got: {:?}",
744 outcome.payload
745 );
746 }
747
748 #[tokio::test]
749 async fn handle_file_honours_interrupt_armed_before_load() {
750 let dir = std::env::temp_dir();
754 let path = dir.join(format!("nms_load_intr_{}.nms", std::process::id()));
755 std::fs::write(&path, "(+ 1 2)\n(+ 3 4)\n").unwrap();
756 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
757 session.interrupt_handle().interrupt();
758 let outcome = session.handle_file(path.to_str().unwrap()).await;
759 std::fs::remove_file(&path).ok();
760 match outcome.payload {
761 ResponsePayload::Error { code, .. } => {
762 assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED, "got: {code:?}");
763 }
764 other => panic!("interrupt should abort the load, got: {other:?}"),
765 }
766 }
767
768 #[tokio::test]
769 async fn handle_request_surfaces_error_payload() {
770 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
771 let outcome = session.handle_request("does-not-exist").await;
772 assert!(
773 matches!(outcome.payload, ResponsePayload::Error { .. }),
774 "payload should be an Error, got: {:?}",
775 outcome.payload
776 );
777 }
778
779 #[tokio::test]
780 async fn handle_request_clears_output_between_calls() {
781 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
783 let _ = session.handle_request("(print \"first\")").await;
784 let second = session.handle_request("(+ 1 1)").await;
785 assert!(
786 second.output.is_empty(),
787 "output leaked from prior request: {:?}",
788 second.output
789 );
790 }
791
792 #[tokio::test]
793 async fn returns_value_for_literal_form() {
794 let response = handle_form_smoke("(:id 9 :form 42)").await;
795 assert_eq!(response, "(:id 9 :value 42)");
796 }
797
798 #[tokio::test]
799 async fn returns_value_for_string_literal() {
800 let response = handle_form_smoke("(:id 9 :form \"hello\")").await;
801 assert_eq!(response, "(:id 9 :value \"hello\")");
802 }
803
804 #[test]
805 fn round_trips_bytes_through_eval() {
806 let value = parse_to_value("'#u8(1 2 3)").unwrap();
807 assert_eq!(value, Value::Bytes(vec![1, 2, 3]));
808 }
809
810 #[tokio::test]
811 async fn bad_envelope_emits_envelope_error() {
812 let response = handle_form_smoke("(:form (+ 1 2))").await;
813 assert!(response.contains(":code args"));
814 assert!(response.contains(":id 0"));
815 }
816
817 #[tokio::test]
818 async fn malformed_envelope_emits_parse_error() {
819 let response = handle_form_smoke("(((((").await;
820 assert!(response.contains(":code parse"));
821 }
822
823 #[tokio::test]
824 async fn undefined_symbol_emits_compile_error() {
825 let response = handle_form_smoke("(:id 7 :form does-not-exist)").await;
826 assert!(response.contains(":id 7"));
827 assert!(response.contains(":code compile"));
828 }
829
830 #[tokio::test]
831 async fn user_function_arity_violation_emits_args_error() {
832 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
833 let _ = session
834 .handle_form("(:id 1 :form (defun id-fn (x) x))")
835 .await;
836 let response = session.handle_form("(:id 2 :form (id-fn))").await;
837 assert!(response.contains(":id 2"));
838 assert!(response.contains(":code args"));
839 }
840
841 #[test]
842 fn completions_match_case_insensitively_and_skip_internal() {
843 let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
844 let defuns = session.completions("def");
847 assert!(defuns.contains(&"DEFUN".to_string()), "got: {defuns:?}");
848 assert!(
849 defuns.iter().all(|n| n.starts_with("DEF")),
850 "got: {defuns:?}"
851 );
852 let all = session.completions("");
854 assert!(all.windows(2).all(|w| w[0] <= w[1]), "must be sorted");
855 assert!(
856 all.iter()
857 .all(|n| !n.starts_with('$') && !n.starts_with("__") && !n.starts_with("(SETF")),
858 "internal/setf symbols must be filtered: {all:?}"
859 );
860 }
861
862 #[tokio::test]
863 async fn completions_include_a_user_defined_symbol() {
864 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
865 let _ = session
866 .handle_form("(:id 1 :form (defun my-helper (x) x))")
867 .await;
868 let hits = session.completions("my-");
870 assert!(hits.contains(&"MY-HELPER".to_string()), "got: {hits:?}");
871 }
872
873 #[test]
874 fn completions_unknown_prefix_is_empty() {
875 let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
876 assert!(session.completions("zzz-no-such-symbol-").is_empty());
877 }
878
879 #[tokio::test]
880 async fn interrupt_before_form_short_circuits_with_interrupted() {
881 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
882 let handle = session.interrupt_handle();
883 handle.interrupt();
884 let response = session.handle_form("(:id 11 :form (+ 1 2))").await;
885 assert!(response.contains(":id 11"));
886 assert!(response.contains(":code interrupted"));
887 }
888
889 #[tokio::test]
890 async fn coalesced_interrupts_abort_one_form_each_in_order() {
891 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
897 let handle = session.interrupt_handle();
898 handle.interrupt();
899 handle.interrupt(); let first = session.handle_form("(:id 60 :form (+ 1 2))").await;
901 assert!(
902 first.contains(":code interrupted"),
903 "first form must abort: {first}"
904 );
905 let second = session.handle_form("(:id 61 :form (+ 1 2))").await;
907 assert_eq!(
908 second, "(:id 61 :value 3)",
909 "next form coalesced-poisoned: {second}"
910 );
911 handle.interrupt();
913 let third = session.handle_form("(:id 62 :form (+ 1 2))").await;
914 assert!(
915 third.contains(":code interrupted"),
916 "a distinct later interrupt must still abort: {third}"
917 );
918 }
919
920 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
921 async fn inflight_interrupt_does_not_poison_next_form() {
922 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
927 let bumper = session.epoch_bumper();
928 let interrupt = session.interrupt_handle();
929 let cancel_task = tokio::spawn(async move {
930 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
931 bumper.bump();
932 interrupt.interrupt();
933 });
934 let cancelled = session
935 .handle_form("(:id 30 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
936 .await;
937 cancel_task.await.unwrap();
938 assert!(
939 cancelled.contains(":code interrupted") || cancelled.contains(":code runtime"),
940 "in-flight eval should have been cancelled: {cancelled}"
941 );
942 let next = session.handle_form("(:id 31 :form (+ 1 2))").await;
944 assert_eq!(next, "(:id 31 :value 3)", "next form was poisoned: {next}");
945 }
946
947 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
948 async fn non_interrupt_failure_still_consumes_a_concurrent_interrupt() {
949 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
954 let interrupt = session.interrupt_handle();
955 let latch_task = tokio::spawn(async move {
956 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
958 interrupt.interrupt();
959 });
960 let failed = session
961 .handle_form("(:id 50 :form (do ((i 0 (+ i 1))) ((>= i 100000000) i)))")
962 .await;
963 latch_task.await.unwrap();
964 assert!(
965 failed.contains(":code runtime") || failed.contains(":code interrupted"),
966 "in-flight form should fail terminally: {failed}"
967 );
968 let next = session.handle_form("(:id 51 :form (+ 1 2))").await;
969 assert_eq!(next, "(:id 51 :value 3)", "next form was poisoned: {next}");
970 }
971
972 #[tokio::test]
973 async fn interrupt_after_clean_eval_aborts_next_form() {
974 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
979 let clean = session.handle_form("(:id 40 :form (+ 1 2))").await;
980 assert_eq!(clean, "(:id 40 :value 3)");
981 session.interrupt_handle().interrupt();
982 let interrupted = session.handle_form("(:id 41 :form (+ 4 5))").await;
983 assert!(
984 interrupted.contains(":code interrupted"),
985 "post-completion interrupt must abort the next form: {interrupted}"
986 );
987 }
988
989 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
990 async fn epoch_bumper_cancels_inflight_long_eval() {
991 let ctx = ScriptCtx::new(uuid::Uuid::nil()).with_limits(ScriptLimits {
1011 fuel: u64::MAX,
1012 ..ScriptLimits::default()
1013 });
1014 let mut session = Session::new(ctx).expect("Session::new");
1015 let bumper = session.epoch_bumper();
1016 let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
1017 let bump_until_done = done.clone();
1018 let bump_task = tokio::spawn(async move {
1019 while !bump_until_done.load(std::sync::atomic::Ordering::Relaxed) {
1020 bumper.bump();
1021 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1022 }
1023 });
1024 let response = tokio::time::timeout(
1029 std::time::Duration::from_secs(60),
1030 session.handle_form("(:id 22 :form (do ((i 0 (+ i 1))) ((>= i 2000000000) i)))"),
1031 )
1032 .await
1033 .expect("epoch bump did not cancel the eval within 60s — interruption has regressed");
1034 done.store(true, std::sync::atomic::Ordering::Relaxed);
1035 bump_task.await.unwrap();
1036
1037 assert!(response.contains(":id 22"), "{response}");
1038 assert!(
1039 response.contains(":code interrupted"),
1040 "epoch bump must cancel the in-flight eval: {response}"
1041 );
1042 assert!(
1043 !response.contains("evaluation interrupted before start"),
1044 "took the pre-start latch path, so the epoch trap went untested: {response}"
1045 );
1046 assert!(
1047 response.contains(":detail"),
1048 "EpochInterrupt must surface its detail; got: {response}"
1049 );
1050 }
1051
1052 #[test]
1053 fn host_prelude_helper_is_loaded_and_compiles() {
1054 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1061 assert!(
1062 session.symbols.contains("SPLIT:LIST-FOR-TRANSACTION"),
1063 "host prelude helper not registered"
1064 );
1065 let program = Reader::parse("(split:list-for-transaction (car (list-transactions \"\")))")
1069 .expect("parse");
1070 if let Err(e) = session
1071 .compiler
1072 .compile_eval_with_type(&program, &mut session.symbols)
1073 {
1074 panic!("host prelude helper failed to compile: {e:?}");
1075 }
1076 }
1077
1078 #[tokio::test]
1079 async fn car_of_quoted_constant_list_compiles_on_eval_path() {
1080 let response = handle_form_smoke("(:id 1 :form (car '(1 2 3)))").await;
1085 assert_eq!(response, "(:id 1 :value 1)");
1086 }
1087
1088 #[tokio::test]
1089 async fn car_of_quoted_heterogeneous_list_compiles_on_eval_path() {
1090 let response = handle_form_smoke("(:id 1 :form (car '(7 \"x\")))").await;
1092 assert_eq!(response, "(:id 1 :value 7)");
1093 }
1094
1095 #[tokio::test]
1096 async fn car_of_cdr_of_quoted_constant_compiles_on_eval_path() {
1097 let response = handle_form_smoke("(:id 1 :form (car (cdr '(1 2 3))))").await;
1100 assert_eq!(response, "(:id 1 :value 2)");
1101 }
1102
1103 #[tokio::test]
1104 async fn cdr_of_quoted_constant_renders_tail_on_eval_path() {
1105 assert_eq!(
1108 handle_form_smoke("(:id 1 :form (cdr '(1 2 3)))").await,
1109 "(:id 1 :value \"(2 3)\")"
1110 );
1111 assert_eq!(
1112 handle_form_smoke("(:id 1 :form (cdr '(1)))").await,
1113 "(:id 1 :value NIL)"
1114 );
1115 }
1116
1117 #[tokio::test]
1118 async fn car_of_quoted_compound_and_symbol_heads_render_as_data() {
1119 assert_eq!(
1122 handle_form_smoke("(:id 1 :form (car '((1 2) 3)))").await,
1123 "(:id 1 :value \"(1 2)\")"
1124 );
1125 assert_eq!(
1126 handle_form_smoke("(:id 1 :form (car '(x y)))").await,
1127 "(:id 1 :value \"X\")"
1128 );
1129 }
1130
1131 #[tokio::test]
1132 async fn reverse_of_constant_list_renders_on_eval_path() {
1133 assert_eq!(
1138 handle_form_smoke("(:id 1 :form (reverse '(1 2 3)))").await,
1139 "(:id 1 :value \"(3 2 1)\")"
1140 );
1141 assert_eq!(
1142 handle_form_smoke("(:id 1 :form (reverse (list 1 2 3)))").await,
1143 "(:id 1 :value \"(3 2 1)\")"
1144 );
1145 assert_eq!(
1147 handle_form_smoke("(:id 1 :form (car (reverse '(1 2 3))))").await,
1148 "(:id 1 :value 3)"
1149 );
1150 }
1151
1152 #[tokio::test]
1153 async fn cons_onto_constant_list_renders_on_eval_path() {
1154 assert_eq!(
1158 handle_form_smoke("(:id 1 :form (cons 0 '(1 2 3)))").await,
1159 "(:id 1 :value \"(0 1 2 3)\")"
1160 );
1161 assert_eq!(
1162 handle_form_smoke("(:id 1 :form (cons 0 (list 1 2 3)))").await,
1163 "(:id 1 :value \"(0 1 2 3)\")"
1164 );
1165 assert_eq!(
1166 handle_form_smoke("(:id 1 :form (cons 1 2))").await,
1167 "(:id 1 :value \"(1 . 2)\")"
1168 );
1169 }
1170
1171 #[tokio::test]
1172 async fn append_of_constant_lists_renders_on_eval_path() {
1173 assert_eq!(
1177 handle_form_smoke("(:id 1 :form (append '(1 2) '(3)))").await,
1178 "(:id 1 :value \"(1 2 3)\")"
1179 );
1180 assert_eq!(
1181 handle_form_smoke("(:id 1 :form (append '(a b) '(c)))").await,
1182 "(:id 1 :value \"(A B C)\")"
1183 );
1184 }
1185
1186 #[tokio::test]
1187 async fn universal_prelude_helper_runs_end_to_end() {
1188 let response = handle_form_smoke("(:id 9 :form (math:square 9))").await;
1191 assert_eq!(response, "(:id 9 :value 81)");
1192 }
1193
1194 #[tokio::test]
1195 async fn pp_form_at_value_position_returns_string() {
1196 let resp = handle_form_smoke("(:id 7 :form (pp 42))").await;
1199 assert!(resp.contains(":id 7"), "{resp}");
1200 assert!(resp.contains("\"42\""), "{resp}");
1201 }
1202
1203 #[tokio::test]
1204 async fn describe_form_at_value_position_returns_doc() {
1205 let resp = handle_form_smoke("(:id 8 :form (describe '+))").await;
1207 assert!(resp.contains(":id 8"), "{resp}");
1208 assert!(!resp.contains(":error"), "{resp}");
1209 }
1210
1211 #[tokio::test]
1212 async fn apropos_form_at_value_position_returns_list() {
1213 let resp = handle_form_smoke("(:id 9 :form (apropos \"entity\"))").await;
1214 assert!(resp.contains(":id 9"), "{resp}");
1215 assert!(!resp.contains(":error"), "{resp}");
1216 }
1217
1218 #[tokio::test]
1219 async fn deftest_form_at_value_position_returns_quoted_name() {
1220 let resp = handle_form_smoke("(:id 10 :form (deftest sanity (assert-equal 1 1)))").await;
1221 assert!(resp.contains(":id 10"), "{resp}");
1222 assert!(!resp.contains(":error"), "{resp}");
1223 }
1224
1225 #[tokio::test]
1226 async fn assert_equal_pass_form_at_value_position() {
1227 let resp = handle_form_smoke("(:id 11 :form (assert-equal 2 2))").await;
1228 assert!(resp.contains(":id 11"), "{resp}");
1229 assert!(!resp.contains(":error"), "{resp}");
1230 }
1231
1232 #[tokio::test]
1233 async fn assert_equal_fail_surfaces_as_error() {
1234 let resp = handle_form_smoke("(:id 12 :form (assert-equal 1 2))").await;
1237 assert!(resp.contains(":id 12"), "{resp}");
1238 assert!(resp.contains(":error"), "{resp}");
1239 }
1240
1241 #[tokio::test]
1242 async fn coverage_dump_lists_called_natives() {
1243 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1244 let _ = session
1245 .handle_form("(:id 1 :form (rpc-protocol-version))")
1246 .await;
1247 let dump = session.handle_form("(:id 2 :form (coverage-dump))").await;
1248 assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
1249 assert!(dump.contains(":id 2"), "{dump}");
1250 }
1251
1252 #[tokio::test]
1253 async fn coverage_dump_reports_pre_warmed_natives() {
1254 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1260 let dump = session.handle_form("(:id 1 :form (coverage-dump))").await;
1261 assert!(dump.contains(":id 1"), "{dump}");
1262 assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
1263 }
1264
1265 #[tokio::test]
1266 async fn interrupt_does_not_persist_across_forms() {
1267 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1268 session.interrupt_handle().interrupt();
1269 let _ = session.handle_form("(:id 11 :form (+ 1 2))").await;
1270 let response = session.handle_form("(:id 12 :form (+ 1 2))").await;
1271 assert_eq!(response, "(:id 12 :value 3)");
1272 }
1273
1274 #[tokio::test]
1275 async fn session_state_persists_across_forms() {
1276 let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1277 let defun = session
1278 .handle_form("(:id 1 :form (defun double (x) (* 2 x)))")
1279 .await;
1280 assert!(defun.contains(":id 1"));
1281 let call = session.handle_form("(:id 2 :form (double 21))").await;
1282 assert_eq!(call, "(:id 2 :value 42)");
1283 }
1284
1285 #[tokio::test]
1286 async fn fraction_results_format_canonically() {
1287 let response = handle_form_smoke("(:id 3 :form 1/4)").await;
1291 assert_eq!(response, "(:id 3 :value 1/4)");
1292 }
1293
1294 #[test]
1295 fn nomi_runtime_value_carries_through() {
1296 let value = parse_to_value("(+ 0.5 0.25)").unwrap();
1297 assert_eq!(value, Value::Number(Fraction::new(3, 4)));
1298 }
1299
1300 #[tokio::test]
1301 async fn calls_meta_native_from_nomiscript_source() {
1302 let response = handle_form_smoke("(:id 1 :form (rpc-protocol-version))").await;
1303 let expected_version = crate::natives::meta::PROTOCOL_VERSION;
1304 assert_eq!(response, format!("(:id 1 :value {expected_version})"));
1305 }
1306
1307 #[tokio::test]
1308 async fn calls_server_get_version_from_nomiscript_source() {
1309 let response = handle_form_smoke("(:id 1 :form (get-version))").await;
1310 assert!(
1314 response.starts_with("(:id 1 :value \""),
1315 "expected string response, got: {response}"
1316 );
1317 assert!(response.ends_with("\")"));
1318 }
1319
1320 #[tokio::test]
1321 async fn calls_server_get_build_date_from_nomiscript_source() {
1322 let response = handle_form_smoke("(:id 2 :form (get-build-date))").await;
1323 assert!(
1324 response.starts_with("(:id 2 :value \""),
1325 "expected string response, got: {response}"
1326 );
1327 assert!(response.ends_with("\")"));
1328 }
1329
1330 #[tokio::test]
1331 async fn cons_list_surfaces_as_printable_string() {
1332 let response = handle_form_smoke("(:id 12 :form (cons 1 (cons 2 (cons 3 nil))))").await;
1339 assert!(
1340 response.contains(":value \"(1 2 3)\""),
1341 "expected :value \"(1 2 3)\", got: {response}"
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn count_native_cannot_mix_with_ratio_arithmetic() {
1347 let response = handle_form_smoke("(:id 11 :form (+ 1/2 (account-count)))").await;
1355 assert!(response.contains(":code compile"), "got: {response}");
1356 assert!(
1357 response.contains("scalar") && response.contains("index"),
1358 "expected Index/Scalar stratum-separation error, got: {response}"
1359 );
1360 }
1361
1362 #[tokio::test]
1363 async fn get_commodity_with_non_uuid_arg_falls_back_to_symbol_lookup() {
1364 let response = handle_form_smoke("(:id 9 :form (get-commodity \"USD\"))").await;
1372 assert!(response.contains(":id 9"), "got: {response}");
1373 assert!(
1374 response.contains(":code runtime") && response.contains("get-commodity"),
1375 "expected a get-commodity runtime error (symbol path hits the DB), got: {response}"
1376 );
1377 assert!(
1378 !response.contains("invalid uuid"),
1379 "a non-uuid arg must no longer short-circuit as an invalid-uuid error: {response}"
1380 );
1381 }
1382
1383 #[test]
1384 fn meta_native_unknown_in_script_mode_compile() {
1385 use nomiscript::CompileMode;
1389 let mut compiler = Compiler::new();
1390 let mut symbols = SymbolTable::with_builtins();
1391 let program = nomiscript::Reader::parse("(rpc-protocol-version)").unwrap();
1392 let result = compiler.compile_with_mode(&program, &mut symbols, CompileMode::Script);
1393 assert!(
1394 result.is_err(),
1395 "host fn should not be callable when compiler has no specs"
1396 );
1397 }
1398
1399 #[tokio::test]
1400 async fn get_config_missing_name_yields_error_envelope() {
1401 let resp = handle_form_smoke("(:id 20 :form (get-config \"\"))").await;
1402 assert!(resp.contains(":id 20"), "{resp}");
1403 assert!(resp.contains(":error"), "{resp}");
1404 assert!(!resp.contains(":value"), "{resp}");
1405 }
1406
1407 #[tokio::test]
1408 async fn get_config_success_shape_is_config_value() {
1409 let resp = handle_form_smoke("(:id 21 :form (get-config \"\"))").await;
1414 assert!(resp.contains(":id 21"), "{resp}");
1415 assert!(!resp.contains(":value"), "{resp}");
1416 }
1417}