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    pub fn new(pattern: &str, negated: bool) -> Result<Self> {
33        let globs = if negated
34            || ["|", "(", "{,", "!"]
35                .iter()
36                .any(|token| pattern.contains(token))
37        {
38            expand(pattern)?
39        } else {
40            vec![(pattern.to_owned(), false)]
41        };
42        let mut included = GlobSetBuilder::new();
43        let mut excluded = GlobSetBuilder::new();
44        for (glob, excludes) in globs {
45            let glob = if glob.ends_with('/') {
46                format!("{glob}**")
47            } else {
48                glob
49            };
50            let glob = GlobBuilder::new(&glob)
51                .literal_separator(true)
52                .backslash_escape(false)
53                .build()?;
54            if excludes && !negated {
55                excluded.add(glob);
56            } else {
57                included.add(glob);
58            }
59        }
60        Ok(Self {
61            included: included.build()?,
62            excluded: excluded.build()?,
63        })
64    }
65
66    /// As Nx's glob sets: an including glob matches and no excluding one, or,
67    /// with only excluding globs, none of them.
68    pub fn is_match(&self, path: impl AsRef<std::path::Path>) -> bool {
69        let path = path.as_ref();
70        let excluded = self.excluded.is_match(path);
71        if self.included.is_empty() {
72            !excluded
73        } else {
74            !excluded && self.included.is_match(path)
75        }
76    }
77}
78
79/// Whether a path segment needs matching rather than being a literal directory.
80pub fn is_literal(segment: &str) -> bool {
81    !segment.contains([
82        '!', '?', '@', '+', '*', '|', ',', '{', '}', '[', ']', '(', ')',
83    ])
84}
85
86#[derive(Debug, PartialEq)]
87enum Group {
88    /// `?(a|b)`, `*(a|b)` and `{,a}`: expanded as absent or once.
89    Optional(String),
90    /// `+(a|b)`, `@(a|b)` and `(a|b)`: expanded as exactly once.
91    Once(String),
92    /// `!(a|b)`.
93    Negated(String),
94    /// `!(a|b).`, which widens to `*.`.
95    NegatedFileName(String),
96    /// `!(a|b)*`, which widens to `*`.
97    NegatedWildcard(String),
98    Literal(String),
99}
100
101/// Expands one glob into plain globs, each with whether it excludes, sorted
102/// and deduplicated like Nx's `convert_glob`. An excluding glob from a
103/// directory segment ends there, with a `/`, and covers that directory.
104fn expand(pattern: &str) -> Result<Vec<(String, bool)>> {
105    let segments: Vec<_> = pattern.split('/').collect();
106    let mut built = Vec::new();
107    for (index, segment) in segments.iter().enumerate() {
108        let groups = parse_segment(segment)
109            .map_err(|error| anyhow::anyhow!("{error} in glob {pattern:?}"))?;
110        built.push(build_segment(
111            "",
112            &groups,
113            index == segments.len() - 1,
114            false,
115        ));
116    }
117    // Every combination of one option per segment, as Nx's cartesian product.
118    let mut products: Vec<Vec<&(String, bool)>> = vec![Vec::new()];
119    for options in &built {
120        products = products
121            .iter()
122            .flat_map(|product| {
123                options.iter().map(move |option| {
124                    let mut next = product.clone();
125                    next.push(option);
126                    next
127                })
128            })
129            .collect();
130    }
131    let mut globs: Vec<(String, bool)> = products
132        .into_iter()
133        .map(|product| {
134            let mut path = String::new();
135            let mut excludes = false;
136            let mut whole = false;
137            for (index, (segment, negative)) in product.iter().enumerate() {
138                whole = index == product.len() - 1;
139                path.push_str(segment);
140                path.push('/');
141                if *negative {
142                    excludes = true;
143                    if !whole {
144                        break;
145                    }
146                }
147            }
148            if whole {
149                path.pop();
150            }
151            (path, excludes)
152        })
153        .collect();
154    globs.sort_by(|a, b| (!a.1, &a.0).cmp(&(!b.1, &b.0)));
155    globs.dedup();
156    Ok(globs)
157}
158
159fn parse_segment(segment: &str) -> Result<Vec<Group>> {
160    let mut groups = Vec::new();
161    let mut rest = segment;
162    while !rest.is_empty() {
163        // Nx drops `?`, `+` and `@` when no group follows, e.g. `+spec` means `spec`.
164        if let Some(after) = rest.strip_prefix(['?', '+', '@'])
165            && !after.is_empty()
166            && !after.starts_with('(')
167        {
168            rest = after;
169        }
170        let parsed = [
171            ("(", ")", false),
172            ("*(", ")", true),
173            ("?(", ")", true),
174            ("+(", ")", false),
175            ("@(", ")", false),
176            ("{,", "}", true),
177        ]
178        .into_iter()
179        .find_map(|(open, close, optional)| {
180            let body = rest.strip_prefix(open)?;
181            let end = body.find(close)?;
182            let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
183            let group = if optional {
184                Group::Optional(items)
185            } else {
186                Group::Once(items)
187            };
188            Some((group, &body[end + close.len()..]))
189        })
190        // Nx tries `!(…).`, then `!(…)*`, then `!(…)`.
191        .or_else(|| {
192            let body = rest.strip_prefix("!(")?;
193            let end = body.find(')')?;
194            let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
195            let after = &body[end + 1..];
196            Some(if let Some(after) = after.strip_prefix('.') {
197                (Group::NegatedFileName(items), after)
198            } else if let Some(after) = after.strip_prefix('*') {
199                (Group::NegatedWildcard(items), after)
200            } else {
201                (Group::Negated(items), after)
202            })
203        });
204        if let Some((group, remaining)) = parsed {
205            groups.push(group);
206            rest = remaining;
207            continue;
208        }
209        // A literal runs to the next `{,` or, without one, to the next special character.
210        let end = rest
211            .find("{,")
212            .unwrap_or_else(|| rest.find(['?', '+', '@', '!', '(']).unwrap_or(rest.len()));
213        if end == 0 {
214            bail!("unsupported glob syntax at {rest:?}");
215        }
216        groups.push(Group::Literal(rest[..end].to_owned()));
217        rest = &rest[end..];
218    }
219    Ok(groups)
220}
221
222fn display(items: &str) -> String {
223    if items.contains(',') {
224        format!("{{{items}}}")
225    } else {
226        items.to_owned()
227    }
228}
229
230/// A segment's options, each with whether a negated group made it one that
231/// excludes: Nx's `build_segment`.
232fn build_segment(
233    existing: &str,
234    groups: &[Group],
235    is_last: bool,
236    negative: bool,
237) -> Vec<(String, bool)> {
238    let Some((group, rest)) = groups.split_first() else {
239        return vec![(existing.to_owned(), negative)];
240    };
241    // Nx widens an omitted group in a directory segment to the whole segment.
242    let omitted = if is_last { existing } else { "*" };
243    let both = |off: &str, on: &str, on_negative: bool| {
244        let mut options = build_segment(off, rest, is_last, negative);
245        options.extend(build_segment(on, rest, is_last, on_negative));
246        options
247    };
248    match group {
249        Group::Optional(items) => both(omitted, &format!("{existing}{}", display(items)), negative),
250        Group::Negated(items) => both(omitted, &format!("{existing}{}", display(items)), true),
251        Group::NegatedFileName(items) => {
252            both("*.", &format!("{existing}{}.", display(items)), true)
253        }
254        Group::NegatedWildcard(items) => both("*", &format!("{existing}{}*", display(items)), true),
255        Group::Once(items) => build_segment(
256            &format!("{existing}{}", display(items)),
257            rest,
258            is_last,
259            negative,
260        ),
261        Group::Literal(text) => {
262            build_segment(&format!("{existing}{text}"), rest, is_last, negative)
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    /// The expansion as Nx's `convert_glob` writes it, `!` for excluding.
272    fn nx(pattern: &str) -> Vec<String> {
273        expand(pattern)
274            .unwrap()
275            .into_iter()
276            .map(|(glob, excludes)| if excludes { format!("!{glob}") } else { glob })
277            .collect()
278    }
279
280    // Expected expansions are Nx 23.1's own glob_transform tests.
281    #[test]
282    fn expands_like_nx() {
283        assert_eq!(
284            nx("libs/**/?(*.)+spec.ts?(.snap)"),
285            [
286                "libs/**/*.spec.ts",
287                "libs/**/*.spec.ts.snap",
288                "libs/**/spec.ts",
289                "libs/**/spec.ts.snap"
290            ]
291        );
292        for pattern in [
293            "libs/**/?(*.)@spec.ts?(.snap)",
294            "libs/**/?(*.)?spec.ts?(.snap)",
295        ] {
296            assert_eq!(nx(pattern), nx("libs/**/?(*.)+spec.ts?(.snap)"));
297        }
298        assert_eq!(nx("dist/**/*.js"), ["dist/**/*.js"]);
299        assert_eq!(nx("**/*.(js|ts)"), ["**/*.{js,ts}"]);
300        assert_eq!(
301            nx("**/*.spec.ts{,.snap}"),
302            ["**/*.spec.ts", "**/*.spec.ts.snap"]
303        );
304    }
305
306    #[test]
307    fn expands_workspace_patterns() {
308        assert_eq!(
309            nx("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)"),
310            [
311                "app/**/*.{spec,test}.[jt]s",
312                "app/**/*.{spec,test}.[jt]s.snap",
313                "app/**/*.{spec,test}.[jt]sx",
314                "app/**/*.{spec,test}.[jt]sx.snap",
315                "app/**/{spec,test}.[jt]s",
316                "app/**/{spec,test}.[jt]s.snap",
317                "app/**/{spec,test}.[jt]sx",
318                "app/**/{spec,test}.[jt]sx.snap",
319            ]
320        );
321        assert_eq!(nx("biome.json?(c)"), ["biome.json", "biome.jsonc"]);
322        assert_eq!(nx("app/(.cache|dist)/**/*"), ["app/{.cache,dist}/**/*"]);
323        // An omitted group in a directory segment widens it, as in Nx.
324        assert_eq!(nx("a?(b)/c"), ["*/c", "ab/c"]);
325    }
326
327    #[test]
328    fn rejects_what_nx_rejects() {
329        assert!(expand("src/a!b").is_err());
330        assert!(expand("file?").is_err());
331        assert!(expand("src/(unterminated").is_err());
332    }
333
334    // Nx 23.1's own convert_glob tests.
335    #[test]
336    fn expands_negated_groups_like_nx() {
337        assert_eq!(
338            nx("dist/!(cache|cache2)/**/!(README|LICENSE).(js|ts)"),
339            [
340                "!dist/*/**/{README,LICENSE}.{js,ts}",
341                "!dist/{cache,cache2}/",
342                "dist/*/**/*.{js,ts}",
343            ]
344        );
345        assert_eq!(
346            nx("dist/**/!(README|LICENSE).(js|ts)"),
347            ["!dist/**/{README,LICENSE}.{js,ts}", "dist/**/*.{js,ts}"]
348        );
349        assert_eq!(
350            nx("dist/!(cache|cache2)/**/*.(js|ts)"),
351            ["!dist/{cache,cache2}/", "dist/*/**/*.{js,ts}"]
352        );
353        assert_eq!(
354            nx("dist/!(cache|cache2)/**/*.js"),
355            ["!dist/{cache,cache2}/", "dist/*/**/*.js"]
356        );
357        assert_eq!(
358            nx("packages/!(package-a)*"),
359            ["!packages/package-a*", "packages/*"]
360        );
361        assert_eq!(
362            nx("packages/!(package-a)*/package.json"),
363            ["!packages/package-a*/", "packages/*/package.json"]
364        );
365        assert_eq!(nx("**/!(package-a)*"), ["!**/package-a*", "**/*"]);
366        assert_eq!(
367            nx("!(test|e2e)/?(*.)+(spec|test).[jt]s!(x)?(.snap)"),
368            [
369                "!*/*.{spec,test}.[jt]sx",
370                "!*/*.{spec,test}.[jt]sx.snap",
371                "!*/{spec,test}.[jt]sx",
372                "!*/{spec,test}.[jt]sx.snap",
373                "!{test,e2e}/",
374                "*/*.{spec,test}.[jt]s",
375                "*/*.{spec,test}.[jt]s.snap",
376                "*/{spec,test}.[jt]s",
377                "*/{spec,test}.[jt]s.snap",
378            ]
379        );
380    }
381
382    #[test]
383    fn negated_groups_exclude_what_they_name() {
384        let pattern = Pattern::new("src/**/!(*.test|*.spec).ts", false).unwrap();
385        assert!(pattern.is_match("src/a/x.ts"));
386        assert!(!pattern.is_match("src/a/x.test.ts") && !pattern.is_match("src/a/x.spec.ts"));
387        let directories = Pattern::new("src/!(a|b)/*", false).unwrap();
388        assert!(directories.is_match("src/c/z.md"));
389        assert!(!directories.is_match("src/a/x.ts"));
390        // As in Nx, a negated group after a literal in its segment leaves
391        // only files ending in that literal.
392        let odd = Pattern::new("src/**/*.!(ts)", false).unwrap();
393        assert!(!odd.is_match("src/b/y.js") && !odd.is_match("src/b/y.ts"));
394        // Negated as a whole, every expansion excludes.
395        let whole = Pattern::new("src/**/!(*.test).ts", true).unwrap();
396        assert!(whole.is_match("src/a/x.ts") && whole.is_match("src/a/x.test.ts"));
397    }
398
399    #[test]
400    fn matches_expanded_patterns() {
401        let tests = Pattern::new("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", true).unwrap();
402        for path in [
403            "app/a.spec.ts",
404            "app/deep/b.test.jsx",
405            "app/test.ts",
406            "app/c.spec.tsx.snap",
407        ] {
408            assert!(tests.is_match(path), "{path}");
409        }
410        for path in ["app/a.ts", "app/a.spec.mts", "other/a.spec.ts"] {
411            assert!(!tests.is_match(path), "{path}");
412        }
413        let plain = Pattern::new("src/*.ts", false).unwrap();
414        assert!(plain.is_match("src/a.ts") && !plain.is_match("src/a/b.ts"));
415    }
416
417    // What Nx 23.1's own glob sets select from the same files.
418    #[test]
419    fn selects_the_files_nx_selects() {
420        let files = [
421            "src/a/x.ts",
422            "src/a/x.test.ts",
423            "src/a/x.spec.ts",
424            "src/b/y.ts",
425            "src/b/y.js",
426            "src/c/deep/z.ts",
427            "src/c/z.md",
428            "src/root.ts",
429            "src/README.md",
430        ];
431        let selected = |pattern: &str| -> Vec<&str> {
432            let pattern = Pattern::new(pattern, false).unwrap();
433            files
434                .iter()
435                .copied()
436                .filter(|file| pattern.is_match(file))
437                .collect()
438        };
439        assert_eq!(
440            selected("src/**/!(*.test).ts"),
441            [
442                "src/a/x.ts",
443                "src/a/x.spec.ts",
444                "src/b/y.ts",
445                "src/c/deep/z.ts",
446                "src/root.ts"
447            ]
448        );
449        assert_eq!(
450            selected("src/!(a)/**"),
451            ["src/b/y.ts", "src/b/y.js", "src/c/deep/z.ts", "src/c/z.md"]
452        );
453        assert_eq!(selected("src/**/*.!(ts)"), [] as [&str; 0]);
454        assert_eq!(selected("src/!(a|b)/*"), ["src/c/z.md"]);
455        assert_eq!(
456            selected("src/*/!(x)*"),
457            ["src/b/y.ts", "src/b/y.js", "src/c/z.md"]
458        );
459        assert_eq!(
460            selected("src/**/!(*.test|*.spec).ts"),
461            ["src/a/x.ts", "src/b/y.ts", "src/c/deep/z.ts", "src/root.ts"]
462        );
463    }
464}