Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions crates/deno_task_shell/src/grammar.pest
Original file line number Diff line number Diff line change
Expand Up @@ -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
))+
}
Expand Down Expand Up @@ -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 | "_")* }
Expand Down
56 changes: 56 additions & 0 deletions crates/deno_task_shell/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>),
Sequence {
start: String,
end: String,
step: Option<i32>,
},
}

#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
#[cfg_attr(
feature = "serialization",
Expand All @@ -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))]
Expand Down Expand Up @@ -1520,6 +1535,10 @@ fn parse_word(pair: Pair<Rule>) -> Result<Word> {
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);
Expand Down Expand Up @@ -2005,6 +2024,43 @@ fn parse_variable_expansion(part: Pair<Rule>) -> Result<WordPart> {
Ok(WordPart::Variable(variable_name, parsed_modifier))
}

fn parse_brace_expansion(pair: Pair<Rule>) -> Result<BraceExpansion> {
let inner = pair
.into_inner()
.next()
.ok_or_else(|| miette!("Expected brace expansion content"))?;

match inner.as_rule() {
Rule::BRACE_LIST => {
let elements: Vec<String> = 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::<i32>().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<Rule>) -> Result<WordPart> {
let tilde_prefix_str = pair.as_str();
let user = if tilde_prefix_str.len() > 1 {
Expand Down
177 changes: 164 additions & 13 deletions crates/deno_task_shell/src/shell/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2154,6 +2157,114 @@ pub enum IsQuoted {
No,
}

/// Expands a brace expansion into a vector of strings
fn expand_braces(
brace_expansion: &BraceExpansion,
) -> Result<Vec<String>, 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<Vec<i32>, 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<Vec<String>, Error> {
// Try to parse as integers first
if let (Ok(start_num), Ok(end_num)) =
(start.parse::<i32>(), end.parse::<i32>())
{
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<WordPart>,
) -> Result<Vec<Vec<WordPart>>, 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<WordPart> = 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<WordPart>,
state: &mut ShellState,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading