Skip to main content

wasmsh_state/
lib.rs

1//! Shell runtime state for wasmsh.
2//!
3//! Manages variables, positional parameters, function registry,
4//! working directory, and exit status.
5
6use std::cell::Cell;
7
8use indexmap::IndexMap;
9use smol_str::SmolStr;
10
11/// Reserved shell variable used as a side channel between parameter expansion
12/// and command dispatch to report `set -u` unbound-variable failures. Treated
13/// as an implementation detail of [`ShellState`]; callers should go through
14/// [`ShellState::set_nounset_error`] / [`ShellState::take_nounset_error`].
15const NOUNSET_ERROR_VAR: &str = "_NOUNSET_ERROR";
16
17/// The value held by a shell variable: scalar, indexed array, or associative array.
18#[derive(Debug, Clone, PartialEq)]
19pub enum VarValue {
20    Scalar(SmolStr),
21    IndexedArray(IndexMap<usize, SmolStr>),
22    AssocArray(IndexMap<SmolStr, SmolStr>),
23}
24
25impl VarValue {
26    /// Return a scalar representation: for scalars the value itself, for arrays
27    /// all values joined by a single space (matching bash behavior when an array
28    /// is accessed without a subscript).
29    #[must_use]
30    pub fn as_scalar(&self) -> SmolStr {
31        match self {
32            Self::Scalar(s) => s.clone(),
33            Self::IndexedArray(map) => {
34                let vals: Vec<&str> = map.values().map(SmolStr::as_str).collect();
35                SmolStr::from(vals.join(" "))
36            }
37            Self::AssocArray(map) => {
38                let vals: Vec<&str> = map.values().map(SmolStr::as_str).collect();
39                SmolStr::from(vals.join(" "))
40            }
41        }
42    }
43}
44
45/// A shell variable with its attributes.
46#[derive(Debug, Clone, PartialEq)]
47#[allow(clippy::struct_excessive_bools)]
48pub struct ShellVar {
49    pub value: VarValue,
50    pub exported: bool,
51    pub readonly: bool,
52    /// `declare -i`: auto-evaluate arithmetic on assignment.
53    pub integer: bool,
54    /// `declare -n`: nameref — value is the name of another variable.
55    pub nameref: bool,
56}
57
58impl ShellVar {
59    /// Convenience: create a scalar `ShellVar` with the given value and default flags.
60    pub fn scalar(value: SmolStr) -> Self {
61        Self {
62            value: VarValue::Scalar(value),
63            exported: false,
64            readonly: false,
65            integer: false,
66            nameref: false,
67        }
68    }
69}
70
71/// The shell environment: a stack of variable scopes.
72#[derive(Debug, Clone)]
73pub struct ShellEnv {
74    pub scopes: Vec<IndexMap<SmolStr, ShellVar>>,
75}
76
77impl ShellEnv {
78    #[must_use]
79    pub fn new() -> Self {
80        Self {
81            scopes: vec![IndexMap::new()],
82        }
83    }
84
85    /// Look up a variable by name, searching from innermost scope outward.
86    #[must_use]
87    pub fn get(&self, name: &str) -> Option<&ShellVar> {
88        for scope in self.scopes.iter().rev() {
89            if let Some(var) = scope.get(name) {
90                return Some(var);
91            }
92        }
93        None
94    }
95
96    /// Get a mutable reference to a variable by name.
97    pub fn get_mut(&mut self, name: &str) -> Option<&mut ShellVar> {
98        for scope in self.scopes.iter_mut().rev() {
99            if let Some(var) = scope.get_mut(name) {
100                return Some(var);
101            }
102        }
103        None
104    }
105
106    /// Set a variable in the current (innermost) scope.
107    pub fn set(&mut self, name: SmolStr, var: ShellVar) {
108        if let Some(scope) = self.scopes.last_mut() {
109            scope.insert(name, var);
110        }
111    }
112
113    /// Push a new scope (e.g. for a function call).
114    pub fn push_scope(&mut self) {
115        self.scopes.push(IndexMap::new());
116    }
117
118    /// Pop the innermost scope. Returns `None` if only the global scope remains.
119    pub fn pop_scope(&mut self) -> Option<IndexMap<SmolStr, ShellVar>> {
120        if self.scopes.len() > 1 {
121            self.scopes.pop()
122        } else {
123            None
124        }
125    }
126
127    /// Remove a variable from the current (innermost) scope.
128    pub fn remove(&mut self, name: &str) -> Option<ShellVar> {
129        if let Some(scope) = self.scopes.last_mut() {
130            scope.shift_remove(name)
131        } else {
132            None
133        }
134    }
135
136    /// Iterate over all exported variables across all scopes
137    /// (innermost wins for shadowed names).
138    pub fn exported_vars(&self) -> IndexMap<SmolStr, SmolStr> {
139        let mut result = IndexMap::new();
140        for scope in &self.scopes {
141            for (name, var) in scope {
142                if var.exported {
143                    result.insert(name.clone(), var.value.as_scalar());
144                }
145            }
146        }
147        result
148    }
149}
150
151impl Default for ShellEnv {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157/// Complete shell state: variables, positional params, status, cwd, functions.
158#[derive(Debug, Clone)]
159pub struct ShellState {
160    /// Variable scopes.
161    pub env: ShellEnv,
162    /// Positional parameters ($1, $2, ...).
163    pub positional: Vec<SmolStr>,
164    /// Last exit status ($?).
165    pub last_status: i32,
166    /// Current working directory ($PWD).
167    pub cwd: String,
168    /// Current line number ($LINENO), updated by the runtime.
169    pub lineno: u32,
170    /// PRNG seed for `$RANDOM` (`XorShift32`). Uses `Cell` so `get_var` can remain `&self`.
171    pub random_seed: Cell<u32>,
172    /// Seconds elapsed since shell start ($SECONDS).
173    #[cfg(not(target_arch = "wasm32"))]
174    pub start_time: std::time::Instant,
175    /// Function call stack for $FUNCNAME.
176    pub func_stack: Vec<SmolStr>,
177    /// Source file stack for `$BASH_SOURCE`.
178    pub source_stack: Vec<SmolStr>,
179    /// Override for `$0` when executing a script (e.g. `bash script.sh`).
180    /// When `None`, `$0` returns the default `"wasmsh"`.
181    pub script_name: Option<SmolStr>,
182    /// Pseudo shell pid used for `$$`.
183    pub shell_pid: u32,
184    /// Last background pid used for `$!`.
185    pub last_background_pid: Option<u32>,
186    /// Last argument of the previously executed command (`$_`).
187    pub last_argument: SmolStr,
188    /// Directory stack used by `dirs`/`pushd`/`popd`.
189    pub dir_stack: Vec<SmolStr>,
190    /// Shell umask used by the `umask` builtin.
191    pub umask: u32,
192}
193
194impl ShellState {
195    #[must_use]
196    pub fn new() -> Self {
197        Self {
198            env: ShellEnv::new(),
199            positional: Vec::new(),
200            last_status: 0,
201            cwd: "/".into(),
202            lineno: 0,
203            random_seed: Cell::new(12345),
204            #[cfg(not(target_arch = "wasm32"))]
205            start_time: std::time::Instant::now(),
206            func_stack: Vec::new(),
207            source_stack: Vec::new(),
208            script_name: None,
209            shell_pid: shell_pid(),
210            last_background_pid: None,
211            last_argument: SmolStr::default(),
212            dir_stack: Vec::new(),
213            umask: 0o022,
214        }
215    }
216
217    /// Advance the `XorShift32` PRNG and return a value in 0..32767.
218    fn next_random(&self) -> u32 {
219        let mut x = self.random_seed.get();
220        x ^= x << 13;
221        x ^= x >> 17;
222        x ^= x << 5;
223        self.random_seed.set(x);
224        x % 32768
225    }
226
227    /// Get the value of a special parameter or named variable.
228    /// For arrays accessed as scalars, returns all values joined by space.
229    /// Dynamic variables (`$RANDOM`, `$LINENO`, `$SECONDS`, `$FUNCNAME`, `$BASH_SOURCE`) are
230    /// resolved on access.
231    #[must_use]
232    pub fn get_var(&self, name: &str) -> Option<SmolStr> {
233        match name {
234            "?" => Some(self.last_status.to_string().into()),
235            "$$" => Some(self.shell_pid.to_string().into()),
236            "!" => Some(
237                self.last_background_pid
238                    .map_or_else(SmolStr::default, |pid| pid.to_string().into()),
239            ),
240            "#" => Some(self.positional.len().to_string().into()),
241            "-" => Some(self.option_flags()),
242            "0" => Some(self.script_name.clone().unwrap_or_else(|| "wasmsh".into())),
243            "_" => Some(self.last_argument.clone()),
244            "@" | "*" => Some(self.positional.join(" ").into()),
245            "RANDOM" => Some(SmolStr::from(self.next_random().to_string())),
246            "LINENO" => Some(SmolStr::from(self.lineno.to_string())),
247            "SECONDS" => Some(self.seconds_value()),
248            "FUNCNAME" => Some(stack_last_or_default(&self.func_stack)),
249            "BASH_SOURCE" => Some(stack_last_or_default(&self.source_stack)),
250            _ => self.get_named_or_positional_var(name),
251        }
252    }
253
254    #[allow(clippy::unused_self)] // self.start_time used on non-wasm targets
255    fn seconds_value(&self) -> SmolStr {
256        #[cfg(not(target_arch = "wasm32"))]
257        {
258            SmolStr::from(self.start_time.elapsed().as_secs().to_string())
259        }
260        #[cfg(target_arch = "wasm32")]
261        {
262            SmolStr::from("0")
263        }
264    }
265
266    fn option_flags(&self) -> SmolStr {
267        let mut flags = String::new();
268        for (name, flag) in [
269            ("SHOPT_a", 'a'),
270            ("SHOPT_C", 'C'),
271            ("SHOPT_E", 'E'),
272            ("SHOPT_e", 'e'),
273            ("SHOPT_f", 'f'),
274            ("SHOPT_n", 'n'),
275            ("SHOPT_p", 'p'),
276            ("SHOPT_T", 'T'),
277            ("SHOPT_u", 'u'),
278            ("SHOPT_v", 'v'),
279            ("SHOPT_x", 'x'),
280        ] {
281            if self.get_env_var(name).as_deref() == Some("1") {
282                flags.push(flag);
283            }
284        }
285        flags.into()
286    }
287
288    fn get_named_or_positional_var(&self, name: &str) -> Option<SmolStr> {
289        if let Some(index) = positional_param_index(name) {
290            return self.positional.get(index).cloned();
291        }
292        self.get_env_var(name)
293    }
294
295    fn get_env_var(&self, name: &str) -> Option<SmolStr> {
296        let var = self.env.get(name)?;
297        if let Some(target) = nameref_target(var, name) {
298            return self.env.get(&target).map(|v| v.value.as_scalar());
299        }
300        Some(var.value.as_scalar())
301    }
302
303    /// Record the last argument for `$_`.
304    pub fn set_last_argument(&mut self, arg: impl Into<SmolStr>) {
305        self.last_argument = arg.into();
306    }
307
308    /// Update the tracked background pid used by `$!`.
309    pub fn set_last_background_pid(&mut self, pid: Option<u32>) {
310        self.last_background_pid = pid;
311    }
312
313    /// Set a named variable (not a special parameter).
314    /// Preserves `exported` and `readonly` flags if the variable already exists.
315    /// If the variable is readonly, the write is silently skipped (like bash).
316    /// Setting a scalar value on an existing array variable replaces element 0
317    /// for indexed arrays, or replaces it entirely with a scalar for assoc arrays.
318    pub fn set_var(&mut self, name: SmolStr, value: SmolStr) {
319        // Follow nameref: if this variable is a nameref, write to the target instead
320        if let Some(var) = self.env.get(&name) {
321            if var.nameref {
322                let target = var.value.as_scalar();
323                if !target.is_empty() && target.as_str() != name.as_str() {
324                    let target_name = SmolStr::from(target.as_str());
325                    self.set_var(target_name, value);
326                    return;
327                }
328            }
329        }
330        let (exported, readonly) = self
331            .env
332            .get(&name)
333            .map_or((false, false), |v| (v.exported, v.readonly));
334        if readonly {
335            return; // readonly variables cannot be overwritten
336        }
337        // allexport (set -a): auto-export all variables except internal SHOPT_* vars
338        let exported = if !exported
339            && !name.starts_with("SHOPT_")
340            && !name.starts_with('_')
341            && self
342                .env
343                .get("SHOPT_a")
344                .is_some_and(|v| matches!(&v.value, VarValue::Scalar(s) if s == "1"))
345        {
346            true
347        } else {
348            exported
349        };
350        // Preserve existing integer/nameref attributes
351        let (integer, nameref) = self
352            .env
353            .get(&name)
354            .map_or((false, false), |v| (v.integer, v.nameref));
355        self.env.set(
356            name,
357            ShellVar {
358                value: VarValue::Scalar(value),
359                exported,
360                readonly,
361                integer,
362                nameref,
363            },
364        );
365    }
366
367    /// Set a variable, returning an error if it is readonly.
368    pub fn set_var_checked(&mut self, name: SmolStr, value: SmolStr) -> Result<(), String> {
369        if let Some(var) = self.env.get(&name) {
370            if var.readonly {
371                return Err(format!("{name}: readonly variable"));
372            }
373        }
374        self.set_var(name, value);
375        Ok(())
376    }
377
378    /// Mark a variable as readonly with the given value.
379    pub fn set_readonly(&mut self, name: SmolStr, value: SmolStr) {
380        let (exported, integer, nameref) = self.env.get(&name).map_or((false, false, false), |v| {
381            (v.exported, v.integer, v.nameref)
382        });
383        self.env.set(
384            name,
385            ShellVar {
386                value: VarValue::Scalar(value),
387                exported,
388                readonly: true,
389                integer,
390                nameref,
391            },
392        );
393    }
394
395    /// Remove a variable. Returns error if readonly.
396    pub fn unset_var(&mut self, name: &str) -> Result<(), String> {
397        if let Some(var) = self.env.get(name) {
398            if var.readonly {
399                return Err(format!("{name}: readonly variable"));
400            }
401        }
402        self.env.remove(name);
403        Ok(())
404    }
405
406    /// Record that `name` was expanded while `nounset` was active. The pending
407    /// error is consumed by [`ShellState::take_nounset_error`] at the next
408    /// command dispatch point, which matches bash semantics of reporting the
409    /// first offender and aborting the current simple command.
410    pub fn set_nounset_error(&mut self, name: &str) {
411        self.env.set(
412            SmolStr::from(NOUNSET_ERROR_VAR),
413            ShellVar::scalar(SmolStr::from(name)),
414        );
415    }
416
417    /// Consume the pending nounset error, if any. Returns the offending
418    /// variable name and clears the sentinel so the next command starts clean.
419    pub fn take_nounset_error(&mut self) -> Option<SmolStr> {
420        let name = self.env.get(NOUNSET_ERROR_VAR)?.value.as_scalar();
421        if name.is_empty() {
422            return None;
423        }
424        self.env.remove(NOUNSET_ERROR_VAR);
425        Some(name)
426    }
427
428    /// Return all variable names (across all scopes) that start with the given prefix.
429    /// Innermost scope wins for shadowed names. Results are sorted alphabetically.
430    #[must_use]
431    pub fn var_names_with_prefix(&self, prefix: &str) -> Vec<SmolStr> {
432        let mut seen = IndexMap::<SmolStr, ()>::new();
433        // Walk from innermost to outermost so inner names are encountered first.
434        for scope in self.env.scopes.iter().rev() {
435            for name in scope.keys() {
436                if name.starts_with(prefix) {
437                    seen.entry(name.clone()).or_default();
438                }
439            }
440        }
441        let mut names: Vec<SmolStr> = seen.into_keys().collect();
442        names.sort();
443        names
444    }
445
446    // ---- Array methods ----
447
448    /// Get a single element from an array (or scalar when index is "0").
449    /// For indexed arrays, the index is parsed as `usize`.
450    /// For associative arrays, the index is used as-is.
451    /// For scalars, index "0" returns the scalar value.
452    #[must_use]
453    pub fn get_array_element(&self, name: &str, index: &str) -> Option<SmolStr> {
454        let var = self.env.get(name)?;
455        match &var.value {
456            VarValue::Scalar(s) => {
457                if index == "0" {
458                    Some(s.clone())
459                } else {
460                    None
461                }
462            }
463            VarValue::IndexedArray(map) => {
464                let idx: usize = index.parse().ok()?;
465                map.get(&idx).cloned()
466            }
467            VarValue::AssocArray(map) => map.get(index).cloned(),
468        }
469    }
470
471    /// Set a single element in an array. Creates an indexed array if the variable
472    /// does not exist. Converts a scalar to an indexed array if needed.
473    pub fn set_array_element(&mut self, name: SmolStr, index: &str, value: SmolStr) {
474        let (exported, readonly) = self
475            .env
476            .get(&name)
477            .map_or((false, false), |v| (v.exported, v.readonly));
478        if readonly {
479            return;
480        }
481
482        if let Some(var) = self.env.get_mut(&name) {
483            match &mut var.value {
484                VarValue::IndexedArray(map) => {
485                    if let Ok(idx) = index.parse::<usize>() {
486                        map.insert(idx, value);
487                    }
488                }
489                VarValue::AssocArray(map) => {
490                    map.insert(SmolStr::from(index), value);
491                }
492                VarValue::Scalar(_) => {
493                    // Convert scalar to indexed array
494                    let mut map = IndexMap::new();
495                    if let Ok(idx) = index.parse::<usize>() {
496                        map.insert(idx, value);
497                    }
498                    var.value = VarValue::IndexedArray(map);
499                }
500            }
501        } else {
502            // Variable doesn't exist; create indexed array
503            let mut map = IndexMap::new();
504            if let Ok(idx) = index.parse::<usize>() {
505                map.insert(idx, value);
506            }
507            self.env.set(
508                name,
509                ShellVar {
510                    value: VarValue::IndexedArray(map),
511                    exported,
512                    readonly,
513                    integer: false,
514                    nameref: false,
515                },
516            );
517        }
518    }
519
520    /// Get all keys/indices of an array variable.
521    #[must_use]
522    pub fn get_array_keys(&self, name: &str) -> Vec<String> {
523        let Some(var) = self.env.get(name) else {
524            return Vec::new();
525        };
526        match &var.value {
527            VarValue::Scalar(s) => {
528                if s.is_empty() {
529                    Vec::new()
530                } else {
531                    vec!["0".to_string()]
532                }
533            }
534            VarValue::IndexedArray(map) => map.keys().map(ToString::to_string).collect(),
535            VarValue::AssocArray(map) => map.keys().map(ToString::to_string).collect(),
536        }
537    }
538
539    /// Get all values of an array variable.
540    #[must_use]
541    pub fn get_array_values(&self, name: &str) -> Vec<SmolStr> {
542        let Some(var) = self.env.get(name) else {
543            return Vec::new();
544        };
545        match &var.value {
546            VarValue::Scalar(s) => {
547                if s.is_empty() {
548                    Vec::new()
549                } else {
550                    vec![s.clone()]
551                }
552            }
553            VarValue::IndexedArray(map) => map.values().cloned().collect(),
554            VarValue::AssocArray(map) => map.values().cloned().collect(),
555        }
556    }
557
558    /// Get the number of elements in an array.
559    #[must_use]
560    pub fn get_array_length(&self, name: &str) -> usize {
561        let Some(var) = self.env.get(name) else {
562            return 0;
563        };
564        match &var.value {
565            VarValue::Scalar(s) => usize::from(!s.is_empty()),
566            VarValue::IndexedArray(map) => map.len(),
567            VarValue::AssocArray(map) => map.len(),
568        }
569    }
570
571    /// Append values to an indexed array (`arr+=(val1 val2)`).
572    /// If the variable is a scalar, it is first converted to an indexed array
573    /// with the scalar as element 0.
574    pub fn append_array(&mut self, name: &str, values: Vec<SmolStr>) {
575        if self.env.get(name).is_some_and(|var| var.readonly) {
576            return;
577        }
578
579        let name_key = SmolStr::from(name);
580        if let Some(var) = self.env.get_mut(name) {
581            match &mut var.value {
582                VarValue::IndexedArray(map) => append_to_indexed_array(map, values),
583                VarValue::AssocArray(_) => {
584                    // Bash doesn't support += for assoc arrays in the same way;
585                    // silently ignore.
586                }
587                VarValue::Scalar(s) => {
588                    var.value = VarValue::IndexedArray(scalar_to_indexed_array(s, values));
589                }
590            }
591        } else {
592            self.env.set(
593                name_key,
594                ShellVar {
595                    value: VarValue::IndexedArray(values_to_indexed_array(values)),
596                    exported: false,
597                    readonly: false,
598                    integer: false,
599                    nameref: false,
600                },
601            );
602        }
603    }
604
605    /// Remove a single element from an array.
606    pub fn unset_array_element(&mut self, name: &str, index: &str) {
607        if let Some(var) = self.env.get(name) {
608            if var.readonly {
609                return;
610            }
611        }
612        if let Some(var) = self.env.get_mut(name) {
613            match &mut var.value {
614                VarValue::IndexedArray(map) => {
615                    if let Ok(idx) = index.parse::<usize>() {
616                        map.shift_remove(&idx);
617                    }
618                }
619                VarValue::AssocArray(map) => {
620                    map.shift_remove(index);
621                }
622                VarValue::Scalar(_) => {
623                    if index == "0" {
624                        var.value = VarValue::Scalar(SmolStr::default());
625                    }
626                }
627            }
628        }
629    }
630
631    /// Initialize an empty indexed array variable.
632    pub fn init_indexed_array(&mut self, name: SmolStr) {
633        let (exported, readonly) = self
634            .env
635            .get(&name)
636            .map_or((false, false), |v| (v.exported, v.readonly));
637        if readonly {
638            return;
639        }
640        self.env.set(
641            name,
642            ShellVar {
643                value: VarValue::IndexedArray(IndexMap::new()),
644                exported,
645                readonly,
646                integer: false,
647                nameref: false,
648            },
649        );
650    }
651
652    /// Initialize an empty associative array variable.
653    pub fn init_assoc_array(&mut self, name: SmolStr) {
654        let (exported, readonly) = self
655            .env
656            .get(&name)
657            .map_or((false, false), |v| (v.exported, v.readonly));
658        if readonly {
659            return;
660        }
661        self.env.set(
662            name,
663            ShellVar {
664                value: VarValue::AssocArray(IndexMap::new()),
665                exported,
666                readonly,
667                integer: false,
668                nameref: false,
669            },
670        );
671    }
672}
673
674fn stack_last_or_default(stack: &[SmolStr]) -> SmolStr {
675    stack.last().cloned().unwrap_or_default()
676}
677
678fn shell_pid() -> u32 {
679    #[cfg(not(target_arch = "wasm32"))]
680    {
681        std::process::id()
682    }
683    #[cfg(target_arch = "wasm32")]
684    {
685        1
686    }
687}
688
689fn positional_param_index(name: &str) -> Option<usize> {
690    let n = name.parse::<usize>().ok()?;
691    (n >= 1).then_some(n - 1)
692}
693
694fn nameref_target(var: &ShellVar, name: &str) -> Option<SmolStr> {
695    if !var.nameref {
696        return None;
697    }
698    let target = var.value.as_scalar();
699    (!target.is_empty() && target.as_str() != name).then_some(target)
700}
701
702fn append_to_indexed_array(map: &mut IndexMap<usize, SmolStr>, values: Vec<SmolStr>) {
703    let next = next_array_index(map);
704    for (i, value) in values.into_iter().enumerate() {
705        map.insert(next + i, value);
706    }
707}
708
709fn scalar_to_indexed_array(scalar: &SmolStr, values: Vec<SmolStr>) -> IndexMap<usize, SmolStr> {
710    let mut map = IndexMap::new();
711    if !scalar.is_empty() {
712        map.insert(0, scalar.clone());
713    }
714    append_to_indexed_array(&mut map, values);
715    map
716}
717
718fn values_to_indexed_array(values: Vec<SmolStr>) -> IndexMap<usize, SmolStr> {
719    let mut map = IndexMap::new();
720    append_to_indexed_array(&mut map, values);
721    map
722}
723
724fn next_array_index(map: &IndexMap<usize, SmolStr>) -> usize {
725    map.keys().max().map_or(0, |k| k + 1)
726}
727
728impl Default for ShellState {
729    fn default() -> Self {
730        Self::new()
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn get_set_variable() {
740        let mut state = ShellState::new();
741        state.set_var("FOO".into(), "bar".into());
742        assert_eq!(state.get_var("FOO").unwrap(), "bar");
743    }
744
745    #[test]
746    fn special_params() {
747        let mut state = ShellState::new();
748        state.last_status = 42;
749        assert_eq!(state.get_var("?").unwrap(), "42");
750        assert_eq!(state.get_var("#").unwrap(), "0");
751        assert_eq!(state.get_var("0").unwrap(), "wasmsh");
752        assert_eq!(state.get_var("$$").unwrap(), state.shell_pid.to_string());
753        assert_eq!(state.get_var("!").unwrap(), "");
754        assert_eq!(state.get_var("-").unwrap(), "");
755        assert_eq!(state.get_var("_").unwrap(), "");
756    }
757
758    #[test]
759    fn special_params_track_last_argument_and_options() {
760        let mut state = ShellState::new();
761        state.set_last_argument("world");
762        state.set_var("SHOPT_e".into(), "1".into());
763        state.set_var("SHOPT_u".into(), "1".into());
764        assert_eq!(state.get_var("_").unwrap(), "world");
765        assert_eq!(state.get_var("-").unwrap(), "eu");
766    }
767
768    #[test]
769    fn special_params_track_background_pid() {
770        let mut state = ShellState::new();
771        state.set_last_background_pid(Some(1234));
772        assert_eq!(state.get_var("!").unwrap(), "1234");
773    }
774
775    #[test]
776    fn positional_params() {
777        let mut state = ShellState::new();
778        state.positional = vec!["a".into(), "b".into(), "c".into()];
779        assert_eq!(state.get_var("1").unwrap(), "a");
780        assert_eq!(state.get_var("2").unwrap(), "b");
781        assert_eq!(state.get_var("3").unwrap(), "c");
782        assert!(state.get_var("4").is_none());
783        assert_eq!(state.get_var("#").unwrap(), "3");
784    }
785
786    #[test]
787    fn scope_shadowing() {
788        let mut state = ShellState::new();
789        state.set_var("X".into(), "global".into());
790        state.env.push_scope();
791        state.set_var("X".into(), "local".into());
792        assert_eq!(state.get_var("X").unwrap(), "local");
793        state.env.pop_scope();
794        assert_eq!(state.get_var("X").unwrap(), "global");
795    }
796
797    #[test]
798    fn exported_vars() {
799        let mut state = ShellState::new();
800        state.env.set(
801            "PATH".into(),
802            ShellVar {
803                value: VarValue::Scalar("/bin".into()),
804                exported: true,
805                readonly: false,
806                integer: false,
807                nameref: false,
808            },
809        );
810        state.set_var("LOCAL".into(), "val".into());
811        let exports = state.env.exported_vars();
812        assert_eq!(exports.len(), 1);
813        assert_eq!(exports["PATH"], "/bin");
814    }
815
816    #[test]
817    fn unset_var_removes() {
818        let mut state = ShellState::new();
819        state.set_var("X".into(), "val".into());
820        assert!(state.get_var("X").is_some());
821        state.unset_var("X").unwrap();
822        assert!(state.get_var("X").is_none());
823    }
824
825    #[test]
826    fn nounset_error_roundtrip() {
827        let mut state = ShellState::new();
828        assert!(state.take_nounset_error().is_none());
829
830        state.set_nounset_error("FOO");
831        assert_eq!(state.take_nounset_error().as_deref(), Some("FOO"));
832        // Second take observes no pending error.
833        assert!(state.take_nounset_error().is_none());
834    }
835
836    #[test]
837    fn readonly_prevents_set() {
838        let mut state = ShellState::new();
839        state.set_readonly("X".into(), "locked".into());
840        assert!(state.set_var_checked("X".into(), "new".into()).is_err());
841        assert_eq!(state.get_var("X").unwrap(), "locked");
842    }
843
844    #[test]
845    fn readonly_prevents_unset() {
846        let mut state = ShellState::new();
847        state.set_readonly("X".into(), "locked".into());
848        assert!(state.unset_var("X").is_err());
849        assert!(state.get_var("X").is_some());
850    }
851
852    #[test]
853    fn set_var_preserves_exported_flag() {
854        let mut state = ShellState::new();
855        state.env.set(
856            "X".into(),
857            ShellVar {
858                value: VarValue::Scalar("old".into()),
859                exported: true,
860                readonly: false,
861                integer: false,
862                nameref: false,
863            },
864        );
865        state.set_var("X".into(), "new".into());
866        let var = state.env.get("X").unwrap();
867        assert_eq!(var.value.as_scalar(), "new");
868        assert!(var.exported); // preserved
869    }
870
871    // ---- Array tests ----
872
873    #[test]
874    fn indexed_array_basics() {
875        let mut state = ShellState::new();
876        state.init_indexed_array("arr".into());
877        state.set_array_element("arr".into(), "0", "zero".into());
878        state.set_array_element("arr".into(), "1", "one".into());
879        state.set_array_element("arr".into(), "2", "two".into());
880
881        assert_eq!(state.get_array_element("arr", "0").unwrap(), "zero");
882        assert_eq!(state.get_array_element("arr", "1").unwrap(), "one");
883        assert_eq!(state.get_array_element("arr", "2").unwrap(), "two");
884        assert!(state.get_array_element("arr", "3").is_none());
885
886        assert_eq!(state.get_array_length("arr"), 3);
887        assert_eq!(state.get_array_keys("arr"), vec!["0", "1", "2"]);
888        assert_eq!(
889            state.get_array_values("arr"),
890            vec![
891                SmolStr::from("zero"),
892                SmolStr::from("one"),
893                SmolStr::from("two")
894            ]
895        );
896    }
897
898    #[test]
899    fn assoc_array_basics() {
900        let mut state = ShellState::new();
901        state.init_assoc_array("map".into());
902        state.set_array_element("map".into(), "key1", "val1".into());
903        state.set_array_element("map".into(), "key2", "val2".into());
904
905        assert_eq!(state.get_array_element("map", "key1").unwrap(), "val1");
906        assert_eq!(state.get_array_element("map", "key2").unwrap(), "val2");
907        assert!(state.get_array_element("map", "key3").is_none());
908
909        assert_eq!(state.get_array_length("map"), 2);
910    }
911
912    #[test]
913    fn array_scalar_access() {
914        let mut state = ShellState::new();
915        state.init_indexed_array("arr".into());
916        state.set_array_element("arr".into(), "0", "a".into());
917        state.set_array_element("arr".into(), "1", "b".into());
918        // Accessing array as scalar returns space-joined values
919        assert_eq!(state.get_var("arr").unwrap(), "a b");
920    }
921
922    #[test]
923    fn append_array_values() {
924        let mut state = ShellState::new();
925        state.init_indexed_array("arr".into());
926        state.set_array_element("arr".into(), "0", "a".into());
927        state.append_array("arr", vec!["b".into(), "c".into()]);
928        assert_eq!(state.get_array_length("arr"), 3);
929        assert_eq!(state.get_array_element("arr", "1").unwrap(), "b");
930        assert_eq!(state.get_array_element("arr", "2").unwrap(), "c");
931    }
932
933    #[test]
934    fn unset_array_element_removes() {
935        let mut state = ShellState::new();
936        state.init_indexed_array("arr".into());
937        state.set_array_element("arr".into(), "0", "a".into());
938        state.set_array_element("arr".into(), "1", "b".into());
939        state.unset_array_element("arr", "0");
940        assert!(state.get_array_element("arr", "0").is_none());
941        assert_eq!(state.get_array_element("arr", "1").unwrap(), "b");
942        assert_eq!(state.get_array_length("arr"), 1);
943    }
944
945    #[test]
946    fn scalar_as_array_element_0() {
947        let mut state = ShellState::new();
948        state.set_var("X".into(), "hello".into());
949        // Scalars can be accessed as element 0
950        assert_eq!(state.get_array_element("X", "0").unwrap(), "hello");
951        assert!(state.get_array_element("X", "1").is_none());
952    }
953
954    #[test]
955    fn set_element_creates_indexed_array() {
956        let mut state = ShellState::new();
957        state.set_array_element("arr".into(), "5", "five".into());
958        assert_eq!(state.get_array_element("arr", "5").unwrap(), "five");
959        assert_eq!(state.get_array_length("arr"), 1);
960    }
961
962    // ---- Dynamic variable tests ----
963
964    #[test]
965    fn random_returns_bounded_value() {
966        let state = ShellState::new();
967        let val: u32 = state.get_var("RANDOM").unwrap().parse().unwrap();
968        assert!(val < 32768);
969    }
970
971    #[test]
972    fn random_changes_each_call() {
973        let state = ShellState::new();
974        let v1 = state.get_var("RANDOM").unwrap();
975        let v2 = state.get_var("RANDOM").unwrap();
976        // Successive calls should produce different values
977        assert_ne!(v1, v2);
978    }
979
980    #[test]
981    fn lineno_returns_current_value() {
982        let mut state = ShellState::new();
983        state.lineno = 42;
984        assert_eq!(state.get_var("LINENO").unwrap(), "42");
985    }
986
987    #[test]
988    fn seconds_returns_value() {
989        let state = ShellState::new();
990        let val = state.get_var("SECONDS").unwrap();
991        // Should parse as a number and be >= 0
992        let secs: u64 = val.parse().unwrap();
993        assert!(secs < 60); // test runs quickly
994    }
995
996    #[test]
997    fn funcname_empty_by_default() {
998        let state = ShellState::new();
999        assert_eq!(state.get_var("FUNCNAME").unwrap(), "");
1000    }
1001
1002    #[test]
1003    fn funcname_returns_top_of_stack() {
1004        let mut state = ShellState::new();
1005        state.func_stack.push("myfunc".into());
1006        assert_eq!(state.get_var("FUNCNAME").unwrap(), "myfunc");
1007    }
1008
1009    #[test]
1010    fn bash_source_returns_top_of_stack() {
1011        let mut state = ShellState::new();
1012        state.source_stack.push("/script.sh".into());
1013        assert_eq!(state.get_var("BASH_SOURCE").unwrap(), "/script.sh");
1014    }
1015}