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 and mark it as exported.
303    pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
304        let name = name.into();
305        self.set(&name, value);
306        self.export(name);
307    }
308
309    /// Unmark a variable from export.
310    pub fn unexport(&mut self, name: &str) {
311        self.exported.remove(name);
312    }
313
314    /// Get all exported variables with their values.
315    ///
316    /// Only returns variables that exist and are marked for export.
317    pub fn exported_vars(&self) -> Vec<(String, Value)> {
318        let mut result = Vec::new();
319        for name in &self.exported {
320            if let Some(value) = self.get(name) {
321                result.push((name.clone(), value.clone()));
322            }
323        }
324        result.sort_by(|(a, _), (b, _)| a.cmp(b));
325        result
326    }
327
328    /// Get all exported variable names.
329    pub fn exported_names(&self) -> Vec<&str> {
330        let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
331        names.sort();
332        names
333    }
334
335    /// Resolve a variable path like `${VAR}` or `${VAR.field}`.
336    ///
337    /// Returns None if the path cannot be resolved.
338    /// `$?` resolves to the previous command's exit code as an int;
339    /// field access on `$?` is rejected by the validator before reaching here.
340    pub fn resolve_path(&self, path: &VarPath) -> Option<Value> {
341        if path.segments.is_empty() {
342            return None;
343        }
344
345        // Get the root variable name
346        let VarSegment::Field(root_name) = &path.segments[0];
347
348        // Special case: $? (last result)
349        if root_name == "?" {
350            return self.resolve_result_path(&path.segments[1..]);
351        }
352
353        // For regular variables, only simple access is supported
354        if path.segments.len() > 1 {
355            return None; // No nested field access for regular variables
356        }
357
358        self.get(root_name).cloned()
359    }
360
361    /// Resolve path segments on the last result ($?).
362    ///
363    /// `$?` alone returns the exit code as an integer (POSIX-shaped).
364    /// Field access on `$?` was removed — the validator rejects it with
365    /// a pointer to `kaish-last`, which exposes the previous command's
366    /// structured data (or stdout) as text.
367    fn resolve_result_path(&self, segments: &[VarSegment]) -> Option<Value> {
368        if segments.is_empty() {
369            return Some(Value::Int(self.last_result.code));
370        }
371        None
372    }
373
374    /// Check if a variable exists in any frame.
375    pub fn contains(&self, name: &str) -> bool {
376        self.get(name).is_some()
377    }
378
379    /// Get all variable names in scope (for debugging/introspection).
380    pub fn all_names(&self) -> Vec<&str> {
381        let mut names: Vec<&str> = self
382            .frames
383            .iter()
384            .flat_map(|f| f.keys().map(|s| s.as_str()))
385            .collect();
386        names.sort();
387        names.dedup();
388        names
389    }
390
391    /// Get all variables as (name, value) pairs.
392    ///
393    /// Variables are deduplicated, with inner frames shadowing outer ones.
394    pub fn all(&self) -> Vec<(String, Value)> {
395        let mut result = std::collections::HashMap::new();
396        // Iterate outer to inner so inner frames override
397        for frame in self.frames.iter() {
398            for (name, value) in frame {
399                result.insert(name.clone(), value.clone());
400            }
401        }
402        let mut pairs: Vec<_> = result.into_iter().collect();
403        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
404        pairs
405    }
406}
407
408impl Default for Scope {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn new_scope_has_one_frame() {
420        let scope = Scope::new();
421        assert_eq!(scope.frames.len(), 1);
422    }
423
424    #[test]
425    fn set_and_get_variable() {
426        let mut scope = Scope::new();
427        scope.set("X", Value::Int(42));
428        assert_eq!(scope.get("X"), Some(&Value::Int(42)));
429    }
430
431    #[test]
432    fn get_nonexistent_returns_none() {
433        let scope = Scope::new();
434        assert_eq!(scope.get("MISSING"), None);
435    }
436
437    #[test]
438    fn inner_frame_shadows_outer() {
439        let mut scope = Scope::new();
440        scope.set("X", Value::Int(1));
441        scope.push_frame();
442        scope.set("X", Value::Int(2));
443        assert_eq!(scope.get("X"), Some(&Value::Int(2)));
444        scope.pop_frame();
445        assert_eq!(scope.get("X"), Some(&Value::Int(1)));
446    }
447
448    #[test]
449    fn inner_frame_can_see_outer_vars() {
450        let mut scope = Scope::new();
451        scope.set("OUTER", Value::String("visible".into()));
452        scope.push_frame();
453        assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
454    }
455
456    #[test]
457    fn resolve_simple_path() {
458        let mut scope = Scope::new();
459        scope.set("NAME", Value::String("Alice".into()));
460
461        let path = VarPath::simple("NAME");
462        assert_eq!(
463            scope.resolve_path(&path),
464            Some(Value::String("Alice".into()))
465        );
466    }
467
468    #[test]
469    fn resolve_bare_last_result_returns_exit_code() {
470        let mut scope = Scope::new();
471        scope.set_last_result(ExecResult::failure(127, "not found"));
472
473        let path = VarPath {
474            segments: vec![VarSegment::Field("?".into())],
475        };
476        assert_eq!(scope.resolve_path(&path), Some(Value::Int(127)));
477    }
478
479    #[test]
480    fn resolve_last_result_field_access_is_rejected() {
481        // Field access on $? was removed — use `kaish-last` for structured data.
482        // The resolver returns None; the validator catches it earlier with a
483        // specific error code so users see actionable diagnostics.
484        let mut scope = Scope::new();
485        scope.set_last_result(ExecResult::success_with_data(
486            "1",
487            Value::Json(serde_json::json!({"count": 5})),
488        ));
489
490        let path = VarPath {
491            segments: vec![
492                VarSegment::Field("?".into()),
493                VarSegment::Field("data".into()),
494            ],
495        };
496        assert_eq!(scope.resolve_path(&path), None);
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}