Skip to main content

qk_cache/
glob.rs

1//! Nx-compatible glob matching.
2//!
3//! Nx does not match extended globs directly. Patterns containing `|`, `(`, `{,`
4//! or a leading `!` are first expanded into plain globs, and that expansion is
5//! deliberately approximate: `+(a|b)` matches exactly one of `a` or `b`, `?(x)` and
6//! `*(x)` mean "absent or once", an absent group in a directory segment widens that
7//! segment to `*`, and a `?`, `+` or `@` not followed by `(` is dropped. The cache
8//! mirrors the expansion of Nx 23 (`packages/nx/src/native/glob/glob_transform.rs`)
9//! so input sets written for Nx select the same files.
10//!
11//! A negated group such as `!(a|b)` expands, as in Nx, into a glob with the
12//! group widened and one with the group's items that excludes: a path matches
13//! when an including glob does and no excluding one does. In a directory
14//! segment the exclusion covers that whole directory. `!(a).` and `!(a)*`
15//! widen to `*.` and `*`. Nx applies those exclusions to every pattern of a
16//! project together; qk applies them to the pattern they come from, which can
17//! only add inputs.
18
19use anyhow::{Result, bail};
20use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
21
22#[derive(Clone)]
23pub struct Pattern {
24    included: GlobSet,
25    excluded: GlobSet,
26}
27
28impl Pattern {
29    /// Compiles a glob without its leading `!`; `negated` says whether one was
30    /// there. Nx expands every negated pattern, and every glob it expands to
31    /// then excludes, so a negated pattern matches what any of them matches.
32    /// As in Nx, `\` escapes the character after it on every platform.
33    pub fn new(pattern: &str, negated: bool) -> Result<Self> {
34        Self::from_globs(&[pattern], negated)
35    }
36
37    /// A fileset entry: as in Nx 24, a path with no glob pattern names that
38    /// file, or that directory and everything under it.
39    pub fn fileset(pattern: &str, negated: bool) -> Result<Self> {
40        if pattern.is_empty() || pattern.ends_with('/') || !names_a_path(pattern) {
41            return Self::new(pattern, negated);
42        }
43        Self::from_globs(&[pattern, &format!("{pattern}/**")], negated)
44    }
45
46    fn from_globs(patterns: &[&str], negated: bool) -> Result<Self> {
47        let mut included = GlobSetBuilder::new();
48        let mut excluded = GlobSetBuilder::new();
49        for pattern in patterns {
50            let globs = if negated
51                || ["|", "(", "{,", "!"]
52                    .iter()
53                    .any(|token| unescaped(pattern).contains(token))
54            {
55                expand(pattern)?
56            } else {
57                vec![((*pattern).to_owned(), false)]
58            };
59            for (glob, excludes) in globs {
60                let glob = if glob.ends_with('/') {
61                    format!("{glob}**")
62                } else {
63                    glob
64                };
65                let glob = GlobBuilder::new(&glob)
66                    .literal_separator(true)
67                    .backslash_escape(true)
68                    .build()?;
69                if excludes && !negated {
70                    excluded.add(glob);
71                } else {
72                    included.add(glob);
73                }
74            }
75        }
76        Ok(Self {
77            included: included.build()?,
78            excluded: excluded.build()?,
79        })
80    }
81
82    /// As Nx's glob sets: an including glob matches and no excluding one, or,
83    /// with only excluding globs, none of them.
84    pub fn is_match(&self, path: impl AsRef<std::path::Path>) -> bool {
85        let path = path.as_ref();
86        let excluded = self.excluded.is_match(path);
87        if self.included.is_empty() {
88            !excluded
89        } else {
90            !excluded && self.included.is_match(path)
91        }
92    }
93}
94
95/// `pattern` with every escaped character, and its `\`, left out, so what
96/// remains is the syntax the pattern uses.
97fn unescaped(pattern: &str) -> String {
98    let mut result = String::new();
99    let mut chars = pattern.chars();
100    while let Some(c) = chars.next() {
101        if c == '\\' {
102            chars.next();
103        } else {
104            result.push(c);
105        }
106    }
107    result
108}
109
110/// `path` with every character a pattern reads as syntax escaped, so that as
111/// a pattern it names exactly that path.
112pub fn escape(path: &str) -> String {
113    let mut escaped = String::with_capacity(path.len());
114    for c in path.chars() {
115        if matches!(
116            c,
117            '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '!' | '@' | '+' | ',' | '|' | '\\'
118        ) {
119            escaped.push('\\');
120        }
121        escaped.push(c);
122    }
123    escaped
124}
125
126/// The path a pattern that [`names_a_path`] names: each `\` escape replaced
127/// by the character it escapes.
128pub fn literal_path(pattern: &str) -> String {
129    let mut path = String::new();
130    let mut chars = pattern.chars();
131    while let Some(c) = chars.next() {
132        path.push(if c == '\\' {
133            chars.next().unwrap_or('\\')
134        } else {
135            c
136        });
137    }
138    path
139}
140
141/// Whether `pattern` names a path rather than matching, as Nx 24 reads it:
142/// nothing in it is glob syntax once its escapes are read. `@` and `+` start
143/// a pattern only with a group after them, so `@types` and `c++` are names.
144pub fn names_a_path(pattern: &str) -> bool {
145    !unescaped(pattern).contains(['*', '?', '[', ']', '{', '}', '(', ')', '!'])
146}
147
148/// Whether a path segment needs matching rather than being a literal directory.
149pub fn is_literal(segment: &str) -> bool {
150    !segment.contains([
151        '!', '?', '@', '+', '*', '|', ',', '{', '}', '[', ']', '(', ')', '\\',
152    ])
153}
154
155#[derive(Debug, PartialEq)]
156enum Group {
157    /// `?(a|b)`, `*(a|b)` and `{,a}`: expanded as absent or once.
158    Optional(String),
159    /// `+(a|b)`, `@(a|b)` and `(a|b)`: expanded as exactly once.
160    Once(String),
161    /// `!(a|b)`.
162    Negated(String),
163    /// `!(a|b).`, which widens to `*.`.
164    NegatedFileName(String),
165    /// `!(a|b)*`, which widens to `*`.
166    NegatedWildcard(String),
167    Literal(String),
168}
169
170/// Expands one glob into plain globs, each with whether it excludes, sorted
171/// and deduplicated like Nx's `convert_glob`. An excluding glob from a
172/// directory segment ends there, with a `/`, and covers that directory.
173fn expand(pattern: &str) -> Result<Vec<(String, bool)>> {
174    let segments: Vec<_> = pattern.split('/').collect();
175    let mut built = Vec::new();
176    for (index, segment) in segments.iter().enumerate() {
177        let groups = parse_segment(segment)
178            .map_err(|error| anyhow::anyhow!("{error} in glob {pattern:?}"))?;
179        built.push(build_segment(
180            "",
181            &groups,
182            index == segments.len() - 1,
183            false,
184        ));
185    }
186    // Every combination of one option per segment, as Nx's cartesian product.
187    let mut products: Vec<Vec<&(String, bool)>> = vec![Vec::new()];
188    for options in &built {
189        products = products
190            .iter()
191            .flat_map(|product| {
192                options.iter().map(move |option| {
193                    let mut next = product.clone();
194                    next.push(option);
195                    next
196                })
197            })
198            .collect();
199    }
200    let mut globs: Vec<(String, bool)> = products
201        .into_iter()
202        .map(|product| {
203            let mut path = String::new();
204            let mut excludes = false;
205            let mut whole = false;
206            for (index, (segment, negative)) in product.iter().enumerate() {
207                whole = index == product.len() - 1;
208                path.push_str(segment);
209                path.push('/');
210                if *negative {
211                    excludes = true;
212                    if !whole {
213                        break;
214                    }
215                }
216            }
217            if whole {
218                path.pop();
219            }
220            (path, excludes)
221        })
222        .collect();
223    globs.sort_by(|a, b| (!a.1, &a.0).cmp(&(!b.1, &b.0)));
224    globs.dedup();
225    Ok(globs)
226}
227
228fn parse_segment(segment: &str) -> Result<Vec<Group>> {
229    let mut groups = Vec::new();
230    let mut rest = segment;
231    while !rest.is_empty() {
232        // Nx drops `?`, `+` and `@` when no group follows, e.g. `+spec` means `spec`.
233        if let Some(after) = rest.strip_prefix(['?', '+', '@'])
234            && !after.is_empty()
235            && !after.starts_with('(')
236        {
237            rest = after;
238        }
239        let parsed = [
240            ("(", ")", false),
241            ("*(", ")", true),
242            ("?(", ")", true),
243            ("+(", ")", false),
244            ("@(", ")", false),
245            ("{,", "}", true),
246        ]
247        .into_iter()
248        .find_map(|(open, close, optional)| {
249            let body = rest.strip_prefix(open)?;
250            let end = body.find(close)?;
251            let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
252            let group = if optional {
253                Group::Optional(items)
254            } else {
255                Group::Once(items)
256            };
257            Some((group, &body[end + close.len()..]))
258        })
259        // Nx tries `!(…).`, then `!(…)*`, then `!(…)`.
260        .or_else(|| {
261            let body = rest.strip_prefix("!(")?;
262            let end = body.find(')')?;
263            let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
264            let after = &body[end + 1..];
265            Some(if let Some(after) = after.strip_prefix('.') {
266                (Group::NegatedFileName(items), after)
267            } else if let Some(after) = after.strip_prefix('*') {
268                (Group::NegatedWildcard(items), after)
269            } else {
270                (Group::Negated(items), after)
271            })
272        });
273        if let Some((group, remaining)) = parsed {
274            groups.push(group);
275            rest = remaining;
276            continue;
277        }
278        // A literal runs to the next `{,` or, without one, to the next special
279        // character, and an escaped character is always literal.
280        let end = literal_end(rest);
281        if end == 0 {
282            bail!("unsupported glob syntax at {rest:?}");
283        }
284        groups.push(Group::Literal(rest[..end].to_owned()));
285        rest = &rest[end..];
286    }
287    Ok(groups)
288}
289
290/// Where a literal starting `rest` ends: at the next unescaped `{,`, or
291/// without one at the next unescaped `?`, `+`, `@`, `!` or `(`.
292fn literal_end(rest: &str) -> usize {
293    let mut brace = None;
294    let mut special = None;
295    let mut chars = rest.char_indices();
296    while let Some((index, c)) = chars.next() {
297        match c {
298            '\\' => {
299                chars.next();
300            }
301            '{' if brace.is_none() && rest[index + 1..].starts_with(',') => brace = Some(index),
302            '?' | '+' | '@' | '!' | '(' if special.is_none() => special = Some(index),
303            _ => {}
304        }
305    }
306    brace.or(special).unwrap_or(rest.len())
307}
308
309fn display(items: &str) -> String {
310    if items.contains(',') {
311        format!("{{{items}}}")
312    } else {
313        items.to_owned()
314    }
315}
316
317/// A segment's options, each with whether a negated group made it one that
318/// excludes: Nx's `build_segment`.
319fn build_segment(
320    existing: &str,
321    groups: &[Group],
322    is_last: bool,
323    negative: bool,
324) -> Vec<(String, bool)> {
325    let Some((group, rest)) = groups.split_first() else {
326        return vec![(existing.to_owned(), negative)];
327    };
328    // Nx widens an omitted group in a directory segment to the whole segment.
329    let omitted = if is_last { existing } else { "*" };
330    let both = |off: &str, on: &str, on_negative: bool| {
331        let mut options = build_segment(off, rest, is_last, negative);
332        options.extend(build_segment(on, rest, is_last, on_negative));
333        options
334    };
335    match group {
336        Group::Optional(items) => both(omitted, &format!("{existing}{}", display(items)), negative),
337        Group::Negated(items) => both(omitted, &format!("{existing}{}", display(items)), true),
338        Group::NegatedFileName(items) => {
339            both("*.", &format!("{existing}{}.", display(items)), true)
340        }
341        Group::NegatedWildcard(items) => both("*", &format!("{existing}{}*", display(items)), true),
342        Group::Once(items) => build_segment(
343            &format!("{existing}{}", display(items)),
344            rest,
345            is_last,
346            negative,
347        ),
348        Group::Literal(text) => {
349            build_segment(&format!("{existing}{text}"), rest, is_last, negative)
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    #[test]
357    fn an_escaped_fileset_names_its_directory_exactly() {
358        let named = super::Pattern::fileset(&super::escape("tmp/a!b (1)"), false).unwrap();
359        assert!(named.is_match("tmp/a!b (1)/entry"));
360        assert!(!named.is_match("tmp/ab 1/entry"));
361    }
362
363    use super::*;
364
365    /// The expansion as Nx's `convert_glob` writes it, `!` for excluding.
366    fn nx(pattern: &str) -> Vec<String> {
367        expand(pattern)
368            .unwrap()
369            .into_iter()
370            .map(|(glob, excludes)| if excludes { format!("!{glob}") } else { glob })
371            .collect()
372    }
373
374    // Expected expansions are Nx 23.1's own glob_transform tests.
375    #[test]
376    fn expands_like_nx() {
377        assert_eq!(
378            nx("libs/**/?(*.)+spec.ts?(.snap)"),
379            [
380                "libs/**/*.spec.ts",
381                "libs/**/*.spec.ts.snap",
382                "libs/**/spec.ts",
383                "libs/**/spec.ts.snap"
384            ]
385        );
386        for pattern in [
387            "libs/**/?(*.)@spec.ts?(.snap)",
388            "libs/**/?(*.)?spec.ts?(.snap)",
389        ] {
390            assert_eq!(nx(pattern), nx("libs/**/?(*.)+spec.ts?(.snap)"));
391        }
392        assert_eq!(nx("dist/**/*.js"), ["dist/**/*.js"]);
393        assert_eq!(nx("**/*.(js|ts)"), ["**/*.{js,ts}"]);
394        assert_eq!(
395            nx("**/*.spec.ts{,.snap}"),
396            ["**/*.spec.ts", "**/*.spec.ts.snap"]
397        );
398    }
399
400    #[test]
401    fn expands_workspace_patterns() {
402        assert_eq!(
403            nx("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)"),
404            [
405                "app/**/*.{spec,test}.[jt]s",
406                "app/**/*.{spec,test}.[jt]s.snap",
407                "app/**/*.{spec,test}.[jt]sx",
408                "app/**/*.{spec,test}.[jt]sx.snap",
409                "app/**/{spec,test}.[jt]s",
410                "app/**/{spec,test}.[jt]s.snap",
411                "app/**/{spec,test}.[jt]sx",
412                "app/**/{spec,test}.[jt]sx.snap",
413            ]
414        );
415        assert_eq!(nx("biome.json?(c)"), ["biome.json", "biome.jsonc"]);
416        assert_eq!(nx("app/(.cache|dist)/**/*"), ["app/{.cache,dist}/**/*"]);
417        // An omitted group in a directory segment widens it, as in Nx.
418        assert_eq!(nx("a?(b)/c"), ["*/c", "ab/c"]);
419    }
420
421    #[test]
422    fn rejects_what_nx_rejects() {
423        assert!(expand("src/a!b").is_err());
424        assert!(expand("file?").is_err());
425        assert!(expand("src/(unterminated").is_err());
426    }
427
428    // Nx 23.1's own convert_glob tests.
429    #[test]
430    fn expands_negated_groups_like_nx() {
431        assert_eq!(
432            nx("dist/!(cache|cache2)/**/!(README|LICENSE).(js|ts)"),
433            [
434                "!dist/*/**/{README,LICENSE}.{js,ts}",
435                "!dist/{cache,cache2}/",
436                "dist/*/**/*.{js,ts}",
437            ]
438        );
439        assert_eq!(
440            nx("dist/**/!(README|LICENSE).(js|ts)"),
441            ["!dist/**/{README,LICENSE}.{js,ts}", "dist/**/*.{js,ts}"]
442        );
443        assert_eq!(
444            nx("dist/!(cache|cache2)/**/*.(js|ts)"),
445            ["!dist/{cache,cache2}/", "dist/*/**/*.{js,ts}"]
446        );
447        assert_eq!(
448            nx("dist/!(cache|cache2)/**/*.js"),
449            ["!dist/{cache,cache2}/", "dist/*/**/*.js"]
450        );
451        assert_eq!(
452            nx("packages/!(package-a)*"),
453            ["!packages/package-a*", "packages/*"]
454        );
455        assert_eq!(
456            nx("packages/!(package-a)*/package.json"),
457            ["!packages/package-a*/", "packages/*/package.json"]
458        );
459        assert_eq!(nx("**/!(package-a)*"), ["!**/package-a*", "**/*"]);
460        assert_eq!(
461            nx("!(test|e2e)/?(*.)+(spec|test).[jt]s!(x)?(.snap)"),
462            [
463                "!*/*.{spec,test}.[jt]sx",
464                "!*/*.{spec,test}.[jt]sx.snap",
465                "!*/{spec,test}.[jt]sx",
466                "!*/{spec,test}.[jt]sx.snap",
467                "!{test,e2e}/",
468                "*/*.{spec,test}.[jt]s",
469                "*/*.{spec,test}.[jt]s.snap",
470                "*/{spec,test}.[jt]s",
471                "*/{spec,test}.[jt]s.snap",
472            ]
473        );
474    }
475
476    #[test]
477    fn negated_groups_exclude_what_they_name() {
478        let pattern = Pattern::new("src/**/!(*.test|*.spec).ts", false).unwrap();
479        assert!(pattern.is_match("src/a/x.ts"));
480        assert!(!pattern.is_match("src/a/x.test.ts") && !pattern.is_match("src/a/x.spec.ts"));
481        let directories = Pattern::new("src/!(a|b)/*", false).unwrap();
482        assert!(directories.is_match("src/c/z.md"));
483        assert!(!directories.is_match("src/a/x.ts"));
484        // As in Nx, a negated group after a literal in its segment leaves
485        // only files ending in that literal.
486        let odd = Pattern::new("src/**/*.!(ts)", false).unwrap();
487        assert!(!odd.is_match("src/b/y.js") && !odd.is_match("src/b/y.ts"));
488        // Negated as a whole, every expansion excludes.
489        let whole = Pattern::new("src/**/!(*.test).ts", true).unwrap();
490        assert!(whole.is_match("src/a/x.ts") && whole.is_match("src/a/x.test.ts"));
491    }
492
493    #[test]
494    fn matches_expanded_patterns() {
495        let tests = Pattern::new("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", true).unwrap();
496        for path in [
497            "app/a.spec.ts",
498            "app/deep/b.test.jsx",
499            "app/test.ts",
500            "app/c.spec.tsx.snap",
501        ] {
502            assert!(tests.is_match(path), "{path}");
503        }
504        for path in ["app/a.ts", "app/a.spec.mts", "other/a.spec.ts"] {
505            assert!(!tests.is_match(path), "{path}");
506        }
507        let plain = Pattern::new("src/*.ts", false).unwrap();
508        assert!(plain.is_match("src/a.ts") && !plain.is_match("src/a/b.ts"));
509    }
510
511    // Nx 24's `a_backslash_escapes_the_next_character` and the inputs
512    // reference: an escaped character matches itself on every platform.
513    #[test]
514    fn a_backslash_escapes_the_next_character() {
515        let route = Pattern::new(r"app/\(marketing\)/**/*", false).unwrap();
516        assert!(route.is_match("app/(marketing)/page.tsx"));
517        assert!(!route.is_match("app/marketing/page.tsx"));
518        let id = Pattern::new(r"src/\[id\].ts", false).unwrap();
519        assert!(id.is_match("src/[id].ts") && !id.is_match("src/i.ts"));
520        let mixed = Pattern::new(r"app/\(a\)/?(*.)+(spec|test).ts", false).unwrap();
521        assert!(mixed.is_match("app/(a)/x.spec.ts") && !mixed.is_match("app/a/x.spec.ts"));
522        // A Windows-style separator is an escape too, so this matches nothing
523        // under `src`.
524        assert!(!Pattern::new(r"src\**", false).unwrap().is_match("src/a.ts"));
525    }
526
527    // Nx 24's `path_or_everything_under`.
528    #[test]
529    fn a_fileset_path_with_no_pattern_names_a_file_or_a_directory() {
530        for pattern in ["src", "src/"] {
531            let fileset = Pattern::fileset(pattern, false).unwrap();
532            assert!(fileset.is_match("src/a.ts") && fileset.is_match("src/deep/b.ts"));
533            assert!(!fileset.is_match("srcs/a.ts"));
534        }
535        assert!(
536            Pattern::fileset("package.json", false)
537                .unwrap()
538                .is_match("package.json")
539        );
540        for named in ["@types", "c++", r"\(group\)"] {
541            let fileset = Pattern::fileset(named, false).unwrap();
542            let unescaped = named.replace('\\', "");
543            assert!(fileset.is_match(format!("{unescaped}/x")), "{named}");
544        }
545        // A pattern keeps matching only what it matches.
546        let glob = Pattern::fileset("src/*.ts", false).unwrap();
547        assert!(glob.is_match("src/a.ts") && !glob.is_match("src/a.ts/b"));
548        assert!(!Pattern::new("src", false).unwrap().is_match("src/a.ts"));
549    }
550
551    // What Nx 23.1's own glob sets select from the same files.
552    #[test]
553    fn selects_the_files_nx_selects() {
554        let files = [
555            "src/a/x.ts",
556            "src/a/x.test.ts",
557            "src/a/x.spec.ts",
558            "src/b/y.ts",
559            "src/b/y.js",
560            "src/c/deep/z.ts",
561            "src/c/z.md",
562            "src/root.ts",
563            "src/README.md",
564        ];
565        let selected = |pattern: &str| -> Vec<&str> {
566            let pattern = Pattern::new(pattern, false).unwrap();
567            files
568                .iter()
569                .copied()
570                .filter(|file| pattern.is_match(file))
571                .collect()
572        };
573        assert_eq!(
574            selected("src/**/!(*.test).ts"),
575            [
576                "src/a/x.ts",
577                "src/a/x.spec.ts",
578                "src/b/y.ts",
579                "src/c/deep/z.ts",
580                "src/root.ts"
581            ]
582        );
583        assert_eq!(
584            selected("src/!(a)/**"),
585            ["src/b/y.ts", "src/b/y.js", "src/c/deep/z.ts", "src/c/z.md"]
586        );
587        assert_eq!(selected("src/**/*.!(ts)"), [] as [&str; 0]);
588        assert_eq!(selected("src/!(a|b)/*"), ["src/c/z.md"]);
589        assert_eq!(
590            selected("src/*/!(x)*"),
591            ["src/b/y.ts", "src/b/y.js", "src/c/z.md"]
592        );
593        assert_eq!(
594            selected("src/**/!(*.test|*.spec).ts"),
595            ["src/a/x.ts", "src/b/y.ts", "src/c/deep/z.ts", "src/root.ts"]
596        );
597    }
598}