use std::cell::Cell;
use indexmap::IndexMap;
use smol_str::SmolStr;
#[derive(Debug, Clone, PartialEq)]
pub enum VarValue {
Scalar(SmolStr),
IndexedArray(IndexMap<usize, SmolStr>),
AssocArray(IndexMap<SmolStr, SmolStr>),
}
impl VarValue {
#[must_use]
pub fn as_scalar(&self) -> SmolStr {
match self {
Self::Scalar(s) => s.clone(),
Self::IndexedArray(map) => {
let vals: Vec<&str> = map.values().map(SmolStr::as_str).collect();
SmolStr::from(vals.join(" "))
}
Self::AssocArray(map) => {
let vals: Vec<&str> = map.values().map(SmolStr::as_str).collect();
SmolStr::from(vals.join(" "))
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::struct_excessive_bools)]
pub struct ShellVar {
pub value: VarValue,
pub exported: bool,
pub readonly: bool,
pub integer: bool,
pub nameref: bool,
}
impl ShellVar {
pub fn scalar(value: SmolStr) -> Self {
Self {
value: VarValue::Scalar(value),
exported: false,
readonly: false,
integer: false,
nameref: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ShellEnv {
pub scopes: Vec<IndexMap<SmolStr, ShellVar>>,
}
impl ShellEnv {
#[must_use]
pub fn new() -> Self {
Self {
scopes: vec![IndexMap::new()],
}
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&ShellVar> {
for scope in self.scopes.iter().rev() {
if let Some(var) = scope.get(name) {
return Some(var);
}
}
None
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut ShellVar> {
for scope in self.scopes.iter_mut().rev() {
if let Some(var) = scope.get_mut(name) {
return Some(var);
}
}
None
}
pub fn set(&mut self, name: SmolStr, var: ShellVar) {
if let Some(scope) = self.scopes.last_mut() {
scope.insert(name, var);
}
}
pub fn push_scope(&mut self) {
self.scopes.push(IndexMap::new());
}
pub fn pop_scope(&mut self) -> Option<IndexMap<SmolStr, ShellVar>> {
if self.scopes.len() > 1 {
self.scopes.pop()
} else {
None
}
}
pub fn remove(&mut self, name: &str) -> Option<ShellVar> {
if let Some(scope) = self.scopes.last_mut() {
scope.shift_remove(name)
} else {
None
}
}
pub fn exported_vars(&self) -> IndexMap<SmolStr, SmolStr> {
let mut result = IndexMap::new();
for scope in &self.scopes {
for (name, var) in scope {
if var.exported {
result.insert(name.clone(), var.value.as_scalar());
}
}
}
result
}
}
impl Default for ShellEnv {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ShellState {
pub env: ShellEnv,
pub positional: Vec<SmolStr>,
pub last_status: i32,
pub cwd: String,
pub lineno: u32,
pub random_seed: Cell<u32>,
#[cfg(not(target_arch = "wasm32"))]
pub start_time: std::time::Instant,
pub func_stack: Vec<SmolStr>,
pub source_stack: Vec<SmolStr>,
}
impl ShellState {
#[must_use]
pub fn new() -> Self {
Self {
env: ShellEnv::new(),
positional: Vec::new(),
last_status: 0,
cwd: "/".into(),
lineno: 0,
random_seed: Cell::new(12345),
#[cfg(not(target_arch = "wasm32"))]
start_time: std::time::Instant::now(),
func_stack: Vec::new(),
source_stack: Vec::new(),
}
}
fn next_random(&self) -> u32 {
let mut x = self.random_seed.get();
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.random_seed.set(x);
x % 32768
}
#[must_use]
pub fn get_var(&self, name: &str) -> Option<SmolStr> {
match name {
"?" => Some(self.last_status.to_string().into()),
"#" => Some(self.positional.len().to_string().into()),
"0" => Some("wasmsh".into()),
"@" | "*" => Some(self.positional.join(" ").into()),
"RANDOM" => Some(SmolStr::from(self.next_random().to_string())),
"LINENO" => Some(SmolStr::from(self.lineno.to_string())),
"SECONDS" => Some(self.seconds_value()),
"FUNCNAME" => Some(stack_last_or_default(&self.func_stack)),
"BASH_SOURCE" => Some(stack_last_or_default(&self.source_stack)),
_ => self.get_named_or_positional_var(name),
}
}
#[allow(clippy::unused_self)] fn seconds_value(&self) -> SmolStr {
#[cfg(not(target_arch = "wasm32"))]
{
SmolStr::from(self.start_time.elapsed().as_secs().to_string())
}
#[cfg(target_arch = "wasm32")]
{
SmolStr::from("0")
}
}
fn get_named_or_positional_var(&self, name: &str) -> Option<SmolStr> {
if let Some(index) = positional_param_index(name) {
return self.positional.get(index).cloned();
}
self.get_env_var(name)
}
fn get_env_var(&self, name: &str) -> Option<SmolStr> {
let var = self.env.get(name)?;
if let Some(target) = nameref_target(var, name) {
return self.env.get(&target).map(|v| v.value.as_scalar());
}
Some(var.value.as_scalar())
}
pub fn set_var(&mut self, name: SmolStr, value: SmolStr) {
if let Some(var) = self.env.get(&name) {
if var.nameref {
let target = var.value.as_scalar();
if !target.is_empty() && target.as_str() != name.as_str() {
let target_name = SmolStr::from(target.as_str());
self.set_var(target_name, value);
return;
}
}
}
let (exported, readonly) = self
.env
.get(&name)
.map_or((false, false), |v| (v.exported, v.readonly));
if readonly {
return; }
let exported = if !exported
&& !name.starts_with("SHOPT_")
&& !name.starts_with('_')
&& self
.env
.get("SHOPT_a")
.is_some_and(|v| matches!(&v.value, VarValue::Scalar(s) if s == "1"))
{
true
} else {
exported
};
let (integer, nameref) = self
.env
.get(&name)
.map_or((false, false), |v| (v.integer, v.nameref));
self.env.set(
name,
ShellVar {
value: VarValue::Scalar(value),
exported,
readonly,
integer,
nameref,
},
);
}
pub fn set_var_checked(&mut self, name: SmolStr, value: SmolStr) -> Result<(), String> {
if let Some(var) = self.env.get(&name) {
if var.readonly {
return Err(format!("{name}: readonly variable"));
}
}
self.set_var(name, value);
Ok(())
}
pub fn set_readonly(&mut self, name: SmolStr, value: SmolStr) {
let (exported, integer, nameref) = self.env.get(&name).map_or((false, false, false), |v| {
(v.exported, v.integer, v.nameref)
});
self.env.set(
name,
ShellVar {
value: VarValue::Scalar(value),
exported,
readonly: true,
integer,
nameref,
},
);
}
pub fn unset_var(&mut self, name: &str) -> Result<(), String> {
if let Some(var) = self.env.get(name) {
if var.readonly {
return Err(format!("{name}: readonly variable"));
}
}
self.env.remove(name);
Ok(())
}
#[must_use]
pub fn var_names_with_prefix(&self, prefix: &str) -> Vec<SmolStr> {
let mut seen = IndexMap::<SmolStr, ()>::new();
for scope in self.env.scopes.iter().rev() {
for name in scope.keys() {
if name.starts_with(prefix) {
seen.entry(name.clone()).or_default();
}
}
}
let mut names: Vec<SmolStr> = seen.into_keys().collect();
names.sort();
names
}
#[must_use]
pub fn get_array_element(&self, name: &str, index: &str) -> Option<SmolStr> {
let var = self.env.get(name)?;
match &var.value {
VarValue::Scalar(s) => {
if index == "0" {
Some(s.clone())
} else {
None
}
}
VarValue::IndexedArray(map) => {
let idx: usize = index.parse().ok()?;
map.get(&idx).cloned()
}
VarValue::AssocArray(map) => map.get(index).cloned(),
}
}
pub fn set_array_element(&mut self, name: SmolStr, index: &str, value: SmolStr) {
let (exported, readonly) = self
.env
.get(&name)
.map_or((false, false), |v| (v.exported, v.readonly));
if readonly {
return;
}
if let Some(var) = self.env.get_mut(&name) {
match &mut var.value {
VarValue::IndexedArray(map) => {
if let Ok(idx) = index.parse::<usize>() {
map.insert(idx, value);
}
}
VarValue::AssocArray(map) => {
map.insert(SmolStr::from(index), value);
}
VarValue::Scalar(_) => {
let mut map = IndexMap::new();
if let Ok(idx) = index.parse::<usize>() {
map.insert(idx, value);
}
var.value = VarValue::IndexedArray(map);
}
}
} else {
let mut map = IndexMap::new();
if let Ok(idx) = index.parse::<usize>() {
map.insert(idx, value);
}
self.env.set(
name,
ShellVar {
value: VarValue::IndexedArray(map),
exported,
readonly,
integer: false,
nameref: false,
},
);
}
}
#[must_use]
pub fn get_array_keys(&self, name: &str) -> Vec<String> {
let Some(var) = self.env.get(name) else {
return Vec::new();
};
match &var.value {
VarValue::Scalar(s) => {
if s.is_empty() {
Vec::new()
} else {
vec!["0".to_string()]
}
}
VarValue::IndexedArray(map) => map.keys().map(ToString::to_string).collect(),
VarValue::AssocArray(map) => map.keys().map(ToString::to_string).collect(),
}
}
#[must_use]
pub fn get_array_values(&self, name: &str) -> Vec<SmolStr> {
let Some(var) = self.env.get(name) else {
return Vec::new();
};
match &var.value {
VarValue::Scalar(s) => {
if s.is_empty() {
Vec::new()
} else {
vec![s.clone()]
}
}
VarValue::IndexedArray(map) => map.values().cloned().collect(),
VarValue::AssocArray(map) => map.values().cloned().collect(),
}
}
#[must_use]
pub fn get_array_length(&self, name: &str) -> usize {
let Some(var) = self.env.get(name) else {
return 0;
};
match &var.value {
VarValue::Scalar(s) => usize::from(!s.is_empty()),
VarValue::IndexedArray(map) => map.len(),
VarValue::AssocArray(map) => map.len(),
}
}
pub fn append_array(&mut self, name: &str, values: Vec<SmolStr>) {
if self.env.get(name).is_some_and(|var| var.readonly) {
return;
}
let name_key = SmolStr::from(name);
if let Some(var) = self.env.get_mut(name) {
match &mut var.value {
VarValue::IndexedArray(map) => append_to_indexed_array(map, values),
VarValue::AssocArray(_) => {
}
VarValue::Scalar(s) => {
var.value = VarValue::IndexedArray(scalar_to_indexed_array(s, values));
}
}
} else {
self.env.set(
name_key,
ShellVar {
value: VarValue::IndexedArray(values_to_indexed_array(values)),
exported: false,
readonly: false,
integer: false,
nameref: false,
},
);
}
}
pub fn unset_array_element(&mut self, name: &str, index: &str) {
if let Some(var) = self.env.get(name) {
if var.readonly {
return;
}
}
if let Some(var) = self.env.get_mut(name) {
match &mut var.value {
VarValue::IndexedArray(map) => {
if let Ok(idx) = index.parse::<usize>() {
map.shift_remove(&idx);
}
}
VarValue::AssocArray(map) => {
map.shift_remove(index);
}
VarValue::Scalar(_) => {
if index == "0" {
var.value = VarValue::Scalar(SmolStr::default());
}
}
}
}
}
pub fn init_indexed_array(&mut self, name: SmolStr) {
let (exported, readonly) = self
.env
.get(&name)
.map_or((false, false), |v| (v.exported, v.readonly));
if readonly {
return;
}
self.env.set(
name,
ShellVar {
value: VarValue::IndexedArray(IndexMap::new()),
exported,
readonly,
integer: false,
nameref: false,
},
);
}
pub fn init_assoc_array(&mut self, name: SmolStr) {
let (exported, readonly) = self
.env
.get(&name)
.map_or((false, false), |v| (v.exported, v.readonly));
if readonly {
return;
}
self.env.set(
name,
ShellVar {
value: VarValue::AssocArray(IndexMap::new()),
exported,
readonly,
integer: false,
nameref: false,
},
);
}
}
fn stack_last_or_default(stack: &[SmolStr]) -> SmolStr {
stack.last().cloned().unwrap_or_default()
}
fn positional_param_index(name: &str) -> Option<usize> {
let n = name.parse::<usize>().ok()?;
(n >= 1).then_some(n - 1)
}
fn nameref_target(var: &ShellVar, name: &str) -> Option<SmolStr> {
if !var.nameref {
return None;
}
let target = var.value.as_scalar();
(!target.is_empty() && target.as_str() != name).then_some(target)
}
fn append_to_indexed_array(map: &mut IndexMap<usize, SmolStr>, values: Vec<SmolStr>) {
let next = next_array_index(map);
for (i, value) in values.into_iter().enumerate() {
map.insert(next + i, value);
}
}
fn scalar_to_indexed_array(scalar: &SmolStr, values: Vec<SmolStr>) -> IndexMap<usize, SmolStr> {
let mut map = IndexMap::new();
if !scalar.is_empty() {
map.insert(0, scalar.clone());
}
append_to_indexed_array(&mut map, values);
map
}
fn values_to_indexed_array(values: Vec<SmolStr>) -> IndexMap<usize, SmolStr> {
let mut map = IndexMap::new();
append_to_indexed_array(&mut map, values);
map
}
fn next_array_index(map: &IndexMap<usize, SmolStr>) -> usize {
map.keys().max().map_or(0, |k| k + 1)
}
impl Default for ShellState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_set_variable() {
let mut state = ShellState::new();
state.set_var("FOO".into(), "bar".into());
assert_eq!(state.get_var("FOO").unwrap(), "bar");
}
#[test]
fn special_params() {
let mut state = ShellState::new();
state.last_status = 42;
assert_eq!(state.get_var("?").unwrap(), "42");
assert_eq!(state.get_var("#").unwrap(), "0");
assert_eq!(state.get_var("0").unwrap(), "wasmsh");
}
#[test]
fn positional_params() {
let mut state = ShellState::new();
state.positional = vec!["a".into(), "b".into(), "c".into()];
assert_eq!(state.get_var("1").unwrap(), "a");
assert_eq!(state.get_var("2").unwrap(), "b");
assert_eq!(state.get_var("3").unwrap(), "c");
assert!(state.get_var("4").is_none());
assert_eq!(state.get_var("#").unwrap(), "3");
}
#[test]
fn scope_shadowing() {
let mut state = ShellState::new();
state.set_var("X".into(), "global".into());
state.env.push_scope();
state.set_var("X".into(), "local".into());
assert_eq!(state.get_var("X").unwrap(), "local");
state.env.pop_scope();
assert_eq!(state.get_var("X").unwrap(), "global");
}
#[test]
fn exported_vars() {
let mut state = ShellState::new();
state.env.set(
"PATH".into(),
ShellVar {
value: VarValue::Scalar("/bin".into()),
exported: true,
readonly: false,
integer: false,
nameref: false,
},
);
state.set_var("LOCAL".into(), "val".into());
let exports = state.env.exported_vars();
assert_eq!(exports.len(), 1);
assert_eq!(exports["PATH"], "/bin");
}
#[test]
fn unset_var_removes() {
let mut state = ShellState::new();
state.set_var("X".into(), "val".into());
assert!(state.get_var("X").is_some());
state.unset_var("X").unwrap();
assert!(state.get_var("X").is_none());
}
#[test]
fn readonly_prevents_set() {
let mut state = ShellState::new();
state.set_readonly("X".into(), "locked".into());
assert!(state.set_var_checked("X".into(), "new".into()).is_err());
assert_eq!(state.get_var("X").unwrap(), "locked");
}
#[test]
fn readonly_prevents_unset() {
let mut state = ShellState::new();
state.set_readonly("X".into(), "locked".into());
assert!(state.unset_var("X").is_err());
assert!(state.get_var("X").is_some());
}
#[test]
fn set_var_preserves_exported_flag() {
let mut state = ShellState::new();
state.env.set(
"X".into(),
ShellVar {
value: VarValue::Scalar("old".into()),
exported: true,
readonly: false,
integer: false,
nameref: false,
},
);
state.set_var("X".into(), "new".into());
let var = state.env.get("X").unwrap();
assert_eq!(var.value.as_scalar(), "new");
assert!(var.exported); }
#[test]
fn indexed_array_basics() {
let mut state = ShellState::new();
state.init_indexed_array("arr".into());
state.set_array_element("arr".into(), "0", "zero".into());
state.set_array_element("arr".into(), "1", "one".into());
state.set_array_element("arr".into(), "2", "two".into());
assert_eq!(state.get_array_element("arr", "0").unwrap(), "zero");
assert_eq!(state.get_array_element("arr", "1").unwrap(), "one");
assert_eq!(state.get_array_element("arr", "2").unwrap(), "two");
assert!(state.get_array_element("arr", "3").is_none());
assert_eq!(state.get_array_length("arr"), 3);
assert_eq!(state.get_array_keys("arr"), vec!["0", "1", "2"]);
assert_eq!(
state.get_array_values("arr"),
vec![
SmolStr::from("zero"),
SmolStr::from("one"),
SmolStr::from("two")
]
);
}
#[test]
fn assoc_array_basics() {
let mut state = ShellState::new();
state.init_assoc_array("map".into());
state.set_array_element("map".into(), "key1", "val1".into());
state.set_array_element("map".into(), "key2", "val2".into());
assert_eq!(state.get_array_element("map", "key1").unwrap(), "val1");
assert_eq!(state.get_array_element("map", "key2").unwrap(), "val2");
assert!(state.get_array_element("map", "key3").is_none());
assert_eq!(state.get_array_length("map"), 2);
}
#[test]
fn array_scalar_access() {
let mut state = ShellState::new();
state.init_indexed_array("arr".into());
state.set_array_element("arr".into(), "0", "a".into());
state.set_array_element("arr".into(), "1", "b".into());
assert_eq!(state.get_var("arr").unwrap(), "a b");
}
#[test]
fn append_array_values() {
let mut state = ShellState::new();
state.init_indexed_array("arr".into());
state.set_array_element("arr".into(), "0", "a".into());
state.append_array("arr", vec!["b".into(), "c".into()]);
assert_eq!(state.get_array_length("arr"), 3);
assert_eq!(state.get_array_element("arr", "1").unwrap(), "b");
assert_eq!(state.get_array_element("arr", "2").unwrap(), "c");
}
#[test]
fn unset_array_element_removes() {
let mut state = ShellState::new();
state.init_indexed_array("arr".into());
state.set_array_element("arr".into(), "0", "a".into());
state.set_array_element("arr".into(), "1", "b".into());
state.unset_array_element("arr", "0");
assert!(state.get_array_element("arr", "0").is_none());
assert_eq!(state.get_array_element("arr", "1").unwrap(), "b");
assert_eq!(state.get_array_length("arr"), 1);
}
#[test]
fn scalar_as_array_element_0() {
let mut state = ShellState::new();
state.set_var("X".into(), "hello".into());
assert_eq!(state.get_array_element("X", "0").unwrap(), "hello");
assert!(state.get_array_element("X", "1").is_none());
}
#[test]
fn set_element_creates_indexed_array() {
let mut state = ShellState::new();
state.set_array_element("arr".into(), "5", "five".into());
assert_eq!(state.get_array_element("arr", "5").unwrap(), "five");
assert_eq!(state.get_array_length("arr"), 1);
}
#[test]
fn random_returns_bounded_value() {
let state = ShellState::new();
let val: u32 = state.get_var("RANDOM").unwrap().parse().unwrap();
assert!(val < 32768);
}
#[test]
fn random_changes_each_call() {
let state = ShellState::new();
let v1 = state.get_var("RANDOM").unwrap();
let v2 = state.get_var("RANDOM").unwrap();
assert_ne!(v1, v2);
}
#[test]
fn lineno_returns_current_value() {
let mut state = ShellState::new();
state.lineno = 42;
assert_eq!(state.get_var("LINENO").unwrap(), "42");
}
#[test]
fn seconds_returns_value() {
let state = ShellState::new();
let val = state.get_var("SECONDS").unwrap();
let secs: u64 = val.parse().unwrap();
assert!(secs < 60); }
#[test]
fn funcname_empty_by_default() {
let state = ShellState::new();
assert_eq!(state.get_var("FUNCNAME").unwrap(), "");
}
#[test]
fn funcname_returns_top_of_stack() {
let mut state = ShellState::new();
state.func_stack.push("myfunc".into());
assert_eq!(state.get_var("FUNCNAME").unwrap(), "myfunc");
}
#[test]
fn bash_source_returns_top_of_stack() {
let mut state = ShellState::new();
state.source_stack.push("/script.sh".into());
assert_eq!(state.get_var("BASH_SOURCE").unwrap(), "/script.sh");
}
}