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