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
259pub const MARKDOWNLINTIGNORE: &str = ".markdownlintignore";
261
262#[derive(Debug, Clone)]
264pub struct MarkdownWalkOptions {
265 pub respect_gitignore: bool,
268 pub skip_vendor_dirs: bool,
271}
272
273impl Default for MarkdownWalkOptions {
274 fn default() -> Self {
275 Self {
276 respect_gitignore: true,
277 skip_vendor_dirs: false,
278 }
279 }
280}
281
282pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
294 !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
295}
296
297fn in_repository(path: &Path) -> bool {
303 let Ok(absolute) = std::fs::canonicalize(path) else {
304 return false;
305 };
306 absolute
307 .ancestors()
308 .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
309}
310
311pub fn apply_markdown_walk_options<P: AsRef<Path>>(
324 builder: &mut ignore::WalkBuilder,
325 roots: &[P],
326 options: &MarkdownWalkOptions,
327) {
328 apply_walk_options_with_markdownlintignore(builder, roots, options, true);
329}
330
331pub fn apply_walk_options_with_markdownlintignore<P: AsRef<Path>>(
336 builder: &mut ignore::WalkBuilder,
337 roots: &[P],
338 options: &MarkdownWalkOptions,
339 markdownlintignore: bool,
340) {
341 let gitignore = options.respect_gitignore;
342 builder
343 .ignore(gitignore)
344 .git_ignore(gitignore)
345 .git_global(gitignore)
346 .git_exclude(gitignore)
347 .parents(true)
351 .hidden(false)
352 .require_git(stops_at_repository_root(roots));
358 if markdownlintignore {
359 builder.add_custom_ignore_filename(MARKDOWNLINTIGNORE);
360 }
361
362 if options.skip_vendor_dirs {
363 let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
364 builder.filter_entry(move |entry| {
365 if roots.iter().any(|root| root == entry.path()) {
366 return true;
367 }
368 let name = entry.file_name().to_str().unwrap_or("");
369 name != ".git" && name != "node_modules" && name != "target"
370 });
371 }
372}
373
374pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
376 let mut builder = ignore::WalkBuilder::new(root);
377 apply_markdown_walk_options(&mut builder, &[root], options);
378 builder
379}
380
381pub struct MarkdownWorkspaceScan<'a> {
388 options: &'a MarkdownWalkOptions,
389 includes: &'a [String],
390 excludes: &'a ExcludeMatchers,
391}
392
393impl<'a> MarkdownWorkspaceScan<'a> {
394 pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
395 Self {
396 options,
397 includes,
398 excludes,
399 }
400 }
401
402 pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
404 let mut files = Vec::new();
405 for root in roots {
406 let selection = RootSelection::new(root, self.includes);
407 let mut builder = markdown_walk_builder(root, self.options);
408 selection.configure_walk(&mut builder);
409
410 for result in builder.build() {
411 match result {
412 Ok(entry)
413 if entry.file_type().is_some_and(|file_type| file_type.is_file())
414 && selection.is_lintable(entry.path())
415 && !self.excluded(root, entry.path()) =>
416 {
417 files.push(entry.into_path());
418 }
419 Ok(_) => {}
420 Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
421 }
422 }
423 }
424 files.sort();
425 files.dedup();
426 files
427 }
428
429 pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
431 let Some(root) = roots
432 .iter()
433 .filter(|root| path.starts_with(root))
434 .max_by_key(|root| root.components().count())
435 else {
436 return false;
437 };
438
439 let selection = RootSelection::new(root, self.includes);
440 if !selection.selects(path) || self.excluded(root, path) {
441 return true;
442 }
443
444 if self.options.skip_vendor_dirs
445 && let Ok(relative) = path.strip_prefix(root)
446 && relative.components().any(|component| {
447 matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
448 })
449 {
450 return true;
451 }
452
453 let target = path.to_path_buf();
454 let mut builder = markdown_walk_builder(root, self.options);
455 selection.configure_walk(&mut builder);
456 builder.filter_entry(move |entry| target.starts_with(entry.path()));
458 !builder.build().flatten().any(|entry| entry.path() == path)
459 }
460
461 fn excluded(&self, root: &Path, path: &Path) -> bool {
462 self.excludes
463 .excludes_file(path_relative_to(path, root).as_deref(), path)
464 }
465}
466
467struct RootSelection {
468 lintable: LintablePathSelector,
469 overrides: Option<ignore::overrides::Override>,
470}
471
472impl RootSelection {
473 fn new(root: &Path, includes: &[String]) -> Self {
474 let normalized: Vec<String> = includes
475 .iter()
476 .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
477 .collect();
478 let overrides = if normalized.is_empty() {
479 None
480 } else {
481 let mut builder = ignore::overrides::OverrideBuilder::new(root);
482 for pattern in &normalized {
483 if let Err(error) = builder.add(pattern) {
484 log::warn!("Invalid include pattern '{pattern}': {error}");
485 }
486 }
487 builder.build().ok()
488 };
489 Self {
490 lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
491 overrides,
492 }
493 }
494
495 fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
496 if let Err(error) = self.lintable.configure_types(builder) {
497 log::warn!("Failed to configure workspace source types: {error}");
498 }
499 if let Some(overrides) = &self.overrides {
500 builder.overrides(overrides.clone());
501 }
502 }
503
504 fn selects(&self, path: &Path) -> bool {
505 self.overrides
506 .as_ref()
507 .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
508 && self.is_lintable(path)
509 }
510
511 fn is_lintable(&self, path: &Path) -> bool {
512 self.lintable.keeps(path)
513 }
514}
515
516pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
536 let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
537 return Cow::Borrowed(path);
538 };
539 let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
540 return Cow::Borrowed(path);
541 };
542 if let Some(share) = rest.strip_prefix("UNC")
545 && share.starts_with(sep)
546 {
547 return Cow::Owned(format!("{sep}{share}"));
548 }
549 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
550 if is_drive_path {
551 Cow::Borrowed(rest)
552 } else {
553 Cow::Borrowed(path)
554 }
555}
556
557pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
565 let canonical = path.canonicalize().ok()?;
566 if !cfg!(windows) {
567 return Some(canonical);
568 }
569 let as_str = canonical.to_string_lossy();
570 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
571}
572
573pub fn resolve_for_matching(path: &Path) -> PathBuf {
584 if let Some(canonical) = canonicalize_for_matching(path) {
585 return canonical;
586 }
587 let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
588 let resolved = resolve_through_existing_ancestor(&absolute);
589 if absolute.components().any(|c| c == std::path::Component::ParentDir) {
593 resolve_through_existing_ancestor(&resolved)
594 } else {
595 resolved
596 }
597}
598
599fn resolve_through_existing_ancestor(absolute: &Path) -> PathBuf {
602 for ancestor in absolute.ancestors() {
603 if let Some(canonical) = canonicalize_for_matching(ancestor) {
604 let remainder = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
605 if remainder.as_os_str().is_empty() {
606 return canonical;
607 }
608 return crate::workspace_index::normalize_relative_path(&canonical.join(remainder));
609 }
610 }
611 crate::workspace_index::normalize_relative_path(absolute)
612}
613
614fn home_dir() -> Option<PathBuf> {
622 #[cfg(feature = "native")]
623 {
624 use etcetera::{BaseStrategy, choose_base_strategy};
625 choose_base_strategy()
626 .ok()
627 .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
628 }
629 #[cfg(not(feature = "native"))]
630 {
631 None
632 }
633}
634
635pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
649 if !has_home_prefix(pattern) {
652 return Cow::Borrowed(pattern);
653 }
654 expand_home_prefix_impl(pattern, home_dir().as_deref())
655}
656
657fn has_home_prefix(pattern: &str) -> bool {
659 pattern == "~" || pattern.starts_with("~/")
660}
661
662fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
663 let Some(suffix) = (if pattern == "~" {
664 Some("")
665 } else {
666 pattern.strip_prefix("~/")
667 }) else {
668 return Cow::Borrowed(pattern);
669 };
670 let Some(home) = home else {
671 return Cow::Borrowed(pattern);
672 };
673
674 let home = normalize_pattern_separators(home.to_string_lossy());
675 let home = home.trim_end_matches('/');
676 if suffix.is_empty() {
677 Cow::Owned(home.to_string())
678 } else {
679 Cow::Owned(format!("{home}/{suffix}"))
680 }
681}
682
683fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
688 if cfg!(windows) && path.contains('\\') {
689 Cow::Owned(path.replace('\\', "/"))
690 } else {
691 path
692 }
693}
694
695pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
714 let expanded = expand_home_prefix(pattern);
715 let Some(base) = base else {
716 return expanded.into_owned();
717 };
718 if !is_absolute_pattern(&expanded) {
719 return expanded.into_owned();
720 }
721
722 if let Some(relative) = strip_base_prefix(Path::new(expanded.as_ref()), base) {
723 return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
724 }
725 if let Some(canonical_pattern) = canonicalize_pattern_prefix(&expanded)
726 && let Some(relative) = strip_base_prefix(Path::new(&canonical_pattern), base)
727 {
728 return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
729 }
730 expanded.into_owned()
731}
732
733fn strip_base_prefix<'a>(pattern: &'a Path, base: &Path) -> Option<&'a Path> {
737 pattern.strip_prefix(base).ok().or_else(|| {
738 let canonical = canonicalize_for_matching(base)?;
739 pattern.strip_prefix(canonical).ok()
740 })
741}
742
743pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
754 let pattern = expand_home_prefix(pattern);
755 let base = pattern.trim_end_matches('/');
756 let final_component = base.rsplit('/').next().unwrap_or(base);
757
758 if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
759 return vec![pattern.to_string()];
760 }
761
762 vec![
763 base.to_string(), format!("{base}/**"), ]
766}
767
768pub fn exclude_override_rule(pattern: &str) -> String {
773 if pattern.starts_with('!') {
774 pattern.to_string()
775 } else {
776 format!("!{pattern}")
777 }
778}
779
780pub fn exclude_pattern_compiles(pattern: &str) -> bool {
787 expand_directory_pattern(pattern).iter().all(|expanded| {
788 exclude_glob(expanded).is_ok()
789 && ignore::overrides::OverrideBuilder::new(Path::new("."))
790 .add(&exclude_override_rule(expanded))
791 .is_ok()
792 })
793}
794
795pub fn include_pattern_compiles(pattern: &str) -> bool {
804 ignore::overrides::OverrideBuilder::new(Path::new("."))
805 .add(&expand_home_prefix(pattern))
806 .is_ok()
807}
808
809fn exclusion_globs(pattern: &str, with_contents: bool, floats: bool) -> Result<GlobSet, globset::Error> {
814 let mut located = vec![pattern.to_string()];
815 if with_contents && !pattern.ends_with("/**") {
816 located.push(format!("{}/**", pattern.trim_end_matches('/')));
817 }
818 if floats {
819 let anywhere: Vec<String> = located.iter().map(|glob| format!("**/{glob}")).collect();
820 located.extend(anywhere);
821 }
822 let mut builder = GlobSetBuilder::new();
823 for glob in &located {
824 builder.add(exclude_glob(glob)?);
825 }
826 builder.build()
827}
828
829fn exclude_glob(glob: &str) -> Result<Glob, globset::Error> {
832 GlobBuilder::new(glob).literal_separator(true).build()
833}
834
835struct ExcludeMatcher {
837 pattern: String,
838 globs: GlobSet,
839 absolute: bool,
842}
843
844pub struct ExcludeMatchers {
851 matchers: Vec<ExcludeMatcher>,
853 aliases: PathAliases,
855 pub invalid: Vec<(String, String)>,
858}
859
860pub fn is_absolute_pattern(pattern: &str) -> bool {
864 pattern.starts_with('/') || Path::new(pattern).is_absolute()
865}
866
867pub fn has_absolute_spelling(pattern: &str) -> bool {
874 if is_absolute_pattern(pattern) {
875 return true;
876 }
877 pattern.contains('{')
878 && expand_braces(pattern)
879 .iter()
880 .any(|spelling| is_absolute_pattern(spelling))
881}
882
883const MAX_BRACE_EXPANSIONS: usize = 64;
890
891fn expand_braces(pattern: &str) -> Vec<String> {
898 let mut pending = vec![pattern.to_string()];
899 let mut expanded: Vec<String> = Vec::new();
900 while let Some(current) = pending.pop() {
901 let Some((prefix, alternatives, suffix)) = split_first_alternation(¤t) else {
902 expanded.push(current);
903 continue;
904 };
905 if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
906 return vec![pattern.to_string()];
907 }
908 for alternative in alternatives {
909 pending.push(format!("{prefix}{alternative}{suffix}"));
910 }
911 }
912 expanded
913}
914
915fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
922 let bytes = pattern.as_bytes();
923 let mut open = None;
924 let mut depth = 0usize;
925 let mut in_class = false;
926 let mut alternatives = Vec::new();
927 let mut alternative_start = 0;
928 let mut index = 0;
929 while index < bytes.len() {
930 match bytes[index] {
931 b'\\' if !cfg!(windows) => index += 1,
932 b'[' if !in_class => in_class = true,
933 b']' if in_class => in_class = false,
934 _ if in_class => {}
935 b'{' => {
936 depth += 1;
937 if depth == 1 {
938 open = Some(index);
939 alternative_start = index + 1;
940 }
941 }
942 b',' if depth == 1 => {
943 alternatives.push(&pattern[alternative_start..index]);
944 alternative_start = index + 1;
945 }
946 b'}' if depth > 0 => {
947 depth -= 1;
948 if depth == 0 {
949 alternatives.push(&pattern[alternative_start..index]);
950 alternatives.retain(|alternative| !alternative.is_empty());
951 if alternatives.is_empty() {
952 return None;
953 }
954 return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
955 }
956 }
957 _ => {}
958 }
959 index += 1;
960 }
961 None
962}
963
964fn literal_path_prefix(pattern: &str) -> Option<&str> {
975 let mut end = 0;
976 let mut saw_component = false;
977 for component in pattern.split('/') {
978 if component.contains(GLOB_METACHARS) {
979 break;
980 }
981 saw_component |= !component.is_empty();
982 end += component.len() + 1;
984 }
985 if !saw_component {
986 return None;
987 }
988 let prefix = &pattern[..(end - 1).min(pattern.len())];
992 Some(prefix.strip_suffix('/').unwrap_or(prefix))
993}
994
995fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
999 let prefix = literal_path_prefix(pattern)?;
1000 let canonical = canonicalize_for_matching(Path::new(prefix))?;
1001 let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
1002 if canonical == prefix {
1003 return None;
1004 }
1005 Some(format!("{canonical}{}", &pattern[prefix.len()..]))
1006}
1007
1008#[derive(Debug, Default)]
1025pub struct PathAliases {
1026 prefixes: Vec<(PathBuf, String)>,
1028}
1029
1030impl PathAliases {
1031 pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
1036 let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
1037 for pattern in patterns {
1038 let pattern = expand_home_prefix(pattern);
1039 if !has_absolute_spelling(&pattern) {
1040 continue;
1041 }
1042 for spelling in expand_braces(&pattern) {
1043 let Some(as_written) = literal_path_prefix(&spelling) else {
1044 continue;
1045 };
1046 if !is_absolute_pattern(as_written) {
1047 continue;
1048 }
1049 let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
1050 continue;
1051 };
1052 if canonical == Path::new(as_written) {
1053 continue;
1054 }
1055 let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
1056 if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
1057 prefixes.push((canonical, as_written));
1058 }
1059 }
1060 }
1061 Self { prefixes }
1062 }
1063
1064 pub fn is_empty(&self) -> bool {
1065 self.prefixes.is_empty()
1066 }
1067
1068 pub fn spellings_of(&self, path: &Path) -> Vec<String> {
1072 self.prefixes
1073 .iter()
1074 .filter_map(|(canonical, as_written)| {
1075 let rest = path.strip_prefix(canonical).ok()?;
1076 if rest.as_os_str().is_empty() {
1077 return Some(as_written.clone());
1078 }
1079 let rest = normalize_pattern_separators(rest.to_string_lossy());
1080 Some(format!("{as_written}/{rest}"))
1081 })
1082 .collect()
1083 }
1084}
1085
1086impl ExcludeMatchers {
1087 pub fn new(patterns: &[String]) -> Self {
1104 let mut matchers = Vec::new();
1105 let mut invalid = Vec::new();
1106 for written in patterns {
1107 let floats = !written.trim_end_matches('/').contains('/');
1108 let expansions = expand_directory_pattern(written);
1109 let with_contents = expansions.len() == 1;
1113 for pattern in expansions {
1114 let absolute = has_absolute_spelling(&pattern);
1115 match exclusion_globs(&pattern, with_contents, floats && !absolute) {
1116 Ok(globs) => matchers.push(ExcludeMatcher {
1117 pattern,
1118 globs,
1119 absolute,
1120 }),
1121 Err(e) => invalid.push((pattern, e.to_string())),
1122 }
1123 }
1124 }
1125 let aliases = PathAliases::new(matchers.iter().map(|matcher| matcher.pattern.as_str()));
1126 Self {
1127 matchers,
1128 aliases,
1129 invalid,
1130 }
1131 }
1132
1133 pub fn is_empty(&self) -> bool {
1134 self.matchers.is_empty()
1135 }
1136
1137 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1139 self.matchers
1140 .iter()
1141 .find(|matcher| matcher.globs.is_match(relative_path))
1142 .map(|matcher| matcher.pattern.as_str())
1143 }
1144
1145 fn matched_absolute_pattern(&self, absolute_path: &str) -> Option<&str> {
1147 self.matchers
1148 .iter()
1149 .find(|matcher| matcher.absolute && matcher.globs.is_match(absolute_path))
1150 .map(|matcher| matcher.pattern.as_str())
1151 }
1152
1153 pub fn is_match(&self, relative_path: &str) -> bool {
1154 self.matched_pattern(relative_path).is_some()
1155 }
1156
1157 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1184 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1185 return Some(pattern);
1186 }
1187 if !self.matchers.iter().any(|matcher| matcher.absolute) {
1190 return None;
1191 }
1192 let resolved = resolve_for_matching(absolute);
1193 if let Some(pattern) = self.matched_absolute_pattern(&normalize_pattern_separators(resolved.to_string_lossy()))
1194 {
1195 return Some(pattern);
1196 }
1197 if absolute.has_root()
1201 && resolved != absolute
1202 && let Some(pattern) =
1203 self.matched_absolute_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
1204 {
1205 return Some(pattern);
1206 }
1207 self.aliases
1208 .spellings_of(&resolved)
1209 .into_iter()
1210 .find_map(|alias| self.matched_absolute_pattern(&alias))
1211 }
1212
1213 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1215 self.matched_pattern_for_file(relative, absolute).is_some()
1216 }
1217}
1218
1219pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1232 let canonical_base = canonicalize_for_matching(base)?;
1233 let resolved_path = resolve_for_matching(path);
1234 resolved_path.strip_prefix(&canonical_base).ok().map(|rel| {
1235 let rel = rel.to_string_lossy();
1236 if cfg!(windows) {
1237 rel.replace('\\', "/")
1238 } else {
1239 rel.to_string()
1240 }
1241 })
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247 use std::fs;
1248 use tempfile::tempdir;
1249
1250 #[test]
1251 fn markdown_extensions_match_case_insensitively() {
1252 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
1253 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
1254 }
1255 for ext in ["rs", "txt", "mdq", ""] {
1256 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
1257 }
1258 assert!(has_markdown_extension(Path::new("a/b/README.md")));
1259 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
1260 assert!(!has_markdown_extension(Path::new("no_extension")));
1261 assert!(!has_markdown_extension(Path::new("lib.rs")));
1262 }
1263
1264 #[test]
1265 fn lintable_selector_makes_adapter_capabilities_explicit() {
1266 let dir = tempdir().unwrap();
1267 let root = dir.path();
1268 fs::create_dir_all(root.join("docs")).unwrap();
1269 fs::create_dir_all(root.join("templates")).unwrap();
1270 fs::create_dir_all(root.join("src")).unwrap();
1271 for relative in [
1272 "docs/guide.md",
1273 "docs/notes.txt",
1274 "templates/page.md.jinja",
1275 "src/lib.rs",
1276 "src/upper.RS",
1277 ] {
1278 fs::write(root.join(relative), "content\n").unwrap();
1279 }
1280 let includes = vec![
1281 "docs/**".to_string(),
1282 "templates/**/*.md.jinja".to_string(),
1283 "src/**/*.rs".to_string(),
1284 ];
1285
1286 let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
1287 assert!(markdown.keeps(&root.join("docs/guide.md")));
1288 assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
1289 assert!(!markdown.keeps(&root.join("docs/notes.txt")));
1290 assert!(
1291 !markdown.keeps(&root.join("src/lib.rs")),
1292 "an LSP must not parse a complete Rust source file as Markdown"
1293 );
1294
1295 let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
1296 assert!(rustdoc.keeps(&root.join("src/lib.rs")));
1297 assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
1298 assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
1299
1300 let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
1301 assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
1302 }
1303
1304 #[test]
1305 fn workspace_scan_rejects_explicit_rust_includes() {
1306 let dir = tempdir().unwrap();
1307 let root = dir.path().to_path_buf();
1308 fs::create_dir(root.join("src")).unwrap();
1309 fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
1310 fs::write(root.join("README.md"), "# Readme\n").unwrap();
1311
1312 let options = MarkdownWalkOptions {
1313 respect_gitignore: false,
1314 skip_vendor_dirs: true,
1315 };
1316 let includes = vec!["src/**/*.rs".to_string()];
1317 let excludes = ExcludeMatchers::new(&[]);
1318 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1319
1320 assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
1321 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
1322 }
1323
1324 #[test]
1325 fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
1326 let dir = tempdir().unwrap();
1327 let root = dir.path().to_path_buf();
1328 fs::create_dir(root.join("docs")).unwrap();
1329 fs::create_dir(root.join("templates")).unwrap();
1330 fs::write(root.join("README.md"), "# Root\n").unwrap();
1331 fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
1332 fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
1333 fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
1334
1335 let options = MarkdownWalkOptions {
1336 respect_gitignore: false,
1337 skip_vendor_dirs: true,
1338 };
1339 let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
1340 let excludes = ExcludeMatchers::new(&[]);
1341 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1342
1343 let names: Vec<String> = scan
1346 .collect(std::slice::from_ref(&root))
1347 .iter()
1348 .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
1349 .collect();
1350 assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
1351
1352 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
1353 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1354 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
1355 }
1356
1357 #[test]
1358 fn workspace_scan_does_not_prune_a_vendor_named_root() {
1359 let dir = tempdir().unwrap();
1360 let root = dir.path().join("target");
1361 fs::create_dir(&root).unwrap();
1362 fs::write(root.join("README.md"), "# Root\n").unwrap();
1363 fs::create_dir(root.join("target")).unwrap();
1364 fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
1365
1366 let options = MarkdownWalkOptions {
1367 respect_gitignore: false,
1368 skip_vendor_dirs: true,
1369 };
1370 let excludes = ExcludeMatchers::new(&[]);
1371 let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
1372
1373 assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
1374 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1375 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
1376 }
1377
1378 #[test]
1379 fn the_type_glob_selects_exactly_what_counts_as_markdown() {
1380 assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
1381
1382 let mut builder = globset::GlobSetBuilder::new();
1385 for ext in MARKDOWN_EXTENSIONS {
1386 builder.add(
1387 globset::GlobBuilder::new(&any_case_extension_glob(ext))
1388 .literal_separator(true)
1389 .build()
1390 .unwrap(),
1391 );
1392 }
1393 let globs = builder.build().unwrap();
1394
1395 for ext in MARKDOWN_EXTENSIONS {
1396 for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
1397 let name = format!("README.{spelling}");
1398 assert!(
1399 globs.is_match(&name),
1400 "{name} is markdown by extension but no type glob selects it"
1401 );
1402 assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
1403 }
1404 }
1405
1406 for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
1408 assert!(!globs.is_match(name), "{name} should not be selected");
1409 }
1410 }
1411
1412 fn capitalize(ext: &str) -> String {
1413 let mut chars = ext.chars();
1414 match chars.next() {
1415 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1416 None => String::new(),
1417 }
1418 }
1419
1420 #[test]
1421 fn walk_includes_hidden_files() {
1422 let temp = tempdir().unwrap();
1423 fs::create_dir_all(temp.path().join(".github")).unwrap();
1424 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
1425 fs::write(temp.path().join("README.md"), "# hi").unwrap();
1426
1427 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
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 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
1434 assert!(files.iter().any(|p| p.ends_with("README.md")));
1435 }
1436
1437 #[test]
1438 fn walk_honors_gitignore_when_enabled_only() {
1439 let temp = tempdir().unwrap();
1440 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
1441 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
1442 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1443
1444 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
1445 markdown_walk_builder(
1446 temp.path(),
1447 &MarkdownWalkOptions {
1448 respect_gitignore: respect,
1449 ..Default::default()
1450 },
1451 )
1452 .build()
1453 .flatten()
1454 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1455 .map(|e| e.path().to_path_buf())
1456 .collect()
1457 };
1458
1459 let respected = walk(true);
1460 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
1461 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
1462
1463 let unrespected = walk(false);
1464 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
1465 }
1466
1467 #[test]
1468 fn a_gitignore_above_the_repository_root_stays_outside_it() {
1469 let temp = tempdir().unwrap();
1470 fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
1471 let repo = temp.path().join("repo");
1472 fs::create_dir_all(repo.join(".git")).unwrap();
1473 fs::write(repo.join("kept.md"), "# hi").unwrap();
1474
1475 let walk = |root: &Path| -> Vec<std::path::PathBuf> {
1476 markdown_walk_builder(root, &MarkdownWalkOptions::default())
1477 .build()
1478 .flatten()
1479 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1480 .map(|e| e.path().to_path_buf())
1481 .collect()
1482 };
1483
1484 assert!(
1485 walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1486 "git reads no gitignore above the repository root, so neither does the walk"
1487 );
1488
1489 fs::remove_dir(repo.join(".git")).unwrap();
1492 assert!(
1493 !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1494 "with no repository to bound it, the walk keeps reading upward"
1495 );
1496 }
1497
1498 #[test]
1499 fn the_repository_boundary_needs_every_root_to_have_one() {
1500 let temp = tempdir().unwrap();
1501 let inside = temp.path().join("repo/docs");
1502 fs::create_dir_all(&inside).unwrap();
1503 fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
1504 let outside = temp.path().join("plain");
1505 fs::create_dir_all(&outside).unwrap();
1506
1507 assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
1508 assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
1509
1510 assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
1514 assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
1515
1516 let worktree = temp.path().join("worktree");
1519 fs::create_dir_all(&worktree).unwrap();
1520 fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1521 assert!(stops_at_repository_root(&[&worktree]));
1522 }
1523
1524 #[test]
1525 fn walk_honors_markdownlintignore() {
1526 let temp = tempdir().unwrap();
1527 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
1528 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
1529 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1530
1531 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1532 .build()
1533 .flatten()
1534 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1535 .map(|e| e.path().to_path_buf())
1536 .collect();
1537 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
1538 assert!(files.iter().any(|p| p.ends_with("kept.md")));
1539 }
1540
1541 #[test]
1542 fn vendor_dirs_skipped_only_when_requested() {
1543 let temp = tempdir().unwrap();
1544 for dir in ["node_modules", "target", "src"] {
1545 fs::create_dir_all(temp.path().join(dir)).unwrap();
1546 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
1547 }
1548
1549 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
1550 markdown_walk_builder(
1551 temp.path(),
1552 &MarkdownWalkOptions {
1553 skip_vendor_dirs: skip,
1554 respect_gitignore: false,
1558 },
1559 )
1560 .build()
1561 .flatten()
1562 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1563 .map(|e| e.path().to_path_buf())
1564 .collect()
1565 };
1566
1567 let skipped = walk(true);
1568 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1569 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
1570 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
1571
1572 let unskipped = walk(false);
1573 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1574 }
1575
1576 #[test]
1577 fn explicit_file_name_glob_extracts_literal_extensions() {
1578 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
1579 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
1580 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
1581 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
1582 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
1583 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
1584 }
1585
1586 #[test]
1587 fn explicit_file_name_glob_rejects_unpinned_patterns() {
1588 for pattern in [
1589 "docs/",
1590 "docs/**",
1591 "docs",
1592 "*",
1593 "**",
1594 "**/*",
1595 "*.*",
1596 "*.md*",
1597 "*.{md,jinja}",
1598 "*.md?",
1599 "data.[ch]",
1600 "!drafts/*.md.jinja",
1601 "",
1602 "**/Makefile",
1603 "*.",
1604 ] {
1605 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
1606 }
1607 }
1608
1609 #[test]
1610 fn explicit_include_matchers_match_full_relative_paths() {
1611 let matchers = ExplicitIncludeMatchers::new(&[
1612 "**/*.md.jinja".to_string(),
1613 "docs/**".to_string(),
1614 "templates/NOTES.tmpl".to_string(),
1615 ]);
1616 assert!(!matchers.is_empty());
1617 assert!(matchers.matches_relative_path("test.md.jinja"));
1618 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
1619 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
1620 assert!(!matchers.matches_relative_path("docs/anything.txt"));
1622 assert!(!matchers.matches_relative_path("test.jinja"));
1623 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
1626 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
1627
1628 let globs: Vec<_> = matchers.file_name_globs().collect();
1629 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
1630 }
1631
1632 #[test]
1633 fn explicit_include_matchers_follow_gitignore_anchoring() {
1634 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
1636 assert!(unanchored.matches_relative_path("test.md.jinja"));
1637 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
1638
1639 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
1641 assert!(anchored.matches_relative_path("docs/a.txt"));
1642 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
1643 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
1644
1645 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
1647 assert!(rooted.matches_relative_path("NOTES.tmpl"));
1648 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
1649 }
1650
1651 #[test]
1652 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
1653 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
1654 assert!(matchers.is_empty());
1655 assert!(!matchers.matches_relative_path("x.md.jinja"));
1656 }
1657
1658 #[test]
1659 fn explicit_include_matchers_skip_invalid_globs() {
1660 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
1663 assert!(matchers.matches_relative_path("ok.md.jinja"));
1664 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
1665 }
1666
1667 #[test]
1668 fn exclude_matchers_expand_directory_patterns() {
1669 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
1670 assert!(matchers.is_match("drafts"));
1671 assert!(
1672 matchers.is_match("drafts/inner.md"),
1673 "directory pattern must match contents"
1674 );
1675 assert!(matchers.is_match("note.tmp.md"));
1676 assert!(!matchers.is_match("docs/guide.md"));
1677 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
1678 assert!(matchers.invalid.is_empty());
1679 }
1680
1681 #[test]
1682 fn exclude_matchers_float_a_pattern_without_a_slash_to_any_depth() {
1683 let matchers =
1684 ExcludeMatchers::new(&["node_modules".to_string(), "build/".to_string(), "notes.md".to_string()]);
1685 for path in [
1686 "node_modules/readme.md",
1687 "packages/app/node_modules/readme.md",
1688 "build/out.md",
1689 "site/build/out.md",
1690 "notes.md",
1691 "docs/notes.md",
1692 ] {
1693 assert!(matchers.is_match(path), "{path} must be excluded");
1694 }
1695 for path in ["node_modules.md", "docs/my-notes.md", "rebuild/out.md", "docs/guide.md"] {
1696 assert!(!matchers.is_match(path), "{path} must not be excluded");
1697 }
1698 assert_eq!(
1700 matchers.matched_pattern("packages/app/node_modules/readme.md"),
1701 Some("node_modules/**")
1702 );
1703 }
1704
1705 #[test]
1706 fn exclude_matchers_keep_a_pattern_containing_a_slash_anchored() {
1707 let matchers = ExcludeMatchers::new(&["docs/generated".to_string(), "sub/*.md".to_string()]);
1708 assert!(matchers.is_match("docs/generated/api.md"));
1709 assert!(!matchers.is_match("site/docs/generated/api.md"));
1710 assert!(matchers.is_match("sub/a.md"));
1711 assert!(!matchers.is_match("other/sub/a.md"));
1712 }
1713
1714 #[test]
1715 fn exclude_matchers_exclude_the_contents_of_a_directory_a_wildcard_matches() {
1716 let matchers = ExcludeMatchers::new(&["draft?".to_string(), "sub/[gh]en".to_string()]);
1717 assert!(matchers.is_match("drafts/note.md"));
1718 assert!(
1719 matchers.is_match("blog/drafts/note.md"),
1720 "a slashless wildcard floats too"
1721 );
1722 assert!(matchers.is_match("sub/gen/x.md"));
1723 assert!(matchers.is_match("sub/hen/deep/x.md"));
1724 assert!(!matchers.is_match("sub/pen/x.md"));
1725 assert!(!matchers.is_match("other/sub/gen/x.md"));
1726 }
1727
1728 #[test]
1729 fn exclude_matchers_report_an_invalid_pattern_once() {
1730 let matchers = ExcludeMatchers::new(&["[".to_string(), "docs".to_string()]);
1731 assert_eq!(matchers.invalid.len(), 1, "{:?}", matchers.invalid);
1732 assert!(matchers.is_match("docs/a.md"));
1733 }
1734
1735 #[test]
1736 fn expand_home_prefix_expands_only_a_leading_tilde() {
1737 let home = Path::new("/home/dev");
1738 assert_eq!(
1739 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
1740 "/home/dev/.cursor/plans"
1741 );
1742 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
1743 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
1744 }
1745
1746 #[test]
1747 fn expand_home_prefix_leaves_interior_tildes_alone() {
1748 let home = Path::new("/home/dev");
1749 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
1751 assert_eq!(
1752 expand_home_prefix_impl(pattern, Some(home)),
1753 pattern,
1754 "{pattern:?} must be left as written"
1755 );
1756 }
1757 }
1758
1759 #[test]
1760 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
1761 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
1762 }
1763
1764 #[test]
1765 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
1766 let temp = tempdir().unwrap();
1767 let base = canonicalize_for_matching(temp.path()).unwrap();
1770 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
1771 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
1772 }
1773
1774 #[test]
1775 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
1776 let temp = tempdir().unwrap();
1779 let canonical = canonicalize_for_matching(temp.path()).unwrap();
1780 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
1781 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
1782 }
1783
1784 #[test]
1785 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
1786 let temp = tempdir().unwrap();
1787 let base = canonicalize_for_matching(temp.path()).unwrap();
1788 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
1790 assert_eq!(
1793 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
1794 "/somewhere/else/**"
1795 );
1796 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
1798 }
1799
1800 #[test]
1801 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
1802 assert_eq!(
1805 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1806 r"C:\Users\dev\AppData\Local\Temp\x"
1807 );
1808 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1809 assert_eq!(
1811 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1812 r"\\server\share\docs"
1813 );
1814 }
1815
1816 #[test]
1817 fn strip_verbatim_prefix_unwraps_the_display_form() {
1818 assert_eq!(
1820 strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
1821 "C:/Users/dev/AppData/Local/Temp/x"
1822 );
1823 assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
1824 assert_eq!(
1825 strip_verbatim_prefix("//?/UNC/server/share/docs"),
1826 "//server/share/docs"
1827 );
1828 }
1829
1830 #[test]
1831 fn strip_verbatim_prefix_leaves_other_paths_alone() {
1832 for path in [
1833 "/home/dev/docs",
1834 r"C:\Users\dev",
1835 "C:/Users/dev",
1836 r"\\server\share",
1837 "//server/share",
1838 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1840 "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
1841 r"\\?\",
1842 "//?/",
1843 r"\\?/C:/Users/dev",
1845 "",
1846 ] {
1847 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1848 }
1849 }
1850
1851 #[test]
1852 fn expand_directory_pattern_expands_a_literal_final_component() {
1853 assert_eq!(
1856 expand_directory_pattern("**/.cursor/plans"),
1857 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1858 );
1859 assert_eq!(
1860 expand_directory_pattern("docs/**/drafts"),
1861 vec!["docs/**/drafts", "docs/**/drafts/**"]
1862 );
1863 assert_eq!(
1865 expand_directory_pattern("logs/{a,b}"),
1866 vec!["logs/{a,b}", "logs/{a,b}/**"]
1867 );
1868 }
1869
1870 #[test]
1871 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1872 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1875 assert_eq!(
1876 expand_directory_pattern(pattern),
1877 vec![pattern.to_string()],
1878 "{pattern:?} must not gain a contents-expansion"
1879 );
1880 }
1881 }
1882
1883 #[test]
1884 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1885 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1886 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1887 assert!(
1888 matchers.excludes_file(None, excluded),
1889 "an absolute pattern must match the absolute path when there is no relative form"
1890 );
1891 assert_eq!(
1892 matchers.matched_pattern_for_file(None, excluded),
1893 Some("/home/dev/.cursor/plans/**")
1894 );
1895 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1898 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1899 }
1900
1901 #[test]
1902 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1903 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1904 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1905 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1906
1907 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "/unrelated".to_string()]);
1910 let note = Path::new("/home/dev/drafts/site/note.md");
1911 assert_eq!(matchers.matched_pattern_for_file(None, note), None);
1912 assert_eq!(matchers.matched_pattern_for_file(Some("note.md"), note), None);
1913 assert_eq!(
1914 matchers.matched_pattern_for_file(Some("drafts/note.md"), Path::new("/home/dev/site/drafts/note.md")),
1915 Some("drafts/**")
1916 );
1917 assert_eq!(
1918 matchers.matched_pattern_for_file(None, Path::new("/unrelated/note.md")),
1919 Some("/unrelated/**")
1920 );
1921 }
1922
1923 #[test]
1924 fn exclude_matchers_match_a_wildcard_within_one_path_component() {
1925 let matchers = ExcludeMatchers::new(&["sub/a*b".to_string(), "notes/a?b.md".to_string()]);
1926 assert!(matchers.is_match("sub/aXb/y.md"));
1927 assert!(matchers.is_match("sub/ab/y.md"));
1928 assert!(!matchers.is_match("sub/a/keep/b/x.md"));
1929 assert!(matchers.is_match("notes/aXb.md"));
1930 assert!(!matchers.is_match("notes/a/b.md"));
1931
1932 let matchers = ExcludeMatchers::new(&["docs/**/draft.md".to_string()]);
1933 assert!(matchers.is_match("docs/draft.md"));
1934 assert!(matchers.is_match("docs/a/b/draft.md"));
1935 }
1936
1937 #[test]
1938 fn exclude_matchers_report_invalid_patterns() {
1939 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1940 assert_eq!(matchers.invalid.len(), 1);
1941 assert_eq!(matchers.invalid[0].0, "[");
1942 assert!(matchers.is_match("ok.md"));
1943 }
1944
1945 #[test]
1946 fn path_relative_to_strips_through_symlinked_base() {
1947 let temp = tempdir().unwrap();
1948 let base = temp.path().join("base");
1949 fs::create_dir_all(base.join("docs")).unwrap();
1950 fs::write(base.join("docs/a.md"), "# hi").unwrap();
1951
1952 assert_eq!(
1953 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
1954 Some("docs/a.md")
1955 );
1956 assert_eq!(
1957 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
1958 Some("a.md")
1959 );
1960 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
1961 assert_eq!(
1962 path_relative_to(&base.join("docs/new/ghost.md"), &base).as_deref(),
1963 Some("docs/new/ghost.md"),
1964 "a file that does not exist yet relativizes the way it will once written"
1965 );
1966 assert_eq!(
1967 path_relative_to(&base.join("docs/a.md"), &base.join("missing")),
1968 None,
1969 "a base that does not exist contains nothing"
1970 );
1971 }
1972
1973 #[test]
1974 fn resolve_for_matching_spells_a_missing_file_the_way_it_will_canonicalize() {
1975 let temp = tempdir().unwrap();
1976 let root = canonicalize_for_matching(temp.path()).unwrap();
1977 fs::create_dir_all(root.join("docs")).unwrap();
1978 fs::write(root.join("docs").join("x.md"), "# x").unwrap();
1979
1980 let cases = [
1983 ("docs/x.md", root.join("docs").join("x.md")),
1984 ("docs/ghost.md", root.join("docs").join("ghost.md")),
1985 (
1986 "docs/new/dir/ghost.md",
1987 root.join("docs").join("new").join("dir").join("ghost.md"),
1988 ),
1989 ("missing/../docs/./x.md", root.join("docs").join("x.md")),
1990 ("docs/missing/../../top.md", root.join("top.md")),
1991 ];
1992 for (relative, expected) in cases {
1993 assert_eq!(
1994 resolve_for_matching(&temp.path().join(relative)),
1995 expected,
1996 "{relative}"
1997 );
1998 }
1999 }
2000
2001 #[test]
2002 fn resolve_for_matching_takes_a_relative_path_from_the_working_directory() {
2003 let cwd = canonicalize_for_matching(&std::env::current_dir().unwrap()).unwrap();
2004 assert_eq!(
2005 resolve_for_matching(Path::new("no-such-dir-for-matching/ghost.md")),
2006 cwd.join("no-such-dir-for-matching").join("ghost.md")
2007 );
2008 assert_eq!(resolve_for_matching(Path::new("./Cargo.toml")), cwd.join("Cargo.toml"));
2009 }
2010
2011 #[cfg(unix)]
2012 #[test]
2013 fn resolve_for_matching_follows_a_symlink_reached_through_a_missing_directory() {
2014 let temp = tempdir().unwrap();
2015 let root = canonicalize_for_matching(temp.path()).unwrap();
2016 fs::create_dir_all(root.join("real")).unwrap();
2017 std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
2018
2019 assert_eq!(
2020 resolve_for_matching(&root.join("link").join("ghost.md")),
2021 root.join("real").join("ghost.md")
2022 );
2023 assert_eq!(
2026 resolve_for_matching(&root.join("missing").join("..").join("link").join("ghost.md")),
2027 root.join("real").join("ghost.md")
2028 );
2029 }
2030
2031 fn sorted(mut patterns: Vec<String>) -> Vec<String> {
2032 patterns.sort();
2033 patterns
2034 }
2035
2036 #[test]
2037 fn expand_braces_yields_every_alternative() {
2038 assert_eq!(
2039 sorted(expand_braces("/{var/folders,tmp}/**")),
2040 vec!["/tmp/**", "/var/folders/**"]
2041 );
2042 assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
2044 assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
2046 assert_eq!(
2048 sorted(expand_braces("/{a,b}/{c,d}.md")),
2049 vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
2050 );
2051 }
2052
2053 #[test]
2054 fn expand_braces_leaves_patterns_it_cannot_split() {
2055 assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
2057 assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
2059 assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
2061 assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
2063 let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
2065 assert_eq!(expand_braces(wide), vec![wide]);
2066 }
2067
2068 #[test]
2069 fn has_absolute_spelling_sees_past_a_leading_alternation() {
2070 assert!(has_absolute_spelling("/var/folders/**"));
2071 assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
2072 assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
2073 assert!(!has_absolute_spelling("docs/**"));
2074 assert!(!has_absolute_spelling("{docs,notes}/**"));
2075 }
2076
2077 #[test]
2078 fn expand_braces_agrees_with_globset() {
2079 let cases = [
2083 ("/{var/folders,tmp}/**", "/tmp/note.md"),
2084 ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
2085 ("/{var/folders,tmp}/**", "/opt/note.md"),
2086 ("/{a,b}/{c,d}.md", "/b/d.md"),
2087 ("/{a,b}/{c,d}.md", "/b/e.md"),
2088 ("x{,y}", "x"),
2089 ("x{,y}", "xy"),
2090 ("/var/[a,b]/**", "/var/a/n.md"),
2091 ("/var/[a,b]/**", "/var/,/n.md"),
2092 ("/var/folders/**", "/var/folders/n.md"),
2093 ];
2094 for (pattern, path) in cases {
2095 let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
2096 let expanded = expand_braces(pattern)
2097 .iter()
2098 .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
2099 assert_eq!(direct, expanded, "pattern {pattern} against {path}");
2100 }
2101 }
2102
2103 #[test]
2104 fn literal_path_prefix_stops_at_the_first_wildcard() {
2105 assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
2106 assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
2107 assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
2108 assert_eq!(literal_path_prefix("/var/"), Some("/var"));
2109 assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
2110 assert_eq!(literal_path_prefix("/**"), None);
2112 assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
2113 assert_eq!(literal_path_prefix("**/note.md"), None);
2114 let pattern = "/var/folders/**";
2117 let prefix = literal_path_prefix(pattern).unwrap();
2118 assert_eq!(&pattern[prefix.len()..], "/**");
2119 }
2120
2121 #[cfg(unix)]
2125 fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
2126 let real = temp.join("real");
2127 fs::create_dir_all(real.join("notes")).unwrap();
2128 fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
2129 let link = temp.join("link");
2130 std::os::unix::fs::symlink(&real, &link).unwrap();
2131 (canonicalize_for_matching(&real).unwrap(), link)
2132 }
2133
2134 #[cfg(unix)]
2135 #[test]
2136 fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
2137 let temp = tempdir().unwrap();
2138 let (real, link) = symlinked_dir(temp.path());
2139 let pattern = format!("{}/notes/**", link.to_string_lossy());
2140
2141 let aliases = PathAliases::new([pattern.as_str()]);
2142 assert!(!aliases.is_empty());
2143 assert_eq!(
2144 aliases.spellings_of(&real.join("notes/scratch.md")),
2145 vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
2146 );
2147 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2149 assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
2150 assert!(
2151 aliases
2152 .spellings_of(&real.join("notes/scratch.md"))
2153 .iter()
2154 .any(|alias| matcher.is_match(alias))
2155 );
2156 assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
2158 }
2159
2160 #[cfg(unix)]
2161 #[test]
2162 fn path_aliases_reach_through_a_brace_alternation() {
2163 let temp = tempdir().unwrap();
2165 let (real, link) = symlinked_dir(temp.path());
2166 let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
2167
2168 let aliases = PathAliases::new([pattern.as_str()]);
2169 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2170 let note = real.join("notes/scratch.md");
2171 assert!(!matcher.is_match(¬e), "negative control");
2172 assert!(aliases.spellings_of(¬e).iter().any(|alias| matcher.is_match(alias)));
2173 }
2174
2175 #[test]
2176 fn path_aliases_are_empty_without_a_symlinked_prefix() {
2177 let temp = tempdir().unwrap();
2178 let canonical = canonicalize_for_matching(temp.path()).unwrap();
2179 let canonical = canonical.to_string_lossy().replace('\\', "/");
2180 for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
2183 assert!(
2184 PathAliases::new([pattern]).is_empty(),
2185 "pattern {pattern} should record no alias"
2186 );
2187 }
2188 }
2189
2190 #[cfg(unix)]
2191 #[test]
2192 fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
2193 let temp = tempdir().unwrap();
2194 let (real, link) = symlinked_dir(temp.path());
2195 let note = real.join("notes/scratch.md");
2196
2197 let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
2198 assert!(matchers.excludes_file(None, ¬e));
2199
2200 fs::write(real.join("other.md"), "# Other\n").unwrap();
2203 assert!(!matchers.excludes_file(None, &real.join("other.md")));
2204 let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
2205 assert!(!elsewhere.excludes_file(None, ¬e));
2206 }
2207
2208 #[cfg(unix)]
2209 #[test]
2210 fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
2211 let temp = tempdir().unwrap();
2212 let (real, link) = symlinked_dir(temp.path());
2213 let pattern = format!("{}/notes/*.md", link.to_string_lossy());
2214
2215 assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
2216 let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
2219 assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
2220 }
2221}