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;
21
22use itertools::Itertools as _;
23use once_cell::sync::Lazy;
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::FilesetDiagnostics;
32pub use crate::fileset_parser::FilesetParseError;
33pub use crate::fileset_parser::FilesetParseErrorKind;
34pub use crate::fileset_parser::FilesetParseResult;
35use crate::fileset_parser::FunctionCallNode;
36use crate::fileset_parser::UnaryOp;
37use crate::matchers::DifferenceMatcher;
38use crate::matchers::EverythingMatcher;
39use crate::matchers::FileGlobsMatcher;
40use crate::matchers::FilesMatcher;
41use crate::matchers::IntersectionMatcher;
42use crate::matchers::Matcher;
43use crate::matchers::NothingMatcher;
44use crate::matchers::PrefixMatcher;
45use crate::matchers::UnionMatcher;
46use crate::repo_path::RelativePathParseError;
47use crate::repo_path::RepoPath;
48use crate::repo_path::RepoPathBuf;
49use crate::repo_path::RepoPathUiConverter;
50use crate::repo_path::UiPathParseError;
51
52/// Error occurred during file pattern parsing.
53#[derive(Debug, Error)]
54pub enum FilePatternParseError {
55    /// Unknown pattern kind is specified.
56    #[error("Invalid file pattern kind `{0}:`")]
57    InvalidKind(String),
58    /// Failed to parse input UI path.
59    #[error(transparent)]
60    UiPath(#[from] UiPathParseError),
61    /// Failed to parse input workspace-relative path.
62    #[error(transparent)]
63    RelativePath(#[from] RelativePathParseError),
64    /// Failed to parse glob pattern.
65    #[error(transparent)]
66    GlobPattern(#[from] glob::PatternError),
67}
68
69/// Basic pattern to match `RepoPath`.
70#[derive(Clone, Debug)]
71pub enum FilePattern {
72    /// Matches file (or exact) path.
73    FilePath(RepoPathBuf),
74    /// Matches path prefix.
75    PrefixPath(RepoPathBuf),
76    /// Matches file (or exact) path with glob pattern.
77    FileGlob {
78        /// Prefix directory path where the `pattern` will be evaluated.
79        dir: RepoPathBuf,
80        /// Glob pattern relative to `dir`.
81        pattern: glob::Pattern,
82    },
83    // TODO: add more patterns:
84    // - FilesInPath: files in directory, non-recursively?
85    // - NameGlob or SuffixGlob: file name with glob?
86}
87
88impl FilePattern {
89    /// Parses the given `input` string as pattern of the specified `kind`.
90    pub fn from_str_kind(
91        path_converter: &RepoPathUiConverter,
92        input: &str,
93        kind: &str,
94    ) -> Result<Self, FilePatternParseError> {
95        // Naming convention:
96        // * path normalization
97        //   * cwd: cwd-relative path (default)
98        //   * root: workspace-relative path
99        // * where to anchor
100        //   * file: exact file path
101        //   * prefix: path prefix (files under directory recursively)
102        //   * files-in: files in directory non-recursively
103        //   * name: file name component (or suffix match?)
104        //   * substring: substring match?
105        // * string pattern syntax (+ case sensitivity?)
106        //   * path: literal path (default) (default anchor: prefix)
107        //   * glob: glob pattern (default anchor: file)
108        //   * regex?
109        match kind {
110            "cwd" => Self::cwd_prefix_path(path_converter, input),
111            "cwd-file" | "file" => Self::cwd_file_path(path_converter, input),
112            "cwd-glob" | "glob" => Self::cwd_file_glob(path_converter, input),
113            "root" => Self::root_prefix_path(input),
114            "root-file" => Self::root_file_path(input),
115            "root-glob" => Self::root_file_glob(input),
116            _ => Err(FilePatternParseError::InvalidKind(kind.to_owned())),
117        }
118    }
119
120    /// Pattern that matches cwd-relative file (or exact) path.
121    pub fn cwd_file_path(
122        path_converter: &RepoPathUiConverter,
123        input: impl AsRef<str>,
124    ) -> Result<Self, FilePatternParseError> {
125        let path = path_converter.parse_file_path(input.as_ref())?;
126        Ok(FilePattern::FilePath(path))
127    }
128
129    /// Pattern that matches cwd-relative path prefix.
130    pub fn cwd_prefix_path(
131        path_converter: &RepoPathUiConverter,
132        input: impl AsRef<str>,
133    ) -> Result<Self, FilePatternParseError> {
134        let path = path_converter.parse_file_path(input.as_ref())?;
135        Ok(FilePattern::PrefixPath(path))
136    }
137
138    /// Pattern that matches cwd-relative file path glob.
139    pub fn cwd_file_glob(
140        path_converter: &RepoPathUiConverter,
141        input: impl AsRef<str>,
142    ) -> Result<Self, FilePatternParseError> {
143        let (dir, pattern) = split_glob_path(input.as_ref());
144        let dir = path_converter.parse_file_path(dir)?;
145        Self::file_glob_at(dir, pattern)
146    }
147
148    /// Pattern that matches workspace-relative file (or exact) path.
149    pub fn root_file_path(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
150        // TODO: Let caller pass in converter for root-relative paths too
151        let path = RepoPathBuf::from_relative_path(input.as_ref())?;
152        Ok(FilePattern::FilePath(path))
153    }
154
155    /// Pattern that matches workspace-relative path prefix.
156    pub fn root_prefix_path(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
157        let path = RepoPathBuf::from_relative_path(input.as_ref())?;
158        Ok(FilePattern::PrefixPath(path))
159    }
160
161    /// Pattern that matches workspace-relative file path glob.
162    pub fn root_file_glob(input: impl AsRef<str>) -> Result<Self, FilePatternParseError> {
163        let (dir, pattern) = split_glob_path(input.as_ref());
164        let dir = RepoPathBuf::from_relative_path(dir)?;
165        Self::file_glob_at(dir, pattern)
166    }
167
168    fn file_glob_at(dir: RepoPathBuf, input: &str) -> Result<Self, FilePatternParseError> {
169        if input.is_empty() {
170            return Ok(FilePattern::FilePath(dir));
171        }
172        // Normalize separator to '/', reject ".." which will never match
173        let normalized = RepoPathBuf::from_relative_path(input)?;
174        let pattern = glob::Pattern::new(normalized.as_internal_file_string())?;
175        Ok(FilePattern::FileGlob { dir, pattern })
176    }
177
178    /// Returns path if this pattern represents a literal path in a workspace.
179    /// Returns `None` if this is a glob pattern for example.
180    pub fn as_path(&self) -> Option<&RepoPath> {
181        match self {
182            FilePattern::FilePath(path) => Some(path),
183            FilePattern::PrefixPath(path) => Some(path),
184            FilePattern::FileGlob { .. } => None,
185        }
186    }
187}
188
189/// Splits `input` path into literal directory path and glob pattern.
190fn split_glob_path(input: &str) -> (&str, &str) {
191    const GLOB_CHARS: &[char] = &['?', '*', '[', ']']; // see glob::Pattern::escape()
192    let prefix_len = input
193        .split_inclusive(path::is_separator)
194        .take_while(|component| !component.contains(GLOB_CHARS))
195        .map(|component| component.len())
196        .sum();
197    input.split_at(prefix_len)
198}
199
200/// AST-level representation of the fileset expression.
201#[derive(Clone, Debug)]
202pub enum FilesetExpression {
203    /// Matches nothing.
204    None,
205    /// Matches everything.
206    All,
207    /// Matches basic pattern.
208    Pattern(FilePattern),
209    /// Matches any of the expressions.
210    ///
211    /// Use `FilesetExpression::union_all()` to construct a union expression.
212    /// It will normalize 0-ary or 1-ary union.
213    UnionAll(Vec<FilesetExpression>),
214    /// Matches both expressions.
215    Intersection(Box<FilesetExpression>, Box<FilesetExpression>),
216    /// Matches the first expression, but not the second expression.
217    Difference(Box<FilesetExpression>, Box<FilesetExpression>),
218}
219
220impl FilesetExpression {
221    /// Expression that matches nothing.
222    pub fn none() -> Self {
223        FilesetExpression::None
224    }
225
226    /// Expression that matches everything.
227    pub fn all() -> Self {
228        FilesetExpression::All
229    }
230
231    /// Expression that matches the given `pattern`.
232    pub fn pattern(pattern: FilePattern) -> Self {
233        FilesetExpression::Pattern(pattern)
234    }
235
236    /// Expression that matches file (or exact) path.
237    pub fn file_path(path: RepoPathBuf) -> Self {
238        FilesetExpression::Pattern(FilePattern::FilePath(path))
239    }
240
241    /// Expression that matches path prefix.
242    pub fn prefix_path(path: RepoPathBuf) -> Self {
243        FilesetExpression::Pattern(FilePattern::PrefixPath(path))
244    }
245
246    /// Expression that matches any of the given `expressions`.
247    pub fn union_all(expressions: Vec<FilesetExpression>) -> Self {
248        match expressions.len() {
249            0 => FilesetExpression::none(),
250            1 => expressions.into_iter().next().unwrap(),
251            _ => FilesetExpression::UnionAll(expressions),
252        }
253    }
254
255    /// Expression that matches both `self` and `other`.
256    pub fn intersection(self, other: Self) -> Self {
257        FilesetExpression::Intersection(Box::new(self), Box::new(other))
258    }
259
260    /// Expression that matches `self` but not `other`.
261    pub fn difference(self, other: Self) -> Self {
262        FilesetExpression::Difference(Box::new(self), Box::new(other))
263    }
264
265    /// Flattens union expression at most one level.
266    fn as_union_all(&self) -> &[Self] {
267        match self {
268            FilesetExpression::None => &[],
269            FilesetExpression::UnionAll(exprs) => exprs,
270            _ => slice::from_ref(self),
271        }
272    }
273
274    fn dfs_pre(&self) -> impl Iterator<Item = &Self> {
275        let mut stack: Vec<&Self> = vec![self];
276        iter::from_fn(move || {
277            let expr = stack.pop()?;
278            match expr {
279                FilesetExpression::None
280                | FilesetExpression::All
281                | FilesetExpression::Pattern(_) => {}
282                FilesetExpression::UnionAll(exprs) => stack.extend(exprs.iter().rev()),
283                FilesetExpression::Intersection(expr1, expr2)
284                | FilesetExpression::Difference(expr1, expr2) => {
285                    stack.push(expr2);
286                    stack.push(expr1);
287                }
288            }
289            Some(expr)
290        })
291    }
292
293    /// Iterates literal paths recursively from this expression.
294    ///
295    /// For example, `"a", "b", "c"` will be yielded in that order for
296    /// expression `"a" | all() & "b" | ~"c"`.
297    pub fn explicit_paths(&self) -> impl Iterator<Item = &RepoPath> {
298        // pre/post-ordering doesn't matter so long as children are visited from
299        // left to right.
300        self.dfs_pre().filter_map(|expr| match expr {
301            FilesetExpression::Pattern(pattern) => pattern.as_path(),
302            _ => None,
303        })
304    }
305
306    /// Transforms the expression tree to `Matcher` object.
307    pub fn to_matcher(&self) -> Box<dyn Matcher> {
308        build_union_matcher(self.as_union_all())
309    }
310}
311
312/// Transforms the union `expressions` to `Matcher` object.
313///
314/// Since `Matcher` typically accepts a set of patterns to be OR-ed, this
315/// function takes a list of union `expressions` as input.
316fn build_union_matcher(expressions: &[FilesetExpression]) -> Box<dyn Matcher> {
317    let mut file_paths = Vec::new();
318    let mut prefix_paths = Vec::new();
319    let mut file_globs = Vec::new();
320    let mut matchers: Vec<Option<Box<dyn Matcher>>> = Vec::new();
321    for expr in expressions {
322        let matcher: Box<dyn Matcher> = match expr {
323            // None and All are supposed to be simplified by caller.
324            FilesetExpression::None => Box::new(NothingMatcher),
325            FilesetExpression::All => Box::new(EverythingMatcher),
326            FilesetExpression::Pattern(pattern) => {
327                match pattern {
328                    FilePattern::FilePath(path) => file_paths.push(path),
329                    FilePattern::PrefixPath(path) => prefix_paths.push(path),
330                    FilePattern::FileGlob { dir, pattern } => {
331                        file_globs.push((dir, pattern.clone()));
332                    }
333                }
334                continue;
335            }
336            // UnionAll is supposed to be flattened by caller.
337            FilesetExpression::UnionAll(exprs) => build_union_matcher(exprs),
338            FilesetExpression::Intersection(expr1, expr2) => {
339                let m1 = build_union_matcher(expr1.as_union_all());
340                let m2 = build_union_matcher(expr2.as_union_all());
341                Box::new(IntersectionMatcher::new(m1, m2))
342            }
343            FilesetExpression::Difference(expr1, expr2) => {
344                let m1 = build_union_matcher(expr1.as_union_all());
345                let m2 = build_union_matcher(expr2.as_union_all());
346                Box::new(DifferenceMatcher::new(m1, m2))
347            }
348        };
349        matchers.push(Some(matcher));
350    }
351
352    if !file_paths.is_empty() {
353        matchers.push(Some(Box::new(FilesMatcher::new(file_paths))));
354    }
355    if !prefix_paths.is_empty() {
356        matchers.push(Some(Box::new(PrefixMatcher::new(prefix_paths))));
357    }
358    if !file_globs.is_empty() {
359        matchers.push(Some(Box::new(FileGlobsMatcher::new(file_globs))));
360    }
361    union_all_matchers(&mut matchers)
362}
363
364/// Concatenates all `matchers` as union.
365///
366/// Each matcher element must be wrapped in `Some` so the matchers can be moved
367/// in arbitrary order.
368fn union_all_matchers(matchers: &mut [Option<Box<dyn Matcher>>]) -> Box<dyn Matcher> {
369    match matchers {
370        [] => Box::new(NothingMatcher),
371        [matcher] => matcher.take().expect("matcher should still be available"),
372        _ => {
373            // Build balanced tree to minimize the recursion depth.
374            let (left, right) = matchers.split_at_mut(matchers.len() / 2);
375            let m1 = union_all_matchers(left);
376            let m2 = union_all_matchers(right);
377            Box::new(UnionMatcher::new(m1, m2))
378        }
379    }
380}
381
382type FilesetFunction = fn(
383    &mut FilesetDiagnostics,
384    &RepoPathUiConverter,
385    &FunctionCallNode,
386) -> FilesetParseResult<FilesetExpression>;
387
388static BUILTIN_FUNCTION_MAP: Lazy<HashMap<&'static str, FilesetFunction>> = Lazy::new(|| {
389    // Not using maplit::hashmap!{} or custom declarative macro here because
390    // code completion inside macro is quite restricted.
391    let mut map: HashMap<&'static str, FilesetFunction> = HashMap::new();
392    map.insert("none", |_diagnostics, _path_converter, function| {
393        function.expect_no_arguments()?;
394        Ok(FilesetExpression::none())
395    });
396    map.insert("all", |_diagnostics, _path_converter, function| {
397        function.expect_no_arguments()?;
398        Ok(FilesetExpression::all())
399    });
400    map
401});
402
403fn resolve_function(
404    diagnostics: &mut FilesetDiagnostics,
405    path_converter: &RepoPathUiConverter,
406    function: &FunctionCallNode,
407) -> FilesetParseResult<FilesetExpression> {
408    if let Some(func) = BUILTIN_FUNCTION_MAP.get(function.name) {
409        func(diagnostics, path_converter, function)
410    } else {
411        Err(FilesetParseError::new(
412            FilesetParseErrorKind::NoSuchFunction {
413                name: function.name.to_owned(),
414                candidates: collect_similar(function.name, BUILTIN_FUNCTION_MAP.keys()),
415            },
416            function.name_span,
417        ))
418    }
419}
420
421fn resolve_expression(
422    diagnostics: &mut FilesetDiagnostics,
423    path_converter: &RepoPathUiConverter,
424    node: &ExpressionNode,
425) -> FilesetParseResult<FilesetExpression> {
426    let wrap_pattern_error =
427        |err| FilesetParseError::expression("Invalid file pattern", node.span).with_source(err);
428    match &node.kind {
429        ExpressionKind::Identifier(name) => {
430            let pattern =
431                FilePattern::cwd_prefix_path(path_converter, name).map_err(wrap_pattern_error)?;
432            Ok(FilesetExpression::pattern(pattern))
433        }
434        ExpressionKind::String(name) => {
435            let pattern =
436                FilePattern::cwd_prefix_path(path_converter, name).map_err(wrap_pattern_error)?;
437            Ok(FilesetExpression::pattern(pattern))
438        }
439        ExpressionKind::StringPattern { kind, value } => {
440            let pattern = FilePattern::from_str_kind(path_converter, value, kind)
441                .map_err(wrap_pattern_error)?;
442            Ok(FilesetExpression::pattern(pattern))
443        }
444        ExpressionKind::Unary(op, arg_node) => {
445            let arg = resolve_expression(diagnostics, path_converter, arg_node)?;
446            match op {
447                UnaryOp::Negate => Ok(FilesetExpression::all().difference(arg)),
448            }
449        }
450        ExpressionKind::Binary(op, lhs_node, rhs_node) => {
451            let lhs = resolve_expression(diagnostics, path_converter, lhs_node)?;
452            let rhs = resolve_expression(diagnostics, path_converter, rhs_node)?;
453            match op {
454                BinaryOp::Intersection => Ok(lhs.intersection(rhs)),
455                BinaryOp::Difference => Ok(lhs.difference(rhs)),
456            }
457        }
458        ExpressionKind::UnionAll(nodes) => {
459            let expressions = nodes
460                .iter()
461                .map(|node| resolve_expression(diagnostics, path_converter, node))
462                .try_collect()?;
463            Ok(FilesetExpression::union_all(expressions))
464        }
465        ExpressionKind::FunctionCall(function) => {
466            resolve_function(diagnostics, path_converter, function)
467        }
468    }
469}
470
471/// Parses text into `FilesetExpression` without bare string fallback.
472pub fn parse(
473    diagnostics: &mut FilesetDiagnostics,
474    text: &str,
475    path_converter: &RepoPathUiConverter,
476) -> FilesetParseResult<FilesetExpression> {
477    let node = fileset_parser::parse_program(text)?;
478    // TODO: add basic tree substitution pass to eliminate redundant expressions
479    resolve_expression(diagnostics, path_converter, &node)
480}
481
482/// Parses text into `FilesetExpression` with bare string fallback.
483///
484/// If the text can't be parsed as a fileset expression, and if it doesn't
485/// contain any operator-like characters, it will be parsed as a file path.
486pub fn parse_maybe_bare(
487    diagnostics: &mut FilesetDiagnostics,
488    text: &str,
489    path_converter: &RepoPathUiConverter,
490) -> FilesetParseResult<FilesetExpression> {
491    let node = fileset_parser::parse_program_or_bare_string(text)?;
492    // TODO: add basic tree substitution pass to eliminate redundant expressions
493    resolve_expression(diagnostics, path_converter, &node)
494}
495
496#[cfg(test)]
497mod tests {
498    use std::path::PathBuf;
499
500    use super::*;
501
502    fn repo_path_buf(value: impl Into<String>) -> RepoPathBuf {
503        RepoPathBuf::from_internal_string(value).unwrap()
504    }
505
506    fn insta_settings() -> insta::Settings {
507        let mut settings = insta::Settings::clone_current();
508        // Elide parsed glob tokens, which aren't interesting.
509        settings.add_filter(r"\b(tokens): \[[^]]*\],", "$1: _,");
510        // Collapse short "Thing(_,)" repeatedly to save vertical space and make
511        // the output more readable.
512        for _ in 0..4 {
513            settings.add_filter(
514                r"(?x)
515                \b([A-Z]\w*)\(\n
516                    \s*(.{1,60}),\n
517                \s*\)",
518                "$1($2)",
519            );
520        }
521        settings
522    }
523
524    #[test]
525    fn test_parse_file_pattern() {
526        let settings = insta_settings();
527        let _guard = settings.bind_to_scope();
528        let path_converter = RepoPathUiConverter::Fs {
529            cwd: PathBuf::from("/ws/cur"),
530            base: PathBuf::from("/ws"),
531        };
532        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
533
534        // cwd-relative patterns
535        insta::assert_debug_snapshot!(
536            parse(".").unwrap(),
537            @r#"Pattern(PrefixPath("cur"))"#);
538        insta::assert_debug_snapshot!(
539            parse("..").unwrap(),
540            @r#"Pattern(PrefixPath(""))"#);
541        assert!(parse("../..").is_err());
542        insta::assert_debug_snapshot!(
543            parse("foo").unwrap(),
544            @r#"Pattern(PrefixPath("cur/foo"))"#);
545        insta::assert_debug_snapshot!(
546            parse("cwd:.").unwrap(),
547            @r#"Pattern(PrefixPath("cur"))"#);
548        insta::assert_debug_snapshot!(
549            parse("cwd-file:foo").unwrap(),
550            @r#"Pattern(FilePath("cur/foo"))"#);
551        insta::assert_debug_snapshot!(
552            parse("file:../foo/bar").unwrap(),
553            @r#"Pattern(FilePath("foo/bar"))"#);
554
555        // workspace-relative patterns
556        insta::assert_debug_snapshot!(
557            parse("root:.").unwrap(),
558            @r#"Pattern(PrefixPath(""))"#);
559        assert!(parse("root:..").is_err());
560        insta::assert_debug_snapshot!(
561            parse("root:foo/bar").unwrap(),
562            @r#"Pattern(PrefixPath("foo/bar"))"#);
563        insta::assert_debug_snapshot!(
564            parse("root-file:bar").unwrap(),
565            @r#"Pattern(FilePath("bar"))"#);
566    }
567
568    #[test]
569    fn test_parse_glob_pattern() {
570        let settings = insta_settings();
571        let _guard = settings.bind_to_scope();
572        let path_converter = RepoPathUiConverter::Fs {
573            // meta character in cwd path shouldn't be expanded
574            cwd: PathBuf::from("/ws/cur*"),
575            base: PathBuf::from("/ws"),
576        };
577        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
578
579        // cwd-relative, without meta characters
580        insta::assert_debug_snapshot!(
581            parse(r#"cwd-glob:"foo""#).unwrap(),
582            @r#"Pattern(FilePath("cur*/foo"))"#);
583        // Strictly speaking, glob:"" shouldn't match a file named <cwd>, but
584        // file pattern doesn't distinguish "foo/" from "foo".
585        insta::assert_debug_snapshot!(
586            parse(r#"glob:"""#).unwrap(),
587            @r#"Pattern(FilePath("cur*"))"#);
588        insta::assert_debug_snapshot!(
589            parse(r#"glob:".""#).unwrap(),
590            @r#"Pattern(FilePath("cur*"))"#);
591        insta::assert_debug_snapshot!(
592            parse(r#"glob:"..""#).unwrap(),
593            @r#"Pattern(FilePath(""))"#);
594
595        // cwd-relative, with meta characters
596        insta::assert_debug_snapshot!(
597            parse(r#"glob:"*""#).unwrap(), @r#"
598        Pattern(
599            FileGlob {
600                dir: "cur*",
601                pattern: Pattern {
602                    original: "*",
603                    tokens: _,
604                    is_recursive: false,
605                },
606            },
607        )
608        "#);
609        insta::assert_debug_snapshot!(
610            parse(r#"glob:"./*""#).unwrap(), @r#"
611        Pattern(
612            FileGlob {
613                dir: "cur*",
614                pattern: Pattern {
615                    original: "*",
616                    tokens: _,
617                    is_recursive: false,
618                },
619            },
620        )
621        "#);
622        insta::assert_debug_snapshot!(
623            parse(r#"glob:"../*""#).unwrap(), @r#"
624        Pattern(
625            FileGlob {
626                dir: "",
627                pattern: Pattern {
628                    original: "*",
629                    tokens: _,
630                    is_recursive: false,
631                },
632            },
633        )
634        "#);
635        // glob:"**" is equivalent to root-glob:"<cwd>/**", not root-glob:"**"
636        insta::assert_debug_snapshot!(
637            parse(r#"glob:"**""#).unwrap(), @r#"
638        Pattern(
639            FileGlob {
640                dir: "cur*",
641                pattern: Pattern {
642                    original: "**",
643                    tokens: _,
644                    is_recursive: true,
645                },
646            },
647        )
648        "#);
649        insta::assert_debug_snapshot!(
650            parse(r#"glob:"../foo/b?r/baz""#).unwrap(), @r#"
651        Pattern(
652            FileGlob {
653                dir: "foo",
654                pattern: Pattern {
655                    original: "b?r/baz",
656                    tokens: _,
657                    is_recursive: false,
658                },
659            },
660        )
661        "#);
662        assert!(parse(r#"glob:"../../*""#).is_err());
663        assert!(parse(r#"glob:"/*""#).is_err());
664        // no support for relative path component after glob meta character
665        assert!(parse(r#"glob:"*/..""#).is_err());
666
667        // cwd-relative, with Windows path separators
668        if cfg!(windows) {
669            insta::assert_debug_snapshot!(
670                parse(r#"glob:"..\\foo\\*\\bar""#).unwrap(), @r#"
671            Pattern(
672                FileGlob {
673                    dir: "foo",
674                    pattern: Pattern {
675                        original: "*/bar",
676                        tokens: _,
677                        is_recursive: false,
678                    },
679                },
680            )
681            "#);
682        } else {
683            insta::assert_debug_snapshot!(
684                parse(r#"glob:"..\\foo\\*\\bar""#).unwrap(), @r#"
685            Pattern(
686                FileGlob {
687                    dir: "cur*",
688                    pattern: Pattern {
689                        original: "..\\foo\\*\\bar",
690                        tokens: _,
691                        is_recursive: false,
692                    },
693                },
694            )
695            "#);
696        }
697
698        // workspace-relative, without meta characters
699        insta::assert_debug_snapshot!(
700            parse(r#"root-glob:"foo""#).unwrap(),
701            @r#"Pattern(FilePath("foo"))"#);
702        insta::assert_debug_snapshot!(
703            parse(r#"root-glob:"""#).unwrap(),
704            @r#"Pattern(FilePath(""))"#);
705        insta::assert_debug_snapshot!(
706            parse(r#"root-glob:".""#).unwrap(),
707            @r#"Pattern(FilePath(""))"#);
708
709        // workspace-relative, with meta characters
710        insta::assert_debug_snapshot!(
711            parse(r#"root-glob:"*""#).unwrap(), @r#"
712        Pattern(
713            FileGlob {
714                dir: "",
715                pattern: Pattern {
716                    original: "*",
717                    tokens: _,
718                    is_recursive: false,
719                },
720            },
721        )
722        "#);
723        insta::assert_debug_snapshot!(
724            parse(r#"root-glob:"foo/bar/b[az]""#).unwrap(), @r#"
725        Pattern(
726            FileGlob {
727                dir: "foo/bar",
728                pattern: Pattern {
729                    original: "b[az]",
730                    tokens: _,
731                        ),
732                    ],
733                    is_recursive: false,
734                },
735            },
736        )
737        "#);
738        assert!(parse(r#"root-glob:"../*""#).is_err());
739        assert!(parse(r#"root-glob:"/*""#).is_err());
740    }
741
742    #[test]
743    fn test_parse_function() {
744        let settings = insta_settings();
745        let _guard = settings.bind_to_scope();
746        let path_converter = RepoPathUiConverter::Fs {
747            cwd: PathBuf::from("/ws/cur"),
748            base: PathBuf::from("/ws"),
749        };
750        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
751
752        insta::assert_debug_snapshot!(parse("all()").unwrap(), @"All");
753        insta::assert_debug_snapshot!(parse("none()").unwrap(), @"None");
754        insta::assert_debug_snapshot!(parse("all(x)").unwrap_err().kind(), @r#"
755        InvalidArguments {
756            name: "all",
757            message: "Expected 0 arguments",
758        }
759        "#);
760        insta::assert_debug_snapshot!(parse("ale()").unwrap_err().kind(), @r#"
761        NoSuchFunction {
762            name: "ale",
763            candidates: [
764                "all",
765            ],
766        }
767        "#);
768    }
769
770    #[test]
771    fn test_parse_compound_expression() {
772        let settings = insta_settings();
773        let _guard = settings.bind_to_scope();
774        let path_converter = RepoPathUiConverter::Fs {
775            cwd: PathBuf::from("/ws/cur"),
776            base: PathBuf::from("/ws"),
777        };
778        let parse = |text| parse_maybe_bare(&mut FilesetDiagnostics::new(), text, &path_converter);
779
780        insta::assert_debug_snapshot!(parse("~x").unwrap(), @r#"
781        Difference(
782            All,
783            Pattern(PrefixPath("cur/x")),
784        )
785        "#);
786        insta::assert_debug_snapshot!(parse("x|y|root:z").unwrap(), @r#"
787        UnionAll(
788            [
789                Pattern(PrefixPath("cur/x")),
790                Pattern(PrefixPath("cur/y")),
791                Pattern(PrefixPath("z")),
792            ],
793        )
794        "#);
795        insta::assert_debug_snapshot!(parse("x|y&z").unwrap(), @r#"
796        UnionAll(
797            [
798                Pattern(PrefixPath("cur/x")),
799                Intersection(
800                    Pattern(PrefixPath("cur/y")),
801                    Pattern(PrefixPath("cur/z")),
802                ),
803            ],
804        )
805        "#);
806    }
807
808    #[test]
809    fn test_explicit_paths() {
810        let collect = |expr: &FilesetExpression| -> Vec<RepoPathBuf> {
811            expr.explicit_paths().map(|path| path.to_owned()).collect()
812        };
813        let file_expr = |path: &str| FilesetExpression::file_path(repo_path_buf(path));
814        assert!(collect(&FilesetExpression::none()).is_empty());
815        assert_eq!(collect(&file_expr("a")), ["a"].map(repo_path_buf));
816        assert_eq!(
817            collect(&FilesetExpression::union_all(vec![
818                file_expr("a"),
819                file_expr("b"),
820                file_expr("c"),
821            ])),
822            ["a", "b", "c"].map(repo_path_buf)
823        );
824        assert_eq!(
825            collect(&FilesetExpression::intersection(
826                FilesetExpression::union_all(vec![
827                    file_expr("a"),
828                    FilesetExpression::none(),
829                    file_expr("b"),
830                    file_expr("c"),
831                ]),
832                FilesetExpression::difference(
833                    file_expr("d"),
834                    FilesetExpression::union_all(vec![file_expr("e"), file_expr("f")])
835                )
836            )),
837            ["a", "b", "c", "d", "e", "f"].map(repo_path_buf)
838        );
839    }
840
841    #[test]
842    fn test_build_matcher_simple() {
843        let settings = insta_settings();
844        let _guard = settings.bind_to_scope();
845
846        insta::assert_debug_snapshot!(FilesetExpression::none().to_matcher(), @"NothingMatcher");
847        insta::assert_debug_snapshot!(FilesetExpression::all().to_matcher(), @"EverythingMatcher");
848        insta::assert_debug_snapshot!(
849            FilesetExpression::file_path(repo_path_buf("foo")).to_matcher(),
850            @r#"
851        FilesMatcher {
852            tree: Dir {
853                "foo": File {},
854            },
855        }
856        "#);
857        insta::assert_debug_snapshot!(
858            FilesetExpression::prefix_path(repo_path_buf("foo")).to_matcher(),
859            @r#"
860        PrefixMatcher {
861            tree: Dir {
862                "foo": Prefix {},
863            },
864        }
865        "#);
866    }
867
868    #[test]
869    fn test_build_matcher_glob_pattern() {
870        let settings = insta_settings();
871        let _guard = settings.bind_to_scope();
872        let glob_expr = |dir: &str, pattern: &str| {
873            FilesetExpression::pattern(FilePattern::FileGlob {
874                dir: repo_path_buf(dir),
875                pattern: glob::Pattern::new(pattern).unwrap(),
876            })
877        };
878
879        insta::assert_debug_snapshot!(glob_expr("", "*").to_matcher(), @r#"
880        FileGlobsMatcher {
881            tree: [
882                Pattern {
883                    original: "*",
884                    tokens: _,
885                    is_recursive: false,
886                },
887            ] {},
888        }
889        "#);
890
891        let expr =
892            FilesetExpression::union_all(vec![glob_expr("foo", "*"), glob_expr("foo/bar", "*")]);
893        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
894        FileGlobsMatcher {
895            tree: [] {
896                "foo": [
897                    Pattern {
898                        original: "*",
899                        tokens: _,
900                        is_recursive: false,
901                    },
902                ] {
903                    "bar": [
904                        Pattern {
905                            original: "*",
906                            tokens: _,
907                            is_recursive: false,
908                        },
909                    ] {},
910                },
911            },
912        }
913        "#);
914    }
915
916    #[test]
917    fn test_build_matcher_union_patterns_of_same_kind() {
918        let settings = insta_settings();
919        let _guard = settings.bind_to_scope();
920
921        let expr = FilesetExpression::union_all(vec![
922            FilesetExpression::file_path(repo_path_buf("foo")),
923            FilesetExpression::file_path(repo_path_buf("foo/bar")),
924        ]);
925        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
926        FilesMatcher {
927            tree: Dir {
928                "foo": File {
929                    "bar": File {},
930                },
931            },
932        }
933        "#);
934
935        let expr = FilesetExpression::union_all(vec![
936            FilesetExpression::prefix_path(repo_path_buf("bar")),
937            FilesetExpression::prefix_path(repo_path_buf("bar/baz")),
938        ]);
939        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
940        PrefixMatcher {
941            tree: Dir {
942                "bar": Prefix {
943                    "baz": Prefix {},
944                },
945            },
946        }
947        "#);
948    }
949
950    #[test]
951    fn test_build_matcher_union_patterns_of_different_kind() {
952        let settings = insta_settings();
953        let _guard = settings.bind_to_scope();
954
955        let expr = FilesetExpression::union_all(vec![
956            FilesetExpression::file_path(repo_path_buf("foo")),
957            FilesetExpression::prefix_path(repo_path_buf("bar")),
958        ]);
959        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
960        UnionMatcher {
961            input1: FilesMatcher {
962                tree: Dir {
963                    "foo": File {},
964                },
965            },
966            input2: PrefixMatcher {
967                tree: Dir {
968                    "bar": Prefix {},
969                },
970            },
971        }
972        "#);
973    }
974
975    #[test]
976    fn test_build_matcher_unnormalized_union() {
977        let settings = insta_settings();
978        let _guard = settings.bind_to_scope();
979
980        let expr = FilesetExpression::UnionAll(vec![]);
981        insta::assert_debug_snapshot!(expr.to_matcher(), @"NothingMatcher");
982
983        let expr =
984            FilesetExpression::UnionAll(vec![FilesetExpression::None, FilesetExpression::All]);
985        insta::assert_debug_snapshot!(expr.to_matcher(), @r"
986        UnionMatcher {
987            input1: NothingMatcher,
988            input2: EverythingMatcher,
989        }
990        ");
991    }
992
993    #[test]
994    fn test_build_matcher_combined() {
995        let settings = insta_settings();
996        let _guard = settings.bind_to_scope();
997
998        let expr = FilesetExpression::union_all(vec![
999            FilesetExpression::intersection(FilesetExpression::all(), FilesetExpression::none()),
1000            FilesetExpression::difference(FilesetExpression::none(), FilesetExpression::all()),
1001            FilesetExpression::file_path(repo_path_buf("foo")),
1002            FilesetExpression::prefix_path(repo_path_buf("bar")),
1003        ]);
1004        insta::assert_debug_snapshot!(expr.to_matcher(), @r#"
1005        UnionMatcher {
1006            input1: UnionMatcher {
1007                input1: IntersectionMatcher {
1008                    input1: EverythingMatcher,
1009                    input2: NothingMatcher,
1010                },
1011                input2: DifferenceMatcher {
1012                    wanted: NothingMatcher,
1013                    unwanted: EverythingMatcher,
1014                },
1015            },
1016            input2: UnionMatcher {
1017                input1: FilesMatcher {
1018                    tree: Dir {
1019                        "foo": File {},
1020                    },
1021                },
1022                input2: PrefixMatcher {
1023                    tree: Dir {
1024                        "bar": Prefix {},
1025                    },
1026                },
1027            },
1028        }
1029        "#);
1030    }
1031}