Skip to main content

kaish_kernel/interpreter/
scope.rs

1//! Variable scope management for kaish.
2//!
3//! Scopes provide variable bindings with:
4//! - Nested scope frames (push/pop for loops, tool calls)
5//! - The special `$?` variable holding the last command's exit code
6//! - Path resolution for nested access (`${VAR.field[0]}`)
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use crate::ast::{Value, VarPath, VarSegment};
12
13use super::result::ExecResult;
14
15/// Variable scope with nested frames and last-result tracking.
16///
17/// Variables are looked up from innermost to outermost frame.
18/// The `?` variable always refers to the last command result.
19///
20/// The `frames` field is wrapped in `Arc` for copy-on-write (COW) semantics.
21/// Cloning a Scope is O(1) — just bumps the Arc refcount. Mutations use
22/// `Arc::make_mut` to clone the inner data only when shared. This matters
23/// because `execute_pipeline` snapshots the scope into ExecContext (clone)
24/// and syncs it back (clone) on every command.
25#[derive(Debug, Clone)]
26pub struct Scope {
27    /// Stack of variable frames. Last element is the innermost scope.
28    /// Wrapped in Arc for copy-on-write: clone is O(1), mutation clones on demand.
29    frames: Arc<Vec<HashMap<String, Value>>>,
30    /// Variables marked for export to child processes.
31    exported: HashSet<String>,
32    /// The result of the last command execution.
33    last_result: ExecResult,
34    /// Script or tool name ($0).
35    script_name: String,
36    /// Positional arguments ($1-$9, $@, $#).
37    positional: Vec<String>,
38    /// Error exit mode (set -e): exit on any command failure.
39    error_exit: bool,
40    /// Counter for temporarily suppressing errexit (e.g. inside && / || left side).
41    /// When > 0, error_exit_enabled() returns false even if error_exit is true.
42    errexit_suppressed: usize,
43    /// AST display mode (kaish-ast -on/-off): show AST instead of executing.
44    show_ast: bool,
45    /// Latch mode (set -o latch): gate dangerous operations behind nonce confirmation.
46    latch_enabled: bool,
47    /// Trash mode (set -o trash): move deleted files to freedesktop.org Trash.
48    trash_enabled: bool,
49    /// Maximum file size (bytes) for trash. Files larger than this bypass trash.
50    /// Default: 10 MB.
51    trash_max_size: u64,
52    /// Glob expansion mode (set -o glob): expand bare glob patterns in arguments.
53    glob_enabled: bool,
54    /// Kaish session identifier ($$). A monotonic counter assigned at Kernel
55    /// construction (see `KERNEL_COUNTER` in kernel.rs) — *not* the OS PID.
56    /// Subshells / forks inherit the parent's value (Scope clone copies it).
57    /// 0 is a sentinel meaning "this scope was constructed outside a Kernel"
58    /// (e.g. arithmetic unit tests, kaish-clear before its setter runs).
59    pid: u64,
60}
61
62impl Scope {
63    /// Create a new scope with one empty frame.
64    ///
65    /// `pid` defaults to 0 (sentinel). The owning Kernel calls `set_pid()`
66    /// during construction to assign the real session identifier.
67    pub fn new() -> Self {
68        Self {
69            frames: Arc::new(vec![HashMap::new()]),
70            exported: HashSet::new(),
71            last_result: ExecResult::default(),
72            script_name: String::new(),
73            positional: Vec::new(),
74            error_exit: false,
75            errexit_suppressed: 0,
76            show_ast: false,
77            latch_enabled: false,
78            trash_enabled: false,
79            trash_max_size: 10 * 1024 * 1024, // 10 MB
80            glob_enabled: true,
81            pid: 0,
82        }
83    }
84
85    /// Get the kaish session identifier ($$).
86    pub fn pid(&self) -> u64 {
87        self.pid
88    }
89
90    /// Set the kaish session identifier ($$). Called by the Kernel during
91    /// construction to thread the assigned counter value into the scope.
92    /// Also used by `kaish-clear` to preserve $$ across a session reset.
93    pub fn set_pid(&mut self, pid: u64) {
94        self.pid = pid;
95    }
96
97    /// Push a new scope frame (for entering a loop, tool call, etc.)
98    pub fn push_frame(&mut self) {
99        Arc::make_mut(&mut self.frames).push(HashMap::new());
100    }
101
102    /// Pop the innermost scope frame.
103    ///
104    /// Panics if attempting to pop the last frame.
105    pub fn pop_frame(&mut self) {
106        if self.frames.len() > 1 {
107            Arc::make_mut(&mut self.frames).pop();
108        } else {
109            panic!("cannot pop the root scope frame");
110        }
111    }
112
113    /// Set a variable in the current (innermost) frame.
114    ///
115    /// Use this for `local` variable declarations.
116    pub fn set(&mut self, name: impl Into<String>, value: Value) {
117        if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
118            frame.insert(name.into(), value);
119        }
120    }
121
122    /// Set a variable with global semantics (shell default).
123    ///
124    /// If the variable exists in any frame, update it there.
125    /// Otherwise, create it in the outermost (root) frame.
126    /// Use this for non-local variable assignments.
127    pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
128        let name = name.into();
129
130        // Search from innermost to outermost to find existing variable
131        let frames = Arc::make_mut(&mut self.frames);
132        for frame in frames.iter_mut().rev() {
133            if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
134                e.insert(value);
135                return;
136            }
137        }
138
139        // Variable doesn't exist - create in root frame (index 0)
140        if let Some(frame) = frames.first_mut() {
141            frame.insert(name, value);
142        }
143    }
144
145    /// Get a variable by name, searching from innermost to outermost frame.
146    pub fn get(&self, name: &str) -> Option<&Value> {
147        for frame in self.frames.iter().rev() {
148            if let Some(value) = frame.get(name) {
149                return Some(value);
150            }
151        }
152        None
153    }
154
155    /// Remove a variable, searching from innermost to outermost frame.
156    ///
157    /// Returns the removed value if found, None otherwise.
158    pub fn remove(&mut self, name: &str) -> Option<Value> {
159        for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
160            if let Some(value) = frame.remove(name) {
161                return Some(value);
162            }
163        }
164        None
165    }
166
167    /// Set the last command result (accessible via `$?`).
168    pub fn set_last_result(&mut self, result: ExecResult) {
169        self.last_result = result;
170    }
171
172    /// Get the last command result.
173    pub fn last_result(&self) -> &ExecResult {
174        &self.last_result
175    }
176
177    /// Set the positional parameters ($0, $1-$9, $@, $#).
178    ///
179    /// The script_name becomes $0, and args become $1, $2, etc.
180    pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
181        self.script_name = script_name.into();
182        self.positional = args;
183    }
184
185    /// Save current positional parameters for later restoration.
186    ///
187    /// Returns (script_name, args) tuple that can be passed to set_positional.
188    pub fn save_positional(&self) -> (String, Vec<String>) {
189        (self.script_name.clone(), self.positional.clone())
190    }
191
192    /// Get a positional parameter by index ($0-$9).
193    ///
194    /// $0 returns the script name, $1-$9 return arguments.
195    pub fn get_positional(&self, n: usize) -> Option<&str> {
196        if n == 0 {
197            if self.script_name.is_empty() {
198                None
199            } else {
200                Some(&self.script_name)
201            }
202        } else {
203            self.positional.get(n - 1).map(|s| s.as_str())
204        }
205    }
206
207    /// Get all positional arguments as a slice ($@).
208    pub fn all_args(&self) -> &[String] {
209        &self.positional
210    }
211
212    /// Get the count of positional arguments ($#).
213    pub fn arg_count(&self) -> usize {
214        self.positional.len()
215    }
216
217    /// Check if error-exit mode is active (set -e and not suppressed).
218    ///
219    /// Returns false when inside the left side of `&&` or `||` chains,
220    /// matching bash behavior where those operators handle failure themselves.
221    pub fn error_exit_enabled(&self) -> bool {
222        self.error_exit && self.errexit_suppressed == 0
223    }
224
225    /// Set error-exit mode (set -e / set +e).
226    pub fn set_error_exit(&mut self, enabled: bool) {
227        self.error_exit = enabled;
228    }
229
230    /// Suppress errexit temporarily (for `&&`/`||` left side).
231    pub fn suppress_errexit(&mut self) {
232        self.errexit_suppressed += 1;
233    }
234
235    /// Unsuppress errexit (after `&&`/`||` left side completes).
236    pub fn unsuppress_errexit(&mut self) {
237        self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
238    }
239
240    /// Check if AST display mode is enabled (kaish-ast -on).
241    pub fn show_ast(&self) -> bool {
242        self.show_ast
243    }
244
245    /// Set AST display mode (kaish-ast -on / kaish-ast -off).
246    pub fn set_show_ast(&mut self, enabled: bool) {
247        self.show_ast = enabled;
248    }
249
250    /// Check if latch mode is enabled (set -o latch).
251    pub fn latch_enabled(&self) -> bool {
252        self.latch_enabled
253    }
254
255    /// Set latch mode (set -o latch / set +o latch).
256    pub fn set_latch_enabled(&mut self, enabled: bool) {
257        self.latch_enabled = enabled;
258    }
259
260    /// Check if trash mode is enabled (set -o trash).
261    pub fn trash_enabled(&self) -> bool {
262        self.trash_enabled
263    }
264
265    /// Set trash mode (set -o trash / set +o trash).
266    pub fn set_trash_enabled(&mut self, enabled: bool) {
267        self.trash_enabled = enabled;
268    }
269
270    /// Get the maximum file size for trash (bytes).
271    pub fn trash_max_size(&self) -> u64 {
272        self.trash_max_size
273    }
274
275    /// Set the maximum file size for trash (bytes).
276    pub fn set_trash_max_size(&mut self, size: u64) {
277        self.trash_max_size = size;
278    }
279
280    /// Check if glob expansion is enabled (set -o glob, default true).
281    pub fn glob_enabled(&self) -> bool {
282        self.glob_enabled
283    }
284
285    /// Set glob expansion mode (set -o glob / set +o glob).
286    pub fn set_glob_enabled(&mut self, enabled: bool) {
287        self.glob_enabled = enabled;
288    }
289
290    /// Mark a variable as exported (visible to child processes).
291    ///
292    /// The variable doesn't need to exist yet; it will be exported when set.
293    pub fn export(&mut self, name: impl Into<String>) {
294        self.exported.insert(name.into());
295    }
296
297    /// Check if a variable is marked for export.
298    pub fn is_exported(&self, name: &str) -> bool {
299        self.exported.contains(name)
300    }
301
302    /// Set a variable in the **innermost** frame and mark it as exported.
303    ///
304    /// Used for frame-scoped overlays (`execute_with_vars`, `FOO=bar cmd`) and
305    /// for seeding root-frame exports at construction. For the `export`
306    /// builtin's assignment form use [`set_exported_global`](Self::set_exported_global)
307    /// so the value survives a function return (shared-scope semantics).
308    pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
309        let name = name.into();
310        self.set(&name, value);
311        self.export(name);
312    }
313
314    /// Set a variable with **global** (shared-scope) semantics and mark it as
315    /// exported. This is `export NAME=VALUE`: like a plain assignment, the value
316    /// updates an existing variable wherever it lives or lands in the root frame,
317    /// so it persists past a function return rather than dying with the
318    /// function's frame.
319    pub fn set_exported_global(&mut self, name: impl Into<String>, value: Value) {
320        let name = name.into();
321        self.set_global(&name, value);
322        self.export(name);
323    }
324
325    /// Unmark a variable from export.
326    pub fn unexport(&mut self, name: &str) {
327        self.exported.remove(name);
328    }
329
330    /// Get all exported variables with their values.
331    ///
332    /// Only returns variables that exist and are marked for export.
333    pub fn exported_vars(&self) -> Vec<(String, Value)> {
334        let mut result = Vec::new();
335        for name in &self.exported {
336            if let Some(value) = self.get(name) {
337                result.push((name.clone(), value.clone()));
338            }
339        }
340        result.sort_by(|(a, _), (b, _)| a.cmp(b));
341        result
342    }
343
344    /// Get all exported variable names.
345    pub fn exported_names(&self) -> Vec<&str> {
346        let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
347        names.sort();
348        names
349    }
350
351    /// Resolve a variable path like `${VAR}` or `${VAR.field}`.
352    ///
353    /// Returns None if the path cannot be resolved.
354    /// `$?` resolves to the previous command's exit code as an int;
355    /// field access on `$?` is rejected by the validator before reaching here.
356    pub fn resolve_path(&self, path: &VarPath) -> Option<Value> {
357        if path.segments.is_empty() {
358            return None;
359        }
360
361        // Get the root variable name
362        let VarSegment::Field(root_name) = &path.segments[0];
363
364        // Special case: $? (last result)
365        if root_name == "?" {
366            return self.resolve_result_path(&path.segments[1..]);
367        }
368
369        // For regular variables, only simple access is supported
370        if path.segments.len() > 1 {
371            return None; // No nested field access for regular variables
372        }
373
374        self.get(root_name).cloned()
375    }
376
377    /// Resolve path segments on the last result ($?).
378    ///
379    /// `$?` alone returns the exit code as an integer (POSIX-shaped).
380    /// Field access on `$?` was removed — the validator rejects it with
381    /// a pointer to `kaish-last`, which exposes the previous command's
382    /// structured data (or stdout) as text.
383    fn resolve_result_path(&self, segments: &[VarSegment]) -> Option<Value> {
384        if segments.is_empty() {
385            return Some(Value::Int(self.last_result.code));
386        }
387        None
388    }
389
390    /// Check if a variable exists in any frame.
391    pub fn contains(&self, name: &str) -> bool {
392        self.get(name).is_some()
393    }
394
395    /// Get all variable names in scope (for debugging/introspection).
396    pub fn all_names(&self) -> Vec<&str> {
397        let mut names: Vec<&str> = self
398            .frames
399            .iter()
400            .flat_map(|f| f.keys().map(|s| s.as_str()))
401            .collect();
402        names.sort();
403        names.dedup();
404        names
405    }
406
407    /// Get all variables as (name, value) pairs.
408    ///
409    /// Variables are deduplicated, with inner frames shadowing outer ones.
410    pub fn all(&self) -> Vec<(String, Value)> {
411        let mut result = std::collections::HashMap::new();
412        // Iterate outer to inner so inner frames override
413        for frame in self.frames.iter() {
414            for (name, value) in frame {
415                result.insert(name.clone(), value.clone());
416            }
417        }
418        let mut pairs: Vec<_> = result.into_iter().collect();
419        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
420        pairs
421    }
422}
423
424impl Default for Scope {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn new_scope_has_one_frame() {
436        let scope = Scope::new();
437        assert_eq!(scope.frames.len(), 1);
438    }
439
440    #[test]
441    fn set_and_get_variable() {
442        let mut scope = Scope::new();
443        scope.set("X", Value::Int(42));
444        assert_eq!(scope.get("X"), Some(&Value::Int(42)));
445    }
446
447    #[test]
448    fn get_nonexistent_returns_none() {
449        let scope = Scope::new();
450        assert_eq!(scope.get("MISSING"), None);
451    }
452
453    #[test]
454    fn inner_frame_shadows_outer() {
455        let mut scope = Scope::new();
456        scope.set("X", Value::Int(1));
457        scope.push_frame();
458        scope.set("X", Value::Int(2));
459        assert_eq!(scope.get("X"), Some(&Value::Int(2)));
460        scope.pop_frame();
461        assert_eq!(scope.get("X"), Some(&Value::Int(1)));
462    }
463
464    #[test]
465    fn inner_frame_can_see_outer_vars() {
466        let mut scope = Scope::new();
467        scope.set("OUTER", Value::String("visible".into()));
468        scope.push_frame();
469        assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
470    }
471
472    #[test]
473    fn resolve_simple_path() {
474        let mut scope = Scope::new();
475        scope.set("NAME", Value::String("Alice".into()));
476
477        let path = VarPath::simple("NAME");
478        assert_eq!(
479            scope.resolve_path(&path),
480            Some(Value::String("Alice".into()))
481        );
482    }
483
484    #[test]
485    fn resolve_bare_last_result_returns_exit_code() {
486        let mut scope = Scope::new();
487        scope.set_last_result(ExecResult::failure(127, "not found"));
488
489        let path = VarPath {
490            segments: vec![VarSegment::Field("?".into())],
491        };
492        assert_eq!(scope.resolve_path(&path), Some(Value::Int(127)));
493    }
494
495    #[test]
496    fn resolve_last_result_field_access_is_rejected() {
497        // Field access on $? was removed — use `kaish-last` for structured data.
498        // The resolver returns None; the validator catches it earlier with a
499        // specific error code so users see actionable diagnostics.
500        let mut scope = Scope::new();
501        scope.set_last_result(ExecResult::success_with_data(
502            "1",
503            Value::Json(serde_json::json!({"count": 5})),
504        ));
505
506        let path = VarPath {
507            segments: vec![
508                VarSegment::Field("?".into()),
509                VarSegment::Field("data".into()),
510            ],
511        };
512        assert_eq!(scope.resolve_path(&path), None);
513    }
514
515    #[test]
516    fn resolve_invalid_path_returns_none() {
517        let mut scope = Scope::new();
518        scope.set("X", Value::Int(42));
519
520        // Cannot do field access on an int
521        let path = VarPath {
522            segments: vec![
523                VarSegment::Field("X".into()),
524                VarSegment::Field("invalid".into()),
525            ],
526        };
527        assert_eq!(scope.resolve_path(&path), None);
528    }
529
530    #[test]
531    fn contains_finds_variable() {
532        let mut scope = Scope::new();
533        scope.set("EXISTS", Value::Bool(true));
534        assert!(scope.contains("EXISTS"));
535        assert!(!scope.contains("MISSING"));
536    }
537
538    #[test]
539    fn all_names_lists_variables() {
540        let mut scope = Scope::new();
541        scope.set("A", Value::Int(1));
542        scope.set("B", Value::Int(2));
543        scope.push_frame();
544        scope.set("C", Value::Int(3));
545
546        let names = scope.all_names();
547        assert!(names.contains(&"A"));
548        assert!(names.contains(&"B"));
549        assert!(names.contains(&"C"));
550    }
551
552    #[test]
553    #[should_panic(expected = "cannot pop the root scope frame")]
554    fn pop_root_frame_panics() {
555        let mut scope = Scope::new();
556        scope.pop_frame();
557    }
558
559    #[test]
560    fn positional_params_basic() {
561        let mut scope = Scope::new();
562        scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
563
564        // $0 is the script/tool name
565        assert_eq!(scope.get_positional(0), Some("my_tool"));
566        // $1, $2, $3 are the arguments
567        assert_eq!(scope.get_positional(1), Some("arg1"));
568        assert_eq!(scope.get_positional(2), Some("arg2"));
569        assert_eq!(scope.get_positional(3), Some("arg3"));
570        // $4 doesn't exist
571        assert_eq!(scope.get_positional(4), None);
572    }
573
574    #[test]
575    fn positional_params_empty() {
576        let scope = Scope::new();
577        // No positional params set
578        assert_eq!(scope.get_positional(0), None);
579        assert_eq!(scope.get_positional(1), None);
580        assert_eq!(scope.arg_count(), 0);
581        assert!(scope.all_args().is_empty());
582    }
583
584    #[test]
585    fn all_args_returns_slice() {
586        let mut scope = Scope::new();
587        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
588
589        let args = scope.all_args();
590        assert_eq!(args, &["a", "b", "c"]);
591    }
592
593    #[test]
594    fn arg_count_returns_count() {
595        let mut scope = Scope::new();
596        scope.set_positional("test", vec!["one".into(), "two".into()]);
597
598        assert_eq!(scope.arg_count(), 2);
599    }
600
601    #[test]
602    fn export_marks_variable() {
603        let mut scope = Scope::new();
604        scope.set("X", Value::Int(42));
605
606        assert!(!scope.is_exported("X"));
607        scope.export("X");
608        assert!(scope.is_exported("X"));
609    }
610
611    #[test]
612    fn set_exported_sets_and_exports() {
613        let mut scope = Scope::new();
614        scope.set_exported("PATH", Value::String("/usr/bin".into()));
615
616        assert!(scope.is_exported("PATH"));
617        assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
618    }
619
620    #[test]
621    fn unexport_removes_export_marker() {
622        let mut scope = Scope::new();
623        scope.set_exported("VAR", Value::Int(1));
624        assert!(scope.is_exported("VAR"));
625
626        scope.unexport("VAR");
627        assert!(!scope.is_exported("VAR"));
628        // Variable still exists, just not exported
629        assert!(scope.get("VAR").is_some());
630    }
631
632    #[test]
633    fn exported_vars_returns_only_exported_with_values() {
634        let mut scope = Scope::new();
635        scope.set_exported("A", Value::Int(1));
636        scope.set_exported("B", Value::Int(2));
637        scope.set("C", Value::Int(3)); // Not exported
638        scope.export("D"); // Exported but no value
639
640        let exported = scope.exported_vars();
641        assert_eq!(exported.len(), 2);
642        assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
643        assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
644    }
645
646    #[test]
647    fn exported_names_returns_sorted_names() {
648        let mut scope = Scope::new();
649        scope.export("Z");
650        scope.export("A");
651        scope.export("M");
652
653        let names = scope.exported_names();
654        assert_eq!(names, vec!["A", "M", "Z"]);
655    }
656}