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