From e0eac26d27d968fec9cc49ad1afa0509e320a29c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:26:26 +0000 Subject: [PATCH 1/2] fix(interpreter): expand array literals like command words Array literal words now get brace and pathname expansion (nullglob honored, quoted words literal), and [i]=value elements set that index. command -v accepts several names and succeeds when any is found. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/interpreter/mod.rs | 146 ++++++++++++++---- .../bash/array-literal-expansion.test.sh | 63 ++++++++ knowledge/log.md | 1 + 3 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/array-literal-expansion.test.sh diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index e1beb5b14..b83114ba2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -6587,7 +6587,34 @@ impl Interpreter { 0 }; - 'array_words: for word in words.iter() { + // Brace expansion runs first, on the unexpanded word, as for + // command arguments; pathname expansion runs on the fields below. + let mut braced_words: Vec = Vec::with_capacity(words.len()); + for w in words.iter() { + match self.brace_expand_word(w) { + Some(ws) => braced_words.extend(ws), + None => braced_words.push(w.clone()), + } + } + + 'array_words: for word in braced_words.iter() { + // `[i]=value` sets one element; the value gets no field + // splitting or pathname expansion. + if let Some((index, value_word)) = split_indexed_element(word) { + let i = self.evaluate_arithmetic_with_assign(&index); + let value = self.expand_word(&value_word).await?; + if i < 0 { + continue; + } + let i = i as usize; + if !next_arr.contains_key(&i) && next_arr.len() >= max_new_entries { + break 'array_words; + } + next_arr.insert(i, value); + idx = i + 1; + continue; + } + let globs = !word.quoted || word.has_unquoted_glob; let is_unquoted_expansion = !word.quoted && word.parts.iter().any(|p| { matches!( @@ -6621,8 +6648,13 @@ impl Interpreter { } let expanded = self.expand_word(word).await?; for field in self.ifs_split_limited(&expanded, remaining)? { - next_arr.insert(idx, field); - idx += 1; + for item in self.glob_array_field(field, word, globs).await { + if next_arr.len() >= max_new_entries { + break 'array_words; + } + next_arr.insert(idx, item); + idx += 1; + } } if next_arr.len() >= max_new_entries { break 'array_words; @@ -6637,11 +6669,13 @@ impl Interpreter { } } else { let value = self.expand_word(word).await?; - if next_arr.len() >= max_new_entries { - break; + for item in self.glob_array_field(value, word, globs).await { + if next_arr.len() >= max_new_entries { + break 'array_words; + } + next_arr.insert(idx, item); + idx += 1; } - next_arr.insert(idx, value); - idx += 1; } } @@ -6652,6 +6686,23 @@ impl Interpreter { Ok(()) } + /// Pathname expansion for one array-literal field. A failglob miss keeps the + /// literal field. + // WTF: bash aborts the assignment with "no match" under failglob; assignment + // expansion has no error channel here, so the pattern is stored as-is. + async fn glob_array_field(&mut self, field: String, word: &Word, globs: bool) -> Vec { + if !globs { + return vec![field]; + } + match self + .expand_glob_item(&field, word.quoted && word.has_unquoted_glob) + .await + { + Ok(items) => items, + Err(_) => vec![field], + } + } + /// Try alias expansion. Returns `Some(result)` if alias was expanded, `None` otherwise. async fn try_alias_expansion( &mut self, @@ -9557,31 +9608,30 @@ impl Interpreter { match mode { 'v' => { - // command -v: print name/path if it's a known command - let registered = self.builtins.contains_key(cmd_name.as_str()) - || is_dispatch_only_builtin(cmd_name) - || self.has_host_builtin(cmd_name); - let output = if self.scoped.functions.contains_key(cmd_name.as_str()) - || is_keyword(cmd_name) - || (registered && builtins::BASH_BUILTIN_NAMES.contains(&cmd_name.as_str())) - { - Some(cmd_name.to_string()) - } else if let Some(path) = self.resolve_command_path(cmd_name).await { - Some(path) - } else { - registered.then(|| cmd_name.to_string()) - }; - let mut result = if let Some(name) = output { - ExecResult::ok(format!("{}\n", name)) - } else { - ExecResult { - stdout: crate::StreamData::new(), - stderr: crate::StreamData::new(), - exit_code: 1, - control_flow: crate::interpreter::ControlFlow::None, - ..Default::default() + // command -v: print the name/path of each known command; + // status 0 when any was found (bash). + let mut out = String::new(); + for cmd_name in &args[cmd_args_start..] { + let registered = self.builtins.contains_key(cmd_name.as_str()) + || is_dispatch_only_builtin(cmd_name) + || self.has_host_builtin(cmd_name); + let found = if self.scoped.functions.contains_key(cmd_name.as_str()) + || is_keyword(cmd_name) + || (registered && builtins::BASH_BUILTIN_NAMES.contains(&cmd_name.as_str())) + { + Some(cmd_name.to_string()) + } else if let Some(path) = self.resolve_command_path(cmd_name).await { + Some(path) + } else { + registered.then(|| cmd_name.to_string()) + }; + if let Some(name) = found { + out.push_str(&name); + out.push('\n'); } - }; + } + let code = if out.is_empty() { 1 } else { 0 }; + let mut result = ExecResult::with_code(out, code); result = self.apply_redirections(result, redirects).await?; Ok(result) } @@ -11720,6 +11770,40 @@ fn builtin_usage_error(msg: &str) -> ExecResult { ExecResult::err(msg, 2) } +/// Split an unquoted `[index]=value` array-literal element into its index text +/// and a word for the value. +fn split_indexed_element(word: &Word) -> Option<(String, Word)> { + let WordPart::Literal(first) = word.parts.first()? else { + return None; + }; + let first_quoted = word.part_quoted.first().copied().unwrap_or(word.quoted); + if first_quoted || !first.starts_with('[') { + return None; + } + let close = first.find("]=")?; + let index = first[1..close].to_string(); + let tail = &first[close + 2..]; + let mut parts = Vec::with_capacity(word.parts.len()); + let mut part_quoted = Vec::with_capacity(word.parts.len()); + if !tail.is_empty() { + parts.push(WordPart::Literal(tail.to_string())); + part_quoted.push(false); + } + for (i, p) in word.parts.iter().enumerate().skip(1) { + parts.push(p.clone()); + part_quoted.push(word.part_quoted.get(i).copied().unwrap_or(word.quoted)); + } + Some(( + index, + Word { + parts, + quoted: true, + has_unquoted_glob: false, + part_quoted, + }, + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/tests/spec_cases/bash/array-literal-expansion.test.sh b/crates/bashkit/tests/spec_cases/bash/array-literal-expansion.test.sh new file mode 100644 index 000000000..e6f6f5cb0 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/array-literal-expansion.test.sh @@ -0,0 +1,63 @@ +### array_literal_glob +cd /tmp && mkdir -p alg && cd alg && touch b.txt a.txt c.log +arr=(*.txt) +echo "${#arr[@]} ${arr[*]}" +### expect +2 a.txt b.txt +### end + +### array_literal_brace +arr=(x{1,2} {a..c}) +echo "${#arr[@]} ${arr[*]}" +### expect +5 x1 x2 a b c +### end + +### array_literal_quoted_kept +cd /tmp && mkdir -p alq && cd alq && touch a.txt +arr=("*.txt" '{1,2}' "x"*.txt) +printf '<%s>' "${arr[@]}"; echo +### expect +<*.txt><{1,2}> +### end + +### array_literal_nullglob +cd /tmp && mkdir -p aln && cd aln +shopt -s nullglob +arr=(*.zz); a=(x*.zz y); b=("*.zz") +echo "${#arr[@]} ${#a[@]} ${#b[@]}" +### expect +0 1 1 +### end + +### array_literal_unquoted_var_globs +cd /tmp && mkdir -p alv && cd alv && touch p1 p2 +pat='p*' +arr=($pat) +echo "${arr[*]}" +### expect +p1 p2 +### end + +### array_literal_indexed_unchanged +arr=([2]=x y [0]=z) +declare -p arr +### expect +declare -a arr=([0]="z" [2]="x" [3]="y") +### end + +### command_v_multiple_names +command -v echo cd +echo "rc=$?" +command -v nope_xyz echo +echo "rc=$?" +command -v nope_xyz nope2_xyz +echo "rc=$?" +### expect +echo +cd +rc=0 +echo +rc=0 +rc=1 +### end diff --git a/knowledge/log.md b/knowledge/log.md index 8f6a151af..80565d090 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -5,6 +5,7 @@ * **Fix**: jq input stream follows jq 1.7: `-n` leaves the stream to `input`/`inputs`, `-s` on empty input gives `[]`, each FILE is read separately (`input_filename`, `input_line_number` as jq counts lines), a missing file is reported and skipped (exit 2), a runtime error prints `jq: error (at FILE:LINE): ...` and moves on to the next input, `input` fails with `No more inputs`, `-e` uses the last output, values before a JSON syntax error are processed. Debian-oracle jq score 67.5% to 74.2%. * **Fix**: awk `for (k in a)` without `PROCINFO["sorted_in"]` now visits keys in gawk's order (integer arrays ascending with other keys first; string keys in gawk's hash-bucket order), so unsorted loops print like Debian awk. All gawk cases in the Debian-oracle awk set now pass (L-AWK-001 narrowed to post-`delete` order). * **Feature**: awk rewritten to target gawk 5.2 (Debian's `awk`). New lexer, parser, async evaluator, ERE translation with POSIX leftmost-longest matching, gawk number formatting and `printf`, gawk's `rand()` sequence, `substr`/`getline`/`typeof` edge cases, `-f`/`-e`/`--` options and gawk's error messages. `system()`, `print | cmd` and `cmd | getline` now run through the sandbox shell (execution-plan driver). Debian-oracle score for awk went from 51% to 94% of 260 cases; the remaining gawk gap is unsorted `for (k in a)` order (L-AWK-001). All resource caps kept; new TM-DOS-128 for awk commands. See foundations/builtins "awk". +* **Fix**: array literals expand like command words. `arr=(*.txt x{1,2})` now runs brace and pathname expansion (honoring `nullglob`; quoted words stay literal), and `arr=([2]=x y)` sets indexed elements instead of storing `[2]=x`. Under `failglob` an unmatched pattern is stored as-is rather than aborting (WTF in code). `command -v a b ...` reports every name found and fails only if none was. `declare -a d=(...)` is unchanged here (declare/local work is in a separate branch). * **Decision**: CPython's workload priority is agents running `python3` for scripted logic (processing a file, transforming data). Low-use stdlib modules are no longer shipped: `unittest`, `doctest`, profilers, `compileall`/`zipapp`, `dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and `bz2`/`lzma`/`compression.zstd` (no C codec in the guest anyway). Stdlib zip 3.58 to 3.16 MB. L-CPY-009 updated. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#workload-priority). * **Feature**: `yes` and `seq` stream into pipelines. As a producer stage they write chunks into the pipe as they go (`Context::stdout_stream`), so `yes | head -1` and `seq 1000000 | head -1` stop after the first chunk with `PIPESTATUS` `141 0` instead of running to the output cap and printing a truncation notice. Step 2 of the roadmap (streaming builtin I/O), starting with the generators. * **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x < Date: Wed, 7 Oct 2026 20:44:22 +0000 Subject: [PATCH 2/2] fix(interpreter): box array-literal assignment off the recursive stack The per-word brace, glob and [i]=v handling grew the simple-command future and overflowed the two-MiB recursion check under coverage. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/interpreter/mod.rs | 236 ++++++++++++++------------ 1 file changed, 125 insertions(+), 111 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index b83114ba2..988172bd2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -6563,127 +6563,141 @@ impl Interpreter { } } AssignmentValue::Array(words) => { - // Expand directly into the replacement array so word-split expansions cannot - // accumulate an unbounded temporary Vec before max_array_entries is enforced. - let arr_name = self.resolve_nameref(&assignment.name).to_string(); - let old_entries = self.scoped.arrays.get(&arr_name).map_or(0, |arr| arr.len()); - let remaining_entries = self - .memory_limits - .max_array_entries - .saturating_sub(self.memory_budget.array_entries); - let max_new_entries = old_entries.saturating_add(remaining_entries); - let mut next_arr = if assignment.append { - self.scoped - .arrays - .get(&arr_name) - .cloned() - .unwrap_or_default() - } else { - HashMap::new() - }; - let mut idx = if assignment.append { - next_arr.keys().max().map(|k| k + 1).unwrap_or(0) - } else { - 0 - }; + self.assign_array_literal(assignment, words).await?; + } + } + } + Ok(()) + } - // Brace expansion runs first, on the unexpanded word, as for - // command arguments; pathname expansion runs on the fields below. - let mut braced_words: Vec = Vec::with_capacity(words.len()); - for w in words.iter() { - match self.brace_expand_word(w) { - Some(ws) => braced_words.extend(ws), - None => braced_words.push(w.clone()), - } - } + /// `name=(words...)` / `name+=(words...)`. Boxed so the per-word brace, + /// glob and `[i]=v` handling stays off the recursive poll stack of every + /// simple command (TM-DOS-020 two-MiB stack budget). + fn assign_array_literal<'a>( + &'a mut self, + assignment: &'a Assignment, + words: &'a [Word], + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // Expand directly into the replacement array so word-split expansions cannot + // accumulate an unbounded temporary Vec before max_array_entries is enforced. + let arr_name = self.resolve_nameref(&assignment.name).to_string(); + let old_entries = self.scoped.arrays.get(&arr_name).map_or(0, |arr| arr.len()); + let remaining_entries = self + .memory_limits + .max_array_entries + .saturating_sub(self.memory_budget.array_entries); + let max_new_entries = old_entries.saturating_add(remaining_entries); + let mut next_arr = if assignment.append { + self.scoped + .arrays + .get(&arr_name) + .cloned() + .unwrap_or_default() + } else { + HashMap::new() + }; + let mut idx = if assignment.append { + next_arr.keys().max().map(|k| k + 1).unwrap_or(0) + } else { + 0 + }; - 'array_words: for word in braced_words.iter() { - // `[i]=value` sets one element; the value gets no field - // splitting or pathname expansion. - if let Some((index, value_word)) = split_indexed_element(word) { - let i = self.evaluate_arithmetic_with_assign(&index); - let value = self.expand_word(&value_word).await?; - if i < 0 { - continue; - } - let i = i as usize; - if !next_arr.contains_key(&i) && next_arr.len() >= max_new_entries { - break 'array_words; - } - next_arr.insert(i, value); - idx = i + 1; - continue; - } - let globs = !word.quoted || word.has_unquoted_glob; - let is_unquoted_expansion = !word.quoted - && word.parts.iter().any(|p| { - matches!( - p, - WordPart::Variable(_) - | WordPart::CommandSubstitution(_) - | WordPart::ArithmeticExpansion(_) - | WordPart::ParameterExpansion { .. } - | WordPart::ArrayAccess { .. } - ) - }); - // "${arr[@]}" or "$@" in array context should splat - // individual elements, not join into a single string. - let is_quoted_splat = word.quoted - && word.parts.len() == 1 - && matches!( - &word.parts[0], - WordPart::ArrayAccess { index, .. } if index == "@" - ); - let is_quoted_positional_splat = word.quoted - && word.parts.len() == 1 - && matches!( - &word.parts[0], - WordPart::Variable(name) if name == "@" - ); + // Brace expansion runs first, on the unexpanded word, as for + // command arguments; pathname expansion runs on the fields below. + let mut braced_words: Vec = Vec::with_capacity(words.len()); + for w in words.iter() { + match self.brace_expand_word(w) { + Some(ws) => braced_words.extend(ws), + None => braced_words.push(w.clone()), + } + } - if is_unquoted_expansion { - let remaining = max_new_entries.saturating_sub(next_arr.len()); - if remaining == 0 { - break; - } - let expanded = self.expand_word(word).await?; - for field in self.ifs_split_limited(&expanded, remaining)? { - for item in self.glob_array_field(field, word, globs).await { - if next_arr.len() >= max_new_entries { - break 'array_words; - } - next_arr.insert(idx, item); - idx += 1; - } - } + 'array_words: for word in braced_words.iter() { + // `[i]=value` sets one element; the value gets no field + // splitting or pathname expansion. + if let Some((index, value_word)) = split_indexed_element(word) { + let i = self.evaluate_arithmetic_with_assign(&index); + let value = self.expand_word(&value_word).await?; + if i < 0 { + continue; + } + let i = i as usize; + if !next_arr.contains_key(&i) && next_arr.len() >= max_new_entries { + break 'array_words; + } + next_arr.insert(i, value); + idx = i + 1; + continue; + } + let globs = !word.quoted || word.has_unquoted_glob; + let is_unquoted_expansion = !word.quoted + && word.parts.iter().any(|p| { + matches!( + p, + WordPart::Variable(_) + | WordPart::CommandSubstitution(_) + | WordPart::ArithmeticExpansion(_) + | WordPart::ParameterExpansion { .. } + | WordPart::ArrayAccess { .. } + ) + }); + // "${arr[@]}" or "$@" in array context should splat + // individual elements, not join into a single string. + let is_quoted_splat = word.quoted + && word.parts.len() == 1 + && matches!( + &word.parts[0], + WordPart::ArrayAccess { index, .. } if index == "@" + ); + let is_quoted_positional_splat = word.quoted + && word.parts.len() == 1 + && matches!( + &word.parts[0], + WordPart::Variable(name) if name == "@" + ); + + if is_unquoted_expansion { + let remaining = max_new_entries.saturating_sub(next_arr.len()); + if remaining == 0 { + break; + } + let expanded = self.expand_word(word).await?; + for field in self.ifs_split_limited(&expanded, remaining)? { + for item in self.glob_array_field(field, word, globs).await { if next_arr.len() >= max_new_entries { break 'array_words; } - } else if is_quoted_splat || is_quoted_positional_splat { - for field in self.expand_word_to_fields(word).await? { - if next_arr.len() >= max_new_entries { - break 'array_words; - } - next_arr.insert(idx, field); - idx += 1; - } - } else { - let value = self.expand_word(word).await?; - for item in self.glob_array_field(value, word, globs).await { - if next_arr.len() >= max_new_entries { - break 'array_words; - } - next_arr.insert(idx, item); - idx += 1; - } + next_arr.insert(idx, item); + idx += 1; } } - - let _ = self.insert_array_checked(arr_name, next_arr); + if next_arr.len() >= max_new_entries { + break 'array_words; + } + } else if is_quoted_splat || is_quoted_positional_splat { + for field in self.expand_word_to_fields(word).await? { + if next_arr.len() >= max_new_entries { + break 'array_words; + } + next_arr.insert(idx, field); + idx += 1; + } + } else { + let value = self.expand_word(word).await?; + for item in self.glob_array_field(value, word, globs).await { + if next_arr.len() >= max_new_entries { + break 'array_words; + } + next_arr.insert(idx, item); + idx += 1; + } } } - } - Ok(()) + + let _ = self.insert_array_checked(arr_name, next_arr); + Ok(()) + }) } /// Pathname expansion for one array-literal field. A failglob miss keeps the