diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index af064285..cdaa71c0 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -12,8 +12,8 @@ use crate::builtins::{self, Builtin}; use crate::error::{Error, Result}; use crate::fs::FileSystem; use crate::parser::{ - CaseCommand, Command, CommandList, CompoundCommand, ForCommand, FunctionDef, IfCommand, - ListOperator, ParameterOp, Pipeline, Redirect, RedirectKind, Script, SimpleCommand, + AssignmentValue, CaseCommand, Command, CommandList, CompoundCommand, ForCommand, FunctionDef, + IfCommand, ListOperator, ParameterOp, Pipeline, Redirect, RedirectKind, Script, SimpleCommand, UntilCommand, WhileCommand, Word, WordPart, }; @@ -33,6 +33,8 @@ pub struct Interpreter { fs: Arc, env: HashMap, variables: HashMap, + /// Arrays - stored as name -> index -> value + arrays: HashMap>, cwd: PathBuf, last_exit_code: i32, builtins: HashMap<&'static str, Box>, @@ -74,6 +76,7 @@ impl Interpreter { fs, env: HashMap::new(), variables: HashMap::new(), + arrays: HashMap::new(), cwd: PathBuf::from("/home/user"), last_exit_code: 0, builtins, @@ -600,8 +603,29 @@ impl Interpreter { ) -> Result { // Process variable assignments first for assignment in &command.assignments { - let value = self.expand_word(&assignment.value).await?; - self.variables.insert(assignment.name.clone(), value); + match &assignment.value { + AssignmentValue::Scalar(word) => { + let value = self.expand_word(word).await?; + if let Some(index_str) = &assignment.index { + // arr[index]=value - set array element + let index: usize = + self.evaluate_arithmetic(index_str).try_into().unwrap_or(0); + let arr = self.arrays.entry(assignment.name.clone()).or_default(); + arr.insert(index, value); + } else { + self.variables.insert(assignment.name.clone(), value); + } + } + AssignmentValue::Array(words) => { + // arr=(a b c) - set whole array + let mut arr = HashMap::new(); + for (i, word) in words.iter().enumerate() { + let value = self.expand_word(word).await?; + arr.insert(i, value); + } + self.arrays.insert(assignment.name.clone(), arr); + } + } } let name = self.expand_word(&command.name).await?; @@ -793,6 +817,36 @@ impl Interpreter { let expanded = self.apply_parameter_op(&value, name, operator, operand); result.push_str(&expanded); } + WordPart::ArrayAccess { name, index } => { + if index == "@" || index == "*" { + // ${arr[@]} or ${arr[*]} - expand to all elements + if let Some(arr) = self.arrays.get(name) { + let mut indices: Vec<_> = arr.keys().collect(); + indices.sort(); + let values: Vec<_> = + indices.iter().filter_map(|i| arr.get(i)).collect(); + result.push_str( + &values.into_iter().cloned().collect::>().join(" "), + ); + } + } else { + // ${arr[n]} - get specific element + let idx: usize = self.evaluate_arithmetic(index).try_into().unwrap_or(0); + if let Some(arr) = self.arrays.get(name) { + if let Some(value) = arr.get(&idx) { + result.push_str(value); + } + } + } + } + WordPart::ArrayLength(name) => { + // ${#arr[@]} - number of elements + if let Some(arr) = self.arrays.get(name) { + result.push_str(&arr.len().to_string()); + } else { + result.push('0'); + } + } } } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index a95c5650..ab47dd24 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -745,4 +745,42 @@ mod tests { let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap(); assert_eq!(result.stdout, "file.tar\n"); } + + #[tokio::test] + async fn test_array_basic() { + let mut bash = Bash::new(); + // Basic array declaration and access + let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap(); + assert_eq!(result.stdout, "b\n"); + } + + #[tokio::test] + async fn test_array_all_elements() { + let mut bash = Bash::new(); + // ${arr[@]} - all elements + let result = bash + .exec("arr=(one two three); echo ${arr[@]}") + .await + .unwrap(); + assert_eq!(result.stdout, "one two three\n"); + } + + #[tokio::test] + async fn test_array_length() { + let mut bash = Bash::new(); + // ${#arr[@]} - number of elements + let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap(); + assert_eq!(result.stdout, "5\n"); + } + + #[tokio::test] + async fn test_array_indexed_assignment() { + let mut bash = Bash::new(); + // arr[n]=value assignment + let result = bash + .exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}") + .await + .unwrap(); + assert_eq!(result.stdout, "first second\n"); + } } diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index d87cb1df..23711d82 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -188,6 +188,8 @@ impl fmt::Display for Word { write!(f, "${{{}{}{}}}", name, op_str, operand)? } WordPart::Length(name) => write!(f, "${{#{}}}", name)?, + WordPart::ArrayAccess { name, index } => write!(f, "${{{}[{}]}}", name, index)?, + WordPart::ArrayLength(name) => write!(f, "${{#{}[@]}}", name)?, } } Ok(()) @@ -213,6 +215,10 @@ pub enum WordPart { }, /// Length expansion ${#var} Length(String), + /// Array element access ${arr[index]} or ${arr[@]} or ${arr[*]} + ArrayAccess { name: String, index: String }, + /// Array length ${#arr[@]} or ${#arr[*]} + ArrayLength(String), } /// Parameter expansion operators @@ -272,5 +278,16 @@ pub enum RedirectKind { #[derive(Debug, Clone)] pub struct Assignment { pub name: String, - pub value: Word, + /// Optional array index for indexed assignments like arr[0]=value + pub index: Option, + pub value: AssignmentValue, +} + +/// Value in an assignment - scalar or array +#[derive(Debug, Clone)] +pub enum AssignmentValue { + /// Scalar value: VAR=value + Scalar(Word), + /// Array value: VAR=(a b c) + Array(Vec), } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 6f601140..f41453c6 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -168,7 +168,10 @@ impl<'a> Parser<'a> { "function" => return self.parse_function_keyword().map(Some), _ => { // Check for POSIX-style function: name() { body } - if matches!(self.peek_next(), Some(tokens::Token::LeftParen)) { + // Don't match if word contains '=' (that's an assignment like arr=(a b c)) + if !word.contains('=') + && matches!(self.peek_next(), Some(tokens::Token::LeftParen)) + { return self.parse_function_posix().map(Some); } } @@ -629,28 +632,53 @@ impl<'a> Parser<'a> { } } - /// Check if a word is an assignment (NAME=value) - fn is_assignment(word: &str) -> Option<(&str, &str)> { + /// Check if a word is an assignment (NAME=value or NAME[index]=value) + /// Returns (name, optional_index, value) + fn is_assignment(word: &str) -> Option<(&str, Option<&str>, &str)> { // Find the first = if let Some(eq_pos) = word.find('=') { - let name = &word[..eq_pos]; + let lhs = &word[..eq_pos]; let value = &word[eq_pos + 1..]; - // Name must be valid identifier: starts with letter or _, followed by alnum or _ - if name.is_empty() { - return None; - } - let mut chars = name.chars(); - let first = chars.next().unwrap(); - if !first.is_ascii_alphabetic() && first != '_' { - return None; - } - for c in chars { - if !c.is_ascii_alphanumeric() && c != '_' { + // Check for array subscript: name[index] + if let Some(bracket_pos) = lhs.find('[') { + let name = &lhs[..bracket_pos]; + // Validate name + if name.is_empty() { + return None; + } + let mut chars = name.chars(); + let first = chars.next().unwrap(); + if !first.is_ascii_alphabetic() && first != '_' { + return None; + } + for c in chars { + if !c.is_ascii_alphanumeric() && c != '_' { + return None; + } + } + // Extract index (everything between [ and ]) + if lhs.ends_with(']') { + let index = &lhs[bracket_pos + 1..lhs.len() - 1]; + return Some((name, Some(index), value)); + } + } else { + // Name must be valid identifier: starts with letter or _, followed by alnum or _ + if lhs.is_empty() { + return None; + } + let mut chars = lhs.chars(); + let first = chars.next().unwrap(); + if !first.is_ascii_alphabetic() && first != '_' { return None; } + for c in chars { + if !c.is_ascii_alphanumeric() && c != '_' { + return None; + } + } + return Some((lhs, None, value)); } - return Some((name, value)); } None } @@ -677,10 +705,67 @@ impl<'a> Parser<'a> { // Check for assignment (only before the command name) if words.is_empty() { - if let Some((name, value)) = Self::is_assignment(w) { + let w_clone = w.clone(); + if let Some((name, index, value)) = Self::is_assignment(&w_clone) { + let name = name.to_string(); + let index = index.map(|s| s.to_string()); + let value_str = value.to_string(); + + // Check for array literal: arr=(a b c) + let assignment_value = if value_str.starts_with('(') + && value_str.ends_with(')') + { + let inner = &value_str[1..value_str.len() - 1]; + let elements: Vec = inner + .split_whitespace() + .map(|s| self.parse_word(s.to_string())) + .collect(); + AssignmentValue::Array(elements) + } else if value_str.is_empty() { + // Check if next token is ( for arr=(...) syntax + self.advance(); + if matches!(self.current_token, Some(tokens::Token::LeftParen)) { + self.advance(); // consume '(' + let mut elements = Vec::new(); + loop { + match &self.current_token { + Some(tokens::Token::RightParen) => { + self.advance(); + break; + } + Some(tokens::Token::Word(elem)) => { + let elem_clone = elem.clone(); + elements.push(self.parse_word(elem_clone)); + self.advance(); + } + None => break, + _ => { + self.advance(); + } + } + } + assignments.push(Assignment { + name, + index, + value: AssignmentValue::Array(elements), + }); + continue; + } else { + // Empty assignment: VAR= + assignments.push(Assignment { + name, + index, + value: AssignmentValue::Scalar(Word::literal("")), + }); + continue; + } + } else { + AssignmentValue::Scalar(self.parse_word(value_str)) + }; assignments.push(Assignment { - name: name.to_string(), - value: self.parse_word(value.to_string()), + name, + index, + value: assignment_value, }); self.advance(); continue; @@ -864,18 +949,44 @@ impl<'a> Parser<'a> { // ${VAR} format with possible parameter expansion chars.next(); // consume '{' - // Check for ${#var} - length expansion + // Check for ${#var} or ${#arr[@]} - length expansion if chars.peek() == Some(&'#') { chars.next(); // consume '#' let mut var_name = String::new(); while let Some(&c) = chars.peek() { - if c == '}' { - chars.next(); + if c == '}' || c == '[' { break; } var_name.push(chars.next().unwrap()); } - parts.push(WordPart::Length(var_name)); + // Check for array length ${#arr[@]} or ${#arr[*]} + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + let mut index = String::new(); + while let Some(&c) = chars.peek() { + if c == ']' { + chars.next(); + break; + } + index.push(chars.next().unwrap()); + } + // Consume closing } + if chars.peek() == Some(&'}') { + chars.next(); + } + if index == "@" || index == "*" { + parts.push(WordPart::ArrayLength(var_name)); + } else { + // ${#arr[n]} - length of element (same as ${#arr[n]}) + parts.push(WordPart::Length(format!("{}[{}]", var_name, index))); + } + } else { + // Consume closing } + if chars.peek() == Some(&'}') { + chars.next(); + } + parts.push(WordPart::Length(var_name)); + } } else { // Read variable name let mut var_name = String::new(); @@ -887,8 +998,27 @@ impl<'a> Parser<'a> { } } - // Check for operator - if let Some(&c) = chars.peek() { + // Check for array access ${arr[index]} + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + let mut index = String::new(); + while let Some(&c) = chars.peek() { + if c == ']' { + chars.next(); + break; + } + index.push(chars.next().unwrap()); + } + // Consume closing } + if chars.peek() == Some(&'}') { + chars.next(); + } + parts.push(WordPart::ArrayAccess { + name: var_name, + index, + }); + } else if let Some(&c) = chars.peek() { + // Check for operator let (operator, operand) = match c { ':' => { chars.next(); // consume ':'