diff --git a/crates/bashkit/src/builtins/introspect.rs b/crates/bashkit/src/builtins/introspect.rs index 3b506480b..541cda26a 100644 --- a/crates/bashkit/src/builtins/introspect.rs +++ b/crates/bashkit/src/builtins/introspect.rs @@ -66,6 +66,7 @@ impl Builtin for Type { } let mut output = String::new(); + let mut errors = String::new(); let mut all_found = true; for name in &names { @@ -105,6 +106,10 @@ impl Builtin for Type { let mut found_any = false; if is_func { output.push_str(&format!("{} is a function\n", name)); + if let Some(text) = shell.function_text(name) { + output.push_str(&text); + output.push('\n'); + } found_any = true; } if is_kw && (show_all || !found_any) { @@ -127,7 +132,7 @@ impl Builtin for Type { found_any = true; } if !found_any { - output.push_str(&format!("bash: type: {}: not found\n", name)); + errors.push_str(&format!("bash: type: {}: not found\n", name)); all_found = false; } } @@ -136,6 +141,7 @@ impl Builtin for Type { let exit_code = if all_found { 0 } else { 1 }; Ok(ExecResult { stdout: output.into(), + stderr: errors.into(), exit_code, ..Default::default() }) diff --git a/crates/bashkit/src/interpreter/arithmetic.rs b/crates/bashkit/src/interpreter/arithmetic.rs index 90f500300..1be9433c2 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -681,6 +681,34 @@ impl Interpreter { /// Try to parse a binary operator at the current precedence level. /// Scans `chars`/`bo` for operators, splitting and recursing. /// Returns `Some(value)` if an operator was found, `None` to try next level. + /// Whether the `+`/`-` at `i` is a sign rather than a binary operator: + /// nothing but another operator precedes it (`3 - -1`, `2 * -x`). A + /// postfix `x++ - 1` still leaves the `-` binary. + fn arith_sign_is_unary(chars: &[char], i: usize) -> bool { + let operand_end = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | ')' | ']'); + let mut j = i; + while j > 0 && chars[j - 1].is_ascii_whitespace() { + j -= 1; + } + if j == 0 { + return true; + } + let p = chars[j - 1]; + if operand_end(p) { + return false; + } + if matches!(p, '+' | '-') && j >= 2 && chars[j - 2] == p { + let mut k = j - 2; + while k > 0 && chars[k - 1].is_ascii_whitespace() { + k -= 1; + } + if k > 0 && operand_end(chars[k - 1]) { + return false; + } + } + true + } + pub(super) fn try_parse_arith_addmul( &self, expr: &str, @@ -708,6 +736,10 @@ impl Interpreter { if chars[i] == '-' && i > 0 && chars[i - 1] == '-' { continue; } + // `3 - -1`: a sign after another operator is unary. + if Self::arith_sign_is_unary(chars, i) { + continue; + } let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); return Some(if chars[i] == '+' { @@ -759,6 +791,11 @@ impl Interpreter { '*' if depth == 0 && i + 1 < chars.len() && chars[i + 1] == '*' => { let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); let right = self.parse_arithmetic_impl(&expr[bo[i] + 2..], arith_depth + 1); + // Negative exponent: bash errors; this evaluator's + // error value is 0 (TM-DOS-029), not `x ** 0`. + if right < 0 { + return Some(0); + } let exp = right.clamp(0, 63) as u32; return Some(left.wrapping_pow(exp)); } diff --git a/crates/bashkit/src/interpreter/brace_expansion.rs b/crates/bashkit/src/interpreter/brace_expansion.rs index 70a0e75dc..3b59829be 100644 --- a/crates/bashkit/src/interpreter/brace_expansion.rs +++ b/crates/bashkit/src/interpreter/brace_expansion.rs @@ -241,6 +241,7 @@ impl Interpreter { quoted: word.quoted, has_unquoted_glob: word.has_unquoted_glob, part_quoted, + raw: None, }); } Some(words) diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index e7a6b6068..c27500e3f 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -378,6 +378,20 @@ impl Interpreter { self.nounset_error = Some(format!("bash: {}: unbound variable\n", name)); } + if operand.contains("$(") { + // Boxed: keeps this frame small for deep `$(...)` nesting + // (TM-DOS-089). + Box::pin(self.prefetch_operand_substs( + operator, + operand, + *colon_variant, + is_set, + &value, + )) + .await?; + } else { + self.operand_substs.clear(); + } // Delegate to sync helper to avoid bloating the async state // machine with Vec locals (causes stack overflow at // depth 32 in debug builds — see stack_overflow_regression_tests). @@ -717,6 +731,7 @@ impl Interpreter { quoted: part_is_quoted, has_unquoted_glob: false, part_quoted: vec![part_is_quoted], + raw: None, }; self.expand_word(&single).await? }; @@ -1108,6 +1123,52 @@ impl Interpreter { /// Expand an operand string from a parameter expansion (sync, lazy). /// Only called when the operand is actually needed, providing lazy evaluation. + /// `${x:-$(cmd)}`: run the operand's command substitutions before the + /// sync operand expansion, only when the operator uses the operand (so + /// an unused default never runs). Outputs queue in `operand_substs`. + async fn prefetch_operand_substs( + &mut self, + operator: &ParameterOp, + operand: &str, + colon_variant: bool, + is_set: bool, + value: &str, + ) -> Result<()> { + self.operand_substs.clear(); + if !operand.contains("$(") { + return Ok(()); + } + let unset_or_null = !is_set || (colon_variant && value.is_empty()); + let uses_operand = match operator { + ParameterOp::UseDefault | ParameterOp::AssignDefault | ParameterOp::Error => { + unset_or_null + } + ParameterOp::UseReplacement => !unset_or_null, + _ => false, + }; + if !uses_operand { + return Ok(()); + } + let (word, _, _) = Self::parse_marked_operand( + operand, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ); + for part in &word.parts { + if let WordPart::CommandSubstitution(commands) = part { + // THREAT[TM-DOS-088]: same depth accounting as word `$(...)`. + if self.counters.push_subst(&self.limits).is_err() { + return Err(crate::error::Error::Execution( + "maximum command substitution depth exceeded".to_string(), + )); + } + let out = self.execute_cmd_subst(commands).await?; + self.operand_substs.push_back(out); + } + } + Ok(()) + } + pub(super) fn expand_operand(&mut self, operand: &str) -> String { if operand.is_empty() { return String::new(); @@ -1165,7 +1226,14 @@ impl Interpreter { let value = self.expand_variable(name).len().to_string(); Self::push_operand_expansion(&mut result, &value, in_marked || force_quoted); } - // TODO: handle CommandSubstitution etc. in sync operand expansion + // Run ahead by `prefetch_operand_substs` (default-family + // operators only); other operators leave the queue empty. + WordPart::CommandSubstitution(_) => { + if let Some(out) = self.operand_substs.pop_front() { + Self::push_operand_expansion(&mut result, &out, in_marked || force_quoted); + } + } + // TODO: process substitution in sync operand expansion _ => {} } } @@ -1594,7 +1662,7 @@ impl Interpreter { replacement, } => { // ${var/pattern/replacement} - replace first occurrence - let expanded_rep = self.expand_operand(replacement); + let expanded_rep = self.expand_replacement_operand(replacement); let expanded_pat = self.expand_replace_pattern(pattern); self.replace_pattern(value, &expanded_pat, &expanded_rep, false) } @@ -1603,7 +1671,7 @@ impl Interpreter { replacement, } => { // ${var//pattern/replacement} - replace all occurrences - let expanded_rep = self.expand_operand(replacement); + let expanded_rep = self.expand_replacement_operand(replacement); let expanded_pat = self.expand_replace_pattern(pattern); self.replace_pattern(value, &expanded_pat, &expanded_rep, true) } @@ -1659,6 +1727,13 @@ impl Interpreter { self.expand_operand(&Self::single_quotes_as_quoted(operand)) } + /// Expand the replacement of `${x/pattern/rep}`. Like the pattern, + /// bash quote-removes `'...'` there even inside double quotes + /// (`"${y/b/'}'}"` is `a}`). + pub(super) fn expand_replacement_operand(&mut self, operand: &str) -> String { + self.expand_operand(&Self::single_quotes_as_quoted(operand)) + } + /// Expand the pattern of `${x/pattern/rep}`. A leading `#`/`%` in the /// source is the anchor; a `#`/`%` that comes from expansion is literal. pub(super) fn expand_replace_pattern(&mut self, pattern: &str) -> String { @@ -2216,6 +2291,7 @@ mod expansion_charge_tests { quoted, has_unquoted_glob, part_quoted: Vec::new(), + raw: None, } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 80b22d9e2..d36c9b3a6 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -483,6 +483,13 @@ impl ShellRef<'_> { self.functions.contains_key(name) } + /// A function's definition as bash prints it (`type`, `declare -f`). + pub(crate) fn function_text(&self, name: &str) -> Option { + self.functions + .get(name) + .map(|f| crate::parser::function_string(name, &f.body)) + } + /// Check if a name is a shell keyword. pub(crate) fn is_keyword(&self, name: &str) -> bool { is_keyword(name) @@ -1403,6 +1410,9 @@ pub struct Interpreter { jobs: SharedJobTable, /// Current line number for $LINENO current_line: usize, + /// `unset LINENO` makes it an ordinary variable for the rest of the + /// shell's life, like bash. + lineno_unset: bool, /// HTTP client for network builtins (curl, wget) #[cfg(feature = "http_client")] http_client: Option>, @@ -1457,6 +1467,9 @@ pub struct Interpreter { output_stream_stderr_bytes: usize, /// Pending nounset (set -u) error message, consumed by execute_command. nounset_error: Option, + /// Outputs of the `$(...)` parts of a `${x:-...}`-family operand, run + /// ahead (async) and consumed in order by the sync `expand_operand`. + operand_substs: std::collections::VecDeque, /// PIPESTATUS: exit codes of the last pipeline's commands pipestatus: Vec, /// Aliases currently being expanded (prevents infinite recursion). @@ -2001,6 +2014,7 @@ impl Interpreter { ), jobs: jobs::new_shared_job_table(), current_line: 1, + lineno_unset: false, #[cfg(feature = "http_client")] http_client: None, #[cfg(feature = "git")] @@ -2024,6 +2038,7 @@ impl Interpreter { output_stream_stdout_bytes: 0, output_stream_stderr_bytes: 0, nounset_error: None, + operand_substs: std::collections::VecDeque::new(), pipestatus: Vec::new(), expanding_aliases: HashSet::new(), regex_cache: RuntimeRegexCache::default(), @@ -2161,6 +2176,7 @@ impl Interpreter { execution_budget: self.execution_budget.clone(), jobs: self.jobs.fork(), current_line: self.current_line, + lineno_unset: self.lineno_unset, #[cfg(feature = "http_client")] http_client: self.http_client.clone(), #[cfg(feature = "git")] @@ -2182,6 +2198,7 @@ impl Interpreter { output_stream_stdout_bytes: 0, output_stream_stderr_bytes: 0, nounset_error: None, + operand_substs: std::collections::VecDeque::new(), pipestatus: Vec::new(), expanding_aliases: HashSet::new(), regex_cache: RuntimeRegexCache::default(), @@ -3655,7 +3672,10 @@ impl Interpreter { CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await, CompoundCommand::Case(case_cmd) => self.execute_case(case_cmd).await, CompoundCommand::Select(select_cmd) => self.execute_select(select_cmd).await, - CompoundCommand::Arithmetic(expr) => self.execute_arithmetic_command(expr).await, + CompoundCommand::Arithmetic(expr) => { + self.execute_arithmetic_command(&crate::parser::arith_exec_text(expr)) + .await + } CompoundCommand::Time(time_cmd) => self.execute_time(time_cmd).await, CompoundCommand::Conditional(words) => self.execute_conditional(words).await, CompoundCommand::Coproc(coproc_cmd) => self.execute_coproc(coproc_cmd).await, @@ -7993,14 +8013,27 @@ impl Interpreter { args: Vec, stdin: Option, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - // Check for functions first - if let Some(func_def) = self.scoped.functions.get(name).cloned() { - return self - .execute_function_call(name, &func_def, args, stdin, &command.redirects) - .await; - } + // Functions first, in their own small future: a function call is the + // recursion path, and the builtin/path-search arms below would + // otherwise add their large frame to every call level + // (THREAT[TM-DOS-020]: bounded recursion on a 2 MiB stack). + if let Some(func_def) = self.scoped.functions.get(name).cloned() { + return Box::pin(async move { + self.execute_function_call(name, &func_def, args, stdin, &command.redirects) + .await + }); + } + self.dispatch_non_function(name, command, args, stdin) + } + fn dispatch_non_function<'a>( + &'a mut self, + name: &'a str, + command: &'a SimpleCommand, + args: Vec, + stdin: Option, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { // Interpreter-level special builtins if Self::is_special_builtin_name(name) { return self @@ -8335,6 +8368,8 @@ impl Interpreter { let saved_env = self.env.clone(); let saved_memory_budget = self.memory_budget.clone(); let saved_exec_fd_table = self.exec_fd_table.clone(); + // The child process has its own working directory (`cd` stays there). + let saved_cwd = self.cwd.clone(); // Child only sees exported variables (env), not all shell variables. // Reset last_exit_code so $? starts at 0 (matches real bash subprocess). @@ -8373,9 +8408,13 @@ impl Interpreter { // Forward pipeline stdin so commands inside the script (cat, read, etc.) can consume it let prev_pipeline_stdin = self.pipeline_stdin.take(); + // Stdin shared with the caller (no pipe or redirect of its own): what + // the child's `read` consumes is gone for the caller too. + let shares_caller_stdin = stdin.is_some() && stdin == prev_pipeline_stdin; self.pipeline_stdin = stdin; let result = self.execute_script_body(&script, true, false).await; + let child_stdin_left = self.pipeline_stdin.take(); // Restore full parent state — child mutations don't propagate self.scoped.variables = saved_vars; @@ -8394,7 +8433,12 @@ impl Interpreter { self.memory_budget = saved_memory_budget; self.exec_fd_table = saved_exec_fd_table; self.bash_source_stack = saved_source_stack; - self.pipeline_stdin = prev_pipeline_stdin; + self.cwd = saved_cwd; + self.pipeline_stdin = if shares_caller_stdin { + child_stdin_left + } else { + prev_pipeline_stdin + }; match result { Ok(mut exec_result) => { @@ -8485,7 +8529,7 @@ impl Interpreter { Err(crate::error::Error::Parse { message, .. }) => { return Ok(ExecResult::err( format!("source: {}: parse error: {}", filename, message), - 1, + 2, )); } Err(e) => return Err(e), @@ -9339,6 +9383,9 @@ impl Interpreter { exit_code = 1; continue; } + if resolved == "LINENO" { + self.lineno_unset = true; + } self.vars_mut().remove(&resolved); self.env.remove(&resolved); // THREAT[TM-DOS-114]: `unset arr` must return the array's entry @@ -9741,6 +9788,27 @@ impl Interpreter { ) .await; } + // `command ./script` / `command name-on-PATH`: run the file, + // like a plain command would. + if self.shell_features.has_script_execution() { + let builtin_args = builtin_args.to_vec(); + if target.contains('/') { + return self + .try_execute_script_by_path(target, &builtin_args, _stdin, redirects) + .await; + } + if let Some(result) = self + .try_execute_script_via_path_search( + target, + &builtin_args, + _stdin, + redirects, + ) + .await? + { + return Ok(result); + } + } Ok(ExecResult::err( format!("bash: {}: command not found\n", remaining[0]), 127, @@ -9782,6 +9850,7 @@ impl Interpreter { let mut is_readonly = false; let mut is_export = false; let mut is_function = false; + let mut function_names_only = false; let mut flags = DeclareFlags::default(); let mut remove_nameref = false; let mut is_lowercase = false; @@ -9797,6 +9866,10 @@ impl Interpreter { 'r' => is_readonly = true, 'x' => is_export = true, 'f' => is_function = true, + 'F' => { + is_function = true; + function_names_only = true; + } 'l' => is_lowercase = true, 'u' => is_uppercase = true, _ => {} // n, a, A, i handled by flags @@ -9815,29 +9888,40 @@ impl Interpreter { } // declare -f: function display mode + // `-F` lists names (`declare -f NAME` for all), `-f` prints bodies + // the way bash's print_cmd lays them out; a missing name prints + // nothing and makes the status 1. if is_function { let mut output = String::new(); + let mut exit_code = 0; if names.is_empty() { - // List all functions let mut func_names: Vec<_> = self.scoped.functions.keys().cloned().collect::>(); func_names.sort(); for fname in &func_names { - output.push_str(&format!("{} ()\n{{\n ...\n}}\n", fname)); + if function_names_only { + output.push_str(&format!("declare -f {fname}\n")); + } else if let Some(f) = self.scoped.functions.get(fname) { + output.push_str(&crate::parser::function_string(fname, &f.body)); + output.push('\n'); + } } } else { - // Print specific functions — return 1 if any not found for name in &names { - if self.scoped.functions.contains_key(*name) { - output.push_str(&format!("{} ()\n{{\n ...\n}}\n", name)); - } else { - let mut result = ExecResult::with_code(String::new(), 1); - result = self.apply_redirections(result, redirects).await?; - return Ok(result); + match self.scoped.functions.get(*name) { + Some(_) if function_names_only => { + output.push_str(name); + output.push('\n'); + } + Some(f) => { + output.push_str(&crate::parser::function_string(name, &f.body)); + output.push('\n'); + } + None => exit_code = 1, } } } - let mut result = ExecResult::ok(output); + let mut result = ExecResult::with_code(output, exit_code); result = self.apply_redirections(result, redirects).await?; return Ok(result); } @@ -11590,8 +11674,18 @@ impl Interpreter { } return u32::from_le_bytes(b).to_string(); } - "LINENO" => { - // $LINENO - current line number from command span + // $LINENO - current line number from command span. Assignments + // do not stick, but a function's `local LINENO` holds its value + // and `unset LINENO` makes it ordinary. + "LINENO" if !self.lineno_unset => { + if let Some(v) = self + .call_stack + .iter() + .rev() + .find_map(|frame| frame.locals.get("LINENO")) + { + return v.clone(); + } return self.current_line.to_string(); } "PWD" => { @@ -11653,6 +11747,9 @@ impl Interpreter { // Resolve nameref before checking — a nameref whose target exists is "set". let name = self.resolve_nameref(name); + if name == "LINENO" && !self.lineno_unset { + return true; + } // Special variables are always "set" if matches!( name, @@ -11664,7 +11761,6 @@ impl Interpreter { | "-" | "RANDOM" | "SRANDOM" - | "LINENO" | "PWD" | "OLDPWD" | "HOSTNAME" @@ -11827,6 +11923,7 @@ fn split_indexed_element(word: &Word) -> Option<(String, Word)> { quoted: true, has_unquoted_glob: false, part_quoted, + raw: None, }, )) } diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 406ca4ce8..fcf7a2d7d 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -112,7 +112,8 @@ pub enum CompoundCommand { Subshell(Vec), /// Brace group BraceGroup(Vec), - /// Arithmetic command ((expression)) + /// Arithmetic command ((expression)), text as written between the + /// parentheses; evaluate `parser::arith_exec_text` of it. Arithmetic(String), /// Time command - measure execution time // Keep the recursive command enum small: time's GNU option payload is @@ -200,6 +201,9 @@ pub struct ArithmeticForCommand { pub condition: String, /// Step/update expression pub step: String, + /// The three clauses as written (for `type`), when read from source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option>, /// Loop body pub body: Vec, /// Source span of this command @@ -283,6 +287,10 @@ pub struct Word { /// fall back to `quoted` for every part. #[serde(default)] pub part_quoted: Vec, + /// Source text as written. Only recorded inside function definitions, + /// where `type`/`declare -f` print words the way bash does (as written). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, } impl Word { @@ -293,6 +301,7 @@ impl Word { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, } } @@ -303,6 +312,7 @@ impl Word { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, } } } @@ -530,6 +540,10 @@ pub struct Redirect { pub kind: RedirectKind, /// Target (file, fd, or heredoc content) pub target: Word, + /// Here-document delimiter as bash prints it (`EOF`, or `'EOF'` when the + /// delimiter was quoted). Only set for `<<`/`<<-`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub heredoc_delim: Option, } /// Types of redirections. @@ -619,6 +633,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "$HOME"); } @@ -630,6 +645,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "$((1+2))"); } @@ -641,6 +657,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${#var}"); } @@ -655,6 +672,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${arr[0]}"); } @@ -666,6 +684,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${#arr[@]}"); } @@ -677,6 +696,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${!arr[@]}"); } @@ -692,6 +712,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:2:3}"); } @@ -707,6 +728,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:2}"); } @@ -722,6 +744,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${arr[@]:1:2}"); } @@ -737,6 +760,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${arr[@]:1}"); } @@ -753,6 +777,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${!ref}"); } @@ -764,6 +789,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${!MY_*}"); } @@ -778,6 +804,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var@Q}"); } @@ -792,6 +819,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "hello $USER"); } @@ -808,6 +836,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:-fallback}"); } @@ -824,6 +853,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var-fallback}"); } @@ -840,6 +870,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:=val}"); } @@ -856,6 +887,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:+alt}"); } @@ -872,6 +904,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:?msg}"); } @@ -889,6 +922,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var#pat}"); @@ -903,6 +937,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var##pat}"); @@ -917,6 +952,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var%pat}"); @@ -931,6 +967,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var%%pat}"); } @@ -950,6 +987,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var/old/new}"); @@ -966,6 +1004,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var///old/new}"); } @@ -983,6 +1022,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), expected); }; @@ -1018,6 +1058,7 @@ mod tests { fd_var: None, kind: RedirectKind::Output, target: Word::literal("out.txt"), + heredoc_delim: None, }], assignments: vec![], span: Span::new(), @@ -1147,6 +1188,7 @@ mod tests { fd_var: None, kind: RedirectKind::Input, target: Word::literal("input.txt"), + heredoc_delim: None, }; assert!(r.fd.is_none()); assert_eq!(r.kind, RedirectKind::Input); @@ -1262,6 +1304,7 @@ mod tests { init: "i=0".into(), condition: "i<10".into(), step: "i++".into(), + raw: None, body: vec![], span: Span::new(), }; diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index c3e4a50b2..b748b5892 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -12,6 +12,9 @@ use super::tokens::Token; pub struct SpannedToken { pub token: Token, pub span: Span, + /// Source text of a word token, only while raw capture is on (function + /// bodies, for `type`/`declare -f` printing). + pub raw: Option, } /// Maximum nesting depth for command substitution in the lexer. @@ -32,6 +35,30 @@ struct ContinuationFlags { error: Option, } +/// Bash parses a backquoted command when it runs, so a syntax error there +/// fails only that substitution (status 2), unlike `$(...)` which fails the +/// whole command at parse time. A body that does not parse becomes +/// `eval 'body'`, which reports the same error when (and only if) it runs. +fn defer_backtick_syntax_error(buf: &mut String, body_start: usize) { + let Some(body) = buf.get(body_start..) else { + return; + }; + if body.trim().is_empty() || super::Parser::new(body).parse().is_ok() { + return; + } + let deferred = format!("eval {}", super::raw::single_quote(body)); + buf.truncate(body_start); + buf.push_str(&deferred); +} + +/// Concrete (drop-free) type of [`Lexer::lookahead`], so borrows end early. +type Lookahead<'s, 'a> = std::iter::Peekable< + std::iter::Chain< + std::iter::Copied>, + std::iter::Peekable>, + >, +>; + /// Lexer for bash scripts. pub struct Lexer<'a> { #[allow(dead_code)] // Stored for error reporting in future @@ -44,6 +71,11 @@ pub struct Lexer<'a> { reinject_buf: VecDeque, /// Maximum allowed nesting depth for command substitution max_subst_depth: usize, + /// Record each word token's source text (see [`SpannedToken::raw`]). + /// Important decision: captured from consumed chars, not span offsets, + /// so text re-injected after a heredoc body still maps to its own token. + capture_raw: bool, + raw_buf: String, } impl<'a> Lexer<'a> { @@ -61,6 +93,8 @@ impl<'a> Lexer<'a> { chars: input.chars().peekable(), reinject_buf: VecDeque::new(), max_subst_depth: max_depth, + capture_raw: false, + raw_buf: String::new(), } } @@ -96,19 +130,53 @@ impl<'a> Lexer<'a> { }; if let Some(c) = ch { self.position.advance(c); + if self.capture_raw { + self.raw_buf.push(c); + } } ch } + /// Upcoming characters without consuming them: re-injected text first + /// (the rest of a heredoc line), then the input. + fn lookahead(&self) -> Lookahead<'_, 'a> { + self.reinject_buf + .iter() + .copied() + .chain(self.chars.clone()) + .peekable() + } + + /// Whether word-token source capture is on. + pub fn capture_raw(&self) -> bool { + self.capture_raw + } + + /// Turn word-token source capture on or off. + pub fn set_capture_raw(&mut self, on: bool) { + self.capture_raw = on; + } + /// Get the next token with its source span. pub fn next_spanned_token(&mut self) -> Option { self.skip_whitespace(); let start = self.position; + self.raw_buf.clear(); let token = self.next_token_inner()?; let end = self.position; + let raw = (self.capture_raw + && matches!( + token, + Token::Word(_) + | Token::LiteralWord(_) + | Token::QuotedWord(_) + | Token::QuotedGlobWord(_) + )) + .then(|| std::mem::take(&mut self.raw_buf)); Some(SpannedToken { token, span: Span::from_positions(start, end), + raw, }) } @@ -143,6 +211,9 @@ impl<'a> Lexer<'a> { if self.peek_char() == Some('|') { self.advance(); Some(Token::Or) + } else if self.peek_char() == Some('&') { + self.advance(); + Some(Token::PipeBoth) } else { Some(Token::Pipe) } @@ -256,7 +327,7 @@ impl<'a> Lexer<'a> { self.advance(); // `[[` is the keyword only as a whole word; `[[:digit:]]*` is // a glob bracket expression. - let mut lookahead = self.chars.clone(); + let mut lookahead = self.lookahead(); let keyword = lookahead.next() == Some('[') && matches!( lookahead.next(), @@ -288,12 +359,24 @@ impl<'a> Lexer<'a> { } } ']' => { + // `]]` closes `[[` only as a whole word; `]]x` and `]x` are + // ordinary words, like bash. + let ends_word = |c: Option| { + matches!( + c, + None | Some(' ' | '\t' | '\n' | ';' | '|' | '&' | '(' | ')' | '<' | '>') + ) + }; self.advance(); - if self.peek_char() == Some(']') { + let mut ahead = self.lookahead(); + let double = ahead.next() == Some(']') && ends_word(ahead.next()); + if double { self.advance(); Some(Token::DoubleRightBracket) - } else { + } else if ends_word(self.peek_char()) { Some(Token::Word("]".to_string())) + } else { + self.read_word_starting_with("]") } } '\'' => self.read_single_quoted_string(), @@ -315,7 +398,7 @@ impl<'a> Lexer<'a> { self.advance(); } else if ch == '\\' { // Check for backslash-newline (line continuation) between tokens - let mut lookahead = self.chars.clone(); + let mut lookahead = self.lookahead(); lookahead.next(); // skip backslash if lookahead.next() == Some('\n') { self.advance(); // consume backslash @@ -357,14 +440,7 @@ impl<'a> Lexer<'a> { // 2-char redirect operator (e.g. ">>", "<&", "<<") matter, so bound the // lookahead — collecting all remaining input here made every // digit-initial word O(n) and the whole lex O(n^2) (TM-DOS-024). - // Re-injected text (the rest of a heredoc line) comes first. - let input_remaining: String = self - .reinject_buf - .iter() - .copied() - .chain(self.chars.clone()) - .take(4) - .collect(); + let input_remaining: String = self.lookahead().take(4).collect(); // Check patterns: "N>" "N>>" "N>&" "N<" "N<&" if fd_str.len() == 1 @@ -925,6 +1001,7 @@ impl<'a> Lexer<'a> { has_unquoted_expansion = true; self.advance(); // consume opening ` word.push_str("$("); + let body_start = word.len(); let mut closed = false; while let Some(c) = self.peek_char() { if c == '`' { @@ -955,6 +1032,7 @@ impl<'a> Lexer<'a> { "unterminated backtick substitution".to_string(), )); } + defer_backtick_syntax_error(&mut word, body_start); word.push(')'); } else if ch == '\\' { self.advance(); @@ -1236,7 +1314,7 @@ impl<'a> Lexer<'a> { } Some('$') => { // Check for $'...' ANSI-C quoting in continuation - let mut lookahead = self.chars.clone(); + let mut lookahead = self.lookahead(); lookahead.next(); // skip $ if lookahead.next() == Some('\'') { self.advance(); // consume $ @@ -1456,6 +1534,15 @@ impl<'a> Lexer<'a> { } depth += 1; } + // `${x:-$(echo })}`: braces inside a command substitution + // belong to it. + '$' if self.peek_char() == Some('(') => { + self.advance(); + word.push_str("$("); + if !self.read_command_subst_body(word) { + return Err("unterminated command substitution".to_string()); + } + } '}' => { word.push('}'); depth -= 1; @@ -1681,6 +1768,7 @@ impl<'a> Lexer<'a> { // Backtick command substitution inside double quotes self.advance(); // consume opening ` content.push_str("$("); + let body_start = content.len(); while let Some(c) = self.peek_char() { if c == '`' { self.advance(); @@ -1703,6 +1791,7 @@ impl<'a> Lexer<'a> { self.advance(); } } + defer_backtick_syntax_error(&mut content, body_start); content.push(')'); } _ => { @@ -1888,6 +1977,19 @@ impl<'a> Lexer<'a> { false } + /// Consume a `<(...)`/`>(...)` body after its opening token, through the + /// closing `)`, with the same scanner as `$(...)`. Nothing is copied; the + /// caller slices the source. Returns whether the `)` was found. + pub fn skip_subst_body(&mut self) -> bool { + let mut scanner = super::subst_scan::SubstScanner::new(); + while let Some(c) = self.advance() { + if scanner.feed(c) == super::subst_scan::Step::Close { + return true; + } + } + false + } + /// Read parameter expansion content after `${`, handling nested braces and quotes. /// In bash, quotes inside `${...}` (e.g. `${arr["key"]}`) don't terminate the /// outer double-quoted string. Appends chars including closing `}` to `content`. @@ -2006,7 +2108,7 @@ impl<'a> Lexer<'a> { /// Check if { is followed by whitespace (brace group start) fn is_brace_group_start(&self) -> bool { - let mut chars = self.chars.clone(); + let mut chars = self.lookahead(); // Skip the opening { if chars.next() != Some('{') { return false; @@ -2023,7 +2125,7 @@ impl<'a> Lexer<'a> { /// and `>`; `}` is not among them, so it delimits a word only when a /// metacharacter or EOF already follows it. fn right_brace_stands_alone(&self) -> bool { - let mut chars = self.chars.clone(); + let mut chars = self.lookahead(); if chars.next() != Some('}') { return false; } @@ -2071,7 +2173,7 @@ impl<'a> Lexer<'a> { // over leading whitespace made `x=(` followed by megabytes of spaces // O(n) per call (TM-DOS-024). const MAX_LOOKAHEAD: usize = 10_000; - let mut chars = self.chars.clone(); + let mut chars = self.lookahead(); // Skip the `(` we haven't consumed yet if chars.next() != Some('(') { return false; @@ -2087,6 +2189,54 @@ impl<'a> Lexer<'a> { false } + /// Read the body of `(( ... ))` as written, after the opening `((`, + /// through the matching `))` (consumed, not returned). Parentheses nest; + /// quoted text is kept verbatim. Bash reads the arithmetic command this + /// way, so `<<` is a shift and spacing survives for `type`. Returns + /// `None` when input ends first. + pub fn read_dparen_body(&mut self) -> Option { + let mut body = String::new(); + let mut depth = 0usize; + while let Some(c) = self.advance() { + match c { + '(' => depth += 1, + ')' if depth == 0 && self.peek_char() == Some(')') => { + self.advance(); + return Some(body); + } + // An unbalanced `)` stays text. + ')' if depth == 0 => {} + ')' => depth -= 1, + '\\' => { + body.push(c); + if let Some(n) = self.advance() { + body.push(n); + } + continue; + } + '\'' | '"' => { + body.push(c); + while let Some(n) = self.advance() { + body.push(n); + if n == c { + break; + } + if n == '\\' + && c == '"' + && let Some(e) = self.advance() + { + body.push(e); + } + } + continue; + } + _ => {} + } + body.push(c); + } + None + } + /// Read the raw source of a `[[ ... =~ REGEX ]]` operand, as bash does: /// one word that ends at unquoted whitespace or `;&<>` outside /// parentheses. Inside parentheses spaces belong to the regex; `|` and @@ -2109,7 +2259,7 @@ impl<'a> Lexer<'a> { } if ch == '|' { // `||` ends the operand; a lone `|` is alternation. - let mut ahead = self.chars.clone(); + let mut ahead = self.lookahead(); ahead.next(); if self.reinject_buf.is_empty() && ahead.peek() == Some(&'|') { break; diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index e16ea662a..4d01b14e2 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -16,6 +16,8 @@ mod ast; pub mod budget; mod lexer; +mod print_cmd; +mod raw; mod span; mod subst_scan; mod tokens; @@ -23,11 +25,13 @@ mod tokens; pub use ast::*; pub use budget::{BudgetError, validate as validate_budget}; pub use lexer::{Lexer, SpannedToken}; +pub use print_cmd::function_string; pub use span::{Position, Span}; use crate::error::{Error, Result}; use crate::limits::LimitExceeded; use crate::time_compat::Instant; +use raw::{heredoc_eof_from_raw, single_quote, split_raw_words}; use std::cell::Cell; use std::time::Duration; @@ -57,6 +61,11 @@ pub struct Parser<'a> { current_span: Span, /// Lookahead token for function parsing peeked_token: Option, + /// Source text of the current word token (only inside function bodies). + current_raw: Option, + /// Nesting depth of function definitions being parsed; word source text + /// is captured while it is non-zero. + function_depth: usize, /// Maximum allowed AST nesting depth max_depth: usize, /// Current nesting depth @@ -128,6 +137,8 @@ impl<'a> Parser<'a> { current_token, current_span, peeked_token: None, + current_raw: None, + function_depth: 0, max_depth: max_depth.min(HARD_MAX_AST_DEPTH), current_depth: 0, fuel: max_fuel, @@ -186,6 +197,27 @@ impl<'a> Parser<'a> { } } + /// Attach the current token's source text to a word built from it. + fn with_raw(&self, mut word: Word) -> Word { + if word.raw.is_none() { + word.raw = self.current_raw.clone(); + } + word + } + + /// Start capturing word source text for a function body. + fn enter_function_body(&mut self) { + self.function_depth += 1; + self.lexer.set_capture_raw(true); + } + + fn leave_function_body(&mut self) { + self.function_depth = self.function_depth.saturating_sub(1); + if self.function_depth == 0 { + self.lexer.set_capture_raw(false); + } + } + fn source_slice(&self, start_offset: usize, end_offset: usize) -> Option { self.input.get(start_offset..end_offset).map(str::to_owned) } @@ -356,14 +388,17 @@ impl<'a> Parser<'a> { if let Some(peeked) = self.peeked_token.take() { self.current_token = Some(peeked.token); self.current_span = peeked.span; + self.current_raw = peeked.raw; } else { match self.lexer.next_spanned_token() { Some(st) => { self.current_token = Some(st.token); self.current_span = st.span; + self.current_raw = st.raw; } None => { self.current_token = None; + self.current_raw = None; // Keep the last span for error reporting } } @@ -501,7 +536,15 @@ impl<'a> Parser<'a> { let mut commands = vec![first]; - while matches!(self.current_token, Some(tokens::Token::Pipe)) { + while matches!( + self.current_token, + Some(tokens::Token::Pipe | tokens::Token::PipeBoth) + ) { + if matches!(self.current_token, Some(tokens::Token::PipeBoth)) + && let Some(prev) = commands.last_mut() + { + add_stderr_to_pipe(prev); + } self.advance(); self.skip_newlines()?; @@ -541,6 +584,7 @@ impl<'a> Parser<'a> { fd_var: None, kind, target, + heredoc_delim: None, }); } } @@ -552,6 +596,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Append, target, + heredoc_delim: None, }); } } @@ -563,6 +608,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Input, target, + heredoc_delim: None, }); } } @@ -574,6 +620,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::OutputBoth, target, + heredoc_delim: None, }); } } @@ -591,6 +638,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target, + heredoc_delim: None, }); } } @@ -603,6 +651,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Output, target, + heredoc_delim: None, }); } } @@ -615,6 +664,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Append, target, + heredoc_delim: None, }); } } @@ -627,6 +677,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, }); } Some(tokens::Token::DupFdCloseOut(fd)) => { @@ -637,6 +688,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal("-"), + heredoc_delim: None, }); } Some(tokens::Token::DupInput) => { @@ -647,6 +699,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target, + heredoc_delim: None, }); } } @@ -659,6 +712,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, }); } Some(tokens::Token::DupFdClose(fd)) => { @@ -669,6 +723,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target: Word::literal("-"), + heredoc_delim: None, }); } Some(tokens::Token::RedirectFdIn(fd)) => { @@ -680,6 +735,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Input, target, + heredoc_delim: None, }); } } @@ -691,6 +747,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::HereString, target, + heredoc_delim: None, }); } } @@ -909,7 +966,7 @@ impl<'a> Parser<'a> { if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { word.has_unquoted_glob = true; } - words.push(word); + words.push(self.with_raw(word)); self.advance(); } Some(tokens::Token::LiteralWord(w)) => { @@ -918,6 +975,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }); self.advance(); } @@ -1013,7 +1071,7 @@ impl<'a> Parser<'a> { if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { word.has_unquoted_glob = true; } - words.push(word); + words.push(self.with_raw(word)); self.advance(); } Some(tokens::Token::LiteralWord(w)) => { @@ -1022,6 +1080,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }); self.advance(); } @@ -1063,14 +1122,31 @@ impl<'a> Parser<'a> { /// Parse C-style arithmetic for loop inner: for ((init; cond; step)); do body; done /// Note: depth tracking is done by parse_for which calls this fn parse_arithmetic_for_inner(&mut self, start_span: Span) -> Result { - self.advance(); // consume '((' - // Read the three expressions separated by semicolons let mut parts: Vec = Vec::new(); + let mut raw_parts: Option> = None; let mut current_expr = String::new(); let mut paren_depth = 0; - loop { + if self.peeked_token.is_none() { + // Read `((init; cond; step))` as written (see read_dparen_body). + let Some(body) = self.lexer.read_dparen_body() else { + return Err(Error::parse( + "unexpected end of input in for loop".to_string(), + )); + }; + let raws = split_arith_for_parts(&body); + if raws.len() != 3 { + return Err(self.error("syntax error: arithmetic expression required")); + } + parts = raws.iter().map(|p| arith_exec_text(p)).collect(); + raw_parts = Some(raws); + self.advance(); + } else { + self.advance(); // consume '((' + } + + while raw_parts.is_none() { match &self.current_token { Some(tokens::Token::DoubleRightParen) => { // End of the (( )) section @@ -1194,6 +1270,7 @@ impl<'a> Parser<'a> { init, condition, step, + raw: raw_parts, body, span: start_span.merge(self.current_span), })) @@ -1312,6 +1389,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }, Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) @@ -1331,7 +1409,7 @@ impl<'a> Parser<'a> { } _ => unreachable!(), }; - patterns.push(pattern); + patterns.push(self.with_raw(pattern)); self.advance(); // Check for | between patterns @@ -1651,7 +1729,7 @@ impl<'a> Parser<'a> { if w_clone.contains('$') && !is_quoted && !is_literal { // Variable reference — parse normally for expansion let parsed = self.parse_word(w_clone); - words.push(parsed); + words.push(self.with_raw(parsed)); self.advance(); } else { let pattern = self.collect_conditional_regex_pattern(&w_clone); @@ -1667,7 +1745,9 @@ impl<'a> Parser<'a> { // pattern text, not comments or operators. words.push(Word::literal("=~")); if let Some(raw) = self.lexer.read_cond_regex() { - words.push(self.cond_regex_word(raw)); + let mut word = self.cond_regex_word(raw.clone()); + word.raw = Some(raw.trim().to_string()); + words.push(word); } self.advance(); continue; @@ -1682,6 +1762,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), } } else { let mut parsed = self.parse_word(w_clone); @@ -1693,7 +1774,7 @@ impl<'a> Parser<'a> { } parsed }; - words.push(word); + words.push(self.with_raw(word)); self.advance(); } // Operators that the lexer tokenizes separately @@ -1825,6 +1906,17 @@ impl<'a> Parser<'a> { } fn parse_arithmetic_command(&mut self) -> Result { + if self.peeked_token.is_none() { + // Read `(( expr ))` as written (see read_dparen_body); the + // command keeps that text, so `type` prints it unchanged. + let Some(body) = self.lexer.read_dparen_body() else { + return Err(Error::parse( + "unexpected end of input in arithmetic command".to_string(), + )); + }; + self.advance(); + return Ok(CompoundCommand::Arithmetic(body)); + } self.advance(); // consume '((' // Read expression until we find )) @@ -1985,33 +2077,15 @@ impl<'a> Parser<'a> { Some(tokens::Token::Word(w)) => w.clone(), _ => return Err(self.error("expected function name")), }; - self.advance(); - self.skip_newlines()?; - - // Optional () after name - if matches!(self.current_token, Some(tokens::Token::LeftParen)) { - self.advance(); // consume '(' - if !matches!(self.current_token, Some(tokens::Token::RightParen)) { - return Err(Error::parse( - "expected ')' in function definition".to_string(), - )); - } - self.advance(); // consume ')' - self.skip_newlines()?; - } - - // Expect { for body - if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) { - return Err(Error::parse("expected '{' for function body".to_string())); - } - - // Parse body as brace group - let body = self.parse_brace_group()?; + self.enter_function_body(); + let body = self.parse_function_keyword_rest(); + self.leave_function_body(); + let body = body?; let end_offset = self.current_command_end_offset(); Ok(Command::Function(FunctionDef { name, - body: Box::new(Command::Compound(body, Vec::new())), + body: Box::new(body), source: self.source_slice(start_span.start.offset, end_offset), span: start_span.merge(self.current_span), })) @@ -2036,26 +2110,71 @@ impl<'a> Parser<'a> { if !matches!(self.current_token, Some(tokens::Token::RightParen)) { return Err(self.error("expected ')' in function definition")); } - self.advance(); // consume ')' - self.skip_newlines()?; - - // Expect { for body - if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) { - return Err(self.error("expected '{' for function body")); - } - - // Parse body as brace group - let body = self.parse_brace_group()?; + self.enter_function_body(); + let body = self.parse_function_posix_rest(); + self.leave_function_body(); + let body = body?; let end_offset = self.current_command_end_offset(); Ok(Command::Function(FunctionDef { name, - body: Box::new(Command::Compound(body, Vec::new())), + body: Box::new(body), source: self.source_slice(start_span.start.offset, end_offset), span: start_span.merge(self.current_span), })) } + /// `function name [()] body`, after the name. + fn parse_function_keyword_rest(&mut self) -> Result { + self.advance(); + self.skip_newlines()?; + + // Optional () after name + if matches!(self.current_token, Some(tokens::Token::LeftParen)) { + self.advance(); // consume '(' + if !matches!(self.current_token, Some(tokens::Token::RightParen)) { + return Err(Error::parse( + "expected ')' in function definition".to_string(), + )); + } + self.advance(); // consume ')' + self.skip_newlines()?; + } + self.parse_function_body() + } + + /// `name () body`, from the `)`. + fn parse_function_posix_rest(&mut self) -> Result { + self.advance(); // consume ')' + self.skip_newlines()?; + self.parse_function_body() + } + + /// A function body: any compound command with its redirections + /// (`f() ( sub )`, `f() if ...; fi`, `f() { ...; } >log`), like bash. + fn parse_function_body(&mut self) -> Result { + let compound_start = match &self.current_token { + Some( + tokens::Token::LeftBrace + | tokens::Token::LeftParen + | tokens::Token::DoubleLeftParen + | tokens::Token::DoubleLeftBracket, + ) => true, + Some(tokens::Token::Word(w)) => matches!( + w.as_str(), + "if" | "for" | "while" | "until" | "case" | "select" + ), + _ => false, + }; + if !compound_start { + return Err(self.error("expected '{' for function body")); + } + match self.parse_command()? { + Some(cmd @ Command::Compound(..)) => Ok(cmd), + _ => Err(self.error("expected '{' for function body")), + } + } + /// Parse commands until a terminating keyword fn parse_compound_list(&mut self, terminator: &str) -> Result> { self.parse_compound_list_until(&[terminator]) @@ -2292,6 +2411,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), } } else if matches!( &self.current_token, @@ -2303,7 +2423,7 @@ impl<'a> Parser<'a> { } else { self.parse_word(elem_clone) }; - elements.push(word); + elements.push(self.with_raw(word)); self.advance(); } None => break, @@ -2323,6 +2443,19 @@ impl<'a> Parser<'a> { /// must drop again so `x="a*"b*` stores `a*b*`. fn try_parse_assignment(&mut self, w: &str, glob_escaped: bool) -> Option<(Assignment, bool)> { let (name, index, value, is_append) = Self::is_assignment(w)?; + // Source text of the value (after `=`), for function printing. + let value_raw = self.current_raw.as_deref().map(|raw| { + let start = match index { + Some(_) => raw.find("]=").or_else(|| raw.find("]+=")).map(|i| i + 1), + None => raw.find(['=', '+']), + } + .unwrap_or(0); + let rest = &raw[start..]; + rest.strip_prefix("+=") + .or_else(|| rest.strip_prefix('=')) + .unwrap_or(rest) + .to_string() + }); let name = name.to_string(); let index = index.map(|s| s.to_string()); let value_str = value.to_string(); @@ -2330,7 +2463,7 @@ impl<'a> Parser<'a> { // Array literal in the token itself: arr=(a b c) if value_str.starts_with('(') && value_str.ends_with(')') { let inner = &value_str[1..value_str.len() - 1]; - let elements: Vec = Self::split_array_elements(inner) + let mut elements: Vec = Self::split_array_elements(inner) .into_iter() .map(|(s, quoted)| { if quoted { @@ -2342,6 +2475,18 @@ impl<'a> Parser<'a> { } }) .collect(); + if let Some(raw) = value_raw.as_deref() { + let raw_inner = raw + .strip_prefix('(') + .and_then(|r| r.strip_suffix(')')) + .unwrap_or(raw); + let raws = split_raw_words(raw_inner); + if raws.len() == elements.len() { + for (elem, r) in elements.iter_mut().zip(raws) { + elem.raw = Some(r); + } + } + } return Some(( Assignment { name, @@ -2370,11 +2515,13 @@ impl<'a> Parser<'a> { )); } // Empty assignment: VAR= + let mut empty = Word::literal(""); + empty.raw = value_raw; return Some(( Assignment { name, index, - value: AssignmentValue::Scalar(Word::literal("")), + value: AssignmentValue::Scalar(empty), append: is_append, }, false, @@ -2382,7 +2529,7 @@ impl<'a> Parser<'a> { } // Quoted or plain scalar value - let value_word = if value_str.starts_with('"') && value_str.ends_with('"') { + let mut value_word = if value_str.starts_with('"') && value_str.ends_with('"') { let inner = Self::strip_quotes(&value_str); let mut w = self.parse_word(inner.to_string()); w.quoted = true; @@ -2394,6 +2541,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, } } else { let mut w = self.parse_word(value_str); @@ -2406,6 +2554,7 @@ impl<'a> Parser<'a> { } w }; + value_word.raw = value_raw; Some(( Assignment { name, @@ -2420,13 +2569,18 @@ impl<'a> Parser<'a> { /// Parse a compound array argument in arg position (e.g. `declare -a arr=(x y z)`). /// Called when the current word ends with `=` and the next token is `(`. /// Returns the compound word if successful, or `None` if not a compound assignment. - fn try_parse_compound_array_arg(&mut self, saved_w: String) -> Option { + fn try_parse_compound_array_arg( + &mut self, + saved_w: String, + saved_raw: Option, + ) -> Option { if !matches!(self.current_token, Some(tokens::Token::LeftParen)) { return None; } self.advance(); // consume '(' let mut compound = saved_w; compound.push('('); + let mut raw_elems: Vec = Vec::new(); loop { match &self.current_token { Some(tokens::Token::RightParen) => { @@ -2442,6 +2596,7 @@ impl<'a> Parser<'a> { compound.push(' '); } compound.push_str(elem); + raw_elems.push(self.current_raw.clone().unwrap_or_default()); self.advance(); } None => break, @@ -2450,7 +2605,9 @@ impl<'a> Parser<'a> { } } } - Some(self.parse_word(compound)) + let mut word = self.parse_word(compound); + word.raw = saved_raw.map(|r| format!("{r}({})", raw_elems.join(" "))); + Some(word) } /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line. @@ -2463,7 +2620,13 @@ impl<'a> Parser<'a> { Some(tokens::Token::HereDocStrip) => (None, true), _ => (None, false), }; + // Capture the delimiter's source text: bash quote-removes it and + // treats the body as literal when any part of it was quoted + // (`<<'E'`, `<<\E`, `< (w.clone(), false), @@ -2473,6 +2636,25 @@ impl<'a> Parser<'a> { } _ => return Err(Error::parse("expected delimiter after <<".to_string())), }; + let (delimiter, quoted, printed) = match self.current_raw.as_deref() { + Some(raw) => { + let (eof, quoted) = heredoc_eof_from_raw(raw); + let printed = if quoted { + single_quote(&eof) + } else { + raw.to_string() + }; + (eof, quoted, printed) + } + None => { + let printed = if quoted { + single_quote(&delimiter) + } else { + delimiter.clone() + }; + (delimiter, quoted, printed) + } + }; let (content, rest_of_line_chars) = self .lexer @@ -2495,11 +2677,12 @@ impl<'a> Parser<'a> { content }; - let target = if quoted { - Word::quoted_literal(content) + let mut target = if quoted { + Word::quoted_literal(content.clone()) } else { self.parse_word(heredoc_body_escapes(&content)) }; + target.raw = Some(content); let kind = if strip_tabs { RedirectKind::HereDocStrip @@ -2512,6 +2695,7 @@ impl<'a> Parser<'a> { fd_var: None, kind, target, + heredoc_delim: Some(printed), }); // Advance so re-injected rest-of-line tokens are picked up @@ -2544,12 +2728,14 @@ impl<'a> Parser<'a> { fd_var, kind: RedirectKind::Append, target, + heredoc_delim: None, }); redirects.push(Redirect { fd: Some(2), fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal("1"), + heredoc_delim: None, }); } @@ -2586,137 +2772,15 @@ impl<'a> Parser<'a> { loop { match &self.current_token { - Some(tokens::Token::Word(w)) - | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) - | Some(tokens::Token::QuotedGlobWord(w)) => { - let is_literal = - matches!(&self.current_token, Some(tokens::Token::LiteralWord(_))); - let is_quoted = matches!( - &self.current_token, - Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) - ); - let is_glob_quoted = - matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))); - // Clone early to release borrow on self.current_token - let w = w.clone(); - - // Stop if this word cannot start a command (like 'then', 'fi', etc.) - if words.is_empty() && Self::is_non_command_word(&w) { + Some( + tokens::Token::Word(_) + | tokens::Token::LiteralWord(_) + | tokens::Token::QuotedWord(_) + | tokens::Token::QuotedGlobWord(_), + ) => { + if !self.parse_simple_word(&mut words, &mut assignments) { break; } - - // Check for assignment (only before the command name, not for literal words) - if words.is_empty() - && !is_literal - && let Some((assignment, needs_advance)) = - self.try_parse_assignment(&w, is_glob_quoted) - { - if needs_advance { - self.advance(); - } - assignments.push(assignment); - continue; - } - - // Handle compound array assignment in arg position: - // declare -a arr=(x y z) → arr=(x y z) as single arg - if w.ends_with('=') && !words.is_empty() { - self.advance(); - if let Some(word) = self.try_parse_compound_array_arg(w.clone()) { - words.push(word); - continue; - } - // Not a compound assignment — treat as regular word - let word = if is_literal { - Word { - parts: vec![WordPart::Literal(w)], - quoted: true, - has_unquoted_glob: false, - part_quoted: Vec::new(), - } - } else { - let mut word = self.parse_word(w); - if is_quoted { - word.quoted = true; - } - if is_glob_quoted { - word.has_unquoted_glob = true; - } - word - }; - words.push(word); - continue; - } - - let word = if is_literal { - Word { - parts: vec![WordPart::Literal(w)], - quoted: true, - has_unquoted_glob: false, - part_quoted: Vec::new(), - } - } else { - let mut word = self.parse_word(w); - if is_quoted { - word.quoted = true; - } - if is_glob_quoted { - word.has_unquoted_glob = true; - } - word - }; - words.push(word); - self.advance(); - } - Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => { - let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) { - RedirectKind::Clobber - } else { - RedirectKind::Output - }; - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind, - target, - }); - } - Some(tokens::Token::RedirectAppend) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::Append, - target, - }); - } - Some(tokens::Token::RedirectIn) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::Input, - target, - }); - } - Some(tokens::Token::HereString) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::HereString, - target, - }); } Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) @@ -2729,125 +2793,38 @@ impl<'a> Parser<'a> { let word = self.expect_word()?; words.push(word); } - Some(tokens::Token::RedirectBoth) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::OutputBoth, - target, - }); + Some( + tokens::Token::RedirectOut + | tokens::Token::Clobber + | tokens::Token::RedirectAppend + | tokens::Token::RedirectIn + | tokens::Token::HereString + | tokens::Token::RedirectBoth + | tokens::Token::DupOutput + | tokens::Token::RedirectFd(_) + | tokens::Token::RedirectFdAppend(_) + | tokens::Token::DupFd(..) + | tokens::Token::DupFdCloseOut(_) + | tokens::Token::DupInput + | tokens::Token::DupFdIn(..) + | tokens::Token::DupFdClose(_) + | tokens::Token::RedirectFdIn(_), + ) => { + self.parse_simple_redirect(&mut words, &mut redirects)?; } Some(tokens::Token::RedirectBothAppend) => { self.parse_append_both(&mut words, &mut redirects)?; } - Some(tokens::Token::DupOutput) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: if fd_var.is_some() { None } else { Some(1) }, - fd_var, - kind: RedirectKind::DupOutput, - target, - }); - } - Some(tokens::Token::RedirectFd(fd)) => { - let fd = *fd; - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Output, - target, - }); - } - Some(tokens::Token::RedirectFdAppend(fd)) => { - let fd = *fd; - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Append, - target, - }); - } - Some(tokens::Token::DupFd(src_fd, dst_fd)) => { - let src_fd = *src_fd; - let dst_fd = *dst_fd; - self.advance(); - redirects.push(Redirect { - fd: Some(src_fd), - fd_var: None, - kind: RedirectKind::DupOutput, - target: Word::literal(dst_fd.to_string()), - }); - } - Some(tokens::Token::DupFdCloseOut(fd)) => { - let fd = *fd; - self.advance(); - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::DupOutput, - target: Word::literal("-"), - }); - } - Some(tokens::Token::DupInput) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: if fd_var.is_some() { None } else { Some(0) }, - fd_var, - kind: RedirectKind::DupInput, - target, - }); - } - Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => { - let src_fd = *src_fd; - let dst_fd = *dst_fd; - self.advance(); - redirects.push(Redirect { - fd: Some(src_fd), - fd_var: None, - kind: RedirectKind::DupInput, - target: Word::literal(dst_fd.to_string()), - }); - } - Some(tokens::Token::DupFdClose(fd)) => { - let fd = *fd; - self.advance(); - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::DupInput, - target: Word::literal("-"), - }); - } - Some(tokens::Token::RedirectFdIn(fd)) => { - let fd = *fd; - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Input, - target, - }); - } - // { and } as arguments (not in command position) are literal words - Some(tokens::Token::LeftBrace) | Some(tokens::Token::RightBrace) - if !words.is_empty() => - { - let sym = if matches!(self.current_token, Some(tokens::Token::LeftBrace)) { - "{" - } else { - "}" + // {, } and ]] as arguments (not in command position) are literal words + Some( + tokens::Token::LeftBrace + | tokens::Token::RightBrace + | tokens::Token::DoubleRightBracket, + ) if !words.is_empty() => { + let sym = match self.current_token { + Some(tokens::Token::LeftBrace) => "{", + Some(tokens::Token::RightBrace) => "}", + _ => "]]", }; words.push(Word::literal(sym)); self.advance(); @@ -2891,11 +2868,285 @@ impl<'a> Parser<'a> { })) } + /// One word of a simple command (outlined to keep the recursive + /// `parse_simple_command` frame small; `$( )` nesting recurses through it). + /// Returns false when the word ends the command. + #[inline(never)] + fn parse_simple_word( + &mut self, + words: &mut Vec, + assignments: &mut Vec, + ) -> bool { + let (w, is_literal, is_quoted, is_glob_quoted) = match &self.current_token { + Some(tokens::Token::Word(w)) => (w.clone(), false, false, false), + Some(tokens::Token::LiteralWord(w)) => (w.clone(), true, false, false), + Some(tokens::Token::QuotedWord(w)) => (w.clone(), false, true, false), + Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), false, true, true), + _ => return false, + }; + // Stop if this word cannot start a command (like 'then', 'fi', etc.) + if words.is_empty() && Self::is_non_command_word(&w) { + return false; + } + + // Check for assignment (only before the command name, not for literal words) + if words.is_empty() + && !is_literal + && let Some((assignment, needs_advance)) = self.try_parse_assignment(&w, is_glob_quoted) + { + if needs_advance { + self.advance(); + } + assignments.push(assignment); + return true; + } + + // Handle compound array assignment in arg position: + // declare -a arr=(x y z) → arr=(x y z) as single arg + if w.ends_with('=') && !words.is_empty() { + let saved_raw = self.current_raw.clone(); + self.advance(); + if let Some(word) = self.try_parse_compound_array_arg(w.clone(), saved_raw.clone()) { + words.push(word); + return true; + } + // Not a compound assignment — treat as regular word + let word = if is_literal { + Word { + parts: vec![WordPart::Literal(w)], + quoted: true, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: saved_raw.clone(), + } + } else { + let mut word = self.parse_word(w); + if is_quoted { + word.quoted = true; + } + if is_glob_quoted { + word.has_unquoted_glob = true; + } + word + }; + let mut word = word; + word.raw = saved_raw; + words.push(word); + return true; + } + + let word = if is_literal { + Word { + parts: vec![WordPart::Literal(w)], + quoted: true, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: self.current_raw.clone(), + } + } else { + let mut word = self.parse_word(w); + if is_quoted { + word.quoted = true; + } + if is_glob_quoted { + word.has_unquoted_glob = true; + } + word + }; + words.push(self.with_raw(word)); + self.advance(); + true + } + + /// One redirection of a simple command (outlined, see `parse_simple_word`). + #[inline(never)] + fn parse_simple_redirect( + &mut self, + words: &mut Vec, + redirects: &mut Vec, + ) -> Result<()> { + match &self.current_token { + Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => { + let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) { + RedirectKind::Clobber + } else { + RedirectKind::Output + }; + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectAppend) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::Append, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectIn) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::Input, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::HereString) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::HereString, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectBoth) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::OutputBoth, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::DupOutput) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: if fd_var.is_some() { None } else { Some(1) }, + fd_var, + kind: RedirectKind::DupOutput, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFd(fd)) => { + let fd = *fd; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::Output, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFdAppend(fd)) => { + let fd = *fd; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::Append, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFd(src_fd, dst_fd)) => { + let src_fd = *src_fd; + let dst_fd = *dst_fd; + self.advance(); + redirects.push(Redirect { + fd: Some(src_fd), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFdCloseOut(fd)) => { + let fd = *fd; + self.advance(); + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal("-"), + heredoc_delim: None, + }); + } + Some(tokens::Token::DupInput) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: if fd_var.is_some() { None } else { Some(0) }, + fd_var, + kind: RedirectKind::DupInput, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => { + let src_fd = *src_fd; + let dst_fd = *dst_fd; + self.advance(); + redirects.push(Redirect { + fd: Some(src_fd), + fd_var: None, + kind: RedirectKind::DupInput, + target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFdClose(fd)) => { + let fd = *fd; + self.advance(); + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::DupInput, + target: Word::literal("-"), + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFdIn(fd)) => { + let fd = *fd; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::Input, + target, + heredoc_delim: None, + }); + } + _ => {} + } + Ok(()) + } + /// Expect a word token and return it as a Word fn expect_word(&mut self) -> Result { match &self.current_token { Some(tokens::Token::Word(w)) => { - let word = self.parse_word(w.clone()); + let word = self.with_raw(self.parse_word(w.clone())); self.advance(); Ok(word) } @@ -2906,13 +3157,14 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }; self.advance(); Ok(word) } Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { // Double-quoted: parse for variable expansion - let word = self.parse_word(w.clone()); + let word = self.with_raw(self.parse_word(w.clone())); self.advance(); Ok(word) } @@ -2927,11 +3179,20 @@ impl<'a> Parser<'a> { let is_input = matches!(self.current_token, Some(tokens::Token::ProcessSubIn)); // Span end of the `<(` / `>(` token is exactly the start of the body. let body_start_offset = self.current_span.end.offset; + let mut depth = 1; + let mut body_end_offset = 0; + // Find the end the way `$(...)` does (quote/case/heredoc + // aware, see subst_scan), so `<(case a in a) ...;; esac)` + // does not stop at the pattern's `)`. + let scanned = self.peeked_token.is_none() && self.lexer.skip_subst_body(); + if scanned { + // The closing `)` is one byte, just consumed. + body_end_offset = self.lexer.position().offset.saturating_sub(1); + depth = 0; + } self.advance(); - let mut depth = 1; - let body_end_offset; - loop { + while depth > 0 { match &self.current_token { Some(tokens::Token::LeftParen) => { depth += 1; @@ -3004,9 +3265,11 @@ impl<'a> Parser<'a> { let result = inner_parser .parse_script_into(&mut commands) .map(|()| commands.into_iter().map(|(cmd, _)| cmd).collect::>()); - (result, inner_parser.fuel) + let raw = (self.function_depth > 0) + .then(|| format!("{}({cmd_src})", if is_input { '<' } else { '>' })); + (result, inner_parser.fuel, raw) }; - let (parse_result, remaining_fuel) = inner_result; + let (parse_result, remaining_fuel, raw) = inner_result; self.fuel = remaining_fuel; let commands = match parse_result { Ok(commands) => commands, @@ -3019,6 +3282,7 @@ impl<'a> Parser<'a> { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw, }) } _ => Err(self.error("expected word")), @@ -3049,6 +3313,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }), _ => None, } @@ -3728,6 +3993,17 @@ impl<'a> Parser<'a> { } if ch == '"' { in_dq = !in_dq; + } else if ch == '\'' && !in_dq { + // `"${y/b/'}'}"`: a single-quoted span + // hides `}`; the expansion quote-removes it. + repl.push(chars.next().unwrap()); + for q in chars.by_ref() { + repl.push(q); + if q == '\'' { + break; + } + } + continue; } else if ch == '\\' { repl.push(chars.next().unwrap()); if let Some(n) = chars.next() { @@ -3907,6 +4183,7 @@ impl<'a> Parser<'a> { quoted: false, has_unquoted_glob: false, part_quoted, + raw: None, } } @@ -3921,6 +4198,19 @@ impl<'a> Parser<'a> { if let Some(n) = chars.next() { operand.push(n); } + } else if c == '$' { + operand.push(chars.next().unwrap()); + if chars.peek() == Some(&'(') { + // `${x:-$(echo })}`: the substitution owns its braces. + operand.push(chars.next().unwrap()); + let mut scanner = subst_scan::SubstScanner::new(); + for n in chars.by_ref() { + operand.push(n); + if scanner.feed(n) == subst_scan::Step::Close { + break; + } + } + } } else if c == '{' { depth += 1; operand.push(chars.next().unwrap()); @@ -3939,6 +4229,24 @@ impl<'a> Parser<'a> { } } +/// `a |& b` is `a 2>&1 | b`: append the dup to `a`. Out of line to keep +/// the recursive `parse_pipeline` frame small (TM-DOS-044). +#[inline(never)] +fn add_stderr_to_pipe(cmd: &mut Command) { + let dup = Redirect { + fd: Some(2), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal("1"), + heredoc_delim: None, + }; + match cmd { + Command::Simple(sc) => sc.redirects.push(dup), + Command::Compound(_, redirects) => redirects.push(dup), + _ => {} + } +} + /// `cmd &` right before a closing keyword: an empty command carrying the `&`. /// Out of line so the `Command` temporary does not enlarge the recursive /// `parse_command_list` frame (nested `$(...)` parse depth, TM-DOS-044). @@ -4058,6 +4366,34 @@ pub(crate) fn unescape_glob_literal(s: &str) -> String { out } +/// Split the text of `for ((init; cond; step))` at top-level `;`. +fn split_arith_for_parts(body: &str) -> Vec { + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut depth = 0usize; + for c in body.chars() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ';' if depth == 0 => { + parts.push(std::mem::take(&mut cur)); + continue; + } + _ => {} + } + cur.push(c); + } + parts.push(cur); + parts +} + +/// Expression text an arithmetic command evaluates: its source with +/// surrounding blanks trimmed and double quotes removed (bash treats +/// `(( ... ))` like `let "..."`). +pub(crate) fn arith_exec_text(raw: &str) -> String { + raw.trim().chars().filter(|&c| c != '"').collect() +} + /// Backslash handling in an unquoted heredoc body, as in double quotes but /// without `"`: `\$`, `` \` `` and `\\` become literal characters (NUL /// sentinel for `parse_word`), `\` joins lines, and any other diff --git a/crates/bashkit/src/parser/print_cmd.rs b/crates/bashkit/src/parser/print_cmd.rs new file mode 100644 index 000000000..417e7f6ca --- /dev/null +++ b/crates/bashkit/src/parser/print_cmd.rs @@ -0,0 +1,941 @@ +//! Function body printer for `type` and `declare -f`. +//! +//! Important decision: a port of bash's `print_cmd.c` layout rules +//! (4-space indent, `;` + newline between commands, `if`/`elif` printed as +//! nested `else if`, deferred here-document bodies, `was_heredoc` quirks) +//! over bashkit's AST. Words print as written (`Word::raw`, recorded by the +//! parser inside function definitions); a word without source text falls +//! back to a reconstruction from its parts. Output never uses `Debug` +//! (TM-INF-022). + +use super::ast::*; +use super::raw::heredoc_eof_from_raw; + +const INDENT_AMOUNT: usize = 4; + +/// Bash's text for `type NAME` / `declare -f NAME`: `name () \n{ \n...\n}`. +pub fn function_string(name: &str, body: &Command) -> String { + let mut p = Printer::default(); + if is_reserved_word(name) { + p.out.push_str("function "); + } + p.out.push_str(name); + p.out.push_str(" () \n"); + p.inside_function_def += 1; + p.out.push_str("{ \n"); + p.indentation += INDENT_AMOUNT; + let func_redirects = p.print_function_body(body); + p.print_deferred_if_any(""); + p.indentation -= INDENT_AMOUNT; + p.inside_function_def -= 1; + p.close_function(func_redirects); + p.out +} + +fn is_reserved_word(name: &str) -> bool { + matches!( + name, + "if" | "then" + | "else" + | "elif" + | "fi" + | "case" + | "esac" + | "for" + | "select" + | "while" + | "until" + | "do" + | "done" + | "in" + | "function" + | "time" + | "{" + | "}" + | "!" + | "[[" + | "]]" + | "coproc" + ) +} + +#[derive(Clone, Copy, PartialEq)] +enum Conn { + Semi, + Amp, + And, + Or, + Pipe, +} + +/// A here-document whose body prints after the current line. +struct HereDoc { + body: String, + eof: String, +} + +#[derive(Default)] +struct Printer { + out: String, + indentation: usize, + skip_this_indent: usize, + was_heredoc: bool, + printing_connection: usize, + inside_function_def: usize, + deferred: Vec, +} + +impl Printer { + fn indent(&mut self, n: usize) { + for _ in 0..n { + self.out.push(' '); + } + } + + /// `newline (s)`: newline, indent, then `s`. + fn newline(&mut self, s: &str) { + self.out.push('\n'); + self.indent(self.indentation); + self.out.push_str(s); + } + + /// `semicolon ()`: a `;` unless the text already ends a line or a job. + fn semicolon(&mut self) { + if matches!(self.out.chars().last(), Some('&' | '\n')) { + return; + } + self.out.push(';'); + } + + /// Prints a function body; returns the redirections of a `{ }` body, + /// which print after its closing brace. + fn print_function_body<'a>(&mut self, body: &'a Command) -> &'a [Redirect] { + match body { + Command::Compound(CompoundCommand::BraceGroup(cmds), redirects) => { + self.print_list(cmds); + redirects + } + other => { + self.make(Some(other)); + &[] + } + } + } + + fn close_function(&mut self, func_redirects: &[Redirect]) { + if func_redirects.is_empty() { + self.newline("}"); + } else { + self.newline("} "); + self.print_redirection_list(func_redirects); + } + } + + fn print_deferred_if_any(&mut self, cstring: &str) { + if !self.deferred.is_empty() { + self.print_deferred_heredocs(cstring); + } + } + + fn print_deferred_heredocs(&mut self, cstring: &str) { + let prints_cstring = !cstring.is_empty() && cstring != ";"; + if prints_cstring { + self.out.push_str(cstring); + } + if !self.deferred.is_empty() { + let docs = std::mem::take(&mut self.deferred); + self.print_heredoc_bodies(&docs); + if prints_cstring { + self.out.push(' '); + } + self.was_heredoc = true; + } + } + + fn print_heredoc_bodies(&mut self, docs: &[HereDoc]) { + self.out.push('\n'); + for doc in docs { + self.out.push_str(&doc.body); + self.out.push_str(&doc.eof); + self.out.push('\n'); + } + } + + /// A command sequence (`;`/newline separated). + fn print_list(&mut self, cmds: &[Command]) { + match cmds { + [] => self.make(None), + [only] => self.make(Some(only)), + [first, rest @ ..] => { + let rest: Vec<(Conn, Option<&Command>)> = + rest.iter().map(|c| (Conn::Semi, Some(c))).collect(); + self.make_connection(first, &rest); + } + } + } + + /// `make_command_string_internal`. + fn make(&mut self, cmd: Option<&Command>) { + let Some(cmd) = cmd else { + return; + }; + if self.skip_this_indent > 0 { + self.skip_this_indent -= 1; + } else { + self.indent(self.indentation); + } + match cmd { + Command::Simple(sc) => self.print_simple(sc), + Command::Pipeline(p) => { + if p.negated { + self.out.push_str("! "); + } + match p.commands.as_slice() { + [] => {} + [only] => { + self.skip_this_indent += 1; + self.make(Some(only)); + } + [first, rest @ ..] => { + let rest: Vec<(Conn, Option<&Command>)> = + rest.iter().map(|c| (Conn::Pipe, Some(c))).collect(); + self.connection_body(first, &rest); + } + } + } + Command::List(list) => { + let rest: Vec<(Conn, Option<&Command>)> = list + .rest + .iter() + .map(|(op, c)| { + let conn = match op { + ListOperator::And => Conn::And, + ListOperator::Or => Conn::Or, + ListOperator::Semicolon => Conn::Semi, + ListOperator::Background => Conn::Amp, + }; + let second = if is_empty_placeholder(c) { + None + } else { + Some(c) + }; + (conn, second) + }) + .collect(); + self.connection_body(&list.first, &rest); + } + Command::Compound(cc, redirects) => { + self.print_compound(cc); + if !redirects.is_empty() { + self.out.push(' '); + self.print_redirection_list(redirects); + } + } + Command::Function(f) => self.print_function_def(f), + } + } + + /// A connection chain, as an indented command of its own. + fn make_connection(&mut self, first: &Command, rest: &[(Conn, Option<&Command>)]) { + if self.skip_this_indent > 0 { + self.skip_this_indent -= 1; + } else { + self.indent(self.indentation); + } + self.connection_body(first, rest); + } + + /// The `cm_connection` case, flattened over a left-associative chain: + /// each link ends like bash's nested connection (deferred heredocs). + fn connection_body(&mut self, first: &Command, rest: &[(Conn, Option<&Command>)]) { + self.skip_this_indent += 1; + self.printing_connection += 1; + self.make(Some(first)); + for (conn, second) in rest { + match conn { + Conn::Amp | Conn::Pipe => { + let s = if *conn == Conn::Amp { " &" } else { " |" }; + self.print_deferred_heredocs(s); + if *conn != Conn::Amp || second.is_some() { + self.out.push(' '); + self.skip_this_indent += 1; + } + } + Conn::And | Conn::Or => { + let s = if *conn == Conn::And { " && " } else { " || " }; + self.print_deferred_heredocs(s); + if second.is_some() { + self.skip_this_indent += 1; + } + } + Conn::Semi => { + if self.deferred.is_empty() { + if !self.was_heredoc { + self.out.push(';'); + } else { + self.was_heredoc = false; + } + } else { + let s = if self.inside_function_def > 0 { + "" + } else { + ";" + }; + self.print_deferred_heredocs(s); + } + if self.inside_function_def > 0 { + self.out.push('\n'); + } else { + self.out.push(' '); + if second.is_some() { + self.skip_this_indent += 1; + } + } + } + } + self.make(*second); + self.print_deferred_if_any(""); + } + self.printing_connection -= 1; + } + + fn print_simple(&mut self, sc: &SimpleCommand) { + let mut words: Vec = Vec::new(); + for a in &sc.assignments { + words.push(assignment_text(a)); + } + let name = word_text(&sc.name); + if !(name.is_empty() && sc.name.raw.is_none()) { + words.push(name); + } + words.extend(sc.args.iter().map(word_text)); + self.out.push_str(&words.join(" ")); + if !sc.redirects.is_empty() { + if !words.is_empty() { + self.out.push(' '); + } + self.print_redirection_list(&sc.redirects); + } + } + + fn print_redirection_list(&mut self, redirects: &[Redirect]) { + let mut heredocs = Vec::new(); + self.was_heredoc = false; + for (i, r) in redirects.iter().enumerate() { + if i > 0 { + self.out.push(' '); + } + if matches!(r.kind, RedirectKind::HereDoc | RedirectKind::HereDocStrip) { + let delim = r.heredoc_delim.clone().unwrap_or_default(); + self.out.push_str(&redirector(r, 0)); + self.out.push_str(if r.kind == RedirectKind::HereDocStrip { + "<<-" + } else { + "<<" + }); + self.out.push_str(&delim); + let eof = if delim.starts_with('\'') { + heredoc_eof_from_raw(&delim).0 + } else { + delim + }; + heredocs.push(HereDoc { + body: r + .target + .raw + .clone() + .unwrap_or_else(|| reconstruct_word(&r.target)), + eof, + }); + } else { + self.out.push_str(&redirection_text(r)); + } + } + if heredocs.is_empty() { + return; + } + if self.printing_connection > 0 { + self.deferred.extend(heredocs); + } else { + self.print_heredoc_bodies(&heredocs); + } + } + + fn print_compound(&mut self, cc: &CompoundCommand) { + match cc { + CompoundCommand::If(c) => self.print_if( + &c.condition, + &c.then_branch, + &c.elif_branches, + c.else_branch.as_deref(), + ), + CompoundCommand::For(f) => { + self.out.push_str("for "); + self.out.push_str(&f.variable); + self.out.push_str(" in "); + match &f.words { + Some(words) => { + let w: Vec = words.iter().map(word_text).collect(); + self.out.push_str(&w.join(" ")); + } + None => self.out.push_str("\"$@\""), + } + self.loop_body_after_head(&f.body); + } + CompoundCommand::Select(f) => { + self.out.push_str("select "); + self.out.push_str(&f.variable); + self.out.push_str(" in "); + let w: Vec = f.words.iter().map(word_text).collect(); + self.out.push_str(&w.join(" ")); + self.loop_body_after_head(&f.body); + } + CompoundCommand::ArithmeticFor(f) => { + let parts: Vec = match &f.raw { + Some(raw) => raw.iter().map(|p| arith_for_part(p)).collect(), + None => [&f.init, &f.condition, &f.step] + .iter() + .map(|p| arith_for_part(p)) + .collect(), + }; + self.out.push_str("for (("); + self.out.push_str(&parts.join("; ")); + self.out.push_str("))"); + self.newline("do\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(&f.body); + self.print_deferred_if_any(""); + self.semicolon(); + self.indentation -= INDENT_AMOUNT; + self.newline("done"); + } + CompoundCommand::While(w) => self.print_until_or_while("while", &w.condition, &w.body), + CompoundCommand::Until(w) => self.print_until_or_while("until", &w.condition, &w.body), + CompoundCommand::Case(c) => { + self.out.push_str("case "); + self.out.push_str(&word_text(&c.word)); + self.out.push_str(" in "); + self.indentation += INDENT_AMOUNT; + for item in &c.cases { + self.newline(""); + let pats: Vec = item.patterns.iter().map(word_text).collect(); + self.out.push_str(&pats.join(" | ")); + self.out.push_str(")\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(&item.commands); + self.indentation -= INDENT_AMOUNT; + self.print_deferred_if_any(""); + self.newline(match item.terminator { + CaseTerminator::Break => ";;", + CaseTerminator::FallThrough => ";&", + CaseTerminator::Continue => ";;&", + }); + } + self.indentation -= INDENT_AMOUNT; + self.newline("esac"); + } + CompoundCommand::Subshell(cmds) => { + self.out.push_str("( "); + self.skip_this_indent += 1; + self.print_list(cmds); + self.print_deferred_if_any(""); + self.out.push_str(" )"); + } + CompoundCommand::BraceGroup(cmds) => { + self.out.push_str("{ "); + if self.inside_function_def == 0 { + self.skip_this_indent += 1; + } else { + self.out.push('\n'); + self.indentation += INDENT_AMOUNT; + } + self.print_list(cmds); + self.print_deferred_if_any(""); + if self.inside_function_def > 0 { + self.out.push('\n'); + self.indentation -= INDENT_AMOUNT; + self.indent(self.indentation); + } else { + self.semicolon(); + self.out.push(' '); + } + self.out.push('}'); + } + CompoundCommand::Arithmetic(expr) => { + self.out.push_str("(("); + self.out.push_str(expr); + self.out.push_str("))"); + } + CompoundCommand::Time(t) => { + self.out.push_str("time"); + if t.posix_format { + self.out.push_str(" -p"); + } + if let Some(cmd) = &t.command { + self.out.push(' '); + self.skip_this_indent += 1; + self.make(Some(cmd)); + } + } + CompoundCommand::Conditional(words) => { + self.out.push_str("[[ "); + let toks: Vec = words.iter().map(word_text).collect(); + self.out.push_str(&cond_text(&toks)); + self.out.push_str(" ]]"); + } + CompoundCommand::Coproc(c) => { + self.out.push_str("coproc "); + self.out.push_str(&c.name); + self.out.push(' '); + self.skip_this_indent += 1; + self.make(Some(&c.body)); + } + } + } + + fn loop_body_after_head(&mut self, body: &[Command]) { + self.out.push(';'); + self.newline("do\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(body); + self.print_deferred_if_any(""); + self.semicolon(); + self.indentation -= INDENT_AMOUNT; + self.newline("done"); + } + + fn print_until_or_while(&mut self, which: &str, test: &[Command], body: &[Command]) { + self.out.push_str(which); + self.out.push(' '); + self.skip_this_indent += 1; + self.print_list(test); + self.print_deferred_if_any(""); + self.semicolon(); + self.out.push_str(" do\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(body); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + self.semicolon(); + self.newline("done"); + } + + /// `if`; bash stores `elif` as a nested `if` in the `else` branch. + fn print_if( + &mut self, + test: &[Command], + then: &[Command], + elifs: &[(Vec, Vec)], + else_branch: Option<&[Command]>, + ) { + self.out.push_str("if "); + self.skip_this_indent += 1; + self.print_list(test); + self.semicolon(); + self.out.push_str(" then\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(then); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + if let Some(((etest, ethen), rest)) = elifs.split_first() { + self.semicolon(); + self.newline("else\n"); + self.indentation += INDENT_AMOUNT; + self.indent(self.indentation); + self.print_if(etest, ethen, rest, else_branch); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + } else if let Some(else_cmds) = else_branch { + self.semicolon(); + self.newline("else\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(else_cmds); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + } + self.semicolon(); + self.newline("fi"); + } + + /// Nested `function name () { ... }` inside a body. + fn print_function_def(&mut self, f: &FunctionDef) { + self.out.push_str("function "); + self.out.push_str(&f.name); + self.out.push_str(" () \n"); + self.indent(self.indentation); + self.out.push_str("{ \n"); + self.inside_function_def += 1; + self.indentation += INDENT_AMOUNT; + let func_redirects = self.print_function_body(&f.body); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + self.inside_function_def -= 1; + self.close_function(func_redirects); + } +} + +/// The `cmd &` placeholder the parser appends after a trailing `&`. +fn is_empty_placeholder(cmd: &Command) -> bool { + matches!(cmd, Command::Simple(sc) + if sc.assignments.is_empty() + && sc.args.is_empty() + && sc.redirects.is_empty() + && sc.name.raw.is_none() + && sc.name.parts.iter().all(|p| matches!(p, WordPart::Literal(s) if s.is_empty()))) +} + +/// One clause of `for ((...))`: leading blanks dropped, empty means `1`. +fn arith_for_part(p: &str) -> String { + let t = p.trim_start(); + if t.trim().is_empty() { + "1".to_string() + } else { + t.to_string() + } +} + +fn redirector(r: &Redirect, default_fd: i32) -> String { + if let Some(var) = &r.fd_var { + return format!("{{{var}}}"); + } + match r.fd { + Some(fd) if fd != default_fd => fd.to_string(), + _ => String::new(), + } +} + +fn redirection_text(r: &Redirect) -> String { + let target = word_text(&r.target); + match r.kind { + RedirectKind::Output => format!("{}> {target}", redirector(r, 1)), + RedirectKind::Clobber => format!("{}>| {target}", redirector(r, 1)), + RedirectKind::Append => format!("{}>> {target}", redirector(r, 1)), + RedirectKind::Input => format!("{}< {target}", redirector(r, 0)), + RedirectKind::HereString => format!("{}<<< {target}", redirector(r, 0)), + RedirectKind::OutputBoth => format!("&> {target}"), + RedirectKind::DupOutput => { + let fd_is_default = r.fd_var.is_none() && r.fd.is_none_or(|fd| fd == 1); + let word_target = !target.starts_with(|c: char| c == '-' || c.is_ascii_digit()) + && !target.starts_with('$'); + if fd_is_default && word_target { + format!("&> {target}") + } else { + format!("{}>&{target}", redirector(r, 1)) + } + } + RedirectKind::DupInput => format!("{}<&{target}", redirector(r, 0)), + RedirectKind::HereDoc | RedirectKind::HereDocStrip => String::new(), + } +} + +fn assignment_text(a: &Assignment) -> String { + let mut s = a.name.clone(); + if let Some(idx) = &a.index { + s.push('['); + s.push_str(idx); + s.push(']'); + } + s.push_str(if a.append { "+=" } else { "=" }); + match &a.value { + AssignmentValue::Scalar(w) => s.push_str(&word_text(w)), + AssignmentValue::Array(elems) => { + let e: Vec = elems.iter().map(word_text).collect(); + s.push('('); + s.push_str(&e.join(" ")); + s.push(')'); + } + } + s +} + +/// A word as written: its source text without line continuations, or a +/// reconstruction when the parser did not record the source. +fn word_text(w: &Word) -> String { + match &w.raw { + Some(raw) => strip_line_continuations(raw), + None => reconstruct_word(w), + } +} + +/// Bash's lexer drops backslash-newline outside single quotes. +fn strip_line_continuations(raw: &str) -> String { + if !raw.contains("\\\n") { + return raw.to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + let mut in_single = false; + let mut in_double = false; + while let Some(c) = chars.next() { + match c { + '\'' if !in_double => { + in_single = !in_single; + out.push(c); + } + '"' if !in_single => { + in_double = !in_double; + out.push(c); + } + '\\' if !in_single => { + if chars.peek() == Some(&'\n') { + chars.next(); + } else { + out.push(c); + if let Some(n) = chars.next() { + out.push(n); + } + } + } + _ => out.push(c), + } + } + out +} + +fn reconstruct_word(w: &Word) -> String { + let mut s = String::new(); + for part in &w.parts { + match part { + WordPart::Literal(t) => s.push_str(t), + WordPart::CommandSubstitution(cmds) => { + s.push_str("$("); + s.push_str(&inline_commands(cmds)); + s.push(')'); + } + WordPart::ProcessSubstitution { commands, is_input } => { + s.push_str(if *is_input { "<(" } else { ">(" }); + s.push_str(&inline_commands(commands)); + s.push(')'); + } + other => { + let single = Word { + parts: vec![other.clone()], + quoted: false, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: None, + }; + s.push_str(&single.to_string()); + } + } + } + if w.quoted && w.parts.iter().all(|p| matches!(p, WordPart::Literal(_))) { + return super::raw::single_quote(&s); + } + s +} + +/// Commands printed on one line (inside `$(...)` reconstructions). +fn inline_commands(cmds: &[Command]) -> String { + let mut p = Printer::default(); + p.print_list(cmds); + p.print_deferred_if_any(""); + p.out +} + +/// `[[ ... ]]` operands, as bash's `print_cond_node` prints its parse: +/// a lone operand becomes `-n WORD`. +fn cond_text(toks: &[String]) -> String { + let mut pos = 0; + let mut out = Vec::new(); + cond_or(toks, &mut pos, &mut out); + // Anything the grammar did not consume prints as written. + out.extend(toks[pos.min(toks.len())..].iter().cloned()); + out.join(" ") +} + +fn cond_or(toks: &[String], pos: &mut usize, out: &mut Vec) { + cond_and(toks, pos, out); + while toks.get(*pos).map(String::as_str) == Some("||") { + out.push("||".into()); + *pos += 1; + cond_and(toks, pos, out); + } +} + +fn cond_and(toks: &[String], pos: &mut usize, out: &mut Vec) { + cond_term(toks, pos, out); + while toks.get(*pos).map(String::as_str) == Some("&&") { + out.push("&&".into()); + *pos += 1; + cond_term(toks, pos, out); + } +} + +fn is_cond_unary(op: &str) -> bool { + matches!( + op, + "-a" | "-b" + | "-c" + | "-d" + | "-e" + | "-f" + | "-g" + | "-h" + | "-k" + | "-p" + | "-r" + | "-s" + | "-t" + | "-u" + | "-w" + | "-x" + | "-O" + | "-G" + | "-L" + | "-S" + | "-N" + | "-n" + | "-z" + | "-o" + | "-v" + | "-R" + ) +} + +fn is_cond_binary(op: &str) -> bool { + matches!( + op, + "==" | "=" + | "!=" + | "<" + | ">" + | "=~" + | "-eq" + | "-ne" + | "-lt" + | "-le" + | "-gt" + | "-ge" + | "-nt" + | "-ot" + | "-ef" + ) +} + +fn cond_term(toks: &[String], pos: &mut usize, out: &mut Vec) { + let Some(tok) = toks.get(*pos) else { + return; + }; + match tok.as_str() { + "!" => { + out.push("!".into()); + *pos += 1; + cond_term(toks, pos, out); + } + "(" => { + out.push("(".into()); + *pos += 1; + cond_or(toks, pos, out); + if toks.get(*pos).map(String::as_str) == Some(")") { + *pos += 1; + } + out.push(")".into()); + } + _ => { + let next = toks.get(*pos + 1).map(String::as_str); + if next.is_some_and(is_cond_binary) { + out.push(tok.clone()); + out.push(toks[*pos + 1].clone()); + if let Some(rhs) = toks.get(*pos + 2) { + out.push(rhs.clone()); + } + *pos += 3; + } else if is_cond_unary(tok) && next.is_some_and(|n| n != "&&" && n != "||" && n != ")") + { + out.push(tok.clone()); + out.push(toks[*pos + 1].clone()); + *pos += 2; + } else { + out.push("-n".into()); + out.push(tok.clone()); + *pos += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Parser; + + fn print_fn(src: &str) -> String { + let script = Parser::new(src).parse().expect("parses"); + let Some(Command::Function(f)) = script.commands.first() else { + panic!("expected a function definition"); + }; + function_string(&f.name, &f.body) + } + + #[test] + fn simple_body_keeps_words_as_written() { + assert_eq!( + print_fn("f() { echo 'a b' \"$x\" \\$y >out; }"), + "f () \n{ \n echo 'a b' \"$x\" \\$y > out\n}" + ); + } + + #[test] + fn elif_prints_as_nested_else_if() { + assert_eq!( + print_fn("f() { if a; then b; elif c; then d; fi; }"), + "f () \n{ \n if a; then\n b;\n else\n if c; then\n d;\n fi;\n fi\n}" + ); + } + + #[test] + fn heredoc_body_follows_its_line() { + assert_eq!( + print_fn("f() { cat <<'E'\n$x\nE\necho z; }"), + "f () \n{ \n cat <<'E'\n$x\nE\n\n echo z\n}" + ); + } + + #[test] + fn lone_cond_operand_gets_dash_n() { + assert_eq!( + print_fn("f() { [[ x ]]; [[ ! ( a == b ) ]]; }"), + "f () \n{ \n [[ -n x ]];\n [[ ! ( a == b ) ]]\n}" + ); + } + + #[test] + fn pipe_both_prints_as_dup_redirect() { + assert_eq!(print_fn("f() { a |& b; }"), "f () \n{ \n a 2>&1 | b\n}"); + } + + #[test] + fn reserved_word_name_gets_function_keyword() { + let body = Parser::new("{ :; }").parse().expect("parses").commands[0].clone(); + assert!(function_string("if", &body).starts_with("function if () \n")); + } + + #[test] + fn word_without_source_text_is_reconstructed() { + assert_eq!(word_text(&Word::literal("plain")), "plain"); + assert_eq!(word_text(&Word::quoted_literal("a b")), "'a b'"); + } + + #[test] + fn line_continuation_dropped_outside_single_quotes() { + assert_eq!(strip_line_continuations("a\\\nb"), "ab"); + assert_eq!(strip_line_continuations("'a\\\nb'"), "'a\\\nb'"); + } + + #[test] + fn empty_arith_for_clause_reads_one() { + assert_eq!(arith_for_part(" "), "1"); + assert_eq!(arith_for_part(" i < 3 "), "i < 3 "); + } + + #[test] + fn top_level_words_do_not_keep_source_text() { + // Only function bodies pay for recorded source text. + let script = Parser::new("echo 'a'").parse().expect("parses"); + let Some(Command::Simple(sc)) = script.commands.first() else { + panic!("expected a simple command"); + }; + assert!(sc.args[0].raw.is_none()); + } +} diff --git a/crates/bashkit/src/parser/raw.rs b/crates/bashkit/src/parser/raw.rs new file mode 100644 index 000000000..7f760d0c5 --- /dev/null +++ b/crates/bashkit/src/parser/raw.rs @@ -0,0 +1,151 @@ +//! Helpers for words kept as written in the source. +//! +//! Important decision: bash prints function bodies (`type`, `declare -f`) +//! with each word exactly as written, so the parser records word source text +//! inside function definitions (`Word::raw`). These helpers derive the +//! source-level pieces the parser cannot get from decoded tokens. + +/// Quote-remove a here-document delimiter as written; the bool reports +/// whether any part of it was quoted (bash then reads the body literally). +pub(crate) fn heredoc_eof_from_raw(raw: &str) -> (String, bool) { + let mut eof = String::new(); + let mut quoted = false; + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + quoted = true; + if let Some(n) = chars.next() { + eof.push(n); + } + } + '\'' => { + quoted = true; + for n in chars.by_ref() { + if n == '\'' { + break; + } + eof.push(n); + } + } + '"' => { + quoted = true; + while let Some(n) = chars.next() { + match n { + '"' => break, + '\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => { + if let Some(e) = chars.next() { + eof.push(e); + } + } + _ => eof.push(n), + } + } + } + _ => eof.push(c), + } + } + (eof, quoted) +} + +/// Single-quote `s` for shell output (`'it'\''s'`). +pub(crate) fn single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Split source text on unquoted blanks, keeping quotes, escapes and +/// `$(..)`/`${..}` groups intact (compound array elements as written). +/// Backslash-newline continuations are dropped, as the lexer does. +pub(crate) fn split_raw_words(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut depth = 0usize; + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + Some('\n') => {} + Some(n) => { + cur.push(c); + cur.push(n); + } + None => cur.push(c), + }, + '\'' => { + cur.push(c); + for n in chars.by_ref() { + cur.push(n); + if n == '\'' { + break; + } + } + } + '"' => { + cur.push(c); + while let Some(n) = chars.next() { + cur.push(n); + if n == '\\' { + if let Some(e) = chars.next() { + cur.push(e); + } + } else if n == '"' { + break; + } + } + } + '(' | '{' => { + depth += 1; + cur.push(c); + } + ')' | '}' => { + depth = depth.saturating_sub(1); + cur.push(c); + } + ' ' | '\t' | '\n' if depth == 0 => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + _ => cur.push(c), + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heredoc_delimiter_quote_removal() { + assert_eq!(heredoc_eof_from_raw("EOF"), ("EOF".into(), false)); + assert_eq!(heredoc_eof_from_raw("'EOF'"), ("EOF".into(), true)); + assert_eq!(heredoc_eof_from_raw("\\E\\OF"), ("EOF".into(), true)); + assert_eq!(heredoc_eof_from_raw("E\"OF\""), ("EOF".into(), true)); + } + + #[test] + fn single_quote_escapes_quotes() { + assert_eq!(single_quote("it's"), "'it'\\''s'"); + } + + #[test] + fn split_raw_words_keeps_quotes_and_groups() { + assert_eq!( + split_raw_words(" 1 2\n 3 "), + vec!["1".to_string(), "2".into(), "3".into()] + ); + assert_eq!( + split_raw_words("'a b' \"c d\" $(x y) e\\ f"), + vec![ + "'a b'".to_string(), + "\"c d\"".into(), + "$(x y)".into(), + "e\\ f".into() + ] + ); + } +} diff --git a/crates/bashkit/src/parser/tokens.rs b/crates/bashkit/src/parser/tokens.rs index a66e04e94..04d1b083e 100644 --- a/crates/bashkit/src/parser/tokens.rs +++ b/crates/bashkit/src/parser/tokens.rs @@ -42,6 +42,9 @@ pub enum Token { /// Pipe (|) Pipe, + /// Pipe with stderr (|&), shorthand for `2>&1 |` + PipeBoth, + /// And (&&) And, diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh index ec666839c..a9c580fa1 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh @@ -52,7 +52,6 @@ a) b) ) ### bashbox_compound_syntax_a_brace_inside_inside # a brace inside $(...) inside ${...} -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo ${x:-$(echo })} ### expect } @@ -60,7 +59,6 @@ echo ${x:-$(echo })} ### bashbox_compound_syntax_quoted_and_escaped_braces_inside # quoted and escaped braces inside ${...} -### skip: TODO bashbox corpus gap, bashkit output differs from real bash y=ab; echo ${x:-"}"} ${x:-\}} ${x:-'a}'} ${y/a/\}} "${y/b/'}'}" ### expect } } a} }b a} diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh index 35f532161..e4b787eaa 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh @@ -97,7 +97,6 @@ echo !x ### bashbox_lexer_double_brackets_followed_by_text_are_a_word # double brackets followed by text are a word -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo ]]x ### expect ]]x @@ -119,7 +118,6 @@ yes ### bashbox_lexer_is_a_shift_inside # << is a shift inside (( )) -### skip: TODO bashbox corpus gap, bashkit output differs from real bash (( x = 1 << 2 )) echo $x ### expect @@ -188,7 +186,6 @@ $x ### bashbox_lexer_backslash_escaped # backslash escaped -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; cat <<\E\OF $x EOF @@ -198,7 +195,6 @@ $x ### bashbox_lexer_partly_quoted # partly quoted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; cat < /tmp/o; cat /tmp/o ### bashbox_parser_c_style_for_keeps_spacing_of_each_clause # C-style for keeps spacing of each clause -### skip: TODO bashbox corpus gap, bashkit output differs from real bash for ((i = 3; i > 0; i = i - -1 - 2)); do echo $i; done 2>/dev/null ### expect 3 @@ -163,7 +162,6 @@ ab ### bashbox_parser_braces_brackets_and_bang_are_words_outside_command_position # braces, brackets and bang are words outside command position -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo } ]] ! ### expect } ]] ! diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh index eb691f26e..d444cd2cb 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh @@ -33,7 +33,6 @@ X=2; export Y=3; printf 'echo "$0 $# $1 [$X][$Y][$FOO]"; exit 3' > s; chmod +x s ### bashbox_script_file_the_script_s_changes_stay_in_the_child # the script's changes stay in the child -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'f(){ :; }; x=1; cd /; exit 4' > s; chmod 755 s; ./s; echo $? "[$x]"; type -t f; [ "$PWD" != / ] && echo kept ### expect 4 [] @@ -42,7 +41,6 @@ kept ### bashbox_script_file_the_script_reads_the_caller_s_stdin # the script reads the caller's stdin -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'read l; echo got $l' > s; chmod +x s; printf 'a\nb\n' | { ./s; read m; echo m=$m; } ### expect got a @@ -58,7 +56,6 @@ printf 'if then' > s; chmod +x s; ./s; echo $? ### bashbox_script_file_command_runs_a_script_file_too # command runs a script file too -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'echo in' > s; chmod +x s; command ./s ### expect in @@ -66,7 +63,6 @@ in ### bashbox_script_file_sourcing_dev_null_does_nothing # sourcing /dev/null does nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash source /dev/null; echo $? ### expect 0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh index 565a365c6..1e77f5aab 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh @@ -4,7 +4,6 @@ ### bashbox_syntax_error_eval_reports_and_returns_2 # eval reports and returns 2 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash eval fi; echo $? ### expect 2 @@ -12,7 +11,6 @@ eval fi; echo $? ### bashbox_syntax_error_a_sourced_file_reports_under_its_name_and_returns_2 # a sourced file reports under its name and returns 2 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo 'if then' > bad.sh; source ./bad.sh; echo $? ### expect 2 @@ -20,7 +18,6 @@ echo 'if then' > bad.sh; source ./bad.sh; echo $? ### bashbox_syntax_error_a_backquoted_substitution_is_parsed_when_it_runs_and_fails_w # a backquoted substitution is parsed when it runs and fails with 2 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=`fi`; echo "[$x] $?" ### expect [] 2 @@ -49,7 +46,6 @@ f(){ trap fi RETURN; }; f; echo $? ### bashbox_syntax_error_a_case_pattern # a case pattern -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=$(case a in a) echo A;; esac); echo "[$x]" ### expect [A] @@ -57,7 +53,6 @@ x=$(case a in a) echo A;; esac); echo "[$x]" ### bashbox_syntax_error_a_case_pattern_in_double_quotes # a case pattern in double quotes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo "$(case b in a) echo A;; b) echo B;; esac)" ### expect B @@ -91,7 +86,6 @@ EOF ### bashbox_syntax_error_a_process_substitution # a process substitution -### skip: TODO bashbox corpus gap, bashkit output differs from real bash cat <(case a in a) echo P;; esac) ### expect P @@ -99,7 +93,6 @@ P ### bashbox_syntax_error_nested_substitutions # nested substitutions -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $(echo $(case a in a) echo in;; esac) out) ### expect in out diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh index 9669209bc..f89bc15df 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh @@ -4,7 +4,6 @@ ### bashbox_type_printer_a_one_line_body # a one-line body -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f() { echo hi; } type f ### expect @@ -17,7 +16,6 @@ f () ### bashbox_type_printer_assignments_words_and_redirections # assignments, words and redirections -### skip: TODO bashbox corpus gap, bashkit output differs from real bash g() { a=1 b=2 cmd x 'y z' "q $v" >out 2>&1 /dev/null type f g @@ -230,7 +219,6 @@ g () ### bashbox_type_printer_here_documents_follow_the_line_that_opens_them # here-documents follow the line that opens them -### skip: TODO bashbox corpus gap, bashkit output differs from real bash n() { cat <f; echo hi one A @@ -358,7 +342,6 @@ EOF ### bashbox_type_printer_arrays_declarations_and_appends # arrays, declarations and appends -### skip: TODO bashbox corpus gap, bashkit output differs from real bash function o { local x=(1 2 3) y; declare -A z=([a]=1); x+=(4); echo "${x[@]}"; x=( 1 2 3 ); } type o @@ -376,7 +359,6 @@ o () ### bashbox_type_printer_time_substitutions_and_a_nested_function # time, substitutions and a nested function -### skip: TODO bashbox corpus gap, bashkit output differs from real bash p() { time ls; time -p ls; echo $(date) `date` $((1+2)) ${a:-b}; i() { inner; }; } type p ### expect @@ -395,7 +377,6 @@ p () ### bashbox_type_printer_redirections_of_the_function_itself # redirections of the function itself -### skip: TODO bashbox corpus gap, bashkit output differs from real bash q() { echo a; } > /dev/null 2>&1 type q ### expect @@ -408,7 +389,6 @@ q () ### bashbox_type_printer_comments_and_continuations_disappear # comments and continuations disappear -### skip: TODO bashbox corpus gap, bashkit output differs from real bash t() { echo # comment } v() { @@ -436,7 +416,6 @@ line" cont ### bashbox_type_printer_quoting_is_kept_as_written # quoting is kept as written -### skip: TODO bashbox corpus gap, bashkit output differs from real bash j() { echo a\ b "c\"d" 'e' \$x x=1; } type j ### expect @@ -449,7 +428,6 @@ j () ### bashbox_type_printer_declare_f_prints_every_function_sorted # declare -f prints every function, sorted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } a() { echo; } declare -f @@ -466,7 +444,6 @@ b () ### bashbox_type_printer_declare_f_with_names_failing_quietly_for_a_missing_one # declare -f with names, failing quietly for a missing one -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } declare -f b nope; echo "s=$?" ### expect @@ -479,7 +456,6 @@ s=1 ### bashbox_type_printer_declare_f_lists_names # declare -F lists names -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } a() { :; } declare -F; declare -F b nope a; echo "s=$?" @@ -493,7 +469,6 @@ s=1 ### bashbox_type_printer_type_reports_each_name # type reports each name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } type b nope; echo "s=$?"; type -t b ### expect diff --git a/crates/bashkit/tests/spec_cases/bash/type.test.sh b/crates/bashkit/tests/spec_cases/bash/type.test.sh index 575ac0977..f4da6ac14 100644 --- a/crates/bashkit/tests/spec_cases/bash/type.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/type.test.sh @@ -13,21 +13,22 @@ if is a shell keyword ### end ### type_function -### bash_diff: real bash also prints function body -# type reports functions +# type reports functions with their body myfunc() { echo hi; } type myfunc ### expect myfunc is a function +myfunc () +{ + echo hi +} ### end ### type_not_found ### exit_code:1 -### bash_diff: real bash writes error to stderr not stdout -# type exits 1 for unknown command +# type exits 1 for unknown command; the error goes to stderr type nonexistent_cmd_xyz ### expect -bash: type: nonexistent_cmd_xyz: not found ### end ### type_t_builtin diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index 42d267c74..c3c75becb 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -143,6 +143,59 @@ break, so words and further redirects after it parse normally: (space, tab, newline, `;`, `&`, `|`, `(`, `)`, or end); `[[:digit:]]*` is a bracket-expression word. +**Function printing (`type`, `declare -f`).** `parser/print_cmd.rs` ports +bash's `print_cmd.c` layout over the AST: `name () ` / `{ ` header, 4-space +indent, `;` + newline between commands, `elif` printed as a nested +`else if`, `for x;` as `for x in "$@";`, a lone `[[ x ]]` operand as `-n x`, +`|&` as `2>&1 |`, and here-document bodies deferred to the end of their line +(including bash's quirk where the `;` after the command following a heredoc +is dropped). Words print as written: while parsing a function definition the +parser turns on lexer raw capture, and every word built from a token keeps +its source text in `Word::raw` (top-level words leave it `None`, so ordinary +scripts pay nothing). Raw text is captured from consumed chars, not span +offsets, because heredoc rest-of-line re-injection makes offsets drift. +Compound arrays print their elements joined by one space, `(( ))` and +`for (( ))` keep their text (`ArithmeticForCommand::raw`), and +`Redirect::heredoc_delim` keeps the delimiter (`'EOF'` when quoted). A word +without source text is reconstructed from its parts (never `Debug`, +TM-INF-022). Function bodies may be any compound command with redirections +(`f() ( ... )`, `f() if ...; fi`, `f() { ...; } >log`). + +**Arithmetic commands.** `(( ... ))` and `for (( ...; ...; ... ))` are read +as raw text up to the matching `))` (`Lexer::read_dparen_body`), like bash's +`parse_dparen`, so `<<` is a shift (not a heredoc) and spacing survives; +evaluation uses `arith_exec_text` (trimmed, double quotes removed, as +`let "..."`). + +**Here-document delimiters.** The delimiter's source text decides quoting: +any `'`, `"` or `\` in it (`<<'E'`, `<<\E`, `<&1 | b` +(the `2>&1` is appended to `a`'s redirections). + +**Backquotes parse late.** Bash parses a backquoted command when it runs, so +`` x=`fi` `` fails only that substitution (status 2). The lexer converts +backquotes to `$(...)`; a body that does not parse becomes +`$(eval 'body')`, which reports the syntax error at run time and never runs +anything else. `$(...)` keeps rejecting the script at parse time. + +**Process substitution end.** `<(...)`/`>(...)` bodies end where the shared +`subst_scan` scanner closes them (`Lexer::skip_subst_body`), so +`<(case a in a) ...;; esac)` keeps the pattern's `)`; the body is still +sliced from the source, not copied (TM-DOS-021). + +**Substitutions in `${x:-...}` operands.** Operand expansion is sync, so the +`$(...)` parts of a `:-`/`:=`/`:?`/`:+` operand run ahead in +`prefetch_operand_substs` (async) and are consumed in order. They run only +when the operator uses the operand, so an unused default never executes. +`${x:-$(echo })}`: the lexer and `read_brace_operand` let a substitution own +its braces. + ## Alternatives Considered - PEG (pest, pom): rejected, bash grammar is context-sensitive, here-docs awkward, manual parser gives better errors. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 5066e5b58..87e66f2e3 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -108,6 +108,7 @@ Target: IEEE 1003.1-2024 Shell Command Language. | `time` | Reserved-word pipelines; `-p`, `--`, GNU `-f/-o/-a/-v`; elapsed time, exit status, and Bashkit counters | Host user/system CPU, RSS, and other process metrics are reported as `unavailable`, never fabricated | | `timeout` | Basic usage | `-k` kill timeout | | `bash`/`sh` | `-c`, `-n`, `-e`, `-x`, `-u`, `-f`, `-o option`, script files, stdin, `--version`, `--help` | Login shell | +| Expansion errors | `set -u` unbound variables stop the shell | `$((1/0))` evaluates to 0 and `${x!}` expands empty; bash reports them and drops the rest of the line the failing command ends on (`bashbox_line_number_an_expansion_error_drops_the_rest_of_the_line_it_ends_on`, skipped) | | Brace expansion | Runs before other expansions on unquoted literal text; lists, nesting, ranges with step and zero padding, quoted/escaped braces and commas | Works on parsed words, not raw text: `$v{1,2}` expands `$v` then appends `1`/`2` (bash reads `$v1`, `$v2`); write `${v}` | ## Builtins