Skip to main content

jj_lib/
fileset.rs

1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Functional language for selecting a set of paths.
16
17use std::collections::HashMap;
18use std::iter;
19use std::path;
20use std::slice;
21use std::sync::LazyLock;
22
23use globset::Glob;
24use globset::GlobBuilder;
25use itertools::Itertools as _;
26use thiserror::Error;
27
28use crate::dsl_util::collect_similar;
29use crate::fileset_parser;
30use crate::fileset_parser::BinaryOp;
31use crate::fileset_parser::ExpressionKind;
32use crate::fileset_parser::ExpressionNode;
33pub use crate::fileset_parser::FilesetDiagnostics;
34pub use crate::fileset_parser::FilesetParseError;
35pub use crate::fileset_parser::FilesetParseErrorKind;
36pub use crate::fileset_parser::FilesetParseResult;
37use crate::fileset_parser::FunctionCallNode;
38use crate::fileset_parser::UnaryOp;
39use crate::matchers::DifferenceMatcher;
40use crate::matchers::EverythingMatcher;
41use crate::matchers::FilesMatcher;
42use crate::matchers::GlobsMatcher;
43use crate::matchers::IntersectionMatcher;
44use crate::matchers::Matcher;
45use crate::matchers::NothingMatcher;
46use crate::matchers::PrefixMatcher;
47use crate::matchers::UnionMatcher;
48use crate::repo_path::RelativePathParseError;
49use crate::repo_path::RepoPath;
50use crate::repo_path::RepoPathBuf;
51use crate::repo_path::RepoPathUiConverter;
52use crate::repo_path::UiPathParseError;
53
54/// Error occurred during file pattern parsing.
55#[derive(Debug, Error)]
56pub enum FilePatternParseError {
57    /// Unknown pattern kind is specified.
58    #[error("Invalid file pattern kind `{0}:`")]
59    InvalidKind(String),
60    /// Failed to parse input UI path.
61    #[error(transparent)]
62    UiPath(#[from] UiPathParseError),
63    /// Failed to parse input workspace-relative path.
64    #[error(transparent)]
65    RelativePath(#[from] RelativePathParseError),
66    /// Failed to parse glob pattern.
67    #[error(transparent)]
68    GlobPattern(#[from] globset::Error),
69}
70
71/// Basic pattern to match `RepoPath`.
72#[derive(Clone, Debug)]
73pub enum FilePattern {
74    /// Matches file (or exact) path.
75    FilePath(RepoPathBuf),
76    /// Matches path prefix.
77    PrefixPath(RepoPathBuf),
78    /// Matches file (or exact) path with glob pattern.
79    FileGlob {
80        /// Prefix directory path where the `pattern` will be evaluated.
81        dir: RepoPathBuf,
82        /// Glob pattern relative to `dir`.
83        pattern: Box<Glob>,
84    },
85    /// Matches path prefix with glob pattern.
86    PrefixGlob {
87        /// Prefix directory path where the `pattern` will be evaluated.
88        dir: RepoPathBuf,
89        /// Glob pattern relative to `dir`.
90        pattern: Box<Glob>,
91    },
92    // TODO: add more patterns:
93    // - FilesInPath: files in directory, non-recursively?
94    // - NameGlob or SuffixGlob: file name with glob?
95}
96
97impl FilePattern {
98    /// Parses the given `input` string as pattern of the specified `kind`.
99    pub fn from_str_kind(
100        path_converter: &RepoPathUiConverter,
101        input: &str,
102        kind: &str,
103    ) -> Result<Self, FilePatternParseError> {
104        // Naming convention:
105        // * path normalization
106        //   * cwd: cwd-relative path (default)
107        //   * root: workspace-relative path
108        // * where to anchor
109        //   * file: exact file path
110        //   * prefix: path prefix (files under directory recursively)
111        //   * files-in: files in directory non-recursively
112        //   * name: file name component (or suffix match?)
113        //   * substring: substring match?
114        // * string pattern syntax (+ case sensitivity?)
115        //   * path: literal path (default) (default anchor: prefix)
116        //   * glob: glob pattern (default anchor: file)
117        //   * regex?
118        match kind {
119            "cwd" => Self::cwd_prefix_path(path_converter, input),
120            "cwd-file" | "file" => Self::cwd_file_path(path_converter, input),
121            "cwd-glob" | "glob" => Self::cwd_file_glob(path_converter, input),
122            "cwd-glob-i" | "glob-i" => Self::cwd_file_glob_i(path_converter, input),
123            "cwd-prefix-glob" | "prefix-glob" => Self::cwd_prefix_glob(path_converter, input),
124            "cwd-prefix-glob-i" | "prefix-glob-i" => Self::cwd_prefix_glob_i(path_converter, input),
125            "root" => Self::root_prefix_path(input),
126            "root-file" => Self::root_file_path(input),
127            "root-glob" => Self::root_file_glob(input),
128            "root-glob-i" => Self::root_file_glob_i(input),
129            "root-prefix-glob" => Self::root_prefix_glob(input),
130            "root-prefix-glob-i" => Self::root_prefix_glob_i(input),
131            _ => Err(FilePatternParseError::InvalidKind(kind.to_owned())),
132        }
133    }
134
135    /// Pattern that matches cwd-relative file (or exact) path.
136    pub fn cwd_file_path(
137        path_converter: &RepoPathUiConverter,
138        input: impl AsRef<str>,
139    ) -> Result<Self, FilePatternParseError> {
140        let path = path_converter.parse_file_path(input.as_ref())?;
141        Ok(Self::FilePath(path))
142    }
143
144    /// Pattern that matches cwd-relative path prefix.
145    pub fn cwd_prefix_path(
146        path_converter: &RepoPathUiConverter,
147        input: impl AsRef<str>,
148    ) -> Result<Self, FilePatternParseError> {
149        let path = path_converter.parse_file_path(input.as_ref())?;
150        Ok(Self::PrefixPath(path))
151    }
152
153    /// Pattern that matches cwd-relative file path glob.
154    pub fn cwd_file_glob(
155        path_converter: &RepoPathUiConverter,
156        input: impl AsRef<str>,
157    ) -> Result<Self, FilePatternParseError> {
158        let (dir, pattern) = split_glob_path(input.as_ref());
159        let dir = path_converter.parse_file_path(dir)?;
160        Self::file_glob_at(dir, pattern, false)
161    }
162
163    /// Pattern that matches cwd-relative file path glob (case-insensitive).
164    pub fn cwd_file_glob_i(
165        path_converter: &RepoPathUiConverter,
166        input: impl AsRef<str>,
167    ) -> Result<Self, FilePatternParseError> {
168        let (dir, pattern) = split_glob_path_i(input.as_ref());
169        let dir = path_converter.parse_file_path(dir)?;
170        Self::file_glob_at(dir, pattern, true)
171    }
172
173    /// Pattern that matches cwd-relative path prefix by glob.
174    pub fn cwd_prefix_glob(
175        path_converter: &RepoPathUiConverter,
176        input: impl AsRef<str>,
177    ) -> Result<Self, FilePatternParseError> {
178        let (dir, pattern) = split_glob_path(input.as_ref());
179        let dir = path_converter.parse_file_path(dir)?;
180        Self::prefix_glob_at(dir, pattern, false)
181    }
182
183    /// Pattern that matches cwd-relative path prefix by glob
184    /// (case-insensitive).
185    pub fn cwd_prefix_glob_i(
186        path_converter: &RepoPathUiConverter,
187        input: impl AsRef<str>,
188    ) -> Result<Self, FilePatternParseError> {
189        let (dir, pattern) = split_glob_path_i(input.as_ref());
190        let dir = path_converter.parse_file_path(dir)?;
191        Self::prefix_glob_at(dir, pattern, true)
192    }
193
194    /// Pattern that matches workspace-relative file (or exact) path.
195    pub fn root_file_path(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
196        // TODO: Let caller pass in converter for root-relative paths too
197        let path = RepoPathBuf::from_relative_path(input.as_ref())?;
198        Ok(Self::FilePath(path))
199    }
200
201    /// Pattern that matches workspace-relative path prefix.
202    pub fn root_prefix_path(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
203        let path = RepoPathBuf::from_relative_path(input.as_ref())?;
204        Ok(Self::PrefixPath(path))
205    }
206
207    /// Pattern that matches workspace-relative file path glob.
208    pub fn root_file_glob(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
209        let (dir, pattern) = split_glob_path(input.as_ref());
210        let dir = RepoPathBuf::from_relative_path(dir)?;
211        Self::file_glob_at(dir, pattern, false)
212    }
213
214    /// Pattern that matches workspace-relative file path glob
215    /// (case-insensitive).
216    pub fn root_file_glob_i(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
217        let (dir, pattern) = split_glob_path_i(input.as_ref());
218        let dir = RepoPathBuf::from_relative_path(dir)?;
219        Self::file_glob_at(dir, pattern, true)
220    }
221
222    /// Pattern that matches workspace-relative path prefix by glob.
223    pub fn root_prefix_glob(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
224        let (dir, pattern) = split_glob_path(input.as_ref());
225        let dir = RepoPathBuf::from_relative_path(dir)?;
226        Self::prefix_glob_at(dir, pattern, false)
227    }
228
229    /// Pattern that matches workspace-relative path prefix by glob
230    /// (case-insensitive).
231    pub fn root_prefix_glob_i(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
232        let (dir, pattern) = split_glob_path_i(input.as_ref());
233        let dir = RepoPathBuf::from_relative_path(dir)?;
234        Self::prefix_glob_at(dir, pattern, true)
235    }
236
237    fn file_glob_at(
238        dir: RepoPathBuf,
239        input: &str,
240        icase: bool,
241    ) -> Result<Self, FilePatternParseError> {
242        if input.is_empty() {
243            return Ok(Self::FilePath(dir));
244        }
245        // Normalize separator to '/', reject ".." which will never match
246        let normalized = RepoPathBuf::from_relative_path(input)?;
247        let pattern = Box::new(parse_file_glob(
248            normalized.as_internal_file_string(),
249            icase,
250        )?);
251        Ok(Self::FileGlob { dir, pattern })
252    }
253
254    fn prefix_glob_at(
255        dir: RepoPathBuf,
256        input: &str,
257        icase: bool,
258    ) -> Result<Self, FilePatternParseError> {
259        if input.is_empty() {
260            return Ok(Self::PrefixPath(dir));
261        }
262        // Normalize separator to '/', reject ".." which will never match
263        let normalized = RepoPathBuf::from_relative_path(input)?;
264        let pattern = Box::new(parse_file_glob(
265            normalized.as_internal_file_string(),
266            icase,
267        )?);
268        Ok(Self::PrefixGlob { dir, pattern })
269    }
270
271    /// Returns path if this pattern represents a literal path in a workspace.
272    /// Returns `None` if this is a glob pattern for example.
273    pub fn as_path(&self) -> Option<&RepoPath> {
274        match self {
275            Self::FilePath(path) => Some(path),
276            Self::PrefixPath(path) => Some(path),
277            Self::FileGlob { .. } | Self::PrefixGlob { .. } => None,
278        }
279    }
280}
281
282pub(super) fn parse_file_glob(input: &str, icase: bool) -> Result<Glob, globset::Error> {
283    GlobBuilder::new(input)
284        .literal_separator(true)
285        .case_insensitive(icase)
286        .build()
287}
288
289/// Checks if a character is a glob metacharacter.
290fn is_glob_char(c: char) -> bool {
291    // See globset::escape(). In addition to that, backslash is parsed as an
292    // escape sequence on Unix.
293    const GLOB_CHARS: &[char] = if cfg!(windows) {
294        &['?', '*', '[', ']', '{', '}']
295    } else {
296        &['?', '*', '[', ']', '{', '}', '\\']
297    };
298    GLOB_CHARS.contains(&c)
299}
300
301/// Splits `input` path into literal directory path and glob pattern.
302fn split_glob_path(input: &str) -> (&str, &str) {
303    let prefix_len = input
304        .split_inclusive(path::is_separator)
305        .take_while(|component| !component.contains(is_glob_char))
306        .map(|component| component.len())
307        .sum();
308    input.split_at(prefix_len)
309}
310
311/// Splits `input` path into literal directory path and glob pattern, for
312/// case-insensitive patterns.
313fn split_glob_path_i(input: &str) -> (&str, &str) {
314    let prefix_len = input
315        .split_inclusive(path::is_separator)
316        .take_while(|component| {
317            !component.contains(|c: char| c.is_ascii_alphabetic() || is_glob_char(c))
318        })
319        .map(|component| component.len())
320        .sum();
321    input.split_at(prefix_len)
322}
323
324/// AST-level representation of the fileset expression.
325#[derive(Clone, Debug)]
326pub enum FilesetExpression {
327    /// Matches nothing.
328    None,
329    /// Matches everything.
330    All,
331    /// Matches basic pattern.
332    Pattern(FilePattern),
333    /// Matches any of the expressions.
334    ///
335    /// Use `FilesetExpression::union_all()` to construct a union expression.
336    /// It will normalize 0-ary or 1-ary union.
337    UnionAll(Vec<Self>),
338    /// Matches both expressions.
339    Intersection(Box<Self>, Box<Self>),
340    /// Matches the first expression, but not the second expression.
341    Difference(Box<Self>, Box<Self>),
342}
343
344impl FilesetExpression {
345    /// Expression that matches nothing.
346    pub fn none() -> Self {
347        Self::None
348    }
349
350    /// Expression that matches everything.
351    pub fn all() -> Self {
352        Self::All
353    }
354
355    /// Expression that matches the given `pattern`.
356    pub fn pattern(pattern: FilePattern) -> Self {
357        Self::Pattern(pattern)
358    }
359
360    /// Expression that matches file (or exact) path.
361    pub fn file_path(path: RepoPathBuf) -> Self {
362        Self::Pattern(FilePattern::FilePath(path))
363    }
364
365    /// Expression that matches path prefix.
366    pub fn prefix_path(path: RepoPathBuf) -> Self {
367        Self::Pattern(FilePattern::PrefixPath(path))
368    }
369
370    /// Expression that matches any of the given `expressions`.
371    pub fn union_all(expressions: Vec<Self>) -> Self {
372        match expressions.len() {
373            0 => Self::none(),
374            1 => expressions.into_iter().next().unwrap(),
375            _ => Self::UnionAll(expressions),
376        }
377    }
378
379    /// Expression that matches both `self` and `other`.
380    pub fn intersection(self, other: Self) -> Self {
381        Self::Intersection(Box::new(self), Box::new(other))
382    }
383
384    /// Expression that matches `self` but not `other`.
385    pub fn difference(self, other: Self) -> Self {
386        Self::Difference(Box::new(self), Box::new(other))
387    }
388
389    /// Flattens union expression at most one level.
390    fn as_union_all(&self) -> &[Self] {
391        match self {
392            Self::None => &[],
393            Self::UnionAll(exprs) => exprs,
394            _ => slice::from_ref(self),
395        }
396    }
397
398    fn dfs_pre(&self) -> impl Iterator<Item = &Self> {
399        let mut stack: Vec<&Self> = vec![self];
400        iter::from_fn(move || {
401            let expr = stack.pop()?;
402            match expr {
403                Self::None | Self::All | Self::Pattern(_) => {}
404                Self::UnionAll(exprs) => stack.extend(exprs.iter().rev()),
405                Self::Intersection(expr1, expr2) | Self::Difference(expr1, expr2) => {
406                    stack.push(expr2);
407                    stack.push(expr1);
408                }
409            }
410            Some(expr)
411        })
412    }
413
414    /// Iterates literal paths recursively from this expression.
415    ///
416    /// For example, `"a", "b", "c"` will be yielded in that order for
417    /// expression `"a" | all() & "b" | ~"c"`.
418    pub fn explicit_paths(&self) -> impl Iterator<Item = &RepoPath> {
419        // pre/post-ordering doesn't matter so long as children are visited from
420        // left to right.
421        self.dfs_pre().filter_map(|expr| match expr {
422            Self::Pattern(pattern) => pattern.as_path(),
423            _ => None,
424        })
425    }
426
427    /// Transforms the expression tree to `Matcher` object.
428    pub fn to_matcher(&self) -> Box<dyn Matcher> {
429        build_union_matcher(self.as_union_all())
430    }
431}
432
433/// Transforms the union `expressions` to `Matcher` object.
434///
435/// Since `Matcher` typically accepts a set of patterns to be OR-ed, this
436/// function takes a list of union `expressions` as input.
437fn build_union_matcher(expressions: &[FilesetExpression]) -> Box<dyn Matcher> {
438    let mut file_paths = Vec::new();
439    let mut prefix_paths = Vec::new();
440    let mut file_globs = GlobsMatcher::builder().prefix_paths(false);
441    let mut prefix_globs = GlobsMatcher::builder().prefix_paths(true);
442    let mut matchers: Vec<Option<Box<dyn Matcher>>> = Vec::new();
443    for expr in expressions {
444        let matcher: Box<dyn Matcher> = match expr {
445            // None and All are supposed to be simplified by caller.
446            FilesetExpression::None => Box::new(NothingMatcher),
447            FilesetExpression::All => Box::new(EverythingMatcher),
448            FilesetExpression::Pattern(pattern) => {
449                match pattern {
450                    FilePattern::FilePath(path) => file_paths.push(path),
451                    FilePattern::PrefixPath(path) => prefix_paths.push(path),
452                    FilePattern::FileGlob { dir, pattern } => file_globs.add(dir, pattern),
453                    FilePattern::PrefixGlob { dir, pattern } => prefix_globs.add(dir, pattern),
454                }
455                continue;
456            }
457            // UnionAll is supposed to be flattened by caller.
458            FilesetExpression::UnionAll(exprs) => build_union_matcher(exprs),
459            FilesetExpression::Intersection(expr1, expr2) => {
460                let m1 = build_union_matcher(expr1.as_union_all());
461                let m2 = build_union_matcher(expr2.as_union_all());
462                Box::new(IntersectionMatcher::new(m1, m2))
463            }
464            FilesetExpression::Difference(expr1, expr2) => {
465                let m1 = build_union_matcher(expr1.as_union_all());
466                let m2 = build_union_matcher(expr2.as_union_all());
467                Box::new(DifferenceMatcher::new(m1, m2))
468            }
469        };
470        matchers.push(Some(matcher));
471    }
472
473    if !file_paths.is_empty() {
474        matchers.push(Some(Box::new(FilesMatcher::new(file_paths))));
475    }
476    if !prefix_paths.is_empty() {
477        matchers.push(Some(Box::new(PrefixMatcher::new(prefix_paths))));
478    }
479    if !file_globs.is_empty() {
480        matchers.push(Some(Box::new(file_globs.build())));
481    }
482    if !prefix_globs.is_empty() {
483        matchers.push(Some(Box::new(prefix_globs.build())));
484    }
485    union_all_matchers(&mut matchers)
486}
487
488/// Concatenates all `matchers` as union.
489///
490/// Each matcher element must be wrapped in `Some` so the matchers can be moved
491/// in arbitrary order.
492fn union_all_matchers(matchers: &mut [Option<Box<dyn Matcher>>]) -> Box<dyn Matcher> {
493    match matchers {
494        [] => Box::new(NothingMatcher),
495        [matcher] => matcher.take().expect("matcher should still be available"),
496        _ => {
497            // Build balanced tree to minimize the recursion depth.
498            let (left, right) = matchers.split_at_mut(matchers.len() / 2);
499            let m1 = union_all_matchers(left);
500            let m2 = union_all_matchers(right);
501            Box::new(UnionMatcher::new(m1, m2))
502        }
503    }
504}
505
506type FilesetFunction = fn(
507    &mut FilesetDiagnostics,
508    &RepoPathUiConverter,
509    &FunctionCallNode,
510) -> FilesetParseResult<FilesetExpression>;
511
512static BUILTIN_FUNCTION_MAP: LazyLock<HashMap<&str, FilesetFunction>> = LazyLock::new(|| {
513    // Not using maplit::hashmap!{} or custom declarative macro here because
514    // code completion inside macro is quite restricted.
515    let mut map: HashMap<&str, FilesetFunction> = HashMap::new();
516    map.insert("none", |_diagnostics, _path_converter, function| {
517        function.expect_no_arguments()?;
518        Ok(FilesetExpression::none())
519    });
520    map.insert("all", |_diagnostics, _path_converter, function| {
521        function.expect_no_arguments()?;
522        Ok(FilesetExpression::all())
523    });
524    map
525});
526
527fn resolve_function(
528    diagnostics: &mut FilesetDiagnostics,
529    path_converter: &RepoPathUiConverter,
530    function: &FunctionCallNode,
531) -> FilesetParseResult<FilesetExpression> {
532    if let Some(func) = BUILTIN_FUNCTION_MAP.get(function.name) {
533        func(diagnostics, path_converter, function)
534    } else {
535        Err(FilesetParseError::new(
536            FilesetParseErrorKind::NoSuchFunction {
537                name: function.name.to_owned(),
538                candidates: collect_similar(function.name, BUILTIN_FUNCTION_MAP.keys()),
539            },
540            function.name_span,
541        ))
542    }
543}
544
545fn resolve_expression(
546    diagnostics: &mut FilesetDiagnostics,
547    path_converter: &RepoPathUiConverter,
548    node: &ExpressionNode,
549) -> FilesetParseResult<FilesetExpression> {
550    let wrap_pattern_error =
551        |err| FilesetParseError::expression("Invalid file pattern", node.span).with_source(err);
552    match &node.kind {
553        ExpressionKind::Identifier(name) => {
554            let pattern =
555                FilePattern::cwd_prefix_glob(path_converter, name).map_err(wrap_pattern_error)?;
556            Ok(FilesetExpression::pattern(pattern))
557        }
558        ExpressionKind::String(name) => {
559            let pattern =
560                FilePattern::cwd_prefix_glob(path_converter, name).map_err(wrap_pattern_error)?;
561            Ok(FilesetExpression::pattern(pattern))
562        }
563        ExpressionKind::Pattern { kind, value } => {
564            let value = fileset_parser::expect_string_literal("string", value)?;
565            let pattern = FilePattern::from_str_kind(path_converter, value, kind)
566                .map_err(wrap_pattern_error)?;
567            Ok(FilesetExpression::pattern(pattern))
568        }
569        ExpressionKind::Unary(op, arg_node) => {
570            let arg = resolve_expression(diagnostics, path_converter, arg_node)?;
571            match op {
572                UnaryOp::Negate => Ok(FilesetExpression::all().difference(arg)),
573            }
574        }
575        ExpressionKind::Binary(op, lhs_node, rhs_node) => {
576            let lhs = resolve_expression(diagnostics, path_converter, lhs_node)?;
577            let rhs = resolve_expression(diagnostics, path_converter, rhs_node)?;
578            match op {
579                BinaryOp::Intersection => Ok(lhs.intersection(rhs)),
580                BinaryOp::Difference => Ok(lhs.difference(rhs)),
581            }
582        }
583        ExpressionKind::UnionAll(nodes) => {
584            let expressions = nodes
585                .iter()
586                .map(|node| resolve_expression(diagnostics, path_converter, node))
587                .try_collect()?;
588            Ok(FilesetExpression::union_all(expressions))
589        }
590        ExpressionKind::FunctionCall(function) => {
591            resolve_function(diagnostics, path_converter, function)
592        }
593    }
594}
595
596/// Parses text into `FilesetExpression` without bare string fallback.
597pub fn parse(
598    diagnostics: &mut FilesetDiagnostics,
599    text: &str,
600    path_converter: &RepoPathUiConverter,
601) -> FilesetParseResult<FilesetExpression> {
602    let node = fileset_parser::parse_program(text)?;
603    // TODO: add basic tree substitution pass to eliminate redundant expressions
604    resolve_expression(diagnostics, path_converter, &node)
605}
606
607/// Parses text into `FilesetExpression` with bare string fallback.
608///
609/// If the text can't be parsed as a fileset expression, and if it doesn't
610/// contain any operator-like characters, it will be parsed as a file path.
611pub fn parse_maybe_bare(
612    diagnostics: &mut FilesetDiagnostics,
613    text: &str,
614    path_converter: &RepoPathUiConverter,
615) -> FilesetParseResult<FilesetExpression> {
616    let node = fileset_parser::parse_program_or_bare_string(text)?;
617    // TODO: add basic tree substitution pass to eliminate redundant expressions
618    resolve_expression(diagnostics, path_converter, &node)
619}
620
621#[cfg(test)]
622mod tests {
623    use std::path::PathBuf;
624
625    use super::*;
626
627    fn repo_path_buf(value: impl Into<String>) -> RepoPathBuf {
628        RepoPathBuf::from_internal_string(value).unwrap()
629    }
630
631    fn insta_settings() -> insta::Settings {
632        let mut settings = insta::Settings::clone_current();
633        // Elide parsed glob options and tokens, which aren't interesting.
634        settings.add_filter(
635            r"(?m)^(\s{12}opts):\s*GlobOptions\s*\{\n(\s{16}.*\n)*\s{12}\},",
636            "$1: _,",
637        );
638        settings.add_filter(
639            r"(?m)^(\s{12}tokens):\s*Tokens\(\n(\s{16}.*\n)*\s{12}\),",
640            "$1: _,",
641        );
642        // Collapse short "Thing(_,)" repeatedly to save vertical space and make
643        // the output more readable.
644        for _ in 0..4 {
645            settings.add_filter(
646                r"(?x)
647                \b([A-Z]\w*)\(\n
648                    \s*(.{1,60}),\n
649                \s*\)",
650                "$1($2)",
651            );
652        }
653        settings
654    }
655
656    #[test]
657    fn test_parse_file_pattern() {
658        let settings = insta_settings();
659        let _guard = settings.bind_to_scope();
660        let path_converter = RepoPathUiConverter::Fs {
661            cwd: PathBuf::from("/ws/cur"),
662            base: PathBuf::from("/ws"),
663        };
664        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
665
666        // cwd-relative patterns
667        insta::assert_debug_snapshot!(
668            parse(".").unwrap(),
669            @r#"Pattern(PrefixPath("cur"))"#);
670        insta::assert_debug_snapshot!(
671            parse("..").unwrap(),
672            @r#"Pattern(PrefixPath(""))"#);
673        assert!(parse("../..").is_err());
674        insta::assert_debug_snapshot!(
675            parse("foo").unwrap(),
676            @r#"Pattern(PrefixPath("cur/foo"))"#);
677        insta::assert_debug_snapshot!(
678            parse("*.*").unwrap(),
679            @r#"
680        Pattern(
681            PrefixGlob {
682                dir: "cur",
683                pattern: Glob {
684                    glob: "*.*",
685                    re: "(?-u)^[^/]*\\.[^/]*$",
686                    opts: _,
687                    tokens: _,
688                },
689            },
690        )
691        "#);
692        insta::assert_debug_snapshot!(
693            parse("cwd:.").unwrap(),
694            @r#"Pattern(PrefixPath("cur"))"#);
695        insta::assert_debug_snapshot!(
696            parse("cwd-file:foo").unwrap(),
697            @r#"Pattern(FilePath("cur/foo"))"#);
698        insta::assert_debug_snapshot!(
699            parse("file:../foo/bar").unwrap(),
700            @r#"Pattern(FilePath("foo/bar"))"#);
701
702        // workspace-relative patterns
703        insta::assert_debug_snapshot!(
704            parse("root:.").unwrap(),
705            @r#"Pattern(PrefixPath(""))"#);
706        assert!(parse("root:..").is_err());
707        insta::assert_debug_snapshot!(
708            parse("root:foo/bar").unwrap(),
709            @r#"Pattern(PrefixPath("foo/bar"))"#);
710        insta::assert_debug_snapshot!(
711            parse("root-file:bar").unwrap(),
712            @r#"Pattern(FilePath("bar"))"#);
713    }
714
715    #[test]
716    fn test_parse_glob_pattern() {
717        let settings = insta_settings();
718        let _guard = settings.bind_to_scope();
719        let path_converter = RepoPathUiConverter::Fs {
720            // meta character in cwd path shouldn't be expanded
721            cwd: PathBuf::from("/ws/cur*"),
722            base: PathBuf::from("/ws"),
723        };
724        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
725
726        // cwd-relative, without meta characters
727        insta::assert_debug_snapshot!(
728            parse(r#"cwd-glob:"foo""#).unwrap(),
729            @r#"Pattern(FilePath("cur*/foo"))"#);
730        // Strictly speaking, glob:"" shouldn't match a file named <cwd>, but
731        // file pattern doesn't distinguish "foo/" from "foo".
732        insta::assert_debug_snapshot!(
733            parse(r#"glob:"""#).unwrap(),
734            @r#"Pattern(FilePath("cur*"))"#);
735        insta::assert_debug_snapshot!(
736            parse(r#"glob:".""#).unwrap(),
737            @r#"Pattern(FilePath("cur*"))"#);
738        insta::assert_debug_snapshot!(
739            parse(r#"glob:"..""#).unwrap(),
740            @r#"Pattern(FilePath(""))"#);
741
742        // cwd-relative, with meta characters
743        insta::assert_debug_snapshot!(
744            parse(r#"glob:"*""#).unwrap(), @r#"
745        Pattern(
746            FileGlob {
747                dir: "cur*",
748                pattern: Glob {
749                    glob: "*",
750                    re: "(?-u)^[^/]*$",
751                    opts: _,
752                    tokens: _,
753                },
754            },
755        )
756        "#);
757        insta::assert_debug_snapshot!(
758            parse(r#"glob:"./*""#).unwrap(), @r#"
759        Pattern(
760            FileGlob {
761                dir: "cur*",
762                pattern: Glob {
763                    glob: "*",
764                    re: "(?-u)^[^/]*$",
765                    opts: _,
766                    tokens: _,
767                },
768            },
769        )
770        "#);
771        insta::assert_debug_snapshot!(
772            parse(r#"glob:"../*""#).unwrap(), @r#"
773        Pattern(
774            FileGlob {
775                dir: "",
776                pattern: Glob {
777                    glob: "*",
778                    re: "(?-u)^[^/]*$",
779                    opts: _,
780                    tokens: _,
781                },
782            },
783        )
784        "#);
785        // glob:"**" is equivalent to root-glob:"<cwd>/**", not root-glob:"**"
786        insta::assert_debug_snapshot!(
787            parse(r#"glob:"**""#).unwrap(), @r#"
788        Pattern(
789            FileGlob {
790                dir: "cur*",
791                pattern: Glob {
792                    glob: "**",
793                    re: "(?-u)^.*$",
794                    opts: _,
795                    tokens: _,
796                },
797            },
798        )
799        "#);
800        insta::assert_debug_snapshot!(
801            parse(r#"glob:"../foo/b?r/baz""#).unwrap(), @r#"
802        Pattern(
803            FileGlob {
804                dir: "foo",
805                pattern: Glob {
806                    glob: "b?r/baz",
807                    re: "(?-u)^b[^/]r/baz$",
808                    opts: _,
809                    tokens: _,
810                },
811            },
812        )
813        "#);
814        assert!(parse(r#"glob:"../../*""#).is_err());
815        assert!(parse(r#"glob-i:"../../*""#).is_err());
816        assert!(parse(r#"glob:"/*""#).is_err());
817        assert!(parse(r#"glob-i:"/*""#).is_err());
818        // no support for relative path component after glob meta character
819        assert!(parse(r#"glob:"*/..""#).is_err());
820        assert!(parse(r#"glob-i:"*/..""#).is_err());
821
822        if cfg!(windows) {
823            // cwd-relative, with Windows path separators
824            insta::assert_debug_snapshot!(
825                parse(r#"glob:"..\\foo\\*\\bar""#).unwrap(), @r#"
826            Pattern(
827                FileGlob {
828                    dir: "foo",
829                    pattern: Glob {
830                        glob: "*/bar",
831                        re: "(?-u)^[^/]*/bar$",
832                        opts: _,
833                        tokens: _,
834                    },
835                },
836            )
837            "#);
838        } else {
839            // backslash is an escape character on Unix
840            insta::assert_debug_snapshot!(
841                parse(r#"glob:"..\\foo\\*\\bar""#).unwrap(), @r#"
842            Pattern(
843                FileGlob {
844                    dir: "cur*",
845                    pattern: Glob {
846                        glob: "..\\foo\\*\\bar",
847                        re: "(?-u)^\\.\\.foo\\*bar$",
848                        opts: _,
849                        tokens: _,
850                    },
851                },
852            )
853            "#);
854        }
855
856        // workspace-relative, without meta characters
857        insta::assert_debug_snapshot!(
858            parse(r#"root-glob:"foo""#).unwrap(),
859            @r#"Pattern(FilePath("foo"))"#);
860        insta::assert_debug_snapshot!(
861            parse(r#"root-glob:"""#).unwrap(),
862            @r#"Pattern(FilePath(""))"#);
863        insta::assert_debug_snapshot!(
864            parse(r#"root-glob:".""#).unwrap(),
865            @r#"Pattern(FilePath(""))"#);
866
867        // workspace-relative, with meta characters
868        insta::assert_debug_snapshot!(
869            parse(r#"root-glob:"*""#).unwrap(), @r#"
870        Pattern(
871            FileGlob {
872                dir: "",
873                pattern: Glob {
874                    glob: "*",
875                    re: "(?-u)^[^/]*$",
876                    opts: _,
877                    tokens: _,
878                },
879            },
880        )
881        "#);
882        insta::assert_debug_snapshot!(
883            parse(r#"root-glob:"foo/bar/b[az]""#).unwrap(), @r#"
884        Pattern(
885            FileGlob {
886                dir: "foo/bar",
887                pattern: Glob {
888                    glob: "b[az]",
889                    re: "(?-u)^b[az]$",
890                    opts: _,
891                    tokens: _,
892                },
893            },
894        )
895        "#);
896        insta::assert_debug_snapshot!(
897            parse(r#"root-glob:"foo/bar/b{ar,az}""#).unwrap(), @r#"
898        Pattern(
899            FileGlob {
900                dir: "foo/bar",
901                pattern: Glob {
902                    glob: "b{ar,az}",
903                    re: "(?-u)^b(?:ar|az)$",
904                    opts: _,
905                    tokens: _,
906                },
907            },
908        )
909        "#);
910        assert!(parse(r#"root-glob:"../*""#).is_err());
911        assert!(parse(r#"root-glob-i:"../*""#).is_err());
912        assert!(parse(r#"root-glob:"/*""#).is_err());
913        assert!(parse(r#"root-glob-i:"/*""#).is_err());
914
915        // workspace-relative, backslash escape without meta characters
916        if cfg!(not(windows)) {
917            insta::assert_debug_snapshot!(
918                parse(r#"root-glob:'foo/bar\baz'"#).unwrap(), @r#"
919            Pattern(
920                FileGlob {
921                    dir: "foo",
922                    pattern: Glob {
923                        glob: "bar\\baz",
924                        re: "(?-u)^barbaz$",
925                        opts: _,
926                        tokens: _,
927                    },
928                },
929            )
930            "#);
931        }
932    }
933
934    #[test]
935    fn test_parse_glob_pattern_case_insensitive() {
936        let settings = insta_settings();
937        let _guard = settings.bind_to_scope();
938        let path_converter = RepoPathUiConverter::Fs {
939            cwd: PathBuf::from("/ws/cur"),
940            base: PathBuf::from("/ws"),
941        };
942        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
943
944        // cwd-relative case-insensitive glob
945        insta::assert_debug_snapshot!(
946            parse(r#"glob-i:"*.TXT""#).unwrap(), @r#"
947        Pattern(
948            FileGlob {
949                dir: "cur",
950                pattern: Glob {
951                    glob: "*.TXT",
952                    re: "(?-u)(?i)^[^/]*\\.TXT$",
953                    opts: _,
954                    tokens: _,
955                },
956            },
957        )
958        "#);
959
960        // cwd-relative case-insensitive glob with more specific pattern
961        insta::assert_debug_snapshot!(
962            parse(r#"cwd-glob-i:"[Ff]oo""#).unwrap(), @r#"
963        Pattern(
964            FileGlob {
965                dir: "cur",
966                pattern: Glob {
967                    glob: "[Ff]oo",
968                    re: "(?-u)(?i)^[Ff]oo$",
969                    opts: _,
970                    tokens: _,
971                },
972            },
973        )
974        "#);
975
976        // workspace-relative case-insensitive glob
977        insta::assert_debug_snapshot!(
978            parse(r#"root-glob-i:"*.Rs""#).unwrap(), @r#"
979        Pattern(
980            FileGlob {
981                dir: "",
982                pattern: Glob {
983                    glob: "*.Rs",
984                    re: "(?-u)(?i)^[^/]*\\.Rs$",
985                    opts: _,
986                    tokens: _,
987                },
988            },
989        )
990        "#);
991
992        // case-insensitive pattern with directory component (should not split the path)
993        insta::assert_debug_snapshot!(
994            parse(r#"glob-i:"SubDir/*.rs""#).unwrap(), @r#"
995        Pattern(
996            FileGlob {
997                dir: "cur",
998                pattern: Glob {
999                    glob: "SubDir/*.rs",
1000                    re: "(?-u)(?i)^SubDir/[^/]*\\.rs$",
1001                    opts: _,
1002                    tokens: _,
1003                },
1004            },
1005        )
1006        "#);
1007
1008        // case-sensitive pattern with directory component (should split the path)
1009        insta::assert_debug_snapshot!(
1010            parse(r#"glob:"SubDir/*.rs""#).unwrap(), @r#"
1011        Pattern(
1012            FileGlob {
1013                dir: "cur/SubDir",
1014                pattern: Glob {
1015                    glob: "*.rs",
1016                    re: "(?-u)^[^/]*\\.rs$",
1017                    opts: _,
1018                    tokens: _,
1019                },
1020            },
1021        )
1022        "#);
1023
1024        // case-insensitive pattern with leading dots (should split dots but not dirs)
1025        insta::assert_debug_snapshot!(
1026            parse(r#"glob-i:"../SomeDir/*.rs""#).unwrap(), @r#"
1027        Pattern(
1028            FileGlob {
1029                dir: "",
1030                pattern: Glob {
1031                    glob: "SomeDir/*.rs",
1032                    re: "(?-u)(?i)^SomeDir/[^/]*\\.rs$",
1033                    opts: _,
1034                    tokens: _,
1035                },
1036            },
1037        )
1038        "#);
1039
1040        // case-insensitive pattern with single leading dot
1041        insta::assert_debug_snapshot!(
1042            parse(r#"glob-i:"./SomeFile*.txt""#).unwrap(), @r#"
1043        Pattern(
1044            FileGlob {
1045                dir: "cur",
1046                pattern: Glob {
1047                    glob: "SomeFile*.txt",
1048                    re: "(?-u)(?i)^SomeFile[^/]*\\.txt$",
1049                    opts: _,
1050                    tokens: _,
1051                },
1052            },
1053        )
1054        "#);
1055    }
1056
1057    #[test]
1058    fn test_parse_prefix_glob_pattern() {
1059        let settings = insta_settings();
1060        let _guard = settings.bind_to_scope();
1061        let path_converter = RepoPathUiConverter::Fs {
1062            // meta character in cwd path shouldn't be expanded
1063            cwd: PathBuf::from("/ws/cur*"),
1064            base: PathBuf::from("/ws"),
1065        };
1066        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
1067
1068        // cwd-relative, without meta/case-insensitive characters
1069        insta::assert_debug_snapshot!(
1070            parse("cwd-prefix-glob:'foo'").unwrap(),
1071            @r#"Pattern(PrefixPath("cur*/foo"))"#);
1072        insta::assert_debug_snapshot!(
1073            parse("prefix-glob:'.'").unwrap(),
1074            @r#"Pattern(PrefixPath("cur*"))"#);
1075        insta::assert_debug_snapshot!(
1076            parse("cwd-prefix-glob-i:'..'").unwrap(),
1077            @r#"Pattern(PrefixPath(""))"#);
1078        insta::assert_debug_snapshot!(
1079            parse("prefix-glob-i:'../_'").unwrap(),
1080            @r#"Pattern(PrefixPath("_"))"#);
1081
1082        // cwd-relative, with meta characters
1083        insta::assert_debug_snapshot!(
1084            parse("cwd-prefix-glob:'*'").unwrap(), @r#"
1085        Pattern(
1086            PrefixGlob {
1087                dir: "cur*",
1088                pattern: Glob {
1089                    glob: "*",
1090                    re: "(?-u)^[^/]*$",
1091                    opts: _,
1092                    tokens: _,
1093                },
1094            },
1095        )
1096        "#);
1097
1098        // cwd-relative, with case-insensitive characters
1099        insta::assert_debug_snapshot!(
1100            parse("cwd-prefix-glob-i:'../foo'").unwrap(), @r#"
1101        Pattern(
1102            PrefixGlob {
1103                dir: "",
1104                pattern: Glob {
1105                    glob: "foo",
1106                    re: "(?-u)(?i)^foo$",
1107                    opts: _,
1108                    tokens: _,
1109                },
1110            },
1111        )
1112        "#);
1113
1114        // workspace-relative, without meta/case-insensitive characters
1115        insta::assert_debug_snapshot!(
1116            parse("root-prefix-glob:'foo'").unwrap(),
1117            @r#"Pattern(PrefixPath("foo"))"#);
1118        insta::assert_debug_snapshot!(
1119            parse("root-prefix-glob-i:'.'").unwrap(),
1120            @r#"Pattern(PrefixPath(""))"#);
1121
1122        // workspace-relative, with meta characters
1123        insta::assert_debug_snapshot!(
1124            parse("root-prefix-glob:'*'").unwrap(), @r#"
1125        Pattern(
1126            PrefixGlob {
1127                dir: "",
1128                pattern: Glob {
1129                    glob: "*",
1130                    re: "(?-u)^[^/]*$",
1131                    opts: _,
1132                    tokens: _,
1133                },
1134            },
1135        )
1136        "#);
1137
1138        // workspace-relative, with case-insensitive characters
1139        insta::assert_debug_snapshot!(
1140            parse("root-prefix-glob-i:'_/foo'").unwrap(), @r#"
1141        Pattern(
1142            PrefixGlob {
1143                dir: "_",
1144                pattern: Glob {
1145                    glob: "foo",
1146                    re: "(?-u)(?i)^foo$",
1147                    opts: _,
1148                    tokens: _,
1149                },
1150            },
1151        )
1152        "#);
1153    }
1154
1155    #[test]
1156    fn test_parse_function() {
1157        let settings = insta_settings();
1158        let _guard = settings.bind_to_scope();
1159        let path_converter = RepoPathUiConverter::Fs {
1160            cwd: PathBuf::from("/ws/cur"),
1161            base: PathBuf::from("/ws"),
1162        };
1163        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
1164
1165        insta::assert_debug_snapshot!(parse("all()").unwrap(), @"All");
1166        insta::assert_debug_snapshot!(parse("none()").unwrap(), @"None");
1167        insta::assert_debug_snapshot!(parse("all(x)").unwrap_err().kind(), @r#"
1168        InvalidArguments {
1169            name: "all",
1170            message: "Expected 0 arguments",
1171        }
1172        "#);
1173        insta::assert_debug_snapshot!(parse("ale()").unwrap_err().kind(), @r#"
1174        NoSuchFunction {
1175            name: "ale",
1176            candidates: [
1177                "all",
1178            ],
1179        }
1180        "#);
1181    }
1182
1183    #[test]
1184    fn test_parse_compound_expression() {
1185        let settings = insta_settings();
1186        let _guard = settings.bind_to_scope();
1187        let path_converter = RepoPathUiConverter::Fs {
1188            cwd: PathBuf::from("/ws/cur"),
1189            base: PathBuf::from("/ws"),
1190        };
1191        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
1192
1193        insta::assert_debug_snapshot!(parse("~x").unwrap(), @r#"
1194        Difference(
1195            All,
1196            Pattern(PrefixPath("cur/x")),
1197        )
1198        "#);
1199        insta::assert_debug_snapshot!(parse("x|y|root:z").unwrap(), @r#"
1200        UnionAll(
1201            [
1202                Pattern(PrefixPath("cur/x")),
1203                Pattern(PrefixPath("cur/y")),
1204                Pattern(PrefixPath("z")),
1205            ],
1206        )
1207        "#);
1208        insta::assert_debug_snapshot!(parse("x|y&z").unwrap(), @r#"
1209        UnionAll(
1210            [
1211                Pattern(PrefixPath("cur/x")),
1212                Intersection(
1213                    Pattern(PrefixPath("cur/y")),
1214                    Pattern(PrefixPath("cur/z")),
1215                ),
1216            ],
1217        )
1218        "#);
1219    }
1220
1221    #[test]
1222    fn test_explicit_paths() {
1223        let collect = |expr: &FilesetExpression| -> Vec<RepoPathBuf> {
1224            expr.explicit_paths().map(|path| path.to_owned()).collect()
1225        };
1226        let file_expr = |path: &str| FilesetExpression::file_path(repo_path_buf(path));
1227        assert!(collect(&FilesetExpression::none()).is_empty());
1228        assert_eq!(collect(&file_expr("a")), ["a"].map(repo_path_buf));
1229        assert_eq!(
1230            collect(&FilesetExpression::union_all(vec![
1231                file_expr("a"),
1232                file_expr("b"),
1233                file_expr("c"),
1234            ])),
1235            ["a", "b", "c"].map(repo_path_buf)
1236        );
1237        assert_eq!(
1238            collect(&FilesetExpression::intersection(
1239                FilesetExpression::union_all(vec![
1240                    file_expr("a"),
1241                    FilesetExpression::none(),
1242                    file_expr("b"),
1243                    file_expr("c"),
1244                ]),
1245                FilesetExpression::difference(
1246                    file_expr("d"),
1247                    FilesetExpression::union_all(vec![file_expr("e"), file_expr("f")])
1248                )
1249            )),
1250            ["a", "b", "c", "d", "e", "f"].map(repo_path_buf)
1251        );
1252    }
1253
1254    #[test]
1255    fn test_build_matcher_simple() {
1256        let settings = insta_settings();
1257        let _guard = settings.bind_to_scope();
1258
1259        insta::assert_debug_snapshot!(FilesetExpression::none().to_matcher(), @"NothingMatcher");
1260        insta::assert_debug_snapshot!(FilesetExpression::all().to_matcher(), @"EverythingMatcher");
1261        insta::assert_debug_snapshot!(
1262            FilesetExpression::file_path(repo_path_buf("foo")).to_matcher(),
1263            @r#"
1264        FilesMatcher {
1265            tree: Dir {
1266                "foo": File {},
1267            },
1268        }
1269        "#);
1270        insta::assert_debug_snapshot!(
1271            FilesetExpression::prefix_path(repo_path_buf("foo")).to_matcher(),
1272            @r#"
1273        PrefixMatcher {
1274            tree: Dir {
1275                "foo": Prefix {},
1276            },
1277        }
1278        "#);
1279    }
1280
1281    #[test]
1282    fn test_build_matcher_glob_pattern() {
1283        let settings = insta_settings();
1284        let _guard = settings.bind_to_scope();
1285        let file_glob_expr = |dir: &str, pattern: &str| {
1286            FilesetExpression::pattern(FilePattern::FileGlob {
1287                dir: repo_path_buf(dir),
1288                pattern: Box::new(parse_file_glob(pattern, false).unwrap()),
1289            })
1290        };
1291        let prefix_glob_expr = |dir: &str, pattern: &str| {
1292            FilesetExpression::pattern(FilePattern::PrefixGlob {
1293                dir: repo_path_buf(dir),
1294                pattern: Box::new(parse_file_glob(pattern, false).unwrap()),
1295            })
1296        };
1297
1298        insta::assert_debug_snapshot!(file_glob_expr("", "*").to_matcher(), @r#"
1299        GlobsMatcher {
1300            tree: Some(RegexSet(["(?-u)^[^/]*$"])) {},
1301            matches_prefix_paths: false,
1302        }
1303        "#);
1304
1305        let expr = FilesetExpression::union_all(vec![
1306            file_glob_expr("foo", "*"),
1307            file_glob_expr("foo/bar", "*"),
1308            file_glob_expr("foo", "?"),
1309            prefix_glob_expr("foo", "ba[rz]"),
1310            prefix_glob_expr("foo", "qu*x"),
1311        ]);
1312        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1313        UnionMatcher {
1314            input1: GlobsMatcher {
1315                tree: None {
1316                    "foo": Some(RegexSet(["(?-u)^[^/]*$", "(?-u)^[^/]$"])) {
1317                        "bar": Some(RegexSet(["(?-u)^[^/]*$"])) {},
1318                    },
1319                },
1320                matches_prefix_paths: false,
1321            },
1322            input2: GlobsMatcher {
1323                tree: None {
1324                    "foo": Some(RegexSet(["(?-u)^ba[rz](?:/|$)", "(?-u)^qu[^/]*x(?:/|$)"])) {},
1325                },
1326                matches_prefix_paths: true,
1327            },
1328        }
1329        "#);
1330    }
1331
1332    #[test]
1333    fn test_build_matcher_union_patterns_of_same_kind() {
1334        let settings = insta_settings();
1335        let _guard = settings.bind_to_scope();
1336
1337        let expr = FilesetExpression::union_all(vec![
1338            FilesetExpression::file_path(repo_path_buf("foo")),
1339            FilesetExpression::file_path(repo_path_buf("foo/bar")),
1340        ]);
1341        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1342        FilesMatcher {
1343            tree: Dir {
1344                "foo": File {
1345                    "bar": File {},
1346                },
1347            },
1348        }
1349        "#);
1350
1351        let expr = FilesetExpression::union_all(vec![
1352            FilesetExpression::prefix_path(repo_path_buf("bar")),
1353            FilesetExpression::prefix_path(repo_path_buf("bar/baz")),
1354        ]);
1355        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1356        PrefixMatcher {
1357            tree: Dir {
1358                "bar": Prefix {
1359                    "baz": Prefix {},
1360                },
1361            },
1362        }
1363        "#);
1364    }
1365
1366    #[test]
1367    fn test_build_matcher_union_patterns_of_different_kind() {
1368        let settings = insta_settings();
1369        let _guard = settings.bind_to_scope();
1370
1371        let expr = FilesetExpression::union_all(vec![
1372            FilesetExpression::file_path(repo_path_buf("foo")),
1373            FilesetExpression::prefix_path(repo_path_buf("bar")),
1374        ]);
1375        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1376        UnionMatcher {
1377            input1: FilesMatcher {
1378                tree: Dir {
1379                    "foo": File {},
1380                },
1381            },
1382            input2: PrefixMatcher {
1383                tree: Dir {
1384                    "bar": Prefix {},
1385                },
1386            },
1387        }
1388        "#);
1389    }
1390
1391    #[test]
1392    fn test_build_matcher_unnormalized_union() {
1393        let settings = insta_settings();
1394        let _guard = settings.bind_to_scope();
1395
1396        let expr = FilesetExpression::UnionAll(vec![]);
1397        insta::assert_debug_snapshot!(expr.to_matcher(), @"NothingMatcher");
1398
1399        let expr =
1400            FilesetExpression::UnionAll(vec![FilesetExpression::None, FilesetExpression::All]);
1401        insta::assert_debug_snapshot!(expr.to_matcher(), @r"
1402        UnionMatcher {
1403            input1: NothingMatcher,
1404            input2: EverythingMatcher,
1405        }
1406        ");
1407    }
1408
1409    #[test]
1410    fn test_build_matcher_combined() {
1411        let settings = insta_settings();
1412        let _guard = settings.bind_to_scope();
1413
1414        let expr = FilesetExpression::union_all(vec![
1415            FilesetExpression::intersection(FilesetExpression::all(), FilesetExpression::none()),
1416            FilesetExpression::difference(FilesetExpression::none(), FilesetExpression::all()),
1417            FilesetExpression::file_path(repo_path_buf("foo")),
1418            FilesetExpression::prefix_path(repo_path_buf("bar")),
1419        ]);
1420        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1421        UnionMatcher {
1422            input1: UnionMatcher {
1423                input1: IntersectionMatcher {
1424                    input1: EverythingMatcher,
1425                    input2: NothingMatcher,
1426                },
1427                input2: DifferenceMatcher {
1428                    wanted: NothingMatcher,
1429                    unwanted: EverythingMatcher,
1430                },
1431            },
1432            input2: UnionMatcher {
1433                input1: FilesMatcher {
1434                    tree: Dir {
1435                        "foo": File {},
1436                    },
1437                },
1438                input2: PrefixMatcher {
1439                    tree: Dir {
1440                        "bar": Prefix {},
1441                    },
1442                },
1443            },
1444        }
1445        "#);
1446    }
1447}