Skip to content

Commit 7c932fa

Browse files
authored
feat: implement brace expansion as strict subset of bash (#273)
* feat: implement brace expansion as strict subset of bash 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
1 parent bce13d8 commit 7c932fa

5 files changed

Lines changed: 429 additions & 27 deletions

File tree

crates/deno_task_shell/src/grammar.pest

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,24 @@ INT = { ("+" | "-")? ~ ASCII_DIGIT+ }
99
// Basic tokens
1010
QUOTED_WORD = { DOUBLE_QUOTED | SINGLE_QUOTED }
1111

12-
UNQUOTED_PENDING_WORD = ${
13-
(TILDE_PREFIX ~ (!(OPERATOR | WHITESPACE | NEWLINE) ~ (
14-
EXIT_STATUS |
15-
UNQUOTED_ESCAPE_CHAR |
12+
UNQUOTED_PENDING_WORD = ${
13+
(TILDE_PREFIX ~ (BRACE_EXPANSION | !(OPERATOR | WHITESPACE | NEWLINE) ~ (
14+
EXIT_STATUS |
15+
UNQUOTED_ESCAPE_CHAR |
1616
"$" ~ ARITHMETIC_EXPRESSION |
17-
SUB_COMMAND |
18-
VARIABLE_EXPANSION |
19-
UNQUOTED_CHAR |
17+
SUB_COMMAND |
18+
VARIABLE_EXPANSION |
19+
UNQUOTED_CHAR |
2020
QUOTED_WORD
2121
))*)
22-
|
23-
(!(OPERATOR | WHITESPACE | NEWLINE) ~ (
24-
EXIT_STATUS |
25-
UNQUOTED_ESCAPE_CHAR |
22+
|
23+
(BRACE_EXPANSION | !(OPERATOR | WHITESPACE | NEWLINE) ~ (
24+
EXIT_STATUS |
25+
UNQUOTED_ESCAPE_CHAR |
2626
"$" ~ ARITHMETIC_EXPRESSION |
27-
SUB_COMMAND |
28-
VARIABLE_EXPANSION |
29-
UNQUOTED_CHAR |
27+
SUB_COMMAND |
28+
VARIABLE_EXPANSION |
29+
UNQUOTED_CHAR |
3030
QUOTED_WORD
3131
))+
3232
}
@@ -92,6 +92,26 @@ VARIABLE_EXPANSION = ${
9292
)
9393
}
9494

95+
BRACE_EXPANSION = ${
96+
"{" ~ (BRACE_SEQUENCE | BRACE_LIST) ~ "}"
97+
}
98+
99+
BRACE_SEQUENCE = ${
100+
BRACE_ELEMENT ~ ".." ~ BRACE_ELEMENT ~ (".." ~ BRACE_STEP)?
101+
}
102+
103+
BRACE_LIST = ${
104+
BRACE_ELEMENT ~ ("," ~ BRACE_ELEMENT)+
105+
}
106+
107+
BRACE_ELEMENT = ${
108+
(!(OPERATOR | WHITESPACE | NEWLINE | "," | "}" | "{" | ".." | "$" | "\\" | "\"" | "'") ~ ANY)*
109+
}
110+
111+
BRACE_STEP = ${
112+
("+" | "-")? ~ ASCII_DIGIT+
113+
}
114+
95115
SPECIAL_PARAM = ${ ARGNUM | "@" | "#" | "?" | "$" | "*" }
96116
ARGNUM = ${ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* | "0" }
97117
VARIABLE = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* }

crates/deno_task_shell/src/parser.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,19 @@ pub enum VariableModifier {
429429
AlternateValue(Word),
430430
}
431431

432+
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
433+
#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))]
434+
#[derive(Debug, PartialEq, Eq, Clone, Error)]
435+
#[error("Invalid brace expansion")]
436+
pub enum BraceExpansion {
437+
List(Vec<String>),
438+
Sequence {
439+
start: String,
440+
end: String,
441+
step: Option<i32>,
442+
},
443+
}
444+
432445
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
433446
#[cfg_attr(
434447
feature = "serialization",
@@ -450,6 +463,8 @@ pub enum WordPart {
450463
Arithmetic(Arithmetic),
451464
#[error("Invalid exit status")]
452465
ExitStatus,
466+
#[error("Invalid brace expansion")]
467+
BraceExpansion(BraceExpansion),
453468
}
454469

455470
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
@@ -1520,6 +1535,10 @@ fn parse_word(pair: Pair<Rule>) -> Result<Word> {
15201535
parse_variable_expansion(part)?;
15211536
parts.push(variable_expansion);
15221537
}
1538+
Rule::BRACE_EXPANSION => {
1539+
let brace_expansion = parse_brace_expansion(part)?;
1540+
parts.push(WordPart::BraceExpansion(brace_expansion));
1541+
}
15231542
Rule::QUOTED_WORD => {
15241543
let quoted = parse_quoted_word(part)?;
15251544
parts.push(quoted);
@@ -2005,6 +2024,43 @@ fn parse_variable_expansion(part: Pair<Rule>) -> Result<WordPart> {
20052024
Ok(WordPart::Variable(variable_name, parsed_modifier))
20062025
}
20072026

2027+
fn parse_brace_expansion(pair: Pair<Rule>) -> Result<BraceExpansion> {
2028+
let inner = pair
2029+
.into_inner()
2030+
.next()
2031+
.ok_or_else(|| miette!("Expected brace expansion content"))?;
2032+
2033+
match inner.as_rule() {
2034+
Rule::BRACE_LIST => {
2035+
let elements: Vec<String> = inner
2036+
.into_inner()
2037+
.map(|elem| elem.as_str().to_string())
2038+
.collect();
2039+
Ok(BraceExpansion::List(elements))
2040+
}
2041+
Rule::BRACE_SEQUENCE => {
2042+
let mut parts = inner.into_inner();
2043+
let start = parts
2044+
.next()
2045+
.ok_or_else(|| miette!("Expected sequence start"))?
2046+
.as_str()
2047+
.to_string();
2048+
let end = parts
2049+
.next()
2050+
.ok_or_else(|| miette!("Expected sequence end"))?
2051+
.as_str()
2052+
.to_string();
2053+
let step =
2054+
parts.next().map(|s| s.as_str().parse::<i32>().unwrap_or(1));
2055+
Ok(BraceExpansion::Sequence { start, end, step })
2056+
}
2057+
_ => Err(miette!(
2058+
"Unexpected rule in brace expansion: {:?}",
2059+
inner.as_rule()
2060+
)),
2061+
}
2062+
}
2063+
20082064
fn parse_tilde_prefix(pair: Pair<Rule>) -> Result<WordPart> {
20092065
let tilde_prefix_str = pair.as_str();
20102066
let user = if tilde_prefix_str.len() > 1 {

crates/deno_task_shell/src/shell/execute.rs

Lines changed: 164 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ use tokio_util::sync::CancellationToken;
1717

1818
use crate::parser::{
1919
Arithmetic, ArithmeticPart, AssignmentOp, BinaryArithmeticOp, BinaryOp,
20-
CaseClause, Command, CommandInner, Condition, ConditionInner, ElsePart,
21-
ForLoop, IfClause, IoFile, PipeSequence, PipeSequenceOperator, Pipeline,
22-
PipelineInner, Redirect, RedirectFd, RedirectOp, RedirectOpInput,
23-
RedirectOpOutput, Sequence, SequentialList, SimpleCommand,
24-
UnaryArithmeticOp, UnaryOp, VariableModifier, WhileLoop, Word, WordPart,
20+
BraceExpansion, CaseClause, Command, CommandInner, Condition,
21+
ConditionInner, ElsePart, ForLoop, IfClause, IoFile, PipeSequence,
22+
PipeSequenceOperator, Pipeline, PipelineInner, Redirect, RedirectFd,
23+
RedirectOp, RedirectOpInput, RedirectOpOutput, Sequence, SequentialList,
24+
SimpleCommand, UnaryArithmeticOp, UnaryOp, VariableModifier, WhileLoop,
25+
Word, WordPart,
2526
};
2627
use crate::shell::commands::{ShellCommand, ShellCommandContext};
2728
use crate::shell::types::TextPart::Text as OtherText;
@@ -1976,6 +1977,8 @@ pub enum EvaluateWordTextError {
19761977
NoFilesMatched { pattern: String },
19771978
#[error("Failed to get home directory")]
19781979
FailedToGetHomeDirectory(miette::Error),
1980+
#[error("{0}")]
1981+
FailedExpanding(String),
19791982
}
19801983

19811984
impl EvaluateWordTextError {
@@ -2154,6 +2157,114 @@ pub enum IsQuoted {
21542157
No,
21552158
}
21562159

2160+
/// Expands a brace expansion into a vector of strings
2161+
fn expand_braces(
2162+
brace_expansion: &BraceExpansion,
2163+
) -> Result<Vec<String>, Error> {
2164+
match brace_expansion {
2165+
BraceExpansion::List(elements) => Ok(elements.clone()),
2166+
BraceExpansion::Sequence { start, end, step } => {
2167+
expand_sequence(start, end, step.as_ref())
2168+
}
2169+
}
2170+
}
2171+
2172+
/// Generates a numeric range with the given step, handling direction automatically.
2173+
fn generate_range(
2174+
start: i32,
2175+
end: i32,
2176+
step: Option<&i32>,
2177+
) -> Result<Vec<i32>, Error> {
2178+
let is_reverse = start > end;
2179+
let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 });
2180+
2181+
// If step is provided and its sign doesn't match the direction, flip it
2182+
if step.is_some() && (is_reverse != (step_val < 0)) {
2183+
step_val = -step_val;
2184+
}
2185+
2186+
if step_val == 0 {
2187+
return Err(miette!("Invalid step value: 0"));
2188+
}
2189+
2190+
let mut result = Vec::new();
2191+
let mut current = start;
2192+
if step_val > 0 {
2193+
while current <= end {
2194+
result.push(current);
2195+
current += step_val;
2196+
}
2197+
} else {
2198+
while current >= end {
2199+
result.push(current);
2200+
current += step_val;
2201+
}
2202+
}
2203+
Ok(result)
2204+
}
2205+
2206+
/// Expands a sequence like {1..10} or {a..z} or {1..10..2}
2207+
fn expand_sequence(
2208+
start: &str,
2209+
end: &str,
2210+
step: Option<&i32>,
2211+
) -> Result<Vec<String>, Error> {
2212+
// Try to parse as integers first
2213+
if let (Ok(start_num), Ok(end_num)) =
2214+
(start.parse::<i32>(), end.parse::<i32>())
2215+
{
2216+
let values = generate_range(start_num, end_num, step)?;
2217+
Ok(values.into_iter().map(|v| v.to_string()).collect())
2218+
}
2219+
// Try to parse as single characters
2220+
else if start.len() == 1 && end.len() == 1 {
2221+
let start_char = start.chars().next().unwrap() as i32;
2222+
let end_char = end.chars().next().unwrap() as i32;
2223+
let values = generate_range(start_char, end_char, step)?;
2224+
Ok(values
2225+
.into_iter()
2226+
.filter_map(|v| char::from_u32(v as u32))
2227+
.map(|c| c.to_string())
2228+
.collect())
2229+
} else {
2230+
// If it's not a valid sequence, return it as-is (bash behavior)
2231+
Ok(vec![format!("{{{}..{}}}", start, end)])
2232+
}
2233+
}
2234+
2235+
/// Pre-processes word parts to expand brace expansions at the word level.
2236+
/// This ensures prefixes and suffixes are correctly combined with each
2237+
/// expanded element (e.g., `pre{a,b}post` → `preapost`, `prebpost`).
2238+
fn expand_brace_word_parts(
2239+
parts: Vec<WordPart>,
2240+
) -> Result<Vec<Vec<WordPart>>, Error> {
2241+
let brace_idx = parts
2242+
.iter()
2243+
.position(|p| matches!(p, WordPart::BraceExpansion(_)));
2244+
2245+
let Some(idx) = brace_idx else {
2246+
return Ok(vec![parts]);
2247+
};
2248+
2249+
let prefix = &parts[..idx];
2250+
let suffix = &parts[idx + 1..];
2251+
2252+
let WordPart::BraceExpansion(ref expansion) = parts[idx] else {
2253+
unreachable!()
2254+
};
2255+
2256+
let expanded = expand_braces(expansion)?;
2257+
let mut result = Vec::new();
2258+
for element in expanded {
2259+
let mut word_parts: Vec<WordPart> = prefix.to_vec();
2260+
word_parts.push(WordPart::Text(element));
2261+
word_parts.extend(suffix.iter().cloned());
2262+
// Recursively expand in case there are more brace expansions
2263+
result.extend(expand_brace_word_parts(word_parts)?);
2264+
}
2265+
Ok(result)
2266+
}
2267+
21572268
fn evaluate_word_parts(
21582269
parts: Vec<WordPart>,
21592270
state: &mut ShellState,
@@ -2380,6 +2491,13 @@ fn evaluate_word_parts(
23802491
.push(TextPart::Text(exit_code.to_string()));
23812492
continue;
23822493
}
2494+
WordPart::BraceExpansion(_) => {
2495+
// Brace expansions are handled at the word level
2496+
// in evaluate_word_parts before this function is called.
2497+
unreachable!(
2498+
"BraceExpansion should be expanded before evaluation"
2499+
)
2500+
}
23832501
};
23842502

23852503
if let Ok(Some(text)) = evaluation_result_text {
@@ -2429,14 +2547,47 @@ fn evaluate_word_parts(
24292547
.boxed_local()
24302548
}
24312549

2432-
evaluate_word_parts_inner(
2433-
parts,
2434-
IsQuoted::No,
2435-
eval_glob,
2436-
state,
2437-
stdin,
2438-
stderr,
2439-
)
2550+
// Expand brace expansions at the word level first, so that
2551+
// prefixes/suffixes combine correctly with each expanded element.
2552+
let expanded_words = match expand_brace_word_parts(parts) {
2553+
Ok(words) => words,
2554+
Err(e) => {
2555+
return async move {
2556+
Err(EvaluateWordTextError::FailedExpanding(e.to_string()))
2557+
}
2558+
.boxed_local()
2559+
}
2560+
};
2561+
2562+
if expanded_words.len() <= 1 {
2563+
let word_parts = expanded_words.into_iter().next().unwrap_or_default();
2564+
evaluate_word_parts_inner(
2565+
word_parts,
2566+
IsQuoted::No,
2567+
eval_glob,
2568+
state,
2569+
stdin,
2570+
stderr,
2571+
)
2572+
} else {
2573+
async move {
2574+
let mut result = WordPartsResult::new(Vec::new(), Vec::new());
2575+
for word_parts in expanded_words {
2576+
let word_result = evaluate_word_parts_inner(
2577+
word_parts,
2578+
IsQuoted::No,
2579+
eval_glob,
2580+
state,
2581+
stdin.clone(),
2582+
stderr.clone(),
2583+
)
2584+
.await?;
2585+
result.extend(word_result);
2586+
}
2587+
Ok(result)
2588+
}
2589+
.boxed_local()
2590+
}
24402591
}
24412592

24422593
async fn evaluate_command_substitution(

0 commit comments

Comments
 (0)