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