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