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::StringPattern { kind, value } => {
564            let pattern = FilePattern::from_str_kind(path_converter, value, kind)
565                .map_err(wrap_pattern_error)?;
566            Ok(FilesetExpression::pattern(pattern))
567        }
568        ExpressionKind::Unary(op, arg_node) => {
569            let arg = resolve_expression(diagnostics, path_converter, arg_node)?;
570            match op {
571                UnaryOp::Negate => Ok(FilesetExpression::all().difference(arg)),
572            }
573        }
574        ExpressionKind::Binary(op, lhs_node, rhs_node) => {
575            let lhs = resolve_expression(diagnostics, path_converter, lhs_node)?;
576            let rhs = resolve_expression(diagnostics, path_converter, rhs_node)?;
577            match op {
578                BinaryOp::Intersection => Ok(lhs.intersection(rhs)),
579                BinaryOp::Difference => Ok(lhs.difference(rhs)),
580            }
581        }
582        ExpressionKind::UnionAll(nodes) => {
583            let expressions = nodes
584                .iter()
585                .map(|node| resolve_expression(diagnostics, path_converter, node))
586                .try_collect()?;
587            Ok(FilesetExpression::union_all(expressions))
588        }
589        ExpressionKind::FunctionCall(function) => {
590            resolve_function(diagnostics, path_converter, function)
591        }
592    }
593}
594
595/// Parses text into `FilesetExpression` without bare string fallback.
596pub fn parse(
597    diagnostics: &mut FilesetDiagnostics,
598    text: &str,
599    path_converter: &RepoPathUiConverter,
600) -> FilesetParseResult<FilesetExpression> {
601    let node = fileset_parser::parse_program(text)?;
602    // TODO: add basic tree substitution pass to eliminate redundant expressions
603    resolve_expression(diagnostics, path_converter, &node)
604}
605
606/// Parses text into `FilesetExpression` with bare string fallback.
607///
608/// If the text can't be parsed as a fileset expression, and if it doesn't
609/// contain any operator-like characters, it will be parsed as a file path.
610pub fn parse_maybe_bare(
611    diagnostics: &mut FilesetDiagnostics,
612    text: &str,
613    path_converter: &RepoPathUiConverter,
614) -> FilesetParseResult<FilesetExpression> {
615    let node = fileset_parser::parse_program_or_bare_string(text)?;
616    // TODO: add basic tree substitution pass to eliminate redundant expressions
617    resolve_expression(diagnostics, path_converter, &node)
618}
619
620#[cfg(test)]
621mod tests {
622    use std::path::PathBuf;
623
624    use super::*;
625
626    fn repo_path_buf(value: impl Into<String>) -> RepoPathBuf {
627        RepoPathBuf::from_internal_string(value).unwrap()
628    }
629
630    fn insta_settings() -> insta::Settings {
631        let mut settings = insta::Settings::clone_current();
632        // Elide parsed glob options and tokens, which aren't interesting.
633        settings.add_filter(
634            r"(?m)^(\s{12}opts):\s*GlobOptions\s*\{\n(\s{16}.*\n)*\s{12}\},",
635            "$1: _,",
636        );
637        settings.add_filter(
638            r"(?m)^(\s{12}tokens):\s*Tokens\(\n(\s{16}.*\n)*\s{12}\),",
639            "$1: _,",
640        );
641        // Collapse short "Thing(_,)" repeatedly to save vertical space and make
642        // the output more readable.
643        for _ in 0..4 {
644            settings.add_filter(
645                r"(?x)
646                \b([A-Z]\w*)\(\n
647                    \s*(.{1,60}),\n
648                \s*\)",
649                "$1($2)",
650            );
651        }
652        settings
653    }
654
655    #[test]
656    fn test_parse_file_pattern() {
657        let settings = insta_settings();
658        let _guard = settings.bind_to_scope();
659        let path_converter = RepoPathUiConverter::Fs {
660            cwd: PathBuf::from("/ws/cur"),
661            base: PathBuf::from("/ws"),
662        };
663        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
664
665        // cwd-relative patterns
666        insta::assert_debug_snapshot!(
667            parse(".").unwrap(),
668            @r#"Pattern(PrefixPath("cur"))"#);
669        insta::assert_debug_snapshot!(
670            parse("..").unwrap(),
671            @r#"Pattern(PrefixPath(""))"#);
672        assert!(parse("../..").is_err());
673        insta::assert_debug_snapshot!(
674            parse("foo").unwrap(),
675            @r#"Pattern(PrefixPath("cur/foo"))"#);
676        insta::assert_debug_snapshot!(
677            parse("*.*").unwrap(),
678            @r#"
679        Pattern(
680            PrefixGlob {
681                dir: "cur",
682                pattern: Glob {
683                    glob: "*.*",
684                    re: "(?-u)^[^/]*\\.[^/]*$",
685                    opts: _,
686                    tokens: _,
687                },
688            },
689        )
690        "#);
691        insta::assert_debug_snapshot!(
692            parse("cwd:.").unwrap(),
693            @r#"Pattern(PrefixPath("cur"))"#);
694        insta::assert_debug_snapshot!(
695            parse("cwd-file:foo").unwrap(),
696            @r#"Pattern(FilePath("cur/foo"))"#);
697        insta::assert_debug_snapshot!(
698            parse("file:../foo/bar").unwrap(),
699            @r#"Pattern(FilePath("foo/bar"))"#);
700
701        // workspace-relative patterns
702        insta::assert_debug_snapshot!(
703            parse("root:.").unwrap(),
704            @r#"Pattern(PrefixPath(""))"#);
705        assert!(parse("root:..").is_err());
706        insta::assert_debug_snapshot!(
707            parse("root:foo/bar").unwrap(),
708            @r#"Pattern(PrefixPath("foo/bar"))"#);
709        insta::assert_debug_snapshot!(
710            parse("root-file:bar").unwrap(),
711            @r#"Pattern(FilePath("bar"))"#);
712    }
713
714    #[test]
715    fn test_parse_glob_pattern() {
716        let settings = insta_settings();
717        let _guard = settings.bind_to_scope();
718        let path_converter = RepoPathUiConverter::Fs {
719            // meta character in cwd path shouldn't be expanded
720            cwd: PathBuf::from("/ws/cur*"),
721            base: PathBuf::from("/ws"),
722        };
723        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
724
725        // cwd-relative, without meta characters
726        insta::assert_debug_snapshot!(
727            parse(r#"cwd-glob:"foo""#).unwrap(),
728            @r#"Pattern(FilePath("cur*/foo"))"#);
729        // Strictly speaking, glob:"" shouldn't match a file named <cwd>, but
730        // file pattern doesn't distinguish "foo/" from "foo".
731        insta::assert_debug_snapshot!(
732            parse(r#"glob:"""#).unwrap(),
733            @r#"Pattern(FilePath("cur*"))"#);
734        insta::assert_debug_snapshot!(
735            parse(r#"glob:".""#).unwrap(),
736            @r#"Pattern(FilePath("cur*"))"#);
737        insta::assert_debug_snapshot!(
738            parse(r#"glob:"..""#).unwrap(),
739            @r#"Pattern(FilePath(""))"#);
740
741        // cwd-relative, with meta characters
742        insta::assert_debug_snapshot!(
743            parse(r#"glob:"*""#).unwrap(), @r#"
744        Pattern(
745            FileGlob {
746                dir: "cur*",
747                pattern: Glob {
748                    glob: "*",
749                    re: "(?-u)^[^/]*$",
750                    opts: _,
751                    tokens: _,
752                },
753            },
754        )
755        "#);
756        insta::assert_debug_snapshot!(
757            parse(r#"glob:"./*""#).unwrap(), @r#"
758        Pattern(
759            FileGlob {
760                dir: "cur*",
761                pattern: Glob {
762                    glob: "*",
763                    re: "(?-u)^[^/]*$",
764                    opts: _,
765                    tokens: _,
766                },
767            },
768        )
769        "#);
770        insta::assert_debug_snapshot!(
771            parse(r#"glob:"../*""#).unwrap(), @r#"
772        Pattern(
773            FileGlob {
774                dir: "",
775                pattern: Glob {
776                    glob: "*",
777                    re: "(?-u)^[^/]*$",
778                    opts: _,
779                    tokens: _,
780                },
781            },
782        )
783        "#);
784        // glob:"**" is equivalent to root-glob:"<cwd>/**", not root-glob:"**"
785        insta::assert_debug_snapshot!(
786            parse(r#"glob:"**""#).unwrap(), @r#"
787        Pattern(
788            FileGlob {
789                dir: "cur*",
790                pattern: Glob {
791                    glob: "**",
792                    re: "(?-u)^.*$",
793                    opts: _,
794                    tokens: _,
795                },
796            },
797        )
798        "#);
799        insta::assert_debug_snapshot!(
800            parse(r#"glob:"../foo/b?r/baz""#).unwrap(), @r#"
801        Pattern(
802            FileGlob {
803                dir: "foo",
804                pattern: Glob {
805                    glob: "b?r/baz",
806                    re: "(?-u)^b[^/]r/baz$",
807                    opts: _,
808                    tokens: _,
809                },
810            },
811        )
812        "#);
813        assert!(parse(r#"glob:"../../*""#).is_err());
814        assert!(parse(r#"glob-i:"../../*""#).is_err());
815        assert!(parse(r#"glob:"/*""#).is_err());
816        assert!(parse(r#"glob-i:"/*""#).is_err());
817        // no support for relative path component after glob meta character
818        assert!(parse(r#"glob:"*/..""#).is_err());
819        assert!(parse(r#"glob-i:"*/..""#).is_err());
820
821        if cfg!(windows) {
822            // cwd-relative, with Windows path separators
823            insta::assert_debug_snapshot!(
824                parse(r#"glob:"..\\foo\\*\\bar""#).unwrap(), @r#"
825            Pattern(
826                FileGlob {
827                    dir: "foo",
828                    pattern: Glob {
829                        glob: "*/bar",
830                        re: "(?-u)^[^/]*/bar$",
831                        opts: _,
832                        tokens: _,
833                    },
834                },
835            )
836            "#);
837        } else {
838            // backslash is an escape character on Unix
839            insta::assert_debug_snapshot!(
840                parse(r#"glob:"..\\foo\\*\\bar""#).unwrap(), @r#"
841            Pattern(
842                FileGlob {
843                    dir: "cur*",
844                    pattern: Glob {
845                        glob: "..\\foo\\*\\bar",
846                        re: "(?-u)^\\.\\.foo\\*bar$",
847                        opts: _,
848                        tokens: _,
849                    },
850                },
851            )
852            "#);
853        }
854
855        // workspace-relative, without meta characters
856        insta::assert_debug_snapshot!(
857            parse(r#"root-glob:"foo""#).unwrap(),
858            @r#"Pattern(FilePath("foo"))"#);
859        insta::assert_debug_snapshot!(
860            parse(r#"root-glob:"""#).unwrap(),
861            @r#"Pattern(FilePath(""))"#);
862        insta::assert_debug_snapshot!(
863            parse(r#"root-glob:".""#).unwrap(),
864            @r#"Pattern(FilePath(""))"#);
865
866        // workspace-relative, with meta characters
867        insta::assert_debug_snapshot!(
868            parse(r#"root-glob:"*""#).unwrap(), @r#"
869        Pattern(
870            FileGlob {
871                dir: "",
872                pattern: Glob {
873                    glob: "*",
874                    re: "(?-u)^[^/]*$",
875                    opts: _,
876                    tokens: _,
877                },
878            },
879        )
880        "#);
881        insta::assert_debug_snapshot!(
882            parse(r#"root-glob:"foo/bar/b[az]""#).unwrap(), @r#"
883        Pattern(
884            FileGlob {
885                dir: "foo/bar",
886                pattern: Glob {
887                    glob: "b[az]",
888                    re: "(?-u)^b[az]$",
889                    opts: _,
890                    tokens: _,
891                },
892            },
893        )
894        "#);
895        insta::assert_debug_snapshot!(
896            parse(r#"root-glob:"foo/bar/b{ar,az}""#).unwrap(), @r#"
897        Pattern(
898            FileGlob {
899                dir: "foo/bar",
900                pattern: Glob {
901                    glob: "b{ar,az}",
902                    re: "(?-u)^b(?:ar|az)$",
903                    opts: _,
904                    tokens: _,
905                },
906            },
907        )
908        "#);
909        assert!(parse(r#"root-glob:"../*""#).is_err());
910        assert!(parse(r#"root-glob-i:"../*""#).is_err());
911        assert!(parse(r#"root-glob:"/*""#).is_err());
912        assert!(parse(r#"root-glob-i:"/*""#).is_err());
913
914        // workspace-relative, backslash escape without meta characters
915        if cfg!(not(windows)) {
916            insta::assert_debug_snapshot!(
917                parse(r#"root-glob:'foo/bar\baz'"#).unwrap(), @r#"
918            Pattern(
919                FileGlob {
920                    dir: "foo",
921                    pattern: Glob {
922                        glob: "bar\\baz",
923                        re: "(?-u)^barbaz$",
924                        opts: _,
925                        tokens: _,
926                    },
927                },
928            )
929            "#);
930        }
931    }
932
933    #[test]
934    fn test_parse_glob_pattern_case_insensitive() {
935        let settings = insta_settings();
936        let _guard = settings.bind_to_scope();
937        let path_converter = RepoPathUiConverter::Fs {
938            cwd: PathBuf::from("/ws/cur"),
939            base: PathBuf::from("/ws"),
940        };
941        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
942
943        // cwd-relative case-insensitive glob
944        insta::assert_debug_snapshot!(
945            parse(r#"glob-i:"*.TXT""#).unwrap(), @r#"
946        Pattern(
947            FileGlob {
948                dir: "cur",
949                pattern: Glob {
950                    glob: "*.TXT",
951                    re: "(?-u)(?i)^[^/]*\\.TXT$",
952                    opts: _,
953                    tokens: _,
954                },
955            },
956        )
957        "#);
958
959        // cwd-relative case-insensitive glob with more specific pattern
960        insta::assert_debug_snapshot!(
961            parse(r#"cwd-glob-i:"[Ff]oo""#).unwrap(), @r#"
962        Pattern(
963            FileGlob {
964                dir: "cur",
965                pattern: Glob {
966                    glob: "[Ff]oo",
967                    re: "(?-u)(?i)^[Ff]oo$",
968                    opts: _,
969                    tokens: _,
970                },
971            },
972        )
973        "#);
974
975        // workspace-relative case-insensitive glob
976        insta::assert_debug_snapshot!(
977            parse(r#"root-glob-i:"*.Rs""#).unwrap(), @r#"
978        Pattern(
979            FileGlob {
980                dir: "",
981                pattern: Glob {
982                    glob: "*.Rs",
983                    re: "(?-u)(?i)^[^/]*\\.Rs$",
984                    opts: _,
985                    tokens: _,
986                },
987            },
988        )
989        "#);
990
991        // case-insensitive pattern with directory component (should not split the path)
992        insta::assert_debug_snapshot!(
993            parse(r#"glob-i:"SubDir/*.rs""#).unwrap(), @r#"
994        Pattern(
995            FileGlob {
996                dir: "cur",
997                pattern: Glob {
998                    glob: "SubDir/*.rs",
999                    re: "(?-u)(?i)^SubDir/[^/]*\\.rs$",
1000                    opts: _,
1001                    tokens: _,
1002                },
1003            },
1004        )
1005        "#);
1006
1007        // case-sensitive pattern with directory component (should split the path)
1008        insta::assert_debug_snapshot!(
1009            parse(r#"glob:"SubDir/*.rs""#).unwrap(), @r#"
1010        Pattern(
1011            FileGlob {
1012                dir: "cur/SubDir",
1013                pattern: Glob {
1014                    glob: "*.rs",
1015                    re: "(?-u)^[^/]*\\.rs$",
1016                    opts: _,
1017                    tokens: _,
1018                },
1019            },
1020        )
1021        "#);
1022
1023        // case-insensitive pattern with leading dots (should split dots but not dirs)
1024        insta::assert_debug_snapshot!(
1025            parse(r#"glob-i:"../SomeDir/*.rs""#).unwrap(), @r#"
1026        Pattern(
1027            FileGlob {
1028                dir: "",
1029                pattern: Glob {
1030                    glob: "SomeDir/*.rs",
1031                    re: "(?-u)(?i)^SomeDir/[^/]*\\.rs$",
1032                    opts: _,
1033                    tokens: _,
1034                },
1035            },
1036        )
1037        "#);
1038
1039        // case-insensitive pattern with single leading dot
1040        insta::assert_debug_snapshot!(
1041            parse(r#"glob-i:"./SomeFile*.txt""#).unwrap(), @r#"
1042        Pattern(
1043            FileGlob {
1044                dir: "cur",
1045                pattern: Glob {
1046                    glob: "SomeFile*.txt",
1047                    re: "(?-u)(?i)^SomeFile[^/]*\\.txt$",
1048                    opts: _,
1049                    tokens: _,
1050                },
1051            },
1052        )
1053        "#);
1054    }
1055
1056    #[test]
1057    fn test_parse_prefix_glob_pattern() {
1058        let settings = insta_settings();
1059        let _guard = settings.bind_to_scope();
1060        let path_converter = RepoPathUiConverter::Fs {
1061            // meta character in cwd path shouldn't be expanded
1062            cwd: PathBuf::from("/ws/cur*"),
1063            base: PathBuf::from("/ws"),
1064        };
1065        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
1066
1067        // cwd-relative, without meta/case-insensitive characters
1068        insta::assert_debug_snapshot!(
1069            parse("cwd-prefix-glob:'foo'").unwrap(),
1070            @r#"Pattern(PrefixPath("cur*/foo"))"#);
1071        insta::assert_debug_snapshot!(
1072            parse("prefix-glob:'.'").unwrap(),
1073            @r#"Pattern(PrefixPath("cur*"))"#);
1074        insta::assert_debug_snapshot!(
1075            parse("cwd-prefix-glob-i:'..'").unwrap(),
1076            @r#"Pattern(PrefixPath(""))"#);
1077        insta::assert_debug_snapshot!(
1078            parse("prefix-glob-i:'../_'").unwrap(),
1079            @r#"Pattern(PrefixPath("_"))"#);
1080
1081        // cwd-relative, with meta characters
1082        insta::assert_debug_snapshot!(
1083            parse("cwd-prefix-glob:'*'").unwrap(), @r#"
1084        Pattern(
1085            PrefixGlob {
1086                dir: "cur*",
1087                pattern: Glob {
1088                    glob: "*",
1089                    re: "(?-u)^[^/]*$",
1090                    opts: _,
1091                    tokens: _,
1092                },
1093            },
1094        )
1095        "#);
1096
1097        // cwd-relative, with case-insensitive characters
1098        insta::assert_debug_snapshot!(
1099            parse("cwd-prefix-glob-i:'../foo'").unwrap(), @r#"
1100        Pattern(
1101            PrefixGlob {
1102                dir: "",
1103                pattern: Glob {
1104                    glob: "foo",
1105                    re: "(?-u)(?i)^foo$",
1106                    opts: _,
1107                    tokens: _,
1108                },
1109            },
1110        )
1111        "#);
1112
1113        // workspace-relative, without meta/case-insensitive characters
1114        insta::assert_debug_snapshot!(
1115            parse("root-prefix-glob:'foo'").unwrap(),
1116            @r#"Pattern(PrefixPath("foo"))"#);
1117        insta::assert_debug_snapshot!(
1118            parse("root-prefix-glob-i:'.'").unwrap(),
1119            @r#"Pattern(PrefixPath(""))"#);
1120
1121        // workspace-relative, with meta characters
1122        insta::assert_debug_snapshot!(
1123            parse("root-prefix-glob:'*'").unwrap(), @r#"
1124        Pattern(
1125            PrefixGlob {
1126                dir: "",
1127                pattern: Glob {
1128                    glob: "*",
1129                    re: "(?-u)^[^/]*$",
1130                    opts: _,
1131                    tokens: _,
1132                },
1133            },
1134        )
1135        "#);
1136
1137        // workspace-relative, with case-insensitive characters
1138        insta::assert_debug_snapshot!(
1139            parse("root-prefix-glob-i:'_/foo'").unwrap(), @r#"
1140        Pattern(
1141            PrefixGlob {
1142                dir: "_",
1143                pattern: Glob {
1144                    glob: "foo",
1145                    re: "(?-u)(?i)^foo$",
1146                    opts: _,
1147                    tokens: _,
1148                },
1149            },
1150        )
1151        "#);
1152    }
1153
1154    #[test]
1155    fn test_parse_function() {
1156        let settings = insta_settings();
1157        let _guard = settings.bind_to_scope();
1158        let path_converter = RepoPathUiConverter::Fs {
1159            cwd: PathBuf::from("/ws/cur"),
1160            base: PathBuf::from("/ws"),
1161        };
1162        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
1163
1164        insta::assert_debug_snapshot!(parse("all()").unwrap(), @"All");
1165        insta::assert_debug_snapshot!(parse("none()").unwrap(), @"None");
1166        insta::assert_debug_snapshot!(parse("all(x)").unwrap_err().kind(), @r#"
1167        InvalidArguments {
1168            name: "all",
1169            message: "Expected 0 arguments",
1170        }
1171        "#);
1172        insta::assert_debug_snapshot!(parse("ale()").unwrap_err().kind(), @r#"
1173        NoSuchFunction {
1174            name: "ale",
1175            candidates: [
1176                "all",
1177            ],
1178        }
1179        "#);
1180    }
1181
1182    #[test]
1183    fn test_parse_compound_expression() {
1184        let settings = insta_settings();
1185        let _guard = settings.bind_to_scope();
1186        let path_converter = RepoPathUiConverter::Fs {
1187            cwd: PathBuf::from("/ws/cur"),
1188            base: PathBuf::from("/ws"),
1189        };
1190        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
1191
1192        insta::assert_debug_snapshot!(parse("~x").unwrap(), @r#"
1193        Difference(
1194            All,
1195            Pattern(PrefixPath("cur/x")),
1196        )
1197        "#);
1198        insta::assert_debug_snapshot!(parse("x|y|root:z").unwrap(), @r#"
1199        UnionAll(
1200            [
1201                Pattern(PrefixPath("cur/x")),
1202                Pattern(PrefixPath("cur/y")),
1203                Pattern(PrefixPath("z")),
1204            ],
1205        )
1206        "#);
1207        insta::assert_debug_snapshot!(parse("x|y&z").unwrap(), @r#"
1208        UnionAll(
1209            [
1210                Pattern(PrefixPath("cur/x")),
1211                Intersection(
1212                    Pattern(PrefixPath("cur/y")),
1213                    Pattern(PrefixPath("cur/z")),
1214                ),
1215            ],
1216        )
1217        "#);
1218    }
1219
1220    #[test]
1221    fn test_explicit_paths() {
1222        let collect = |expr: &FilesetExpression| -> Vec<RepoPathBuf> {
1223            expr.explicit_paths().map(|path| path.to_owned()).collect()
1224        };
1225        let file_expr = |path: &str| FilesetExpression::file_path(repo_path_buf(path));
1226        assert!(collect(&FilesetExpression::none()).is_empty());
1227        assert_eq!(collect(&file_expr("a")), ["a"].map(repo_path_buf));
1228        assert_eq!(
1229            collect(&FilesetExpression::union_all(vec![
1230                file_expr("a"),
1231                file_expr("b"),
1232                file_expr("c"),
1233            ])),
1234            ["a", "b", "c"].map(repo_path_buf)
1235        );
1236        assert_eq!(
1237            collect(&FilesetExpression::intersection(
1238                FilesetExpression::union_all(vec![
1239                    file_expr("a"),
1240                    FilesetExpression::none(),
1241                    file_expr("b"),
1242                    file_expr("c"),
1243                ]),
1244                FilesetExpression::difference(
1245                    file_expr("d"),
1246                    FilesetExpression::union_all(vec![file_expr("e"), file_expr("f")])
1247                )
1248            )),
1249            ["a", "b", "c", "d", "e", "f"].map(repo_path_buf)
1250        );
1251    }
1252
1253    #[test]
1254    fn test_build_matcher_simple() {
1255        let settings = insta_settings();
1256        let _guard = settings.bind_to_scope();
1257
1258        insta::assert_debug_snapshot!(FilesetExpression::none().to_matcher(), @"NothingMatcher");
1259        insta::assert_debug_snapshot!(FilesetExpression::all().to_matcher(), @"EverythingMatcher");
1260        insta::assert_debug_snapshot!(
1261            FilesetExpression::file_path(repo_path_buf("foo")).to_matcher(),
1262            @r#"
1263        FilesMatcher {
1264            tree: Dir {
1265                "foo": File {},
1266            },
1267        }
1268        "#);
1269        insta::assert_debug_snapshot!(
1270            FilesetExpression::prefix_path(repo_path_buf("foo")).to_matcher(),
1271            @r#"
1272        PrefixMatcher {
1273            tree: Dir {
1274                "foo": Prefix {},
1275            },
1276        }
1277        "#);
1278    }
1279
1280    #[test]
1281    fn test_build_matcher_glob_pattern() {
1282        let settings = insta_settings();
1283        let _guard = settings.bind_to_scope();
1284        let file_glob_expr = |dir: &str, pattern: &str| {
1285            FilesetExpression::pattern(FilePattern::FileGlob {
1286                dir: repo_path_buf(dir),
1287                pattern: Box::new(parse_file_glob(pattern, false).unwrap()),
1288            })
1289        };
1290        let prefix_glob_expr = |dir: &str, pattern: &str| {
1291            FilesetExpression::pattern(FilePattern::PrefixGlob {
1292                dir: repo_path_buf(dir),
1293                pattern: Box::new(parse_file_glob(pattern, false).unwrap()),
1294            })
1295        };
1296
1297        insta::assert_debug_snapshot!(file_glob_expr("", "*").to_matcher(), @r#"
1298        GlobsMatcher {
1299            tree: Some(RegexSet(["(?-u)^[^/]*$"])) {},
1300            matches_prefix_paths: false,
1301        }
1302        "#);
1303
1304        let expr = FilesetExpression::union_all(vec![
1305            file_glob_expr("foo", "*"),
1306            file_glob_expr("foo/bar", "*"),
1307            file_glob_expr("foo", "?"),
1308            prefix_glob_expr("foo", "ba[rz]"),
1309            prefix_glob_expr("foo", "qu*x"),
1310        ]);
1311        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1312        UnionMatcher {
1313            input1: GlobsMatcher {
1314                tree: None {
1315                    "foo": Some(RegexSet(["(?-u)^[^/]*$", "(?-u)^[^/]$"])) {
1316                        "bar": Some(RegexSet(["(?-u)^[^/]*$"])) {},
1317                    },
1318                },
1319                matches_prefix_paths: false,
1320            },
1321            input2: GlobsMatcher {
1322                tree: None {
1323                    "foo": Some(RegexSet(["(?-u)^ba[rz](?:/|$)", "(?-u)^qu[^/]*x(?:/|$)"])) {},
1324                },
1325                matches_prefix_paths: true,
1326            },
1327        }
1328        "#);
1329    }
1330
1331    #[test]
1332    fn test_build_matcher_union_patterns_of_same_kind() {
1333        let settings = insta_settings();
1334        let _guard = settings.bind_to_scope();
1335
1336        let expr = FilesetExpression::union_all(vec![
1337            FilesetExpression::file_path(repo_path_buf("foo")),
1338            FilesetExpression::file_path(repo_path_buf("foo/bar")),
1339        ]);
1340        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1341        FilesMatcher {
1342            tree: Dir {
1343                "foo": File {
1344                    "bar": File {},
1345                },
1346            },
1347        }
1348        "#);
1349
1350        let expr = FilesetExpression::union_all(vec![
1351            FilesetExpression::prefix_path(repo_path_buf("bar")),
1352            FilesetExpression::prefix_path(repo_path_buf("bar/baz")),
1353        ]);
1354        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1355        PrefixMatcher {
1356            tree: Dir {
1357                "bar": Prefix {
1358                    "baz": Prefix {},
1359                },
1360            },
1361        }
1362        "#);
1363    }
1364
1365    #[test]
1366    fn test_build_matcher_union_patterns_of_different_kind() {
1367        let settings = insta_settings();
1368        let _guard = settings.bind_to_scope();
1369
1370        let expr = FilesetExpression::union_all(vec![
1371            FilesetExpression::file_path(repo_path_buf("foo")),
1372            FilesetExpression::prefix_path(repo_path_buf("bar")),
1373        ]);
1374        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1375        UnionMatcher {
1376            input1: FilesMatcher {
1377                tree: Dir {
1378                    "foo": File {},
1379                },
1380            },
1381            input2: PrefixMatcher {
1382                tree: Dir {
1383                    "bar": Prefix {},
1384                },
1385            },
1386        }
1387        "#);
1388    }
1389
1390    #[test]
1391    fn test_build_matcher_unnormalized_union() {
1392        let settings = insta_settings();
1393        let _guard = settings.bind_to_scope();
1394
1395        let expr = FilesetExpression::UnionAll(vec![]);
1396        insta::assert_debug_snapshot!(expr.to_matcher(), @"NothingMatcher");
1397
1398        let expr =
1399            FilesetExpression::UnionAll(vec![FilesetExpression::None, FilesetExpression::All]);
1400        insta::assert_debug_snapshot!(expr.to_matcher(), @r"
1401        UnionMatcher {
1402            input1: NothingMatcher,
1403            input2: EverythingMatcher,
1404        }
1405        ");
1406    }
1407
1408    #[test]
1409    fn test_build_matcher_combined() {
1410        let settings = insta_settings();
1411        let _guard = settings.bind_to_scope();
1412
1413        let expr = FilesetExpression::union_all(vec![
1414            FilesetExpression::intersection(FilesetExpression::all(), FilesetExpression::none()),
1415            FilesetExpression::difference(FilesetExpression::none(), FilesetExpression::all()),
1416            FilesetExpression::file_path(repo_path_buf("foo")),
1417            FilesetExpression::prefix_path(repo_path_buf("bar")),
1418        ]);
1419        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1420        UnionMatcher {
1421            input1: UnionMatcher {
1422                input1: IntersectionMatcher {
1423                    input1: EverythingMatcher,
1424                    input2: NothingMatcher,
1425                },
1426                input2: DifferenceMatcher {
1427                    wanted: NothingMatcher,
1428                    unwanted: EverythingMatcher,
1429                },
1430            },
1431            input2: UnionMatcher {
1432                input1: FilesMatcher {
1433                    tree: Dir {
1434                        "foo": File {},
1435                    },
1436                },
1437                input2: PrefixMatcher {
1438                    tree: Dir {
1439                        "bar": Prefix {},
1440                    },
1441                },
1442            },
1443        }
1444        "#);
1445    }
1446}