Skip to main content

sema_core/
context.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeMap, HashMap};
3use std::path::PathBuf;
4use std::time::Instant;
5
6use crate::{CallFrame, Env, Sandbox, SemaError, Span, SpanMap, StackTrace, Value};
7
8const MAX_SPAN_TABLE_ENTRIES: usize = 200_000;
9
10/// Function-pointer type for the full evaluator callback: (ctx, expr, env) -> Result<Value, SemaError>
11pub type EvalCallbackFn = fn(&EvalContext, &Value, &Env) -> Result<Value, SemaError>;
12
13/// Function-pointer type for calling a function value with evaluated arguments: (ctx, func, args) -> Result<Value, SemaError>
14pub type CallCallbackFn = fn(&EvalContext, &Value, &[Value]) -> Result<Value, SemaError>;
15
16pub struct EvalContext {
17    pub module_cache: RefCell<BTreeMap<PathBuf, BTreeMap<String, Value>>>,
18    pub current_file: RefCell<Vec<PathBuf>>,
19    pub module_exports: RefCell<Vec<Option<Vec<String>>>>,
20    pub module_load_stack: RefCell<Vec<PathBuf>>,
21    pub call_stack: RefCell<Vec<CallFrame>>,
22    pub span_table: RefCell<HashMap<usize, Span>>,
23    pub eval_depth: Cell<usize>,
24    pub max_eval_depth: Cell<usize>,
25    pub eval_step_limit: Cell<usize>,
26    pub eval_steps: Cell<usize>,
27    /// Optional wall-clock deadline for evaluation. When set, both the
28    /// tree-walker and the bytecode VM periodically check whether the current
29    /// time has passed this instant and, if so, abort with an error. Used by
30    /// the notebook engine to bound how long a single cell evaluation can run.
31    pub eval_deadline: Cell<Option<Instant>>,
32    pub sandbox: Sandbox,
33    pub user_context: RefCell<Vec<BTreeMap<Value, Value>>>,
34    pub hidden_context: RefCell<Vec<BTreeMap<Value, Value>>>,
35    pub context_stacks: RefCell<BTreeMap<Value, Vec<Value>>>,
36    pub eval_fn: Cell<Option<EvalCallbackFn>>,
37    pub call_fn: Cell<Option<CallCallbackFn>>,
38    pub interactive: Cell<bool>,
39    /// Whether the bytecode VM is the active backend. When true, `(load ...)`
40    /// compiles and runs the loaded file's body on the VM (so async/channels work
41    /// in loaded files and the code runs at VM speed); when false (the
42    /// tree-walker, e.g. `--tw`), the body is tree-walked. Defaults to false so
43    /// embedders that never set it keep the tree-walker path. NOTE: `import` is
44    /// always tree-walked regardless (its module isolation needs lexical env
45    /// capture the VM does not yet provide — see docs/plans/2026-06-16-vm-module-loading.md).
46    pub vm_backend: Cell<bool>,
47}
48
49impl EvalContext {
50    pub fn new() -> Self {
51        EvalContext {
52            module_cache: RefCell::new(BTreeMap::new()),
53            current_file: RefCell::new(Vec::new()),
54            module_exports: RefCell::new(Vec::new()),
55            module_load_stack: RefCell::new(Vec::new()),
56            call_stack: RefCell::new(Vec::new()),
57            span_table: RefCell::new(HashMap::new()),
58            eval_depth: Cell::new(0),
59            max_eval_depth: Cell::new(0),
60            eval_step_limit: Cell::new(0),
61            eval_steps: Cell::new(0),
62            eval_deadline: Cell::new(None),
63            sandbox: Sandbox::allow_all(),
64            user_context: RefCell::new(vec![BTreeMap::new()]),
65            hidden_context: RefCell::new(vec![BTreeMap::new()]),
66            context_stacks: RefCell::new(BTreeMap::new()),
67            eval_fn: Cell::new(None),
68            call_fn: Cell::new(None),
69            interactive: Cell::new(false),
70            vm_backend: Cell::new(false),
71        }
72    }
73
74    pub fn new_with_sandbox(sandbox: Sandbox) -> Self {
75        EvalContext {
76            module_cache: RefCell::new(BTreeMap::new()),
77            current_file: RefCell::new(Vec::new()),
78            module_exports: RefCell::new(Vec::new()),
79            module_load_stack: RefCell::new(Vec::new()),
80            call_stack: RefCell::new(Vec::new()),
81            span_table: RefCell::new(HashMap::new()),
82            eval_depth: Cell::new(0),
83            max_eval_depth: Cell::new(0),
84            eval_step_limit: Cell::new(0),
85            eval_steps: Cell::new(0),
86            eval_deadline: Cell::new(None),
87            sandbox,
88            user_context: RefCell::new(vec![BTreeMap::new()]),
89            hidden_context: RefCell::new(vec![BTreeMap::new()]),
90            context_stacks: RefCell::new(BTreeMap::new()),
91            eval_fn: Cell::new(None),
92            call_fn: Cell::new(None),
93            interactive: Cell::new(false),
94            vm_backend: Cell::new(false),
95        }
96    }
97
98    /// Mark whether the bytecode VM is the active backend (see `vm_backend`).
99    pub fn set_vm_backend(&self, on: bool) {
100        self.vm_backend.set(on);
101    }
102
103    /// Whether the bytecode VM is the active backend.
104    pub fn vm_backend(&self) -> bool {
105        self.vm_backend.get()
106    }
107
108    pub fn push_file_path(&self, path: PathBuf) {
109        self.current_file.borrow_mut().push(path);
110    }
111
112    pub fn pop_file_path(&self) {
113        self.current_file.borrow_mut().pop();
114    }
115
116    pub fn current_file_dir(&self) -> Option<PathBuf> {
117        self.current_file
118            .borrow()
119            .last()
120            .and_then(|p| p.parent().map(|d| d.to_path_buf()))
121    }
122
123    pub fn current_file_path(&self) -> Option<PathBuf> {
124        self.current_file.borrow().last().cloned()
125    }
126
127    pub fn get_cached_module(&self, path: &PathBuf) -> Option<BTreeMap<String, Value>> {
128        self.module_cache.borrow().get(path).cloned()
129    }
130
131    pub fn cache_module(&self, path: PathBuf, exports: BTreeMap<String, Value>) {
132        self.module_cache.borrow_mut().insert(path, exports);
133    }
134
135    pub fn set_module_exports(&self, names: Vec<String>) {
136        let mut stack = self.module_exports.borrow_mut();
137        if let Some(top) = stack.last_mut() {
138            *top = Some(names);
139        }
140    }
141
142    pub fn clear_module_exports(&self) {
143        self.module_exports.borrow_mut().push(None);
144    }
145
146    pub fn take_module_exports(&self) -> Option<Vec<String>> {
147        self.module_exports.borrow_mut().pop().flatten()
148    }
149
150    pub fn begin_module_load(&self, path: &PathBuf) -> Result<(), SemaError> {
151        let mut stack = self.module_load_stack.borrow_mut();
152        if let Some(pos) = stack.iter().position(|p| p == path) {
153            let mut cycle: Vec<String> = stack[pos..]
154                .iter()
155                .map(|p| p.display().to_string())
156                .collect();
157            cycle.push(path.display().to_string());
158            return Err(SemaError::eval(format!(
159                "cyclic import detected: {}",
160                cycle.join(" -> ")
161            )));
162        }
163        stack.push(path.clone());
164        Ok(())
165    }
166
167    pub fn end_module_load(&self, path: &PathBuf) {
168        let mut stack = self.module_load_stack.borrow_mut();
169        if matches!(stack.last(), Some(last) if last == path) {
170            stack.pop();
171        } else if let Some(pos) = stack.iter().rposition(|p| p == path) {
172            stack.remove(pos);
173        }
174    }
175
176    pub fn push_call_frame(&self, frame: CallFrame) {
177        self.call_stack.borrow_mut().push(frame);
178    }
179
180    pub fn call_stack_depth(&self) -> usize {
181        self.call_stack.borrow().len()
182    }
183
184    pub fn truncate_call_stack(&self, depth: usize) {
185        self.call_stack.borrow_mut().truncate(depth);
186    }
187
188    pub fn capture_stack_trace(&self) -> StackTrace {
189        let stack = self.call_stack.borrow();
190        StackTrace(stack.iter().rev().cloned().collect())
191    }
192
193    pub fn merge_span_table(&self, spans: SpanMap) {
194        let mut table = self.span_table.borrow_mut();
195        if table.len() < MAX_SPAN_TABLE_ENTRIES {
196            table.extend(spans);
197        }
198        // If table is full, skip merging new spans (preserves existing error locations)
199    }
200
201    pub fn lookup_span(&self, ptr: usize) -> Option<Span> {
202        self.span_table.borrow().get(&ptr).cloned()
203    }
204
205    pub fn set_eval_step_limit(&self, limit: usize) {
206        self.eval_step_limit.set(limit);
207    }
208
209    /// Set a wall-clock deadline after which evaluation should abort.
210    /// Passing `None` clears any existing deadline.
211    pub fn set_eval_deadline(&self, deadline: Option<Instant>) {
212        self.eval_deadline.set(deadline);
213    }
214
215    /// Returns true if a deadline is set and has been exceeded.
216    #[inline]
217    pub fn deadline_exceeded(&self) -> bool {
218        match self.eval_deadline.get() {
219            Some(d) => Instant::now() >= d,
220            None => false,
221        }
222    }
223
224    /// Returns an `eval` error if a deadline is set and exceeded; otherwise Ok(()).
225    #[inline]
226    pub fn check_deadline(&self) -> Result<(), SemaError> {
227        if self.deadline_exceeded() {
228            Err(SemaError::eval(
229                "evaluation exceeded time budget (looks like an infinite loop?)".to_string(),
230            ))
231        } else {
232            Ok(())
233        }
234    }
235
236    // --- User context methods ---
237
238    pub fn context_get(&self, key: &Value) -> Option<Value> {
239        let frames = self.user_context.borrow();
240        for frame in frames.iter().rev() {
241            if let Some(v) = frame.get(key) {
242                return Some(v.clone());
243            }
244        }
245        None
246    }
247
248    pub fn context_set(&self, key: Value, value: Value) {
249        let mut frames = self.user_context.borrow_mut();
250        if let Some(top) = frames.last_mut() {
251            top.insert(key, value);
252        }
253    }
254
255    pub fn context_has(&self, key: &Value) -> bool {
256        let frames = self.user_context.borrow();
257        frames.iter().any(|frame| frame.contains_key(key))
258    }
259
260    pub fn context_remove(&self, key: &Value) -> Option<Value> {
261        let mut frames = self.user_context.borrow_mut();
262        let mut first_found = None;
263        for frame in frames.iter_mut().rev() {
264            if let Some(v) = frame.remove(key) {
265                if first_found.is_none() {
266                    first_found = Some(v);
267                }
268            }
269        }
270        first_found
271    }
272
273    pub fn context_all(&self) -> BTreeMap<Value, Value> {
274        let frames = self.user_context.borrow();
275        let mut merged = BTreeMap::new();
276        for frame in frames.iter() {
277            for (k, v) in frame {
278                merged.insert(k.clone(), v.clone());
279            }
280        }
281        merged
282    }
283
284    pub fn context_push_frame(&self) {
285        self.user_context.borrow_mut().push(BTreeMap::new());
286    }
287
288    pub fn context_push_frame_with(&self, bindings: BTreeMap<Value, Value>) {
289        self.user_context.borrow_mut().push(bindings);
290    }
291
292    pub fn context_pop_frame(&self) {
293        let mut frames = self.user_context.borrow_mut();
294        if frames.len() > 1 {
295            frames.pop();
296        }
297    }
298
299    pub fn context_clear(&self) {
300        let mut frames = self.user_context.borrow_mut();
301        frames.clear();
302        frames.push(BTreeMap::new());
303    }
304
305    // --- Hidden context methods ---
306
307    pub fn hidden_get(&self, key: &Value) -> Option<Value> {
308        let frames = self.hidden_context.borrow();
309        for frame in frames.iter().rev() {
310            if let Some(v) = frame.get(key) {
311                return Some(v.clone());
312            }
313        }
314        None
315    }
316
317    pub fn hidden_set(&self, key: Value, value: Value) {
318        let mut frames = self.hidden_context.borrow_mut();
319        if let Some(top) = frames.last_mut() {
320            top.insert(key, value);
321        }
322    }
323
324    pub fn hidden_has(&self, key: &Value) -> bool {
325        let frames = self.hidden_context.borrow();
326        frames.iter().any(|frame| frame.contains_key(key))
327    }
328
329    pub fn hidden_push_frame(&self) {
330        self.hidden_context.borrow_mut().push(BTreeMap::new());
331    }
332
333    pub fn hidden_pop_frame(&self) {
334        let mut frames = self.hidden_context.borrow_mut();
335        if frames.len() > 1 {
336            frames.pop();
337        }
338    }
339
340    // --- Stack methods ---
341
342    pub fn context_stack_push(&self, key: Value, value: Value) {
343        self.context_stacks
344            .borrow_mut()
345            .entry(key)
346            .or_default()
347            .push(value);
348    }
349
350    pub fn context_stack_get(&self, key: &Value) -> Vec<Value> {
351        self.context_stacks
352            .borrow()
353            .get(key)
354            .cloned()
355            .unwrap_or_default()
356    }
357
358    pub fn context_stack_pop(&self, key: &Value) -> Option<Value> {
359        let mut stacks = self.context_stacks.borrow_mut();
360        let stack = stacks.get_mut(key)?;
361        let val = stack.pop();
362        if stack.is_empty() {
363            stacks.remove(key);
364        }
365        val
366    }
367}
368
369impl Default for EvalContext {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use std::collections::BTreeMap;
379    use std::path::PathBuf;
380
381    use crate::{Caps, Sandbox, Value};
382
383    // --- File path tracking ---
384
385    #[test]
386    fn test_push_pop_file_path() {
387        let ctx = EvalContext::new();
388        let path = PathBuf::from("/foo/bar/baz.sema");
389        ctx.push_file_path(path.clone());
390        assert_eq!(ctx.current_file_path(), Some(path));
391        ctx.pop_file_path();
392        assert_eq!(ctx.current_file_path(), None);
393    }
394
395    #[test]
396    fn test_current_file_dir() {
397        let ctx = EvalContext::new();
398        ctx.push_file_path(PathBuf::from("/foo/bar/baz.sema"));
399        assert_eq!(ctx.current_file_dir(), Some(PathBuf::from("/foo/bar")));
400    }
401
402    #[test]
403    fn test_current_file_dir_empty() {
404        let ctx = EvalContext::new();
405        assert_eq!(ctx.current_file_dir(), None);
406    }
407
408    #[test]
409    fn test_nested_file_paths() {
410        let ctx = EvalContext::new();
411        let first = PathBuf::from("/a/first.sema");
412        let second = PathBuf::from("/b/second.sema");
413        ctx.push_file_path(first.clone());
414        ctx.push_file_path(second.clone());
415        assert_eq!(ctx.current_file_path(), Some(second));
416        ctx.pop_file_path();
417        assert_eq!(ctx.current_file_path(), Some(first));
418    }
419
420    // --- Module caching ---
421
422    #[test]
423    fn test_cache_module() {
424        let ctx = EvalContext::new();
425        let path = PathBuf::from("/lib/math.sema");
426        let mut exports = BTreeMap::new();
427        exports.insert("add".to_string(), Value::int(1));
428        ctx.cache_module(path.clone(), exports.clone());
429        let cached = ctx.get_cached_module(&path).unwrap();
430        assert_eq!(cached.len(), 1);
431        assert_eq!(cached.get("add"), Some(&Value::int(1)));
432    }
433
434    #[test]
435    fn test_get_cached_module_miss() {
436        let ctx = EvalContext::new();
437        let path = PathBuf::from("/nonexistent.sema");
438        assert_eq!(ctx.get_cached_module(&path), None);
439    }
440
441    #[test]
442    fn test_cache_module_overwrites() {
443        let ctx = EvalContext::new();
444        let path = PathBuf::from("/lib/math.sema");
445
446        let mut first = BTreeMap::new();
447        first.insert("old".to_string(), Value::int(1));
448        ctx.cache_module(path.clone(), first);
449
450        let mut second = BTreeMap::new();
451        second.insert("new".to_string(), Value::int(2));
452        ctx.cache_module(path.clone(), second);
453
454        let cached = ctx.get_cached_module(&path).unwrap();
455        assert!(!cached.contains_key("old"));
456        assert_eq!(cached.get("new"), Some(&Value::int(2)));
457    }
458
459    // --- Module exports ---
460
461    #[test]
462    fn test_module_exports_roundtrip() {
463        let ctx = EvalContext::new();
464        ctx.clear_module_exports(); // pushes None onto stack
465        ctx.set_module_exports(vec!["foo".to_string(), "bar".to_string()]);
466        let taken = ctx.take_module_exports();
467        assert_eq!(taken, Some(vec!["foo".to_string(), "bar".to_string()]));
468    }
469
470    #[test]
471    fn test_take_module_exports_empty() {
472        let ctx = EvalContext::new();
473        // Nothing has been pushed, so take should return None
474        assert_eq!(ctx.take_module_exports(), None);
475    }
476
477    // --- Cyclic import detection ---
478
479    #[test]
480    fn test_begin_module_load_ok() {
481        let ctx = EvalContext::new();
482        let path = PathBuf::from("/lib/a.sema");
483        assert!(ctx.begin_module_load(&path).is_ok());
484    }
485
486    #[test]
487    fn test_begin_module_load_cycle() {
488        let ctx = EvalContext::new();
489        let path = PathBuf::from("/lib/a.sema");
490        ctx.begin_module_load(&path).unwrap();
491        let result = ctx.begin_module_load(&path);
492        assert!(result.is_err());
493        let err = result.unwrap_err();
494        let msg = err.to_string();
495        assert!(
496            msg.contains("cyclic import"),
497            "error should mention cyclic import: {msg}"
498        );
499    }
500
501    #[test]
502    fn test_end_module_load() {
503        let ctx = EvalContext::new();
504        let path = PathBuf::from("/lib/a.sema");
505        ctx.begin_module_load(&path).unwrap();
506        ctx.end_module_load(&path);
507        // Stack is now empty, so beginning the same path again should succeed
508        assert!(ctx.begin_module_load(&path).is_ok());
509    }
510
511    #[test]
512    fn test_nested_module_loads() {
513        let ctx = EvalContext::new();
514        let a = PathBuf::from("/lib/a.sema");
515        let b = PathBuf::from("/lib/b.sema");
516        ctx.begin_module_load(&a).unwrap();
517        ctx.begin_module_load(&b).unwrap();
518        ctx.end_module_load(&b);
519        // A should still be in the stack — beginning A again should fail
520        let result = ctx.begin_module_load(&a);
521        assert!(result.is_err());
522        let msg = result.unwrap_err().to_string();
523        assert!(
524            msg.contains("cyclic import"),
525            "A should still be loading: {msg}"
526        );
527    }
528
529    // --- Sandbox integration ---
530
531    #[test]
532    fn test_new_with_sandbox() {
533        let sandbox = Sandbox::deny(Caps::NETWORK);
534        let ctx = EvalContext::new_with_sandbox(sandbox);
535        // Verify the sandbox is set by checking a denied capability
536        let result = ctx.sandbox.check(Caps::NETWORK, "http/get");
537        assert!(result.is_err());
538        // Allowed capability should pass
539        let result = ctx.sandbox.check(Caps::FS_READ, "file/read");
540        assert!(result.is_ok());
541    }
542}
543
544thread_local! {
545    static STDLIB_CTX: EvalContext = EvalContext::new();
546}
547
548/// Get a reference to the shared stdlib EvalContext.
549/// Use this for stdlib callback invocations instead of creating throwaway contexts.
550pub fn with_stdlib_ctx<F, R>(f: F) -> R
551where
552    F: FnOnce(&EvalContext) -> R,
553{
554    STDLIB_CTX.with(f)
555}
556
557/// Register the full evaluator callback. Called by `sema-eval` during interpreter init.
558/// Stores into both `ctx` and the shared `STDLIB_CTX` so that stdlib simple-fn closures
559/// (which lack a ctx parameter) can still invoke the evaluator.
560pub fn set_eval_callback(ctx: &EvalContext, f: EvalCallbackFn) {
561    ctx.eval_fn.set(Some(f));
562    STDLIB_CTX.with(|stdlib| stdlib.eval_fn.set(Some(f)));
563}
564
565/// Register the call-value callback. Called by `sema-eval` during interpreter init.
566/// Stores into both `ctx` and the shared `STDLIB_CTX`.
567pub fn set_call_callback(ctx: &EvalContext, f: CallCallbackFn) {
568    ctx.call_fn.set(Some(f));
569    STDLIB_CTX.with(|stdlib| stdlib.call_fn.set(Some(f)));
570}
571
572/// Evaluate an expression using the registered evaluator.
573/// Returns an error if no evaluator has been registered.
574pub fn eval_callback(ctx: &EvalContext, expr: &Value, env: &Env) -> Result<Value, SemaError> {
575    let f = ctx.eval_fn.get().ok_or_else(|| {
576        SemaError::eval("eval callback not registered — Interpreter::new() must be called first")
577    })?;
578    f(ctx, expr, env)
579}
580
581/// Call a function value with arguments using the registered callback.
582/// Returns an error if no callback has been registered.
583pub fn call_callback(ctx: &EvalContext, func: &Value, args: &[Value]) -> Result<Value, SemaError> {
584    let f = ctx.call_fn.get().ok_or_else(|| {
585        SemaError::eval("call callback not registered — Interpreter::new() must be called first")
586    })?;
587    f(ctx, func, args)
588}