Skip to content

Commit 5f07246

Browse files
committed
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 7a197ad commit 5f07246

File tree

4 files changed

+218
-14
lines changed

4 files changed

+218
-14
lines changed

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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,15 @@ pub enum VariableModifier {
418418
AlternateValue(Word),
419419
}
420420

421+
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
422+
#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))]
423+
#[derive(Debug, PartialEq, Eq, Clone, Error)]
424+
#[error("Invalid brace expansion")]
425+
pub enum BraceExpansion {
426+
List(Vec<String>),
427+
Sequence { start: String, end: String, step: Option<i32> },
428+
}
429+
421430
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
422431
#[cfg_attr(
423432
feature = "serialization",
@@ -439,6 +448,8 @@ pub enum WordPart {
439448
Arithmetic(Arithmetic),
440449
#[error("Invalid exit status")]
441450
ExitStatus,
451+
#[error("Invalid brace expansion")]
452+
BraceExpansion(BraceExpansion),
442453
}
443454

444455
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
@@ -1432,6 +1443,10 @@ fn parse_word(pair: Pair<Rule>) -> Result<Word> {
14321443
parse_variable_expansion(part)?;
14331444
parts.push(variable_expansion);
14341445
}
1446+
Rule::BRACE_EXPANSION => {
1447+
let brace_expansion = parse_brace_expansion(part)?;
1448+
parts.push(WordPart::BraceExpansion(brace_expansion));
1449+
}
14351450
Rule::QUOTED_WORD => {
14361451
let quoted = parse_quoted_word(part)?;
14371452
parts.push(quoted);
@@ -1822,6 +1837,35 @@ fn parse_variable_expansion(part: Pair<Rule>) -> Result<WordPart> {
18221837
Ok(WordPart::Variable(variable_name, parsed_modifier))
18231838
}
18241839

1840+
fn parse_brace_expansion(pair: Pair<Rule>) -> Result<BraceExpansion> {
1841+
let inner = pair.into_inner().next()
1842+
.ok_or_else(|| miette!("Expected brace expansion content"))?;
1843+
1844+
match inner.as_rule() {
1845+
Rule::BRACE_LIST => {
1846+
let elements: Vec<String> = inner.into_inner()
1847+
.map(|elem| elem.as_str().to_string())
1848+
.collect();
1849+
Ok(BraceExpansion::List(elements))
1850+
}
1851+
Rule::BRACE_SEQUENCE => {
1852+
let mut parts = inner.into_inner();
1853+
let start = parts.next()
1854+
.ok_or_else(|| miette!("Expected sequence start"))?
1855+
.as_str().to_string();
1856+
let end = parts.next()
1857+
.ok_or_else(|| miette!("Expected sequence end"))?
1858+
.as_str().to_string();
1859+
let step = parts.next().map(|s| {
1860+
s.as_str().parse::<i32>()
1861+
.unwrap_or(1)
1862+
});
1863+
Ok(BraceExpansion::Sequence { start, end, step })
1864+
}
1865+
_ => Err(miette!("Unexpected rule in brace expansion: {:?}", inner.as_rule()))
1866+
}
1867+
}
1868+
18251869
fn parse_tilde_prefix(pair: Pair<Rule>) -> Result<WordPart> {
18261870
let tilde_prefix_str = pair.as_str();
18271871
let user = if tilde_prefix_str.len() > 1 {

crates/deno_task_shell/src/shell/execute.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use tokio_util::sync::CancellationToken;
1717

1818
use crate::parser::AssignmentOp;
1919
use crate::parser::BinaryOp;
20+
use crate::parser::BraceExpansion;
2021
use crate::parser::CaseClause;
2122
use crate::parser::Condition;
2223
use crate::parser::ConditionInner;
@@ -1875,6 +1876,98 @@ pub enum IsQuoted {
18751876
No,
18761877
}
18771878

1879+
/// Expands a brace expansion into a vector of strings
1880+
fn expand_braces(brace_expansion: &BraceExpansion) -> Result<Vec<String>, Error> {
1881+
match brace_expansion {
1882+
BraceExpansion::List(elements) => {
1883+
Ok(elements.clone())
1884+
}
1885+
BraceExpansion::Sequence { start, end, step } => {
1886+
expand_sequence(start, end, step.as_ref())
1887+
}
1888+
}
1889+
}
1890+
1891+
/// Expands a sequence like {1..10} or {a..z} or {1..10..2}
1892+
fn expand_sequence(start: &str, end: &str, step: Option<&i32>) -> Result<Vec<String>, Error> {
1893+
// Try to parse as integers first
1894+
if let (Ok(start_num), Ok(end_num)) = (start.parse::<i32>(), end.parse::<i32>()) {
1895+
let is_reverse = start_num > end_num;
1896+
let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 });
1897+
1898+
// If step is provided and direction is reverse, negate the step
1899+
if step.is_some() && is_reverse && step_val > 0 {
1900+
step_val = -step_val;
1901+
}
1902+
// If step is provided and direction is forward, ensure step is positive
1903+
else if step.is_some() && !is_reverse && step_val < 0 {
1904+
step_val = -step_val;
1905+
}
1906+
1907+
if step_val == 0 {
1908+
return Err(miette!("Invalid step value: 0"));
1909+
}
1910+
1911+
let mut result = Vec::new();
1912+
if step_val > 0 {
1913+
let mut current = start_num;
1914+
while current <= end_num {
1915+
result.push(current.to_string());
1916+
current += step_val;
1917+
}
1918+
} else {
1919+
let mut current = start_num;
1920+
while current >= end_num {
1921+
result.push(current.to_string());
1922+
current += step_val;
1923+
}
1924+
}
1925+
Ok(result)
1926+
}
1927+
// Try to parse as single characters
1928+
else if start.len() == 1 && end.len() == 1 {
1929+
let start_char = start.chars().next().unwrap();
1930+
let end_char = end.chars().next().unwrap();
1931+
let is_reverse = start_char > end_char;
1932+
let mut step_val = step.copied().unwrap_or(if is_reverse { -1 } else { 1 });
1933+
1934+
// If step is provided and direction is reverse, negate the step
1935+
if step.is_some() && is_reverse && step_val > 0 {
1936+
step_val = -step_val;
1937+
}
1938+
// If step is provided and direction is forward, ensure step is positive
1939+
else if step.is_some() && !is_reverse && step_val < 0 {
1940+
step_val = -step_val;
1941+
}
1942+
1943+
if step_val == 0 {
1944+
return Err(miette!("Invalid step value: 0"));
1945+
}
1946+
1947+
let mut result = Vec::new();
1948+
if step_val > 0 {
1949+
let mut current = start_char as i32;
1950+
let end_val = end_char as i32;
1951+
while current <= end_val {
1952+
result.push((current as u8 as char).to_string());
1953+
current += step_val;
1954+
}
1955+
} else {
1956+
let mut current = start_char as i32;
1957+
let end_val = end_char as i32;
1958+
while current >= end_val {
1959+
result.push((current as u8 as char).to_string());
1960+
current += step_val;
1961+
}
1962+
}
1963+
Ok(result)
1964+
}
1965+
else {
1966+
// If it's not a valid sequence, return it as-is (bash behavior)
1967+
Ok(vec![format!("{{{}..{}}}", start, end)])
1968+
}
1969+
}
1970+
18781971
fn evaluate_word_parts(
18791972
parts: Vec<WordPart>,
18801973
state: &mut ShellState,
@@ -2101,6 +2194,14 @@ fn evaluate_word_parts(
21012194
.push(TextPart::Text(exit_code.to_string()));
21022195
continue;
21032196
}
2197+
WordPart::BraceExpansion(brace_expansion) => {
2198+
let expanded = expand_braces(&brace_expansion)?;
2199+
Ok(Some(Text::new(
2200+
expanded.into_iter()
2201+
.map(|s| TextPart::Text(s))
2202+
.collect()
2203+
)))
2204+
}
21042205
};
21052206

21062207
if let Ok(Some(text)) = evaluation_result_text {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Basic comma-separated list
2+
> echo {a,b,c}
3+
a b c
4+
5+
# Numeric sequence
6+
> echo {1..5}
7+
1 2 3 4 5
8+
9+
# Character sequence
10+
> echo {a..e}
11+
a b c d e
12+
13+
# Reverse numeric sequence
14+
> echo {5..1}
15+
5 4 3 2 1
16+
17+
# Reverse character sequence
18+
> echo {e..a}
19+
e d c b a
20+
21+
# Numeric sequence with step
22+
> echo {1..10..2}
23+
1 3 5 7 9
24+
25+
# Numeric sequence with step (reverse)
26+
> echo {10..1..2}
27+
10 8 6 4 2
28+
29+
# Empty elements in list
30+
> echo {a,,b}
31+
a b
32+
33+
# Quoted braces - should not expand
34+
> echo "{a,b,c}"
35+
{a,b,c}
36+
37+
# Mixed with other arguments
38+
> echo start {a,b,c} end
39+
start a b c end

0 commit comments

Comments
 (0)