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