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
65 changes: 60 additions & 5 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1680,8 +1680,9 @@ impl Interpreter {
for w in words {
let fields = self.expand_word_to_fields(w).await?;

// Quoted words skip brace/glob expansion
if w.quoted {
// Quoted words skip brace/glob expansion — unless the
// word has unquoted glob chars (e.g. `"$var"*.ext`)
if w.quoted && !w.has_unquoted_glob {
vals.extend(fields);
continue;
}
Expand Down Expand Up @@ -1751,7 +1752,7 @@ impl Interpreter {
let mut values = Vec::new();
for w in &select_cmd.words {
let fields = self.expand_word_to_fields(w).await?;
if w.quoted {
if w.quoted && !w.has_unquoted_glob {
values.extend(fields);
} else {
for expanded in fields {
Expand Down Expand Up @@ -3833,8 +3834,11 @@ impl Interpreter {
// Use field expansion so "${arr[@]}" produces multiple args
let fields = self.expand_word_to_fields(word).await?;

// Skip brace and glob expansion for quoted words
if word.quoted {
// Skip brace and glob expansion for quoted words — unless the
// word has unquoted glob chars (e.g. `"$var"*.ext`) in which case
// the quoted expansion suppresses IFS splitting but the unquoted
// portion must still undergo glob expansion.
if word.quoted && !word.has_unquoted_glob {
args.extend(fields);
continue;
}
Expand Down Expand Up @@ -10903,6 +10907,57 @@ echo "count=$COUNT"
assert!(output.contains("b"), "got: {}", output);
}

/// Issue #1277: glob `*` not expanded when adjacent to quoted variable expansion.
/// In `"$var"*.ext`, the unquoted `*` must undergo glob expansion even though
/// the word contains a quoted expansion (which suppresses IFS splitting).
#[tokio::test]
async fn test_glob_adjacent_to_quoted_variable() {
let mut bash = crate::Bash::new();
bash.fs()
.mkdir(std::path::Path::new("/tmp/test"), true)
.await
.unwrap();
bash.fs()
.write_file(
std::path::Path::new("/tmp/test/tag_hello.tmp.html"),
b"hello",
)
.await
.unwrap();
bash.fs()
.write_file(
std::path::Path::new("/tmp/test/tag_world.tmp.html"),
b"world",
)
.await
.unwrap();

// Test: ./"$p"*.tmp.html should expand the glob
let result = bash
.exec(r#"cd /tmp/test; p="tag_"; for f in ./"$p"*.tmp.html; do echo "$f"; done"#)
.await
.unwrap();
let mut lines: Vec<&str> = result.stdout.trim().lines().collect();
lines.sort();
assert_eq!(
lines,
vec!["./tag_hello.tmp.html", "./tag_world.tmp.html"],
"glob * adjacent to quoted var should expand"
);

// Test: ls ./"$p"*.tmp.html should also work
let result = bash
.exec(r#"cd /tmp/test; p="tag_"; ls ./"$p"*.tmp.html"#)
.await
.unwrap();
assert_eq!(result.exit_code, 0, "ls stderr: {}", result.stderr);
assert!(
result.stdout.contains("tag_hello.tmp.html"),
"ls output: {}",
result.stdout
);
}

#[tokio::test]
async fn test_glob_with_quoted_prefix() {
let mut bash = crate::Bash::new();
Expand Down
34 changes: 34 additions & 0 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ pub struct Word {
/// True if this word came from a quoted source (single or double quotes)
/// Quoted words should not undergo brace expansion or glob expansion
pub quoted: bool,
/// True when the word mixes quoted and unquoted segments and the unquoted
/// portion contains glob metacharacters (`*`, `?`, `[`). For example,
/// `"$var"*.txt` — the quoted expansion suppresses IFS splitting
/// (`quoted == true`) but the unquoted `*` must still undergo glob
/// expansion.
pub has_unquoted_glob: bool,
}

impl Word {
Expand All @@ -262,6 +268,7 @@ impl Word {
Self {
parts: vec![WordPart::Literal(s.into())],
quoted: false,
has_unquoted_glob: false,
}
}

Expand All @@ -270,6 +277,7 @@ impl Word {
Self {
parts: vec![WordPart::Literal(s.into())],
quoted: true,
has_unquoted_glob: false,
}
}
}
Expand Down Expand Up @@ -578,6 +586,7 @@ mod tests {
let w = Word {
parts: vec![WordPart::Variable("HOME".into())],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "$HOME");
}
Expand All @@ -587,6 +596,7 @@ mod tests {
let w = Word {
parts: vec![WordPart::ArithmeticExpansion("1+2".into())],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "$((1+2))");
}
Expand All @@ -596,6 +606,7 @@ mod tests {
let w = Word {
parts: vec![WordPart::Length("var".into())],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${#var}");
}
Expand All @@ -608,6 +619,7 @@ mod tests {
index: "0".into(),
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${arr[0]}");
}
Expand All @@ -617,6 +629,7 @@ mod tests {
let w = Word {
parts: vec![WordPart::ArrayLength("arr".into())],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${#arr[@]}");
}
Expand All @@ -626,6 +639,7 @@ mod tests {
let w = Word {
parts: vec![WordPart::ArrayIndices("arr".into())],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${!arr[@]}");
}
Expand All @@ -639,6 +653,7 @@ mod tests {
length: Some("3".into()),
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var:2:3}");
}
Expand All @@ -652,6 +667,7 @@ mod tests {
length: None,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var:2}");
}
Expand All @@ -665,6 +681,7 @@ mod tests {
length: Some("2".into()),
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${arr[@]:1:2}");
}
Expand All @@ -678,6 +695,7 @@ mod tests {
length: None,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${arr[@]:1}");
}
Expand All @@ -692,6 +710,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${!ref}");
}
Expand All @@ -701,6 +720,7 @@ mod tests {
let w = Word {
parts: vec![WordPart::PrefixMatch("MY_".into())],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${!MY_*}");
}
Expand All @@ -713,6 +733,7 @@ mod tests {
operator: 'Q',
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var@Q}");
}
Expand All @@ -725,6 +746,7 @@ mod tests {
WordPart::Variable("USER".into()),
],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "hello $USER");
}
Expand All @@ -739,6 +761,7 @@ mod tests {
colon_variant: true,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var:-fallback}");
}
Expand All @@ -753,6 +776,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var-fallback}");
}
Expand All @@ -767,6 +791,7 @@ mod tests {
colon_variant: true,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var:=val}");
}
Expand All @@ -781,6 +806,7 @@ mod tests {
colon_variant: true,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var:+alt}");
}
Expand All @@ -795,6 +821,7 @@ mod tests {
colon_variant: true,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var:?msg}");
}
Expand All @@ -810,6 +837,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var#pat}");

Expand All @@ -822,6 +850,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var##pat}");

Expand All @@ -834,6 +863,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var%pat}");

Expand All @@ -846,6 +876,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var%%pat}");
}
Expand All @@ -863,6 +894,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var/old/new}");

Expand All @@ -877,6 +909,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), "${var///old/new}");
}
Expand All @@ -892,6 +925,7 @@ mod tests {
colon_variant: false,
}],
quoted: false,
has_unquoted_glob: false,
};
assert_eq!(format!("{w}"), expected);
};
Expand Down
27 changes: 25 additions & 2 deletions crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,11 @@ impl<'a> Lexer<'a> {
// the interpreter suppresses IFS field splitting — matching POSIX
// behaviour for words like +"$fmt" or prefix"$var"suffix.
let mut has_quoted_expansion = false;
// Track whether any glob metacharacter (*, ?, [) appears in an
// unquoted portion of the word. When both `has_quoted_expansion` and
// this flag are true, the word needs IFS-splitting suppression (quoted)
// *and* glob expansion on the unquoted portion — e.g. `"$var"*.ext`.
let mut has_unquoted_glob = false;

while let Some(ch) = self.peek_char() {
// Handle quoted strings within words (e.g., a="Hello" or VAR="value")
Expand Down Expand Up @@ -943,6 +948,10 @@ impl<'a> Lexer<'a> {
}
}
} else if self.is_word_char(ch) {
// Track glob metacharacters in unquoted portions
if matches!(ch, '*' | '?' | '[') {
has_unquoted_glob = true;
}
word.push(ch);
self.advance();
} else {
Expand All @@ -952,6 +961,11 @@ impl<'a> Lexer<'a> {

if word.is_empty() {
None
} else if has_quoted_expansion && has_unquoted_glob {
// Mixed quoted/unquoted word with glob chars in the unquoted
// portion — e.g. `"$var"*.ext`. Suppress IFS splitting (quoted)
// but glob expansion must still apply on the unquoted portions.
Some(Token::QuotedGlobWord(word))
} else if has_quoted_expansion {
// A double-quoted segment contained a variable/command expansion.
// Promote to QuotedWord so the interpreter suppresses IFS field
Expand Down Expand Up @@ -1268,12 +1282,21 @@ impl<'a> Lexer<'a> {

// Check for continuation after closing quote: "foo"bar or "foo"/* etc.
// If there's adjacent unquoted content (word chars, globs, more quotes),
// concatenate and return as Word (not QuotedWord) so glob expansion works
// on the unquoted portion.
// concatenate so the word stays a single token. When the continuation
// contains glob metacharacters, return QuotedGlobWord so the interpreter
// suppresses IFS splitting (the double-quoted segment) while still
// performing glob expansion on the unquoted portion.
if let Some(ch) = self.peek_char()
&& (self.is_word_char(ch) || ch == '\'' || ch == '"' || ch == '$')
{
let before_len = content.len();
self.read_continuation_into(&mut content);
let has_glob = content[before_len..]
.chars()
.any(|c| matches!(c, '*' | '?' | '['));
if has_glob && content[..before_len].contains('$') {
return Some(Token::QuotedGlobWord(content));
}
return Some(Token::Word(content));
}

Expand Down
Loading
Loading