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