Skip to main content

fdu_core/query/
query_glob.rs

1//! Glob patterns for selecting entries out of a built index.
2//!
3//! # Why this is first-party
4//!
5//! `globset` is the obvious dependency and a good one, but the selection axis is
6//! feature-independent — Rust callers and the Python bindings get the same filters the
7//! CLI does — so it would land in the core crate's always-on tree, which today holds
8//! exactly one crate. `globset` brings roughly six transitive crates with it, against a
9//! stated policy of keeping that list short, to match a handful of user-supplied patterns
10//! at query time rather than the hundreds of ignore rules per walked entry that motivate
11//! its automaton. The escape hatch is deliberate and cheap: if the pattern language grows
12//! toward regexes or real gitignore semantics, or a measurement shows matching on the hot
13//! path, `globset`/`ignore` goes through the dependency cool-off and this module is
14//! deleted. Until then the language stays closed, and closed is what makes it testable.
15//!
16//! # The language
17//!
18//! - `*` matches any run of characters within one path component, including none.
19//! - `?` matches exactly one character within a component.
20//! - `**` as a whole component matches zero or more components.
21//! - `[abc]`, `[a-z]`, `[!abc]` match one character from a class.
22//! - `{a,b}` expands to alternatives before matching, so `*.{rs,toml}` is two patterns.
23//! - `\` escapes the next character.
24//!
25//! A pattern containing `/` matches against an entry's path relative to the index root;
26//! a pattern without one matches against the entry's file name at any depth. That is the
27//! convention `fd` and gitignore use, and it is why `--include '*.rs'` finds every Rust
28//! file in the tree rather than only those in the root.
29
30use std::path::Path;
31
32use crate::engine_contract::{Error, Result};
33
34/// How many alternatives one pattern may expand to before it is rejected.
35///
36/// Nested braces multiply, so a pattern like `{a,b}{c,d}{e,f}…` can expand without bound.
37/// The cap turns that into an error at parse time instead of an allocation the caller
38/// never asked for.
39const MAX_ALTERNATIVES: usize = 1_024;
40
41/// A compiled glob pattern.
42#[derive(Clone, Debug)]
43pub struct Pattern {
44    /// Brace-free alternatives; the pattern matches when any of them does.
45    alternatives: Vec<Alternative>,
46    /// Whether the pattern is matched against the whole relative path.
47    anchored: bool,
48    /// The pattern as written, for diagnostics.
49    source: String,
50}
51
52/// One brace-free alternative, split into path components.
53#[derive(Clone, Debug)]
54struct Alternative {
55    components: Vec<Component>,
56}
57
58/// One path component of a pattern.
59#[derive(Clone, Debug)]
60enum Component {
61    /// `**`: zero or more whole components.
62    AnyComponents,
63    /// Tokens matched against exactly one component.
64    Tokens(Vec<Token>),
65}
66
67/// One matching unit inside a component.
68#[derive(Clone, Debug)]
69enum Token {
70    /// A literal character.
71    Literal(char),
72    /// `?`: exactly one character.
73    AnyChar,
74    /// `*`: any run of characters, including none.
75    AnyRun,
76    /// A character class.
77    Class {
78        /// Whether the class is negated with `!` or `^`.
79        negated: bool,
80        /// Inclusive character ranges; a single character is a range to itself.
81        ranges: Vec<(char, char)>,
82    },
83}
84
85impl Pattern {
86    /// Compile a pattern, expanding braces.
87    pub fn parse(source: &str) -> Result<Self> {
88        if source.is_empty() {
89            return Err(glob_error(source, "expected a pattern, as in `*.rs` or `src/**/*.rs`"));
90        }
91
92        let expanded = expand_braces(source)?;
93        let anchored = source.contains('/');
94        let mut alternatives = Vec::with_capacity(expanded.len());
95        for candidate in expanded {
96            alternatives.push(Alternative { components: parse_components(source, &candidate)? });
97        }
98        Ok(Self { alternatives, anchored, source: source.to_string() })
99    }
100
101    /// Whether this pattern matches an entry.
102    ///
103    /// `relative` is the entry's path below the index root, and `name` its final
104    /// component; both are supplied because which one applies depends on the pattern.
105    pub fn matches(&self, relative: &Path, name: &str) -> bool {
106        if self.anchored {
107            // Components, not a split on '/': Windows spells the separator differently,
108            // and splitting on one character would leave `src\\main.rs` as a single
109            // component so every anchored pattern silently failed to match there.
110            let parts: Vec<String> = relative
111                .components()
112                .filter_map(|component| match component {
113                    std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
114                    _ => None,
115                })
116                .collect();
117            let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
118            self.alternatives.iter().any(|alt| match_components(&alt.components, &parts))
119        } else {
120            self.alternatives.iter().any(|alt| match_components(&alt.components, &[name]))
121        }
122    }
123
124    /// The pattern as it was written.
125    pub fn source(&self) -> &str {
126        &self.source
127    }
128
129    /// Heap payload retained when this compiled pattern is cloned into live query state.
130    pub(crate) fn retained_heap_bytes(&self) -> usize {
131        let alternatives = self.alternatives.iter().fold(0_usize, |total, alternative| {
132            let components = alternative.components.iter().fold(0_usize, |total, component| {
133                let nested = match component {
134                    Component::AnyComponents => 0,
135                    Component::Tokens(tokens) => tokens.iter().fold(
136                        tokens.capacity().saturating_mul(std::mem::size_of::<Token>()),
137                        |total, token| match token {
138                            Token::Class { ranges, .. } => total.saturating_add(
139                                ranges
140                                    .capacity()
141                                    .saturating_mul(std::mem::size_of::<(char, char)>()),
142                            ),
143                            Token::Literal(_) | Token::AnyChar | Token::AnyRun => total,
144                        },
145                    ),
146                };
147                total.saturating_add(nested)
148            });
149            total
150                .saturating_add(
151                    alternative
152                        .components
153                        .capacity()
154                        .saturating_mul(std::mem::size_of::<Component>()),
155                )
156                .saturating_add(components)
157        });
158        self.source
159            .capacity()
160            .saturating_add(
161                self.alternatives.capacity().saturating_mul(std::mem::size_of::<Alternative>()),
162            )
163            .saturating_add(alternatives)
164    }
165}
166
167/// Expand `{a,b}` alternation into brace-free patterns.
168fn expand_braces(source: &str) -> Result<Vec<String>> {
169    let mut pending = vec![String::new()];
170    let mut chars = source.chars().peekable();
171
172    while let Some(ch) = chars.next() {
173        match ch {
174            '\\' => {
175                let escaped = chars
176                    .next()
177                    .ok_or_else(|| glob_error(source, "pattern ends with a trailing `\\`"))?;
178                for candidate in &mut pending {
179                    candidate.push('\\');
180                    candidate.push(escaped);
181                }
182            }
183            '{' => {
184                let group = take_group(source, &mut chars)?;
185                let branches = split_branches(&group);
186                let mut grown = Vec::with_capacity(pending.len() * branches.len());
187                for candidate in &pending {
188                    for branch in &branches {
189                        // Branches may themselves contain braces, so each is expanded in
190                        // turn; the cap below is what keeps that from running away.
191                        for nested in expand_braces(branch)? {
192                            grown.push(format!("{candidate}{nested}"));
193                        }
194                    }
195                }
196                if grown.len() > MAX_ALTERNATIVES {
197                    return Err(glob_error(
198                        source,
199                        "pattern expands to too many alternatives; write several patterns instead",
200                    ));
201                }
202                pending = grown;
203            }
204            '}' => return Err(glob_error(source, "unmatched `}` in pattern")),
205            _ => {
206                for candidate in &mut pending {
207                    candidate.push(ch);
208                }
209            }
210        }
211    }
212
213    Ok(pending)
214}
215
216/// Read a brace group's contents, honoring nesting and escapes.
217fn take_group(source: &str, chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String> {
218    let mut depth = 1usize;
219    let mut group = String::new();
220    for ch in chars.by_ref() {
221        match ch {
222            '{' => {
223                depth += 1;
224                group.push(ch);
225            }
226            '}' => {
227                depth -= 1;
228                if depth == 0 {
229                    return Ok(group);
230                }
231                group.push(ch);
232            }
233            _ => group.push(ch),
234        }
235    }
236    Err(glob_error(source, "unmatched `{` in pattern"))
237}
238
239/// Split a brace group on top-level commas.
240fn split_branches(group: &str) -> Vec<String> {
241    let mut branches = Vec::new();
242    let mut current = String::new();
243    let mut depth = 0usize;
244    let mut chars = group.chars();
245    while let Some(ch) = chars.next() {
246        match ch {
247            '\\' => {
248                current.push(ch);
249                if let Some(escaped) = chars.next() {
250                    current.push(escaped);
251                }
252            }
253            '{' => {
254                depth += 1;
255                current.push(ch);
256            }
257            '}' => {
258                depth = depth.saturating_sub(1);
259                current.push(ch);
260            }
261            ',' if depth == 0 => branches.push(std::mem::take(&mut current)),
262            _ => current.push(ch),
263        }
264    }
265    branches.push(current);
266    branches
267}
268
269/// Split a brace-free pattern into components and tokenize each.
270fn parse_components(source: &str, pattern: &str) -> Result<Vec<Component>> {
271    let mut components = Vec::new();
272    for part in pattern.split('/') {
273        if part.is_empty() {
274            // A leading or doubled `/` carries no matching meaning; the path side filters
275            // empty components too, so both sides agree.
276            continue;
277        }
278        if part == "**" {
279            components.push(Component::AnyComponents);
280        } else {
281            components.push(Component::Tokens(tokenize(source, part)?));
282        }
283    }
284    Ok(components)
285}
286
287/// Tokenize one component.
288fn tokenize(source: &str, part: &str) -> Result<Vec<Token>> {
289    let mut tokens = Vec::new();
290    let mut chars = part.chars().peekable();
291    while let Some(ch) = chars.next() {
292        match ch {
293            '*' => {
294                // `***` and friends collapse: repeated stars inside a component add
295                // nothing, and collapsing keeps the matcher from backtracking over them.
296                while chars.peek() == Some(&'*') {
297                    chars.next();
298                }
299                tokens.push(Token::AnyRun);
300            }
301            '?' => tokens.push(Token::AnyChar),
302            '[' => tokens.push(parse_class(source, &mut chars)?),
303            '\\' => {
304                let escaped = chars
305                    .next()
306                    .ok_or_else(|| glob_error(source, "pattern ends with a trailing `\\`"))?;
307                tokens.push(Token::Literal(escaped));
308            }
309            _ => tokens.push(Token::Literal(ch)),
310        }
311    }
312    Ok(tokens)
313}
314
315/// Parse a `[...]` character class.
316fn parse_class(source: &str, chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<Token> {
317    let negated = matches!(chars.peek(), Some('!' | '^'));
318    if negated {
319        chars.next();
320    }
321
322    let mut ranges = Vec::new();
323    let mut first = true;
324    while let Some(ch) = chars.next() {
325        // A `]` in the first position is a literal, per the shell convention.
326        if ch == ']' && !first {
327            if ranges.is_empty() {
328                return Err(glob_error(source, "empty character class in pattern"));
329            }
330            return Ok(Token::Class { negated, ranges });
331        }
332        first = false;
333
334        let start = if ch == '\\' {
335            chars.next().ok_or_else(|| glob_error(source, "pattern ends with a trailing `\\`"))?
336        } else {
337            ch
338        };
339
340        if chars.peek() == Some(&'-') {
341            chars.next();
342            match chars.next() {
343                // A `-` before the closing bracket is a literal `-`.
344                Some(']') => {
345                    ranges.push((start, start));
346                    ranges.push(('-', '-'));
347                    return Ok(Token::Class { negated, ranges });
348                }
349                Some(end) => ranges.push((start, end)),
350                None => return Err(glob_error(source, "unmatched `[` in pattern")),
351            }
352        } else {
353            ranges.push((start, start));
354        }
355    }
356    Err(glob_error(source, "unmatched `[` in pattern"))
357}
358
359/// Match a component list against a path's components.
360fn match_components(pattern: &[Component], parts: &[&str]) -> bool {
361    match pattern.split_first() {
362        None => parts.is_empty(),
363        Some((Component::AnyComponents, rest)) => {
364            // `**` consumes zero or more whole components; try each split.
365            (0..=parts.len()).any(|skip| match_components(rest, &parts[skip..]))
366        }
367        Some((Component::Tokens(tokens), rest)) => match parts.split_first() {
368            Some((part, remaining)) => {
369                match_tokens(tokens, part) && match_components(rest, remaining)
370            }
371            None => false,
372        },
373    }
374}
375
376/// Match one component's tokens against one path component.
377fn match_tokens(tokens: &[Token], part: &str) -> bool {
378    let chars: Vec<char> = part.chars().collect();
379    match_tokens_at(tokens, &chars)
380}
381
382/// Backtracking matcher over one component.
383fn match_tokens_at(tokens: &[Token], text: &[char]) -> bool {
384    match tokens.split_first() {
385        None => text.is_empty(),
386        Some((Token::AnyRun, rest)) => {
387            (0..=text.len()).any(|skip| match_tokens_at(rest, &text[skip..]))
388        }
389        Some((token, rest)) => match text.split_first() {
390            Some((ch, remaining)) => match_one(token, *ch) && match_tokens_at(rest, remaining),
391            None => false,
392        },
393    }
394}
395
396/// Whether a single token matches a single character.
397fn match_one(token: &Token, ch: char) -> bool {
398    match token {
399        Token::Literal(expected) => *expected == ch,
400        Token::AnyChar => true,
401        Token::Class { negated, ranges } => {
402            let inside = ranges.iter().any(|(start, end)| *start <= ch && ch <= *end);
403            inside != *negated
404        }
405        // Handled by the caller, which must special-case the variable-width token.
406        Token::AnyRun => false,
407    }
408}
409
410/// Build a pattern rejection.
411fn glob_error(source: &str, hint: &str) -> Error {
412    Error::InvalidValue { kind: "pattern", value: source.to_string(), hint: hint.to_string() }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use std::path::PathBuf;
419
420    fn matches(pattern: &str, path: &str) -> bool {
421        let compiled = Pattern::parse(pattern).expect("pattern compiles");
422        let relative = PathBuf::from(path);
423        let name = relative
424            .file_name()
425            .map(|name| name.to_string_lossy().into_owned())
426            .unwrap_or_default();
427        compiled.matches(&relative, &name)
428    }
429
430    fn rejection(pattern: &str) -> String {
431        match Pattern::parse(pattern) {
432            Err(Error::InvalidValue { kind: "pattern", hint, .. }) => hint,
433            other => panic!("expected {pattern:?} to be rejected, got {other:?}"),
434        }
435    }
436
437    #[test]
438    fn bare_patterns_match_the_file_name_at_any_depth() {
439        // The convention that makes `--include '*.rs'` mean what a user expects.
440        assert!(matches("*.rs", "main.rs"));
441        assert!(matches("*.rs", "src/deep/nested/main.rs"));
442        assert!(!matches("*.rs", "src/main.toml"));
443        assert!(matches("main.rs", "a/b/c/main.rs"));
444    }
445
446    #[test]
447    fn patterns_with_a_separator_match_the_whole_relative_path() {
448        assert!(matches("src/*.rs", "src/main.rs"));
449        assert!(!matches("src/*.rs", "other/main.rs"));
450        // A single `*` does not cross a component boundary.
451        assert!(!matches("src/*.rs", "src/deep/main.rs"));
452    }
453
454    #[test]
455    fn double_star_crosses_component_boundaries_including_none() {
456        assert!(matches("src/**/*.rs", "src/main.rs"), "** matches zero components");
457        assert!(matches("src/**/*.rs", "src/a/main.rs"));
458        assert!(matches("src/**/*.rs", "src/a/b/c/main.rs"));
459        assert!(!matches("src/**/*.rs", "other/a/main.rs"));
460        assert!(matches("**/target/**", "a/b/target/c/d"));
461    }
462
463    #[test]
464    fn braces_expand_to_alternatives() {
465        assert!(matches("*.{rs,toml}", "main.rs"));
466        assert!(matches("*.{rs,toml}", "Cargo.toml"));
467        assert!(!matches("*.{rs,toml}", "notes.md"));
468        // Nested braces expand too.
469        assert!(matches("{src,tests}/*.{rs,md}", "tests/readme.md"));
470        assert!(!matches("{src,tests}/*.{rs,md}", "docs/readme.md"));
471    }
472
473    #[test]
474    fn character_classes_match_one_character() {
475        assert!(matches("file[0-9].txt", "file7.txt"));
476        assert!(!matches("file[0-9].txt", "filex.txt"));
477        assert!(matches("file[!0-9].txt", "filex.txt"));
478        assert!(!matches("file[!0-9].txt", "file7.txt"));
479        assert!(matches("[abc]at", "cat"));
480    }
481
482    #[test]
483    fn question_mark_matches_exactly_one_character() {
484        assert!(matches("?.rs", "a.rs"));
485        assert!(!matches("?.rs", "ab.rs"));
486        assert!(!matches("?.rs", ".rs"));
487    }
488
489    #[test]
490    fn escapes_make_metacharacters_literal() {
491        assert!(matches(r"\*.rs", "*.rs"));
492        assert!(!matches(r"\*.rs", "main.rs"));
493        assert!(matches(r"a\?b", "a?b"));
494    }
495
496    #[test]
497    fn stars_match_empty_runs() {
498        assert!(matches("*", "anything"));
499        assert!(matches("*.rs", ".rs"));
500        assert!(matches("a*b", "ab"));
501    }
502
503    #[test]
504    fn repeated_stars_inside_a_component_collapse() {
505        // `***` is not a third syntax; it is `*` written twice over.
506        assert!(matches("a***b", "axyzb"));
507        assert!(matches("a***b", "ab"));
508    }
509
510    #[test]
511    fn malformed_patterns_are_rejected_with_a_reason() {
512        assert!(rejection("").contains("expected a pattern"));
513        assert!(rejection("{a,b").contains("unmatched `{`"));
514        assert!(rejection("a}b").contains("unmatched `}`"));
515        assert!(rejection("[abc").contains("unmatched `[`"));
516        assert!(rejection("[]").contains("unmatched `[`"));
517        assert!(rejection(r"abc\").contains("trailing `\\`"));
518    }
519
520    #[test]
521    fn runaway_brace_expansion_is_rejected_rather_than_allocated() {
522        let bomb = "{a,b}".repeat(11);
523        assert!(rejection(&bomb).contains("too many alternatives"));
524    }
525
526    #[test]
527    fn anchored_patterns_match_however_the_platform_spells_a_separator() {
528        // Built from components rather than a literal string, so this exercises the
529        // native separator. Splitting the path on '/' passed here on Unix and silently
530        // matched nothing on Windows, which CI caught and this test now pins.
531        let relative: PathBuf = ["src", "deep", "main.rs"].iter().collect();
532        let compiled = Pattern::parse("src/**/*.rs").expect("pattern compiles");
533        assert!(compiled.matches(&relative, "main.rs"));
534
535        let shallow: PathBuf = ["src", "main.rs"].iter().collect();
536        assert!(Pattern::parse("src/*.rs").expect("compiles").matches(&shallow, "main.rs"));
537        assert!(!Pattern::parse("other/*.rs").expect("compiles").matches(&shallow, "main.rs"));
538    }
539
540    #[test]
541    fn source_is_retained_for_diagnostics() {
542        assert_eq!(Pattern::parse("*.rs").expect("compiles").source(), "*.rs");
543    }
544}