Skip to main content

rumdl_lib/config/
cli2_ignores.rs

1//! CLI2 ignore patterns retain the directory and phase in which CLI2 applies them.
2//! Root patterns filter discovery; nested patterns filter a configuration group.
3
4use std::path::{Path, PathBuf};
5
6use globset::{GlobBuilder, GlobMatcher};
7
8#[derive(Debug, Clone)]
9enum Matcher {
10    Glob(GlobMatcher),
11    Extglob(fancy_regex::Regex),
12}
13
14impl Matcher {
15    fn new(pattern: &str) -> Result<Self, String> {
16        Self::compile(&translate_glob_ranges(pattern, true)?)
17    }
18
19    fn for_scope(pattern: &str, root: bool) -> Result<Self, String> {
20        if root {
21            Self::new(pattern)
22        } else {
23            Self::compile(&translate_glob_ranges(pattern, false)?)
24        }
25    }
26
27    fn compile(pattern: &str) -> Result<Self, String> {
28        if pattern
29            .as_bytes()
30            .windows(2)
31            .any(|pair| b"@?+*!".contains(&pair[0]) && pair[1] == b'(')
32        {
33            let expression = extglob_regex(pattern)?;
34            fancy_regex::Regex::new(&format!("^(?:{expression})$"))
35                .map(Self::Extglob)
36                .map_err(|error| error.to_string())
37        } else {
38            GlobBuilder::new(pattern)
39                .literal_separator(true)
40                .empty_alternates(true)
41                .backslash_escape(true)
42                .build()
43                .map(|glob| Self::Glob(glob.compile_matcher()))
44                .map_err(|error| error.to_string())
45        }
46    }
47
48    fn is_match(&self, path: &str) -> bool {
49        match self {
50            Self::Glob(glob) => glob.is_match(path),
51            Self::Extglob(regex) => regex.is_match(path).unwrap_or(false),
52        }
53    }
54}
55
56/// Preserve globset's globstar and character-class handling, substituting
57/// extglob groups in its generated expression. Alternatives can themselves
58/// contain extglobs. Negation follows picomatch's prefix matching except for
59/// terminal groups and wildcard groups followed by an extension.
60fn extglob_regex(pattern: &str) -> Result<String, String> {
61    extglob_regex_with_suffix(pattern, "")
62}
63
64fn extglob_regex_with_suffix(pattern: &str, following: &str) -> Result<String, String> {
65    let mut marker_prefix = "RUMDL_EXTGLOB_".to_string();
66    while pattern.contains(&marker_prefix) {
67        marker_prefix.push('_');
68    }
69    let mut masked = String::new();
70    let mut substitutions = Vec::new();
71    let bytes = pattern.as_bytes();
72    let mut index = 0;
73    let mut in_class = false;
74    while index < bytes.len() {
75        let start = index;
76        if bytes[index] == b'\\' && index + 1 < bytes.len() {
77            let next = pattern[index + 1..].chars().next().unwrap();
78            index += 1 + next.len_utf8();
79            masked.push_str(&pattern[start..index]);
80            continue;
81        }
82        if bytes[index] == b'[' {
83            in_class = true;
84        } else if bytes[index] == b']' {
85            in_class = false;
86        }
87        if !in_class && b"@?+*!".contains(&bytes[index]) && bytes.get(index + 1) == Some(&b'(') {
88            let operator = bytes[index];
89            let mut depth = 1;
90            let mut end = index + 2;
91            let mut alternative_start = end;
92            let mut alternatives = Vec::new();
93            let mut class = false;
94            while end < bytes.len() {
95                match bytes[end] {
96                    b'\\' => {
97                        end += 2;
98                        continue;
99                    }
100                    b'[' => class = true,
101                    b']' => class = false,
102                    b'(' if !class => depth += 1,
103                    b')' if !class => {
104                        depth -= 1;
105                        if depth == 0 {
106                            break;
107                        }
108                    }
109                    b'|' if !class && depth == 1 => {
110                        alternatives.push(extglob_regex_with_suffix(
111                            &pattern[alternative_start..end],
112                            &format!("{}{following}", &pattern[end..]),
113                        )?);
114                        alternative_start = end + 1;
115                    }
116                    _ => {}
117                }
118                end += 1;
119            }
120            if depth != 0 {
121                return Err("Unclosed extglob group".into());
122            }
123            alternatives.push(extglob_regex_with_suffix(
124                &pattern[alternative_start..end],
125                &format!("{}{following}", &pattern[end..]),
126            )?);
127            let alternatives = alternatives.join("|");
128            let expression = match operator {
129                b'@' => format!("(?:{alternatives})"),
130                b'?' => format!("(?:{alternatives})?"),
131                b'+' => format!("(?:{alternatives})+"),
132                b'*' => format!("(?:{alternatives})*"),
133                b'!' => {
134                    let inner = &pattern[start + 2..end];
135                    let rest = format!("{}{following}", &pattern[end + 1..]);
136                    let spans_components = inner.contains('/');
137                    let terminal = rest.is_empty() || rest.chars().all(|c| c == ')');
138                    let suffix = if inner.contains('*')
139                        && rest.starts_with('.')
140                        && !rest.contains(['/', '\\'])
141                        && !rest[1..].contains('.')
142                    {
143                        extglob_regex(&rest)?
144                    } else if terminal || spans_components {
145                        "$".into()
146                    } else {
147                        String::new()
148                    };
149                    let wildcard = if spans_components { ".*" } else { "[^/]*" };
150                    format!("(?!(?:{alternatives}){suffix}){wildcard}")
151                }
152                _ => unreachable!(),
153            };
154            let marker = format!("{marker_prefix}{}TOKEN", substitutions.len());
155            masked.push_str(&marker);
156            substitutions.push((marker, expression));
157            index = end + 1;
158        } else {
159            let character = pattern[index..].chars().next().unwrap();
160            masked.push(character);
161            index += character.len_utf8();
162        }
163    }
164    let glob = GlobBuilder::new(&masked)
165        .literal_separator(true)
166        .empty_alternates(true)
167        .backslash_escape(true)
168        .build()
169        .map_err(|error| error.to_string())?;
170    let mut expression = glob
171        .regex()
172        .strip_prefix("(?-u)^")
173        .unwrap()
174        .strip_suffix('$')
175        .unwrap()
176        .to_string();
177    for (marker, substitution) in substitutions {
178        expression = expression.replace(&marker, &substitution);
179    }
180    // Globset targets bytes and writes non-ASCII literals as UTF-8 byte
181    // escapes. Fancy-regex targets text: restore those literals as characters.
182    static BYTE_ESCAPES: std::sync::LazyLock<regex::Regex> =
183        std::sync::LazyLock::new(|| regex::Regex::new(r"(?:\\x[0-9a-fA-F]{2})+").unwrap());
184    Ok(BYTE_ESCAPES
185        .replace_all(&expression, |capture: &regex::Captures<'_>| {
186            let bytes = capture[0]
187                .as_bytes()
188                .chunks_exact(4)
189                .map(|byte| u8::from_str_radix(std::str::from_utf8(&byte[2..]).unwrap(), 16).unwrap())
190                .collect::<Vec<_>>();
191            std::str::from_utf8(&bytes).map_or_else(|_| capture[0].to_string(), regex::escape)
192        })
193        .into_owned())
194}
195
196fn translate_glob_ranges(pattern: &str, expand_for_discovery: bool) -> Result<String, String> {
197    static RANGE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
198        regex::Regex::new(r"\{(-?\d+|[A-Za-z])\.\.(-?\d+|[A-Za-z])(?:\.\.(-?\d+))?\}").unwrap()
199    });
200    let mut expanded = String::new();
201    let mut previous = 0;
202    for capture in RANGE.captures_iter(pattern) {
203        let range = capture.get(0).unwrap();
204        expanded.push_str(&pattern[previous..range.start()]);
205        previous = range.end();
206        // Escaped braces and character classes do not declare ranges.
207        let prefix = &pattern[..range.start()];
208        let escaped = prefix.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1;
209        let in_class = prefix
210            .rfind('[')
211            .is_some_and(|open| prefix.rfind(']').is_none_or(|close| open > close));
212        let alphabetic = capture[1].len() == 1 && capture[1].as_bytes()[0].is_ascii_alphabetic();
213        let end_alphabetic = capture[2].len() == 1 && capture[2].as_bytes()[0].is_ascii_alphabetic();
214        if escaped || in_class || alphabetic != end_alphabetic {
215            expanded.push_str(range.as_str());
216            continue;
217        }
218        if !expand_for_discovery {
219            // Picomatch sorts range terms and tries them as a character class,
220            // even for stepped or padded ranges. Invalid classes fall back to
221            // the literal terms joined with dots, without the braces.
222            let mut terms = vec![capture[1].to_string(), capture[2].to_string()];
223            if let Some(step) = capture.get(3) {
224                terms.push(step.as_str().to_string());
225            }
226            terms.sort();
227            let class = format!("[{}]", terms.join("-"));
228            if let Ok(class) = regex::Regex::new(&format!("^{class}$")) {
229                // Globset's class parser treats consecutive hyphens as chained
230                // ranges. Enumerate this ASCII class to retain JS regex semantics.
231                let characters = (0u8..=127)
232                    .filter_map(|byte| {
233                        let character = char::from(byte);
234                        class.is_match(&character.to_string()).then(|| {
235                            if "[]?*{},\\".contains(character) {
236                                format!("\\{character}")
237                            } else {
238                                character.to_string()
239                            }
240                        })
241                    })
242                    .collect::<Vec<_>>()
243                    .join(",");
244                expanded.push('{');
245                expanded.push_str(&characters);
246                expanded.push('}');
247            } else {
248                expanded.push_str(&terms.join(".."));
249            }
250            continue;
251        }
252        let parse = |value: &str| value.parse::<i64>().map_err(|error| error.to_string());
253        let (start, end) = if alphabetic {
254            (i64::from(capture[1].as_bytes()[0]), i64::from(capture[2].as_bytes()[0]))
255        } else {
256            (parse(&capture[1])?, parse(&capture[2])?)
257        };
258        let step = capture
259            .get(3)
260            .map_or(Ok(1), |step| parse(step.as_str()))?
261            .checked_abs()
262            .ok_or("CLI2 range step is too large")?
263            .max(1);
264        let count = (i128::from(end) - i128::from(start)).abs() / i128::from(step) + 1;
265        if count > 1000 {
266            return Err("CLI2 ignore range exceeds 1000 alternatives".into());
267        }
268        let width = [&capture[1], &capture[2]]
269            .into_iter()
270            .filter(|value| value.trim_start_matches('-').starts_with('0') && value.len() > 1)
271            .map(|value| value.trim_start_matches('-').len())
272            .max()
273            .unwrap_or(0);
274        let direction = if end >= start { 1 } else { -1 };
275        let alternatives = (0..count)
276            .map(|index| {
277                let value = i128::from(start) + index * i128::from(step) * direction;
278                if alphabetic {
279                    let character = char::from_u32(value as u32).unwrap();
280                    if "[]?*{},\\".contains(character) {
281                        format!("\\{character}")
282                    } else {
283                        character.to_string()
284                    }
285                } else if value < 0 {
286                    format!("-{:0width$}", -value)
287                } else {
288                    format!("{value:0width$}")
289                }
290            })
291            .collect::<Vec<_>>()
292            .join(",");
293        expanded.push('{');
294        expanded.push_str(&alternatives);
295        expanded.push('}');
296    }
297    expanded.push_str(&pattern[previous..]);
298    Ok(expanded)
299}
300
301#[derive(Debug, Clone)]
302pub struct Cli2Ignore {
303    pub directory: PathBuf,
304    pub patterns: Vec<String>,
305    pub root: bool,
306    matchers: Vec<(bool, Matcher)>,
307    directory_pruners: Vec<(bool, Matcher)>,
308}
309
310impl PartialEq for Cli2Ignore {
311    fn eq(&self, other: &Self) -> bool {
312        self.directory == other.directory && self.patterns == other.patterns && self.root == other.root
313    }
314}
315
316impl Cli2Ignore {
317    pub(crate) fn new(directory: PathBuf, patterns: Vec<String>, root: bool) -> Result<Self, String> {
318        let mut matchers = Vec::new();
319        let mut directory_pruners = Vec::new();
320        for pattern in &patterns {
321            let mut glob = pattern.as_str();
322            let mut negations = 0;
323            while let Some(rest) = glob.strip_prefix('!').filter(|rest| !rest.starts_with('(')) {
324                negations += 1;
325                glob = rest;
326            }
327            // Globby removes one negation before fast-glob compiles its ignore
328            // regex; micromatch toggles inclusion for every remaining negation.
329            let include = if root {
330                negations > 0 && negations % 2 == 0
331            } else {
332                negations % 2 == 1
333            };
334            let glob = glob.strip_prefix("./").unwrap_or(glob);
335            // globby expands a literal directory argument only at the root.
336            // Nested ignores are micromatch expressions over file paths.
337            let glob =
338                if root && negations == 0 && !glob.contains(['*', '?', '[', '{']) && directory.join(glob).is_dir() {
339                    format!("{}/**", glob.trim_end_matches('/'))
340                } else {
341                    glob.to_string()
342                };
343            let matcher =
344                Matcher::for_scope(&glob, root).map_err(|e| format!("Invalid CLI2 ignore pattern {pattern:?}: {e}"))?;
345            // Fast-glob also applies static-basename and trailing-globstar
346            // ignore regexes to directories before descending into them.
347            let basename = glob.rsplit('/').next().unwrap_or(&glob);
348            if root && (glob.ends_with("/**") || !basename.contains(['*', '?', '[', '{', '(', '|', '\\'])) {
349                directory_pruners.push((include, matcher.clone()));
350            }
351            matchers.push((include, matcher));
352        }
353        Ok(Self {
354            directory,
355            patterns,
356            root,
357            matchers,
358            directory_pruners,
359        })
360    }
361
362    /// Whether this discovery scope can prune directories during a walk.
363    pub fn prunes_directories(&self) -> bool {
364        !self.directory_pruners.is_empty()
365    }
366
367    /// Directory pruning applies to a walk, but not to explicitly named files.
368    pub fn prunes_walked_path(&self, path: &Path, walk_root: &Path) -> bool {
369        if self.directory_pruners.is_empty() {
370            return false;
371        }
372        let absolute = |path: &Path| crate::workspace_index::absolute_selection_path(path).ok();
373        let (Some(path), Some(walk_root)) = (absolute(path), absolute(walk_root)) else {
374            return false;
375        };
376        if !path.starts_with(&walk_root) {
377            return false;
378        }
379        path.ancestors()
380            .skip(1)
381            .take_while(|parent| *parent != walk_root)
382            .any(|parent| {
383                let relative = relative_ignore_path(parent, &self.directory, true);
384                let absolute = parent.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
385                self.directory_pruners.iter().any(|(negated, matcher)| {
386                    let matches = relative.as_deref().is_some_and(|relative| matcher.is_match(relative))
387                        || matcher.is_match(&absolute);
388                    matches != *negated
389                })
390            })
391    }
392
393    pub fn matches(&self, path: &Path) -> bool {
394        // Ignore globs name the path supplied to CLI2, not a symlink's target.
395        // Resolve aliases of the scope itself without resolving names below it.
396        let relative = relative_ignore_path(path, &self.directory, self.root);
397        if relative.is_none() && !self.root {
398            return false;
399        }
400        let absolute = crate::workspace_index::absolute_selection_path(path)
401            .ok()
402            .map(|path| path.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"));
403        let matches = |matcher: &Matcher| {
404            relative.as_deref().is_some_and(|relative| matcher.is_match(relative))
405                || (self.root && absolute.as_deref().is_some_and(|absolute| matcher.is_match(absolute)))
406        };
407        // Globby's discovery phase excludes matching root patterns, including
408        // doubly negated ones. Micromatch's group phase is a separate scope.
409        if self.root {
410            return self
411                .matchers
412                .iter()
413                .any(|(negated, matcher)| matches(matcher) != *negated);
414        }
415        // Doubly negated micromatch expressions are inclusions: if any exists,
416        // a file must match one to survive this group's filtering.
417        let mut ignored = self.matchers.iter().any(|(include, _)| *include);
418        for (include, matcher) in &self.matchers {
419            if matches(matcher) {
420                ignored = !include;
421            }
422        }
423        ignored
424    }
425}
426
427fn relative_ignore_path(path: &Path, directory: &Path, allow_parent: bool) -> Option<String> {
428    let absolute = crate::workspace_index::absolute_selection_path(path).ok()?;
429    let base = crate::workspace_index::absolute_selection_path(directory).ok()?;
430    let relative = absolute
431        .strip_prefix(&base)
432        .ok()
433        .map(Path::to_path_buf)
434        .or_else(|| {
435            let canonical_base = std::fs::canonicalize(&base).ok()?;
436            absolute.ancestors().skip(1).find_map(|ancestor| {
437                (std::fs::canonicalize(ancestor).ok().as_ref() == Some(&canonical_base))
438                    .then(|| absolute.strip_prefix(ancestor).ok().map(Path::to_path_buf))
439                    .flatten()
440            })
441        })
442        .or_else(|| allow_parent.then(|| lexical_relative_path(&absolute, &base)).flatten())?;
443    Some(relative.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"))
444}
445
446fn lexical_relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
447    let path_components = path.components().collect::<Vec<_>>();
448    let base_components = base.components().collect::<Vec<_>>();
449    if path_components.first() != base_components.first() {
450        return None; // Different Windows drives cannot be expressed relatively.
451    }
452    let common = path_components
453        .iter()
454        .zip(&base_components)
455        .take_while(|(a, b)| a == b)
456        .count();
457    let mut relative = PathBuf::new();
458    for _ in &base_components[common..] {
459        relative.push("..");
460    }
461    for component in &path_components[common..] {
462        relative.push(component.as_os_str());
463    }
464    Some(relative)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    #[test]
471    fn ranges_keep_discovery_and_group_selection_phases() {
472        let temp = tempfile::tempdir().unwrap();
473        let root = temp.path();
474        let patterns = vec!["chapter{5..1..2}.md".into()];
475        let top = Cli2Ignore::new(root.to_path_buf(), patterns.clone(), true).unwrap();
476        let nested = Cli2Ignore::new(root.to_path_buf(), patterns, false).unwrap();
477        assert!(!top.matches(&root.join("chapter2.md")));
478        assert!(top.matches(&root.join("chapter3.md")));
479        assert!(!top.matches(&root.join("chapter4.md")));
480        assert!(nested.matches(&root.join("chapter2.md")));
481        assert!(!nested.matches(&root.join("chapter3.md")));
482        let padded = vec!["chapter{01..03}.md".into()];
483        assert!(
484            Cli2Ignore::new(root.to_path_buf(), padded.clone(), true)
485                .unwrap()
486                .matches(&root.join("chapter02.md"))
487        );
488        assert!(
489            !Cli2Ignore::new(root.to_path_buf(), padded, false)
490                .unwrap()
491                .matches(&root.join("chapter02.md"))
492        );
493    }
494    #[test]
495    fn extglobs_match_alternatives_repetitions_and_negated_components() {
496        for (pattern, yes, no) in [
497            ("**/@(generated|vendor)/**", "docs/generated/x.md", "docs/manual/x.md"),
498            ("docs/?(draft).md", "docs/.md", "docs/draftdraft.md"),
499            ("docs/+(ab|c).md", "docs/abcc.md", "docs/.md"),
500            ("docs/*(ab|c).md", "docs/.md", "docs/d.md"),
501            ("docs/!(generated|vendor).md", "docs/keep.md", "docs/generated.md"),
502            ("docs/!(@(generated|vendor)).md", "docs/keep.md", "docs/vendor.md"),
503            ("docs/@(a|+(b|c)).md", "docs/bc.md", "docs/d.md"),
504            ("docs/!(generated).md", "docs/keep.md", "docs/generated-extra.md"),
505            ("docs/!(foo)", "docs/foobar", "docs/foo"),
506            ("docs/!(*foo*).md", "docs/bar.md", "docs/foo-extra.md"),
507            ("docs/chapter{1..3}.md", "docs/chapter2.md", "docs/chapter4.md"),
508            ("docs/chapter{01..03}.md", "docs/chapter02.md", "docs/chapter2.md"),
509            ("docs/chapter{5..1..2}.md", "docs/chapter3.md", "docs/chapter2.md"),
510            ("docs/{a..c}.md", "docs/b.md", "docs/d.md"),
511            (
512                "docs/{preface,chapter{1..3}}.md",
513                "docs/chapter2.md",
514                "docs/chapter4.md",
515            ),
516            ("@(café|東京).md", "café.md", "cafe.md"),
517            ("docs/@([é]|東京).md", "docs/é.md", "docs/e.md"),
518            ("docs/@(a|!(foo)).md", "docs/bar.md", "docs/foobar.md"),
519            ("docs/@(a|!(foo))", "docs/foobar", "docs/foo"),
520        ] {
521            let matcher = Matcher::new(pattern).unwrap();
522            assert!(matcher.is_match(yes), "{pattern}: {yes}");
523            assert!(!matcher.is_match(no), "{pattern}: {no}");
524        }
525        assert!(Matcher::new("docs/@(unclosed").is_err());
526    }
527
528    #[test]
529    fn repeated_negations_keep_discovery_and_group_parity() {
530        let temp = tempfile::tempdir().unwrap();
531        let root = temp.path();
532        let keep = root.join("keep.md");
533        let other = root.join("other.md");
534        for (pattern, root_keep, root_other, group_keep, group_other) in [
535            ("!keep.md", true, false, false, true),
536            ("!!keep.md", false, true, true, false),
537            ("!!!keep.md", true, false, false, true),
538        ] {
539            let discovery = Cli2Ignore::new(root.to_path_buf(), vec![pattern.into()], true).unwrap();
540            let group = Cli2Ignore::new(root.to_path_buf(), vec![pattern.into()], false).unwrap();
541            assert_eq!(discovery.matches(&keep), root_keep, "{pattern}");
542            assert_eq!(discovery.matches(&other), root_other, "{pattern}");
543            assert_eq!(group.matches(&keep), group_keep, "{pattern}");
544            assert_eq!(group.matches(&other), group_other, "{pattern}");
545        }
546    }
547
548    #[test]
549    fn root_and_nested_patterns_keep_cli2_glob_scope() {
550        let temp = tempfile::tempdir().unwrap();
551        let root = temp.path();
552        std::fs::create_dir_all(root.join("docs/notes")).unwrap();
553        let patterns = vec!["**/*.md".into(), "!notes/keep.md".into()];
554        let nested = Cli2Ignore::new(root.join("docs"), patterns.clone(), false).unwrap();
555        assert!(nested.matches(&root.join("docs/.hidden.md")));
556        assert!(nested.matches(&root.join("docs/notes/x.md")));
557        assert!(!nested.matches(&root.join("docs/notes/keep.md")));
558        let top = Cli2Ignore::new(root.to_path_buf(), patterns, true).unwrap();
559        assert!(top.matches(&root.join("notes/keep.md")));
560        let direct = Cli2Ignore::new(root.to_path_buf(), vec!["docs/*".into()], true).unwrap();
561        assert!(direct.matches(&root.join("docs/x.md")));
562        assert!(!direct.matches(&root.join("docs/notes/x.md")));
563        let directory = Cli2Ignore::new(root.to_path_buf(), vec!["docs".into()], true).unwrap();
564        assert!(directory.matches(&root.join("docs/notes/x.md")));
565        let nested_directory = Cli2Ignore::new(root.join("docs"), vec!["notes".into()], false).unwrap();
566        assert!(!nested_directory.matches(&root.join("docs/notes/x.md")));
567    }
568}