diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 2a1158c..dfbb747 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -7,7 +7,9 @@ mod flow; mod navigation; mod printf; mod read; +mod source; mod test; +mod vars; pub use cat::Cat; pub use echo::Echo; @@ -16,7 +18,9 @@ pub use flow::{Break, Continue, Exit, False, Return, True}; pub use navigation::{Cd, Pwd}; pub use printf::Printf; pub use read::Read; +pub use source::Source; pub use test::{Bracket, Test}; +pub use vars::{Local, Set, Shift, Unset}; use async_trait::async_trait; use std::collections::HashMap; diff --git a/crates/bashkit/src/builtins/source.rs b/crates/bashkit/src/builtins/source.rs new file mode 100644 index 0000000..e3b8c40 --- /dev/null +++ b/crates/bashkit/src/builtins/source.rs @@ -0,0 +1,62 @@ +//! source builtin - execute commands from a file + +use async_trait::async_trait; +use std::path::Path; +use std::sync::Arc; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::fs::FileSystem; +use crate::interpreter::ExecResult; +use crate::parser::Parser; + +/// source/. builtin - execute commands from a file in current shell +pub struct Source { + fs: Arc, +} + +impl Source { + pub fn new(fs: Arc) -> Self { + Self { fs } + } +} + +#[async_trait] +impl Builtin for Source { + async fn execute(&self, ctx: Context<'_>) -> Result { + let filename = match ctx.args.first() { + Some(f) => f, + None => { + return Ok(ExecResult::err("source: filename argument required", 1)); + } + }; + + // Read the file + let path = Path::new(filename); + let content = match self.fs.read_file(path).await { + Ok(c) => String::from_utf8_lossy(&c).to_string(), + Err(_) => { + return Ok(ExecResult::err( + format!("source: {}: No such file", filename), + 1, + )); + } + }; + + // Parse and return the script for the interpreter to execute + // We store the parsed commands in a special variable for the interpreter + let parser = Parser::new(&content); + match parser.parse() { + Ok(_script) => { + // Store the script content for interpreter to execute + // The actual execution happens in the interpreter + ctx.variables.insert("_SOURCE_SCRIPT".to_string(), content); + Ok(ExecResult::ok(String::new())) + } + Err(e) => Ok(ExecResult::err( + format!("source: {}: parse error: {}", filename, e), + 1, + )), + } + } +} diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs new file mode 100644 index 0000000..ae2d5ce --- /dev/null +++ b/crates/bashkit/src/builtins/vars.rs @@ -0,0 +1,103 @@ +//! Variable manipulation builtins: set, unset, local, shift + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// unset builtin - remove variables +pub struct Unset; + +#[async_trait] +impl Builtin for Unset { + async fn execute(&self, ctx: Context<'_>) -> Result { + for name in ctx.args { + ctx.variables.remove(name); + // Note: env is immutable in our model - environment variables + // are inherited and can't be unset by the shell + } + Ok(ExecResult::ok(String::new())) + } +} + +/// set builtin - set/display shell options and positional parameters +/// +/// Currently supports: +/// - `set -e` - exit on error (stored but not enforced yet) +/// - `set -x` - trace mode (stored but not enforced yet) +/// - `set --` - set positional parameters +pub struct Set; + +#[async_trait] +impl Builtin for Set { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + // Display all variables + let mut output = String::new(); + for (name, value) in ctx.variables.iter() { + output.push_str(&format!("{}={}\n", name, value)); + } + return Ok(ExecResult::ok(output)); + } + + for arg in ctx.args.iter() { + if arg == "--" { + // Set positional parameters (would need call stack access) + // For now, just consume remaining args + break; + } else if arg.starts_with('-') || arg.starts_with('+') { + // Shell options - store in variables for now + let enable = arg.starts_with('-'); + for opt in arg.chars().skip(1) { + let opt_name = format!("SHOPT_{}", opt); + ctx.variables + .insert(opt_name, if enable { "1" } else { "0" }.to_string()); + } + } + } + + Ok(ExecResult::ok(String::new())) + } +} + +/// shift builtin - shift positional parameters +pub struct Shift; + +#[async_trait] +impl Builtin for Shift { + async fn execute(&self, ctx: Context<'_>) -> Result { + // Number of positions to shift (default 1) + let n: usize = ctx.args.first().and_then(|s| s.parse().ok()).unwrap_or(1); + + // In real bash, this shifts the positional parameters + // For now, we store the shift count for the interpreter to handle + ctx.variables + .insert("_SHIFT_COUNT".to_string(), n.to_string()); + + Ok(ExecResult::ok(String::new())) + } +} + +/// local builtin - declare local variables in functions +pub struct Local; + +#[async_trait] +impl Builtin for Local { + async fn execute(&self, ctx: Context<'_>) -> Result { + // Local sets variables in the current function scope + // The actual scoping is handled by the interpreter's call stack + for arg in ctx.args { + if let Some(eq_pos) = arg.find('=') { + let name = &arg[..eq_pos]; + let value = &arg[eq_pos + 1..]; + // Mark as local by setting it + ctx.variables.insert(name.to_string(), value.to_string()); + } else { + // Just declare without value + ctx.variables.insert(arg.to_string(), String::new()); + } + } + Ok(ExecResult::ok(String::new())) + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 287db9a..af06428 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -13,8 +13,8 @@ use crate::error::{Error, Result}; use crate::fs::FileSystem; use crate::parser::{ CaseCommand, Command, CommandList, CompoundCommand, ForCommand, FunctionDef, IfCommand, - ListOperator, Pipeline, Redirect, RedirectKind, Script, SimpleCommand, UntilCommand, - WhileCommand, Word, WordPart, + ListOperator, ParameterOp, Pipeline, Redirect, RedirectKind, Script, SimpleCommand, + UntilCommand, WhileCommand, Word, WordPart, }; /// A frame in the call stack for local variable scoping @@ -63,6 +63,12 @@ impl Interpreter { builtins.insert("printf", Box::new(builtins::Printf)); builtins.insert("export", Box::new(builtins::Export)); builtins.insert("read", Box::new(builtins::Read)); + builtins.insert("set", Box::new(builtins::Set)); + builtins.insert("unset", Box::new(builtins::Unset)); + builtins.insert("shift", Box::new(builtins::Shift)); + builtins.insert("local", Box::new(builtins::Local)); + builtins.insert("source", Box::new(builtins::Source::new(fs.clone()))); + builtins.insert(".", Box::new(builtins::Source::new(fs.clone()))); Self { fs, @@ -690,6 +696,11 @@ impl Interpreter { let content = self.expand_word(&redirect.target).await?; stdin = Some(format!("{}\n", content)); } + RedirectKind::HereDoc => { + // << EOF - use the heredoc content as stdin + let content = self.expand_word(&redirect.target).await?; + stdin = Some(content); + } _ => { // Output redirections handled separately } @@ -768,12 +779,210 @@ impl Interpreter { let value = self.evaluate_arithmetic(expr); result.push_str(&value.to_string()); } + WordPart::Length(name) => { + // ${#var} - length of variable value + let value = self.expand_variable(name); + result.push_str(&value.len().to_string()); + } + WordPart::ParameterExpansion { + name, + operator, + operand, + } => { + let value = self.expand_variable(name); + let expanded = self.apply_parameter_op(&value, name, operator, operand); + result.push_str(&expanded); + } } } Ok(result) } + /// Apply parameter expansion operator + fn apply_parameter_op( + &mut self, + value: &str, + name: &str, + operator: &ParameterOp, + operand: &str, + ) -> String { + match operator { + ParameterOp::UseDefault => { + // ${var:-default} - use default if unset/empty + if value.is_empty() { + operand.to_string() + } else { + value.to_string() + } + } + ParameterOp::AssignDefault => { + // ${var:=default} - assign default if unset/empty + if value.is_empty() { + self.variables.insert(name.to_string(), operand.to_string()); + operand.to_string() + } else { + value.to_string() + } + } + ParameterOp::UseReplacement => { + // ${var:+replacement} - use replacement if set + if !value.is_empty() { + operand.to_string() + } else { + String::new() + } + } + ParameterOp::Error => { + // ${var:?error} - error if unset/empty + if value.is_empty() { + // In real bash this would exit, we just return empty + String::new() + } else { + value.to_string() + } + } + ParameterOp::RemovePrefixShort => { + // ${var#pattern} - remove shortest prefix match + self.remove_pattern(value, operand, true, false) + } + ParameterOp::RemovePrefixLong => { + // ${var##pattern} - remove longest prefix match + self.remove_pattern(value, operand, true, true) + } + ParameterOp::RemoveSuffixShort => { + // ${var%pattern} - remove shortest suffix match + self.remove_pattern(value, operand, false, false) + } + ParameterOp::RemoveSuffixLong => { + // ${var%%pattern} - remove longest suffix match + self.remove_pattern(value, operand, false, true) + } + } + } + + /// Remove prefix/suffix pattern from value + fn remove_pattern(&self, value: &str, pattern: &str, prefix: bool, longest: bool) -> String { + // Simple pattern matching with * glob + if pattern.is_empty() { + return value.to_string(); + } + + if prefix { + // Remove from beginning + if pattern == "*" { + if longest { + return String::new(); + } else if !value.is_empty() { + return value.chars().skip(1).collect(); + } else { + return value.to_string(); + } + } + + // Check if pattern contains * + if let Some(star_pos) = pattern.find('*') { + let prefix_part = &pattern[..star_pos]; + let suffix_part = &pattern[star_pos + 1..]; + + if prefix_part.is_empty() { + // Pattern is "*suffix" - find suffix and remove everything before it + if longest { + // Find last occurrence of suffix + if let Some(pos) = value.rfind(suffix_part) { + return value[pos + suffix_part.len()..].to_string(); + } + } else { + // Find first occurrence of suffix + if let Some(pos) = value.find(suffix_part) { + return value[pos + suffix_part.len()..].to_string(); + } + } + } else if suffix_part.is_empty() { + // Pattern is "prefix*" - match prefix and any chars after + if let Some(rest) = value.strip_prefix(prefix_part) { + if longest { + return String::new(); + } else { + return rest.to_string(); + } + } + } else { + // Pattern is "prefix*suffix" - more complex matching + if let Some(rest) = value.strip_prefix(prefix_part) { + if longest { + if let Some(pos) = rest.rfind(suffix_part) { + return rest[pos + suffix_part.len()..].to_string(); + } + } else if let Some(pos) = rest.find(suffix_part) { + return rest[pos + suffix_part.len()..].to_string(); + } + } + } + } else if let Some(rest) = value.strip_prefix(pattern) { + return rest.to_string(); + } + } else { + // Remove from end (suffix) + if pattern == "*" { + if longest { + return String::new(); + } else if !value.is_empty() { + let mut s = value.to_string(); + s.pop(); + return s; + } else { + return value.to_string(); + } + } + + // Check if pattern contains * + if let Some(star_pos) = pattern.find('*') { + let prefix_part = &pattern[..star_pos]; + let suffix_part = &pattern[star_pos + 1..]; + + if suffix_part.is_empty() { + // Pattern is "prefix*" - find prefix and remove from there to end + if longest { + // Find first occurrence of prefix + if let Some(pos) = value.find(prefix_part) { + return value[..pos].to_string(); + } + } else { + // Find last occurrence of prefix + if let Some(pos) = value.rfind(prefix_part) { + return value[..pos].to_string(); + } + } + } else if prefix_part.is_empty() { + // Pattern is "*suffix" - match any chars before suffix + if let Some(before) = value.strip_suffix(suffix_part) { + if longest { + return String::new(); + } else { + return before.to_string(); + } + } + } else { + // Pattern is "prefix*suffix" - more complex matching + if let Some(before_suffix) = value.strip_suffix(suffix_part) { + if longest { + if let Some(pos) = before_suffix.find(prefix_part) { + return value[..pos].to_string(); + } + } else if let Some(pos) = before_suffix.rfind(prefix_part) { + return value[..pos].to_string(); + } + } + } + } else if let Some(before) = value.strip_suffix(pattern) { + return before.to_string(); + } + } + + value.to_string() + } + /// Evaluate a simple arithmetic expression fn evaluate_arithmetic(&self, expr: &str) -> i64 { // Simple arithmetic evaluation - handles basic operations diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 571dc09..a95c565 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -663,4 +663,86 @@ mod tests { let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap(); assert_eq!(result.stdout, "14\n"); } + + #[tokio::test] + async fn test_heredoc_simple() { + let mut bash = Bash::new(); + let result = bash.exec("cat < write!(f, "${}", name)?, WordPart::CommandSubstitution(cmd) => write!(f, "$({:?})", cmd)?, WordPart::ArithmeticExpansion(expr) => write!(f, "$(({}))", expr)?, + WordPart::ParameterExpansion { + name, + operator, + operand, + } => { + let op_str = match operator { + ParameterOp::UseDefault => ":-", + ParameterOp::AssignDefault => ":=", + ParameterOp::UseReplacement => ":+", + ParameterOp::Error => ":?", + ParameterOp::RemovePrefixShort => "#", + ParameterOp::RemovePrefixLong => "##", + ParameterOp::RemoveSuffixShort => "%", + ParameterOp::RemoveSuffixLong => "%%", + }; + write!(f, "${{{}{}{}}}", name, op_str, operand)? + } + WordPart::Length(name) => write!(f, "${{#{}}}", name)?, } } Ok(()) @@ -187,6 +205,35 @@ pub enum WordPart { CommandSubstitution(Vec), /// Arithmetic expansion ($((...))) ArithmeticExpansion(String), + /// Parameter expansion with operator ${var:-default}, ${var:=default}, etc. + ParameterExpansion { + name: String, + operator: ParameterOp, + operand: String, + }, + /// Length expansion ${#var} + Length(String), +} + +/// Parameter expansion operators +#[derive(Debug, Clone, PartialEq)] +pub enum ParameterOp { + /// :- use default if unset/empty + UseDefault, + /// := assign default if unset/empty + AssignDefault, + /// :+ use replacement if set + UseReplacement, + /// :? error if unset/empty + Error, + /// # remove prefix (shortest) + RemovePrefixShort, + /// ## remove prefix (longest) + RemovePrefixLong, + /// % remove suffix (shortest) + RemoveSuffixShort, + /// %% remove suffix (longest) + RemoveSuffixLong, } /// I/O redirection. diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index be715fb..84e1f4b 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -324,6 +324,52 @@ impl<'a> Lexer<'a> { | '#' ) } + + /// Read here document content until the delimiter line is found + pub fn read_heredoc(&mut self, delimiter: &str) -> String { + let mut content = String::new(); + let mut current_line = String::new(); + + // Skip to end of current line first (after the delimiter on command line) + while let Some(ch) = self.peek_char() { + self.advance(); + if ch == '\n' { + break; + } + } + + // Read lines until we find the delimiter + loop { + match self.peek_char() { + Some('\n') => { + self.advance(); + // Check if current line matches delimiter + if current_line.trim() == delimiter { + break; + } + content.push_str(¤t_line); + content.push('\n'); + current_line.clear(); + } + Some(ch) => { + current_line.push(ch); + self.advance(); + } + None => { + // End of input - check last line + if current_line.trim() == delimiter { + break; + } + if !current_line.is_empty() { + content.push_str(¤t_line); + } + break; + } + } + } + + content + } } #[cfg(test)] @@ -429,4 +475,34 @@ mod tests { assert_eq!(lexer.next_token(), Some(Token::Word("cat".to_string()))); assert_eq!(lexer.next_token(), None); } + + #[test] + fn test_read_heredoc() { + // Simulate state after reading "cat < Parser<'a> { target, }); } + Some(tokens::Token::HereDoc) => { + self.advance(); + // Get the delimiter word + let delimiter = match &self.current_token { + Some(tokens::Token::Word(w)) => w.clone(), + _ => return Err(Error::Parse("expected delimiter after <<".to_string())), + }; + // Don't advance - let read_heredoc consume directly from lexer position + + // Read the here document content (reads until delimiter line) + let content = self.lexer.read_heredoc(&delimiter); + + // Now advance to get the next token after the heredoc + self.advance(); + + redirects.push(Redirect { + fd: None, + kind: RedirectKind::HereDoc, + target: Word::literal(content), + }); + } Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) | Some(tokens::Token::Pipe) @@ -840,18 +861,112 @@ impl<'a> Parser<'a> { } } } else if chars.peek() == Some(&'{') { - // ${VAR} format + // ${VAR} format with possible parameter expansion chars.next(); // consume '{' - let mut var_name = String::new(); - while let Some(&c) = chars.peek() { - if c == '}' { - chars.next(); // consume '}' - break; + + // Check for ${#var} - 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(); + break; + } + var_name.push(chars.next().unwrap()); + } + parts.push(WordPart::Length(var_name)); + } else { + // Read variable name + let mut var_name = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_alphanumeric() || c == '_' { + var_name.push(chars.next().unwrap()); + } else { + break; + } + } + + // Check for operator + if let Some(&c) = chars.peek() { + let (operator, operand) = match c { + ':' => { + chars.next(); // consume ':' + match chars.peek() { + Some(&'-') => { + chars.next(); + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::UseDefault), op) + } + Some(&'=') => { + chars.next(); + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::AssignDefault), op) + } + Some(&'+') => { + chars.next(); + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::UseReplacement), op) + } + Some(&'?') => { + chars.next(); + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::Error), op) + } + _ => (None, String::new()), + } + } + '#' => { + chars.next(); + if chars.peek() == Some(&'#') { + chars.next(); + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::RemovePrefixLong), op) + } else { + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::RemovePrefixShort), op) + } + } + '%' => { + chars.next(); + if chars.peek() == Some(&'%') { + chars.next(); + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::RemoveSuffixLong), op) + } else { + let op = self.read_brace_operand(&mut chars); + (Some(ParameterOp::RemoveSuffixShort), op) + } + } + '}' => { + chars.next(); + (None, String::new()) + } + _ => { + // Unknown, consume until } + while let Some(&ch) = chars.peek() { + if ch == '}' { + chars.next(); + break; + } + chars.next(); + } + (None, String::new()) + } + }; + + if let Some(op) = operator { + parts.push(WordPart::ParameterExpansion { + name: var_name, + operator: op, + operand, + }); + } else if !var_name.is_empty() { + parts.push(WordPart::Variable(var_name)); + } + } else if !var_name.is_empty() { + parts.push(WordPart::Variable(var_name)); } - var_name.push(chars.next().unwrap()); - } - if !var_name.is_empty() { - parts.push(WordPart::Variable(var_name)); } } else if let Some(&c) = chars.peek() { // Check for special single-character variables ($?, $#, $@, $*, $!, $$, $-, $0-$9) @@ -895,6 +1010,28 @@ impl<'a> Parser<'a> { Word { parts } } + + /// Read operand for brace expansion (everything until closing brace) + fn read_brace_operand(&self, chars: &mut std::iter::Peekable>) -> String { + let mut operand = String::new(); + let mut depth = 1; // Track nested braces + while let Some(&c) = chars.peek() { + if c == '{' { + depth += 1; + operand.push(chars.next().unwrap()); + } else if c == '}' { + depth -= 1; + if depth == 0 { + chars.next(); // consume closing } + break; + } + operand.push(chars.next().unwrap()); + } else { + operand.push(chars.next().unwrap()); + } + } + operand + } } #[cfg(test)]