Skip to main content

shrub_parser/
pattern.rs

1//! Implements parsing for shell glob and extglob patterns.
2
3use crate::error;
4
5/// Represents the kind of an extended glob.
6pub enum ExtendedGlobKind {
7    /// The `+` extended glob; matches one or more occurrences of the inner
8    /// pattern.
9    Plus,
10    /// The `@` extended glob; allows matching an alternation of inner patterns.
11    At,
12    /// The `!` extended glob; matches the negation of the inner pattern.
13    Exclamation,
14    /// The `?` extended glob; matches zero or one occurrence of the inner
15    /// pattern.
16    Question,
17    /// The `*` extended glob; matches zero or more occurrences of the inner
18    /// pattern.
19    Star,
20}
21
22/// Converts a shell pattern to a regular expression string.
23///
24/// # Arguments
25///
26/// * `pattern` - The shell pattern to convert.
27/// * `enable_extended_globbing` - Whether to enable extended globbing
28///   (extglob).
29///
30/// # Errors
31///
32/// Returns [`crate::WordParseError::Pattern`] if `pattern` is not valid shell
33/// pattern syntax.
34pub fn pattern_to_regex_str(
35    pattern: &str,
36    enable_extended_globbing: bool,
37) -> Result<String, error::WordParseError> {
38    let regex_str = pattern_to_regex_translator::pattern(pattern, enable_extended_globbing)
39        .map_err(|e| error::WordParseError::Pattern(e.into()))?;
40    Ok(regex_str)
41}
42
43/// Formats a range between two bracket expression members, returning `None` if
44/// the range is reversed (and therefore matches nothing).
45fn bracket_range(from: (String, char), to: (String, char)) -> Option<String> {
46    let (from_str, from_c) = from;
47    let (to_str, to_c) = to;
48
49    if from_c <= to_c {
50        Some(std::format!("{from_str}-{to_str}"))
51    } else {
52        None
53    }
54}
55
56peg::parser! {
57    grammar pattern_to_regex_translator(enable_extended_globbing: bool) for str {
58        pub(crate) rule pattern() -> String =
59            pieces:(pattern_piece()*) {
60                pieces.join("")
61            }
62
63        rule pattern_piece() -> String =
64            escape_sequence() /
65            bracket_expression() /
66            extglob_enabled() s:extended_glob_pattern() { s } /
67            wildcard() /
68            [c if regex_char_needs_escaping(c)] {
69                let mut s = '\\'.to_string();
70                s.push(c);
71                s
72            } /
73            [c] { c.to_string() }
74
75        rule escape_sequence() -> String =
76            sequence:$(['\\'] [c if regex_char_needs_escaping(c)]) { sequence.to_owned() } /
77            ['\\'] [c] { c.to_string() }
78
79        rule bracket_expression() -> String =
80            "[" invert:(invert_char()?) leading:(leading_bracket_member()?) members:bracket_member()* "]" {
81                let mut members = leading
82                    .into_iter()
83                    .chain(members)
84                    .flatten()
85                    .collect::<Vec<_>>();
86
87                // If we completed the parse but ended up with no valid members
88                // of the bracket expression, then return a regex that matches nothing.
89                // (Or in the inverted case, matches everything.)
90                if members.is_empty() {
91                    if invert.is_some() {
92                        String::from(".")
93                    } else {
94                        String::from("(?!)")
95                    }
96                } else {
97                    if invert.is_some() {
98                        members.insert(0, String::from("^"));
99                    }
100
101                    std::format!("[{}]", members.join(""))
102                }
103            }
104
105        // A ']' first in the expression (after any '!' or '^') is a member, not the end.
106        // It may also be the low end of a range, so it goes through the same range
107        // validation as any other member.
108        rule leading_bracket_member() -> Option<String> =
109            from:close_bracket_member() "-" to:single_char_bracket_member() {
110                bracket_range(from, to)
111            } /
112            from:close_bracket_member() { Some(from.0) }
113
114        rule close_bracket_member() -> (String, char) =
115            "]" { (String::from(r"\]"), ']') }
116
117        rule invert_char() -> bool =
118            ['!' | '^'] { true }
119
120        rule bracket_member() -> Option<String> =
121            e:char_class_expression() { Some(e) } /
122            r:char_range() { r } /
123            m:single_char_bracket_member() {
124                let (char_str, _) = m;
125                Some(char_str)
126            }
127
128        rule char_class_expression() -> String =
129            e:$("[:" char_class() ":]") { e.to_owned() }
130
131        rule char_class() =
132            "alnum" / "alpha" / "blank" / "cntrl" / "digit" / "graph" / "lower" / "print" / "punct" / "space" / "upper"/ "xdigit"
133
134        rule char_range() -> Option<String> =
135            from:single_char_bracket_member() "-" to:single_char_bracket_member() {
136                bracket_range(from, to)
137            }
138
139        rule single_char_bracket_member() -> (String, char) =
140            // Preserve escaped characters as-is.
141            ['\\'] [c] { (std::format!("\\{c}"), c) } /
142            // Escape the characters that are special inside a regex character class;
143            // left bare, a '^' first in the class would negate it and a '-' would be
144            // read as a range separator.
145            [c if matches!(c, '[' | '^' | '-')] { (std::format!("\\{c}"), c) } /
146            // Any other character except closing bracket gets added as-is.
147            [c if c != ']'] { (c.to_string(), c) }
148
149        rule wildcard() -> String =
150            "?" { String::from(".") } /
151            "*" { String::from(".*") }
152
153        rule extglob_enabled() -> () =
154            &[_] {? if enable_extended_globbing { Ok(()) } else { Err("extglob disabled") } }
155
156        pub(crate) rule extended_glob_pattern() -> String =
157            kind:extended_glob_prefix() "(" branches:extended_glob_body() ")" {
158                let mut s = String::new();
159
160                // fancy_regex uses ?! to indicate a negative lookahead.
161                if matches!(kind, ExtendedGlobKind::Exclamation) {
162                    if !branches.is_empty() {
163                        s.push_str("(?:(?!");
164                        s.push_str(&branches.join("|"));
165                        s.push_str(").*|(?>");
166                        s.push_str(&branches.join("|"));
167                        s.push_str(").+?|)");
168                    } else {
169                        s.push_str("(?:.+)");
170                    }
171                } else {
172                    s.push('(');
173                    s.push_str(&branches.join("|"));
174                    s.push(')');
175
176                    match kind {
177                        ExtendedGlobKind::Plus => s.push('+'),
178                        ExtendedGlobKind::Question => s.push('?'),
179                        ExtendedGlobKind::Star => s.push('*'),
180                        ExtendedGlobKind::At | ExtendedGlobKind::Exclamation => (),
181                    }
182                }
183
184                s
185            }
186
187        rule extended_glob_prefix() -> ExtendedGlobKind =
188            "+" { ExtendedGlobKind::Plus } /
189            "@" { ExtendedGlobKind::At } /
190            "!" { ExtendedGlobKind::Exclamation } /
191            "?" { ExtendedGlobKind::Question } /
192            "*" { ExtendedGlobKind::Star }
193
194        pub(crate) rule extended_glob_body() -> Vec<String> =
195            // Cover case with *no* branches.
196            &[')'] { vec![] } /
197            // Otherwise, look for branches separated by '|'.
198            extended_glob_branch() ** "|"
199
200        rule extended_glob_branch() -> String =
201            // Cover case of empty branch.
202            &['|' | ')'] { String::new() } /
203            pieces:(!['|' | ')'] piece:pattern_piece() { piece })+ {
204                pieces.join("")
205            }
206
207        // A glob metacharacter construct: wildcard, bracket expression, or extglob.
208        rule glob_piece() =
209            bracket_expression() /
210            extglob_enabled() extended_glob_pattern() /
211            wildcard()
212
213        // A non-glob piece: an escape sequence or any character not starting a glob.
214        rule non_glob_piece() =
215            escape_sequence() /
216            !glob_piece() [_]
217
218        // Succeeds (returning true) if the pattern contains at least one glob
219        // metacharacter. The same bracket_expression, wildcard, and
220        // extended_glob_pattern rules used for regex conversion are reused here
221        // via negative lookaheads, keeping a single source of truth.
222        pub(crate) rule has_glob_metacharacters() -> bool =
223            non_glob_piece()* glob_piece() [_]* { true }
224    }
225}
226
227/// Returns whether a pattern string contains any glob metacharacters.
228///
229/// Uses the same PEG grammar rules that `pattern_to_regex_str` uses, keeping
230/// a single source of truth for what constitutes a glob metacharacter.
231///
232/// # Arguments
233///
234/// * `pattern` - The shell pattern to check.
235/// * `enable_extended_globbing` - Whether to enable extended globbing
236///   (extglob).
237pub fn pattern_has_glob_metacharacters(pattern: &str, enable_extended_globbing: bool) -> bool {
238    pattern_to_regex_translator::has_glob_metacharacters(pattern, enable_extended_globbing)
239        .unwrap_or(false)
240}
241
242/// Returns whether or not a given character needs to be escaped in a regular
243/// expression.
244///
245/// # Arguments
246///
247/// * `c` - The character to check.
248pub const fn regex_char_needs_escaping(c: char) -> bool {
249    matches!(
250        c,
251        '[' | ']' | '(' | ')' | '{' | '}' | '*' | '?' | '.' | '+' | '^' | '$' | '|' | '\\' | '-'
252    )
253}
254
255#[cfg(test)]
256#[expect(clippy::panic_in_result_fn)]
257mod tests {
258    use anyhow::Result;
259
260    use super::*;
261
262    #[test]
263    fn test_bracket_exprs() -> Result<()> {
264        assert_eq!(pattern_to_regex_str("[a-z]", true)?, "[a-z]");
265        assert_eq!(pattern_to_regex_str("[z-a]", true)?, "(?!)");
266        assert_eq!(pattern_to_regex_str("[+-/]", true)?, "[+-/]");
267        assert_eq!(pattern_to_regex_str(r"[\*-/]", true)?, r"[\*-/]");
268        assert_eq!(pattern_to_regex_str("[abc]", true)?, "[abc]");
269        assert_eq!(pattern_to_regex_str(r"[\(]", true)?, r"[\(]");
270        assert_eq!(pattern_to_regex_str(r"[(]", true)?, "[(]");
271        assert_eq!(pattern_to_regex_str("[[:digit:]]", true)?, "[[:digit:]]");
272        assert_eq!(pattern_to_regex_str(r"[-(),!]*", true)?, r"[\-(),!].*");
273        assert_eq!(
274            pattern_to_regex_str(r"[-\(\),\!]*", true)?,
275            r"[\-\(\),\!].*"
276        );
277        assert_eq!(pattern_to_regex_str(r"[a\-b]", true)?, r"[a\-b]");
278        assert_eq!(pattern_to_regex_str(r"[a\-\*]", true)?, r"[a\-\*]");
279
280        // A ']' in the first member position (after any '!' or '^') is a member
281        // of the bracket expression, not its terminator.
282        assert_eq!(pattern_to_regex_str("[]]", true)?, r"[\]]");
283        assert_eq!(pattern_to_regex_str("[][]", true)?, r"[\]\[]");
284        assert_eq!(pattern_to_regex_str("[]abc[]", true)?, r"[\]abc\[]");
285        assert_eq!(pattern_to_regex_str("[]]]", true)?, r"[\]]\]");
286        assert_eq!(pattern_to_regex_str("[!]]", true)?, r"[^\]]");
287        assert_eq!(pattern_to_regex_str("[^][]", true)?, r"[^\]\[]");
288
289        // A range starting at that ']' is validated like any other range.
290        assert_eq!(pattern_to_regex_str("[]-a]", true)?, r"[\]-a]");
291        assert_eq!(pattern_to_regex_str("[]-a[]", true)?, r"[\]-a\[]");
292        assert_eq!(pattern_to_regex_str("[]-A]", true)?, "(?!)");
293        assert_eq!(pattern_to_regex_str("[!]-A]", true)?, ".");
294
295        // '^' and '-' are escaped as members, so a '^' left first by a dropped
296        // range can't negate the class and a '-' endpoint can't be read as a
297        // range separator.
298        assert_eq!(pattern_to_regex_str("[9-0^a]", true)?, r"[\^a]");
299        assert_eq!(pattern_to_regex_str("[z-a^]", true)?, r"[\^]");
300        assert_eq!(pattern_to_regex_str("[+--]", true)?, r"[+-\-]");
301        assert_eq!(pattern_to_regex_str("[--/]", true)?, r"[\--/]");
302
303        // An unterminated bracket expression is still just literal text.
304        assert_eq!(pattern_to_regex_str("[]", true)?, r"\[\]");
305        assert_eq!(pattern_to_regex_str("[!]", true)?, r"\[!\]");
306
307        Ok(())
308    }
309
310    #[test]
311    fn test_extended_glob() -> Result<()> {
312        assert_eq!(
313            pattern_to_regex_translator::extended_glob_pattern("@(a|b)", true)?,
314            "(a|b)"
315        );
316
317        assert_eq!(
318            pattern_to_regex_translator::extended_glob_pattern("@(|a)", true)?,
319            "(|a)"
320        );
321
322        assert_eq!(
323            pattern_to_regex_translator::extended_glob_pattern("@(|)", true)?,
324            "(|)"
325        );
326
327        assert_eq!(
328            pattern_to_regex_translator::extended_glob_body("ab|ac", true)?,
329            vec!["ab", "ac"],
330        );
331
332        assert_eq!(
333            pattern_to_regex_translator::extended_glob_pattern("*(ab|ac)", true)?,
334            "(ab|ac)*"
335        );
336
337        assert_eq!(
338            pattern_to_regex_translator::extended_glob_body("", true)?,
339            Vec::<String>::new(),
340        );
341
342        Ok(())
343    }
344
345    #[test]
346    fn test_has_glob_metacharacters() {
347        // Basic metacharacters.
348        assert!(pattern_has_glob_metacharacters("*", false));
349        assert!(pattern_has_glob_metacharacters("?", false));
350        assert!(pattern_has_glob_metacharacters("a*b", false));
351        assert!(pattern_has_glob_metacharacters("a?b", false));
352
353        // Valid bracket expressions.
354        assert!(pattern_has_glob_metacharacters("[abc]", false));
355        assert!(pattern_has_glob_metacharacters("[a-z]", false));
356        assert!(pattern_has_glob_metacharacters("[!a]", false));
357
358        // A leading ']' is a member, so these are bracket expressions too.
359        assert!(pattern_has_glob_metacharacters("[]]", false));
360        assert!(pattern_has_glob_metacharacters("[][]", false));
361        assert!(pattern_has_glob_metacharacters("[!]]", false));
362
363        // Lone `]` is NOT a glob metacharacter.
364        assert!(!pattern_has_glob_metacharacters("]", false));
365        assert!(!pattern_has_glob_metacharacters("foo]", false));
366        assert!(!pattern_has_glob_metacharacters("a]b", false));
367
368        // Lone `[` without matching `]` is NOT a glob metacharacter.
369        assert!(!pattern_has_glob_metacharacters("[", false));
370        assert!(!pattern_has_glob_metacharacters("[]", false));
371        assert!(!pattern_has_glob_metacharacters("[!]", false));
372        assert!(!pattern_has_glob_metacharacters("[abc", false));
373        assert!(!pattern_has_glob_metacharacters("a[b", false));
374
375        // Plain text — no glob chars.
376        assert!(!pattern_has_glob_metacharacters("hello", false));
377        assert!(!pattern_has_glob_metacharacters("", false));
378
379        // Backslash-escaped metacharacters are not globs.
380        assert!(!pattern_has_glob_metacharacters(r"\*", false));
381        assert!(!pattern_has_glob_metacharacters(r"\?", false));
382        assert!(!pattern_has_glob_metacharacters(r"\[abc]", false));
383
384        // Extglob patterns — not detected without extended globbing.
385        assert!(!pattern_has_glob_metacharacters("@(a)", false));
386        assert!(!pattern_has_glob_metacharacters("!(a)", false));
387        assert!(!pattern_has_glob_metacharacters("+(a)", false));
388
389        // Extglob patterns — detected with extended globbing.
390        assert!(pattern_has_glob_metacharacters("@(a)", true));
391        assert!(pattern_has_glob_metacharacters("!(a)", true));
392        assert!(pattern_has_glob_metacharacters("+(a)", true));
393
394        // *( and ?( are already caught by * and ? checks.
395        assert!(pattern_has_glob_metacharacters("*(a)", false));
396        assert!(pattern_has_glob_metacharacters("?(a)", false));
397    }
398}