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_pattern = 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 = path_glob(&path_pattern, true).ok()?.compile_matcher();
103 Some(ExplicitInclude {
104 file_name_glob: file_name_glob.to_string(),
105 path_matcher,
106 })
107 })
108 .collect();
109 Self { matchers }
110 }
111
112 pub fn is_empty(&self) -> bool {
113 self.matchers.is_empty()
114 }
115
116 pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
118 self.matchers.iter().map(|m| m.file_name_glob.as_str())
119 }
120
121 pub fn matches_relative_path(&self, path: &str) -> bool {
124 self.matchers.iter().any(|m| m.path_matcher.is_match(path))
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum LintableFileMode {
136 Markdown,
137 MarkdownAndRust,
138 Any,
139}
140
141pub struct LintablePathSelector {
148 base: Option<PathBuf>,
149 explicit: ExplicitIncludeMatchers,
150 mode: LintableFileMode,
151 named: Option<ignore::overrides::Override>,
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 named: None,
164 }
165 }
166
167 #[must_use]
171 pub fn with_named_files(mut self, named: ignore::overrides::Override) -> Self {
172 self.named = Some(named);
173 self
174 }
175
176 pub fn keeps(&self, path: &Path) -> bool {
178 if self.mode == LintableFileMode::Any {
179 return self
180 .named
181 .as_ref()
182 .is_none_or(|named| has_markdown_extension(path) || named.matched(path, false).is_whitelist());
183 }
184 if has_markdown_extension(path) {
185 return true;
186 }
187
188 let is_rust = path.extension().and_then(OsStr::to_str) == Some("rs");
191 if is_rust {
192 return self.mode == LintableFileMode::MarkdownAndRust;
193 }
194
195 match self.base.as_deref().and_then(|base| path_relative_to(path, base)) {
196 Some(relative) => self.explicit.matches_relative_path(&relative),
197 None => self.explicit.matches_relative_path(&path.to_string_lossy()),
200 }
201 }
202
203 pub fn selects_every_file_type(&self) -> bool {
205 self.mode == LintableFileMode::Any
206 }
207
208 pub fn configure_types(&self, builder: &mut ignore::WalkBuilder) -> Result<(), ignore::Error> {
212 if self.mode == LintableFileMode::Any {
213 return Ok(());
214 }
215
216 let mut types = ignore::types::TypesBuilder::new();
217 for extension in MARKDOWN_EXTENSIONS {
221 types.add("markdown", &any_case_extension_glob(extension))?;
222 }
223 types.select("markdown");
224 if self.mode == LintableFileMode::MarkdownAndRust {
225 types.add("rustdoc", "*.rs")?;
226 types.select("rustdoc");
227 }
228 for glob in self.explicit.file_name_globs() {
229 types.add("configinclude", type_filter_glob(glob))?;
230 }
231 if !self.explicit.is_empty() {
232 types.select("configinclude");
233 }
234 builder.types(types.build()?);
235 Ok(())
236 }
237}
238
239fn type_filter_glob(glob: &str) -> &str {
247 if glob.contains('\\') { "*" } else { glob }
248}
249
250pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
252
253#[inline]
256pub fn is_markdown_extension(ext: &OsStr) -> bool {
257 ext.to_str()
258 .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
259}
260
261#[inline]
263pub fn has_markdown_extension(path: &Path) -> bool {
264 path.extension().is_some_and(is_markdown_extension)
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum FileKind {
270 Markdown,
272 RustDocComments,
274 SourceCode,
277}
278
279pub fn classify_file(path: &Path) -> FileKind {
289 if has_markdown_extension(path) {
290 return FileKind::Markdown;
291 }
292 if crate::doc_comment_lint::is_rust_source(path) {
293 return FileKind::RustDocComments;
294 }
295 let Some(extension) = path.extension().and_then(OsStr::to_str) else {
296 return FileKind::Markdown;
297 };
298 if extension.eq_ignore_ascii_case("litcoffee") || has_inner_markdown_extension(path) {
299 return FileKind::Markdown;
300 }
301 if crate::linguist_data::is_source_code_extension(extension) {
302 FileKind::SourceCode
303 } else {
304 FileKind::Markdown
305 }
306}
307
308fn has_inner_markdown_extension(path: &Path) -> bool {
312 let mut stem = path.file_stem();
313 while let Some(current) = stem {
314 let current = Path::new(current);
315 if has_markdown_extension(current) {
316 return true;
317 }
318 stem = current.extension().and(current.file_stem());
319 }
320 false
321}
322
323pub fn any_case_extension_glob(ext: &str) -> String {
331 let mut glob = String::with_capacity(2 + ext.len() * 4);
332 glob.push_str("*.");
333 for ch in ext.chars() {
334 if ch.is_ascii_alphabetic() {
335 glob.push('[');
336 glob.push(ch.to_ascii_lowercase());
337 glob.push(ch.to_ascii_uppercase());
338 glob.push(']');
339 } else {
340 glob.push(ch);
341 }
342 }
343 glob
344}
345
346pub const MARKDOWNLINTIGNORE: &str = ".markdownlintignore";
348
349#[derive(Debug, Clone)]
351pub struct MarkdownWalkOptions {
352 pub respect_gitignore: bool,
355 pub skip_vendor_dirs: bool,
358}
359
360impl Default for MarkdownWalkOptions {
361 fn default() -> Self {
362 Self {
363 respect_gitignore: true,
364 skip_vendor_dirs: false,
365 }
366 }
367}
368
369pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
381 !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
382}
383
384fn in_repository(path: &Path) -> bool {
390 let Ok(absolute) = std::fs::canonicalize(path) else {
391 return false;
392 };
393 absolute
394 .ancestors()
395 .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
396}
397
398pub fn apply_markdown_walk_options<P: AsRef<Path>>(
411 builder: &mut ignore::WalkBuilder,
412 roots: &[P],
413 options: &MarkdownWalkOptions,
414) {
415 apply_walk_options_with_markdownlintignore(builder, roots, options, true);
416}
417
418pub fn apply_walk_options_with_markdownlintignore<P: AsRef<Path>>(
423 builder: &mut ignore::WalkBuilder,
424 roots: &[P],
425 options: &MarkdownWalkOptions,
426 markdownlintignore: bool,
427) {
428 let gitignore = options.respect_gitignore;
429 builder
430 .ignore(gitignore)
431 .git_ignore(gitignore)
432 .git_global(gitignore)
433 .git_exclude(gitignore)
434 .parents(true)
438 .hidden(false)
439 .require_git(stops_at_repository_root(roots));
445 if markdownlintignore {
446 builder.add_custom_ignore_filename(MARKDOWNLINTIGNORE);
447 }
448
449 if options.skip_vendor_dirs {
450 let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
451 builder.filter_entry(move |entry| {
452 if roots.iter().any(|root| root == entry.path()) {
453 return true;
454 }
455 let name = entry.file_name().to_str().unwrap_or("");
456 name != ".git" && name != "node_modules" && name != "target"
457 });
458 }
459}
460
461const VCS_DIRECTORIES: &[&str] = &[".git", ".hg", ".svn", ".jj"];
463
464pub fn skip_version_control_directories(builder: &mut ignore::WalkBuilder) {
473 builder.filter_entry(|entry| {
474 let is_dir = entry.file_type().is_some_and(|file_type| file_type.is_dir());
475 !(is_dir && VCS_DIRECTORIES.contains(&entry.file_name().to_str().unwrap_or("")))
476 });
477}
478
479pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
481 let mut builder = ignore::WalkBuilder::new(root);
482 apply_markdown_walk_options(&mut builder, &[root], options);
483 builder
484}
485
486pub struct MarkdownWorkspaceScan<'a> {
493 options: &'a MarkdownWalkOptions,
494 includes: &'a [String],
495 excludes: &'a ExcludeMatchers,
496}
497
498impl<'a> MarkdownWorkspaceScan<'a> {
499 pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
500 Self {
501 options,
502 includes,
503 excludes,
504 }
505 }
506
507 pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
509 let mut files = Vec::new();
510 for root in roots {
511 let selection = RootSelection::new(root, self.includes);
512 let mut builder = markdown_walk_builder(root, self.options);
513 selection.configure_walk(&mut builder);
514
515 for result in builder.build() {
516 match result {
517 Ok(entry)
518 if entry.file_type().is_some_and(|file_type| file_type.is_file())
519 && selection.is_lintable(entry.path())
520 && !self.excluded(root, entry.path()) =>
521 {
522 files.push(entry.into_path());
523 }
524 Ok(_) => {}
525 Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
526 }
527 }
528 }
529 files.sort();
530 files.dedup();
531 files
532 }
533
534 pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
536 let Some(root) = roots
537 .iter()
538 .filter(|root| path.starts_with(root))
539 .max_by_key(|root| root.components().count())
540 else {
541 return false;
542 };
543
544 let selection = RootSelection::new(root, self.includes);
545 if !selection.selects(path) || self.excluded(root, path) {
546 return true;
547 }
548
549 if self.options.skip_vendor_dirs
550 && let Ok(relative) = path.strip_prefix(root)
551 && relative.components().any(|component| {
552 matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
553 })
554 {
555 return true;
556 }
557
558 let target = path.to_path_buf();
559 let mut builder = markdown_walk_builder(root, self.options);
560 selection.configure_walk(&mut builder);
561 builder.filter_entry(move |entry| target.starts_with(entry.path()));
563 !builder.build().flatten().any(|entry| entry.path() == path)
564 }
565
566 fn excluded(&self, root: &Path, path: &Path) -> bool {
567 self.excludes
568 .excludes_file(path_relative_to(path, root).as_deref(), path)
569 }
570}
571
572struct RootSelection {
573 lintable: LintablePathSelector,
574 overrides: Option<ignore::overrides::Override>,
575}
576
577impl RootSelection {
578 fn new(root: &Path, includes: &[String]) -> Self {
579 let normalized: Vec<String> = includes
580 .iter()
581 .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
582 .collect();
583 let overrides = if normalized.is_empty() {
584 None
585 } else {
586 let mut builder = ignore::overrides::OverrideBuilder::new(root);
587 for pattern in &normalized {
588 for rule in expand_include_directory(pattern) {
589 if let Err(error) = builder.add(&rule) {
590 log::warn!("Invalid include pattern '{pattern}': {error}");
591 break;
592 }
593 }
594 }
595 builder.build().ok()
596 };
597 Self {
598 lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
599 overrides,
600 }
601 }
602
603 fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
604 if let Err(error) = self.lintable.configure_types(builder) {
605 log::warn!("Failed to configure workspace source types: {error}");
606 }
607 if let Some(overrides) = &self.overrides {
608 builder.overrides(overrides.clone());
609 }
610 }
611
612 fn selects(&self, path: &Path) -> bool {
613 self.overrides
614 .as_ref()
615 .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
616 && self.is_lintable(path)
617 }
618
619 fn is_lintable(&self, path: &Path) -> bool {
620 self.lintable.keeps(path)
621 }
622}
623
624pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
644 let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
645 return Cow::Borrowed(path);
646 };
647 let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
648 return Cow::Borrowed(path);
649 };
650 if let Some(share) = rest.strip_prefix("UNC")
653 && share.starts_with(sep)
654 {
655 return Cow::Owned(format!("{sep}{share}"));
656 }
657 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
658 if is_drive_path {
659 Cow::Borrowed(rest)
660 } else {
661 Cow::Borrowed(path)
662 }
663}
664
665pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
673 let canonical = path.canonicalize().ok()?;
674 if !cfg!(windows) {
675 return Some(canonical);
676 }
677 let as_str = canonical.to_string_lossy();
678 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
679}
680
681pub fn resolve_for_matching(path: &Path) -> PathBuf {
692 if let Some(canonical) = canonicalize_for_matching(path) {
693 return canonical;
694 }
695 let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
696 let resolved = resolve_through_existing_ancestor(&absolute);
697 if absolute.components().any(|c| c == std::path::Component::ParentDir) {
701 resolve_through_existing_ancestor(&resolved)
702 } else {
703 resolved
704 }
705}
706
707fn resolve_through_existing_ancestor(absolute: &Path) -> PathBuf {
710 for ancestor in absolute.ancestors() {
711 if let Some(canonical) = canonicalize_for_matching(ancestor) {
712 let remainder = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
713 if remainder.as_os_str().is_empty() {
714 return canonical;
715 }
716 return crate::workspace_index::normalize_relative_path(&canonical.join(remainder));
717 }
718 }
719 crate::workspace_index::normalize_relative_path(absolute)
720}
721
722fn home_dir() -> Option<PathBuf> {
730 crate::config::user_paths::process_home_path().map(|home| canonicalize_for_matching(&home).unwrap_or(home))
731}
732
733pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
747 if !has_home_prefix(pattern) {
750 return Cow::Borrowed(pattern);
751 }
752 expand_home_prefix_impl(pattern, home_dir().as_deref())
753}
754
755fn has_home_prefix(pattern: &str) -> bool {
757 pattern == "~" || pattern.starts_with("~/")
758}
759
760fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
761 let Some(suffix) = (if pattern == "~" {
762 Some("")
763 } else {
764 pattern.strip_prefix("~/")
765 }) else {
766 return Cow::Borrowed(pattern);
767 };
768 let Some(home) = home else {
769 return Cow::Borrowed(pattern);
770 };
771
772 let home = normalize_pattern_separators(home.to_string_lossy());
773 let home = home.trim_end_matches('/');
774 if suffix.is_empty() {
775 Cow::Owned(home.to_string())
776 } else {
777 Cow::Owned(format!("{home}/{suffix}"))
778 }
779}
780
781fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
786 if cfg!(windows) && path.contains('\\') {
787 Cow::Owned(path.replace('\\', "/"))
788 } else {
789 path
790 }
791}
792
793fn spell_pattern(pattern: &str, home: bool, windows: bool) -> Cow<'_, str> {
798 let separated = if windows && pattern.contains('\\') {
799 Cow::Owned(pattern.replace('\\', "/"))
800 } else {
801 Cow::Borrowed(pattern)
802 };
803 let separated = if windows {
805 match strip_verbatim_prefix(&separated) {
806 Cow::Owned(path) => Cow::Owned(path),
807 Cow::Borrowed(path) if path.len() != separated.len() => Cow::Owned(path.to_string()),
808 Cow::Borrowed(_) => separated,
809 }
810 } else {
811 separated
812 };
813 if !home {
814 return separated;
815 }
816 match expand_home_prefix(&separated) {
817 Cow::Owned(expanded) => Cow::Owned(expanded),
818 Cow::Borrowed(_) => separated,
819 }
820}
821
822pub fn prepare_pattern(pattern: &str) -> Cow<'_, str> {
826 prepare_pattern_for(pattern, cfg!(windows))
827}
828
829pub fn prepare_pattern_for(pattern: &str, windows: bool) -> Cow<'_, str> {
832 let spelled = spell_pattern(pattern, true, windows);
833 match normalize_dot_segments(&spelled, windows) {
834 Cow::Owned(normalized) => Cow::Owned(normalized),
835 Cow::Borrowed(_) => spelled,
836 }
837}
838
839fn is_plain_component(component: &str) -> bool {
842 !component.contains(GLOB_METACHARS) && !component.contains('\\')
843}
844
845fn split_components(pattern: &str) -> Option<Vec<&str>> {
851 let bytes = pattern.as_bytes();
852 let mut parts = Vec::new();
853 let mut start = 0;
854 let mut depth = 0usize;
855 let mut in_class = false;
856 let mut class_start = 0;
858 let mut index = 0;
859 while index < bytes.len() {
860 match bytes[index] {
861 b'\\' if !in_class => index += 1,
862 b'[' if !in_class => {
863 in_class = true;
864 class_start = index + 1;
865 if matches!(bytes.get(class_start), Some(b'!' | b'^')) {
866 class_start += 1;
867 }
868 }
869 b']' if in_class && index == class_start => {}
870 b']' if in_class => in_class = false,
871 _ if in_class => {}
872 b'{' => depth += 1,
873 b'}' if depth > 0 => depth -= 1,
874 b'/' if depth == 0 => {
875 parts.push(&pattern[start..index]);
876 start = index + 1;
877 }
878 _ => {}
879 }
880 index += 1;
881 }
882 if in_class || depth != 0 {
883 return None;
884 }
885 parts.push(&pattern[start..]);
886 Some(parts)
887}
888
889fn normalize_dot_segments(pattern: &str, windows: bool) -> Cow<'_, str> {
911 if !(pattern.contains("//") || pattern.split('/').any(|part| part == "." || part == "..")) {
912 return Cow::Borrowed(pattern);
913 }
914 let Some(parts) = split_components(pattern) else {
915 return Cow::Borrowed(pattern);
916 };
917 let count = parts.len();
918 let leading_empty = parts.iter().take_while(|part| part.is_empty()).count();
919 let mut index = 0;
920 let mut lead = "";
921 let mut absolute = false;
922 let mut kept: Vec<&str> = Vec::new();
923 let mut protected = 0;
925 if leading_empty > 0 {
926 absolute = true;
927 let slashes = if leading_empty == count {
928 count - 1
929 } else {
930 leading_empty
931 };
932 index = leading_empty.min(count);
933 lead = if windows && slashes >= 2 { "//" } else { "/" };
934 if lead == "//" {
935 for part in parts.iter().skip(index).take(2).filter(|part| !part.is_empty()) {
937 kept.push(part);
938 index += 1;
939 protected += 1;
940 }
941 }
942 } else if windows
943 && let [letter, b':'] = parts[0].as_bytes()
944 && letter.is_ascii_alphabetic()
945 {
946 absolute = true;
947 kept.push(parts[0]);
948 protected = 1;
949 index = 1;
950 }
951
952 let mut opaque = false;
953 let mut trailing = false;
954 for (position, &part) in parts.iter().enumerate().skip(index) {
955 let last = position + 1 == count;
956 match part {
957 "" => trailing |= last,
958 "." => {}
959 ".." => {
960 let removable = kept.len() > protected
961 && kept
962 .last()
963 .is_some_and(|prev| *prev != ".." && is_plain_component(prev));
964 if removable {
965 if !absolute {
966 kept.pop();
967 } else if opaque {
968 kept.push("..");
969 } else {
970 let prefix = format!("{lead}{}", kept.join("/"));
971 if Path::new(&prefix).exists() {
972 kept.push("..");
973 } else {
974 kept.pop();
975 }
976 }
977 } else if !(absolute && kept.len() == protected) {
978 kept.push("..");
979 }
980 }
981 _ => {
982 opaque |= !is_plain_component(part);
983 kept.push(part);
984 }
985 }
986 }
987
988 if kept.is_empty() && !absolute {
989 return Cow::Borrowed(pattern);
990 }
991 let mut normalized = format!("{lead}{}", kept.join("/"));
992 let bare_drive = lead.is_empty() && absolute && protected == 1 && kept.len() == 1;
994 if (trailing && !kept.is_empty()) || bare_drive {
995 normalized.push('/');
996 }
997 if normalized == pattern {
998 Cow::Borrowed(pattern)
999 } else {
1000 Cow::Owned(normalized)
1001 }
1002}
1003
1004fn keep_anchored(spelled: &str, normalized: String) -> String {
1012 let floated = !trim_directory_slash(spelled).contains('/');
1013 let directory = trim_directory_slash(&normalized);
1014 if !floated && !directory.is_empty() && !directory.contains('/') {
1015 format!("/{normalized}")
1016 } else {
1017 normalized
1018 }
1019}
1020
1021pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
1040 normalize_pattern_for_base_for(pattern, base, cfg!(windows))
1041}
1042
1043pub fn normalize_pattern_for_base_for(pattern: &str, base: Option<&Path>, windows: bool) -> String {
1052 let (negated, operand) = match pattern.strip_prefix('!') {
1053 Some(operand) => (true, operand),
1054 None => (false, pattern),
1055 };
1056 let spelled = spell_pattern(operand, !negated, windows);
1057 let normalized = match normalize_dot_segments(&spelled, windows) {
1058 Cow::Owned(normalized) if negated || !(spelled.starts_with('#') || normalized.starts_with(['#', '!'])) => {
1059 normalized
1060 }
1061 _ => spelled.to_string(),
1062 };
1063 if negated {
1064 return format!("!{}", keep_anchored(&spelled, normalized));
1065 }
1066 let Some(base) = base else {
1067 return keep_anchored(&spelled, normalized);
1068 };
1069 if !is_absolute_pattern(&normalized) {
1070 return keep_anchored(&spelled, normalized);
1071 }
1072 let prepared = normalized;
1073
1074 if let Some(relative) = strip_base_prefix(&prepared, base) {
1075 return anchor_remainder(resolve_remainder_dots(relative, base).unwrap_or(prepared));
1076 }
1077 if let Some(canonical_pattern) = canonicalize_pattern_prefix(&prepared)
1078 && let Some(relative) = strip_base_prefix(&canonical_pattern, base)
1079 {
1080 return anchor_remainder(resolve_remainder_dots(relative, base).unwrap_or(prepared));
1081 }
1082 prepared
1083}
1084
1085fn anchor_remainder(remainder: String) -> String {
1093 let directory = trim_directory_slash(&remainder);
1094 if directory.is_empty() || directory.contains('/') {
1095 remainder
1096 } else {
1097 format!("/{remainder}")
1098 }
1099}
1100
1101fn resolve_remainder_dots(remainder: &str, base: &Path) -> Option<String> {
1112 if !remainder.split('/').any(|part| part == "..") {
1113 return Some(remainder.to_string());
1114 }
1115 let parts = split_components(remainder)?;
1116 let canonical_base = canonicalize_for_matching(base)?;
1117 let mut kept: Vec<String> = Vec::new();
1118 let mut opaque = false;
1119 let mut trailing = false;
1120 for (position, part) in parts.iter().enumerate() {
1121 match *part {
1122 "" => trailing |= position + 1 == parts.len(),
1123 "." => {}
1124 ".." => {
1125 let removable = !opaque && kept.last().is_some_and(|prev| prev != ".." && is_plain_component(prev));
1126 if removable {
1127 kept = parent_relative_to(&canonical_base, &kept)?;
1128 } else if kept.is_empty() && !opaque {
1129 return None;
1131 } else {
1132 kept.push("..".to_string());
1133 }
1134 }
1135 _ => {
1136 opaque |= !is_plain_component(part);
1137 kept.push((*part).to_string());
1138 }
1139 }
1140 }
1141 let mut resolved = kept.join("/");
1142 if trailing && !kept.is_empty() {
1143 resolved.push('/');
1144 }
1145 Some(resolved)
1146}
1147
1148fn parent_relative_to(canonical_base: &Path, kept: &[String]) -> Option<Vec<String>> {
1152 let full = kept
1153 .iter()
1154 .fold(canonical_base.to_path_buf(), |path, part| path.join(part));
1155 let resolved = full.ancestors().find_map(|ancestor| {
1156 let canonical = canonicalize_for_matching(ancestor)?;
1157 let rest = full.strip_prefix(ancestor).ok()?;
1158 Some(canonical.join(rest))
1159 })?;
1160 let relative = resolved.parent()?.strip_prefix(canonical_base).ok()?;
1161 relative
1162 .components()
1163 .map(|component| {
1164 let name = component.as_os_str().to_str()?;
1165 is_plain_component(name).then(|| name.to_string())
1166 })
1167 .collect()
1168}
1169
1170fn strip_base_prefix<'a>(pattern: &'a str, base: &Path) -> Option<&'a str> {
1174 strip_base_text(pattern, base).or_else(|| {
1175 let canonical = canonicalize_for_matching(base)?;
1176 strip_base_text(pattern, &canonical)
1177 })
1178}
1179
1180fn strip_base_text<'a>(pattern: &'a str, base: &Path) -> Option<&'a str> {
1188 let boundaries = pattern
1189 .match_indices('/')
1190 .map(|(index, _)| index)
1191 .chain(std::iter::once(pattern.len()));
1192 for end in boundaries {
1193 let rest = if Path::new(&pattern[..end]) == base {
1195 &pattern[end..]
1196 } else if end < pattern.len() && Path::new(&pattern[..=end]) == base {
1197 &pattern[end + 1..]
1198 } else {
1199 continue;
1200 };
1201 return Some(trim_relative_remainder(rest));
1202 }
1203 None
1204}
1205
1206fn trim_relative_remainder(mut rest: &str) -> &str {
1209 loop {
1210 if let Some(tail) = rest.strip_prefix('/').or_else(|| rest.strip_prefix("./")) {
1211 rest = tail;
1212 } else if rest == "." {
1213 rest = "";
1214 } else {
1215 break;
1216 }
1217 }
1218 loop {
1219 let trimmed = trim_directory_slash(rest);
1220 let trimmed = match trimmed.strip_suffix("/.") {
1221 Some(head) if trailing_backslashes(head).is_multiple_of(2) => head,
1223 _ => trimmed,
1224 };
1225 if trimmed.len() == rest.len() {
1226 return rest;
1227 }
1228 rest = trimmed;
1229 }
1230}
1231
1232fn trailing_backslashes(text: &str) -> usize {
1233 text.bytes().rev().take_while(|&byte| byte == b'\\').count()
1234}
1235
1236fn trim_directory_slash(pattern: &str) -> &str {
1246 let trimmed = pattern.trim_end_matches('/');
1247 match pattern.len() - trimmed.len() {
1248 0 => pattern,
1249 1 => trimmed.strip_suffix('\\').unwrap_or(trimmed),
1250 _ => trimmed,
1251 }
1252}
1253
1254pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
1269 expand_directory_pattern_for(pattern, cfg!(windows))
1270}
1271
1272pub fn expand_directory_pattern_for(pattern: &str, windows: bool) -> Vec<String> {
1274 let expanded = expand_exclude(pattern, windows);
1275 if expanded.walker_anchor {
1276 expanded.entries.iter().map(|entry| format!("/{entry}")).collect()
1277 } else {
1278 expanded.entries
1279 }
1280}
1281
1282pub fn expand_include_directory(pattern: &str) -> Vec<String> {
1298 if pattern.trim().is_empty() || pattern.starts_with(['!', '#']) {
1301 return vec![pattern.to_string()];
1302 }
1303 let final_component = trim_directory_slash(pattern).rsplit('/').next().unwrap_or_default();
1306 if final_component.contains('{') {
1307 return vec![pattern.to_string()];
1308 }
1309 let mut rules = directory_entries(pattern);
1310 if let [directory, contents] = rules.as_mut_slice() {
1311 if !directory.contains('/') {
1312 *contents = format!("**/{directory}/**");
1313 }
1314 *directory = pattern.to_string();
1317 }
1318 rules
1319}
1320
1321struct ExpandedExclude {
1323 entries: Vec<String>,
1326 floats: bool,
1330 walker_anchor: bool,
1333}
1334
1335fn expand_exclude(pattern: &str, windows: bool) -> ExpandedExclude {
1336 let spelled = spell_pattern(pattern, true, windows);
1337 let floats = !trim_directory_slash(&spelled).contains('/');
1338 let prepared = match normalize_dot_segments(&spelled, windows) {
1339 Cow::Owned(normalized) => Cow::Owned(normalized),
1340 Cow::Borrowed(_) => spelled,
1341 };
1342 let directory = trim_directory_slash(&prepared);
1343 let walker_anchor = !floats && !is_absolute_pattern(&prepared) && !directory.is_empty() && !directory.contains('/');
1344 ExpandedExclude {
1345 entries: directory_entries(&prepared),
1346 floats,
1347 walker_anchor,
1348 }
1349}
1350
1351fn directory_entries(pattern: &str) -> Vec<String> {
1352 let base = trim_directory_slash(pattern);
1353 let final_component = base.rsplit('/').next().unwrap_or(base);
1354
1355 let dangling_escape = trailing_backslashes(base) % 2 == 1;
1358 if final_component.is_empty() || final_component.contains(['*', '?', '[']) || dangling_escape {
1359 return vec![pattern.to_string()];
1360 }
1361
1362 vec![
1363 base.to_string(), format!("{base}/**"), ]
1366}
1367
1368pub const EXCLUDE_NEGATION_UNSUPPORTED: &str = "a leading '!' does not negate in exclude and is not supported; \
1371move both the broad exclusion and its exception into .markdownlintignore, which supports '!' re-inclusion";
1372
1373pub fn is_exclude_negation(pattern: &str) -> bool {
1382 pattern.starts_with('!')
1383}
1384
1385pub fn exclude_override_rule(pattern: &str) -> String {
1391 format!("!{pattern}")
1392}
1393
1394pub fn exclude_pattern_compiles(pattern: &str) -> bool {
1402 if is_exclude_negation(pattern) {
1403 return false;
1404 }
1405 expand_directory_pattern(pattern).iter().all(|expanded| {
1406 exclude_glob(expanded).is_ok()
1407 && ignore::overrides::OverrideBuilder::new(Path::new("."))
1408 .add(&exclude_override_rule(expanded))
1409 .is_ok()
1410 })
1411}
1412
1413pub fn include_pattern_compiles(pattern: &str) -> bool {
1422 ignore::overrides::OverrideBuilder::new(Path::new("."))
1423 .add(&prepare_pattern(pattern))
1424 .is_ok()
1425}
1426
1427fn exclusion_globs(pattern: &str, with_contents: bool, floats: bool, windows: bool) -> Result<GlobSet, globset::Error> {
1432 let mut located = vec![pattern.to_string()];
1433 let directory = trim_directory_slash(pattern);
1436 if directory.len() != pattern.len() {
1437 path_glob_for(directory, true, windows)?;
1438 }
1439 if with_contents && !pattern.ends_with("/**") {
1440 located.push(format!("{directory}/**"));
1441 }
1442 if floats {
1443 let anywhere: Vec<String> = located.iter().map(|glob| format!("**/{glob}")).collect();
1444 located.extend(anywhere);
1445 }
1446 let mut builder = GlobSetBuilder::new();
1447 for glob in &located {
1448 builder.add(path_glob_for(glob, true, windows)?);
1449 }
1450 builder.build()
1451}
1452
1453fn exclude_glob(glob: &str) -> Result<Glob, globset::Error> {
1456 path_glob(glob, true)
1457}
1458
1459pub(crate) fn path_glob(pattern: &str, literal_separator: bool) -> Result<Glob, globset::Error> {
1470 path_glob_for(pattern, literal_separator, cfg!(windows))
1471}
1472
1473pub(crate) fn path_glob_for(pattern: &str, literal_separator: bool, windows: bool) -> Result<Glob, globset::Error> {
1477 GlobBuilder::new(&spell_pattern(pattern, false, windows))
1478 .literal_separator(literal_separator)
1479 .backslash_escape(true)
1480 .build()
1481}
1482
1483struct ExcludeMatcher {
1485 pattern: String,
1486 globs: GlobSet,
1487 absolute: bool,
1490}
1491
1492pub struct ExcludeMatchers {
1499 matchers: Vec<ExcludeMatcher>,
1501 aliases: PathAliases,
1503 pub invalid: Vec<(String, String)>,
1506}
1507
1508pub fn is_absolute_pattern(pattern: &str) -> bool {
1512 pattern.starts_with('/') || Path::new(pattern).is_absolute()
1513}
1514
1515pub fn has_absolute_spelling(pattern: &str) -> bool {
1522 if is_absolute_pattern(pattern) {
1523 return true;
1524 }
1525 pattern.contains('{')
1526 && expand_braces(pattern)
1527 .iter()
1528 .any(|spelling| is_absolute_pattern(spelling))
1529}
1530
1531const MAX_BRACE_EXPANSIONS: usize = 64;
1538
1539fn expand_braces(pattern: &str) -> Vec<String> {
1546 let mut pending = vec![pattern.to_string()];
1547 let mut expanded: Vec<String> = Vec::new();
1548 while let Some(current) = pending.pop() {
1549 let Some((prefix, alternatives, suffix)) = split_first_alternation(¤t) else {
1550 expanded.push(current);
1551 continue;
1552 };
1553 if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
1554 return vec![pattern.to_string()];
1555 }
1556 for alternative in alternatives {
1557 pending.push(format!("{prefix}{alternative}{suffix}"));
1558 }
1559 }
1560 expanded
1561}
1562
1563fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
1570 let bytes = pattern.as_bytes();
1571 let mut open = None;
1572 let mut depth = 0usize;
1573 let mut in_class = false;
1574 let mut class_start = 0;
1576 let mut alternatives = Vec::new();
1577 let mut alternative_start = 0;
1578 let mut index = 0;
1579 while index < bytes.len() {
1580 match bytes[index] {
1581 b'\\' if !in_class => index += 1,
1582 b'[' if !in_class => {
1583 in_class = true;
1584 class_start = index + 1;
1585 if matches!(bytes.get(class_start), Some(b'!' | b'^')) {
1586 class_start += 1;
1587 }
1588 }
1589 b']' if in_class && index == class_start => {}
1590 b']' if in_class => in_class = false,
1591 _ if in_class => {}
1592 b'{' => {
1593 depth += 1;
1594 if depth == 1 {
1595 open = Some(index);
1596 alternative_start = index + 1;
1597 }
1598 }
1599 b',' if depth == 1 => {
1600 alternatives.push(&pattern[alternative_start..index]);
1601 alternative_start = index + 1;
1602 }
1603 b'}' if depth > 0 => {
1604 depth -= 1;
1605 if depth == 0 {
1606 alternatives.push(&pattern[alternative_start..index]);
1607 alternatives.retain(|alternative| !alternative.is_empty());
1608 if alternatives.is_empty() {
1609 return None;
1610 }
1611 return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
1612 }
1613 }
1614 _ => {}
1615 }
1616 index += 1;
1617 }
1618 None
1619}
1620
1621fn literal_path_prefix(pattern: &str) -> Option<&str> {
1632 let mut end = 0;
1633 let mut saw_component = false;
1634 for component in pattern.split('/') {
1635 if component.contains(GLOB_METACHARS) || component.contains('\\') {
1638 break;
1639 }
1640 saw_component |= !component.is_empty();
1641 end += component.len() + 1;
1643 }
1644 if !saw_component {
1645 return None;
1646 }
1647 let prefix = &pattern[..(end - 1).min(pattern.len())];
1651 let prefix = prefix.strip_suffix('/').unwrap_or(prefix);
1652 let bare_drive = matches!(prefix.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic());
1655 (!bare_drive).then_some(prefix)
1656}
1657
1658fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
1662 let prefix = literal_path_prefix(pattern)?;
1663 let canonical = canonicalize_for_matching(Path::new(prefix))?;
1664 let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
1665 if canonical == prefix {
1666 return None;
1667 }
1668 Some(format!("{canonical}{}", &pattern[prefix.len()..]))
1669}
1670
1671#[derive(Debug, Default)]
1688pub struct PathAliases {
1689 prefixes: Vec<(PathBuf, String)>,
1691}
1692
1693impl PathAliases {
1694 pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
1699 let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
1700 for pattern in patterns {
1701 let pattern = prepare_pattern(pattern);
1702 if !has_absolute_spelling(&pattern) {
1703 continue;
1704 }
1705 for spelling in expand_braces(&pattern) {
1706 let Some(as_written) = literal_path_prefix(&spelling) else {
1707 continue;
1708 };
1709 if !is_absolute_pattern(as_written) {
1710 continue;
1711 }
1712 let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
1713 continue;
1714 };
1715 if canonical == Path::new(as_written) {
1716 continue;
1717 }
1718 let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
1719 if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
1720 prefixes.push((canonical, as_written));
1721 }
1722 }
1723 }
1724 Self { prefixes }
1725 }
1726
1727 pub fn is_empty(&self) -> bool {
1728 self.prefixes.is_empty()
1729 }
1730
1731 pub fn spellings_of(&self, path: &Path) -> Vec<String> {
1735 self.prefixes
1736 .iter()
1737 .filter_map(|(canonical, as_written)| {
1738 let rest = path.strip_prefix(canonical).ok()?;
1739 if rest.as_os_str().is_empty() {
1740 return Some(as_written.clone());
1741 }
1742 let rest = normalize_pattern_separators(rest.to_string_lossy());
1743 Some(format!("{as_written}/{rest}"))
1744 })
1745 .collect()
1746 }
1747}
1748
1749impl ExcludeMatchers {
1750 pub fn new(patterns: &[String]) -> Self {
1767 Self::for_platform(patterns, cfg!(windows))
1768 }
1769
1770 pub fn for_platform(patterns: &[String], windows: bool) -> Self {
1772 let mut matchers = Vec::new();
1773 let mut invalid = Vec::new();
1774 for written in patterns {
1775 if is_exclude_negation(written) {
1776 if !invalid.iter().any(|(pattern, _): &(String, String)| pattern == written) {
1778 invalid.push((written.clone(), EXCLUDE_NEGATION_UNSUPPORTED.to_string()));
1779 }
1780 continue;
1781 }
1782 let ExpandedExclude {
1783 entries: expansions,
1784 floats,
1785 ..
1786 } = expand_exclude(written, windows);
1787 let with_contents = expansions.len() == 1;
1791 for pattern in expansions {
1792 let absolute = has_absolute_spelling(&pattern);
1793 match exclusion_globs(&pattern, with_contents, floats && !absolute, windows) {
1794 Ok(globs) => matchers.push(ExcludeMatcher {
1795 pattern,
1796 globs,
1797 absolute,
1798 }),
1799 Err(e) => invalid.push((pattern, e.to_string())),
1800 }
1801 }
1802 }
1803 let aliases = PathAliases::new(matchers.iter().map(|matcher| matcher.pattern.as_str()));
1804 Self {
1805 matchers,
1806 aliases,
1807 invalid,
1808 }
1809 }
1810
1811 pub fn is_empty(&self) -> bool {
1812 self.matchers.is_empty()
1813 }
1814
1815 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1817 self.matchers
1818 .iter()
1819 .find(|matcher| matcher.globs.is_match(relative_path))
1820 .map(|matcher| matcher.pattern.as_str())
1821 }
1822
1823 fn matched_absolute_pattern(&self, absolute_path: &str) -> Option<&str> {
1825 self.matchers
1826 .iter()
1827 .find(|matcher| matcher.absolute && matcher.globs.is_match(absolute_path))
1828 .map(|matcher| matcher.pattern.as_str())
1829 }
1830
1831 pub fn is_match(&self, relative_path: &str) -> bool {
1832 self.matched_pattern(relative_path).is_some()
1833 }
1834
1835 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1862 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1863 return Some(pattern);
1864 }
1865 if !self.matchers.iter().any(|matcher| matcher.absolute) {
1868 return None;
1869 }
1870 let resolved = resolve_for_matching(absolute);
1871 if let Some(pattern) = self.matched_absolute_pattern(&normalize_pattern_separators(resolved.to_string_lossy()))
1872 {
1873 return Some(pattern);
1874 }
1875 if absolute.has_root()
1879 && resolved != absolute
1880 && let Some(pattern) =
1881 self.matched_absolute_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
1882 {
1883 return Some(pattern);
1884 }
1885 self.aliases
1886 .spellings_of(&resolved)
1887 .into_iter()
1888 .find_map(|alias| self.matched_absolute_pattern(&alias))
1889 }
1890
1891 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1893 self.matched_pattern_for_file(relative, absolute).is_some()
1894 }
1895}
1896
1897pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1910 let canonical_base = canonicalize_for_matching(base)?;
1911 let resolved_path = resolve_for_matching(path);
1912 resolved_path.strip_prefix(&canonical_base).ok().map(|rel| {
1913 let rel = rel.to_string_lossy();
1914 if cfg!(windows) {
1915 rel.replace('\\', "/")
1916 } else {
1917 rel.to_string()
1918 }
1919 })
1920}
1921
1922#[cfg(test)]
1923mod tests {
1924 #[test]
1925 fn type_filter_preserves_legacy_final_file_selection() {
1926 use super::*;
1927 let dir = tempfile::tempdir().unwrap();
1928 let root = dir.path();
1929 for name in [
1930 "README.md",
1931 "notes.MD",
1932 "mixed.mArKdOwN",
1933 "page.mdx",
1934 "index.mkd",
1935 "a.mkdn",
1936 "a.mdown",
1937 "a.mdwn",
1938 "a.qmd",
1939 "a.Rmd",
1940 "a.mkdown",
1941 "a.rs",
1942 "a.RS",
1943 "a.json",
1944 "a.tmpl",
1945 "文档.MD",
1946 "no-extension",
1947 ] {
1948 std::fs::write(root.join(name), "body\n").unwrap();
1949 }
1950 for mode in [
1951 LintableFileMode::Markdown,
1952 LintableFileMode::MarkdownAndRust,
1953 LintableFileMode::Any,
1954 ] {
1955 for includes in [vec![], vec!["a.tmpl".to_string()], vec!["*.json".to_string()]] {
1956 let selector = LintablePathSelector::new(Some(root), &includes, mode);
1957 let mut old = ignore::WalkBuilder::new(root);
1958 if mode != LintableFileMode::Any {
1959 let mut types = ignore::types::TypesBuilder::new();
1960 types.add_defaults();
1961 for ext in MARKDOWN_EXTENSIONS {
1962 types.add("markdown", &any_case_extension_glob(ext)).unwrap();
1963 }
1964 types.select("markdown");
1965 if mode == LintableFileMode::MarkdownAndRust {
1966 types.add("rustdoc", "*.rs").unwrap();
1967 types.select("rustdoc");
1968 }
1969 for glob in selector.explicit.file_name_globs() {
1970 types.add("configinclude", type_filter_glob(glob)).unwrap();
1971 }
1972 if !selector.explicit.is_empty() {
1973 types.select("configinclude");
1974 }
1975 old.types(types.build().unwrap());
1976 }
1977 let mut new = ignore::WalkBuilder::new(root);
1978 selector.configure_types(&mut new).unwrap();
1979 let collect = |builder: ignore::WalkBuilder| {
1980 let mut files: Vec<_> = builder
1981 .build()
1982 .map(Result::unwrap)
1983 .filter(|entry| {
1984 entry.file_type().is_some_and(|kind| kind.is_file()) && selector.keeps(entry.path())
1985 })
1986 .map(|entry| entry.path().strip_prefix(root).unwrap().to_path_buf())
1987 .collect();
1988 files.sort();
1989 files
1990 };
1991 assert_eq!(collect(new), collect(old), "{mode:?} {includes:?}");
1992 }
1993 }
1994 }
1995
1996 use super::*;
1997 use std::fs;
1998 use tempfile::tempdir;
1999
2000 #[test]
2001 fn markdown_extensions_match_case_insensitively() {
2002 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
2003 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
2004 }
2005 for ext in ["rs", "txt", "mdq", ""] {
2006 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
2007 }
2008 assert!(has_markdown_extension(Path::new("a/b/README.md")));
2009 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
2010 assert!(!has_markdown_extension(Path::new("no_extension")));
2011 assert!(!has_markdown_extension(Path::new("lib.rs")));
2012 }
2013
2014 #[test]
2015 fn lintable_selector_makes_adapter_capabilities_explicit() {
2016 let dir = tempdir().unwrap();
2017 let root = dir.path();
2018 fs::create_dir_all(root.join("docs")).unwrap();
2019 fs::create_dir_all(root.join("templates")).unwrap();
2020 fs::create_dir_all(root.join("src")).unwrap();
2021 for relative in [
2022 "docs/guide.md",
2023 "docs/notes.txt",
2024 "templates/page.md.jinja",
2025 "src/lib.rs",
2026 "src/upper.RS",
2027 ] {
2028 fs::write(root.join(relative), "content\n").unwrap();
2029 }
2030 let includes = vec![
2031 "docs/**".to_string(),
2032 "templates/**/*.md.jinja".to_string(),
2033 "src/**/*.rs".to_string(),
2034 ];
2035
2036 let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
2037 assert!(markdown.keeps(&root.join("docs/guide.md")));
2038 assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
2039 assert!(!markdown.keeps(&root.join("docs/notes.txt")));
2040 assert!(
2041 !markdown.keeps(&root.join("src/lib.rs")),
2042 "an LSP must not parse a complete Rust source file as Markdown"
2043 );
2044
2045 let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
2046 assert!(rustdoc.keeps(&root.join("src/lib.rs")));
2047 assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
2048 assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
2049
2050 let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
2051 assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
2052 }
2053
2054 #[test]
2055 fn workspace_scan_rejects_explicit_rust_includes() {
2056 let dir = tempdir().unwrap();
2057 let root = dir.path().to_path_buf();
2058 fs::create_dir(root.join("src")).unwrap();
2059 fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
2060 fs::write(root.join("README.md"), "# Readme\n").unwrap();
2061
2062 let options = MarkdownWalkOptions {
2063 respect_gitignore: false,
2064 skip_vendor_dirs: true,
2065 };
2066 let includes = vec!["src/**/*.rs".to_string()];
2067 let excludes = ExcludeMatchers::new(&[]);
2068 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
2069
2070 assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
2071 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
2072 }
2073
2074 #[test]
2075 fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
2076 let dir = tempdir().unwrap();
2077 let root = dir.path().to_path_buf();
2078 fs::create_dir(root.join("docs")).unwrap();
2079 fs::create_dir(root.join("templates")).unwrap();
2080 fs::write(root.join("README.md"), "# Root\n").unwrap();
2081 fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
2082 fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
2083 fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
2084
2085 let options = MarkdownWalkOptions {
2086 respect_gitignore: false,
2087 skip_vendor_dirs: true,
2088 };
2089 let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
2090 let excludes = ExcludeMatchers::new(&[]);
2091 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
2092
2093 let names: Vec<String> = scan
2096 .collect(std::slice::from_ref(&root))
2097 .iter()
2098 .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
2099 .collect();
2100 assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
2101
2102 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
2103 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
2104 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
2105 }
2106
2107 #[test]
2108 fn workspace_scan_selects_the_markdown_beneath_an_included_directory() {
2109 let dir = tempdir().unwrap();
2110 let root = dir.path().to_path_buf();
2111 for path in [
2112 "docs/guide.md",
2113 "docs/sub/deep.md",
2114 "docs/image.png",
2115 "other/docs/x.md",
2116 "README.md",
2117 ] {
2118 fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
2119 fs::write(root.join(path), "# T\n").unwrap();
2120 }
2121 let options = MarkdownWalkOptions {
2122 respect_gitignore: false,
2123 skip_vendor_dirs: true,
2124 };
2125 let excludes = ExcludeMatchers::new(&[]);
2126 let names = |includes: &[&str]| -> Vec<String> {
2127 let includes: Vec<String> = includes.iter().map(ToString::to_string).collect();
2128 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
2129 let mut names: Vec<String> = scan
2130 .collect(std::slice::from_ref(&root))
2131 .iter()
2132 .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
2133 .collect();
2134 names.sort();
2135 names
2136 };
2137
2138 assert_eq!(
2140 names(&["docs"]),
2141 ["docs/guide.md", "docs/sub/deep.md", "other/docs/x.md"]
2142 );
2143 assert_eq!(
2144 names(&["docs/"]),
2145 ["docs/guide.md", "docs/sub/deep.md", "other/docs/x.md"]
2146 );
2147 assert_eq!(names(&["./docs"]), ["docs/guide.md", "docs/sub/deep.md"]);
2149 assert_eq!(names(&["other/docs"]), ["other/docs/x.md"]);
2150 assert_eq!(names(&["./docs", "#other"]), ["docs/guide.md", "docs/sub/deep.md"]);
2152 }
2153
2154 #[test]
2155 fn workspace_scan_does_not_prune_a_vendor_named_root() {
2156 let dir = tempdir().unwrap();
2157 let root = dir.path().join("target");
2158 fs::create_dir(&root).unwrap();
2159 fs::write(root.join("README.md"), "# Root\n").unwrap();
2160 fs::create_dir(root.join("target")).unwrap();
2161 fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
2162
2163 let options = MarkdownWalkOptions {
2164 respect_gitignore: false,
2165 skip_vendor_dirs: true,
2166 };
2167 let excludes = ExcludeMatchers::new(&[]);
2168 let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
2169
2170 assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
2171 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
2172 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
2173 }
2174
2175 #[test]
2176 fn the_type_glob_selects_exactly_what_counts_as_markdown() {
2177 assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
2178
2179 let mut builder = globset::GlobSetBuilder::new();
2182 for ext in MARKDOWN_EXTENSIONS {
2183 builder.add(
2184 globset::GlobBuilder::new(&any_case_extension_glob(ext))
2185 .literal_separator(true)
2186 .build()
2187 .unwrap(),
2188 );
2189 }
2190 let globs = builder.build().unwrap();
2191
2192 for ext in MARKDOWN_EXTENSIONS {
2193 for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
2194 let name = format!("README.{spelling}");
2195 assert!(
2196 globs.is_match(&name),
2197 "{name} is markdown by extension but no type glob selects it"
2198 );
2199 assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
2200 }
2201 }
2202
2203 for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
2205 assert!(!globs.is_match(name), "{name} should not be selected");
2206 }
2207 }
2208
2209 fn capitalize(ext: &str) -> String {
2210 let mut chars = ext.chars();
2211 match chars.next() {
2212 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
2213 None => String::new(),
2214 }
2215 }
2216
2217 #[test]
2218 fn walk_includes_hidden_files() {
2219 let temp = tempdir().unwrap();
2220 fs::create_dir_all(temp.path().join(".github")).unwrap();
2221 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
2222 fs::write(temp.path().join("README.md"), "# hi").unwrap();
2223
2224 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
2225 .build()
2226 .flatten()
2227 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2228 .map(|e| e.path().to_path_buf())
2229 .collect();
2230 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
2231 assert!(files.iter().any(|p| p.ends_with("README.md")));
2232 }
2233
2234 #[test]
2235 fn walk_honors_gitignore_when_enabled_only() {
2236 let temp = tempdir().unwrap();
2237 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
2238 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
2239 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
2240
2241 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
2242 markdown_walk_builder(
2243 temp.path(),
2244 &MarkdownWalkOptions {
2245 respect_gitignore: respect,
2246 ..Default::default()
2247 },
2248 )
2249 .build()
2250 .flatten()
2251 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2252 .map(|e| e.path().to_path_buf())
2253 .collect()
2254 };
2255
2256 let respected = walk(true);
2257 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
2258 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
2259
2260 let unrespected = walk(false);
2261 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
2262 }
2263
2264 #[test]
2265 fn a_gitignore_above_the_repository_root_stays_outside_it() {
2266 let temp = tempdir().unwrap();
2267 fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
2268 let repo = temp.path().join("repo");
2269 fs::create_dir_all(repo.join(".git")).unwrap();
2270 fs::write(repo.join("kept.md"), "# hi").unwrap();
2271
2272 let walk = |root: &Path| -> Vec<std::path::PathBuf> {
2273 markdown_walk_builder(root, &MarkdownWalkOptions::default())
2274 .build()
2275 .flatten()
2276 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2277 .map(|e| e.path().to_path_buf())
2278 .collect()
2279 };
2280
2281 assert!(
2282 walk(&repo).iter().any(|p| p.ends_with("kept.md")),
2283 "git reads no gitignore above the repository root, so neither does the walk"
2284 );
2285
2286 fs::remove_dir(repo.join(".git")).unwrap();
2289 assert!(
2290 !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
2291 "with no repository to bound it, the walk keeps reading upward"
2292 );
2293 }
2294
2295 #[test]
2296 fn the_repository_boundary_needs_every_root_to_have_one() {
2297 let temp = tempdir().unwrap();
2298 let inside = temp.path().join("repo/docs");
2299 fs::create_dir_all(&inside).unwrap();
2300 fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
2301 let outside = temp.path().join("plain");
2302 fs::create_dir_all(&outside).unwrap();
2303
2304 assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
2305 assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
2306
2307 assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
2311 assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
2312
2313 let worktree = temp.path().join("worktree");
2316 fs::create_dir_all(&worktree).unwrap();
2317 fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
2318 assert!(stops_at_repository_root(&[&worktree]));
2319 }
2320
2321 #[test]
2322 fn walk_honors_markdownlintignore() {
2323 let temp = tempdir().unwrap();
2324 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
2325 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
2326 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
2327
2328 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
2329 .build()
2330 .flatten()
2331 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2332 .map(|e| e.path().to_path_buf())
2333 .collect();
2334 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
2335 assert!(files.iter().any(|p| p.ends_with("kept.md")));
2336 }
2337
2338 #[test]
2339 fn vendor_dirs_skipped_only_when_requested() {
2340 let temp = tempdir().unwrap();
2341 for dir in ["node_modules", "target", "src"] {
2342 fs::create_dir_all(temp.path().join(dir)).unwrap();
2343 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
2344 }
2345
2346 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
2347 markdown_walk_builder(
2348 temp.path(),
2349 &MarkdownWalkOptions {
2350 skip_vendor_dirs: skip,
2351 respect_gitignore: false,
2355 },
2356 )
2357 .build()
2358 .flatten()
2359 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2360 .map(|e| e.path().to_path_buf())
2361 .collect()
2362 };
2363
2364 let skipped = walk(true);
2365 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
2366 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
2367 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
2368
2369 let unskipped = walk(false);
2370 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
2371 }
2372
2373 #[test]
2374 fn explicit_file_name_glob_extracts_literal_extensions() {
2375 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
2376 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
2377 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
2378 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
2379 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
2380 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
2381 }
2382
2383 #[test]
2384 fn explicit_file_name_glob_rejects_unpinned_patterns() {
2385 for pattern in [
2386 "docs/",
2387 "docs/**",
2388 "docs",
2389 "*",
2390 "**",
2391 "**/*",
2392 "*.*",
2393 "*.md*",
2394 "*.{md,jinja}",
2395 "*.md?",
2396 "data.[ch]",
2397 "!drafts/*.md.jinja",
2398 "",
2399 "**/Makefile",
2400 "*.",
2401 ] {
2402 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
2403 }
2404 }
2405
2406 #[test]
2407 fn explicit_include_matchers_match_full_relative_paths() {
2408 let matchers = ExplicitIncludeMatchers::new(&[
2409 "**/*.md.jinja".to_string(),
2410 "docs/**".to_string(),
2411 "templates/NOTES.tmpl".to_string(),
2412 ]);
2413 assert!(!matchers.is_empty());
2414 assert!(matchers.matches_relative_path("test.md.jinja"));
2415 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
2416 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
2417 assert!(!matchers.matches_relative_path("docs/anything.txt"));
2419 assert!(!matchers.matches_relative_path("test.jinja"));
2420 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
2423 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
2424
2425 let globs: Vec<_> = matchers.file_name_globs().collect();
2426 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
2427 }
2428
2429 #[test]
2430 fn explicit_include_matchers_follow_gitignore_anchoring() {
2431 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
2433 assert!(unanchored.matches_relative_path("test.md.jinja"));
2434 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
2435
2436 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
2438 assert!(anchored.matches_relative_path("docs/a.txt"));
2439 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
2440 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
2441
2442 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
2444 assert!(rooted.matches_relative_path("NOTES.tmpl"));
2445 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
2446 }
2447
2448 #[test]
2449 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
2450 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
2451 assert!(matchers.is_empty());
2452 assert!(!matchers.matches_relative_path("x.md.jinja"));
2453 }
2454
2455 #[test]
2456 fn explicit_include_matchers_skip_invalid_globs() {
2457 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
2460 assert!(matchers.matches_relative_path("ok.md.jinja"));
2461 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
2462 }
2463
2464 #[test]
2465 fn exclude_matchers_expand_directory_patterns() {
2466 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
2467 assert!(matchers.is_match("drafts"));
2468 assert!(
2469 matchers.is_match("drafts/inner.md"),
2470 "directory pattern must match contents"
2471 );
2472 assert!(matchers.is_match("note.tmp.md"));
2473 assert!(!matchers.is_match("docs/guide.md"));
2474 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
2475 assert!(matchers.invalid.is_empty());
2476 }
2477
2478 #[test]
2479 fn exclude_matchers_float_a_pattern_without_a_slash_to_any_depth() {
2480 let matchers =
2481 ExcludeMatchers::new(&["node_modules".to_string(), "build/".to_string(), "notes.md".to_string()]);
2482 for path in [
2483 "node_modules/readme.md",
2484 "packages/app/node_modules/readme.md",
2485 "build/out.md",
2486 "site/build/out.md",
2487 "notes.md",
2488 "docs/notes.md",
2489 ] {
2490 assert!(matchers.is_match(path), "{path} must be excluded");
2491 }
2492 for path in ["node_modules.md", "docs/my-notes.md", "rebuild/out.md", "docs/guide.md"] {
2493 assert!(!matchers.is_match(path), "{path} must not be excluded");
2494 }
2495 assert_eq!(
2497 matchers.matched_pattern("packages/app/node_modules/readme.md"),
2498 Some("node_modules/**")
2499 );
2500 }
2501
2502 #[test]
2503 fn exclude_matchers_keep_a_pattern_containing_a_slash_anchored() {
2504 let matchers = ExcludeMatchers::new(&["docs/generated".to_string(), "sub/*.md".to_string()]);
2505 assert!(matchers.is_match("docs/generated/api.md"));
2506 assert!(!matchers.is_match("site/docs/generated/api.md"));
2507 assert!(matchers.is_match("sub/a.md"));
2508 assert!(!matchers.is_match("other/sub/a.md"));
2509 }
2510
2511 #[test]
2512 fn exclude_matchers_exclude_the_contents_of_a_directory_a_wildcard_matches() {
2513 let matchers = ExcludeMatchers::new(&["draft?".to_string(), "sub/[gh]en".to_string()]);
2514 assert!(matchers.is_match("drafts/note.md"));
2515 assert!(
2516 matchers.is_match("blog/drafts/note.md"),
2517 "a slashless wildcard floats too"
2518 );
2519 assert!(matchers.is_match("sub/gen/x.md"));
2520 assert!(matchers.is_match("sub/hen/deep/x.md"));
2521 assert!(!matchers.is_match("sub/pen/x.md"));
2522 assert!(!matchers.is_match("other/sub/gen/x.md"));
2523 }
2524
2525 #[test]
2526 fn exclude_matchers_report_an_invalid_pattern_once() {
2527 let matchers = ExcludeMatchers::new(&["[".to_string(), "docs".to_string()]);
2528 assert_eq!(matchers.invalid.len(), 1, "{:?}", matchers.invalid);
2529 assert!(matchers.is_match("docs/a.md"));
2530 }
2531
2532 #[test]
2533 fn expand_home_prefix_expands_only_a_leading_tilde() {
2534 let home = Path::new("/home/dev");
2535 assert_eq!(
2536 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
2537 "/home/dev/.cursor/plans"
2538 );
2539 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
2540 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
2541 }
2542
2543 #[test]
2544 fn expand_home_prefix_leaves_interior_tildes_alone() {
2545 let home = Path::new("/home/dev");
2546 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
2548 assert_eq!(
2549 expand_home_prefix_impl(pattern, Some(home)),
2550 pattern,
2551 "{pattern:?} must be left as written"
2552 );
2553 }
2554 }
2555
2556 #[test]
2557 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
2558 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
2559 }
2560
2561 #[test]
2562 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
2563 let temp = tempdir().unwrap();
2564 let base = canonicalize_for_matching(temp.path()).unwrap();
2567 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
2568 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
2569 }
2570
2571 #[test]
2572 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
2573 let temp = tempdir().unwrap();
2576 let canonical = canonicalize_for_matching(temp.path()).unwrap();
2577 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
2578 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
2579 }
2580
2581 #[test]
2582 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
2583 let temp = tempdir().unwrap();
2584 let base = canonicalize_for_matching(temp.path()).unwrap();
2585 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
2587 assert_eq!(
2590 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
2591 "/somewhere/else/**"
2592 );
2593 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
2595 }
2596
2597 #[test]
2598 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
2599 assert_eq!(
2602 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
2603 r"C:\Users\dev\AppData\Local\Temp\x"
2604 );
2605 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
2606 assert_eq!(
2608 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
2609 r"\\server\share\docs"
2610 );
2611 }
2612
2613 #[test]
2614 fn strip_verbatim_prefix_unwraps_the_display_form() {
2615 assert_eq!(
2617 strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
2618 "C:/Users/dev/AppData/Local/Temp/x"
2619 );
2620 assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
2621 assert_eq!(
2622 strip_verbatim_prefix("//?/UNC/server/share/docs"),
2623 "//server/share/docs"
2624 );
2625 }
2626
2627 #[test]
2628 fn strip_verbatim_prefix_leaves_other_paths_alone() {
2629 for path in [
2630 "/home/dev/docs",
2631 r"C:\Users\dev",
2632 "C:/Users/dev",
2633 r"\\server\share",
2634 "//server/share",
2635 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
2637 "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
2638 r"\\?\",
2639 "//?/",
2640 r"\\?/C:/Users/dev",
2642 "",
2643 ] {
2644 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
2645 }
2646 }
2647
2648 #[test]
2649 fn a_negated_exclude_pattern_is_invalid_and_matches_nothing() {
2650 let matchers = ExcludeMatchers::new(&[
2651 "!docs/a.md".to_string(),
2652 "!".to_string(),
2653 "!docs/a.md".to_string(),
2654 "docs/b.md".to_string(),
2655 ]);
2656 let reported: Vec<&str> = matchers.invalid.iter().map(|(pattern, _)| pattern.as_str()).collect();
2657 assert_eq!(reported, ["!docs/a.md", "!"], "each distinct negation once");
2658 assert!(
2659 matchers
2660 .invalid
2661 .iter()
2662 .all(|(_, error)| error == EXCLUDE_NEGATION_UNSUPPORTED)
2663 );
2664 assert!(!matchers.is_match("docs/a.md"));
2665 assert!(!matchers.is_match("!"));
2666 assert!(!matchers.is_match("!docs/a.md"));
2667 assert!(
2668 matchers.is_match("docs/b.md"),
2669 "an ordinary pattern beside it still applies"
2670 );
2671 }
2672
2673 #[test]
2674 #[cfg(not(windows))]
2675 fn an_escaped_leading_bang_is_a_literal_exclude() {
2676 let matchers = ExcludeMatchers::new(&[r"\!bang.md".to_string()]);
2677 assert!(matchers.invalid.is_empty(), "{:?}", matchers.invalid);
2678 assert!(matchers.is_match("!bang.md"));
2679 assert!(exclude_pattern_compiles(r"\!bang.md"));
2680 assert!(!is_exclude_negation(r"\!bang.md"));
2681 }
2682
2683 #[test]
2684 #[cfg(not(windows))]
2685 fn an_escaped_leading_bang_preserves_subsequent_escapes() {
2686 for pattern in [r"\!foo\[bar].md", r"\!foo\[bar\].md"] {
2687 let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
2688 assert!(matchers.invalid.is_empty(), "{pattern}: {:?}", matchers.invalid);
2689 assert!(matchers.is_match("!foo[bar].md"), "{pattern}");
2690 assert!(matchers.is_match("docs/!foo[bar].md"), "{pattern}");
2691 assert!(!matchers.is_match("!foo/b.md"), "{pattern}");
2692 assert!(!matchers.is_match("!foob.md"), "{pattern}");
2693 }
2694 }
2695
2696 #[test]
2700 #[cfg(not(windows))]
2701 fn exclude_glob_reads_a_backslash_as_an_escape_off_windows() {
2702 let escaped = exclude_glob(r"\!bang.md").unwrap().compile_matcher();
2703 assert!(escaped.is_match("!bang.md"));
2704 assert!(!escaped.is_match(r"\!bang.md"));
2705
2706 let unescaped = GlobBuilder::new(r"\!bang.md")
2709 .literal_separator(true)
2710 .backslash_escape(false)
2711 .build()
2712 .unwrap()
2713 .compile_matcher();
2714 assert!(!unescaped.is_match("!bang.md"));
2715 }
2716
2717 #[test]
2720 #[cfg(not(windows))]
2721 fn exclude_matchers_and_walker_overrides_agree_on_escaped_patterns() {
2722 let names = ["!bang.md", "[x].md", "x.md", "a*b.md", "axb.md", r"\!bang.md"];
2723 for pattern in [r"\!bang.md", r"\[x\].md", r"a\*b.md"] {
2724 let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
2725 assert!(matchers.invalid.is_empty(), "{pattern}: {:?}", matchers.invalid);
2726 let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
2727 builder.add(&exclude_override_rule(pattern)).unwrap();
2728 let walker = builder.build().unwrap();
2729 for name in names {
2730 let by_walker = walker.matched(Path::new("/root").join(name), false).is_ignore();
2731 assert_eq!(
2732 matchers.is_match(name),
2733 by_walker,
2734 "pattern {pattern} on {name}: matchers vs walker"
2735 );
2736 }
2737 assert!(
2738 matchers.is_match(names[0]) || pattern != r"\!bang.md",
2739 "the escaped bang names the literal file"
2740 );
2741 }
2742 }
2743
2744 #[test]
2745 fn exclude_pattern_compiles_rejects_a_negation() {
2746 assert!(!exclude_pattern_compiles("!x"));
2747 assert!(!exclude_pattern_compiles("!"));
2748 assert!(exclude_pattern_compiles("x"));
2749 }
2750
2751 #[test]
2752 fn expand_directory_pattern_expands_a_literal_final_component() {
2753 assert_eq!(
2756 expand_directory_pattern("**/.cursor/plans"),
2757 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
2758 );
2759 assert_eq!(
2760 expand_directory_pattern("docs/**/drafts"),
2761 vec!["docs/**/drafts", "docs/**/drafts/**"]
2762 );
2763 assert_eq!(
2765 expand_directory_pattern("logs/{a,b}"),
2766 vec!["logs/{a,b}", "logs/{a,b}/**"]
2767 );
2768 }
2769
2770 #[test]
2771 fn expand_include_directory_adds_the_contents_with_the_patterns_own_anchoring() {
2772 assert_eq!(expand_include_directory("docs"), ["docs", "**/docs/**"]);
2774 assert_eq!(expand_include_directory("docs/"), ["docs/", "**/docs/**"]);
2776 assert_eq!(expand_include_directory("a/docs"), ["a/docs", "a/docs/**"]);
2778 assert_eq!(expand_include_directory("/docs"), ["/docs", "/docs/**"]);
2779 assert_eq!(expand_include_directory("/abs/docs/"), ["/abs/docs/", "/abs/docs/**"]);
2780 assert_eq!(expand_include_directory("**/docs"), ["**/docs", "**/docs/**"]);
2781 }
2782
2783 #[test]
2784 fn expand_include_directory_leaves_other_patterns_alone() {
2785 for pattern in [
2786 "*.md",
2787 "docs/*",
2788 "docs/**",
2789 "docs/**/",
2790 "d?cs",
2791 "docs/[ab]",
2792 "docs/{public,private}",
2793 "!drafts",
2794 "#docs",
2795 "docs\\",
2796 "",
2797 " ",
2798 "\t",
2799 ] {
2800 assert_eq!(expand_include_directory(pattern), [pattern], "{pattern:?}");
2801 }
2802 }
2803
2804 #[test]
2805 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
2806 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
2809 assert_eq!(
2810 expand_directory_pattern(pattern),
2811 vec![pattern.to_string()],
2812 "{pattern:?} must not gain a contents-expansion"
2813 );
2814 }
2815 }
2816
2817 #[test]
2818 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
2819 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
2820 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
2821 assert!(
2822 matchers.excludes_file(None, excluded),
2823 "an absolute pattern must match the absolute path when there is no relative form"
2824 );
2825 assert_eq!(
2826 matchers.matched_pattern_for_file(None, excluded),
2827 Some("/home/dev/.cursor/plans/**")
2828 );
2829 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
2832 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
2833 }
2834
2835 #[test]
2836 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
2837 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
2838 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
2839 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
2840
2841 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "/unrelated".to_string()]);
2844 let note = Path::new("/home/dev/drafts/site/note.md");
2845 assert_eq!(matchers.matched_pattern_for_file(None, note), None);
2846 assert_eq!(matchers.matched_pattern_for_file(Some("note.md"), note), None);
2847 assert_eq!(
2848 matchers.matched_pattern_for_file(Some("drafts/note.md"), Path::new("/home/dev/site/drafts/note.md")),
2849 Some("drafts/**")
2850 );
2851 assert_eq!(
2852 matchers.matched_pattern_for_file(None, Path::new("/unrelated/note.md")),
2853 Some("/unrelated/**")
2854 );
2855 }
2856
2857 #[test]
2858 fn exclude_matchers_match_a_wildcard_within_one_path_component() {
2859 let matchers = ExcludeMatchers::new(&["sub/a*b".to_string(), "notes/a?b.md".to_string()]);
2860 assert!(matchers.is_match("sub/aXb/y.md"));
2861 assert!(matchers.is_match("sub/ab/y.md"));
2862 assert!(!matchers.is_match("sub/a/keep/b/x.md"));
2863 assert!(matchers.is_match("notes/aXb.md"));
2864 assert!(!matchers.is_match("notes/a/b.md"));
2865
2866 let matchers = ExcludeMatchers::new(&["docs/**/draft.md".to_string()]);
2867 assert!(matchers.is_match("docs/draft.md"));
2868 assert!(matchers.is_match("docs/a/b/draft.md"));
2869 }
2870
2871 #[test]
2872 fn exclude_matchers_report_invalid_patterns() {
2873 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
2874 assert_eq!(matchers.invalid.len(), 1);
2875 assert_eq!(matchers.invalid[0].0, "[");
2876 assert!(matchers.is_match("ok.md"));
2877 }
2878
2879 #[test]
2880 fn path_relative_to_strips_through_symlinked_base() {
2881 let temp = tempdir().unwrap();
2882 let base = temp.path().join("base");
2883 fs::create_dir_all(base.join("docs")).unwrap();
2884 fs::write(base.join("docs/a.md"), "# hi").unwrap();
2885
2886 assert_eq!(
2887 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
2888 Some("docs/a.md")
2889 );
2890 assert_eq!(
2891 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
2892 Some("a.md")
2893 );
2894 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
2895 assert_eq!(
2896 path_relative_to(&base.join("docs/new/ghost.md"), &base).as_deref(),
2897 Some("docs/new/ghost.md"),
2898 "a file that does not exist yet relativizes the way it will once written"
2899 );
2900 assert_eq!(
2901 path_relative_to(&base.join("docs/a.md"), &base.join("missing")),
2902 None,
2903 "a base that does not exist contains nothing"
2904 );
2905 }
2906
2907 #[test]
2908 fn resolve_for_matching_spells_a_missing_file_the_way_it_will_canonicalize() {
2909 let temp = tempdir().unwrap();
2910 let root = canonicalize_for_matching(temp.path()).unwrap();
2911 fs::create_dir_all(root.join("docs")).unwrap();
2912 fs::write(root.join("docs").join("x.md"), "# x").unwrap();
2913
2914 let cases = [
2917 ("docs/x.md", root.join("docs").join("x.md")),
2918 ("docs/ghost.md", root.join("docs").join("ghost.md")),
2919 (
2920 "docs/new/dir/ghost.md",
2921 root.join("docs").join("new").join("dir").join("ghost.md"),
2922 ),
2923 ("missing/../docs/./x.md", root.join("docs").join("x.md")),
2924 ("docs/missing/../../top.md", root.join("top.md")),
2925 ];
2926 for (relative, expected) in cases {
2927 assert_eq!(
2928 resolve_for_matching(&temp.path().join(relative)),
2929 expected,
2930 "{relative}"
2931 );
2932 }
2933 }
2934
2935 #[test]
2936 fn resolve_for_matching_takes_a_relative_path_from_the_working_directory() {
2937 let cwd = canonicalize_for_matching(&std::env::current_dir().unwrap()).unwrap();
2938 assert_eq!(
2939 resolve_for_matching(Path::new("no-such-dir-for-matching/ghost.md")),
2940 cwd.join("no-such-dir-for-matching").join("ghost.md")
2941 );
2942 assert_eq!(resolve_for_matching(Path::new("./Cargo.toml")), cwd.join("Cargo.toml"));
2943 }
2944
2945 #[cfg(unix)]
2946 #[test]
2947 fn resolve_for_matching_follows_a_symlink_reached_through_a_missing_directory() {
2948 let temp = tempdir().unwrap();
2949 let root = canonicalize_for_matching(temp.path()).unwrap();
2950 fs::create_dir_all(root.join("real")).unwrap();
2951 std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
2952
2953 assert_eq!(
2954 resolve_for_matching(&root.join("link").join("ghost.md")),
2955 root.join("real").join("ghost.md")
2956 );
2957 assert_eq!(
2960 resolve_for_matching(&root.join("missing").join("..").join("link").join("ghost.md")),
2961 root.join("real").join("ghost.md")
2962 );
2963 }
2964
2965 fn sorted(mut patterns: Vec<String>) -> Vec<String> {
2966 patterns.sort();
2967 patterns
2968 }
2969
2970 #[test]
2971 fn expand_braces_yields_every_alternative() {
2972 assert_eq!(
2973 sorted(expand_braces("/{var/folders,tmp}/**")),
2974 vec!["/tmp/**", "/var/folders/**"]
2975 );
2976 assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
2978 assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
2980 assert_eq!(
2982 sorted(expand_braces("/{a,b}/{c,d}.md")),
2983 vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
2984 );
2985 }
2986
2987 #[test]
2988 fn expand_braces_leaves_patterns_it_cannot_split() {
2989 assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
2991 assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
2993 assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
2995 assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
2997 let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
2999 assert_eq!(expand_braces(wide), vec![wide]);
3000 }
3001
3002 #[test]
3003 fn has_absolute_spelling_sees_past_a_leading_alternation() {
3004 assert!(has_absolute_spelling("/var/folders/**"));
3005 assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
3006 assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
3007 assert!(!has_absolute_spelling("docs/**"));
3008 assert!(!has_absolute_spelling("{docs,notes}/**"));
3009 }
3010
3011 #[test]
3012 fn expand_braces_agrees_with_globset() {
3013 let cases = [
3017 ("/{var/folders,tmp}/**", "/tmp/note.md"),
3018 ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
3019 ("/{var/folders,tmp}/**", "/opt/note.md"),
3020 ("/{a,b}/{c,d}.md", "/b/d.md"),
3021 ("/{a,b}/{c,d}.md", "/b/e.md"),
3022 ("x{,y}", "x"),
3023 ("x{,y}", "xy"),
3024 ("/var/[a,b]/**", "/var/a/n.md"),
3025 ("/var/[a,b]/**", "/var/,/n.md"),
3026 ("/var/folders/**", "/var/folders/n.md"),
3027 ];
3028 for (pattern, path) in cases {
3029 let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
3030 let expanded = expand_braces(pattern)
3031 .iter()
3032 .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
3033 assert_eq!(direct, expanded, "pattern {pattern} against {path}");
3034 }
3035 }
3036
3037 #[test]
3038 fn literal_path_prefix_stops_at_the_first_wildcard() {
3039 assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
3040 assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
3041 assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
3042 assert_eq!(literal_path_prefix("/var/"), Some("/var"));
3043 assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
3044 assert_eq!(literal_path_prefix("/**"), None);
3046 assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
3047 assert_eq!(literal_path_prefix("**/note.md"), None);
3048 let pattern = "/var/folders/**";
3051 let prefix = literal_path_prefix(pattern).unwrap();
3052 assert_eq!(&pattern[prefix.len()..], "/**");
3053 }
3054
3055 #[cfg(unix)]
3059 fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
3060 let real = temp.join("real");
3061 fs::create_dir_all(real.join("notes")).unwrap();
3062 fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
3063 let link = temp.join("link");
3064 std::os::unix::fs::symlink(&real, &link).unwrap();
3065 (canonicalize_for_matching(&real).unwrap(), link)
3066 }
3067
3068 #[cfg(unix)]
3069 #[test]
3070 fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
3071 let temp = tempdir().unwrap();
3072 let (real, link) = symlinked_dir(temp.path());
3073 let pattern = format!("{}/notes/**", link.to_string_lossy());
3074
3075 let aliases = PathAliases::new([pattern.as_str()]);
3076 assert!(!aliases.is_empty());
3077 assert_eq!(
3078 aliases.spellings_of(&real.join("notes/scratch.md")),
3079 vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
3080 );
3081 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
3083 assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
3084 assert!(
3085 aliases
3086 .spellings_of(&real.join("notes/scratch.md"))
3087 .iter()
3088 .any(|alias| matcher.is_match(alias))
3089 );
3090 assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
3092 }
3093
3094 #[cfg(unix)]
3095 #[test]
3096 fn path_aliases_reach_through_a_brace_alternation() {
3097 let temp = tempdir().unwrap();
3099 let (real, link) = symlinked_dir(temp.path());
3100 let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
3101
3102 let aliases = PathAliases::new([pattern.as_str()]);
3103 let matcher = Glob::new(&pattern).unwrap().compile_matcher();
3104 let note = real.join("notes/scratch.md");
3105 assert!(!matcher.is_match(¬e), "negative control");
3106 assert!(aliases.spellings_of(¬e).iter().any(|alias| matcher.is_match(alias)));
3107 }
3108
3109 #[test]
3110 fn path_aliases_are_empty_without_a_symlinked_prefix() {
3111 let temp = tempdir().unwrap();
3112 let canonical = canonicalize_for_matching(temp.path()).unwrap();
3113 let canonical = canonical.to_string_lossy().replace('\\', "/");
3114 for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
3117 assert!(
3118 PathAliases::new([pattern]).is_empty(),
3119 "pattern {pattern} should record no alias"
3120 );
3121 }
3122 }
3123
3124 #[cfg(unix)]
3125 #[test]
3126 fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
3127 let temp = tempdir().unwrap();
3128 let (real, link) = symlinked_dir(temp.path());
3129 let note = real.join("notes/scratch.md");
3130
3131 let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
3132 assert!(matchers.excludes_file(None, ¬e));
3133
3134 fs::write(real.join("other.md"), "# Other\n").unwrap();
3137 assert!(!matchers.excludes_file(None, &real.join("other.md")));
3138 let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
3139 assert!(!elsewhere.excludes_file(None, ¬e));
3140 }
3141
3142 #[cfg(unix)]
3143 #[test]
3144 fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
3145 let temp = tempdir().unwrap();
3146 let (real, link) = symlinked_dir(temp.path());
3147 let pattern = format!("{}/notes/*.md", link.to_string_lossy());
3148
3149 assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
3150 let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
3153 assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
3154 }
3155
3156 #[test]
3157 fn classify_file_reads_the_whole_name() {
3158 let cases = [
3159 ("README.md", FileKind::Markdown),
3160 ("README.MD", FileKind::Markdown),
3161 ("notes.txt", FileKind::Markdown),
3162 ("LICENSE", FileKind::Markdown),
3163 ("rules.mdc", FileKind::Markdown),
3164 ("page.html", FileKind::Markdown),
3165 ("lib.rs", FileKind::RustDocComments),
3166 ("LIB.RS", FileKind::SourceCode),
3168 ("main.swift", FileKind::SourceCode),
3169 ("Main.KT", FileKind::SourceCode),
3170 ("config.yaml", FileKind::SourceCode),
3171 ("template.md.jinja", FileKind::Markdown),
3172 ("README.md.j2.in", FileKind::Markdown),
3173 ("README.md.swift", FileKind::Markdown),
3174 ("script.litcoffee", FileKind::Markdown),
3175 ("readme.swift.txt", FileKind::Markdown),
3176 (".md.swift", FileKind::SourceCode),
3178 ];
3179 for (name, expected) in cases {
3180 assert_eq!(classify_file(Path::new(name)), expected, "{name}");
3181 assert_eq!(
3182 classify_file(&Path::new("dir.md").join(name)),
3183 expected,
3184 "dir.md/{name}"
3185 );
3186 }
3187 }
3188
3189 #[test]
3190 fn source_code_extensions_are_sorted_and_exclude_document_formats() {
3191 let extensions = crate::linguist_data::SOURCE_CODE_EXTENSIONS;
3192 assert!(extensions.windows(2).all(|pair| pair[0] < pair[1]));
3193 for document in ["md", "txt", "asc", "rst", "html", "mdx", "ipynb", "tex"] {
3194 assert!(!crate::linguist_data::is_source_code_extension(document), "{document}");
3195 }
3196 for source in ["rs", "swift", "kt", "dart", "java", "py", "json", "SWIFT"] {
3197 assert!(crate::linguist_data::is_source_code_extension(source), "{source}");
3198 }
3199 }
3200
3201 #[cfg(not(windows))]
3204 fn include_override_matches(pattern: &str, name: &str) -> bool {
3205 let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3206 builder.add(pattern).unwrap();
3207 builder
3208 .build()
3209 .unwrap()
3210 .matched(Path::new("/root").join(name), false)
3211 .is_whitelist()
3212 }
3213
3214 #[test]
3219 #[cfg(not(windows))]
3220 fn explicit_include_matchers_and_walker_overrides_agree_on_escaped_patterns() {
3221 let names = ["!bang.md", "[x].md", "x.md", "a*b.md", "axb.md", r"\!bang.md"];
3222 for pattern in [r"\!bang.md", r"\[x\].md", r"a\*b.md", "[x].md", "a*b.md"] {
3223 let matchers = ExplicitIncludeMatchers::new(&[pattern.to_string()]);
3224 assert!(!matchers.is_empty(), "{pattern} is an explicit file include");
3225 for name in names {
3226 assert_eq!(
3227 matchers.matches_relative_path(name),
3228 include_override_matches(pattern, name),
3229 "include {pattern} on {name}: matchers vs walker"
3230 );
3231 }
3232 }
3233 for (pattern, name) in [
3235 (r"\!bang.md", "!bang.md"),
3236 (r"\[x\].md", "[x].md"),
3237 (r"a\*b.md", "a*b.md"),
3238 ] {
3239 let matchers = ExplicitIncludeMatchers::new(&[pattern.to_string()]);
3240 assert!(matchers.matches_relative_path(name), "{pattern} should match {name}");
3241 assert!(include_override_matches(pattern, name), "walker: {pattern} on {name}");
3242 }
3243 let escaped_class = ExplicitIncludeMatchers::new(&[r"\[x\].md".to_string()]);
3245 assert!(!escaped_class.matches_relative_path("x.md"));
3246 let escaped_star = ExplicitIncludeMatchers::new(&[r"a\*b.md".to_string()]);
3247 assert!(!escaped_star.matches_relative_path("axb.md"));
3248 }
3249
3250 #[test]
3255 fn split_first_alternation_reads_a_backslash_inside_a_class_literally() {
3256 let (prefix, alternatives, suffix) = split_first_alternation(r"{[a\],/tmp}/notes/*.md").unwrap();
3257 assert_eq!(prefix, "");
3258 assert_eq!(alternatives, vec![r"[a\]", "/tmp"]);
3259 assert_eq!(suffix, "/notes/*.md");
3260 assert!(has_absolute_spelling(r"{[a\],/tmp}/notes/*.md"));
3261 }
3262
3263 #[test]
3265 fn split_first_alternation_reads_a_leading_close_bracket_as_a_class_member() {
3266 for pattern in [r"{[]a],/tmp}/*.md", r"{[!]a],/tmp}/*.md", r"{[^]a],/tmp}/*.md"] {
3267 let (_, alternatives, suffix) = split_first_alternation(pattern).unwrap();
3268 assert_eq!(alternatives.len(), 2, "{pattern}: {alternatives:?}");
3269 assert_eq!(alternatives[1], "/tmp", "{pattern}");
3270 assert_eq!(suffix, "/*.md", "{pattern}");
3271 assert!(has_absolute_spelling(pattern), "{pattern}");
3272 let glob = path_glob(pattern, false).unwrap().compile_matcher();
3273 assert!(glob.is_match("/tmp/x.md"), "{pattern}: globset agrees");
3274 }
3275 let pattern = "{[]},a],/tmp}/*.md";
3278 let (_, alternatives, suffix) = split_first_alternation(pattern).unwrap();
3279 assert_eq!(alternatives, vec!["[]},a]", "/tmp"]);
3280 assert_eq!(suffix, "/*.md");
3281 assert!(has_absolute_spelling(pattern));
3282 assert!(
3283 path_glob(pattern, false)
3284 .unwrap()
3285 .compile_matcher()
3286 .is_match("/tmp/x.md")
3287 );
3288 }
3289
3290 #[test]
3292 fn split_first_alternation_treats_an_escaped_brace_as_a_literal() {
3293 assert!(
3294 split_first_alternation(r"a\{b,c}").is_none(),
3295 "an escaped brace opens nothing"
3296 );
3297 let (prefix, alternatives, suffix) = split_first_alternation(r"a\\{b,c}").unwrap();
3298 assert_eq!((prefix, alternatives, suffix), (r"a\\", vec!["b", "c"], ""));
3299 let (_, alternatives, _) = split_first_alternation(r"{a\,b,c}").unwrap();
3300 assert_eq!(alternatives, vec![r"a\,b", "c"], "an escaped comma does not split");
3301 }
3302
3303 #[test]
3307 fn literal_path_prefix_ends_at_a_component_holding_a_backslash() {
3308 assert_eq!(literal_path_prefix(r"/alias/foo\bar/x"), Some("/alias"));
3309 assert_eq!(literal_path_prefix(r"/alias/foo\[x\]/x"), Some("/alias"));
3310 assert_eq!(literal_path_prefix(r"foo\bar/x"), None);
3311 assert_eq!(literal_path_prefix(r"C:/foo\bar/*.tmpl"), None);
3313 assert_eq!(literal_path_prefix("/alias/plain/x"), Some("/alias/plain/x"));
3314 assert_eq!(literal_path_prefix("C:/**"), None);
3315 assert_eq!(literal_path_prefix("C:/proj/**"), Some("C:/proj"));
3316 }
3317
3318 #[test]
3321 #[cfg(not(windows))]
3322 fn path_glob_reads_a_backslash_as_an_escape_and_keeps_the_separator_option() {
3323 let escaped = path_glob(r"\[x\].md", true).unwrap().compile_matcher();
3324 assert!(escaped.is_match("[x].md"));
3325 assert!(!escaped.is_match("x.md"));
3326 assert!(!escaped.is_match(r"\[x\].md"));
3327 assert!(!escaped.is_match("/[x/].md"), "a backslash is not a slash");
3328
3329 let star = path_glob(r"a\*b.md", true).unwrap().compile_matcher();
3330 assert!(star.is_match("a*b.md"));
3331 assert!(!star.is_match("axb.md"));
3332
3333 assert!(!path_glob("*.md", true).unwrap().compile_matcher().is_match("a/x.md"));
3335 assert!(path_glob("*.md", false).unwrap().compile_matcher().is_match("a/x.md"));
3336
3337 assert!(path_glob(r"docs\", true).is_err());
3339 }
3340
3341 #[test]
3342 fn trim_directory_slash_drops_an_escape_left_without_a_character() {
3343 assert_eq!(trim_directory_slash("docs"), "docs");
3344 assert_eq!(trim_directory_slash("docs/"), "docs");
3345 assert_eq!(trim_directory_slash("docs//"), "docs");
3346 assert_eq!(trim_directory_slash(r"docs\/"), "docs");
3347 assert_eq!(trim_directory_slash(r"docs\\/"), r"docs\");
3349 assert_eq!(trim_directory_slash(r"docs\\\/"), r"docs\\");
3350 assert_eq!(trim_directory_slash(r"docs\//"), r"docs\");
3352 assert_eq!(trim_directory_slash(r"docs\"), r"docs\");
3354 assert_eq!(trim_directory_slash(r"a\/b/"), r"a\/b");
3355 }
3356
3357 #[test]
3361 #[cfg(not(windows))]
3362 fn an_escaped_trailing_slash_excludes_the_directory_like_the_walker() {
3363 let pattern = r"docs\/";
3364 assert!(exclude_pattern_compiles(pattern));
3365 let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
3366 assert!(matchers.invalid.is_empty(), "{:?}", matchers.invalid);
3367 assert!(matchers.is_match("docs/x.md"));
3368 assert!(matchers.is_match("a/docs/x.md"));
3369 assert!(!matchers.is_match("other/x.md"), "negative control");
3370
3371 let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3372 for expanded in expand_directory_pattern(pattern) {
3373 builder.add(&exclude_override_rule(&expanded)).unwrap();
3374 }
3375 let walker = builder.build().unwrap();
3376 for (dir, file) in [
3379 ("docs", "docs/x.md"),
3380 ("a/docs", "a/docs/x.md"),
3381 ("other", "other/x.md"),
3382 ] {
3383 assert_eq!(
3384 matchers.is_match(file),
3385 walker.matched(Path::new("/root").join(dir), true).is_ignore(),
3386 "{dir}"
3387 );
3388 }
3389 assert_eq!(expand_directory_pattern(pattern), vec!["docs", "docs/**"]);
3390 for dangling in [r"docs\\/", r"docs*\\/", r"docs\"] {
3393 let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3394 assert!(
3395 builder.add(&exclude_override_rule(dangling)).is_err(),
3396 "{dangling}: walker"
3397 );
3398 assert!(!exclude_pattern_compiles(dangling), "{dangling}: pattern check");
3399 let matchers = ExcludeMatchers::new(&[dangling.to_string()]);
3400 assert!(!matchers.invalid.is_empty(), "{dangling}: matchers reject it");
3401 for file in [r"docs\/x.md", "docs/x.md", "docs/sub/x.md"] {
3402 assert!(!matchers.is_match(file), "{dangling}: matches nothing, not {file}");
3403 }
3404 }
3405 let several = ExcludeMatchers::new(&[r"docs\//".to_string()]);
3408 assert!(!several.invalid.is_empty());
3409 assert!(!several.is_match("docs/x.md") && !several.is_match(r"docs\/x.md"));
3410 }
3411
3412 #[test]
3418 #[cfg(not(windows))]
3419 fn configure_types_admits_every_name_for_a_backslashed_explicit_include() {
3420 let dir = tempfile::tempdir().unwrap();
3421 for name in ["[x].txt", "x.txt", "y.md", "z.png"] {
3422 fs::write(dir.path().join(name), "").unwrap();
3423 }
3424 let walked = |includes: &[String]| {
3425 let selector = LintablePathSelector::new(Some(dir.path()), includes, LintableFileMode::Markdown);
3426 let mut builder = ignore::WalkBuilder::new(dir.path());
3427 selector.configure_types(&mut builder).unwrap();
3428 let mut names: Vec<String> = builder
3429 .build()
3430 .filter_map(Result::ok)
3431 .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
3432 .map(|entry| entry.file_name().to_string_lossy().into_owned())
3433 .collect();
3434 names.sort();
3435 names
3436 };
3437 assert_eq!(
3438 walked(&[r"\[x\].txt".to_string()]),
3439 ["[x].txt", "x.txt", "y.md", "z.png"],
3440 "the escaped name widens the type filter to every file"
3441 );
3442 assert_eq!(walked(&["x.txt".to_string()]), ["x.txt", "y.md"]);
3444 let selector = LintablePathSelector::new(
3446 Some(dir.path()),
3447 &[r"\[x\].txt".to_string()],
3448 LintableFileMode::Markdown,
3449 );
3450 assert!(selector.keeps(&dir.path().join("[x].txt")));
3451 assert!(!selector.keeps(&dir.path().join("x.txt")));
3452 assert!(!selector.keeps(&dir.path().join("z.png")));
3453 }
3454
3455 #[test]
3456 #[cfg(not(windows))]
3457 fn type_filter_glob_widens_a_name_holding_a_backslash() {
3458 assert_eq!(type_filter_glob("*.md.jinja"), "*.md.jinja");
3459 assert_eq!(type_filter_glob(r"\[x\].txt"), "*");
3460 assert_eq!(type_filter_glob(r"a\*b.md"), "*");
3461 }
3462
3463 #[test]
3467 fn strip_base_text_agrees_with_a_path_strip_when_there_is_no_backslash() {
3468 let legacy = |pattern: &str, base: &Path| {
3469 Path::new(pattern)
3470 .strip_prefix(base)
3471 .ok()
3472 .map(|rest| rest.to_string_lossy().into_owned())
3473 };
3474 let root = Path::new("/root");
3475 let cases = [
3476 "/root/docs/**",
3477 "/root/./docs",
3478 "/root//docs",
3479 "/root/docs/",
3480 "/root/docs/**/.",
3481 "/root/docs/../../.",
3482 "/./root/.//",
3483 "/root",
3484 "/root/",
3485 "/rooted/docs",
3486 "/other/docs",
3487 "docs/**",
3488 "/root/a/b/c.md",
3489 ];
3490 for pattern in cases {
3491 assert_eq!(
3492 strip_base_text(pattern, root).map(str::to_string),
3493 legacy(pattern, root),
3494 "{pattern}"
3495 );
3496 }
3497 for pattern in ["/docs/**", "/", "/docs/", "/docs/./x"] {
3498 assert_eq!(
3499 strip_base_text(pattern, Path::new("/")).map(str::to_string),
3500 legacy(pattern, Path::new("/")),
3501 "root base: {pattern}"
3502 );
3503 }
3504 }
3505
3506 #[test]
3509 fn strip_base_text_keeps_escapes_and_never_treats_a_backslash_as_a_boundary() {
3510 let root = Path::new("/root");
3511 assert_eq!(strip_base_text(r"/root\[x\]", root), None);
3512 assert_eq!(strip_base_text(r"/root\docs", root), None);
3513 assert_eq!(strip_base_text(r"/root/\[x\].md", root), Some(r"\[x\].md"));
3514 assert_eq!(
3515 strip_base_text(r"/root/docs/\[notes\].tmpl", root),
3516 Some(r"docs/\[notes\].tmpl")
3517 );
3518 assert_eq!(strip_base_text(r"/root/docs\/", root), Some("docs"));
3519 assert_eq!(
3520 strip_base_text(r"/root/docs\/.", root),
3521 Some(r"docs\/."),
3522 "an escaped slash is no component"
3523 );
3524 }
3525
3526 #[test]
3527 #[cfg(not(windows))]
3528 fn normalize_pattern_for_base_keeps_escapes_in_the_rewritten_pattern() {
3529 let root = Path::new("/root");
3530 assert_eq!(
3531 normalize_pattern_for_base(r"/root/docs/\[notes\].tmpl", Some(root)),
3532 r"docs/\[notes\].tmpl"
3533 );
3534 assert_eq!(
3535 normalize_pattern_for_base(r"/root/\!bang.md", Some(root)),
3536 r"/\!bang.md"
3537 );
3538 let matcher = ExplicitIncludeMatchers::new(&[normalize_pattern_for_base("/root/./docs/*.tmpl", Some(root))]);
3540 assert!(matcher.matches_relative_path("docs/x.tmpl"));
3541 assert!(!matcher.matches_relative_path("other/x.tmpl"));
3542 }
3543
3544 fn dots(pattern: &str) -> String {
3545 normalize_dot_segments(pattern, false).into_owned()
3546 }
3547
3548 fn dots_windows(pattern: &str) -> String {
3549 normalize_dot_segments(pattern, true).into_owned()
3550 }
3551
3552 #[test]
3553 fn dot_segments_drop_dot_components_and_cancel_literal_dot_dot() {
3554 for (pattern, expected) in [
3555 ("./dir", "dir"),
3556 ("dir/./x", "dir/x"),
3557 ("./dir/", "dir/"),
3558 ("dir/.", "dir"),
3559 ("dir/./", "dir/"),
3560 ("a/b/../c", "a/c"),
3561 ("a/../b/../c", "c"),
3562 ("a/b/../../dir/**", "dir/**"),
3563 ("nonexist/../dir/**", "dir/**"),
3564 ("a//b", "a/b"),
3565 ("a//../b", "b"),
3566 ("*/x/../y", "*/y"),
3567 ("**/./x", "**/x"),
3568 ("dir/plain", "dir/plain"),
3569 ] {
3570 assert_eq!(dots(pattern), expected, "{pattern}");
3571 assert_eq!(dots(expected), expected, "idempotent on {expected}");
3572 }
3573 }
3574
3575 #[test]
3576 fn dot_segments_leave_what_cannot_be_resolved() {
3577 for pattern in [
3578 ".",
3579 "./",
3580 "a/..",
3581 "../x",
3582 "../../x",
3583 "*/../x",
3584 "dir/**/../x",
3585 "{a,b}/../x",
3586 r"a\*/../x",
3587 "x/[/../y",
3588 "x/{a/../y",
3589 "x/[/]/../y",
3590 "[!]/../x",
3591 ] {
3592 assert_eq!(dots(pattern), pattern, "{pattern}");
3593 }
3594 assert_eq!(dots("[a/b]/./x"), "[a/b]/x");
3596 assert_eq!(dots("{a/b,c}/./x"), "{a/b,c}/x");
3597 }
3598
3599 #[test]
3600 fn dot_segments_resolve_an_absolute_prefix_lexically_only_when_it_is_missing() {
3601 let dots = |pattern: &str| normalize_dot_segments(pattern, cfg!(windows)).into_owned();
3602 let temp = tempdir().unwrap();
3603 let root = canonicalize_for_matching(temp.path()).unwrap();
3604 let root = root.to_string_lossy().replace('\\', "/");
3605 fs::create_dir_all(format!("{root}/a/b")).unwrap();
3606
3607 assert_eq!(dots(&format!("{root}/./dir")), format!("{root}/dir"));
3608 assert_eq!(dots(&format!("{root}/nonexist/../dir")), format!("{root}/dir"));
3609 assert_eq!(dots(&format!("{root}/a/../dir")), format!("{root}/a/../dir"));
3611 assert_eq!(dots(&format!("{root}/a/b/../../dir")), format!("{root}/a/b/../../dir"));
3612 for opaque in ["*", "{a,b}", r"\*"] {
3614 let pattern = format!("{root}/{opaque}/nonexist/../dir");
3615 assert_eq!(dots(&pattern), pattern, "{pattern}");
3616 }
3617 assert_eq!(dots("/zz-missing/x/../y"), "/zz-missing/y");
3618 }
3619
3620 #[test]
3621 fn dot_segments_never_remove_a_root() {
3622 assert_eq!(dots("/.."), "/");
3623 assert_eq!(dots("/../x"), "/x");
3624 assert_eq!(dots("/zz-missing/../../x"), "/x");
3625 assert_eq!(dots("//zz-missing/x/../y"), "/zz-missing/y");
3627 assert_eq!(dots_windows("C:/proj/./docs"), "C:/proj/docs");
3629 assert_eq!(dots_windows("C:/../x"), "C:/x");
3630 assert_eq!(dots_windows("C:/.."), "C:/");
3631 assert_eq!(dots_windows("C:/."), "C:/");
3632 assert_eq!(dots_windows("C:/./"), "C:/");
3633 assert_eq!(dots_windows("C:/proj/.."), "C:/");
3634 assert_eq!(dots_windows("//server/share/../x"), "//server/share/x");
3635 assert_eq!(dots_windows("//?/C:/zz-missing/../x"), "//?/C:/x");
3636 assert_eq!(dots_windows("//server/share/./x"), "//server/share/x");
3637 assert_eq!(dots("C:/../x"), "x");
3638 }
3639
3640 #[test]
3641 fn dot_segments_leave_a_symlinked_prefix_to_the_filesystem() {
3642 #[cfg(unix)]
3643 {
3644 let temp = tempdir().unwrap();
3645 let root = temp.path().canonicalize().unwrap();
3646 fs::create_dir_all(root.join("a/b")).unwrap();
3647 fs::create_dir_all(root.join("a/dir")).unwrap();
3648 std::os::unix::fs::symlink(root.join("a/b"), root.join("link")).unwrap();
3649 let pattern = format!("{}/link/../dir", root.display());
3650 assert_eq!(dots(&pattern), pattern, "link/.. is a/, not the root");
3651 let aliases = PathAliases::new([pattern.as_str()]);
3652 assert!(!aliases.is_empty(), "the filesystem still resolves it for the aliases");
3653 let matchers = ExcludeMatchers::new(&[format!("{}/link/../dir/**", root.display())]);
3654 fs::write(root.join("a/dir/e.md"), "x").unwrap();
3655 fs::create_dir_all(root.join("dir")).unwrap();
3656 fs::write(root.join("dir/f.md"), "x").unwrap();
3657 assert!(matchers.excludes_file(None, &root.join("a/dir/e.md")));
3658 assert!(!matchers.excludes_file(None, &root.join("dir/f.md")));
3659 }
3660 }
3661
3662 #[test]
3663 fn a_dot_dot_that_cannot_resolve_still_matches_through_another_patterns_alias() {
3664 #[cfg(unix)]
3667 {
3668 let temp = tempdir().unwrap();
3669 let root = temp.path().canonicalize().unwrap();
3670 fs::create_dir_all(root.join("a")).unwrap();
3671 fs::create_dir_all(root.join("docs")).unwrap();
3672 fs::write(root.join("docs/x.md"), "x").unwrap();
3673 let first = format!("{}/*/../docs/**", root.display());
3674 let second = format!("{}/a/../*.tmp.md", root.display());
3675 let file = root.join("docs/x.md");
3676 assert!(!ExcludeMatchers::new(std::slice::from_ref(&first)).excludes_file(None, &file));
3677 assert!(!ExcludeMatchers::new(std::slice::from_ref(&second)).excludes_file(None, &file));
3678 let both = ExcludeMatchers::new(&[first, second]);
3679 assert!(both.invalid.is_empty(), "{:?}", both.invalid);
3680 assert!(both.excludes_file(None, &file));
3681 }
3682 }
3683
3684 #[test]
3685 fn a_dotted_exclude_stays_anchored_in_the_walker_and_the_matchers() {
3686 assert_eq!(expand_directory_pattern_for("./dir", false), ["/dir", "/dir/**"]);
3687 assert_eq!(expand_directory_pattern_for("./dir/", false), ["/dir", "/dir/**"]);
3688 assert_eq!(expand_directory_pattern_for("./a/b", false), ["a/b", "a/b/**"]);
3689 assert_eq!(expand_directory_pattern_for("dir/../dir", false), ["/dir", "/dir/**"]);
3690 assert_eq!(expand_directory_pattern_for("./*.md", false), ["/*.md"]);
3691 assert_eq!(expand_directory_pattern_for("dir", false), ["dir", "dir/**"]);
3693 assert_eq!(expand_directory_pattern_for("*.md", false), ["*.md"]);
3694
3695 let names = [
3696 "top.md",
3697 "other/top.md",
3698 "dir/a.md",
3699 "other/dir/a.md",
3700 "dir/sub/b.md",
3701 "a/b/d.md",
3702 ];
3703 for pattern in [
3704 "./top.md",
3705 "top.md",
3706 "./dir",
3707 "dir/./sub",
3708 "a/../dir/",
3709 "./*.md",
3710 "./a/b",
3711 ] {
3712 let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
3713 assert!(matchers.invalid.is_empty(), "{pattern}: {:?}", matchers.invalid);
3714 let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3715 for entry in expand_directory_pattern(pattern) {
3716 builder.add(&exclude_override_rule(&entry)).unwrap();
3717 }
3718 let walker = builder.build().unwrap();
3719 for name in names {
3720 let by_walker = walker.matched(Path::new("/root").join(name), false).is_ignore();
3721 assert_eq!(
3722 matchers.is_match(name),
3723 by_walker,
3724 "pattern {pattern} on {name}: matchers vs walker"
3725 );
3726 }
3727 }
3728 let anchored = ExcludeMatchers::new(&["./top.md".to_string()]);
3729 assert!(anchored.is_match("top.md"));
3730 assert!(!anchored.is_match("other/top.md"), "./top.md must not float");
3731 let floating = ExcludeMatchers::new(&["top.md".to_string()]);
3732 assert!(floating.is_match("other/top.md"));
3733 }
3734
3735 #[test]
3736 fn include_normalization_keeps_anchoring_and_guards_override_lines() {
3737 let n = |pattern: &str| normalize_pattern_for_base_for(pattern, None, false);
3738 assert_eq!(n("./top.md"), "/top.md");
3739 assert_eq!(n("./dir/"), "/dir/");
3740 assert_eq!(n("dir/./x"), "dir/x");
3741 assert_eq!(n("./a/b"), "a/b");
3742 assert_eq!(n("top.md"), "top.md");
3743 assert_eq!(n("!./top.md"), "!/top.md");
3744 assert_eq!(n("!./dir/x"), "!dir/x");
3745 assert_eq!(n("#dir/../docs/**"), "#dir/../docs/**");
3748 assert_eq!(n("a/../#x"), "a/../#x");
3749 assert_eq!(n("./!x"), "./!x");
3750 assert_eq!(n("!#dir/../docs/**"), "!docs/**");
3752 assert_eq!(n("!./!x/**"), "!!x/**");
3753 for pattern in ["./top.md", "!./dir/x", "dir/../#x", "./dir/"] {
3754 let once = n(pattern);
3755 assert_eq!(n(&once), once, "idempotent on {pattern}");
3756 }
3757 }
3758
3759 #[test]
3760 fn include_normalization_resolves_dots_in_an_absolute_pattern_under_the_base() {
3761 let temp = tempdir().unwrap();
3762 let root = canonicalize_for_matching(temp.path()).unwrap();
3763 fs::create_dir_all(root.join("docs")).unwrap();
3764 let text = root.to_string_lossy().replace('\\', "/");
3765 let n = |suffix: &str| normalize_pattern_for_base_for(&format!("{text}/{suffix}"), Some(&root), cfg!(windows));
3766 assert_eq!(n("./docs/**"), "docs/**");
3767 assert_eq!(n("nonexist/../docs/**"), "docs/**");
3768 assert_eq!(n("docs/../notes/**"), "notes/**");
3769 assert_eq!(n("docs/./../notes/"), "/notes");
3771 let explicit = ExplicitIncludeMatchers::new(&[n("x.tmpl")]);
3772 assert!(explicit.matches_relative_path("x.tmpl"));
3773 assert!(!explicit.matches_relative_path("a/x.tmpl"));
3774 assert_eq!(n("top.md"), "/top.md");
3776 assert_eq!(n("docs/../top.md"), "/top.md");
3777 assert_eq!(n("*.md"), "/*.md");
3778 assert_eq!(n("dir/top.md"), "dir/top.md");
3779 assert_eq!(n("../elsewhere/**"), format!("{text}/../elsewhere/**"));
3781 assert_eq!(n("docs/../../elsewhere/**"), format!("{text}/docs/../../elsewhere/**"));
3782 }
3783
3784 #[test]
3785 fn include_normalization_resolves_dots_under_a_base_holding_metacharacters() {
3786 let temp = tempdir().unwrap();
3787 let base = canonicalize_for_matching(temp.path()).unwrap().join("project[1]");
3788 fs::create_dir_all(base.join("sub")).unwrap();
3789 let text = base.to_string_lossy().replace('\\', "/");
3790 let n = |suffix: &str| normalize_pattern_for_base_for(&format!("{text}/{suffix}"), Some(&base), cfg!(windows));
3791 assert_eq!(n("nonexist/../docs/**"), "docs/**");
3792 assert_eq!(n("sub/../docs/**"), "docs/**");
3793 assert_eq!(n("./docs/**"), "docs/**");
3794 assert_eq!(n("../out/**"), format!("{text}/../out/**"));
3795 }
3796
3797 #[cfg(unix)]
3798 #[test]
3799 fn include_normalization_follows_a_symlink_before_dot_dot() {
3800 let temp = tempdir().unwrap();
3801 let root = temp.path().canonicalize().unwrap();
3802 fs::create_dir_all(root.join("x/y")).unwrap();
3803 std::os::unix::fs::symlink(root.join("x/y"), root.join("link")).unwrap();
3804 let pattern = format!("{}/link/../docs/**", root.display());
3805 assert_eq!(
3806 normalize_pattern_for_base_for(&pattern, Some(&root), false),
3807 "x/docs/**"
3808 );
3809 }
3810
3811 #[test]
3812 fn per_file_keys_normalize_dots_and_keep_a_bang_as_a_name_character() {
3813 assert_eq!(prepare_pattern_for("./dir/x.md", false), "dir/x.md");
3814 assert_eq!(prepare_pattern_for("a/../dir/x.md", false), "dir/x.md");
3815 assert_eq!(prepare_pattern_for("!dir/../f.md", false), "f.md");
3816 assert_eq!(prepare_pattern_for("!./f.md", false), "!./f.md");
3818 assert_eq!(prepare_pattern_for("a/!x/../f.md", false), "a/f.md");
3819 assert_eq!(prepare_pattern_for("../x.md", false), "../x.md");
3820 }
3821
3822 #[test]
3823 fn windows_reads_a_backslash_as_a_separator_and_unix_as_an_escape() {
3824 assert_eq!(prepare_pattern_for(r"docs\**\", true), "docs/**/");
3826 assert_eq!(prepare_pattern_for(r"docs\sub\*.md", true), "docs/sub/*.md");
3827 assert_eq!(prepare_pattern_for(r"..\x", true), "../x");
3828 assert_eq!(prepare_pattern_for(r".\docs\..\notes", true), "notes");
3829 assert_eq!(prepare_pattern_for(r"C:\proj\.\docs", true), "C:/proj/docs");
3830 assert_eq!(expand_directory_pattern_for(r"docs\**\", true), ["docs/**/"]);
3831 assert_eq!(
3832 expand_directory_pattern_for(r"docs\sub", true),
3833 ["docs/sub", "docs/sub/**"]
3834 );
3835 assert_eq!(expand_directory_pattern_for(r".\dir", true), ["/dir", "/dir/**"]);
3836 assert_eq!(normalize_pattern_for_base_for(r".\docs\*.md", None, true), "docs/*.md");
3837 assert_eq!(normalize_pattern_for_base_for(r"!.\docs\x", None, true), "!docs/x");
3838
3839 let separator = path_glob_for(r"docs\*.md", true, true).unwrap().compile_matcher();
3840 assert!(separator.is_match("docs/a.md"));
3841 assert!(
3842 path_glob_for(r"docs\", true, true).is_ok(),
3843 "no dangling escape on Windows"
3844 );
3845 let matchers = ExcludeMatchers::for_platform(&[r"docs\**".to_string()], true);
3846 assert!(matchers.invalid.is_empty(), "{:?}", matchers.invalid);
3847 assert!(matchers.is_match("docs/a.md"));
3848 assert!(matchers.is_match("docs/sub/b.md"));
3849 assert!(!matchers.is_match("other/a.md"));
3850
3851 let bang = path_glob_for("{!}name.md", true, true).unwrap().compile_matcher();
3853 assert!(bang.is_match("!name.md"));
3854 let star = path_glob_for("a[*]b.md", true, true).unwrap().compile_matcher();
3855 assert!(star.is_match("a*b.md") && !star.is_match("axb.md"));
3856 let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3858 for entry in expand_directory_pattern_for("{!}name.md", true)
3859 .into_iter()
3860 .chain(expand_directory_pattern_for("a[*]b.md", true))
3861 {
3862 builder.add(&exclude_override_rule(&entry)).unwrap();
3863 }
3864 let walker = builder.build().unwrap();
3865 for hit in ["!name.md", "a*b.md"] {
3866 assert!(walker.matched(Path::new("/root").join(hit), false).is_ignore(), "{hit}");
3867 }
3868 for miss in ["name.md", "axb.md"] {
3869 assert!(
3870 !walker.matched(Path::new("/root").join(miss), false).is_ignore(),
3871 "{miss}"
3872 );
3873 }
3874
3875 assert_eq!(prepare_pattern_for(r"docs\**", false), r"docs\**");
3877 assert_eq!(prepare_pattern_for(r"\!name", false), r"\!name");
3878 let escaped = path_glob_for(r"\!name.md", true, false).unwrap().compile_matcher();
3879 assert!(escaped.is_match("!name.md"));
3880 let unix = ExcludeMatchers::for_platform(&[r"docs\**".to_string()], false);
3881 assert!(!unix.is_match("docs/a.md"), "an escaped star names a file called docs*");
3882 assert!(path_glob_for(r"docs\", true, false).is_err());
3883 }
3884
3885 #[test]
3886 fn windows_verbatim_patterns_are_unwrapped_before_glob_parsing() {
3887 for pattern in [r"\\?\C:\repo\docs\..\*.md", "//?/C:/repo/docs/../*.md"] {
3888 assert_eq!(prepare_pattern_for(pattern, true), "C:/repo/*.md");
3889 let matcher = path_glob_for(pattern, true, true).unwrap().compile_matcher();
3890 assert!(matcher.is_match("C:/repo/docs/../top.md"));
3891 assert!(!matcher.is_match("D:/repo/docs/../top.md"));
3892 }
3893 assert_eq!(
3894 prepare_pattern_for("//?/UNC/server/share/docs/../*.md", true),
3895 "//server/share/*.md"
3896 );
3897 assert_eq!(prepare_pattern_for("//?/C:/repo/./*.md", false), "/?/C:/repo/*.md");
3898 }
3899
3900 #[test]
3901 fn path_aliases_see_through_a_dot_in_the_prefix() {
3902 #[cfg(unix)]
3903 {
3904 let temp = tempdir().unwrap();
3905 let root = temp.path().canonicalize().unwrap();
3906 fs::create_dir_all(root.join("real/docs")).unwrap();
3907 std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
3908 let aliases = PathAliases::new([format!("{}/./alias/docs/**", root.display()).as_str()]);
3909 assert_eq!(
3910 aliases.spellings_of(&root.join("real/docs/x.md")),
3911 vec![format!("{}/alias/docs/x.md", root.display())]
3912 );
3913 }
3914 }
3915}