From 5f072469b335d1ea1e17ab0b43d6024da7229fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 06:37:08 +0000 Subject: [PATCH 1/2] 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/2] 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(), ))) } };