diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index ac1187cb..afb5d36f 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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; } @@ -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 { @@ -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; } @@ -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(); diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index c61fef3c..7af65fe7 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -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 { @@ -262,6 +268,7 @@ impl Word { Self { parts: vec![WordPart::Literal(s.into())], quoted: false, + has_unquoted_glob: false, } } @@ -270,6 +277,7 @@ impl Word { Self { parts: vec![WordPart::Literal(s.into())], quoted: true, + has_unquoted_glob: false, } } } @@ -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"); } @@ -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))"); } @@ -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}"); } @@ -608,6 +619,7 @@ mod tests { index: "0".into(), }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${arr[0]}"); } @@ -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[@]}"); } @@ -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[@]}"); } @@ -639,6 +653,7 @@ mod tests { length: Some("3".into()), }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var:2:3}"); } @@ -652,6 +667,7 @@ mod tests { length: None, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var:2}"); } @@ -665,6 +681,7 @@ mod tests { length: Some("2".into()), }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${arr[@]:1:2}"); } @@ -678,6 +695,7 @@ mod tests { length: None, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${arr[@]:1}"); } @@ -692,6 +710,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${!ref}"); } @@ -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_*}"); } @@ -713,6 +733,7 @@ mod tests { operator: 'Q', }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var@Q}"); } @@ -725,6 +746,7 @@ mod tests { WordPart::Variable("USER".into()), ], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "hello $USER"); } @@ -739,6 +761,7 @@ mod tests { colon_variant: true, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var:-fallback}"); } @@ -753,6 +776,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var-fallback}"); } @@ -767,6 +791,7 @@ mod tests { colon_variant: true, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var:=val}"); } @@ -781,6 +806,7 @@ mod tests { colon_variant: true, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var:+alt}"); } @@ -795,6 +821,7 @@ mod tests { colon_variant: true, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var:?msg}"); } @@ -810,6 +837,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var#pat}"); @@ -822,6 +850,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var##pat}"); @@ -834,6 +863,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var%pat}"); @@ -846,6 +876,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var%%pat}"); } @@ -863,6 +894,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var/old/new}"); @@ -877,6 +909,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), "${var///old/new}"); } @@ -892,6 +925,7 @@ mod tests { colon_variant: false, }], quoted: false, + has_unquoted_glob: false, }; assert_eq!(format!("{w}"), expected); }; diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 31e1164b..20bd4e4d 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -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") @@ -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 { @@ -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 @@ -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)); } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 707bc9ba..573d63be 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -518,7 +518,8 @@ impl<'a> Parser<'a> { let (delimiter, quoted) = match &self.current_token { Some(tokens::Token::Word(w)) => (w.clone(), false), Some(tokens::Token::LiteralWord(w)) => (w.clone(), true), - Some(tokens::Token::QuotedWord(w)) => (w.clone(), true), + Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), true), _ => break, }; let content = self.lexer.read_heredoc(&delimiter); @@ -722,7 +723,8 @@ impl<'a> Parser<'a> { let variable = match &self.current_token { Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => w.clone(), + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => w.clone(), _ => { self.pop_depth(); return Err(Error::parse( @@ -741,13 +743,21 @@ impl<'a> Parser<'a> { loop { match &self.current_token { Some(tokens::Token::Word(w)) if w == "do" => break, - Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => { - let is_quoted = - matches!(&self.current_token, Some(tokens::Token::QuotedWord(_))); + Some(tokens::Token::Word(w)) + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { + let is_quoted = matches!( + &self.current_token, + Some(tokens::Token::QuotedWord(_)) + | Some(tokens::Token::QuotedGlobWord(_)) + ); let mut word = self.parse_word(w.clone()); if is_quoted { word.quoted = true; } + if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { + word.has_unquoted_glob = true; + } words.push(word); self.advance(); } @@ -755,6 +765,7 @@ impl<'a> Parser<'a> { words.push(Word { parts: vec![WordPart::Literal(w.clone())], quoted: true, + has_unquoted_glob: false, }); self.advance(); } @@ -813,7 +824,8 @@ impl<'a> Parser<'a> { let variable = match &self.current_token { Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => w.clone(), + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => w.clone(), _ => { self.pop_depth(); return Err(Error::parse("expected variable name in select".to_string())); @@ -833,13 +845,20 @@ impl<'a> Parser<'a> { loop { match &self.current_token { Some(tokens::Token::Word(w)) if w == "do" => break, - Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => { - let is_quoted = - matches!(&self.current_token, Some(tokens::Token::QuotedWord(_))); + Some(tokens::Token::Word(w)) + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { + let is_quoted = matches!( + &self.current_token, + Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) + ); let mut word = self.parse_word(w.clone()); if is_quoted { word.quoted = true; } + if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { + word.has_unquoted_glob = true; + } words.push(word); self.advance(); } @@ -847,6 +866,7 @@ impl<'a> Parser<'a> { words.push(Word { parts: vec![WordPart::Literal(w.clone())], quoted: true, + has_unquoted_glob: false, }); self.advance(); } @@ -930,7 +950,8 @@ impl<'a> Parser<'a> { } Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => { + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { // Don't add space when joining operator pairs like < + =3 → <=3 let skip_space = current_expr.ends_with('<') || current_expr.ends_with('>') @@ -1126,11 +1147,13 @@ impl<'a> Parser<'a> { Some(tokens::Token::Word(_)) | Some(tokens::Token::LiteralWord(_)) | Some(tokens::Token::QuotedWord(_)) + | Some(tokens::Token::QuotedGlobWord(_)) ) { let w = match &self.current_token { Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => w.clone(), + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => w.clone(), _ => unreachable!(), }; patterns.push(self.parse_word(w)); @@ -1382,10 +1405,13 @@ impl<'a> Parser<'a> { } Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => { + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { let w_clone = w.clone(); - let is_quoted = - matches!(self.current_token, Some(tokens::Token::QuotedWord(_))); + let is_quoted = matches!( + self.current_token, + Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) + ); let is_literal = matches!(self.current_token, Some(tokens::Token::LiteralWord(_))); @@ -1415,12 +1441,16 @@ impl<'a> Parser<'a> { Word { parts: vec![WordPart::Literal(w_clone)], quoted: true, + has_unquoted_glob: false, } } else { let mut parsed = self.parse_word(w_clone); if is_quoted { parsed.quoted = true; } + if matches!(self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { + parsed.has_unquoted_glob = true; + } parsed }; words.push(word); @@ -1485,7 +1515,8 @@ impl<'a> Parser<'a> { } Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => { + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { pattern.push_str(w); self.advance(); } @@ -1541,7 +1572,8 @@ impl<'a> Parser<'a> { } Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => { + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { if !expr.is_empty() && !expr.ends_with(' ') && !expr.ends_with('(') { expr.push(' '); } @@ -1924,15 +1956,20 @@ impl<'a> Parser<'a> { } Some(tokens::Token::Word(elem)) | Some(tokens::Token::LiteralWord(elem)) - | Some(tokens::Token::QuotedWord(elem)) => { + | Some(tokens::Token::QuotedWord(elem)) + | Some(tokens::Token::QuotedGlobWord(elem)) => { let elem_clone = elem.clone(); let word = if matches!(&self.current_token, Some(tokens::Token::LiteralWord(_))) { Word { parts: vec![WordPart::Literal(elem_clone)], quoted: true, + has_unquoted_glob: false, } - } else if matches!(&self.current_token, Some(tokens::Token::QuotedWord(_))) { + } else if matches!( + &self.current_token, + Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) + ) { let mut w = self.parse_word(elem_clone); w.quoted = true; w @@ -2025,6 +2062,7 @@ impl<'a> Parser<'a> { Word { parts: vec![WordPart::Literal(inner.to_string())], quoted: true, + has_unquoted_glob: false, } } else { self.parse_word(value_str) @@ -2059,7 +2097,8 @@ impl<'a> Parser<'a> { } Some(tokens::Token::Word(elem)) | Some(tokens::Token::LiteralWord(elem)) - | Some(tokens::Token::QuotedWord(elem)) => { + | Some(tokens::Token::QuotedWord(elem)) + | Some(tokens::Token::QuotedGlobWord(elem)) => { if !compound.ends_with('(') { compound.push(' '); } @@ -2086,7 +2125,9 @@ impl<'a> Parser<'a> { let (delimiter, quoted) = match &self.current_token { Some(tokens::Token::Word(w)) => (w.clone(), false), Some(tokens::Token::LiteralWord(w)) => (w.clone(), true), - Some(tokens::Token::QuotedWord(w)) => (w.clone(), true), + Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { + (w.clone(), true) + } _ => return Err(Error::parse("expected delimiter after <<".to_string())), }; @@ -2262,11 +2303,16 @@ impl<'a> Parser<'a> { match &self.current_token { Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => { + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { let is_literal = matches!(&self.current_token, Some(tokens::Token::LiteralWord(_))); - let is_quoted = - matches!(&self.current_token, Some(tokens::Token::QuotedWord(_))); + let is_quoted = matches!( + &self.current_token, + Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) + ); + let is_glob_quoted = + matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))); // Clone early to release borrow on self.current_token let w = w.clone(); @@ -2300,12 +2346,16 @@ impl<'a> Parser<'a> { Word { parts: vec![WordPart::Literal(w)], quoted: true, + has_unquoted_glob: false, } } else { let mut word = self.parse_word(w); if is_quoted { word.quoted = true; } + if is_glob_quoted { + word.has_unquoted_glob = true; + } word }; words.push(word); @@ -2316,12 +2366,16 @@ impl<'a> Parser<'a> { Word { parts: vec![WordPart::Literal(w)], quoted: true, + has_unquoted_glob: false, } } else { let mut word = self.parse_word(w); if is_quoted { word.quoted = true; } + if is_glob_quoted { + word.has_unquoted_glob = true; + } word }; words.push(word); @@ -2546,11 +2600,12 @@ impl<'a> Parser<'a> { let word = Word { parts: vec![WordPart::Literal(w.clone())], quoted: true, + has_unquoted_glob: false, }; self.advance(); Ok(word) } - Some(tokens::Token::QuotedWord(w)) => { + Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { // Double-quoted: parse for variable expansion let word = self.parse_word(w.clone()); self.advance(); @@ -2587,7 +2642,8 @@ impl<'a> Parser<'a> { cmd_str.push_str(w); self.advance(); } - Some(tokens::Token::QuotedWord(w)) => { + Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => { if !cmd_str.is_empty() { cmd_str.push(' '); } @@ -2787,6 +2843,7 @@ impl<'a> Parser<'a> { Ok(Word { parts: vec![WordPart::ProcessSubstitution { commands, is_input }], quoted: false, + has_unquoted_glob: false, }) } _ => Err(self.error("expected word")), @@ -2798,12 +2855,13 @@ impl<'a> Parser<'a> { /// Convert current word token to Word (handles Word, LiteralWord, QuotedWord) fn current_word_to_word(&self) -> Option { match &self.current_token { - Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => { - Some(self.parse_word(w.clone())) - } + Some(tokens::Token::Word(w)) + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => Some(self.parse_word(w.clone())), Some(tokens::Token::LiteralWord(w)) => Some(Word { parts: vec![WordPart::Literal(w.clone())], quoted: true, + has_unquoted_glob: false, }), _ => None, } @@ -2817,6 +2875,7 @@ impl<'a> Parser<'a> { Some(tokens::Token::Word(_)) | Some(tokens::Token::LiteralWord(_)) | Some(tokens::Token::QuotedWord(_)) + | Some(tokens::Token::QuotedGlobWord(_)) ) } @@ -2826,7 +2885,8 @@ impl<'a> Parser<'a> { match &self.current_token { Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) => Some(w.clone()), + | Some(tokens::Token::QuotedWord(w)) + | Some(tokens::Token::QuotedGlobWord(w)) => Some(w.clone()), _ => None, } } @@ -3553,6 +3613,7 @@ impl<'a> Parser<'a> { Word { parts, quoted: false, + has_unquoted_glob: false, } } diff --git a/crates/bashkit/src/parser/tokens.rs b/crates/bashkit/src/parser/tokens.rs index 5b2bc01f..c1eaca15 100644 --- a/crates/bashkit/src/parser/tokens.rs +++ b/crates/bashkit/src/parser/tokens.rs @@ -17,6 +17,13 @@ pub enum Token { /// but is marked as quoted (affects heredoc delimiter semantics) QuotedWord(String), + /// A word that mixes quoted and unquoted segments where the unquoted + /// portion contains glob metacharacters (*, ?, [). Semantically + /// equivalent to QuotedWord for IFS splitting (suppressed), but the + /// interpreter must still perform glob expansion on the result. + /// Example: `"$var"*.ext` or `./"$dir"/*.log` + QuotedGlobWord(String), + /// Newline character Newline,