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 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 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 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 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 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 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 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 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 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 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 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 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 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(); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}