1use 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
56fn 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 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: ®ex::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 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 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 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 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 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 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 pub fn prunes_directories(&self) -> bool {
364 !self.directory_pruners.is_empty()
365 }
366
367 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 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 if self.root {
410 return self
411 .matchers
412 .iter()
413 .any(|(negated, matcher)| matches(matcher) != *negated);
414 }
415 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; }
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}