1use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
19use std::borrow::Cow;
20use std::ffi::OsStr;
21use std::path::{Path, PathBuf};
22
23const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
26
27pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
43 if pattern.starts_with('!') {
44 return None;
45 }
46 let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
47 if file_name.is_empty() {
48 return None;
49 }
50 let literal_tail = match file_name.rfind(GLOB_METACHARS) {
53 Some(idx) => &file_name[idx + 1..],
54 None => file_name,
55 };
56 match literal_tail.rsplit_once('.') {
57 Some((_, ext)) if !ext.is_empty() => Some(file_name),
58 _ => None,
59 }
60}
61
62pub struct ExplicitIncludeMatchers {
81 matchers: Vec<ExplicitInclude>,
82}
83
84struct ExplicitInclude {
85 file_name_glob: String,
86 path_matcher: GlobMatcher,
87}
88
89impl ExplicitIncludeMatchers {
90 pub fn new(patterns: &[String]) -> Self {
91 let matchers = patterns
92 .iter()
93 .filter_map(|pattern| {
94 let file_name_glob = explicit_file_name_glob(pattern)?;
95 let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
96 anchored.to_string()
97 } else if pattern.contains('/') {
98 pattern.clone()
99 } else {
100 format!("**/{pattern}")
101 };
102 let path_matcher = globset::GlobBuilder::new(&path_glob)
103 .literal_separator(true)
104 .build()
105 .ok()?
106 .compile_matcher();
107 Some(ExplicitInclude {
108 file_name_glob: file_name_glob.to_string(),
109 path_matcher,
110 })
111 })
112 .collect();
113 Self { matchers }
114 }
115
116 pub fn is_empty(&self) -> bool {
117 self.matchers.is_empty()
118 }
119
120 pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
122 self.matchers.iter().map(|m| m.file_name_glob.as_str())
123 }
124
125 pub fn matches_relative_path(&self, path: &str) -> bool {
128 self.matchers.iter().any(|m| m.path_matcher.is_match(path))
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum LintableFileMode {
140 Markdown,
141 MarkdownAndRust,
142 Any,
143}
144
145pub struct LintablePathSelector {
152 base: Option<PathBuf>,
153 explicit: ExplicitIncludeMatchers,
154 mode: LintableFileMode,
155}
156
157impl LintablePathSelector {
158 pub fn new(base: Option<&Path>, includes: &[String], mode: LintableFileMode) -> Self {
159 Self {
160 base: base.map(Path::to_path_buf),
161 explicit: ExplicitIncludeMatchers::new(includes),
162 mode,
163 }
164 }
165
166 pub fn keeps(&self, path: &Path) -> bool {
168 if self.mode == LintableFileMode::Any {
169 return true;
170 }
171 if has_markdown_extension(path) {
172 return true;
173 }
174
175 let is_rust = path.extension().and_then(OsStr::to_str) == Some("rs");
178 if is_rust {
179 return self.mode == LintableFileMode::MarkdownAndRust;
180 }
181
182 match self.base.as_deref().and_then(|base| path_relative_to(path, base)) {
183 Some(relative) => self.explicit.matches_relative_path(&relative),
184 None => self.explicit.matches_relative_path(&path.to_string_lossy()),
187 }
188 }
189
190 pub fn configure_types(&self, builder: &mut ignore::WalkBuilder) -> Result<(), ignore::Error> {
194 if self.mode == LintableFileMode::Any {
195 return Ok(());
196 }
197
198 let mut types = ignore::types::TypesBuilder::new();
199 types.add_defaults();
200 for extension in MARKDOWN_EXTENSIONS {
201 types.add("markdown", &any_case_extension_glob(extension))?;
202 }
203 types.select("markdown");
204 if self.mode == LintableFileMode::MarkdownAndRust {
205 types.add("rustdoc", "*.rs")?;
206 types.select("rustdoc");
207 }
208 for glob in self.explicit.file_name_globs() {
209 types.add("configinclude", glob)?;
210 }
211 if !self.explicit.is_empty() {
212 types.select("configinclude");
213 }
214 builder.types(types.build()?);
215 Ok(())
216 }
217}
218
219pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
221
222#[inline]
225pub fn is_markdown_extension(ext: &OsStr) -> bool {
226 ext.to_str()
227 .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
228}
229
230#[inline]
232pub fn has_markdown_extension(path: &Path) -> bool {
233 path.extension().is_some_and(is_markdown_extension)
234}
235
236pub fn any_case_extension_glob(ext: &str) -> String {
244 let mut glob = String::with_capacity(2 + ext.len() * 4);
245 glob.push_str("*.");
246 for ch in ext.chars() {
247 if ch.is_ascii_alphabetic() {
248 glob.push('[');
249 glob.push(ch.to_ascii_lowercase());
250 glob.push(ch.to_ascii_uppercase());
251 glob.push(']');
252 } else {
253 glob.push(ch);
254 }
255 }
256 glob
257}
258
259#[derive(Debug, Clone)]
261pub struct MarkdownWalkOptions {
262 pub respect_gitignore: bool,
265 pub skip_vendor_dirs: bool,
268}
269
270impl Default for MarkdownWalkOptions {
271 fn default() -> Self {
272 Self {
273 respect_gitignore: true,
274 skip_vendor_dirs: false,
275 }
276 }
277}
278
279pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
291 !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
292}
293
294fn in_repository(path: &Path) -> bool {
300 let Ok(absolute) = std::fs::canonicalize(path) else {
301 return false;
302 };
303 absolute
304 .ancestors()
305 .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
306}
307
308pub fn apply_markdown_walk_options<P: AsRef<Path>>(
318 builder: &mut ignore::WalkBuilder,
319 roots: &[P],
320 options: &MarkdownWalkOptions,
321) {
322 let gitignore = options.respect_gitignore;
323 builder
324 .ignore(gitignore)
325 .git_ignore(gitignore)
326 .git_global(gitignore)
327 .git_exclude(gitignore)
328 .parents(gitignore)
329 .hidden(false)
330 .require_git(stops_at_repository_root(roots))
336 .add_custom_ignore_filename(".markdownlintignore");
337
338 if options.skip_vendor_dirs {
339 let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
340 builder.filter_entry(move |entry| {
341 if roots.iter().any(|root| root == entry.path()) {
342 return true;
343 }
344 let name = entry.file_name().to_str().unwrap_or("");
345 name != ".git" && name != "node_modules" && name != "target"
346 });
347 }
348}
349
350pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
352 let mut builder = ignore::WalkBuilder::new(root);
353 apply_markdown_walk_options(&mut builder, &[root], options);
354 builder
355}
356
357pub struct MarkdownWorkspaceScan<'a> {
364 options: &'a MarkdownWalkOptions,
365 includes: &'a [String],
366 excludes: &'a ExcludeMatchers,
367}
368
369impl<'a> MarkdownWorkspaceScan<'a> {
370 pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
371 Self {
372 options,
373 includes,
374 excludes,
375 }
376 }
377
378 pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
380 let mut files = Vec::new();
381 for root in roots {
382 let selection = RootSelection::new(root, self.includes);
383 let mut builder = markdown_walk_builder(root, self.options);
384 selection.configure_walk(&mut builder);
385
386 for result in builder.build() {
387 match result {
388 Ok(entry)
389 if entry.file_type().is_some_and(|file_type| file_type.is_file())
390 && selection.is_lintable(entry.path())
391 && !self.excluded(root, entry.path()) =>
392 {
393 files.push(entry.into_path());
394 }
395 Ok(_) => {}
396 Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
397 }
398 }
399 }
400 files.sort();
401 files.dedup();
402 files
403 }
404
405 pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
407 let Some(root) = roots
408 .iter()
409 .filter(|root| path.starts_with(root))
410 .max_by_key(|root| root.components().count())
411 else {
412 return false;
413 };
414
415 let selection = RootSelection::new(root, self.includes);
416 if !selection.selects(path) || self.excluded(root, path) {
417 return true;
418 }
419
420 if self.options.skip_vendor_dirs
421 && let Ok(relative) = path.strip_prefix(root)
422 && relative.components().any(|component| {
423 matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
424 })
425 {
426 return true;
427 }
428
429 let target = path.to_path_buf();
430 let mut builder = markdown_walk_builder(root, self.options);
431 selection.configure_walk(&mut builder);
432 builder.filter_entry(move |entry| target.starts_with(entry.path()));
434 !builder.build().flatten().any(|entry| entry.path() == path)
435 }
436
437 fn excluded(&self, root: &Path, path: &Path) -> bool {
438 self.excludes
439 .excludes_file(path_relative_to(path, root).as_deref(), path)
440 }
441}
442
443struct RootSelection {
444 lintable: LintablePathSelector,
445 overrides: Option<ignore::overrides::Override>,
446}
447
448impl RootSelection {
449 fn new(root: &Path, includes: &[String]) -> Self {
450 let normalized: Vec<String> = includes
451 .iter()
452 .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
453 .collect();
454 let overrides = if normalized.is_empty() {
455 None
456 } else {
457 let mut builder = ignore::overrides::OverrideBuilder::new(root);
458 for pattern in &normalized {
459 if let Err(error) = builder.add(pattern) {
460 log::warn!("Invalid include pattern '{pattern}': {error}");
461 }
462 }
463 builder.build().ok()
464 };
465 Self {
466 lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
467 overrides,
468 }
469 }
470
471 fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
472 if let Err(error) = self.lintable.configure_types(builder) {
473 log::warn!("Failed to configure workspace source types: {error}");
474 }
475 if let Some(overrides) = &self.overrides {
476 builder.overrides(overrides.clone());
477 }
478 }
479
480 fn selects(&self, path: &Path) -> bool {
481 self.overrides
482 .as_ref()
483 .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
484 && self.is_lintable(path)
485 }
486
487 fn is_lintable(&self, path: &Path) -> bool {
488 self.lintable.keeps(path)
489 }
490}
491
492pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
512 let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
513 return Cow::Borrowed(path);
514 };
515 let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
516 return Cow::Borrowed(path);
517 };
518 if let Some(share) = rest.strip_prefix("UNC")
521 && share.starts_with(sep)
522 {
523 return Cow::Owned(format!("{sep}{share}"));
524 }
525 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
526 if is_drive_path {
527 Cow::Borrowed(rest)
528 } else {
529 Cow::Borrowed(path)
530 }
531}
532
533pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
541 let canonical = path.canonicalize().ok()?;
542 if !cfg!(windows) {
543 return Some(canonical);
544 }
545 let as_str = canonical.to_string_lossy();
546 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
547}
548
549pub fn resolve_for_matching(path: &Path) -> PathBuf {
560 if let Some(canonical) = canonicalize_for_matching(path) {
561 return canonical;
562 }
563 let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
564 let resolved = resolve_through_existing_ancestor(&absolute);
565 if absolute.components().any(|c| c == std::path::Component::ParentDir) {
569 resolve_through_existing_ancestor(&resolved)
570 } else {
571 resolved
572 }
573}
574
575fn resolve_through_existing_ancestor(absolute: &Path) -> PathBuf {
578 for ancestor in absolute.ancestors() {
579 if let Some(canonical) = canonicalize_for_matching(ancestor) {
580 let remainder = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
581 if remainder.as_os_str().is_empty() {
582 return canonical;
583 }
584 return crate::workspace_index::normalize_relative_path(&canonical.join(remainder));
585 }
586 }
587 crate::workspace_index::normalize_relative_path(absolute)
588}
589
590fn home_dir() -> Option<PathBuf> {
598 #[cfg(feature = "native")]
599 {
600 use etcetera::{BaseStrategy, choose_base_strategy};
601 choose_base_strategy()
602 .ok()
603 .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
604 }
605 #[cfg(not(feature = "native"))]
606 {
607 None
608 }
609}
610
611pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
625 if !has_home_prefix(pattern) {
628 return Cow::Borrowed(pattern);
629 }
630 expand_home_prefix_impl(pattern, home_dir().as_deref())
631}
632
633fn has_home_prefix(pattern: &str) -> bool {
635 pattern == "~" || pattern.starts_with("~/")
636}
637
638fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
639 let Some(suffix) = (if pattern == "~" {
640 Some("")
641 } else {
642 pattern.strip_prefix("~/")
643 }) else {
644 return Cow::Borrowed(pattern);
645 };
646 let Some(home) = home else {
647 return Cow::Borrowed(pattern);
648 };
649
650 let home = normalize_pattern_separators(home.to_string_lossy());
651 let home = home.trim_end_matches('/');
652 if suffix.is_empty() {
653 Cow::Owned(home.to_string())
654 } else {
655 Cow::Owned(format!("{home}/{suffix}"))
656 }
657}
658
659fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
664 if cfg!(windows) && path.contains('\\') {
665 Cow::Owned(path.replace('\\', "/"))
666 } else {
667 path
668 }
669}
670
671pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
690 let expanded = expand_home_prefix(pattern);
691 let Some(base) = base else {
692 return expanded.into_owned();
693 };
694 if !is_absolute_pattern(&expanded) {
695 return expanded.into_owned();
696 }
697
698 if let Some(relative) = strip_base_prefix(Path::new(expanded.as_ref()), base) {
699 return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
700 }
701 if let Some(canonical_pattern) = canonicalize_pattern_prefix(&expanded)
702 && let Some(relative) = strip_base_prefix(Path::new(&canonical_pattern), base)
703 {
704 return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
705 }
706 expanded.into_owned()
707}
708
709fn strip_base_prefix<'a>(pattern: &'a Path, base: &Path) -> Option<&'a Path> {
713 pattern.strip_prefix(base).ok().or_else(|| {
714 let canonical = canonicalize_for_matching(base)?;
715 pattern.strip_prefix(canonical).ok()
716 })
717}
718
719pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
730 let pattern = expand_home_prefix(pattern);
731 let base = pattern.trim_end_matches('/');
732 let final_component = base.rsplit('/').next().unwrap_or(base);
733
734 if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
735 return vec![pattern.to_string()];
736 }
737
738 vec![
739 base.to_string(), format!("{base}/**"), ]
742}
743
744pub fn exclude_override_rule(pattern: &str) -> String {
749 if pattern.starts_with('!') {
750 pattern.to_string()
751 } else {
752 format!("!{pattern}")
753 }
754}
755
756pub fn exclude_pattern_compiles(pattern: &str) -> bool {
763 expand_directory_pattern(pattern).iter().all(|expanded| {
764 exclude_glob(expanded).is_ok()
765 && ignore::overrides::OverrideBuilder::new(Path::new("."))
766 .add(&exclude_override_rule(expanded))
767 .is_ok()
768 })
769}
770
771pub fn include_pattern_compiles(pattern: &str) -> bool {
780 ignore::overrides::OverrideBuilder::new(Path::new("."))
781 .add(&expand_home_prefix(pattern))
782 .is_ok()
783}
784
785fn exclusion_globs(pattern: &str, with_contents: bool, floats: bool) -> Result<GlobSet, globset::Error> {
790 let mut located = vec![pattern.to_string()];
791 if with_contents && !pattern.ends_with("/**") {
792 located.push(format!("{}/**", pattern.trim_end_matches('/')));
793 }
794 if floats {
795 let anywhere: Vec<String> = located.iter().map(|glob| format!("**/{glob}")).collect();
796 located.extend(anywhere);
797 }
798 let mut builder = GlobSetBuilder::new();
799 for glob in &located {
800 builder.add(exclude_glob(glob)?);
801 }
802 builder.build()
803}
804
805fn exclude_glob(glob: &str) -> Result<Glob, globset::Error> {
808 GlobBuilder::new(glob).literal_separator(true).build()
809}
810
811struct ExcludeMatcher {
813 pattern: String,
814 globs: GlobSet,
815 absolute: bool,
818}
819
820pub struct ExcludeMatchers {
827 matchers: Vec<ExcludeMatcher>,
829 aliases: PathAliases,
831 pub invalid: Vec<(String, String)>,
834}
835
836pub fn is_absolute_pattern(pattern: &str) -> bool {
840 pattern.starts_with('/') || Path::new(pattern).is_absolute()
841}
842
843pub fn has_absolute_spelling(pattern: &str) -> bool {
850 if is_absolute_pattern(pattern) {
851 return true;
852 }
853 pattern.contains('{')
854 && expand_braces(pattern)
855 .iter()
856 .any(|spelling| is_absolute_pattern(spelling))
857}
858
859const MAX_BRACE_EXPANSIONS: usize = 64;
866
867fn expand_braces(pattern: &str) -> Vec<String> {
874 let mut pending = vec![pattern.to_string()];
875 let mut expanded: Vec<String> = Vec::new();
876 while let Some(current) = pending.pop() {
877 let Some((prefix, alternatives, suffix)) = split_first_alternation(¤t) else {
878 expanded.push(current);
879 continue;
880 };
881 if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
882 return vec![pattern.to_string()];
883 }
884 for alternative in alternatives {
885 pending.push(format!("{prefix}{alternative}{suffix}"));
886 }
887 }
888 expanded
889}
890
891fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
898 let bytes = pattern.as_bytes();
899 let mut open = None;
900 let mut depth = 0usize;
901 let mut in_class = false;
902 let mut alternatives = Vec::new();
903 let mut alternative_start = 0;
904 let mut index = 0;
905 while index < bytes.len() {
906 match bytes[index] {
907 b'\\' if !cfg!(windows) => index += 1,
908 b'[' if !in_class => in_class = true,
909 b']' if in_class => in_class = false,
910 _ if in_class => {}
911 b'{' => {
912 depth += 1;
913 if depth == 1 {
914 open = Some(index);
915 alternative_start = index + 1;
916 }
917 }
918 b',' if depth == 1 => {
919 alternatives.push(&pattern[alternative_start..index]);
920 alternative_start = index + 1;
921 }
922 b'}' if depth > 0 => {
923 depth -= 1;
924 if depth == 0 {
925 alternatives.push(&pattern[alternative_start..index]);
926 alternatives.retain(|alternative| !alternative.is_empty());
927 if alternatives.is_empty() {
928 return None;
929 }
930 return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
931 }
932 }
933 _ => {}
934 }
935 index += 1;
936 }
937 None
938}
939
940fn literal_path_prefix(pattern: &str) -> Option<&str> {
951 let mut end = 0;
952 let mut saw_component = false;
953 for component in pattern.split('/') {
954 if component.contains(GLOB_METACHARS) {
955 break;
956 }
957 saw_component |= !component.is_empty();
958 end += component.len() + 1;
960 }
961 if !saw_component {
962 return None;
963 }
964 let prefix = &pattern[..(end - 1).min(pattern.len())];
968 Some(prefix.strip_suffix('/').unwrap_or(prefix))
969}
970
971fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
975 let prefix = literal_path_prefix(pattern)?;
976 let canonical = canonicalize_for_matching(Path::new(prefix))?;
977 let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
978 if canonical == prefix {
979 return None;
980 }
981 Some(format!("{canonical}{}", &pattern[prefix.len()..]))
982}
983
984#[derive(Debug, Default)]
1001pub struct PathAliases {
1002 prefixes: Vec<(PathBuf, String)>,
1004}
1005
1006impl PathAliases {
1007 pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
1012 let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
1013 for pattern in patterns {
1014 let pattern = expand_home_prefix(pattern);
1015 if !has_absolute_spelling(&pattern) {
1016 continue;
1017 }
1018 for spelling in expand_braces(&pattern) {
1019 let Some(as_written) = literal_path_prefix(&spelling) else {
1020 continue;
1021 };
1022 if !is_absolute_pattern(as_written) {
1023 continue;
1024 }
1025 let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
1026 continue;
1027 };
1028 if canonical == Path::new(as_written) {
1029 continue;
1030 }
1031 let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
1032 if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
1033 prefixes.push((canonical, as_written));
1034 }
1035 }
1036 }
1037 Self { prefixes }
1038 }
1039
1040 pub fn is_empty(&self) -> bool {
1041 self.prefixes.is_empty()
1042 }
1043
1044 pub fn spellings_of(&self, path: &Path) -> Vec<String> {
1048 self.prefixes
1049 .iter()
1050 .filter_map(|(canonical, as_written)| {
1051 let rest = path.strip_prefix(canonical).ok()?;
1052 if rest.as_os_str().is_empty() {
1053 return Some(as_written.clone());
1054 }
1055 let rest = normalize_pattern_separators(rest.to_string_lossy());
1056 Some(format!("{as_written}/{rest}"))
1057 })
1058 .collect()
1059 }
1060}
1061
1062impl ExcludeMatchers {
1063 pub fn new(patterns: &[String]) -> Self {
1080 let mut matchers = Vec::new();
1081 let mut invalid = Vec::new();
1082 for written in patterns {
1083 let floats = !written.trim_end_matches('/').contains('/');
1084 let expansions = expand_directory_pattern(written);
1085 let with_contents = expansions.len() == 1;
1089 for pattern in expansions {
1090 let absolute = has_absolute_spelling(&pattern);
1091 match exclusion_globs(&pattern, with_contents, floats && !absolute) {
1092 Ok(globs) => matchers.push(ExcludeMatcher {
1093 pattern,
1094 globs,
1095 absolute,
1096 }),
1097 Err(e) => invalid.push((pattern, e.to_string())),
1098 }
1099 }
1100 }
1101 let aliases = PathAliases::new(matchers.iter().map(|matcher| matcher.pattern.as_str()));
1102 Self {
1103 matchers,
1104 aliases,
1105 invalid,
1106 }
1107 }
1108
1109 pub fn is_empty(&self) -> bool {
1110 self.matchers.is_empty()
1111 }
1112
1113 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1115 self.matchers
1116 .iter()
1117 .find(|matcher| matcher.globs.is_match(relative_path))
1118 .map(|matcher| matcher.pattern.as_str())
1119 }
1120
1121 fn matched_absolute_pattern(&self, absolute_path: &str) -> Option<&str> {
1123 self.matchers
1124 .iter()
1125 .find(|matcher| matcher.absolute && matcher.globs.is_match(absolute_path))
1126 .map(|matcher| matcher.pattern.as_str())
1127 }
1128
1129 pub fn is_match(&self, relative_path: &str) -> bool {
1130 self.matched_pattern(relative_path).is_some()
1131 }
1132
1133 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1160 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1161 return Some(pattern);
1162 }
1163 if !self.matchers.iter().any(|matcher| matcher.absolute) {
1166 return None;
1167 }
1168 let resolved = resolve_for_matching(absolute);
1169 if let Some(pattern) = self.matched_absolute_pattern(&normalize_pattern_separators(resolved.to_string_lossy()))
1170 {
1171 return Some(pattern);
1172 }
1173 if absolute.has_root()
1177 && resolved != absolute
1178 && let Some(pattern) =
1179 self.matched_absolute_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
1180 {
1181 return Some(pattern);
1182 }
1183 self.aliases
1184 .spellings_of(&resolved)
1185 .into_iter()
1186 .find_map(|alias| self.matched_absolute_pattern(&alias))
1187 }
1188
1189 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1191 self.matched_pattern_for_file(relative, absolute).is_some()
1192 }
1193}
1194
1195pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1208 let canonical_base = canonicalize_for_matching(base)?;
1209 let resolved_path = resolve_for_matching(path);
1210 resolved_path.strip_prefix(&canonical_base).ok().map(|rel| {
1211 let rel = rel.to_string_lossy();
1212 if cfg!(windows) {
1213 rel.replace('\\', "/")
1214 } else {
1215 rel.to_string()
1216 }
1217 })
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use std::fs;
1224 use tempfile::tempdir;
1225
1226 #[test]
1227 fn markdown_extensions_match_case_insensitively() {
1228 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
1229 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
1230 }
1231 for ext in ["rs", "txt", "mdq", ""] {
1232 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
1233 }
1234 assert!(has_markdown_extension(Path::new("a/b/README.md")));
1235 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
1236 assert!(!has_markdown_extension(Path::new("no_extension")));
1237 assert!(!has_markdown_extension(Path::new("lib.rs")));
1238 }
1239
1240 #[test]
1241 fn lintable_selector_makes_adapter_capabilities_explicit() {
1242 let dir = tempdir().unwrap();
1243 let root = dir.path();
1244 fs::create_dir_all(root.join("docs")).unwrap();
1245 fs::create_dir_all(root.join("templates")).unwrap();
1246 fs::create_dir_all(root.join("src")).unwrap();
1247 for relative in [
1248 "docs/guide.md",
1249 "docs/notes.txt",
1250 "templates/page.md.jinja",
1251 "src/lib.rs",
1252 "src/upper.RS",
1253 ] {
1254 fs::write(root.join(relative), "content\n").unwrap();
1255 }
1256 let includes = vec![
1257 "docs/**".to_string(),
1258 "templates/**/*.md.jinja".to_string(),
1259 "src/**/*.rs".to_string(),
1260 ];
1261
1262 let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
1263 assert!(markdown.keeps(&root.join("docs/guide.md")));
1264 assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
1265 assert!(!markdown.keeps(&root.join("docs/notes.txt")));
1266 assert!(
1267 !markdown.keeps(&root.join("src/lib.rs")),
1268 "an LSP must not parse a complete Rust source file as Markdown"
1269 );
1270
1271 let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
1272 assert!(rustdoc.keeps(&root.join("src/lib.rs")));
1273 assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
1274 assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
1275
1276 let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
1277 assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
1278 }
1279
1280 #[test]
1281 fn workspace_scan_rejects_explicit_rust_includes() {
1282 let dir = tempdir().unwrap();
1283 let root = dir.path().to_path_buf();
1284 fs::create_dir(root.join("src")).unwrap();
1285 fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
1286 fs::write(root.join("README.md"), "# Readme\n").unwrap();
1287
1288 let options = MarkdownWalkOptions {
1289 respect_gitignore: false,
1290 skip_vendor_dirs: true,
1291 };
1292 let includes = vec!["src/**/*.rs".to_string()];
1293 let excludes = ExcludeMatchers::new(&[]);
1294 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1295
1296 assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
1297 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
1298 }
1299
1300 #[test]
1301 fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
1302 let dir = tempdir().unwrap();
1303 let root = dir.path().to_path_buf();
1304 fs::create_dir(root.join("docs")).unwrap();
1305 fs::create_dir(root.join("templates")).unwrap();
1306 fs::write(root.join("README.md"), "# Root\n").unwrap();
1307 fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
1308 fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
1309 fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
1310
1311 let options = MarkdownWalkOptions {
1312 respect_gitignore: false,
1313 skip_vendor_dirs: true,
1314 };
1315 let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
1316 let excludes = ExcludeMatchers::new(&[]);
1317 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1318
1319 let names: Vec<String> = scan
1322 .collect(std::slice::from_ref(&root))
1323 .iter()
1324 .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
1325 .collect();
1326 assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
1327
1328 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
1329 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1330 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
1331 }
1332
1333 #[test]
1334 fn workspace_scan_does_not_prune_a_vendor_named_root() {
1335 let dir = tempdir().unwrap();
1336 let root = dir.path().join("target");
1337 fs::create_dir(&root).unwrap();
1338 fs::write(root.join("README.md"), "# Root\n").unwrap();
1339 fs::create_dir(root.join("target")).unwrap();
1340 fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
1341
1342 let options = MarkdownWalkOptions {
1343 respect_gitignore: false,
1344 skip_vendor_dirs: true,
1345 };
1346 let excludes = ExcludeMatchers::new(&[]);
1347 let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
1348
1349 assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
1350 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1351 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
1352 }
1353
1354 #[test]
1355 fn the_type_glob_selects_exactly_what_counts_as_markdown() {
1356 assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
1357
1358 let mut builder = globset::GlobSetBuilder::new();
1361 for ext in MARKDOWN_EXTENSIONS {
1362 builder.add(
1363 globset::GlobBuilder::new(&any_case_extension_glob(ext))
1364 .literal_separator(true)
1365 .build()
1366 .unwrap(),
1367 );
1368 }
1369 let globs = builder.build().unwrap();
1370
1371 for ext in MARKDOWN_EXTENSIONS {
1372 for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
1373 let name = format!("README.{spelling}");
1374 assert!(
1375 globs.is_match(&name),
1376 "{name} is markdown by extension but no type glob selects it"
1377 );
1378 assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
1379 }
1380 }
1381
1382 for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
1384 assert!(!globs.is_match(name), "{name} should not be selected");
1385 }
1386 }
1387
1388 fn capitalize(ext: &str) -> String {
1389 let mut chars = ext.chars();
1390 match chars.next() {
1391 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1392 None => String::new(),
1393 }
1394 }
1395
1396 #[test]
1397 fn walk_includes_hidden_files() {
1398 let temp = tempdir().unwrap();
1399 fs::create_dir_all(temp.path().join(".github")).unwrap();
1400 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
1401 fs::write(temp.path().join("README.md"), "# hi").unwrap();
1402
1403 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1404 .build()
1405 .flatten()
1406 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1407 .map(|e| e.path().to_path_buf())
1408 .collect();
1409 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
1410 assert!(files.iter().any(|p| p.ends_with("README.md")));
1411 }
1412
1413 #[test]
1414 fn walk_honors_gitignore_when_enabled_only() {
1415 let temp = tempdir().unwrap();
1416 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
1417 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
1418 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1419
1420 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
1421 markdown_walk_builder(
1422 temp.path(),
1423 &MarkdownWalkOptions {
1424 respect_gitignore: respect,
1425 ..Default::default()
1426 },
1427 )
1428 .build()
1429 .flatten()
1430 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1431 .map(|e| e.path().to_path_buf())
1432 .collect()
1433 };
1434
1435 let respected = walk(true);
1436 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
1437 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
1438
1439 let unrespected = walk(false);
1440 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
1441 }
1442
1443 #[test]
1444 fn a_gitignore_above_the_repository_root_stays_outside_it() {
1445 let temp = tempdir().unwrap();
1446 fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
1447 let repo = temp.path().join("repo");
1448 fs::create_dir_all(repo.join(".git")).unwrap();
1449 fs::write(repo.join("kept.md"), "# hi").unwrap();
1450
1451 let walk = |root: &Path| -> Vec<std::path::PathBuf> {
1452 markdown_walk_builder(root, &MarkdownWalkOptions::default())
1453 .build()
1454 .flatten()
1455 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1456 .map(|e| e.path().to_path_buf())
1457 .collect()
1458 };
1459
1460 assert!(
1461 walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1462 "git reads no gitignore above the repository root, so neither does the walk"
1463 );
1464
1465 fs::remove_dir(repo.join(".git")).unwrap();
1468 assert!(
1469 !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1470 "with no repository to bound it, the walk keeps reading upward"
1471 );
1472 }
1473
1474 #[test]
1475 fn the_repository_boundary_needs_every_root_to_have_one() {
1476 let temp = tempdir().unwrap();
1477 let inside = temp.path().join("repo/docs");
1478 fs::create_dir_all(&inside).unwrap();
1479 fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
1480 let outside = temp.path().join("plain");
1481 fs::create_dir_all(&outside).unwrap();
1482
1483 assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
1484 assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
1485
1486 assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
1490 assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
1491
1492 let worktree = temp.path().join("worktree");
1495 fs::create_dir_all(&worktree).unwrap();
1496 fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1497 assert!(stops_at_repository_root(&[&worktree]));
1498 }
1499
1500 #[test]
1501 fn walk_honors_markdownlintignore() {
1502 let temp = tempdir().unwrap();
1503 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
1504 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
1505 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1506
1507 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1508 .build()
1509 .flatten()
1510 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1511 .map(|e| e.path().to_path_buf())
1512 .collect();
1513 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
1514 assert!(files.iter().any(|p| p.ends_with("kept.md")));
1515 }
1516
1517 #[test]
1518 fn vendor_dirs_skipped_only_when_requested() {
1519 let temp = tempdir().unwrap();
1520 for dir in ["node_modules", "target", "src"] {
1521 fs::create_dir_all(temp.path().join(dir)).unwrap();
1522 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
1523 }
1524
1525 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
1526 markdown_walk_builder(
1527 temp.path(),
1528 &MarkdownWalkOptions {
1529 skip_vendor_dirs: skip,
1530 respect_gitignore: false,
1534 },
1535 )
1536 .build()
1537 .flatten()
1538 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1539 .map(|e| e.path().to_path_buf())
1540 .collect()
1541 };
1542
1543 let skipped = walk(true);
1544 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1545 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
1546 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
1547
1548 let unskipped = walk(false);
1549 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1550 }
1551
1552 #[test]
1553 fn explicit_file_name_glob_extracts_literal_extensions() {
1554 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
1555 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
1556 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
1557 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
1558 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
1559 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
1560 }
1561
1562 #[test]
1563 fn explicit_file_name_glob_rejects_unpinned_patterns() {
1564 for pattern in [
1565 "docs/",
1566 "docs/**",
1567 "docs",
1568 "*",
1569 "**",
1570 "**/*",
1571 "*.*",
1572 "*.md*",
1573 "*.{md,jinja}",
1574 "*.md?",
1575 "data.[ch]",
1576 "!drafts/*.md.jinja",
1577 "",
1578 "**/Makefile",
1579 "*.",
1580 ] {
1581 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
1582 }
1583 }
1584
1585 #[test]
1586 fn explicit_include_matchers_match_full_relative_paths() {
1587 let matchers = ExplicitIncludeMatchers::new(&[
1588 "**/*.md.jinja".to_string(),
1589 "docs/**".to_string(),
1590 "templates/NOTES.tmpl".to_string(),
1591 ]);
1592 assert!(!matchers.is_empty());
1593 assert!(matchers.matches_relative_path("test.md.jinja"));
1594 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
1595 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
1596 assert!(!matchers.matches_relative_path("docs/anything.txt"));
1598 assert!(!matchers.matches_relative_path("test.jinja"));
1599 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
1602 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
1603
1604 let globs: Vec<_> = matchers.file_name_globs().collect();
1605 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
1606 }
1607
1608 #[test]
1609 fn explicit_include_matchers_follow_gitignore_anchoring() {
1610 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
1612 assert!(unanchored.matches_relative_path("test.md.jinja"));
1613 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
1614
1615 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
1617 assert!(anchored.matches_relative_path("docs/a.txt"));
1618 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
1619 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
1620
1621 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
1623 assert!(rooted.matches_relative_path("NOTES.tmpl"));
1624 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
1625 }
1626
1627 #[test]
1628 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
1629 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
1630 assert!(matchers.is_empty());
1631 assert!(!matchers.matches_relative_path("x.md.jinja"));
1632 }
1633
1634 #[test]
1635 fn explicit_include_matchers_skip_invalid_globs() {
1636 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
1639 assert!(matchers.matches_relative_path("ok.md.jinja"));
1640 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
1641 }
1642
1643 #[test]
1644 fn exclude_matchers_expand_directory_patterns() {
1645 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
1646 assert!(matchers.is_match("drafts"));
1647 assert!(
1648 matchers.is_match("drafts/inner.md"),
1649 "directory pattern must match contents"
1650 );
1651 assert!(matchers.is_match("note.tmp.md"));
1652 assert!(!matchers.is_match("docs/guide.md"));
1653 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
1654 assert!(matchers.invalid.is_empty());
1655 }
1656
1657 #[test]
1658 fn exclude_matchers_float_a_pattern_without_a_slash_to_any_depth() {
1659 let matchers =
1660 ExcludeMatchers::new(&["node_modules".to_string(), "build/".to_string(), "notes.md".to_string()]);
1661 for path in [
1662 "node_modules/readme.md",
1663 "packages/app/node_modules/readme.md",
1664 "build/out.md",
1665 "site/build/out.md",
1666 "notes.md",
1667 "docs/notes.md",
1668 ] {
1669 assert!(matchers.is_match(path), "{path} must be excluded");
1670 }
1671 for path in ["node_modules.md", "docs/my-notes.md", "rebuild/out.md", "docs/guide.md"] {
1672 assert!(!matchers.is_match(path), "{path} must not be excluded");
1673 }
1674 assert_eq!(
1676 matchers.matched_pattern("packages/app/node_modules/readme.md"),
1677 Some("node_modules/**")
1678 );
1679 }
1680
1681 #[test]
1682 fn exclude_matchers_keep_a_pattern_containing_a_slash_anchored() {
1683 let matchers = ExcludeMatchers::new(&["docs/generated".to_string(), "sub/*.md".to_string()]);
1684 assert!(matchers.is_match("docs/generated/api.md"));
1685 assert!(!matchers.is_match("site/docs/generated/api.md"));
1686 assert!(matchers.is_match("sub/a.md"));
1687 assert!(!matchers.is_match("other/sub/a.md"));
1688 }
1689
1690 #[test]
1691 fn exclude_matchers_exclude_the_contents_of_a_directory_a_wildcard_matches() {
1692 let matchers = ExcludeMatchers::new(&["draft?".to_string(), "sub/[gh]en".to_string()]);
1693 assert!(matchers.is_match("drafts/note.md"));
1694 assert!(
1695 matchers.is_match("blog/drafts/note.md"),
1696 "a slashless wildcard floats too"
1697 );
1698 assert!(matchers.is_match("sub/gen/x.md"));
1699 assert!(matchers.is_match("sub/hen/deep/x.md"));
1700 assert!(!matchers.is_match("sub/pen/x.md"));
1701 assert!(!matchers.is_match("other/sub/gen/x.md"));
1702 }
1703
1704 #[test]
1705 fn exclude_matchers_report_an_invalid_pattern_once() {
1706 let matchers = ExcludeMatchers::new(&["[".to_string(), "docs".to_string()]);
1707 assert_eq!(matchers.invalid.len(), 1, "{:?}", matchers.invalid);
1708 assert!(matchers.is_match("docs/a.md"));
1709 }
1710
1711 #[test]
1712 fn expand_home_prefix_expands_only_a_leading_tilde() {
1713 let home = Path::new("/home/dev");
1714 assert_eq!(
1715 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
1716 "/home/dev/.cursor/plans"
1717 );
1718 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
1719 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
1720 }
1721
1722 #[test]
1723 fn expand_home_prefix_leaves_interior_tildes_alone() {
1724 let home = Path::new("/home/dev");
1725 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
1727 assert_eq!(
1728 expand_home_prefix_impl(pattern, Some(home)),
1729 pattern,
1730 "{pattern:?} must be left as written"
1731 );
1732 }
1733 }
1734
1735 #[test]
1736 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
1737 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
1738 }
1739
1740 #[test]
1741 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
1742 let temp = tempdir().unwrap();
1743 let base = canonicalize_for_matching(temp.path()).unwrap();
1746 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
1747 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
1748 }
1749
1750 #[test]
1751 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
1752 let temp = tempdir().unwrap();
1755 let canonical = canonicalize_for_matching(temp.path()).unwrap();
1756 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
1757 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
1758 }
1759
1760 #[test]
1761 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
1762 let temp = tempdir().unwrap();
1763 let base = canonicalize_for_matching(temp.path()).unwrap();
1764 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
1766 assert_eq!(
1769 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
1770 "/somewhere/else/**"
1771 );
1772 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
1774 }
1775
1776 #[test]
1777 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
1778 assert_eq!(
1781 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1782 r"C:\Users\dev\AppData\Local\Temp\x"
1783 );
1784 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1785 assert_eq!(
1787 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1788 r"\\server\share\docs"
1789 );
1790 }
1791
1792 #[test]
1793 fn strip_verbatim_prefix_unwraps_the_display_form() {
1794 assert_eq!(
1796 strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
1797 "C:/Users/dev/AppData/Local/Temp/x"
1798 );
1799 assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
1800 assert_eq!(
1801 strip_verbatim_prefix("//?/UNC/server/share/docs"),
1802 "//server/share/docs"
1803 );
1804 }
1805
1806 #[test]
1807 fn strip_verbatim_prefix_leaves_other_paths_alone() {
1808 for path in [
1809 "/home/dev/docs",
1810 r"C:\Users\dev",
1811 "C:/Users/dev",
1812 r"\\server\share",
1813 "//server/share",
1814 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1816 "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
1817 r"\\?\",
1818 "//?/",
1819 r"\\?/C:/Users/dev",
1821 "",
1822 ] {
1823 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1824 }
1825 }
1826
1827 #[test]
1828 fn expand_directory_pattern_expands_a_literal_final_component() {
1829 assert_eq!(
1832 expand_directory_pattern("**/.cursor/plans"),
1833 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1834 );
1835 assert_eq!(
1836 expand_directory_pattern("docs/**/drafts"),
1837 vec!["docs/**/drafts", "docs/**/drafts/**"]
1838 );
1839 assert_eq!(
1841 expand_directory_pattern("logs/{a,b}"),
1842 vec!["logs/{a,b}", "logs/{a,b}/**"]
1843 );
1844 }
1845
1846 #[test]
1847 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1848 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1851 assert_eq!(
1852 expand_directory_pattern(pattern),
1853 vec![pattern.to_string()],
1854 "{pattern:?} must not gain a contents-expansion"
1855 );
1856 }
1857 }
1858
1859 #[test]
1860 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1861 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1862 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1863 assert!(
1864 matchers.excludes_file(None, excluded),
1865 "an absolute pattern must match the absolute path when there is no relative form"
1866 );
1867 assert_eq!(
1868 matchers.matched_pattern_for_file(None, excluded),
1869 Some("/home/dev/.cursor/plans/**")
1870 );
1871 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1874 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1875 }
1876
1877 #[test]
1878 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1879 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1880 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1881 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1882
1883 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "/unrelated".to_string()]);
1886 let note = Path::new("/home/dev/drafts/site/note.md");
1887 assert_eq!(matchers.matched_pattern_for_file(None, note), None);
1888 assert_eq!(matchers.matched_pattern_for_file(Some("note.md"), note), None);
1889 assert_eq!(
1890 matchers.matched_pattern_for_file(Some("drafts/note.md"), Path::new("/home/dev/site/drafts/note.md")),
1891 Some("drafts/**")
1892 );
1893 assert_eq!(
1894 matchers.matched_pattern_for_file(None, Path::new("/unrelated/note.md")),
1895 Some("/unrelated/**")
1896 );
1897 }
1898
1899 #[test]
1900 fn exclude_matchers_match_a_wildcard_within_one_path_component() {
1901 let matchers = ExcludeMatchers::new(&["sub/a*b".to_string(), "notes/a?b.md".to_string()]);
1902 assert!(matchers.is_match("sub/aXb/y.md"));
1903 assert!(matchers.is_match("sub/ab/y.md"));
1904 assert!(!matchers.is_match("sub/a/keep/b/x.md"));
1905 assert!(matchers.is_match("notes/aXb.md"));
1906 assert!(!matchers.is_match("notes/a/b.md"));
1907
1908 let matchers = ExcludeMatchers::new(&["docs/**/draft.md".to_string()]);
1909 assert!(matchers.is_match("docs/draft.md"));
1910 assert!(matchers.is_match("docs/a/b/draft.md"));
1911 }
1912
1913 #[test]
1914 fn exclude_matchers_report_invalid_patterns() {
1915 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1916 assert_eq!(matchers.invalid.len(), 1);
1917 assert_eq!(matchers.invalid[0].0, "[");
1918 assert!(matchers.is_match("ok.md"));
1919 }
1920
1921 #[test]
1922 fn path_relative_to_strips_through_symlinked_base() {
1923 let temp = tempdir().unwrap();
1924 let base = temp.path().join("base");
1925 fs::create_dir_all(base.join("docs")).unwrap();
1926 fs::write(base.join("docs/a.md"), "# hi").unwrap();
1927
1928 assert_eq!(
1929 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
1930 Some("docs/a.md")
1931 );
1932 assert_eq!(
1933 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
1934 Some("a.md")
1935 );
1936 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
1937 assert_eq!(
1938 path_relative_to(&base.join("docs/new/ghost.md"), &base).as_deref(),
1939 Some("docs/new/ghost.md"),
1940 "a file that does not exist yet relativizes the way it will once written"
1941 );
1942 assert_eq!(
1943 path_relative_to(&base.join("docs/a.md"), &base.join("missing")),
1944 None,
1945 "a base that does not exist contains nothing"
1946 );
1947 }
1948
1949 #[test]
1950 fn resolve_for_matching_spells_a_missing_file_the_way_it_will_canonicalize() {
1951 let temp = tempdir().unwrap();
1952 let root = canonicalize_for_matching(temp.path()).unwrap();
1953 fs::create_dir_all(root.join("docs")).unwrap();
1954 fs::write(root.join("docs").join("x.md"), "# x").unwrap();
1955
1956 let cases = [
1959 ("docs/x.md", root.join("docs").join("x.md")),
1960 ("docs/ghost.md", root.join("docs").join("ghost.md")),
1961 (
1962 "docs/new/dir/ghost.md",
1963 root.join("docs").join("new").join("dir").join("ghost.md"),
1964 ),
1965 ("missing/../docs/./x.md", root.join("docs").join("x.md")),
1966 ("docs/missing/../../top.md", root.join("top.md")),
1967 ];
1968 for (relative, expected) in cases {
1969 assert_eq!(
1970 resolve_for_matching(&temp.path().join(relative)),
1971 expected,
1972 "{relative}"
1973 );
1974 }
1975 }
1976
1977 #[test]
1978 fn resolve_for_matching_takes_a_relative_path_from_the_working_directory() {
1979 let cwd = canonicalize_for_matching(&std::env::current_dir().unwrap()).unwrap();
1980 assert_eq!(
1981 resolve_for_matching(Path::new("no-such-dir-for-matching/ghost.md")),
1982 cwd.join("no-such-dir-for-matching").join("ghost.md")
1983 );
1984 assert_eq!(resolve_for_matching(Path::new("./Cargo.toml")), cwd.join("Cargo.toml"));
1985 }
1986
1987 #[cfg(unix)]
1988 #[test]
1989 fn resolve_for_matching_follows_a_symlink_reached_through_a_missing_directory() {
1990 let temp = tempdir().unwrap();
1991 let root = canonicalize_for_matching(temp.path()).unwrap();
1992 fs::create_dir_all(root.join("real")).unwrap();
1993 std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
1994
1995 assert_eq!(
1996 resolve_for_matching(&root.join("link").join("ghost.md")),
1997 root.join("real").join("ghost.md")
1998 );
1999 assert_eq!(
2002 resolve_for_matching(&root.join("missing").join("..").join("link").join("ghost.md")),
2003 root.join("real").join("ghost.md")
2004 );
2005 }
2006
2007 fn sorted(mut patterns: Vec<String>) -> Vec<String> {
2008 patterns.sort();
2009 patterns
2010 }
2011
2012 #[test]
2013 fn expand_braces_yields_every_alternative() {
2014 assert_eq!(
2015 sorted(expand_braces("/{var/folders,tmp}/**")),
2016 vec!["/tmp/**", "/var/folders/**"]
2017 );
2018 assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
2020 assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
2022 assert_eq!(
2024 sorted(expand_braces("/{a,b}/{c,d}.md")),
2025 vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
2026 );
2027 }
2028
2029 #[test]
2030 fn expand_braces_leaves_patterns_it_cannot_split() {
2031 assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
2033 assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
2035 assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
2037 assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
2039 let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
2041 assert_eq!(expand_braces(wide), vec![wide]);
2042 }
2043
2044 #[test]
2045 fn has_absolute_spelling_sees_past_a_leading_alternation() {
2046 assert!(has_absolute_spelling("/var/folders/**"));
2047 assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
2048 assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
2049 assert!(!has_absolute_spelling("docs/**"));
2050 assert!(!has_absolute_spelling("{docs,notes}/**"));
2051 }
2052
2053 #[test]
2054 fn expand_braces_agrees_with_globset() {
2055 let cases = [
2059 ("/{var/folders,tmp}/**", "/tmp/note.md"),
2060 ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
2061 ("/{var/folders,tmp}/**", "/opt/note.md"),
2062 ("/{a,b}/{c,d}.md", "/b/d.md"),
2063 ("/{a,b}/{c,d}.md", "/b/e.md"),
2064 ("x{,y}", "x"),
2065 ("x{,y}", "xy"),
2066 ("/var/[a,b]/**", "/var/a/n.md"),
2067 ("/var/[a,b]/**", "/var/,/n.md"),
2068 ("/var/folders/**", "/var/folders/n.md"),
2069 ];
2070 for (pattern, path) in cases {
2071 let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
2072 let expanded = expand_braces(pattern)
2073 .iter()
2074 .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
2075 assert_eq!(direct, expanded, "pattern {pattern} against {path}");
2076 }
2077 }
2078
2079 #[test]
2080 fn literal_path_prefix_stops_at_the_first_wildcard() {
2081 assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
2082 assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
2083 assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
2084 assert_eq!(literal_path_prefix("/var/"), Some("/var"));
2085 assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
2086 assert_eq!(literal_path_prefix("/**"), None);
2088 assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
2089 assert_eq!(literal_path_prefix("**/note.md"), None);
2090 let pattern = "/var/folders/**";
2093 let prefix = literal_path_prefix(pattern).unwrap();
2094 assert_eq!(&pattern[prefix.len()..], "/**");
2095 }
2096
2097 #[cfg(unix)]
2101 fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
2102 let real = temp.join("real");
2103 fs::create_dir_all(real.join("notes")).unwrap();
2104 fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
2105 let link = temp.join("link");
2106 std::os::unix::fs::symlink(&real, &link).unwrap();
2107 (canonicalize_for_matching(&real).unwrap(), link)
2108 }
2109
2110 #[cfg(unix)]
2111 #[test]
2112 fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
2113 let temp = tempdir().unwrap();
2114 let (real, link) = symlinked_dir(temp.path());
2115 let pattern = format!("{}/notes/**", link.to_string_lossy());
2116
2117 let aliases = PathAliases::new([pattern.as_str()]);
2118 assert!(!aliases.is_empty());
2119 assert_eq!(
2120 aliases.spellings_of(&real.join("notes/scratch.md")),
2121 vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
2122 );
2123 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2125 assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
2126 assert!(
2127 aliases
2128 .spellings_of(&real.join("notes/scratch.md"))
2129 .iter()
2130 .any(|alias| matcher.is_match(alias))
2131 );
2132 assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
2134 }
2135
2136 #[cfg(unix)]
2137 #[test]
2138 fn path_aliases_reach_through_a_brace_alternation() {
2139 let temp = tempdir().unwrap();
2141 let (real, link) = symlinked_dir(temp.path());
2142 let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
2143
2144 let aliases = PathAliases::new([pattern.as_str()]);
2145 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2146 let note = real.join("notes/scratch.md");
2147 assert!(!matcher.is_match(¬e), "negative control");
2148 assert!(aliases.spellings_of(¬e).iter().any(|alias| matcher.is_match(alias)));
2149 }
2150
2151 #[test]
2152 fn path_aliases_are_empty_without_a_symlinked_prefix() {
2153 let temp = tempdir().unwrap();
2154 let canonical = canonicalize_for_matching(temp.path()).unwrap();
2155 let canonical = canonical.to_string_lossy().replace('\\', "/");
2156 for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
2159 assert!(
2160 PathAliases::new([pattern]).is_empty(),
2161 "pattern {pattern} should record no alias"
2162 );
2163 }
2164 }
2165
2166 #[cfg(unix)]
2167 #[test]
2168 fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
2169 let temp = tempdir().unwrap();
2170 let (real, link) = symlinked_dir(temp.path());
2171 let note = real.join("notes/scratch.md");
2172
2173 let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
2174 assert!(matchers.excludes_file(None, ¬e));
2175
2176 fs::write(real.join("other.md"), "# Other\n").unwrap();
2179 assert!(!matchers.excludes_file(None, &real.join("other.md")));
2180 let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
2181 assert!(!elsewhere.excludes_file(None, ¬e));
2182 }
2183
2184 #[cfg(unix)]
2185 #[test]
2186 fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
2187 let temp = tempdir().unwrap();
2188 let (real, link) = symlinked_dir(temp.path());
2189 let pattern = format!("{}/notes/*.md", link.to_string_lossy());
2190
2191 assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
2192 let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
2195 assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
2196 }
2197}