diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index e72ada6..347f8da 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 9dd10d8..c50bf18 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -429,6 +429,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", @@ -450,6 +463,8 @@ pub enum WordPart { Arithmetic(Arithmetic), #[error("Invalid exit status")] ExitStatus, + #[error("Invalid brace expansion")] + BraceExpansion(BraceExpansion), } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] @@ -1520,6 +1535,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); @@ -2005,6 +2024,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 436c49e..4ef6613 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -17,11 +17,12 @@ use tokio_util::sync::CancellationToken; use crate::parser::{ Arithmetic, ArithmeticPart, AssignmentOp, BinaryArithmeticOp, BinaryOp, - CaseClause, Command, CommandInner, Condition, ConditionInner, ElsePart, - ForLoop, IfClause, IoFile, PipeSequence, PipeSequenceOperator, Pipeline, - PipelineInner, Redirect, RedirectFd, RedirectOp, RedirectOpInput, - RedirectOpOutput, Sequence, SequentialList, SimpleCommand, - UnaryArithmeticOp, UnaryOp, VariableModifier, WhileLoop, Word, WordPart, + BraceExpansion, CaseClause, Command, CommandInner, Condition, + ConditionInner, ElsePart, ForLoop, IfClause, IoFile, PipeSequence, + PipeSequenceOperator, Pipeline, PipelineInner, Redirect, RedirectFd, + RedirectOp, RedirectOpInput, RedirectOpOutput, Sequence, SequentialList, + SimpleCommand, UnaryArithmeticOp, UnaryOp, VariableModifier, WhileLoop, + Word, WordPart, }; use crate::shell::commands::{ShellCommand, ShellCommandContext}; use crate::shell::types::TextPart::Text as OtherText; @@ -1976,6 +1977,8 @@ pub enum EvaluateWordTextError { NoFilesMatched { pattern: String }, #[error("Failed to get home directory")] FailedToGetHomeDirectory(miette::Error), + #[error("{0}")] + FailedExpanding(String), } impl EvaluateWordTextError { @@ -2154,6 +2157,114 @@ 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()) + } + } +} + +/// 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, + 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 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() 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, @@ -2380,6 +2491,13 @@ fn evaluate_word_parts( .push(TextPart::Text(exit_code.to_string())); continue; } + 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" + ) + } }; if let Ok(Some(text)) = evaluation_result_text { @@ -2429,14 +2547,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 f4c77e7..8d24e47 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -1538,6 +1538,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; +} + #[tokio::test] async fn file_test_operators() { // -f: regular file 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