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::borrow::Cow;
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use kaish_types::{json_to_value_no_envelope, value_to_json};
13
14use crate::ast::{Value, VarPath, VarSegment};
15
16use super::eval::value_to_string;
17use super::result::ExecResult;
18
19/// Why a variable path failed to resolve.
20///
21/// The three-way split is load-bearing for `${path:-default}` (decision A): the
22/// default fires on **absence** but never on a **shape** error — a wrong-typed
23/// access is a bug, not a missing value. All three are loud for a bare access;
24/// they diverge only when `:-` is present (see the default handling), where
25/// `UndefinedRoot`/`Absence` yield the default and `Shape` still shouts.
26///
27/// - `UndefinedRoot` — the root variable (or an unset dynamic `$k` subscript) is
28///   not in scope. Soft in string interpolation (expands to empty, matching
29///   bash), loud in expression position.
30/// - `Absence` — the path is well-shaped but the target isn't there: a missing
31///   record key or an out-of-bounds list index.
32/// - `Shape` — the access is wrong for the value: a string key on a list, an
33///   integer index on a record, subscripting a scalar, a dotted (non-bracket)
34///   segment, or slicing a record. Never suppressed by `:-`.
35///
36/// A loud error is surfaced everywhere, including inside strings; it is NEVER
37/// silently swallowed to an empty expansion.
38#[derive(Debug, Clone, PartialEq)]
39#[non_exhaustive]
40pub enum PathError {
41    /// The root variable is not in scope (or an unset dynamic `$k` subscript).
42    UndefinedRoot(String),
43    /// A missing key or out-of-bounds index — absence, not misuse.
44    Absence(String),
45    /// A wrong-for-the-shape access. Message ready to display.
46    Shape(String),
47}
48
49/// A human-readable type name for a value, for path error messages.
50fn type_name(value: &Value) -> &'static str {
51    match value {
52        Value::Null => "null",
53        Value::Bool(_) => "a boolean",
54        Value::Int(_) => "an integer",
55        Value::Float(_) => "a float",
56        Value::String(_) => "a string",
57        Value::Json(serde_json::Value::Array(_)) => "a list",
58        Value::Json(serde_json::Value::Object(_)) => "a record",
59        Value::Json(_) => "a scalar",
60        Value::Bytes(_) => "binary data",
61    }
62}
63
64/// A dynamic subscript value usable as a list index: an integer, or a string
65/// that parses as one (`k=1; ${xs[$k]}`).
66fn value_as_index(value: &Value) -> Option<i64> {
67    match value {
68        Value::Int(i) => Some(*i),
69        Value::String(s) => s.parse::<i64>().ok(),
70        _ => None,
71    }
72}
73
74/// A concrete, container-resolved subscript. [`resolve_step`] produces one per
75/// hop *after* seeing the container — negative indices normalized, bounds
76/// checked, dynamic keys looked up — so read traversal and (next phase) lvalue
77/// writes share one classification and can never drift. Record-key *presence*
78/// is deliberately NOT decided here: that is per-hop walk policy (a read errors
79/// on a missing key; a write leaf inserts it).
80#[derive(Debug, Clone, PartialEq)]
81enum Step {
82    /// A validated, in-bounds list index.
83    Index(usize),
84    /// A record key (existence unchecked — see the type doc).
85    Key(String),
86    /// A normalized, end-exclusive slice range (`s <= e <= len`).
87    Slice(usize, usize),
88}
89
90/// Classify a list index against an array. Negative indices count from the end;
91/// out of bounds is a loud error, and an integer subscript on a record is an
92/// error (keys are strings — the design's "integers index lists" rule). The
93/// container is a collection by [`resolve_step`]'s guard, so the scalar arm is
94/// unreachable.
95fn classify_index(json: &serde_json::Value, i: i64, path: &str) -> Result<Step, PathError> {
96    let arr = match json {
97        serde_json::Value::Array(a) => a,
98        serde_json::Value::Object(_) => {
99            return Err(PathError::Shape(format!(
100                "${{{path}[{i}]}}: integer index on a record — record keys are strings, use ${{{path}[\"{i}\"]}}"
101            )))
102        }
103        _ => unreachable!("resolve_step guards non-collection containers"),
104    };
105    let len = arr.len() as i64;
106    let idx = if i < 0 { len + i } else { i };
107    if idx < 0 || idx >= len {
108        return Err(PathError::Absence(format!(
109            "${{{path}[{i}]}}: index out of bounds (list length {len})"
110        )));
111    }
112    Ok(Step::Index(idx as usize))
113}
114
115/// Classify a record key against an object. A bareword/string key on a list is
116/// an error; key *presence* is checked when the step is applied ([`descend`]),
117/// not here — the read/write split lives in that leaf policy.
118fn classify_key(json: &serde_json::Value, key: &str, path: &str) -> Result<Step, PathError> {
119    match json {
120        serde_json::Value::Object(_) => Ok(Step::Key(key.to_string())),
121        serde_json::Value::Array(_) => Err(PathError::Shape(format!(
122            "${{{path}[{key}]}}: string key on a list — use an integer index"
123        ))),
124        _ => unreachable!("resolve_step guards non-collection containers"),
125    }
126}
127
128/// Classify a slice against an array, end-exclusive. Bounds clamp; negatives
129/// count from the end; an inverted or empty range yields an empty range.
130/// Slicing a record is an error.
131fn classify_slice(
132    json: &serde_json::Value,
133    start: Option<i64>,
134    end: Option<i64>,
135    path: &str,
136) -> Result<Step, PathError> {
137    // A string slices by CHARACTERS, not bytes: kaish refuses lossy text
138    // everywhere else, and a byte range can split a multi-byte sequence.
139    let len = match json {
140        serde_json::Value::Array(a) => a.len() as i64,
141        serde_json::Value::String(s) => s.chars().count() as i64,
142        serde_json::Value::Object(_) => {
143            return Err(PathError::Shape(format!(
144                "${{{path}[..]}}: cannot slice a record"
145            )))
146        }
147        _ => unreachable!("resolve_step guards non-sliceable containers"),
148    };
149    let norm = |b: i64| -> i64 {
150        let b = if b < 0 { len + b } else { b };
151        b.clamp(0, len)
152    };
153    let s = start.map(norm).unwrap_or(0);
154    let e = end.map(norm).unwrap_or(len);
155    let (s, e) = if s >= e {
156        (s as usize, s as usize)
157    } else {
158        (s as usize, e as usize)
159    };
160    Ok(Step::Slice(s, e))
161}
162
163/// A dotted `.field` access. Brackets-only: always a loud error, with the
164/// bracket fix in the message. Shared by the root pre-check and `resolve_step`
165/// so a dotted segment reports identically at any hop.
166fn dotted_access_error(path: &str, field: &str) -> PathError {
167    PathError::Shape(format!(
168        "${{{path}…}}: kaish uses bracket access, not dots — write the key as a subscript: [{field}]"
169    ))
170}
171
172/// Render a subscript as the user wrote it, for building the path prefix that
173/// error messages carry (`a` → `a[b]` → `a[b][0]`) so a nested failure names the
174/// real path, not just the root.
175fn render_segment(seg: &VarSegment) -> String {
176    match seg {
177        VarSegment::Index(i) => format!("[{i}]"),
178        VarSegment::Key(k) => format!("[{k}]"),
179        VarSegment::Dynamic(v) => format!("[${v}]"),
180        VarSegment::Slice(a, b) => format!(
181            "[{}:{}]",
182            a.map(|n| n.to_string()).unwrap_or_default(),
183            b.map(|n| n.to_string()).unwrap_or_default()
184        ),
185        VarSegment::Field(f) => format!(".{f}"),
186    }
187}
188
189/// Classify one subscript against its container — the shared per-hop unit that
190/// keeps read traversal and (next phase) lvalue writes from diverging. Only the
191/// dynamic-key arm needs the scope (to look up `$k`); everything else is a pure
192/// function of the container and segment. A non-collection container is caught
193/// here once, so the `classify_*` helpers never see a scalar.
194fn resolve_step(
195    container: &serde_json::Value,
196    seg: &VarSegment,
197    scope: &Scope,
198    path: &str,
199) -> Result<Step, PathError> {
200    // A non-root `Field` is a dotted `.field` access — checked before the
201    // container guard so a dotted segment always wins over a "not a collection"
202    // message (the per-hop precedence the old walker had).
203    if let VarSegment::Field(name) = seg {
204        return Err(dotted_access_error(path, name));
205    }
206
207    // A string is sliceable but not indexable. `${s[0:5]}` is the first five
208    // characters; `${s[0]}` has no meaning kaish defines, since an index picks
209    // an element and a string has no elements. The error says which is which
210    // rather than only refusing.
211    if matches!(container, serde_json::Value::String(_)) {
212        return match seg {
213            VarSegment::Slice(start, end) => classify_slice(container, *start, *end, path),
214            _ => Err(PathError::Shape(format!(
215                "${{{path}…}}: cannot subscript a string — slice it instead, \
216                 e.g. ${{{path}[0:5]}} for the first five characters"
217            ))),
218        };
219    }
220
221    // Every remaining subscript needs a collection container.
222    if !matches!(
223        container,
224        serde_json::Value::Array(_) | serde_json::Value::Object(_)
225    ) {
226        return Err(PathError::Shape(format!(
227            "${{{path}…}}: cannot subscript {} — it is not a collection",
228            type_name(&json_to_value_no_envelope(container.clone()))
229        )));
230    }
231
232    match seg {
233        VarSegment::Index(i) => classify_index(container, *i, path),
234        VarSegment::Key(k) => classify_key(container, k, path),
235        VarSegment::Slice(start, end) => classify_slice(container, *start, *end, path),
236        VarSegment::Dynamic(var) => {
237            // The variable's value is the subscript; the container type decides
238            // whether it's an index or a key. An unset `$k` is UndefinedRoot,
239            // not Absence — the *variable* is missing, so `${r[$k]:-d}` defaults.
240            let key_val = scope.get(var).ok_or_else(|| {
241                PathError::UndefinedRoot(format!("${{{path}[${var}]}}: ${var} is not set"))
242            })?;
243            match container {
244                serde_json::Value::Array(_) => {
245                    let idx = value_as_index(key_val).ok_or_else(|| {
246                        PathError::Shape(format!(
247                            "${{{path}[${var}]}}: a list index must be an integer, got \"{}\"",
248                            value_to_string(key_val)
249                        ))
250                    })?;
251                    classify_index(container, idx, path)
252                }
253                serde_json::Value::Object(_) => Ok(Step::Key(value_to_string(key_val))),
254                _ => unreachable!("non-collection container guarded above"),
255            }
256        }
257        VarSegment::Field(_) => unreachable!("dotted segment handled above"),
258    }
259}
260
261/// Apply one classified step, descending the borrowed JSON tree. Borrowed input
262/// stays borrowed for index/key (no clone); a slice always allocates a new list,
263/// and once owned (post-slice) descent clones the selected child. A missing
264/// record key is a loud read error here — the write-leaf insert is the next
265/// phase, and lives in the walk, not in [`resolve_step`].
266fn descend<'a>(
267    current: Cow<'a, serde_json::Value>,
268    step: Step,
269    path: &str,
270) -> Result<Cow<'a, serde_json::Value>, PathError> {
271    match step {
272        Step::Slice(s, e) => match current.as_ref() {
273            serde_json::Value::Array(arr) => {
274                Ok(Cow::Owned(serde_json::Value::Array(arr[s..e].to_vec())))
275            }
276            // Char-indexed, matching `classify_slice`'s char-based bounds.
277            serde_json::Value::String(text) => Ok(Cow::Owned(serde_json::Value::String(
278                text.chars().skip(s).take(e - s).collect(),
279            ))),
280            _ => unreachable!("slice classified against an array or string"),
281        },
282        Step::Index(i) => match current {
283            Cow::Borrowed(j) => {
284                let Some(arr) = j.as_array() else {
285                    unreachable!("index classified against an array")
286                };
287                Ok(Cow::Borrowed(&arr[i]))
288            }
289            Cow::Owned(j) => {
290                let Some(arr) = j.as_array() else {
291                    unreachable!("index classified against an array")
292                };
293                Ok(Cow::Owned(arr[i].clone()))
294            }
295        },
296        Step::Key(k) => match current {
297            Cow::Borrowed(j) => match j.as_object().and_then(|m| m.get(&k)) {
298                Some(child) => Ok(Cow::Borrowed(child)),
299                None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
300            },
301            Cow::Owned(j) => match j.as_object().and_then(|m| m.get(&k)) {
302                Some(child) => Ok(Cow::Owned(child.clone())),
303                None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
304            },
305        },
306    }
307}
308
309/// Apply one classified step during a **write** walk's intermediate hops:
310/// descend mutably, requiring the child to already exist — no
311/// autovivification. Bounds/shape were already checked by `resolve_step`;
312/// this only adds the "must already exist" policy that only a write walk
313/// needs (a read's `descend` also requires existence for `Key`, but a write's
314/// *final* hop diverges — see `apply_leaf_write`). A `Slice` step can never
315/// be part of a valid lvalue path (mutating through a detached slice copy
316/// wouldn't write back), so it is always a loud `Shape` error here,
317/// intermediate or not.
318fn descend_mut<'a>(
319    current: &'a mut serde_json::Value,
320    step: Step,
321    path: &str,
322) -> Result<&'a mut serde_json::Value, PathError> {
323    match step {
324        Step::Slice(..) => Err(PathError::Shape(format!(
325            "${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
326        ))),
327        Step::Index(i) => {
328            let Some(arr) = current.as_array_mut() else {
329                unreachable!("index classified against an array")
330            };
331            Ok(&mut arr[i])
332        }
333        Step::Key(k) => {
334            let Some(map) = current.as_object_mut() else {
335                unreachable!("key classified against an object")
336            };
337            match map.get_mut(&k) {
338                Some(child) => Ok(child),
339                None => Err(PathError::Absence(format!(
340                    "${{{path}[{k}]}}: no such key — no autovivification, create it first (e.g. `{path}[{k}]={{}}`)"
341                ))),
342            }
343        }
344    }
345}
346
347/// Apply the classified **final** step of a write walk: a record key inserts
348/// or updates (the one thing a path-set may create), a list index updates
349/// in-bounds (already validated by `resolve_step`'s `classify_index` — an
350/// out-of-bounds index is an `Absence` error before this is ever reached),
351/// and a slice is a loud `Shape` error (no slice lvalues — `push` grows
352/// lists).
353fn apply_leaf_write(
354    current: &mut serde_json::Value,
355    step: Step,
356    value: serde_json::Value,
357    path: &str,
358) -> Result<(), PathError> {
359    match step {
360        Step::Slice(..) => Err(PathError::Shape(format!(
361            "${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
362        ))),
363        Step::Index(i) => {
364            let Some(arr) = current.as_array_mut() else {
365                unreachable!("index classified against an array")
366            };
367            arr[i] = value;
368            Ok(())
369        }
370        Step::Key(k) => {
371            let Some(map) = current.as_object_mut() else {
372                unreachable!("key classified against an object")
373            };
374            map.insert(k, value);
375            Ok(())
376        }
377    }
378}
379
380/// Render a mid-walk `PathError` for `push`'s error text. `Absence`/`Shape`
381/// already carry a ready-to-display `${…}` message from `resolve_step`/
382/// `descend_mut` (shared with `walk_write`); only `UndefinedRoot` needs
383/// `push`-flavored wording here — it fires for an unset `$k` dynamic
384/// subscript mid-path, not the root itself (`walk_append` checks the root
385/// exists before ever starting the walk).
386fn push_path_error_message(err: PathError, root_name: &str) -> String {
387    match err {
388        PathError::UndefinedRoot(msg) if msg.is_empty() => {
389            format!("push: {root_name} is not defined")
390        }
391        PathError::UndefinedRoot(msg) => format!("push: {msg}"),
392        PathError::Absence(msg) | PathError::Shape(msg) => msg,
393    }
394}
395
396/// Variable scope with nested frames and last-result tracking.
397///
398/// Variables are looked up from innermost to outermost frame.
399/// The `?` variable always refers to the last command result.
400///
401/// The `frames` field is wrapped in `Arc` for copy-on-write (COW) semantics.
402/// Cloning a Scope is O(1) — just bumps the Arc refcount. Mutations use
403/// `Arc::make_mut` to clone the inner data only when shared. This matters
404/// because `execute_pipeline` snapshots the scope into ExecContext (clone)
405/// and syncs it back (clone) on every command.
406#[derive(Debug, Clone)]
407pub struct Scope {
408    /// Stack of variable frames. Last element is the innermost scope.
409    /// Wrapped in Arc for copy-on-write: clone is O(1), mutation clones on demand.
410    frames: Arc<Vec<HashMap<String, Value>>>,
411    /// Variables marked for export to child processes.
412    exported: HashSet<String>,
413    /// The result of the last command execution.
414    ///
415    /// Boxed: `Scope` is cloned/held by value at every recursion level (the
416    /// dispatch snapshot, the command-subst save/restore), and an inline
417    /// `ExecResult` made `Scope` ~half again as large in each of those copies
418    /// (GH #48, item 5). The box is reused in place by `set_last_result`, so the
419    /// steady state is one allocation per `Scope`, not one per update.
420    last_result: Box<ExecResult>,
421    /// Exit code of the last command substitution performed, noted as each
422    /// substitution completes. An assignment with no command name takes this
423    /// as its own status (or 0 when `None`) — bash's rule, re-probed: the
424    /// LAST substitution wins, not the first, not "any failed". The note is
425    /// cleared before evaluating an assignment's value so a substitution
426    /// from an earlier statement cannot leak in.
427    last_cmdsubst_code: Option<i64>,
428    /// Script or tool name ($0).
429    script_name: String,
430    /// Positional arguments ($1-$9, $@, $#).
431    positional: Vec<String>,
432    /// Error exit mode (set -e): exit on any command failure.
433    error_exit: bool,
434    /// Counter for temporarily suppressing errexit (e.g. inside && / || left side).
435    /// When > 0, error_exit_enabled() returns false even if error_exit is true.
436    errexit_suppressed: usize,
437    /// AST display mode (kaish-ast -on/-off): show AST instead of executing.
438    show_ast: bool,
439    /// Trash mode (set -o trash): move deleted files to freedesktop.org Trash.
440    trash_enabled: bool,
441    /// Maximum file size (bytes) for trash. Files larger than this bypass trash.
442    /// Default: 10 MB.
443    trash_max_size: u64,
444    /// Glob expansion mode (set -o glob): expand bare glob patterns in arguments.
445    glob_enabled: bool,
446    /// Pipefail mode (set -o pipefail): a pipeline reports the rightmost
447    /// non-zero stage instead of only its last stage.
448    pipefail_enabled: bool,
449    /// Kaish session identifier ($$). A monotonic counter assigned at Kernel
450    /// construction (see `KERNEL_COUNTER` in kernel.rs) — *not* the OS PID.
451    /// Subshells / forks inherit the parent's value (Scope clone copies it).
452    /// 0 is a sentinel meaning "this scope was constructed outside a Kernel"
453    /// (e.g. arithmetic unit tests, kaish-clear before its setter runs).
454    pid: u64,
455}
456
457impl Scope {
458    /// Create a new scope with one empty frame.
459    ///
460    /// `pid` defaults to 0 (sentinel). The owning Kernel calls `set_pid()`
461    /// during construction to assign the real session identifier.
462    pub fn new() -> Self {
463        Self {
464            frames: Arc::new(vec![HashMap::new()]),
465            exported: HashSet::new(),
466            last_result: Box::new(ExecResult::default()),
467            last_cmdsubst_code: None,
468            script_name: String::new(),
469            positional: Vec::new(),
470            error_exit: false,
471            errexit_suppressed: 0,
472            show_ast: false,
473            trash_enabled: false,
474            trash_max_size: 10 * 1024 * 1024, // 10 MB
475            glob_enabled: true,
476            pipefail_enabled: false,
477            pid: 0,
478        }
479    }
480
481    /// Get the kaish session identifier ($$).
482    pub fn pid(&self) -> u64 {
483        self.pid
484    }
485
486    /// Set the kaish session identifier ($$). Called by the Kernel during
487    /// construction to thread the assigned counter value into the scope.
488    /// Also used by `kaish-clear` to preserve $$ across a session reset.
489    pub fn set_pid(&mut self, pid: u64) {
490        self.pid = pid;
491    }
492
493    /// Push a new scope frame (for entering a loop, tool call, etc.)
494    pub fn push_frame(&mut self) {
495        Arc::make_mut(&mut self.frames).push(HashMap::new());
496    }
497
498    /// Pop the innermost scope frame.
499    ///
500    /// Panics if attempting to pop the last frame.
501    pub fn pop_frame(&mut self) {
502        if self.frames.len() > 1 {
503            Arc::make_mut(&mut self.frames).pop();
504        } else {
505            panic!("cannot pop the root scope frame");
506        }
507    }
508
509    /// Set a variable in the current (innermost) frame.
510    ///
511    /// Use this for `local` variable declarations.
512    ///
513    /// The name is NFC-normalized here, which is what makes the scope's keys
514    /// canonical no matter which door bound them. Parse-time normalization
515    /// covers the four written spellings of a name; this covers every runtime
516    /// binder — `for`, `read`, `unset`, `scatter --as`, and the embedder's own
517    /// `initial_vars` — without each having to remember.
518    pub fn set(&mut self, name: impl Into<String>, value: Value) {
519        let name = crate::ast::normalize_name(name.into());
520        if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
521            frame.insert(name, value);
522        }
523    }
524
525    /// Set a variable with global semantics (shell default).
526    ///
527    /// If the variable exists in any frame, update it there.
528    /// Otherwise, create it in the outermost (root) frame.
529    /// Use this for non-local variable assignments.
530    pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
531        let name = crate::ast::normalize_name(name.into());
532
533        // Search from innermost to outermost to find existing variable
534        let frames = Arc::make_mut(&mut self.frames);
535        for frame in frames.iter_mut().rev() {
536            if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
537                e.insert(value);
538                return;
539            }
540        }
541
542        // Variable doesn't exist - create in root frame (index 0)
543        if let Some(frame) = frames.first_mut() {
544            frame.insert(name, value);
545        }
546    }
547
548    /// Get a variable by name, searching from innermost to outermost frame.
549    pub fn get(&self, name: &str) -> Option<&Value> {
550        let normalized;
551        let name = if name.is_ascii() {
552            name
553        } else {
554            normalized = crate::ast::normalize_name(name.to_string());
555            normalized.as_str()
556        };
557        for frame in self.frames.iter().rev() {
558            if let Some(value) = frame.get(name) {
559                return Some(value);
560            }
561        }
562        None
563    }
564
565    /// Remove a variable, searching from innermost to outermost frame.
566    ///
567    /// Returns the removed value if found, None otherwise.
568    pub fn remove(&mut self, name: &str) -> Option<Value> {
569        let normalized;
570        let name = if name.is_ascii() {
571            name
572        } else {
573            normalized = crate::ast::normalize_name(name.to_string());
574            normalized.as_str()
575        };
576        for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
577            if let Some(value) = frame.remove(name) {
578                return Some(value);
579            }
580        }
581        None
582    }
583
584    /// Set the last command result (accessible via `$?`).
585    pub fn set_last_result(&mut self, result: ExecResult) {
586        // Write through the existing box rather than reallocating one.
587        *self.last_result = result;
588    }
589
590    /// Get the last command result.
591    pub fn last_result(&self) -> &ExecResult {
592        &self.last_result
593    }
594
595    /// Note the exit code of a command substitution that just completed.
596    /// Overwritten by each later substitution, so the last one performed
597    /// wins.
598    pub fn note_cmdsubst_code(&mut self, code: i64) {
599        self.last_cmdsubst_code = Some(code);
600    }
601
602    /// Forget any noted command-substitution code. Called before evaluating
603    /// an assignment's value so an earlier statement's substitution cannot
604    /// leak into this one's status.
605    pub fn clear_cmdsubst_code(&mut self) {
606        self.last_cmdsubst_code = None;
607    }
608
609    /// Take the noted command-substitution code, leaving none.
610    pub fn take_cmdsubst_code(&mut self) -> Option<i64> {
611        self.last_cmdsubst_code.take()
612    }
613
614    /// Set the positional parameters ($0, $1-$9, $@, $#).
615    ///
616    /// The script_name becomes $0, and args become $1, $2, etc.
617    pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
618        self.script_name = script_name.into();
619        self.positional = args;
620    }
621
622    /// Save current positional parameters for later restoration.
623    ///
624    /// Returns (script_name, args) tuple that can be passed to set_positional.
625    pub fn save_positional(&self) -> (String, Vec<String>) {
626        (self.script_name.clone(), self.positional.clone())
627    }
628
629    /// Get a positional parameter by index ($0-$9).
630    ///
631    /// $0 returns the script name, $1-$9 return arguments.
632    pub fn get_positional(&self, n: usize) -> Option<&str> {
633        if n == 0 {
634            if self.script_name.is_empty() {
635                None
636            } else {
637                Some(&self.script_name)
638            }
639        } else {
640            self.positional.get(n - 1).map(|s| s.as_str())
641        }
642    }
643
644    /// Get all positional arguments as a slice ($@).
645    pub fn all_args(&self) -> &[String] {
646        &self.positional
647    }
648
649    /// Get the count of positional arguments ($#).
650    pub fn arg_count(&self) -> usize {
651        self.positional.len()
652    }
653
654    /// Check if error-exit mode is active (set -e and not suppressed).
655    ///
656    /// Returns false when inside the left side of `&&` or `||` chains,
657    /// matching bash behavior where those operators handle failure themselves.
658    pub fn error_exit_enabled(&self) -> bool {
659        self.error_exit && self.errexit_suppressed == 0
660    }
661
662    /// The raw `set -e` flag, ignoring any active suppression.
663    ///
664    /// [`Self::error_exit_enabled`] answers "should errexit fire right now",
665    /// which is false while suppressed inside a `&&`/`||` left side. Anything
666    /// that SAVES the setting to restore later must read this instead, or a
667    /// save taken during suppression restores `set -e` as off and quietly
668    /// disables it for everything after.
669    pub fn error_exit_flag(&self) -> bool {
670        self.error_exit
671    }
672
673    /// Set error-exit mode (set -e / set +e).
674    pub fn set_error_exit(&mut self, enabled: bool) {
675        self.error_exit = enabled;
676    }
677
678    /// Suppress errexit temporarily (for `&&`/`||` left side).
679    pub fn suppress_errexit(&mut self) {
680        self.errexit_suppressed += 1;
681    }
682
683    /// Unsuppress errexit (after `&&`/`||` left side completes).
684    pub fn unsuppress_errexit(&mut self) {
685        self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
686    }
687
688    /// Check if AST display mode is enabled (kaish-ast -on).
689    pub fn show_ast(&self) -> bool {
690        self.show_ast
691    }
692
693    /// Set AST display mode (kaish-ast -on / kaish-ast -off).
694    pub fn set_show_ast(&mut self, enabled: bool) {
695        self.show_ast = enabled;
696    }
697
698    /// Check if pipefail is enabled (set -o pipefail).
699    pub fn pipefail_enabled(&self) -> bool {
700        self.pipefail_enabled
701    }
702
703    /// Set pipefail mode (set -o pipefail / set +o pipefail).
704    pub fn set_pipefail_enabled(&mut self, enabled: bool) {
705        self.pipefail_enabled = enabled;
706    }
707
708    /// Record every stage's exit code as `PIPESTATUS`, a list.
709    ///
710    /// A list rather than bash's `${PIPESTATUS[@]}` word-array, because kaish
711    /// has collections and no word splitting: `${PIPESTATUS[0]}` indexes it,
712    /// `${#PIPESTATUS}` counts it, and `$(values $PIPESTATUS)` iterates it.
713    /// Written for EVERY pipeline including a one-stage one, as bash does —
714    /// `false; echo ${PIPESTATUS[0]}` is `1`.
715    pub fn set_pipestatus(&mut self, codes: &[i64]) {
716        let list = serde_json::Value::Array(
717            codes.iter().map(|c| serde_json::Value::from(*c)).collect(),
718        );
719        self.set_global("PIPESTATUS", Value::Json(list));
720    }
721
722    /// The rightmost non-zero code in `PIPESTATUS`, or `None` when every
723    /// stage succeeded.
724    ///
725    /// bash's pipefail rule is the LAST failing stage, not the first:
726    /// `set -o pipefail; (exit 3) | (exit 4) | true` is 4. Reading
727    /// left-to-right is the easy mistake, and it is wrong on exactly the input
728    /// that proves a pipeline can fail more than once.
729    pub fn pipestatus_rightmost_failure(&self) -> Option<i64> {
730        let Some(Value::Json(serde_json::Value::Array(codes))) = self.get("PIPESTATUS") else {
731            return None;
732        };
733        codes
734            .iter()
735            .filter_map(serde_json::Value::as_i64)
736            .rfind(|c| *c != 0)
737    }
738
739    /// Check if trash mode is enabled (set -o trash).
740    pub fn trash_enabled(&self) -> bool {
741        self.trash_enabled
742    }
743
744    /// Set trash mode (set -o trash / set +o trash).
745    pub fn set_trash_enabled(&mut self, enabled: bool) {
746        self.trash_enabled = enabled;
747    }
748
749    /// Get the maximum file size for trash (bytes).
750    pub fn trash_max_size(&self) -> u64 {
751        self.trash_max_size
752    }
753
754    /// Set the maximum file size for trash (bytes).
755    pub fn set_trash_max_size(&mut self, size: u64) {
756        self.trash_max_size = size;
757    }
758
759    /// Check if glob expansion is enabled (set -o glob, default true).
760    pub fn glob_enabled(&self) -> bool {
761        self.glob_enabled
762    }
763
764    /// Set glob expansion mode (set -o glob / set +o glob).
765    pub fn set_glob_enabled(&mut self, enabled: bool) {
766        self.glob_enabled = enabled;
767    }
768
769    /// Mark a variable as exported (visible to child processes).
770    ///
771    /// The variable doesn't need to exist yet; it will be exported when set.
772    pub fn export(&mut self, name: impl Into<String>) {
773        self.exported.insert(name.into());
774    }
775
776    /// Check if a variable is marked for export.
777    pub fn is_exported(&self, name: &str) -> bool {
778        self.exported.contains(name)
779    }
780
781    /// Set a variable in the **innermost** frame and mark it as exported.
782    ///
783    /// Used for frame-scoped overlays (`execute_with_vars`, `FOO=bar cmd`) and
784    /// for seeding root-frame exports at construction. For the `export`
785    /// builtin's assignment form use [`set_exported_global`](Self::set_exported_global)
786    /// so the value survives a function return (shared-scope semantics).
787    pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
788        let name = name.into();
789        self.set(&name, value);
790        self.export(name);
791    }
792
793    /// Set a variable with **global** (shared-scope) semantics and mark it as
794    /// exported. This is `export NAME=VALUE`: like a plain assignment, the value
795    /// updates an existing variable wherever it lives or lands in the root frame,
796    /// so it persists past a function return rather than dying with the
797    /// function's frame.
798    pub fn set_exported_global(&mut self, name: impl Into<String>, value: Value) {
799        let name = name.into();
800        self.set_global(&name, value);
801        self.export(name);
802    }
803
804    /// Unmark a variable from export.
805    pub fn unexport(&mut self, name: &str) {
806        self.exported.remove(name);
807    }
808
809    /// Get all exported variables with their values.
810    ///
811    /// Only returns variables that exist and are marked for export.
812    pub fn exported_vars(&self) -> Vec<(String, Value)> {
813        let mut result = Vec::new();
814        for name in &self.exported {
815            if let Some(value) = self.get(name) {
816                result.push((name.clone(), value.clone()));
817            }
818        }
819        result.sort_by(|(a, _), (b, _)| a.cmp(b));
820        result
821    }
822
823    /// Get all exported variable names.
824    pub fn exported_names(&self) -> Vec<&str> {
825        let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
826        names.sort();
827        names
828    }
829
830    /// Resolve a variable path: `${VAR}`, `${xs[0]}`, `${r[key]}`, `${a[b][c]}`.
831    ///
832    /// The first segment is the root name; the rest are bracket subscripts,
833    /// walked left to right into the root's `Value::Json`. A subscript landing
834    /// on a JSON scalar unwraps to a native `Value` (envelope-free); a subscript
835    /// landing on a collection stays `Value::Json`. `$?` resolves to the
836    /// previous command's exit code (bare only).
837    ///
838    /// Traversal borrows into the root's JSON tree and clones only the selected
839    /// leaf (a slice builds a new list); the whole-root clone is never taken, so
840    /// repeated `${u[$k]}` in a loop stays O(depth), not O(root size). The
841    /// per-hop classification lives in `resolve_step`, shared with the future
842    /// lvalue-write walk so read and write can never diverge.
843    ///
844    /// Errors distinguish an undefined root (soft) from a loud path error (see
845    /// [`PathError`]).
846    pub fn resolve_path(&self, path: &VarPath) -> Result<Value, PathError> {
847        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
848            // Empty path, or a first segment the parser never emits as root.
849            return Err(PathError::UndefinedRoot(String::new()));
850        };
851
852        // Special case: $? (last result) — bare only.
853        if root_name == "?" {
854            if path.segments.len() == 1 {
855                return Ok(Value::Int(self.last_result.code));
856            }
857            return Err(PathError::Shape(
858                "$? is the POSIX exit code, not a collection — use `kaish-last` for structured data"
859                    .to_string(),
860            ));
861        }
862
863        let root = self
864            .get(root_name)
865            .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
866
867        // Bare `${VAR}`: return the stored value unchanged — no subscript, no
868        // envelope unwrap.
869        let subscripts = &path.segments[1..];
870        if subscripts.is_empty() {
871            return Ok(root.clone());
872        }
873
874        // A leading dotted segment is brackets-only regardless of the root's
875        // type (matches the per-hop Field-before-container precedence in
876        // `resolve_step`, which the root-collection check below would otherwise
877        // preempt on a scalar root).
878        if let Some(VarSegment::Field(name)) = subscripts.first() {
879            return Err(dotted_access_error(root_name, name));
880        }
881
882        // Subscripted: the root must be a collection to descend into — or a
883        // string, which is sliceable (`${s[0:5]}`) though not indexable. A
884        // native `Value::String` root is lifted into JSON here so the one walk
885        // handles both; `resolve_step` then decides slice-versus-index and
886        // owns the message. Any other scalar reports the same "not a
887        // collection" message a mid-path scalar would.
888        let lifted;
889        let root_json = match root {
890            Value::Json(j) => j,
891            Value::String(s) => {
892                lifted = serde_json::Value::String(s.clone());
893                &lifted
894            }
895            other => {
896                return Err(PathError::Shape(format!(
897                    "${{{root_name}…}}: cannot subscript {} — it is not a collection",
898                    type_name(other)
899                )))
900            }
901        };
902
903        // Walk the subscripts, borrowing into the tree; only a slice (which
904        // builds a new list) and the terminal unwrap allocate. `prefix`
905        // accumulates the path walked so far so a nested failure names the real
906        // path (`${a[b][9]}`, not `${a[9]}`).
907        let mut current = Cow::Borrowed(root_json);
908        let mut prefix = root_name.clone();
909        for seg in subscripts {
910            let step = resolve_step(&current, seg, self, &prefix)?;
911            current = descend(current, step, &prefix)?;
912            prefix.push_str(&render_segment(seg));
913        }
914        Ok(json_to_value_no_envelope(current.into_owned()))
915    }
916
917    /// Write a value into a collection lvalue path: `xs[0]=9`,
918    /// `user[email]=amy@example.com`, `services[web][port]=9090`.
919    ///
920    /// Shares `resolve_step` with [`resolve_path`](Self::resolve_path) so
921    /// classification (bounds, shape) never drifts between read and write.
922    /// The walk itself diverges at the leaf: every intermediate hop requires
923    /// the child to already exist (`descend_mut` — **no autovivification**),
924    /// while the final hop may insert a new record key (`apply_leaf_write`) —
925    /// the ONLY thing a path-set may create. A list index write is in-bounds
926    /// update only (`resolve_step`'s `classify_index` already turns an
927    /// out-of-bounds index into a loud `Absence`); `push` is how lists grow.
928    /// A slice lvalue (`xs[0:2]=…`) is always a `Shape` error.
929    ///
930    /// The root must already be defined (`UndefinedRoot`) and be a collection
931    /// (`Shape` for a scalar root) — same rule as a read. On success the
932    /// mutated root replaces the old value via `set_global`. A bracket-path
933    /// write updates the variable wherever it lives and ignores `local`,
934    /// because it mutates an existing binding instead of creating one. See
935    /// `docs/LANGUAGE.md`, "Assignment — bracket-path lvalues".
936    pub fn walk_write(&mut self, path: &VarPath, value: Value) -> Result<(), PathError> {
937        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
938            return Err(PathError::UndefinedRoot(String::new()));
939        };
940
941        let root = self
942            .get(root_name)
943            .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
944
945        let mut root_json = match root {
946            Value::Json(j) => j.clone(),
947            other => {
948                return Err(PathError::Shape(format!(
949                    "${{{root_name}…}}: cannot subscript {} — it is not a collection",
950                    type_name(other)
951                )))
952            }
953        };
954
955        let subscripts = &path.segments[1..];
956        let Some((last, intermediates)) = subscripts.split_last() else {
957            // A bare name never reaches walk_write — the kernel routes a
958            // one-segment path through set/set_global. Guard defensively
959            // rather than silently no-op.
960            return Err(PathError::Shape(format!(
961                "{root_name}: assignment target has no subscript"
962            )));
963        };
964
965        let mut current = &mut root_json;
966        let mut prefix = root_name.clone();
967        for seg in intermediates {
968            let step = resolve_step(current, seg, self, &prefix)?;
969            current = descend_mut(current, step, &prefix)?;
970            prefix.push_str(&render_segment(seg));
971        }
972
973        let step = resolve_step(current, last, self, &prefix)?;
974        apply_leaf_write(current, step, value_to_json(&value), &prefix)?;
975
976        self.set_global(root_name.clone(), Value::Json(root_json));
977        Ok(())
978    }
979
980    /// Append value(s) to a list variable, in place: a top-level bareword
981    /// target (`push xs val`) or a bracket-path target
982    /// (`push services[web][tags] item`).
983    ///
984    /// The target must already exist and be a list — an undefined root, a
985    /// non-list leaf, or a missing intermediate hop is a loud error, never a
986    /// silent create or autoviv. See `docs/LANGUAGE.md`, "Assignment —
987    /// bracket-path lvalues + `push`". Intermediate hops share `walk_write`'s
988    /// `resolve_step`/`descend_mut`, so a `push` path and an assignment path
989    /// classify identically. Only the final hop differs: it appends instead
990    /// of replacing.
991    pub fn walk_append(&mut self, path: &VarPath, values: Vec<Value>) -> Result<(), String> {
992        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
993            return Err("push: target has no root".to_string());
994        };
995        let root_name = root_name.clone();
996        let current = self
997            .get(&root_name)
998            .ok_or_else(|| format!("push: {root_name} is not defined"))?
999            .clone();
1000
1001        let subscripts = &path.segments[1..];
1002        if subscripts.is_empty() {
1003            if !matches!(current, Value::Json(serde_json::Value::Array(_))) {
1004                return Err(format!("push: {root_name} is not a list ({})", type_name(&current)));
1005            }
1006            let Value::Json(serde_json::Value::Array(mut arr)) = current else {
1007                unreachable!("checked above")
1008            };
1009            arr.extend(values.iter().map(value_to_json));
1010            self.set_global(root_name, Value::Json(serde_json::Value::Array(arr)));
1011            return Ok(());
1012        }
1013
1014        let mut root_json = match current {
1015            Value::Json(j) => j,
1016            other => {
1017                return Err(format!(
1018                    "push: {root_name}…: cannot subscript {} — it is not a collection",
1019                    type_name(&other)
1020                ))
1021            }
1022        };
1023
1024        // Walk every subscript — including the last — with the shared
1025        // no-autoviv intermediate walker: the container the values append
1026        // into must already exist, matching `walk_write`'s policy.
1027        let mut cur = &mut root_json;
1028        let mut prefix = root_name.clone();
1029        for seg in subscripts {
1030            let step = resolve_step(cur, seg, self, &prefix)
1031                .map_err(|e| push_path_error_message(e, &root_name))?;
1032            cur = descend_mut(cur, step, &prefix)
1033                .map_err(|e| push_path_error_message(e, &root_name))?;
1034            prefix.push_str(&render_segment(seg));
1035        }
1036
1037        let serde_json::Value::Array(arr) = cur else {
1038            return Err(format!(
1039                "push: {prefix} is not a list ({})",
1040                type_name(&json_to_value_no_envelope(cur.clone()))
1041            ));
1042        };
1043        arr.extend(values.iter().map(value_to_json));
1044        self.set_global(root_name, Value::Json(root_json));
1045        Ok(())
1046    }
1047
1048    /// Check if a variable exists in any frame.
1049    pub fn contains(&self, name: &str) -> bool {
1050        self.get(name).is_some()
1051    }
1052
1053    /// Get all variable names in scope (for debugging/introspection).
1054    pub fn all_names(&self) -> Vec<&str> {
1055        let mut names: Vec<&str> = self
1056            .frames
1057            .iter()
1058            .flat_map(|f| f.keys().map(|s| s.as_str()))
1059            .collect();
1060        names.sort();
1061        names.dedup();
1062        names
1063    }
1064
1065    /// Get all variables as (name, value) pairs.
1066    ///
1067    /// Variables are deduplicated, with inner frames shadowing outer ones.
1068    pub fn all(&self) -> Vec<(String, Value)> {
1069        let mut result = std::collections::HashMap::new();
1070        // Iterate outer to inner so inner frames override
1071        for frame in self.frames.iter() {
1072            for (name, value) in frame {
1073                result.insert(name.clone(), value.clone());
1074            }
1075        }
1076        let mut pairs: Vec<_> = result.into_iter().collect();
1077        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
1078        pairs
1079    }
1080}
1081
1082impl Default for Scope {
1083    fn default() -> Self {
1084        Self::new()
1085    }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use super::*;
1091
1092    #[test]
1093    fn new_scope_has_one_frame() {
1094        let scope = Scope::new();
1095        assert_eq!(scope.frames.len(), 1);
1096    }
1097
1098    #[test]
1099    fn set_and_get_variable() {
1100        let mut scope = Scope::new();
1101        scope.set("X", Value::Int(42));
1102        assert_eq!(scope.get("X"), Some(&Value::Int(42)));
1103    }
1104
1105    #[test]
1106    fn get_nonexistent_returns_none() {
1107        let scope = Scope::new();
1108        assert_eq!(scope.get("MISSING"), None);
1109    }
1110
1111    #[test]
1112    fn inner_frame_shadows_outer() {
1113        let mut scope = Scope::new();
1114        scope.set("X", Value::Int(1));
1115        scope.push_frame();
1116        scope.set("X", Value::Int(2));
1117        assert_eq!(scope.get("X"), Some(&Value::Int(2)));
1118        scope.pop_frame();
1119        assert_eq!(scope.get("X"), Some(&Value::Int(1)));
1120    }
1121
1122    #[test]
1123    fn inner_frame_can_see_outer_vars() {
1124        let mut scope = Scope::new();
1125        scope.set("OUTER", Value::String("visible".into()));
1126        scope.push_frame();
1127        assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
1128    }
1129
1130    #[test]
1131    fn resolve_simple_path() {
1132        let mut scope = Scope::new();
1133        scope.set("NAME", Value::String("Alice".into()));
1134
1135        let path = VarPath::simple("NAME");
1136        assert_eq!(
1137            scope.resolve_path(&path),
1138            Ok(Value::String("Alice".into()))
1139        );
1140    }
1141
1142    #[test]
1143    fn resolve_bare_last_result_returns_exit_code() {
1144        let mut scope = Scope::new();
1145        scope.set_last_result(ExecResult::failure(127, "not found"));
1146
1147        let path = VarPath {
1148            segments: vec![VarSegment::Field("?".into())],
1149        };
1150        assert_eq!(scope.resolve_path(&path), Ok(Value::Int(127)));
1151    }
1152
1153    #[test]
1154    fn resolve_last_result_field_access_is_rejected() {
1155        // Field access on $? was removed — use `kaish-last` for structured data.
1156        // The resolver now returns a loud error; the validator also catches it
1157        // earlier with a specific error code for actionable diagnostics.
1158        let mut scope = Scope::new();
1159        scope.set_last_result(ExecResult::success_with_data(
1160            "1",
1161            Value::Json(serde_json::json!({"count": 5})),
1162        ));
1163
1164        let path = VarPath {
1165            segments: vec![
1166                VarSegment::Field("?".into()),
1167                VarSegment::Field("data".into()),
1168            ],
1169        };
1170        assert!(matches!(
1171            scope.resolve_path(&path),
1172            Err(PathError::Shape(_))
1173        ));
1174    }
1175
1176    #[test]
1177    fn resolve_dotted_access_on_scalar_is_a_loud_error() {
1178        let mut scope = Scope::new();
1179        scope.set("X", Value::Int(42));
1180
1181        // Dotted access `${X.invalid}` — brackets-only, so it's a loud error.
1182        let path = VarPath {
1183            segments: vec![
1184                VarSegment::Field("X".into()),
1185                VarSegment::Field("invalid".into()),
1186            ],
1187        };
1188        assert!(matches!(
1189            scope.resolve_path(&path),
1190            Err(PathError::Shape(_))
1191        ));
1192    }
1193
1194    #[test]
1195    fn resolve_undefined_root_is_soft() {
1196        let scope = Scope::new();
1197        let path = VarPath::simple("NOPE");
1198        assert!(matches!(
1199            scope.resolve_path(&path),
1200            Err(PathError::UndefinedRoot(_))
1201        ));
1202    }
1203
1204    // ── PathError classification (Absence vs Shape) ─────────────────────────
1205    // Pins the three-way split: `${path:-default}` (a later commit) leans on
1206    // Absence-vs-Shape, so a misclassification here is a real semantic bug, not
1207    // cosmetics. All three stay loud for a bare access.
1208
1209    /// Build `${root[seg]}` with one bracket subscript.
1210    fn subscripted(scope: &mut Scope, root: &str, value: serde_json::Value, seg: VarSegment) -> Result<Value, PathError> {
1211        scope.set(root, Value::Json(value));
1212        let path = VarPath {
1213            segments: vec![VarSegment::Field(root.into()), seg],
1214        };
1215        scope.resolve_path(&path)
1216    }
1217
1218    #[test]
1219    fn out_of_bounds_index_is_absence() {
1220        let mut scope = Scope::new();
1221        let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Index(9));
1222        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1223    }
1224
1225    #[test]
1226    fn missing_record_key_is_absence() {
1227        let mut scope = Scope::new();
1228        let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Key("nope".into()));
1229        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1230    }
1231
1232    #[test]
1233    fn string_key_on_a_list_is_shape() {
1234        let mut scope = Scope::new();
1235        let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Key("web".into()));
1236        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1237    }
1238
1239    #[test]
1240    fn integer_index_on_a_record_is_shape() {
1241        let mut scope = Scope::new();
1242        let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Index(0));
1243        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1244    }
1245
1246    #[test]
1247    fn subscripting_a_scalar_is_shape() {
1248        let mut scope = Scope::new();
1249        scope.set("s", Value::String("hello".into()));
1250        let path = VarPath {
1251            segments: vec![VarSegment::Field("s".into()), VarSegment::Index(0)],
1252        };
1253        assert!(matches!(scope.resolve_path(&path), Err(PathError::Shape(_))));
1254    }
1255
1256    #[test]
1257    fn unset_dynamic_key_is_undefined_root_not_absence() {
1258        // `${r[$k]}` with `$k` unset: the *variable* is missing, so it's
1259        // UndefinedRoot-class (which `:-` treats as absence), not a Shape error.
1260        let mut scope = Scope::new();
1261        let r = subscripted(
1262            &mut scope,
1263            "r",
1264            serde_json::json!({"name": "amy"}),
1265            VarSegment::Dynamic("k".into()),
1266        );
1267        assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1268    }
1269
1270    #[test]
1271    fn contains_finds_variable() {
1272        let mut scope = Scope::new();
1273        scope.set("EXISTS", Value::Bool(true));
1274        assert!(scope.contains("EXISTS"));
1275        assert!(!scope.contains("MISSING"));
1276    }
1277
1278    #[test]
1279    fn all_names_lists_variables() {
1280        let mut scope = Scope::new();
1281        scope.set("A", Value::Int(1));
1282        scope.set("B", Value::Int(2));
1283        scope.push_frame();
1284        scope.set("C", Value::Int(3));
1285
1286        let names = scope.all_names();
1287        assert!(names.contains(&"A"));
1288        assert!(names.contains(&"B"));
1289        assert!(names.contains(&"C"));
1290    }
1291
1292    #[test]
1293    #[should_panic(expected = "cannot pop the root scope frame")]
1294    fn pop_root_frame_panics() {
1295        let mut scope = Scope::new();
1296        scope.pop_frame();
1297    }
1298
1299    #[test]
1300    fn positional_params_basic() {
1301        let mut scope = Scope::new();
1302        scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
1303
1304        // $0 is the script/tool name
1305        assert_eq!(scope.get_positional(0), Some("my_tool"));
1306        // $1, $2, $3 are the arguments
1307        assert_eq!(scope.get_positional(1), Some("arg1"));
1308        assert_eq!(scope.get_positional(2), Some("arg2"));
1309        assert_eq!(scope.get_positional(3), Some("arg3"));
1310        // $4 doesn't exist
1311        assert_eq!(scope.get_positional(4), None);
1312    }
1313
1314    #[test]
1315    fn positional_params_empty() {
1316        let scope = Scope::new();
1317        // No positional params set
1318        assert_eq!(scope.get_positional(0), None);
1319        assert_eq!(scope.get_positional(1), None);
1320        assert_eq!(scope.arg_count(), 0);
1321        assert!(scope.all_args().is_empty());
1322    }
1323
1324    #[test]
1325    fn all_args_returns_slice() {
1326        let mut scope = Scope::new();
1327        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1328
1329        let args = scope.all_args();
1330        assert_eq!(args, &["a", "b", "c"]);
1331    }
1332
1333    #[test]
1334    fn arg_count_returns_count() {
1335        let mut scope = Scope::new();
1336        scope.set_positional("test", vec!["one".into(), "two".into()]);
1337
1338        assert_eq!(scope.arg_count(), 2);
1339    }
1340
1341    #[test]
1342    fn export_marks_variable() {
1343        let mut scope = Scope::new();
1344        scope.set("X", Value::Int(42));
1345
1346        assert!(!scope.is_exported("X"));
1347        scope.export("X");
1348        assert!(scope.is_exported("X"));
1349    }
1350
1351    #[test]
1352    fn set_exported_sets_and_exports() {
1353        let mut scope = Scope::new();
1354        scope.set_exported("PATH", Value::String("/usr/bin".into()));
1355
1356        assert!(scope.is_exported("PATH"));
1357        assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
1358    }
1359
1360    #[test]
1361    fn unexport_removes_export_marker() {
1362        let mut scope = Scope::new();
1363        scope.set_exported("VAR", Value::Int(1));
1364        assert!(scope.is_exported("VAR"));
1365
1366        scope.unexport("VAR");
1367        assert!(!scope.is_exported("VAR"));
1368        // Variable still exists, just not exported
1369        assert!(scope.get("VAR").is_some());
1370    }
1371
1372    #[test]
1373    fn exported_vars_returns_only_exported_with_values() {
1374        let mut scope = Scope::new();
1375        scope.set_exported("A", Value::Int(1));
1376        scope.set_exported("B", Value::Int(2));
1377        scope.set("C", Value::Int(3)); // Not exported
1378        scope.export("D"); // Exported but no value
1379
1380        let exported = scope.exported_vars();
1381        assert_eq!(exported.len(), 2);
1382        assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
1383        assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
1384    }
1385
1386    #[test]
1387    fn exported_names_returns_sorted_names() {
1388        let mut scope = Scope::new();
1389        scope.export("Z");
1390        scope.export("A");
1391        scope.export("M");
1392
1393        let names = scope.exported_names();
1394        assert_eq!(names, vec!["A", "M", "Z"]);
1395    }
1396
1397    // ── walk_write (lvalue assignment) ──────────────────────────────────────
1398
1399    /// Build `xs[seg]=value` and apply it.
1400    fn write_at(
1401        scope: &mut Scope,
1402        root: &str,
1403        segs: Vec<VarSegment>,
1404    ) -> Result<(), PathError> {
1405        let mut segments = vec![VarSegment::Field(root.into())];
1406        segments.extend(segs);
1407        scope.walk_write(&VarPath { segments }, Value::Int(0))
1408    }
1409
1410    #[test]
1411    fn walk_write_list_index_update() {
1412        let mut scope = Scope::new();
1413        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1414        let path = VarPath {
1415            segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(0)],
1416        };
1417        scope.walk_write(&path, Value::Int(9)).expect("write should succeed");
1418        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([9, 2, 3]))));
1419    }
1420
1421    #[test]
1422    fn walk_write_negative_index() {
1423        let mut scope = Scope::new();
1424        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1425        let path = VarPath {
1426            segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(-1)],
1427        };
1428        scope.walk_write(&path, Value::Int(7)).expect("write should succeed");
1429        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([1, 2, 7]))));
1430    }
1431
1432    #[test]
1433    fn walk_write_inserts_a_new_record_key() {
1434        let mut scope = Scope::new();
1435        scope.set("u", Value::Json(serde_json::json!({"port": 8080})));
1436        let path = VarPath {
1437            segments: vec![VarSegment::Field("u".into()), VarSegment::Key("host".into())],
1438        };
1439        scope
1440            .walk_write(&path, Value::String("localhost".into()))
1441            .expect("write should succeed");
1442        assert_eq!(
1443            scope.get("u"),
1444            Some(&Value::Json(serde_json::json!({"port": 8080, "host": "localhost"})))
1445        );
1446    }
1447
1448    #[test]
1449    fn walk_write_deep_path_updates_nested_key() {
1450        let mut scope = Scope::new();
1451        scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1452        let path = VarPath {
1453            segments: vec![
1454                VarSegment::Field("s".into()),
1455                VarSegment::Key("web".into()),
1456                VarSegment::Key("port".into()),
1457            ],
1458        };
1459        scope.walk_write(&path, Value::Int(9000)).expect("write should succeed");
1460        assert_eq!(
1461            scope.get("s"),
1462            Some(&Value::Json(serde_json::json!({"web": {"port": 9000}})))
1463        );
1464    }
1465
1466    #[test]
1467    fn walk_write_out_of_bounds_index_is_absence() {
1468        let mut scope = Scope::new();
1469        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1470        let r = write_at(&mut scope, "xs", vec![VarSegment::Index(9)]);
1471        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1472    }
1473
1474    #[test]
1475    fn walk_write_missing_intermediate_is_absence_no_autoviv() {
1476        let mut scope = Scope::new();
1477        scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1478        let r = write_at(
1479            &mut scope,
1480            "s",
1481            vec![VarSegment::Key("api".into()), VarSegment::Key("port".into())],
1482        );
1483        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1484        // The root is untouched — no partial autovivification.
1485        assert_eq!(
1486            scope.get("s"),
1487            Some(&Value::Json(serde_json::json!({"web": {"port": 8080}})))
1488        );
1489    }
1490
1491    #[test]
1492    fn walk_write_scalar_root_is_shape() {
1493        let mut scope = Scope::new();
1494        scope.set("y", Value::String("hi".into()));
1495        let r = write_at(&mut scope, "y", vec![VarSegment::Index(0)]);
1496        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1497    }
1498
1499    #[test]
1500    fn walk_write_undefined_root_is_undefined_root() {
1501        let mut scope = Scope::new();
1502        let r = write_at(&mut scope, "z", vec![VarSegment::Index(0)]);
1503        assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1504    }
1505
1506    #[test]
1507    fn walk_write_slice_lvalue_is_shape() {
1508        let mut scope = Scope::new();
1509        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1510        let r = write_at(&mut scope, "xs", vec![VarSegment::Slice(Some(0), Some(2))]);
1511        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1512    }
1513
1514    // ── walk_append (push) ──────────────────────────────────────────────────
1515
1516    #[test]
1517    fn walk_append_extends_a_list_in_place() {
1518        let mut scope = Scope::new();
1519        scope.set("xs", Value::Json(serde_json::json!(["a", "b"])));
1520        scope
1521            .walk_append(&VarPath::simple("xs"), vec![Value::String("c".into())])
1522            .expect("push should succeed");
1523        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!(["a", "b", "c"]))));
1524    }
1525
1526    #[test]
1527    fn walk_append_undefined_target_is_a_loud_error() {
1528        let mut scope = Scope::new();
1529        let r = scope.walk_append(&VarPath::simple("nope"), vec![Value::Int(1)]);
1530        assert!(r.is_err(), "expected a loud error for an undefined target");
1531    }
1532
1533    #[test]
1534    fn walk_append_non_list_target_is_a_loud_error() {
1535        let mut scope = Scope::new();
1536        scope.set("y", Value::String("hi".into()));
1537        let r = scope.walk_append(&VarPath::simple("y"), vec![Value::Int(1)]);
1538        assert!(r.is_err(), "expected a loud error for a non-list target");
1539    }
1540
1541    #[test]
1542    fn walk_append_bracket_path_extends_a_nested_list_in_place() {
1543        let mut scope = Scope::new();
1544        scope.set(
1545            "services",
1546            Value::Json(serde_json::json!({"web": {"tags": ["a"]}})),
1547        );
1548        let path = VarPath {
1549            segments: vec![
1550                VarSegment::Field("services".into()),
1551                VarSegment::Key("web".into()),
1552                VarSegment::Key("tags".into()),
1553            ],
1554        };
1555        scope
1556            .walk_append(&path, vec![Value::String("b".into())])
1557            .expect("bracket-path push should succeed");
1558        assert_eq!(
1559            scope.get("services"),
1560            Some(&Value::Json(serde_json::json!({"web": {"tags": ["a", "b"]}})))
1561        );
1562    }
1563
1564    #[test]
1565    fn walk_append_bracket_path_missing_intermediate_is_a_loud_error() {
1566        let mut scope = Scope::new();
1567        scope.set("services", Value::Json(serde_json::json!({})));
1568        let path = VarPath {
1569            segments: vec![
1570                VarSegment::Field("services".into()),
1571                VarSegment::Key("web".into()),
1572                VarSegment::Key("tags".into()),
1573            ],
1574        };
1575        let r = scope.walk_append(&path, vec![Value::String("x".into())]);
1576        assert!(r.is_err(), "expected a loud error for a missing intermediate");
1577    }
1578
1579    #[test]
1580    fn walk_append_bracket_path_non_list_leaf_is_a_loud_error() {
1581        let mut scope = Scope::new();
1582        scope.set(
1583            "services",
1584            Value::Json(serde_json::json!({"web": {"port": 8080}})),
1585        );
1586        let path = VarPath {
1587            segments: vec![
1588                VarSegment::Field("services".into()),
1589                VarSegment::Key("web".into()),
1590                VarSegment::Key("port".into()),
1591            ],
1592        };
1593        let r = scope.walk_append(&path, vec![Value::Int(1)]);
1594        assert!(r.is_err(), "expected a loud error for a non-list leaf");
1595    }
1596}