From 5f072469b335d1ea1e17ab0b43d6024da7229fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 06:37:08 +0000 Subject: [PATCH 1/3] feat: implement brace expansion as strict subset of bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds brace expansion support to the shell, implementing a strict subset of bash's brace expansion functionality. Supported features: - Comma-separated lists: {a,b,c} → a b c - Numeric sequences: {1..10} → 1 2 3 4 5 6 7 8 9 10 - Character sequences: {a..z} → a b c ... z - Reverse sequences: {10..1} → 10 9 8 7 6 5 4 3 2 1 - Step sequences: {1..10..2} → 1 3 5 7 9 - Reverse step sequences: {10..1..2} → 10 8 6 4 2 - Empty elements: {a,,b} → a b - Quoted braces don't expand: "{a,b,c}" → {a,b,c} Implementation details: - Added BRACE_EXPANSION grammar rule to grammar.pest - Added BraceExpansion enum to parser.rs with List and Sequence variants - Added WordPart::BraceExpansion variant - Implemented expand_braces() and expand_sequence() functions - Added comprehensive test suite in brace_expansion.sh The implementation is a strict subset of bash, ensuring compatibility while keeping the implementation simple and maintainable. Fixes #242 --- crates/deno_task_shell/src/grammar.pest | 48 +++++++--- crates/deno_task_shell/src/parser.rs | 44 +++++++++ crates/deno_task_shell/src/shell/execute.rs | 101 ++++++++++++++++++++ crates/tests/test-data/brace_expansion.sh | 39 ++++++++ 4 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 crates/tests/test-data/brace_expansion.sh diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index 094e058..bc0ce87 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -9,24 +9,24 @@ INT = { ("+" | "-")? ~ ASCII_DIGIT+ } // Basic tokens QUOTED_WORD = { DOUBLE_QUOTED | SINGLE_QUOTED } -UNQUOTED_PENDING_WORD = ${ - (TILDE_PREFIX ~ (!(OPERATOR | WHITESPACE | NEWLINE) ~ ( - EXIT_STATUS | - UNQUOTED_ESCAPE_CHAR | +UNQUOTED_PENDING_WORD = ${ + (TILDE_PREFIX ~ (BRACE_EXPANSION | !(OPERATOR | WHITESPACE | NEWLINE) ~ ( + EXIT_STATUS | + UNQUOTED_ESCAPE_CHAR | "$" ~ ARITHMETIC_EXPRESSION | - SUB_COMMAND | - VARIABLE_EXPANSION | - UNQUOTED_CHAR | + SUB_COMMAND | + VARIABLE_EXPANSION | + UNQUOTED_CHAR | QUOTED_WORD ))*) - | - (!(OPERATOR | WHITESPACE | NEWLINE) ~ ( - EXIT_STATUS | - UNQUOTED_ESCAPE_CHAR | + | + (BRACE_EXPANSION | !(OPERATOR | WHITESPACE | NEWLINE) ~ ( + EXIT_STATUS | + UNQUOTED_ESCAPE_CHAR | "$" ~ ARITHMETIC_EXPRESSION | - SUB_COMMAND | - VARIABLE_EXPANSION | - UNQUOTED_CHAR | + SUB_COMMAND | + VARIABLE_EXPANSION | + UNQUOTED_CHAR | QUOTED_WORD ))+ } @@ -92,6 +92,26 @@ VARIABLE_EXPANSION = ${ ) } +BRACE_EXPANSION = ${ + "{" ~ (BRACE_SEQUENCE | BRACE_LIST) ~ "}" +} + +BRACE_SEQUENCE = ${ + BRACE_ELEMENT ~ ".." ~ BRACE_ELEMENT ~ (".." ~ BRACE_STEP)? +} + +BRACE_LIST = ${ + BRACE_ELEMENT ~ ("," ~ BRACE_ELEMENT)+ +} + +BRACE_ELEMENT = ${ + (!(OPERATOR | WHITESPACE | NEWLINE | "," | "}" | "{" | "." | "$" | "\\" | "\"" | "'") ~ ANY)* +} + +BRACE_STEP = ${ + ("+" | "-")? ~ ASCII_DIGIT+ +} + SPECIAL_PARAM = ${ ARGNUM | "@" | "#" | "?" | "$" | "*" } ARGNUM = ${ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* | "0" } VARIABLE = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } diff --git a/crates/deno_task_shell/src/parser.rs b/crates/deno_task_shell/src/parser.rs index 6c41ab5..dbe7beb 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -418,6 +418,15 @@ pub enum VariableModifier { AlternateValue(Word), } +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid brace expansion")] +pub enum BraceExpansion { + List(Vec), + Sequence { start: String, end: String, step: Option }, +} + #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr( feature = "serialization", @@ -439,6 +448,8 @@ pub enum WordPart { Arithmetic(Arithmetic), #[error("Invalid exit status")] ExitStatus, + #[error("Invalid brace expansion")] + BraceExpansion(BraceExpansion), } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] @@ -1432,6 +1443,10 @@ fn parse_word(pair: Pair) -> Result { parse_variable_expansion(part)?; parts.push(variable_expansion); } + Rule::BRACE_EXPANSION => { + let brace_expansion = parse_brace_expansion(part)?; + parts.push(WordPart::BraceExpansion(brace_expansion)); + } Rule::QUOTED_WORD => { let quoted = parse_quoted_word(part)?; parts.push(quoted); @@ -1822,6 +1837,35 @@ fn parse_variable_expansion(part: Pair) -> Result { Ok(WordPart::Variable(variable_name, parsed_modifier)) } +fn parse_brace_expansion(pair: Pair) -> Result { + let inner = pair.into_inner().next() + .ok_or_else(|| miette!("Expected brace expansion content"))?; + + match inner.as_rule() { + Rule::BRACE_LIST => { + let elements: Vec = inner.into_inner() + .map(|elem| elem.as_str().to_string()) + .collect(); + Ok(BraceExpansion::List(elements)) + } + Rule::BRACE_SEQUENCE => { + let mut parts = inner.into_inner(); + let start = parts.next() + .ok_or_else(|| miette!("Expected sequence start"))? + .as_str().to_string(); + let end = parts.next() + .ok_or_else(|| miette!("Expected sequence end"))? + .as_str().to_string(); + let step = parts.next().map(|s| { + s.as_str().parse::() + .unwrap_or(1) + }); + Ok(BraceExpansion::Sequence { start, end, step }) + } + _ => Err(miette!("Unexpected rule in brace expansion: {:?}", inner.as_rule())) + } +} + fn parse_tilde_prefix(pair: Pair) -> Result { let tilde_prefix_str = pair.as_str(); let user = if tilde_prefix_str.len() > 1 { diff --git a/crates/deno_task_shell/src/shell/execute.rs b/crates/deno_task_shell/src/shell/execute.rs index ed790a0..45bf75b 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -17,6 +17,7 @@ use tokio_util::sync::CancellationToken; use crate::parser::AssignmentOp; use crate::parser::BinaryOp; +use crate::parser::BraceExpansion; use crate::parser::CaseClause; use crate::parser::Condition; use crate::parser::ConditionInner; @@ -1875,6 +1876,98 @@ pub enum IsQuoted { No, } +/// Expands a brace expansion into a vector of strings +fn expand_braces(brace_expansion: &BraceExpansion) -> Result, Error> { + match brace_expansion { + BraceExpansion::List(elements) => { + Ok(elements.clone()) + } + BraceExpansion::Sequence { start, end, step } => { + expand_sequence(start, end, step.as_ref()) + } + } +} + +/// Expands a sequence like {1..10} or {a..z} or {1..10..2} +fn expand_sequence(start: &str, end: &str, step: Option<&i32>) -> Result, Error> { + // Try to parse as integers first + if let (Ok(start_num), Ok(end_num)) = (start.parse::(), end.parse::()) { + let is_reverse = start_num > end_num; + let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); + + // If step is provided and direction is reverse, negate the step + if step.is_some() && is_reverse && step_val > 0 { + step_val = -step_val; + } + // If step is provided and direction is forward, ensure step is positive + else if step.is_some() && !is_reverse && step_val < 0 { + step_val = -step_val; + } + + if step_val == 0 { + return Err(miette!("Invalid step value: 0")); + } + + let mut result = Vec::new(); + if step_val > 0 { + let mut current = start_num; + while current <= end_num { + result.push(current.to_string()); + current += step_val; + } + } else { + let mut current = start_num; + while current >= end_num { + result.push(current.to_string()); + current += step_val; + } + } + Ok(result) + } + // Try to parse as single characters + else if start.len() == 1 && end.len() == 1 { + let start_char = start.chars().next().unwrap(); + let end_char = end.chars().next().unwrap(); + let is_reverse = start_char > end_char; + let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); + + // If step is provided and direction is reverse, negate the step + if step.is_some() && is_reverse && step_val > 0 { + step_val = -step_val; + } + // If step is provided and direction is forward, ensure step is positive + else if step.is_some() && !is_reverse && step_val < 0 { + step_val = -step_val; + } + + if step_val == 0 { + return Err(miette!("Invalid step value: 0")); + } + + let mut result = Vec::new(); + if step_val > 0 { + let mut current = start_char as i32; + let end_val = end_char as i32; + while current <= end_val { + result.push((current as u8 as char).to_string()); + current += step_val; + } + } else { + let mut current = start_char as i32; + let end_val = end_char as i32; + while current >= end_val { + result.push((current as u8 as char).to_string()); + current += step_val; + } + } + Ok(result) + } + else { + // If it's not a valid sequence, return it as-is (bash behavior) + Ok(vec![format!("{{{}..{}}}", start, end)]) + } +} + fn evaluate_word_parts( parts: Vec, state: &mut ShellState, @@ -2101,6 +2194,14 @@ fn evaluate_word_parts( .push(TextPart::Text(exit_code.to_string())); continue; } + WordPart::BraceExpansion(brace_expansion) => { + let expanded = expand_braces(&brace_expansion)?; + Ok(Some(Text::new( + expanded.into_iter() + .map(|s| TextPart::Text(s)) + .collect() + ))) + } }; if let Ok(Some(text)) = evaluation_result_text { diff --git a/crates/tests/test-data/brace_expansion.sh b/crates/tests/test-data/brace_expansion.sh new file mode 100644 index 0000000..9192b5c --- /dev/null +++ b/crates/tests/test-data/brace_expansion.sh @@ -0,0 +1,39 @@ +# Basic comma-separated list +> echo {a,b,c} +a b c + +# Numeric sequence +> echo {1..5} +1 2 3 4 5 + +# Character sequence +> echo {a..e} +a b c d e + +# Reverse numeric sequence +> echo {5..1} +5 4 3 2 1 + +# Reverse character sequence +> echo {e..a} +e d c b a + +# Numeric sequence with step +> echo {1..10..2} +1 3 5 7 9 + +# Numeric sequence with step (reverse) +> echo {10..1..2} +10 8 6 4 2 + +# Empty elements in list +> echo {a,,b} +a b + +# Quoted braces - should not expand +> echo "{a,b,c}" +{a,b,c} + +# Mixed with other arguments +> echo start {a,b,c} end +start a b c end From e28c9dda2688b14768c71a216f8a99c4cacb6b74 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 11 Nov 2025 13:11:35 +0100 Subject: [PATCH 2/3] clippy fmt --- crates/deno_task_shell/src/parser.rs | 36 ++++++++++----- crates/deno_task_shell/src/shell/execute.rs | 51 +++++++++++---------- 2 files changed, 51 insertions(+), 36 deletions(-) diff --git a/crates/deno_task_shell/src/parser.rs b/crates/deno_task_shell/src/parser.rs index dbe7beb..d29f091 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -424,7 +424,11 @@ pub enum VariableModifier { #[error("Invalid brace expansion")] pub enum BraceExpansion { List(Vec), - Sequence { start: String, end: String, step: Option }, + Sequence { + start: String, + end: String, + step: Option, + }, } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] @@ -1838,31 +1842,39 @@ fn parse_variable_expansion(part: Pair) -> Result { } fn parse_brace_expansion(pair: Pair) -> Result { - let inner = pair.into_inner().next() + let inner = pair + .into_inner() + .next() .ok_or_else(|| miette!("Expected brace expansion content"))?; match inner.as_rule() { Rule::BRACE_LIST => { - let elements: Vec = inner.into_inner() + let elements: Vec = inner + .into_inner() .map(|elem| elem.as_str().to_string()) .collect(); Ok(BraceExpansion::List(elements)) } Rule::BRACE_SEQUENCE => { let mut parts = inner.into_inner(); - let start = parts.next() + let start = parts + .next() .ok_or_else(|| miette!("Expected sequence start"))? - .as_str().to_string(); - let end = parts.next() + .as_str() + .to_string(); + let end = parts + .next() .ok_or_else(|| miette!("Expected sequence end"))? - .as_str().to_string(); - let step = parts.next().map(|s| { - s.as_str().parse::() - .unwrap_or(1) - }); + .as_str() + .to_string(); + let step = + parts.next().map(|s| s.as_str().parse::().unwrap_or(1)); Ok(BraceExpansion::Sequence { start, end, step }) } - _ => Err(miette!("Unexpected rule in brace expansion: {:?}", inner.as_rule())) + _ => Err(miette!( + "Unexpected rule in brace expansion: {:?}", + inner.as_rule() + )), } } diff --git a/crates/deno_task_shell/src/shell/execute.rs b/crates/deno_task_shell/src/shell/execute.rs index 45bf75b..983dae0 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -1877,11 +1877,11 @@ pub enum IsQuoted { } /// Expands a brace expansion into a vector of strings -fn expand_braces(brace_expansion: &BraceExpansion) -> Result, Error> { +fn expand_braces( + brace_expansion: &BraceExpansion, +) -> Result, Error> { match brace_expansion { - BraceExpansion::List(elements) => { - Ok(elements.clone()) - } + BraceExpansion::List(elements) => Ok(elements.clone()), BraceExpansion::Sequence { start, end, step } => { expand_sequence(start, end, step.as_ref()) } @@ -1889,18 +1889,21 @@ fn expand_braces(brace_expansion: &BraceExpansion) -> Result, Error> } /// Expands a sequence like {1..10} or {a..z} or {1..10..2} -fn expand_sequence(start: &str, end: &str, step: Option<&i32>) -> Result, Error> { +fn expand_sequence( + start: &str, + end: &str, + step: Option<&i32>, +) -> Result, Error> { // Try to parse as integers first - if let (Ok(start_num), Ok(end_num)) = (start.parse::(), end.parse::()) { + if let (Ok(start_num), Ok(end_num)) = + (start.parse::(), end.parse::()) + { let is_reverse = start_num > end_num; - let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); + let mut step_val = + step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); - // If step is provided and direction is reverse, negate the step - if step.is_some() && is_reverse && step_val > 0 { - step_val = -step_val; - } - // If step is provided and direction is forward, ensure step is positive - else if step.is_some() && !is_reverse && step_val < 0 { + // If step is provided and its sign doesn't match the direction, flip it + if step.is_some() && (is_reverse != (step_val < 0)) { step_val = -step_val; } @@ -1929,14 +1932,14 @@ fn expand_sequence(start: &str, end: &str, step: Option<&i32>) -> Result end_char; - let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); + let mut step_val = + step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); // If step is provided and direction is reverse, negate the step - if step.is_some() && is_reverse && step_val > 0 { - step_val = -step_val; - } - // If step is provided and direction is forward, ensure step is positive - else if step.is_some() && !is_reverse && step_val < 0 { + // Or if step is provided and direction is forward, ensure step is positive + if step.is_some() && (is_reverse && step_val > 0) + || (!is_reverse && step_val < 0) + { step_val = -step_val; } @@ -1961,8 +1964,7 @@ fn expand_sequence(start: &str, end: &str, step: Option<&i32>) -> Result { let expanded = expand_braces(&brace_expansion)?; Ok(Some(Text::new( - expanded.into_iter() - .map(|s| TextPart::Text(s)) - .collect() + expanded + .into_iter() + .map(TextPart::Text) + .collect(), ))) } }; From ed7ee82ef99bab66f89f7f92c436e108f0ac2208 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 17 Mar 2026 13:43:01 +0100 Subject: [PATCH 3/3] fix up pr --- crates/deno_task_shell/src/grammar.pest | 2 +- crates/deno_task_shell/src/shell/execute.rs | 205 ++++++++++++-------- crates/tests/src/lib.rs | 136 +++++++++++++ 3 files changed, 263 insertions(+), 80 deletions(-) diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index bc0ce87..b781db0 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -105,7 +105,7 @@ BRACE_LIST = ${ } BRACE_ELEMENT = ${ - (!(OPERATOR | WHITESPACE | NEWLINE | "," | "}" | "{" | "." | "$" | "\\" | "\"" | "'") ~ ANY)* + (!(OPERATOR | WHITESPACE | NEWLINE | "," | "}" | "{" | ".." | "$" | "\\" | "\"" | "'") ~ ANY)* } BRACE_STEP = ${ diff --git a/crates/deno_task_shell/src/shell/execute.rs b/crates/deno_task_shell/src/shell/execute.rs index 983dae0..66807a8 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -1698,6 +1698,8 @@ pub enum EvaluateWordTextError { NoFilesMatched { pattern: String }, #[error("Failed to get home directory")] FailedToGetHomeDirectory(miette::Error), + #[error("{0}")] + FailedExpanding(String), } impl EvaluateWordTextError { @@ -1888,6 +1890,40 @@ fn expand_braces( } } +/// Generates a numeric range with the given step, handling direction automatically. +fn generate_range( + start: i32, + end: i32, + step: Option<&i32>, +) -> Result, Error> { + let is_reverse = start > end; + let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); + + // If step is provided and its sign doesn't match the direction, flip it + if step.is_some() && (is_reverse != (step_val < 0)) { + step_val = -step_val; + } + + if step_val == 0 { + return Err(miette!("Invalid step value: 0")); + } + + let mut result = Vec::new(); + let mut current = start; + if step_val > 0 { + while current <= end { + result.push(current); + current += step_val; + } + } else { + while current >= end { + result.push(current); + current += step_val; + } + } + Ok(result) +} + /// Expands a sequence like {1..10} or {a..z} or {1..10..2} fn expand_sequence( start: &str, @@ -1898,78 +1934,58 @@ fn expand_sequence( if let (Ok(start_num), Ok(end_num)) = (start.parse::(), end.parse::()) { - let is_reverse = start_num > end_num; - let mut step_val = - step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); - - // If step is provided and its sign doesn't match the direction, flip it - if step.is_some() && (is_reverse != (step_val < 0)) { - step_val = -step_val; - } - - if step_val == 0 { - return Err(miette!("Invalid step value: 0")); - } - - let mut result = Vec::new(); - if step_val > 0 { - let mut current = start_num; - while current <= end_num { - result.push(current.to_string()); - current += step_val; - } - } else { - let mut current = start_num; - while current >= end_num { - result.push(current.to_string()); - current += step_val; - } - } - Ok(result) + let values = generate_range(start_num, end_num, step)?; + Ok(values.into_iter().map(|v| v.to_string()).collect()) } // Try to parse as single characters else if start.len() == 1 && end.len() == 1 { - let start_char = start.chars().next().unwrap(); - let end_char = end.chars().next().unwrap(); - let is_reverse = start_char > end_char; - let mut step_val = - step.copied().unwrap_or(if is_reverse { -1 } else { 1 }); - - // If step is provided and direction is reverse, negate the step - // Or if step is provided and direction is forward, ensure step is positive - if step.is_some() && (is_reverse && step_val > 0) - || (!is_reverse && step_val < 0) - { - step_val = -step_val; - } - - if step_val == 0 { - return Err(miette!("Invalid step value: 0")); - } - - let mut result = Vec::new(); - if step_val > 0 { - let mut current = start_char as i32; - let end_val = end_char as i32; - while current <= end_val { - result.push((current as u8 as char).to_string()); - current += step_val; - } - } else { - let mut current = start_char as i32; - let end_val = end_char as i32; - while current >= end_val { - result.push((current as u8 as char).to_string()); - current += step_val; - } - } - Ok(result) + let start_char = start.chars().next().unwrap() as i32; + let end_char = end.chars().next().unwrap() as i32; + let values = generate_range(start_char, end_char, step)?; + Ok(values + .into_iter() + .filter_map(|v| char::from_u32(v as u32)) + .map(|c| c.to_string()) + .collect()) } else { // If it's not a valid sequence, return it as-is (bash behavior) Ok(vec![format!("{{{}..{}}}", start, end)]) } } +/// Pre-processes word parts to expand brace expansions at the word level. +/// This ensures prefixes and suffixes are correctly combined with each +/// expanded element (e.g., `pre{a,b}post` → `preapost`, `prebpost`). +fn expand_brace_word_parts( + parts: Vec, +) -> Result>, Error> { + let brace_idx = parts + .iter() + .position(|p| matches!(p, WordPart::BraceExpansion(_))); + + let Some(idx) = brace_idx else { + return Ok(vec![parts]); + }; + + let prefix = &parts[..idx]; + let suffix = &parts[idx + 1..]; + + let WordPart::BraceExpansion(ref expansion) = parts[idx] else { + unreachable!() + }; + + let expanded = expand_braces(expansion)?; + let mut result = Vec::new(); + for element in expanded { + let mut word_parts: Vec = prefix.to_vec(); + word_parts.push(WordPart::Text(element)); + word_parts.extend(suffix.iter().cloned()); + // Recursively expand in case there are more brace expansions + result.extend(expand_brace_word_parts(word_parts)?); + } + Ok(result) +} + fn evaluate_word_parts( parts: Vec, state: &mut ShellState, @@ -2196,14 +2212,12 @@ fn evaluate_word_parts( .push(TextPart::Text(exit_code.to_string())); continue; } - WordPart::BraceExpansion(brace_expansion) => { - let expanded = expand_braces(&brace_expansion)?; - Ok(Some(Text::new( - expanded - .into_iter() - .map(TextPart::Text) - .collect(), - ))) + WordPart::BraceExpansion(_) => { + // Brace expansions are handled at the word level + // in evaluate_word_parts before this function is called. + unreachable!( + "BraceExpansion should be expanded before evaluation" + ) } }; @@ -2254,14 +2268,47 @@ fn evaluate_word_parts( .boxed_local() } - evaluate_word_parts_inner( - parts, - IsQuoted::No, - eval_glob, - state, - stdin, - stderr, - ) + // Expand brace expansions at the word level first, so that + // prefixes/suffixes combine correctly with each expanded element. + let expanded_words = match expand_brace_word_parts(parts) { + Ok(words) => words, + Err(e) => { + return async move { + Err(EvaluateWordTextError::FailedExpanding(e.to_string())) + } + .boxed_local() + } + }; + + if expanded_words.len() <= 1 { + let word_parts = expanded_words.into_iter().next().unwrap_or_default(); + evaluate_word_parts_inner( + word_parts, + IsQuoted::No, + eval_glob, + state, + stdin, + stderr, + ) + } else { + async move { + let mut result = WordPartsResult::new(Vec::new(), Vec::new()); + for word_parts in expanded_words { + let word_result = evaluate_word_parts_inner( + word_parts, + IsQuoted::No, + eval_glob, + state, + stdin.clone(), + stderr.clone(), + ) + .await?; + result.extend(word_result); + } + Ok(result) + } + .boxed_local() + } } async fn evaluate_command_substitution( diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 3123f38..6e44111 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -1490,6 +1490,142 @@ async fn test_comma_unquoted() { .await; } +#[tokio::test] +async fn brace_expansion() { + // Basic comma-separated list + TestBuilder::new() + .command("echo {a,b,c}") + .assert_stdout("a b c\n") + .run() + .await; + + // Numeric sequence + TestBuilder::new() + .command("echo {1..5}") + .assert_stdout("1 2 3 4 5\n") + .run() + .await; + + // Character sequence + TestBuilder::new() + .command("echo {a..e}") + .assert_stdout("a b c d e\n") + .run() + .await; + + // Reverse numeric sequence + TestBuilder::new() + .command("echo {5..1}") + .assert_stdout("5 4 3 2 1\n") + .run() + .await; + + // Reverse character sequence + TestBuilder::new() + .command("echo {e..a}") + .assert_stdout("e d c b a\n") + .run() + .await; + + // Numeric sequence with step + TestBuilder::new() + .command("echo {1..10..2}") + .assert_stdout("1 3 5 7 9\n") + .run() + .await; + + // Numeric sequence with step (reverse) + TestBuilder::new() + .command("echo {10..1..2}") + .assert_stdout("10 8 6 4 2\n") + .run() + .await; + + // Character sequence with step + TestBuilder::new() + .command("echo {a..z..5}") + .assert_stdout("a f k p u z\n") + .run() + .await; + + // Empty elements in list + TestBuilder::new() + .command("echo {a,,b}") + .assert_stdout("a b\n") + .run() + .await; + + // Quoted braces should not expand + TestBuilder::new() + .command(r#"echo "{a,b,c}""#) + .assert_stdout("{a,b,c}\n") + .run() + .await; + + // Mixed with other arguments + TestBuilder::new() + .command("echo start {a,b,c} end") + .assert_stdout("start a b c end\n") + .run() + .await; + + // Prefix with brace expansion + TestBuilder::new() + .command("echo pre{a,b,c}") + .assert_stdout("prea preb prec\n") + .run() + .await; + + // Suffix with brace expansion + TestBuilder::new() + .command("echo {a,b,c}post") + .assert_stdout("apost bpost cpost\n") + .run() + .await; + + // Prefix and suffix + TestBuilder::new() + .command("echo pre{a,b,c}post") + .assert_stdout("preapost prebpost precpost\n") + .run() + .await; + + // Multiple brace expansions (cartesian product) + TestBuilder::new() + .command("echo {a,b}{1,2}") + .assert_stdout("a1 a2 b1 b2\n") + .run() + .await; + + // Brace expansion with dots in elements (e.g., filenames) + TestBuilder::new() + .command("echo {foo.txt,bar.txt}") + .assert_stdout("foo.txt bar.txt\n") + .run() + .await; + + // Numeric sequence with prefix/suffix + TestBuilder::new() + .command("echo file{1..3}.txt") + .assert_stdout("file1.txt file2.txt file3.txt\n") + .run() + .await; + + // Brace expansion with hyphens in elements + TestBuilder::new() + .command("echo {foo-bar,baz-qux}") + .assert_stdout("foo-bar baz-qux\n") + .run() + .await; + + // Comma outside braces should not trigger expansion + TestBuilder::new() + .command("echo a,b,c") + .assert_stdout("a,b,c\n") + .run() + .await; +} + #[cfg(test)] fn no_such_file_error_text() -> &'static str { if cfg!(windows) {