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
3 changes: 3 additions & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ pub enum NodeKind {
/// Locale string: `$"..."`
LocaleString { content: String },

/// Brace expansion: `{a,b,c}` or `{1..10}`.
BraceExpansion { content: String },

/// Arithmetic expansion: `$(( expr ))`
ArithmeticExpansion { expression: Option<Box<Node>> },

Expand Down
4 changes: 3 additions & 1 deletion src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,9 @@ fn process_word_value(value: &str, spans: &[crate::lexer::word_builder::WordSpan
}
result.push(')');
}
WordSegment::ParamExpansion(text) | WordSegment::SimpleVar(text) => {
WordSegment::ParamExpansion(text)
| WordSegment::SimpleVar(text)
| WordSegment::BraceExpansion(text) => {
result.push_str(text);
}
}
Expand Down
189 changes: 189 additions & 0 deletions src/lexer/brace_expansion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! Post-hoc brace expansion detection.
//!
//! After a word is fully built by the lexer, scans the word value
//! to identify brace expansion patterns (`{a,b,c}`, `{1..10}`) and
//! records `WordSpanKind::BraceExpansion` spans. Existing spans
//! (quotes, escapes, parameter expansions) are used to skip
//! protected regions.

use super::word_builder::{QuotingContext, WordBuilder, WordSpan, WordSpanKind};

/// Scans the completed word value for brace expansion patterns and
/// records `BraceExpansion` spans for each one found.
pub(super) fn detect_brace_expansions(wb: &mut WordBuilder) {
let value = wb.value.as_bytes();
let spans = &wb.spans;
let mut new_spans: Vec<WordSpan> = Vec::new();
let mut i = 0;

while i < value.len() {
if value[i] != b'{' {
i += 1;
continue;
}

// Skip if preceded by $ (parameter expansion)
if i > 0 && value[i - 1] == b'$' {
i += 1;
continue;
}

// Skip if inside an existing span
if span_end_at(i, spans).is_some() {
i += 1;
continue;
}

// Try to find matching } with a comma or .. separator
if let Some(close) = find_brace_close(value, i, spans) {
new_spans.push(WordSpan {
start: i,
end: close + 1,
kind: WordSpanKind::BraceExpansion,
context: QuotingContext::None,
});
i = close + 1;
} else {
i += 1;
}
}

wb.spans.extend(new_spans);
}

/// Returns the byte index of the matching `}` if the content between
/// `{` and `}` contains a `,` or `..` at depth 1. Returns `None` if
/// no valid brace expansion is found.
fn find_brace_close(value: &[u8], open: usize, spans: &[WordSpan]) -> Option<usize> {
let mut depth: i32 = 1;
let mut has_comma = false;
let mut has_dotdot = false;
let mut j = open + 1;

while j < value.len() {
// Skip positions inside existing spans
if let Some(end) = span_end_at(j, spans) {
j = end;
continue;
}

match value[j] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
if has_comma || has_dotdot {
return Some(j);
}
return None;
}
}
b',' if depth == 1 => has_comma = true,
b'.' if depth == 1 && j + 1 < value.len() && value[j + 1] == b'.' => {
has_dotdot = true;
}
_ => {}
}
j += 1;
}

None
}

/// If `pos` falls inside an existing span, returns the span's end offset.
fn span_end_at(pos: usize, spans: &[WordSpan]) -> Option<usize> {
spans
.iter()
.find(|s| pos >= s.start && pos < s.end)
.map(|s| s.end)
}

#[cfg(test)]
mod tests {
use crate::lexer::word_builder::WordSpanKind;

/// Helper: lex a single word and check for `BraceExpansion` spans.
#[allow(clippy::unwrap_used)]
fn brace_spans(source: &str) -> Vec<(usize, usize)> {
let mut lexer = crate::lexer::Lexer::new(source, false);
let tok = lexer.next_token().unwrap();
tok.spans
.iter()
.filter(|s| s.kind == WordSpanKind::BraceExpansion)
.map(|s| (s.start, s.end))
.collect()
}

#[test]
fn comma_form() {
let spans = brace_spans("{a,b,c}");
assert_eq!(spans, vec![(0, 7)]);
}

#[test]
fn range_form() {
let spans = brace_spans("{1..10}");
assert_eq!(spans, vec![(0, 7)]);
}

#[test]
fn mid_word() {
let spans = brace_spans("file{1,2}.txt");
assert_eq!(spans, vec![(4, 9)]);
}

#[test]
fn nested_braces() {
let spans = brace_spans("{a,{b,c}}");
assert_eq!(spans, vec![(0, 9)]);
}

#[test]
fn empty_braces_not_expansion() {
let spans = brace_spans("{}");
assert!(spans.is_empty());
}

#[test]
fn single_element_not_expansion() {
let spans = brace_spans("{a}");
assert!(spans.is_empty());
}

#[test]
fn trailing_comma() {
let spans = brace_spans("{a,}");
assert_eq!(spans, vec![(0, 4)]);
}

#[test]
fn leading_comma() {
let spans = brace_spans("{,a}");
assert_eq!(spans, vec![(0, 4)]);
}

#[test]
fn param_expansion_not_brace() {
// ${foo} should NOT produce a BraceExpansion span
let spans = brace_spans("${foo}");
assert!(spans.is_empty());
}

#[test]
fn adjacent_brace_expansions() {
let spans = brace_spans("{a,b}{c,d}");
assert_eq!(spans, vec![(0, 5), (5, 10)]);
}

#[test]
fn alpha_range() {
let spans = brace_spans("{a..z}");
assert_eq!(spans, vec![(0, 6)]);
}

#[test]
fn range_with_step() {
let spans = brace_spans("{1..10..2}");
assert_eq!(spans, vec![(0, 10)]);
}
}
1 change: 1 addition & 0 deletions src/lexer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::error::{RableError, Result};
use crate::token::{Token, TokenType};

mod brace_expansion;
mod expansions;
mod heredoc;
mod operators;
Expand Down
2 changes: 2 additions & 0 deletions src/lexer/word_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,6 @@ pub enum WordSpanKind {
DeprecatedArith,
/// Backslash escape: `\X` (not `\<newline>` line continuations).
Escape,
/// Brace expansion: `{a,b,c}` or `{1..10}`.
BraceExpansion,
}
2 changes: 2 additions & 0 deletions src/lexer/words.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ impl Lexer {
}
}

super::brace_expansion::detect_brace_expansions(&mut wb);

if wb.is_empty() {
return Err(RableError::parse("unexpected character", start, line));
}
Expand Down
41 changes: 41 additions & 0 deletions src/parser/word_parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ fn segment_to_node(seg: WordSegment) -> Node {
}
WordSegment::SimpleVar(text) => parse_simple_var(&text),
WordSegment::ParamExpansion(text) => parse_braced_param(&text),
WordSegment::BraceExpansion(text) => {
Node::empty(NodeKind::BraceExpansion { content: text })
}
}
}

Expand Down Expand Up @@ -517,4 +520,42 @@ mod tests {
NodeKind::LocaleString { content } if content == "\"hello\""
));
}

#[test]
fn brace_expansion_comma() {
let parts = decompose("{a,b,c}");
assert_eq!(parts.len(), 1);
assert!(matches!(
&parts[0].kind,
NodeKind::BraceExpansion { content } if content == "{a,b,c}"
));
}

#[test]
fn brace_expansion_range() {
let parts = decompose("{1..10}");
assert_eq!(parts.len(), 1);
assert!(matches!(
&parts[0].kind,
NodeKind::BraceExpansion { content } if content == "{1..10}"
));
}

#[test]
fn brace_expansion_mid_word() {
let parts = decompose("file{1,2}.txt");
assert_eq!(parts.len(), 3);
assert!(matches!(
&parts[0].kind,
NodeKind::WordLiteral { value } if value == "file"
));
assert!(matches!(
&parts[1].kind,
NodeKind::BraceExpansion { content } if content == "{1,2}"
));
assert!(matches!(
&parts[2].kind,
NodeKind::WordLiteral { value } if value == ".txt"
));
}
}
5 changes: 4 additions & 1 deletion src/sexp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ impl fmt::Display for NodeKind {
}
Self::AnsiCQuote { content } => write!(f, "$'{content}'"),
Self::LocaleString { content } => write!(f, "$\"{content}\""),
Self::BraceExpansion { content } => write!(f, "{content}"),
Self::ArithmeticExpansion { expression } => {
write_arith_wrapper(f, "arith", expression.as_deref())
}
Expand Down Expand Up @@ -735,7 +736,9 @@ fn write_redirect_segments(
}
write!(f, ")")?;
}
word::WordSegment::ParamExpansion(text) | word::WordSegment::SimpleVar(text) => {
word::WordSegment::ParamExpansion(text)
| word::WordSegment::SimpleVar(text)
| word::WordSegment::BraceExpansion(text) => {
write!(f, "{text}")?;
}
}
Expand Down
Loading
Loading