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
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum FileKind {
239 Markdown,
241 RustDocComments,
243 SourceCode,
246}
247
248pub fn classify_file(path: &Path) -> FileKind {
258 if has_markdown_extension(path) {
259 return FileKind::Markdown;
260 }
261 if crate::doc_comment_lint::is_rust_source(path) {
262 return FileKind::RustDocComments;
263 }
264 let Some(extension) = path.extension().and_then(OsStr::to_str) else {
265 return FileKind::Markdown;
266 };
267 if extension.eq_ignore_ascii_case("litcoffee") || has_inner_markdown_extension(path) {
268 return FileKind::Markdown;
269 }
270 if crate::linguist_data::is_source_code_extension(extension) {
271 FileKind::SourceCode
272 } else {
273 FileKind::Markdown
274 }
275}
276
277fn has_inner_markdown_extension(path: &Path) -> bool {
281 let mut stem = path.file_stem();
282 while let Some(current) = stem {
283 let current = Path::new(current);
284 if has_markdown_extension(current) {
285 return true;
286 }
287 stem = current.extension().and(current.file_stem());
288 }
289 false
290}
291
292pub fn any_case_extension_glob(ext: &str) -> String {
300 let mut glob = String::with_capacity(2 + ext.len() * 4);
301 glob.push_str("*.");
302 for ch in ext.chars() {
303 if ch.is_ascii_alphabetic() {
304 glob.push('[');
305 glob.push(ch.to_ascii_lowercase());
306 glob.push(ch.to_ascii_uppercase());
307 glob.push(']');
308 } else {
309 glob.push(ch);
310 }
311 }
312 glob
313}
314
315pub const MARKDOWNLINTIGNORE: &str = ".markdownlintignore";
317
318#[derive(Debug, Clone)]
320pub struct MarkdownWalkOptions {
321 pub respect_gitignore: bool,
324 pub skip_vendor_dirs: bool,
327}
328
329impl Default for MarkdownWalkOptions {
330 fn default() -> Self {
331 Self {
332 respect_gitignore: true,
333 skip_vendor_dirs: false,
334 }
335 }
336}
337
338pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
350 !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
351}
352
353fn in_repository(path: &Path) -> bool {
359 let Ok(absolute) = std::fs::canonicalize(path) else {
360 return false;
361 };
362 absolute
363 .ancestors()
364 .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
365}
366
367pub fn apply_markdown_walk_options<P: AsRef<Path>>(
380 builder: &mut ignore::WalkBuilder,
381 roots: &[P],
382 options: &MarkdownWalkOptions,
383) {
384 apply_walk_options_with_markdownlintignore(builder, roots, options, true);
385}
386
387pub fn apply_walk_options_with_markdownlintignore<P: AsRef<Path>>(
392 builder: &mut ignore::WalkBuilder,
393 roots: &[P],
394 options: &MarkdownWalkOptions,
395 markdownlintignore: bool,
396) {
397 let gitignore = options.respect_gitignore;
398 builder
399 .ignore(gitignore)
400 .git_ignore(gitignore)
401 .git_global(gitignore)
402 .git_exclude(gitignore)
403 .parents(true)
407 .hidden(false)
408 .require_git(stops_at_repository_root(roots));
414 if markdownlintignore {
415 builder.add_custom_ignore_filename(MARKDOWNLINTIGNORE);
416 }
417
418 if options.skip_vendor_dirs {
419 let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
420 builder.filter_entry(move |entry| {
421 if roots.iter().any(|root| root == entry.path()) {
422 return true;
423 }
424 let name = entry.file_name().to_str().unwrap_or("");
425 name != ".git" && name != "node_modules" && name != "target"
426 });
427 }
428}
429
430pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
432 let mut builder = ignore::WalkBuilder::new(root);
433 apply_markdown_walk_options(&mut builder, &[root], options);
434 builder
435}
436
437pub struct MarkdownWorkspaceScan<'a> {
444 options: &'a MarkdownWalkOptions,
445 includes: &'a [String],
446 excludes: &'a ExcludeMatchers,
447}
448
449impl<'a> MarkdownWorkspaceScan<'a> {
450 pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
451 Self {
452 options,
453 includes,
454 excludes,
455 }
456 }
457
458 pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
460 let mut files = Vec::new();
461 for root in roots {
462 let selection = RootSelection::new(root, self.includes);
463 let mut builder = markdown_walk_builder(root, self.options);
464 selection.configure_walk(&mut builder);
465
466 for result in builder.build() {
467 match result {
468 Ok(entry)
469 if entry.file_type().is_some_and(|file_type| file_type.is_file())
470 && selection.is_lintable(entry.path())
471 && !self.excluded(root, entry.path()) =>
472 {
473 files.push(entry.into_path());
474 }
475 Ok(_) => {}
476 Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
477 }
478 }
479 }
480 files.sort();
481 files.dedup();
482 files
483 }
484
485 pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
487 let Some(root) = roots
488 .iter()
489 .filter(|root| path.starts_with(root))
490 .max_by_key(|root| root.components().count())
491 else {
492 return false;
493 };
494
495 let selection = RootSelection::new(root, self.includes);
496 if !selection.selects(path) || self.excluded(root, path) {
497 return true;
498 }
499
500 if self.options.skip_vendor_dirs
501 && let Ok(relative) = path.strip_prefix(root)
502 && relative.components().any(|component| {
503 matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
504 })
505 {
506 return true;
507 }
508
509 let target = path.to_path_buf();
510 let mut builder = markdown_walk_builder(root, self.options);
511 selection.configure_walk(&mut builder);
512 builder.filter_entry(move |entry| target.starts_with(entry.path()));
514 !builder.build().flatten().any(|entry| entry.path() == path)
515 }
516
517 fn excluded(&self, root: &Path, path: &Path) -> bool {
518 self.excludes
519 .excludes_file(path_relative_to(path, root).as_deref(), path)
520 }
521}
522
523struct RootSelection {
524 lintable: LintablePathSelector,
525 overrides: Option<ignore::overrides::Override>,
526}
527
528impl RootSelection {
529 fn new(root: &Path, includes: &[String]) -> Self {
530 let normalized: Vec<String> = includes
531 .iter()
532 .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
533 .collect();
534 let overrides = if normalized.is_empty() {
535 None
536 } else {
537 let mut builder = ignore::overrides::OverrideBuilder::new(root);
538 for pattern in &normalized {
539 if let Err(error) = builder.add(pattern) {
540 log::warn!("Invalid include pattern '{pattern}': {error}");
541 }
542 }
543 builder.build().ok()
544 };
545 Self {
546 lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
547 overrides,
548 }
549 }
550
551 fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
552 if let Err(error) = self.lintable.configure_types(builder) {
553 log::warn!("Failed to configure workspace source types: {error}");
554 }
555 if let Some(overrides) = &self.overrides {
556 builder.overrides(overrides.clone());
557 }
558 }
559
560 fn selects(&self, path: &Path) -> bool {
561 self.overrides
562 .as_ref()
563 .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
564 && self.is_lintable(path)
565 }
566
567 fn is_lintable(&self, path: &Path) -> bool {
568 self.lintable.keeps(path)
569 }
570}
571
572pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
592 let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
593 return Cow::Borrowed(path);
594 };
595 let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
596 return Cow::Borrowed(path);
597 };
598 if let Some(share) = rest.strip_prefix("UNC")
601 && share.starts_with(sep)
602 {
603 return Cow::Owned(format!("{sep}{share}"));
604 }
605 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
606 if is_drive_path {
607 Cow::Borrowed(rest)
608 } else {
609 Cow::Borrowed(path)
610 }
611}
612
613pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
621 let canonical = path.canonicalize().ok()?;
622 if !cfg!(windows) {
623 return Some(canonical);
624 }
625 let as_str = canonical.to_string_lossy();
626 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
627}
628
629pub fn resolve_for_matching(path: &Path) -> PathBuf {
640 if let Some(canonical) = canonicalize_for_matching(path) {
641 return canonical;
642 }
643 let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
644 let resolved = resolve_through_existing_ancestor(&absolute);
645 if absolute.components().any(|c| c == std::path::Component::ParentDir) {
649 resolve_through_existing_ancestor(&resolved)
650 } else {
651 resolved
652 }
653}
654
655fn resolve_through_existing_ancestor(absolute: &Path) -> PathBuf {
658 for ancestor in absolute.ancestors() {
659 if let Some(canonical) = canonicalize_for_matching(ancestor) {
660 let remainder = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
661 if remainder.as_os_str().is_empty() {
662 return canonical;
663 }
664 return crate::workspace_index::normalize_relative_path(&canonical.join(remainder));
665 }
666 }
667 crate::workspace_index::normalize_relative_path(absolute)
668}
669
670fn home_dir() -> Option<PathBuf> {
678 #[cfg(feature = "native")]
679 {
680 use etcetera::{BaseStrategy, choose_base_strategy};
681 choose_base_strategy()
682 .ok()
683 .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
684 }
685 #[cfg(not(feature = "native"))]
686 {
687 None
688 }
689}
690
691pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
705 if !has_home_prefix(pattern) {
708 return Cow::Borrowed(pattern);
709 }
710 expand_home_prefix_impl(pattern, home_dir().as_deref())
711}
712
713fn has_home_prefix(pattern: &str) -> bool {
715 pattern == "~" || pattern.starts_with("~/")
716}
717
718fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
719 let Some(suffix) = (if pattern == "~" {
720 Some("")
721 } else {
722 pattern.strip_prefix("~/")
723 }) else {
724 return Cow::Borrowed(pattern);
725 };
726 let Some(home) = home else {
727 return Cow::Borrowed(pattern);
728 };
729
730 let home = normalize_pattern_separators(home.to_string_lossy());
731 let home = home.trim_end_matches('/');
732 if suffix.is_empty() {
733 Cow::Owned(home.to_string())
734 } else {
735 Cow::Owned(format!("{home}/{suffix}"))
736 }
737}
738
739fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
744 if cfg!(windows) && path.contains('\\') {
745 Cow::Owned(path.replace('\\', "/"))
746 } else {
747 path
748 }
749}
750
751pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
770 let expanded = expand_home_prefix(pattern);
771 let Some(base) = base else {
772 return expanded.into_owned();
773 };
774 if !is_absolute_pattern(&expanded) {
775 return expanded.into_owned();
776 }
777
778 if let Some(relative) = strip_base_prefix(Path::new(expanded.as_ref()), base) {
779 return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
780 }
781 if let Some(canonical_pattern) = canonicalize_pattern_prefix(&expanded)
782 && let Some(relative) = strip_base_prefix(Path::new(&canonical_pattern), base)
783 {
784 return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
785 }
786 expanded.into_owned()
787}
788
789fn strip_base_prefix<'a>(pattern: &'a Path, base: &Path) -> Option<&'a Path> {
793 pattern.strip_prefix(base).ok().or_else(|| {
794 let canonical = canonicalize_for_matching(base)?;
795 pattern.strip_prefix(canonical).ok()
796 })
797}
798
799pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
810 let pattern = expand_home_prefix(pattern);
811 let base = pattern.trim_end_matches('/');
812 let final_component = base.rsplit('/').next().unwrap_or(base);
813
814 if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
815 return vec![pattern.to_string()];
816 }
817
818 vec![
819 base.to_string(), format!("{base}/**"), ]
822}
823
824pub fn exclude_override_rule(pattern: &str) -> String {
829 if pattern.starts_with('!') {
830 pattern.to_string()
831 } else {
832 format!("!{pattern}")
833 }
834}
835
836pub fn exclude_pattern_compiles(pattern: &str) -> bool {
843 expand_directory_pattern(pattern).iter().all(|expanded| {
844 exclude_glob(expanded).is_ok()
845 && ignore::overrides::OverrideBuilder::new(Path::new("."))
846 .add(&exclude_override_rule(expanded))
847 .is_ok()
848 })
849}
850
851pub fn include_pattern_compiles(pattern: &str) -> bool {
860 ignore::overrides::OverrideBuilder::new(Path::new("."))
861 .add(&expand_home_prefix(pattern))
862 .is_ok()
863}
864
865fn exclusion_globs(pattern: &str, with_contents: bool, floats: bool) -> Result<GlobSet, globset::Error> {
870 let mut located = vec![pattern.to_string()];
871 if with_contents && !pattern.ends_with("/**") {
872 located.push(format!("{}/**", pattern.trim_end_matches('/')));
873 }
874 if floats {
875 let anywhere: Vec<String> = located.iter().map(|glob| format!("**/{glob}")).collect();
876 located.extend(anywhere);
877 }
878 let mut builder = GlobSetBuilder::new();
879 for glob in &located {
880 builder.add(exclude_glob(glob)?);
881 }
882 builder.build()
883}
884
885fn exclude_glob(glob: &str) -> Result<Glob, globset::Error> {
888 GlobBuilder::new(glob).literal_separator(true).build()
889}
890
891struct ExcludeMatcher {
893 pattern: String,
894 globs: GlobSet,
895 absolute: bool,
898}
899
900pub struct ExcludeMatchers {
907 matchers: Vec<ExcludeMatcher>,
909 aliases: PathAliases,
911 pub invalid: Vec<(String, String)>,
914}
915
916pub fn is_absolute_pattern(pattern: &str) -> bool {
920 pattern.starts_with('/') || Path::new(pattern).is_absolute()
921}
922
923pub fn has_absolute_spelling(pattern: &str) -> bool {
930 if is_absolute_pattern(pattern) {
931 return true;
932 }
933 pattern.contains('{')
934 && expand_braces(pattern)
935 .iter()
936 .any(|spelling| is_absolute_pattern(spelling))
937}
938
939const MAX_BRACE_EXPANSIONS: usize = 64;
946
947fn expand_braces(pattern: &str) -> Vec<String> {
954 let mut pending = vec![pattern.to_string()];
955 let mut expanded: Vec<String> = Vec::new();
956 while let Some(current) = pending.pop() {
957 let Some((prefix, alternatives, suffix)) = split_first_alternation(¤t) else {
958 expanded.push(current);
959 continue;
960 };
961 if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
962 return vec![pattern.to_string()];
963 }
964 for alternative in alternatives {
965 pending.push(format!("{prefix}{alternative}{suffix}"));
966 }
967 }
968 expanded
969}
970
971fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
978 let bytes = pattern.as_bytes();
979 let mut open = None;
980 let mut depth = 0usize;
981 let mut in_class = false;
982 let mut alternatives = Vec::new();
983 let mut alternative_start = 0;
984 let mut index = 0;
985 while index < bytes.len() {
986 match bytes[index] {
987 b'\\' if !cfg!(windows) => index += 1,
988 b'[' if !in_class => in_class = true,
989 b']' if in_class => in_class = false,
990 _ if in_class => {}
991 b'{' => {
992 depth += 1;
993 if depth == 1 {
994 open = Some(index);
995 alternative_start = index + 1;
996 }
997 }
998 b',' if depth == 1 => {
999 alternatives.push(&pattern[alternative_start..index]);
1000 alternative_start = index + 1;
1001 }
1002 b'}' if depth > 0 => {
1003 depth -= 1;
1004 if depth == 0 {
1005 alternatives.push(&pattern[alternative_start..index]);
1006 alternatives.retain(|alternative| !alternative.is_empty());
1007 if alternatives.is_empty() {
1008 return None;
1009 }
1010 return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
1011 }
1012 }
1013 _ => {}
1014 }
1015 index += 1;
1016 }
1017 None
1018}
1019
1020fn literal_path_prefix(pattern: &str) -> Option<&str> {
1031 let mut end = 0;
1032 let mut saw_component = false;
1033 for component in pattern.split('/') {
1034 if component.contains(GLOB_METACHARS) {
1035 break;
1036 }
1037 saw_component |= !component.is_empty();
1038 end += component.len() + 1;
1040 }
1041 if !saw_component {
1042 return None;
1043 }
1044 let prefix = &pattern[..(end - 1).min(pattern.len())];
1048 Some(prefix.strip_suffix('/').unwrap_or(prefix))
1049}
1050
1051fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
1055 let prefix = literal_path_prefix(pattern)?;
1056 let canonical = canonicalize_for_matching(Path::new(prefix))?;
1057 let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
1058 if canonical == prefix {
1059 return None;
1060 }
1061 Some(format!("{canonical}{}", &pattern[prefix.len()..]))
1062}
1063
1064#[derive(Debug, Default)]
1081pub struct PathAliases {
1082 prefixes: Vec<(PathBuf, String)>,
1084}
1085
1086impl PathAliases {
1087 pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
1092 let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
1093 for pattern in patterns {
1094 let pattern = expand_home_prefix(pattern);
1095 if !has_absolute_spelling(&pattern) {
1096 continue;
1097 }
1098 for spelling in expand_braces(&pattern) {
1099 let Some(as_written) = literal_path_prefix(&spelling) else {
1100 continue;
1101 };
1102 if !is_absolute_pattern(as_written) {
1103 continue;
1104 }
1105 let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
1106 continue;
1107 };
1108 if canonical == Path::new(as_written) {
1109 continue;
1110 }
1111 let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
1112 if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
1113 prefixes.push((canonical, as_written));
1114 }
1115 }
1116 }
1117 Self { prefixes }
1118 }
1119
1120 pub fn is_empty(&self) -> bool {
1121 self.prefixes.is_empty()
1122 }
1123
1124 pub fn spellings_of(&self, path: &Path) -> Vec<String> {
1128 self.prefixes
1129 .iter()
1130 .filter_map(|(canonical, as_written)| {
1131 let rest = path.strip_prefix(canonical).ok()?;
1132 if rest.as_os_str().is_empty() {
1133 return Some(as_written.clone());
1134 }
1135 let rest = normalize_pattern_separators(rest.to_string_lossy());
1136 Some(format!("{as_written}/{rest}"))
1137 })
1138 .collect()
1139 }
1140}
1141
1142impl ExcludeMatchers {
1143 pub fn new(patterns: &[String]) -> Self {
1160 let mut matchers = Vec::new();
1161 let mut invalid = Vec::new();
1162 for written in patterns {
1163 let floats = !written.trim_end_matches('/').contains('/');
1164 let expansions = expand_directory_pattern(written);
1165 let with_contents = expansions.len() == 1;
1169 for pattern in expansions {
1170 let absolute = has_absolute_spelling(&pattern);
1171 match exclusion_globs(&pattern, with_contents, floats && !absolute) {
1172 Ok(globs) => matchers.push(ExcludeMatcher {
1173 pattern,
1174 globs,
1175 absolute,
1176 }),
1177 Err(e) => invalid.push((pattern, e.to_string())),
1178 }
1179 }
1180 }
1181 let aliases = PathAliases::new(matchers.iter().map(|matcher| matcher.pattern.as_str()));
1182 Self {
1183 matchers,
1184 aliases,
1185 invalid,
1186 }
1187 }
1188
1189 pub fn is_empty(&self) -> bool {
1190 self.matchers.is_empty()
1191 }
1192
1193 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1195 self.matchers
1196 .iter()
1197 .find(|matcher| matcher.globs.is_match(relative_path))
1198 .map(|matcher| matcher.pattern.as_str())
1199 }
1200
1201 fn matched_absolute_pattern(&self, absolute_path: &str) -> Option<&str> {
1203 self.matchers
1204 .iter()
1205 .find(|matcher| matcher.absolute && matcher.globs.is_match(absolute_path))
1206 .map(|matcher| matcher.pattern.as_str())
1207 }
1208
1209 pub fn is_match(&self, relative_path: &str) -> bool {
1210 self.matched_pattern(relative_path).is_some()
1211 }
1212
1213 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1240 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1241 return Some(pattern);
1242 }
1243 if !self.matchers.iter().any(|matcher| matcher.absolute) {
1246 return None;
1247 }
1248 let resolved = resolve_for_matching(absolute);
1249 if let Some(pattern) = self.matched_absolute_pattern(&normalize_pattern_separators(resolved.to_string_lossy()))
1250 {
1251 return Some(pattern);
1252 }
1253 if absolute.has_root()
1257 && resolved != absolute
1258 && let Some(pattern) =
1259 self.matched_absolute_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
1260 {
1261 return Some(pattern);
1262 }
1263 self.aliases
1264 .spellings_of(&resolved)
1265 .into_iter()
1266 .find_map(|alias| self.matched_absolute_pattern(&alias))
1267 }
1268
1269 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1271 self.matched_pattern_for_file(relative, absolute).is_some()
1272 }
1273}
1274
1275pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1288 let canonical_base = canonicalize_for_matching(base)?;
1289 let resolved_path = resolve_for_matching(path);
1290 resolved_path.strip_prefix(&canonical_base).ok().map(|rel| {
1291 let rel = rel.to_string_lossy();
1292 if cfg!(windows) {
1293 rel.replace('\\', "/")
1294 } else {
1295 rel.to_string()
1296 }
1297 })
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303 use std::fs;
1304 use tempfile::tempdir;
1305
1306 #[test]
1307 fn markdown_extensions_match_case_insensitively() {
1308 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
1309 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
1310 }
1311 for ext in ["rs", "txt", "mdq", ""] {
1312 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
1313 }
1314 assert!(has_markdown_extension(Path::new("a/b/README.md")));
1315 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
1316 assert!(!has_markdown_extension(Path::new("no_extension")));
1317 assert!(!has_markdown_extension(Path::new("lib.rs")));
1318 }
1319
1320 #[test]
1321 fn lintable_selector_makes_adapter_capabilities_explicit() {
1322 let dir = tempdir().unwrap();
1323 let root = dir.path();
1324 fs::create_dir_all(root.join("docs")).unwrap();
1325 fs::create_dir_all(root.join("templates")).unwrap();
1326 fs::create_dir_all(root.join("src")).unwrap();
1327 for relative in [
1328 "docs/guide.md",
1329 "docs/notes.txt",
1330 "templates/page.md.jinja",
1331 "src/lib.rs",
1332 "src/upper.RS",
1333 ] {
1334 fs::write(root.join(relative), "content\n").unwrap();
1335 }
1336 let includes = vec![
1337 "docs/**".to_string(),
1338 "templates/**/*.md.jinja".to_string(),
1339 "src/**/*.rs".to_string(),
1340 ];
1341
1342 let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
1343 assert!(markdown.keeps(&root.join("docs/guide.md")));
1344 assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
1345 assert!(!markdown.keeps(&root.join("docs/notes.txt")));
1346 assert!(
1347 !markdown.keeps(&root.join("src/lib.rs")),
1348 "an LSP must not parse a complete Rust source file as Markdown"
1349 );
1350
1351 let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
1352 assert!(rustdoc.keeps(&root.join("src/lib.rs")));
1353 assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
1354 assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
1355
1356 let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
1357 assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
1358 }
1359
1360 #[test]
1361 fn workspace_scan_rejects_explicit_rust_includes() {
1362 let dir = tempdir().unwrap();
1363 let root = dir.path().to_path_buf();
1364 fs::create_dir(root.join("src")).unwrap();
1365 fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
1366 fs::write(root.join("README.md"), "# Readme\n").unwrap();
1367
1368 let options = MarkdownWalkOptions {
1369 respect_gitignore: false,
1370 skip_vendor_dirs: true,
1371 };
1372 let includes = vec!["src/**/*.rs".to_string()];
1373 let excludes = ExcludeMatchers::new(&[]);
1374 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1375
1376 assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
1377 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
1378 }
1379
1380 #[test]
1381 fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
1382 let dir = tempdir().unwrap();
1383 let root = dir.path().to_path_buf();
1384 fs::create_dir(root.join("docs")).unwrap();
1385 fs::create_dir(root.join("templates")).unwrap();
1386 fs::write(root.join("README.md"), "# Root\n").unwrap();
1387 fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
1388 fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
1389 fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
1390
1391 let options = MarkdownWalkOptions {
1392 respect_gitignore: false,
1393 skip_vendor_dirs: true,
1394 };
1395 let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
1396 let excludes = ExcludeMatchers::new(&[]);
1397 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1398
1399 let names: Vec<String> = scan
1402 .collect(std::slice::from_ref(&root))
1403 .iter()
1404 .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
1405 .collect();
1406 assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
1407
1408 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
1409 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1410 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
1411 }
1412
1413 #[test]
1414 fn workspace_scan_does_not_prune_a_vendor_named_root() {
1415 let dir = tempdir().unwrap();
1416 let root = dir.path().join("target");
1417 fs::create_dir(&root).unwrap();
1418 fs::write(root.join("README.md"), "# Root\n").unwrap();
1419 fs::create_dir(root.join("target")).unwrap();
1420 fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
1421
1422 let options = MarkdownWalkOptions {
1423 respect_gitignore: false,
1424 skip_vendor_dirs: true,
1425 };
1426 let excludes = ExcludeMatchers::new(&[]);
1427 let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
1428
1429 assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
1430 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1431 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
1432 }
1433
1434 #[test]
1435 fn the_type_glob_selects_exactly_what_counts_as_markdown() {
1436 assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
1437
1438 let mut builder = globset::GlobSetBuilder::new();
1441 for ext in MARKDOWN_EXTENSIONS {
1442 builder.add(
1443 globset::GlobBuilder::new(&any_case_extension_glob(ext))
1444 .literal_separator(true)
1445 .build()
1446 .unwrap(),
1447 );
1448 }
1449 let globs = builder.build().unwrap();
1450
1451 for ext in MARKDOWN_EXTENSIONS {
1452 for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
1453 let name = format!("README.{spelling}");
1454 assert!(
1455 globs.is_match(&name),
1456 "{name} is markdown by extension but no type glob selects it"
1457 );
1458 assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
1459 }
1460 }
1461
1462 for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
1464 assert!(!globs.is_match(name), "{name} should not be selected");
1465 }
1466 }
1467
1468 fn capitalize(ext: &str) -> String {
1469 let mut chars = ext.chars();
1470 match chars.next() {
1471 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1472 None => String::new(),
1473 }
1474 }
1475
1476 #[test]
1477 fn walk_includes_hidden_files() {
1478 let temp = tempdir().unwrap();
1479 fs::create_dir_all(temp.path().join(".github")).unwrap();
1480 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
1481 fs::write(temp.path().join("README.md"), "# hi").unwrap();
1482
1483 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1484 .build()
1485 .flatten()
1486 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1487 .map(|e| e.path().to_path_buf())
1488 .collect();
1489 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
1490 assert!(files.iter().any(|p| p.ends_with("README.md")));
1491 }
1492
1493 #[test]
1494 fn walk_honors_gitignore_when_enabled_only() {
1495 let temp = tempdir().unwrap();
1496 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
1497 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
1498 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1499
1500 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
1501 markdown_walk_builder(
1502 temp.path(),
1503 &MarkdownWalkOptions {
1504 respect_gitignore: respect,
1505 ..Default::default()
1506 },
1507 )
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 };
1514
1515 let respected = walk(true);
1516 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
1517 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
1518
1519 let unrespected = walk(false);
1520 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
1521 }
1522
1523 #[test]
1524 fn a_gitignore_above_the_repository_root_stays_outside_it() {
1525 let temp = tempdir().unwrap();
1526 fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
1527 let repo = temp.path().join("repo");
1528 fs::create_dir_all(repo.join(".git")).unwrap();
1529 fs::write(repo.join("kept.md"), "# hi").unwrap();
1530
1531 let walk = |root: &Path| -> Vec<std::path::PathBuf> {
1532 markdown_walk_builder(root, &MarkdownWalkOptions::default())
1533 .build()
1534 .flatten()
1535 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1536 .map(|e| e.path().to_path_buf())
1537 .collect()
1538 };
1539
1540 assert!(
1541 walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1542 "git reads no gitignore above the repository root, so neither does the walk"
1543 );
1544
1545 fs::remove_dir(repo.join(".git")).unwrap();
1548 assert!(
1549 !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1550 "with no repository to bound it, the walk keeps reading upward"
1551 );
1552 }
1553
1554 #[test]
1555 fn the_repository_boundary_needs_every_root_to_have_one() {
1556 let temp = tempdir().unwrap();
1557 let inside = temp.path().join("repo/docs");
1558 fs::create_dir_all(&inside).unwrap();
1559 fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
1560 let outside = temp.path().join("plain");
1561 fs::create_dir_all(&outside).unwrap();
1562
1563 assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
1564 assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
1565
1566 assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
1570 assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
1571
1572 let worktree = temp.path().join("worktree");
1575 fs::create_dir_all(&worktree).unwrap();
1576 fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1577 assert!(stops_at_repository_root(&[&worktree]));
1578 }
1579
1580 #[test]
1581 fn walk_honors_markdownlintignore() {
1582 let temp = tempdir().unwrap();
1583 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
1584 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
1585 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1586
1587 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1588 .build()
1589 .flatten()
1590 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1591 .map(|e| e.path().to_path_buf())
1592 .collect();
1593 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
1594 assert!(files.iter().any(|p| p.ends_with("kept.md")));
1595 }
1596
1597 #[test]
1598 fn vendor_dirs_skipped_only_when_requested() {
1599 let temp = tempdir().unwrap();
1600 for dir in ["node_modules", "target", "src"] {
1601 fs::create_dir_all(temp.path().join(dir)).unwrap();
1602 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
1603 }
1604
1605 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
1606 markdown_walk_builder(
1607 temp.path(),
1608 &MarkdownWalkOptions {
1609 skip_vendor_dirs: skip,
1610 respect_gitignore: false,
1614 },
1615 )
1616 .build()
1617 .flatten()
1618 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1619 .map(|e| e.path().to_path_buf())
1620 .collect()
1621 };
1622
1623 let skipped = walk(true);
1624 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1625 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
1626 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
1627
1628 let unskipped = walk(false);
1629 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1630 }
1631
1632 #[test]
1633 fn explicit_file_name_glob_extracts_literal_extensions() {
1634 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
1635 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
1636 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
1637 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
1638 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
1639 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
1640 }
1641
1642 #[test]
1643 fn explicit_file_name_glob_rejects_unpinned_patterns() {
1644 for pattern in [
1645 "docs/",
1646 "docs/**",
1647 "docs",
1648 "*",
1649 "**",
1650 "**/*",
1651 "*.*",
1652 "*.md*",
1653 "*.{md,jinja}",
1654 "*.md?",
1655 "data.[ch]",
1656 "!drafts/*.md.jinja",
1657 "",
1658 "**/Makefile",
1659 "*.",
1660 ] {
1661 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
1662 }
1663 }
1664
1665 #[test]
1666 fn explicit_include_matchers_match_full_relative_paths() {
1667 let matchers = ExplicitIncludeMatchers::new(&[
1668 "**/*.md.jinja".to_string(),
1669 "docs/**".to_string(),
1670 "templates/NOTES.tmpl".to_string(),
1671 ]);
1672 assert!(!matchers.is_empty());
1673 assert!(matchers.matches_relative_path("test.md.jinja"));
1674 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
1675 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
1676 assert!(!matchers.matches_relative_path("docs/anything.txt"));
1678 assert!(!matchers.matches_relative_path("test.jinja"));
1679 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
1682 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
1683
1684 let globs: Vec<_> = matchers.file_name_globs().collect();
1685 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
1686 }
1687
1688 #[test]
1689 fn explicit_include_matchers_follow_gitignore_anchoring() {
1690 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
1692 assert!(unanchored.matches_relative_path("test.md.jinja"));
1693 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
1694
1695 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
1697 assert!(anchored.matches_relative_path("docs/a.txt"));
1698 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
1699 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
1700
1701 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
1703 assert!(rooted.matches_relative_path("NOTES.tmpl"));
1704 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
1705 }
1706
1707 #[test]
1708 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
1709 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
1710 assert!(matchers.is_empty());
1711 assert!(!matchers.matches_relative_path("x.md.jinja"));
1712 }
1713
1714 #[test]
1715 fn explicit_include_matchers_skip_invalid_globs() {
1716 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
1719 assert!(matchers.matches_relative_path("ok.md.jinja"));
1720 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
1721 }
1722
1723 #[test]
1724 fn exclude_matchers_expand_directory_patterns() {
1725 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
1726 assert!(matchers.is_match("drafts"));
1727 assert!(
1728 matchers.is_match("drafts/inner.md"),
1729 "directory pattern must match contents"
1730 );
1731 assert!(matchers.is_match("note.tmp.md"));
1732 assert!(!matchers.is_match("docs/guide.md"));
1733 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
1734 assert!(matchers.invalid.is_empty());
1735 }
1736
1737 #[test]
1738 fn exclude_matchers_float_a_pattern_without_a_slash_to_any_depth() {
1739 let matchers =
1740 ExcludeMatchers::new(&["node_modules".to_string(), "build/".to_string(), "notes.md".to_string()]);
1741 for path in [
1742 "node_modules/readme.md",
1743 "packages/app/node_modules/readme.md",
1744 "build/out.md",
1745 "site/build/out.md",
1746 "notes.md",
1747 "docs/notes.md",
1748 ] {
1749 assert!(matchers.is_match(path), "{path} must be excluded");
1750 }
1751 for path in ["node_modules.md", "docs/my-notes.md", "rebuild/out.md", "docs/guide.md"] {
1752 assert!(!matchers.is_match(path), "{path} must not be excluded");
1753 }
1754 assert_eq!(
1756 matchers.matched_pattern("packages/app/node_modules/readme.md"),
1757 Some("node_modules/**")
1758 );
1759 }
1760
1761 #[test]
1762 fn exclude_matchers_keep_a_pattern_containing_a_slash_anchored() {
1763 let matchers = ExcludeMatchers::new(&["docs/generated".to_string(), "sub/*.md".to_string()]);
1764 assert!(matchers.is_match("docs/generated/api.md"));
1765 assert!(!matchers.is_match("site/docs/generated/api.md"));
1766 assert!(matchers.is_match("sub/a.md"));
1767 assert!(!matchers.is_match("other/sub/a.md"));
1768 }
1769
1770 #[test]
1771 fn exclude_matchers_exclude_the_contents_of_a_directory_a_wildcard_matches() {
1772 let matchers = ExcludeMatchers::new(&["draft?".to_string(), "sub/[gh]en".to_string()]);
1773 assert!(matchers.is_match("drafts/note.md"));
1774 assert!(
1775 matchers.is_match("blog/drafts/note.md"),
1776 "a slashless wildcard floats too"
1777 );
1778 assert!(matchers.is_match("sub/gen/x.md"));
1779 assert!(matchers.is_match("sub/hen/deep/x.md"));
1780 assert!(!matchers.is_match("sub/pen/x.md"));
1781 assert!(!matchers.is_match("other/sub/gen/x.md"));
1782 }
1783
1784 #[test]
1785 fn exclude_matchers_report_an_invalid_pattern_once() {
1786 let matchers = ExcludeMatchers::new(&["[".to_string(), "docs".to_string()]);
1787 assert_eq!(matchers.invalid.len(), 1, "{:?}", matchers.invalid);
1788 assert!(matchers.is_match("docs/a.md"));
1789 }
1790
1791 #[test]
1792 fn expand_home_prefix_expands_only_a_leading_tilde() {
1793 let home = Path::new("/home/dev");
1794 assert_eq!(
1795 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
1796 "/home/dev/.cursor/plans"
1797 );
1798 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
1799 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
1800 }
1801
1802 #[test]
1803 fn expand_home_prefix_leaves_interior_tildes_alone() {
1804 let home = Path::new("/home/dev");
1805 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
1807 assert_eq!(
1808 expand_home_prefix_impl(pattern, Some(home)),
1809 pattern,
1810 "{pattern:?} must be left as written"
1811 );
1812 }
1813 }
1814
1815 #[test]
1816 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
1817 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
1818 }
1819
1820 #[test]
1821 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
1822 let temp = tempdir().unwrap();
1823 let base = canonicalize_for_matching(temp.path()).unwrap();
1826 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
1827 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
1828 }
1829
1830 #[test]
1831 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
1832 let temp = tempdir().unwrap();
1835 let canonical = canonicalize_for_matching(temp.path()).unwrap();
1836 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
1837 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
1838 }
1839
1840 #[test]
1841 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
1842 let temp = tempdir().unwrap();
1843 let base = canonicalize_for_matching(temp.path()).unwrap();
1844 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
1846 assert_eq!(
1849 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
1850 "/somewhere/else/**"
1851 );
1852 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
1854 }
1855
1856 #[test]
1857 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
1858 assert_eq!(
1861 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1862 r"C:\Users\dev\AppData\Local\Temp\x"
1863 );
1864 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1865 assert_eq!(
1867 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1868 r"\\server\share\docs"
1869 );
1870 }
1871
1872 #[test]
1873 fn strip_verbatim_prefix_unwraps_the_display_form() {
1874 assert_eq!(
1876 strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
1877 "C:/Users/dev/AppData/Local/Temp/x"
1878 );
1879 assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
1880 assert_eq!(
1881 strip_verbatim_prefix("//?/UNC/server/share/docs"),
1882 "//server/share/docs"
1883 );
1884 }
1885
1886 #[test]
1887 fn strip_verbatim_prefix_leaves_other_paths_alone() {
1888 for path in [
1889 "/home/dev/docs",
1890 r"C:\Users\dev",
1891 "C:/Users/dev",
1892 r"\\server\share",
1893 "//server/share",
1894 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1896 "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
1897 r"\\?\",
1898 "//?/",
1899 r"\\?/C:/Users/dev",
1901 "",
1902 ] {
1903 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1904 }
1905 }
1906
1907 #[test]
1908 fn expand_directory_pattern_expands_a_literal_final_component() {
1909 assert_eq!(
1912 expand_directory_pattern("**/.cursor/plans"),
1913 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1914 );
1915 assert_eq!(
1916 expand_directory_pattern("docs/**/drafts"),
1917 vec!["docs/**/drafts", "docs/**/drafts/**"]
1918 );
1919 assert_eq!(
1921 expand_directory_pattern("logs/{a,b}"),
1922 vec!["logs/{a,b}", "logs/{a,b}/**"]
1923 );
1924 }
1925
1926 #[test]
1927 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1928 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1931 assert_eq!(
1932 expand_directory_pattern(pattern),
1933 vec![pattern.to_string()],
1934 "{pattern:?} must not gain a contents-expansion"
1935 );
1936 }
1937 }
1938
1939 #[test]
1940 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1941 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1942 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1943 assert!(
1944 matchers.excludes_file(None, excluded),
1945 "an absolute pattern must match the absolute path when there is no relative form"
1946 );
1947 assert_eq!(
1948 matchers.matched_pattern_for_file(None, excluded),
1949 Some("/home/dev/.cursor/plans/**")
1950 );
1951 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1954 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1955 }
1956
1957 #[test]
1958 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1959 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1960 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1961 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1962
1963 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "/unrelated".to_string()]);
1966 let note = Path::new("/home/dev/drafts/site/note.md");
1967 assert_eq!(matchers.matched_pattern_for_file(None, note), None);
1968 assert_eq!(matchers.matched_pattern_for_file(Some("note.md"), note), None);
1969 assert_eq!(
1970 matchers.matched_pattern_for_file(Some("drafts/note.md"), Path::new("/home/dev/site/drafts/note.md")),
1971 Some("drafts/**")
1972 );
1973 assert_eq!(
1974 matchers.matched_pattern_for_file(None, Path::new("/unrelated/note.md")),
1975 Some("/unrelated/**")
1976 );
1977 }
1978
1979 #[test]
1980 fn exclude_matchers_match_a_wildcard_within_one_path_component() {
1981 let matchers = ExcludeMatchers::new(&["sub/a*b".to_string(), "notes/a?b.md".to_string()]);
1982 assert!(matchers.is_match("sub/aXb/y.md"));
1983 assert!(matchers.is_match("sub/ab/y.md"));
1984 assert!(!matchers.is_match("sub/a/keep/b/x.md"));
1985 assert!(matchers.is_match("notes/aXb.md"));
1986 assert!(!matchers.is_match("notes/a/b.md"));
1987
1988 let matchers = ExcludeMatchers::new(&["docs/**/draft.md".to_string()]);
1989 assert!(matchers.is_match("docs/draft.md"));
1990 assert!(matchers.is_match("docs/a/b/draft.md"));
1991 }
1992
1993 #[test]
1994 fn exclude_matchers_report_invalid_patterns() {
1995 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1996 assert_eq!(matchers.invalid.len(), 1);
1997 assert_eq!(matchers.invalid[0].0, "[");
1998 assert!(matchers.is_match("ok.md"));
1999 }
2000
2001 #[test]
2002 fn path_relative_to_strips_through_symlinked_base() {
2003 let temp = tempdir().unwrap();
2004 let base = temp.path().join("base");
2005 fs::create_dir_all(base.join("docs")).unwrap();
2006 fs::write(base.join("docs/a.md"), "# hi").unwrap();
2007
2008 assert_eq!(
2009 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
2010 Some("docs/a.md")
2011 );
2012 assert_eq!(
2013 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
2014 Some("a.md")
2015 );
2016 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
2017 assert_eq!(
2018 path_relative_to(&base.join("docs/new/ghost.md"), &base).as_deref(),
2019 Some("docs/new/ghost.md"),
2020 "a file that does not exist yet relativizes the way it will once written"
2021 );
2022 assert_eq!(
2023 path_relative_to(&base.join("docs/a.md"), &base.join("missing")),
2024 None,
2025 "a base that does not exist contains nothing"
2026 );
2027 }
2028
2029 #[test]
2030 fn resolve_for_matching_spells_a_missing_file_the_way_it_will_canonicalize() {
2031 let temp = tempdir().unwrap();
2032 let root = canonicalize_for_matching(temp.path()).unwrap();
2033 fs::create_dir_all(root.join("docs")).unwrap();
2034 fs::write(root.join("docs").join("x.md"), "# x").unwrap();
2035
2036 let cases = [
2039 ("docs/x.md", root.join("docs").join("x.md")),
2040 ("docs/ghost.md", root.join("docs").join("ghost.md")),
2041 (
2042 "docs/new/dir/ghost.md",
2043 root.join("docs").join("new").join("dir").join("ghost.md"),
2044 ),
2045 ("missing/../docs/./x.md", root.join("docs").join("x.md")),
2046 ("docs/missing/../../top.md", root.join("top.md")),
2047 ];
2048 for (relative, expected) in cases {
2049 assert_eq!(
2050 resolve_for_matching(&temp.path().join(relative)),
2051 expected,
2052 "{relative}"
2053 );
2054 }
2055 }
2056
2057 #[test]
2058 fn resolve_for_matching_takes_a_relative_path_from_the_working_directory() {
2059 let cwd = canonicalize_for_matching(&std::env::current_dir().unwrap()).unwrap();
2060 assert_eq!(
2061 resolve_for_matching(Path::new("no-such-dir-for-matching/ghost.md")),
2062 cwd.join("no-such-dir-for-matching").join("ghost.md")
2063 );
2064 assert_eq!(resolve_for_matching(Path::new("./Cargo.toml")), cwd.join("Cargo.toml"));
2065 }
2066
2067 #[cfg(unix)]
2068 #[test]
2069 fn resolve_for_matching_follows_a_symlink_reached_through_a_missing_directory() {
2070 let temp = tempdir().unwrap();
2071 let root = canonicalize_for_matching(temp.path()).unwrap();
2072 fs::create_dir_all(root.join("real")).unwrap();
2073 std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
2074
2075 assert_eq!(
2076 resolve_for_matching(&root.join("link").join("ghost.md")),
2077 root.join("real").join("ghost.md")
2078 );
2079 assert_eq!(
2082 resolve_for_matching(&root.join("missing").join("..").join("link").join("ghost.md")),
2083 root.join("real").join("ghost.md")
2084 );
2085 }
2086
2087 fn sorted(mut patterns: Vec<String>) -> Vec<String> {
2088 patterns.sort();
2089 patterns
2090 }
2091
2092 #[test]
2093 fn expand_braces_yields_every_alternative() {
2094 assert_eq!(
2095 sorted(expand_braces("/{var/folders,tmp}/**")),
2096 vec!["/tmp/**", "/var/folders/**"]
2097 );
2098 assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
2100 assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
2102 assert_eq!(
2104 sorted(expand_braces("/{a,b}/{c,d}.md")),
2105 vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
2106 );
2107 }
2108
2109 #[test]
2110 fn expand_braces_leaves_patterns_it_cannot_split() {
2111 assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
2113 assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
2115 assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
2117 assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
2119 let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
2121 assert_eq!(expand_braces(wide), vec![wide]);
2122 }
2123
2124 #[test]
2125 fn has_absolute_spelling_sees_past_a_leading_alternation() {
2126 assert!(has_absolute_spelling("/var/folders/**"));
2127 assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
2128 assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
2129 assert!(!has_absolute_spelling("docs/**"));
2130 assert!(!has_absolute_spelling("{docs,notes}/**"));
2131 }
2132
2133 #[test]
2134 fn expand_braces_agrees_with_globset() {
2135 let cases = [
2139 ("/{var/folders,tmp}/**", "/tmp/note.md"),
2140 ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
2141 ("/{var/folders,tmp}/**", "/opt/note.md"),
2142 ("/{a,b}/{c,d}.md", "/b/d.md"),
2143 ("/{a,b}/{c,d}.md", "/b/e.md"),
2144 ("x{,y}", "x"),
2145 ("x{,y}", "xy"),
2146 ("/var/[a,b]/**", "/var/a/n.md"),
2147 ("/var/[a,b]/**", "/var/,/n.md"),
2148 ("/var/folders/**", "/var/folders/n.md"),
2149 ];
2150 for (pattern, path) in cases {
2151 let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
2152 let expanded = expand_braces(pattern)
2153 .iter()
2154 .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
2155 assert_eq!(direct, expanded, "pattern {pattern} against {path}");
2156 }
2157 }
2158
2159 #[test]
2160 fn literal_path_prefix_stops_at_the_first_wildcard() {
2161 assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
2162 assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
2163 assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
2164 assert_eq!(literal_path_prefix("/var/"), Some("/var"));
2165 assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
2166 assert_eq!(literal_path_prefix("/**"), None);
2168 assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
2169 assert_eq!(literal_path_prefix("**/note.md"), None);
2170 let pattern = "/var/folders/**";
2173 let prefix = literal_path_prefix(pattern).unwrap();
2174 assert_eq!(&pattern[prefix.len()..], "/**");
2175 }
2176
2177 #[cfg(unix)]
2181 fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
2182 let real = temp.join("real");
2183 fs::create_dir_all(real.join("notes")).unwrap();
2184 fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
2185 let link = temp.join("link");
2186 std::os::unix::fs::symlink(&real, &link).unwrap();
2187 (canonicalize_for_matching(&real).unwrap(), link)
2188 }
2189
2190 #[cfg(unix)]
2191 #[test]
2192 fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
2193 let temp = tempdir().unwrap();
2194 let (real, link) = symlinked_dir(temp.path());
2195 let pattern = format!("{}/notes/**", link.to_string_lossy());
2196
2197 let aliases = PathAliases::new([pattern.as_str()]);
2198 assert!(!aliases.is_empty());
2199 assert_eq!(
2200 aliases.spellings_of(&real.join("notes/scratch.md")),
2201 vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
2202 );
2203 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2205 assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
2206 assert!(
2207 aliases
2208 .spellings_of(&real.join("notes/scratch.md"))
2209 .iter()
2210 .any(|alias| matcher.is_match(alias))
2211 );
2212 assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
2214 }
2215
2216 #[cfg(unix)]
2217 #[test]
2218 fn path_aliases_reach_through_a_brace_alternation() {
2219 let temp = tempdir().unwrap();
2221 let (real, link) = symlinked_dir(temp.path());
2222 let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
2223
2224 let aliases = PathAliases::new([pattern.as_str()]);
2225 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2226 let note = real.join("notes/scratch.md");
2227 assert!(!matcher.is_match(¬e), "negative control");
2228 assert!(aliases.spellings_of(¬e).iter().any(|alias| matcher.is_match(alias)));
2229 }
2230
2231 #[test]
2232 fn path_aliases_are_empty_without_a_symlinked_prefix() {
2233 let temp = tempdir().unwrap();
2234 let canonical = canonicalize_for_matching(temp.path()).unwrap();
2235 let canonical = canonical.to_string_lossy().replace('\\', "/");
2236 for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
2239 assert!(
2240 PathAliases::new([pattern]).is_empty(),
2241 "pattern {pattern} should record no alias"
2242 );
2243 }
2244 }
2245
2246 #[cfg(unix)]
2247 #[test]
2248 fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
2249 let temp = tempdir().unwrap();
2250 let (real, link) = symlinked_dir(temp.path());
2251 let note = real.join("notes/scratch.md");
2252
2253 let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
2254 assert!(matchers.excludes_file(None, ¬e));
2255
2256 fs::write(real.join("other.md"), "# Other\n").unwrap();
2259 assert!(!matchers.excludes_file(None, &real.join("other.md")));
2260 let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
2261 assert!(!elsewhere.excludes_file(None, ¬e));
2262 }
2263
2264 #[cfg(unix)]
2265 #[test]
2266 fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
2267 let temp = tempdir().unwrap();
2268 let (real, link) = symlinked_dir(temp.path());
2269 let pattern = format!("{}/notes/*.md", link.to_string_lossy());
2270
2271 assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
2272 let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
2275 assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
2276 }
2277
2278 #[test]
2279 fn classify_file_reads_the_whole_name() {
2280 let cases = [
2281 ("README.md", FileKind::Markdown),
2282 ("README.MD", FileKind::Markdown),
2283 ("notes.txt", FileKind::Markdown),
2284 ("LICENSE", FileKind::Markdown),
2285 ("rules.mdc", FileKind::Markdown),
2286 ("page.html", FileKind::Markdown),
2287 ("lib.rs", FileKind::RustDocComments),
2288 ("LIB.RS", FileKind::SourceCode),
2290 ("main.swift", FileKind::SourceCode),
2291 ("Main.KT", FileKind::SourceCode),
2292 ("config.yaml", FileKind::SourceCode),
2293 ("template.md.jinja", FileKind::Markdown),
2294 ("README.md.j2.in", FileKind::Markdown),
2295 ("README.md.swift", FileKind::Markdown),
2296 ("script.litcoffee", FileKind::Markdown),
2297 ("readme.swift.txt", FileKind::Markdown),
2298 (".md.swift", FileKind::SourceCode),
2300 ];
2301 for (name, expected) in cases {
2302 assert_eq!(classify_file(Path::new(name)), expected, "{name}");
2303 assert_eq!(
2304 classify_file(&Path::new("dir.md").join(name)),
2305 expected,
2306 "dir.md/{name}"
2307 );
2308 }
2309 }
2310
2311 #[test]
2312 fn source_code_extensions_are_sorted_and_exclude_document_formats() {
2313 let extensions = crate::linguist_data::SOURCE_CODE_EXTENSIONS;
2314 assert!(extensions.windows(2).all(|pair| pair[0] < pair[1]));
2315 for document in ["md", "txt", "asc", "rst", "html", "mdx", "ipynb", "tex"] {
2316 assert!(!crate::linguist_data::is_source_code_extension(document), "{document}");
2317 }
2318 for source in ["rs", "swift", "kt", "dart", "java", "py", "json", "SWIFT"] {
2319 assert!(crate::linguist_data::is_source_code_extension(source), "{source}");
2320 }
2321 }
2322}