jj_lib/
fileset.rs

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