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 tracking the last command result
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    /// Current process ID ($$), captured at scope creation.
53    pid: u32,
54}
55
56impl Scope {
57    /// Create a new scope with one empty frame.
58    pub fn new() -> Self {
59        Self {
60            frames: Arc::new(vec![HashMap::new()]),
61            exported: HashSet::new(),
62            last_result: ExecResult::default(),
63            script_name: String::new(),
64            positional: Vec::new(),
65            error_exit: false,
66            errexit_suppressed: 0,
67            show_ast: false,
68            latch_enabled: false,
69            trash_enabled: false,
70            trash_max_size: 10 * 1024 * 1024, // 10 MB
71            pid: std::process::id(),
72        }
73    }
74
75    /// Get the process ID ($$).
76    pub fn pid(&self) -> u32 {
77        self.pid
78    }
79
80    /// Push a new scope frame (for entering a loop, tool call, etc.)
81    pub fn push_frame(&mut self) {
82        Arc::make_mut(&mut self.frames).push(HashMap::new());
83    }
84
85    /// Pop the innermost scope frame.
86    ///
87    /// Panics if attempting to pop the last frame.
88    pub fn pop_frame(&mut self) {
89        if self.frames.len() > 1 {
90            Arc::make_mut(&mut self.frames).pop();
91        } else {
92            panic!("cannot pop the root scope frame");
93        }
94    }
95
96    /// Set a variable in the current (innermost) frame.
97    ///
98    /// Use this for `local` variable declarations.
99    pub fn set(&mut self, name: impl Into<String>, value: Value) {
100        if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
101            frame.insert(name.into(), value);
102        }
103    }
104
105    /// Set a variable with global semantics (shell default).
106    ///
107    /// If the variable exists in any frame, update it there.
108    /// Otherwise, create it in the outermost (root) frame.
109    /// Use this for non-local variable assignments.
110    pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
111        let name = name.into();
112
113        // Search from innermost to outermost to find existing variable
114        let frames = Arc::make_mut(&mut self.frames);
115        for frame in frames.iter_mut().rev() {
116            if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
117                e.insert(value);
118                return;
119            }
120        }
121
122        // Variable doesn't exist - create in root frame (index 0)
123        if let Some(frame) = frames.first_mut() {
124            frame.insert(name, value);
125        }
126    }
127
128    /// Get a variable by name, searching from innermost to outermost frame.
129    pub fn get(&self, name: &str) -> Option<&Value> {
130        for frame in self.frames.iter().rev() {
131            if let Some(value) = frame.get(name) {
132                return Some(value);
133            }
134        }
135        None
136    }
137
138    /// Remove a variable, searching from innermost to outermost frame.
139    ///
140    /// Returns the removed value if found, None otherwise.
141    pub fn remove(&mut self, name: &str) -> Option<Value> {
142        for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
143            if let Some(value) = frame.remove(name) {
144                return Some(value);
145            }
146        }
147        None
148    }
149
150    /// Set the last command result (accessible via `$?`).
151    pub fn set_last_result(&mut self, result: ExecResult) {
152        self.last_result = result;
153    }
154
155    /// Get the last command result.
156    pub fn last_result(&self) -> &ExecResult {
157        &self.last_result
158    }
159
160    /// Set the positional parameters ($0, $1-$9, $@, $#).
161    ///
162    /// The script_name becomes $0, and args become $1, $2, etc.
163    pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
164        self.script_name = script_name.into();
165        self.positional = args;
166    }
167
168    /// Save current positional parameters for later restoration.
169    ///
170    /// Returns (script_name, args) tuple that can be passed to set_positional.
171    pub fn save_positional(&self) -> (String, Vec<String>) {
172        (self.script_name.clone(), self.positional.clone())
173    }
174
175    /// Get a positional parameter by index ($0-$9).
176    ///
177    /// $0 returns the script name, $1-$9 return arguments.
178    pub fn get_positional(&self, n: usize) -> Option<&str> {
179        if n == 0 {
180            if self.script_name.is_empty() {
181                None
182            } else {
183                Some(&self.script_name)
184            }
185        } else {
186            self.positional.get(n - 1).map(|s| s.as_str())
187        }
188    }
189
190    /// Get all positional arguments as a slice ($@).
191    pub fn all_args(&self) -> &[String] {
192        &self.positional
193    }
194
195    /// Get the count of positional arguments ($#).
196    pub fn arg_count(&self) -> usize {
197        self.positional.len()
198    }
199
200    /// Check if error-exit mode is active (set -e and not suppressed).
201    ///
202    /// Returns false when inside the left side of `&&` or `||` chains,
203    /// matching bash behavior where those operators handle failure themselves.
204    pub fn error_exit_enabled(&self) -> bool {
205        self.error_exit && self.errexit_suppressed == 0
206    }
207
208    /// Set error-exit mode (set -e / set +e).
209    pub fn set_error_exit(&mut self, enabled: bool) {
210        self.error_exit = enabled;
211    }
212
213    /// Suppress errexit temporarily (for `&&`/`||` left side).
214    pub fn suppress_errexit(&mut self) {
215        self.errexit_suppressed += 1;
216    }
217
218    /// Unsuppress errexit (after `&&`/`||` left side completes).
219    pub fn unsuppress_errexit(&mut self) {
220        self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
221    }
222
223    /// Check if AST display mode is enabled (kaish-ast -on).
224    pub fn show_ast(&self) -> bool {
225        self.show_ast
226    }
227
228    /// Set AST display mode (kaish-ast -on / kaish-ast -off).
229    pub fn set_show_ast(&mut self, enabled: bool) {
230        self.show_ast = enabled;
231    }
232
233    /// Check if latch mode is enabled (set -o latch).
234    pub fn latch_enabled(&self) -> bool {
235        self.latch_enabled
236    }
237
238    /// Set latch mode (set -o latch / set +o latch).
239    pub fn set_latch_enabled(&mut self, enabled: bool) {
240        self.latch_enabled = enabled;
241    }
242
243    /// Check if trash mode is enabled (set -o trash).
244    pub fn trash_enabled(&self) -> bool {
245        self.trash_enabled
246    }
247
248    /// Set trash mode (set -o trash / set +o trash).
249    pub fn set_trash_enabled(&mut self, enabled: bool) {
250        self.trash_enabled = enabled;
251    }
252
253    /// Get the maximum file size for trash (bytes).
254    pub fn trash_max_size(&self) -> u64 {
255        self.trash_max_size
256    }
257
258    /// Set the maximum file size for trash (bytes).
259    pub fn set_trash_max_size(&mut self, size: u64) {
260        self.trash_max_size = size;
261    }
262
263    /// Mark a variable as exported (visible to child processes).
264    ///
265    /// The variable doesn't need to exist yet; it will be exported when set.
266    pub fn export(&mut self, name: impl Into<String>) {
267        self.exported.insert(name.into());
268    }
269
270    /// Check if a variable is marked for export.
271    pub fn is_exported(&self, name: &str) -> bool {
272        self.exported.contains(name)
273    }
274
275    /// Set a variable and mark it as exported.
276    pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
277        let name = name.into();
278        self.set(&name, value);
279        self.export(name);
280    }
281
282    /// Unmark a variable from export.
283    pub fn unexport(&mut self, name: &str) {
284        self.exported.remove(name);
285    }
286
287    /// Get all exported variables with their values.
288    ///
289    /// Only returns variables that exist and are marked for export.
290    pub fn exported_vars(&self) -> Vec<(String, Value)> {
291        let mut result = Vec::new();
292        for name in &self.exported {
293            if let Some(value) = self.get(name) {
294                result.push((name.clone(), value.clone()));
295            }
296        }
297        result.sort_by(|(a, _), (b, _)| a.cmp(b));
298        result
299    }
300
301    /// Get all exported variable names.
302    pub fn exported_names(&self) -> Vec<&str> {
303        let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
304        names.sort();
305        names
306    }
307
308    /// Resolve a variable path like `${VAR}` or `${?.field}`.
309    ///
310    /// Returns None if the path cannot be resolved.
311    /// Field access is only supported for the special `$?` variable.
312    pub fn resolve_path(&self, path: &VarPath) -> Option<Value> {
313        if path.segments.is_empty() {
314            return None;
315        }
316
317        // Get the root variable name
318        let VarSegment::Field(root_name) = &path.segments[0];
319
320        // Special case: $? (last result)
321        if root_name == "?" {
322            return self.resolve_result_path(&path.segments[1..]);
323        }
324
325        // For regular variables, only simple access is supported
326        if path.segments.len() > 1 {
327            return None; // No nested field access for regular variables
328        }
329
330        self.get(root_name).cloned()
331    }
332
333    /// Resolve path segments on the last result ($?).
334    ///
335    /// `$?` alone returns the exit code as an integer (0-255).
336    /// `${?.code}`, `${?.ok}`, `${?.out}`, `${?.err}` access specific fields.
337    fn resolve_result_path(&self, segments: &[VarSegment]) -> Option<Value> {
338        if segments.is_empty() {
339            // $? alone returns just the exit code as an integer (bash-compatible)
340            return Some(Value::Int(self.last_result.code));
341        }
342
343        // Allow ${?.code}, ${?.ok}, etc.
344        let VarSegment::Field(field_name) = &segments[0];
345
346        // Only single-level field access on $?
347        if segments.len() > 1 {
348            return None;
349        }
350
351        // Get the field value from the result
352        self.last_result.get_field(field_name)
353    }
354
355    /// Check if a variable exists in any frame.
356    pub fn contains(&self, name: &str) -> bool {
357        self.get(name).is_some()
358    }
359
360    /// Get all variable names in scope (for debugging/introspection).
361    pub fn all_names(&self) -> Vec<&str> {
362        let mut names: Vec<&str> = self
363            .frames
364            .iter()
365            .flat_map(|f| f.keys().map(|s| s.as_str()))
366            .collect();
367        names.sort();
368        names.dedup();
369        names
370    }
371
372    /// Get all variables as (name, value) pairs.
373    ///
374    /// Variables are deduplicated, with inner frames shadowing outer ones.
375    pub fn all(&self) -> Vec<(String, Value)> {
376        let mut result = std::collections::HashMap::new();
377        // Iterate outer to inner so inner frames override
378        for frame in self.frames.iter() {
379            for (name, value) in frame {
380                result.insert(name.clone(), value.clone());
381            }
382        }
383        let mut pairs: Vec<_> = result.into_iter().collect();
384        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
385        pairs
386    }
387}
388
389impl Default for Scope {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn new_scope_has_one_frame() {
401        let scope = Scope::new();
402        assert_eq!(scope.frames.len(), 1);
403    }
404
405    #[test]
406    fn set_and_get_variable() {
407        let mut scope = Scope::new();
408        scope.set("X", Value::Int(42));
409        assert_eq!(scope.get("X"), Some(&Value::Int(42)));
410    }
411
412    #[test]
413    fn get_nonexistent_returns_none() {
414        let scope = Scope::new();
415        assert_eq!(scope.get("MISSING"), None);
416    }
417
418    #[test]
419    fn inner_frame_shadows_outer() {
420        let mut scope = Scope::new();
421        scope.set("X", Value::Int(1));
422        scope.push_frame();
423        scope.set("X", Value::Int(2));
424        assert_eq!(scope.get("X"), Some(&Value::Int(2)));
425        scope.pop_frame();
426        assert_eq!(scope.get("X"), Some(&Value::Int(1)));
427    }
428
429    #[test]
430    fn inner_frame_can_see_outer_vars() {
431        let mut scope = Scope::new();
432        scope.set("OUTER", Value::String("visible".into()));
433        scope.push_frame();
434        assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
435    }
436
437    #[test]
438    fn resolve_simple_path() {
439        let mut scope = Scope::new();
440        scope.set("NAME", Value::String("Alice".into()));
441
442        let path = VarPath::simple("NAME");
443        assert_eq!(
444            scope.resolve_path(&path),
445            Some(Value::String("Alice".into()))
446        );
447    }
448
449    #[test]
450    fn resolve_last_result_ok() {
451        let mut scope = Scope::new();
452        scope.set_last_result(ExecResult::success("output"));
453
454        let path = VarPath {
455            segments: vec![
456                VarSegment::Field("?".into()),
457                VarSegment::Field("ok".into()),
458            ],
459        };
460        assert_eq!(scope.resolve_path(&path), Some(Value::Bool(true)));
461    }
462
463    #[test]
464    fn resolve_last_result_code() {
465        let mut scope = Scope::new();
466        scope.set_last_result(ExecResult::failure(127, "not found"));
467
468        let path = VarPath {
469            segments: vec![
470                VarSegment::Field("?".into()),
471                VarSegment::Field("code".into()),
472            ],
473        };
474        assert_eq!(scope.resolve_path(&path), Some(Value::Int(127)));
475    }
476
477    #[test]
478    fn resolve_last_result_data_field() {
479        let mut scope = Scope::new();
480        scope.set_last_result(ExecResult::success(r#"{"count": 5}"#));
481
482        // ${?.data} - only single-level field access is supported on $?
483        let path = VarPath {
484            segments: vec![
485                VarSegment::Field("?".into()),
486                VarSegment::Field("data".into()),
487            ],
488        };
489        // data is now a Value::Json for structured data
490        let result = scope.resolve_path(&path);
491        assert!(result.is_some());
492        if let Some(Value::Json(json)) = result {
493            assert_eq!(json.get("count"), Some(&serde_json::json!(5)));
494        } else {
495            panic!("expected Value::Json, got {:?}", result);
496        }
497    }
498
499    #[test]
500    fn resolve_invalid_path_returns_none() {
501        let mut scope = Scope::new();
502        scope.set("X", Value::Int(42));
503
504        // Cannot do field access on an int
505        let path = VarPath {
506            segments: vec![
507                VarSegment::Field("X".into()),
508                VarSegment::Field("invalid".into()),
509            ],
510        };
511        assert_eq!(scope.resolve_path(&path), None);
512    }
513
514    #[test]
515    fn contains_finds_variable() {
516        let mut scope = Scope::new();
517        scope.set("EXISTS", Value::Bool(true));
518        assert!(scope.contains("EXISTS"));
519        assert!(!scope.contains("MISSING"));
520    }
521
522    #[test]
523    fn all_names_lists_variables() {
524        let mut scope = Scope::new();
525        scope.set("A", Value::Int(1));
526        scope.set("B", Value::Int(2));
527        scope.push_frame();
528        scope.set("C", Value::Int(3));
529
530        let names = scope.all_names();
531        assert!(names.contains(&"A"));
532        assert!(names.contains(&"B"));
533        assert!(names.contains(&"C"));
534    }
535
536    #[test]
537    #[should_panic(expected = "cannot pop the root scope frame")]
538    fn pop_root_frame_panics() {
539        let mut scope = Scope::new();
540        scope.pop_frame();
541    }
542
543    #[test]
544    fn positional_params_basic() {
545        let mut scope = Scope::new();
546        scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
547
548        // $0 is the script/tool name
549        assert_eq!(scope.get_positional(0), Some("my_tool"));
550        // $1, $2, $3 are the arguments
551        assert_eq!(scope.get_positional(1), Some("arg1"));
552        assert_eq!(scope.get_positional(2), Some("arg2"));
553        assert_eq!(scope.get_positional(3), Some("arg3"));
554        // $4 doesn't exist
555        assert_eq!(scope.get_positional(4), None);
556    }
557
558    #[test]
559    fn positional_params_empty() {
560        let scope = Scope::new();
561        // No positional params set
562        assert_eq!(scope.get_positional(0), None);
563        assert_eq!(scope.get_positional(1), None);
564        assert_eq!(scope.arg_count(), 0);
565        assert!(scope.all_args().is_empty());
566    }
567
568    #[test]
569    fn all_args_returns_slice() {
570        let mut scope = Scope::new();
571        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
572
573        let args = scope.all_args();
574        assert_eq!(args, &["a", "b", "c"]);
575    }
576
577    #[test]
578    fn arg_count_returns_count() {
579        let mut scope = Scope::new();
580        scope.set_positional("test", vec!["one".into(), "two".into()]);
581
582        assert_eq!(scope.arg_count(), 2);
583    }
584
585    #[test]
586    fn export_marks_variable() {
587        let mut scope = Scope::new();
588        scope.set("X", Value::Int(42));
589
590        assert!(!scope.is_exported("X"));
591        scope.export("X");
592        assert!(scope.is_exported("X"));
593    }
594
595    #[test]
596    fn set_exported_sets_and_exports() {
597        let mut scope = Scope::new();
598        scope.set_exported("PATH", Value::String("/usr/bin".into()));
599
600        assert!(scope.is_exported("PATH"));
601        assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
602    }
603
604    #[test]
605    fn unexport_removes_export_marker() {
606        let mut scope = Scope::new();
607        scope.set_exported("VAR", Value::Int(1));
608        assert!(scope.is_exported("VAR"));
609
610        scope.unexport("VAR");
611        assert!(!scope.is_exported("VAR"));
612        // Variable still exists, just not exported
613        assert!(scope.get("VAR").is_some());
614    }
615
616    #[test]
617    fn exported_vars_returns_only_exported_with_values() {
618        let mut scope = Scope::new();
619        scope.set_exported("A", Value::Int(1));
620        scope.set_exported("B", Value::Int(2));
621        scope.set("C", Value::Int(3)); // Not exported
622        scope.export("D"); // Exported but no value
623
624        let exported = scope.exported_vars();
625        assert_eq!(exported.len(), 2);
626        assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
627        assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
628    }
629
630    #[test]
631    fn exported_names_returns_sorted_names() {
632        let mut scope = Scope::new();
633        scope.export("Z");
634        scope.export("A");
635        scope.export("M");
636
637        let names = scope.exported_names();
638        assert_eq!(names, vec!["A", "M", "Z"]);
639    }
640}