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..d29f091 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -418,6 +418,19 @@ 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 +452,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 +1447,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 +1841,43 @@ 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..983dae0 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,100 @@ 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 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) + } + // 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) + } 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 +2196,15 @@ 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(), + ))) + } }; 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