diff --git a/NUCLEIC_FORK.md b/NUCLEIC_FORK.md index 4bd5d84..78d88bc 100644 --- a/NUCLEIC_FORK.md +++ b/NUCLEIC_FORK.md @@ -11,7 +11,16 @@ Every fork change is marked with a `// nash:` comment. Current patches: | --- | --- | | `brush-shell/src/productinfo.rs` | `PRODUCT_NAME` → `nash`; display string identifies the fork | | `brush-shell/src/args.rs` | usage/version strings rebranded | -| `brush-core/src/expansion.rs` | corpus divergence D1: added `ExpansionPiece::LiteralText` — literal unquoted text is never field-split (bash splits only expansion results) but keeps glob characters active. Upstream candidate. | +| `brush-core/src/expansion.rs` | corpus divergence D1: added `ExpansionPiece::LiteralText` — literal unquoted text is never field-split (bash splits only expansion results) but keeps glob characters active. Parameter-expansion substitutions (`${v:-word}` etc.) convert back to splittable at the expansion boundary; `ExpanderOptions.field_split_literal_text` lets data-string callers opt in. Upstream candidate. | +| `brush-core/src/completion.rs` | `compgen -W` word list opts into `field_split_literal_text` (the -W string is data) | +| `brush-shell/tests/cases/compat/ifs.yaml` | removed `known_failure` from 3 IFS tests the D1 fix repairs | + +Verification (this container): brush compat suite `2067 ran: 1684 succeeded, 6 +failed, 377 known to fail, 38 skipped` — the 6 failures are identical to a +pristine `brush-shell-v0.4.0` baseline run (environmental: ANSI-C quote and +`echo -e` oracle differences, syntax-error formatting, missing `hexdump`), i.e. +zero fork-caused regressions; +3 successes vs baseline are the repaired IFS +tests. nash corpus: 94/94. Planned (M1, per docs/NASH.md §4.2): `brush-core` `Gate` trait (verdict-shaped observation hooks) — the only substantial divergence from upstream. diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs index 9884a8c..de75a53 100644 --- a/brush-core/src/completion.rs +++ b/brush-core/src/completion.rs @@ -308,6 +308,8 @@ impl Spec { // and field splitting but NOT pathname expansion (globbing). let options = crate::expansion::ExpanderOptions { pathname_expand: false, + // nash: the -W string is data — its literal text is split too. + field_split_literal_text: true, ..Default::default() }; let words = crate::expansion::full_expand_and_split_word_with_options( diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs index c8dfe04..d6a7385 100644 --- a/brush-core/src/expansion.rs +++ b/brush-core/src/expansion.rs @@ -37,6 +37,11 @@ pub(crate) struct ExpanderOptions { /// Whether to perform pathname expansion (globbing). If disabled, glob patterns /// are returned as literal strings. pub pathname_expand: bool, + // nash: whether literal (unquoted, non-expansion) text is also subject to + // field splitting. Off by default per POSIX/bash (only expansion results + // split); enabled by callers whose input string is *data*, e.g. the + // `compgen -W` word list. + pub field_split_literal_text: bool, } impl Default for ExpanderOptions { @@ -46,6 +51,7 @@ impl Default for ExpanderOptions { brace_expand: true, execute_command_substitutions: true, pathname_expand: true, + field_split_literal_text: false, } } } @@ -333,6 +339,15 @@ impl ExpansionPiece { Self::LiteralText(s) => Self::Unsplittable(s), } } + + // nash: opt literal text into field splitting (see + // `ExpanderOptions::field_split_literal_text`). Quoted pieces stay intact. + fn make_literal_splittable(self) -> Self { + match self { + Self::LiteralText(s) => Self::Splittable(s), + other => other, + } + } } enum ParameterState { @@ -532,6 +547,8 @@ struct WordExpander<'a, SE: extensions::ShellExtensions> { in_double_quotes: bool, /// Whether to use heredoc expansion semantics (literal quotes, no brace expansion). heredoc_mode: bool, + // nash: see `ExpanderOptions::field_split_literal_text`. + field_split_literal_text: bool, } impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { @@ -547,6 +564,7 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { disable_pathname_expansion: false, in_double_quotes: false, heredoc_mode: false, + field_split_literal_text: false, } } @@ -572,6 +590,7 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { disable_pathname_expansion: !options.pathname_expand, in_double_quotes: false, heredoc_mode: false, + field_split_literal_text: options.field_split_literal_text, } } @@ -802,6 +821,13 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { // Go through the fields we have so far. for existing_field in expansion.fields { for piece in existing_field.0 { + // nash: callers whose input is data (e.g. compgen -W) opt + // literal text into splitting. + let piece = if self.field_split_literal_text { + piece.make_literal_splittable() + } else { + piece + }; match piece { // nash: literal text is never field-split (only expansion // results are), but stays a distinct piece so its glob @@ -937,7 +963,19 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { ExpansionPiece::Unsplittable(self.expand_tilde_expression(&tilde_expr)?), ), brush_parser::word::WordPiece::ParameterExpansion(p) => { - self.expand_parameter_expr(p).await? + // nash: whatever a parameter expansion substitutes (including + // literal text from ${v:-default} / ${v:+alt} words) is an + // expansion *result*, so it is subject to field splitting. + // Quoted pieces inside remain Unsplittable. + let mut expansion = self.expand_parameter_expr(p).await?; + for field in &mut expansion.fields { + for piece in &mut field.0 { + if let ExpansionPiece::LiteralText(s) = piece { + *piece = ExpansionPiece::Splittable(std::mem::take(s)); + } + } + } + expansion } brush_parser::word::WordPiece::BackquotedCommandSubstitution(s) | brush_parser::word::WordPiece::CommandSubstitution(s) => { diff --git a/brush-shell/tests/cases/compat/ifs.yaml b/brush-shell/tests/cases/compat/ifs.yaml index 7b04241..64f6fb9 100644 --- a/brush-shell/tests/cases/compat/ifs.yaml +++ b/brush-shell/tests/cases/compat/ifs.yaml @@ -219,7 +219,7 @@ cases: echo "After: [$IFS]" - name: "IFS does not affect for loop literal words" - known_failure: true + # nash: fixed by ExpansionPiece::LiteralText (literal words are not field-split) stdin: | IFS=':' for word in a:b:c; do @@ -249,7 +249,7 @@ cases: echo "Quoted: $var" - name: "IFS with partially quoted expansion" - known_failure: true + # nash: fixed by ExpansionPiece::LiteralText (literal words are not field-split) stdin: | IFS=':' var="a:b:c" @@ -309,7 +309,7 @@ cases: echo "[$1]" - name: "IFS with glob pattern literal" - known_failure: true + # nash: fixed by ExpansionPiece::LiteralText (literal words are not field-split) stdin: | IFS=':' # Glob patterns as literals shouldn't split