1use globset::{Glob, GlobMatcher};
19use std::borrow::Cow;
20use std::ffi::OsStr;
21use std::path::{Path, PathBuf};
22
23const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
26
27pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
43 if pattern.starts_with('!') {
44 return None;
45 }
46 let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
47 if file_name.is_empty() {
48 return None;
49 }
50 let literal_tail = match file_name.rfind(GLOB_METACHARS) {
53 Some(idx) => &file_name[idx + 1..],
54 None => file_name,
55 };
56 match literal_tail.rsplit_once('.') {
57 Some((_, ext)) if !ext.is_empty() => Some(file_name),
58 _ => None,
59 }
60}
61
62pub struct ExplicitIncludeMatchers {
81 matchers: Vec<ExplicitInclude>,
82}
83
84struct ExplicitInclude {
85 file_name_glob: String,
86 path_matcher: GlobMatcher,
87}
88
89impl ExplicitIncludeMatchers {
90 pub fn new(patterns: &[String]) -> Self {
91 let matchers = patterns
92 .iter()
93 .filter_map(|pattern| {
94 let file_name_glob = explicit_file_name_glob(pattern)?;
95 let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
96 anchored.to_string()
97 } else if pattern.contains('/') {
98 pattern.clone()
99 } else {
100 format!("**/{pattern}")
101 };
102 let path_matcher = globset::GlobBuilder::new(&path_glob)
103 .literal_separator(true)
104 .build()
105 .ok()?
106 .compile_matcher();
107 Some(ExplicitInclude {
108 file_name_glob: file_name_glob.to_string(),
109 path_matcher,
110 })
111 })
112 .collect();
113 Self { matchers }
114 }
115
116 pub fn is_empty(&self) -> bool {
117 self.matchers.is_empty()
118 }
119
120 pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
122 self.matchers.iter().map(|m| m.file_name_glob.as_str())
123 }
124
125 pub fn matches_relative_path(&self, path: &str) -> bool {
128 self.matchers.iter().any(|m| m.path_matcher.is_match(path))
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum LintableFileMode {
140 Markdown,
141 MarkdownAndRust,
142 Any,
143}
144
145pub struct LintablePathSelector {
152 base: Option<PathBuf>,
153 explicit: ExplicitIncludeMatchers,
154 mode: LintableFileMode,
155}
156
157impl LintablePathSelector {
158 pub fn new(base: Option<&Path>, includes: &[String], mode: LintableFileMode) -> Self {
159 Self {
160 base: base.map(Path::to_path_buf),
161 explicit: ExplicitIncludeMatchers::new(includes),
162 mode,
163 }
164 }
165
166 pub fn keeps(&self, path: &Path) -> bool {
168 if self.mode == LintableFileMode::Any {
169 return true;
170 }
171 if has_markdown_extension(path) {
172 return true;
173 }
174
175 let is_rust = path.extension().and_then(OsStr::to_str) == Some("rs");
178 if is_rust {
179 return self.mode == LintableFileMode::MarkdownAndRust;
180 }
181
182 match self.base.as_deref().and_then(|base| path_relative_to(path, base)) {
183 Some(relative) => self.explicit.matches_relative_path(&relative),
184 None => self.explicit.matches_relative_path(&path.to_string_lossy()),
187 }
188 }
189
190 pub fn configure_types(&self, builder: &mut ignore::WalkBuilder) -> Result<(), ignore::Error> {
194 if self.mode == LintableFileMode::Any {
195 return Ok(());
196 }
197
198 let mut types = ignore::types::TypesBuilder::new();
199 types.add_defaults();
200 for extension in MARKDOWN_EXTENSIONS {
201 types.add("markdown", &any_case_extension_glob(extension))?;
202 }
203 types.select("markdown");
204 if self.mode == LintableFileMode::MarkdownAndRust {
205 types.add("rustdoc", "*.rs")?;
206 types.select("rustdoc");
207 }
208 for glob in self.explicit.file_name_globs() {
209 types.add("configinclude", glob)?;
210 }
211 if !self.explicit.is_empty() {
212 types.select("configinclude");
213 }
214 builder.types(types.build()?);
215 Ok(())
216 }
217}
218
219pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
221
222#[inline]
225pub fn is_markdown_extension(ext: &OsStr) -> bool {
226 ext.to_str()
227 .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
228}
229
230#[inline]
232pub fn has_markdown_extension(path: &Path) -> bool {
233 path.extension().is_some_and(is_markdown_extension)
234}
235
236pub fn any_case_extension_glob(ext: &str) -> String {
244 let mut glob = String::with_capacity(2 + ext.len() * 4);
245 glob.push_str("*.");
246 for ch in ext.chars() {
247 if ch.is_ascii_alphabetic() {
248 glob.push('[');
249 glob.push(ch.to_ascii_lowercase());
250 glob.push(ch.to_ascii_uppercase());
251 glob.push(']');
252 } else {
253 glob.push(ch);
254 }
255 }
256 glob
257}
258
259#[derive(Debug, Clone)]
261pub struct MarkdownWalkOptions {
262 pub respect_gitignore: bool,
265 pub skip_vendor_dirs: bool,
268}
269
270impl Default for MarkdownWalkOptions {
271 fn default() -> Self {
272 Self {
273 respect_gitignore: true,
274 skip_vendor_dirs: false,
275 }
276 }
277}
278
279pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
291 !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
292}
293
294fn in_repository(path: &Path) -> bool {
300 let Ok(absolute) = std::fs::canonicalize(path) else {
301 return false;
302 };
303 absolute
304 .ancestors()
305 .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
306}
307
308pub fn apply_markdown_walk_options<P: AsRef<Path>>(
318 builder: &mut ignore::WalkBuilder,
319 roots: &[P],
320 options: &MarkdownWalkOptions,
321) {
322 let gitignore = options.respect_gitignore;
323 builder
324 .ignore(gitignore)
325 .git_ignore(gitignore)
326 .git_global(gitignore)
327 .git_exclude(gitignore)
328 .parents(gitignore)
329 .hidden(false)
330 .require_git(stops_at_repository_root(roots))
336 .add_custom_ignore_filename(".markdownlintignore");
337
338 if options.skip_vendor_dirs {
339 let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
340 builder.filter_entry(move |entry| {
341 if roots.iter().any(|root| root == entry.path()) {
342 return true;
343 }
344 let name = entry.file_name().to_str().unwrap_or("");
345 name != ".git" && name != "node_modules" && name != "target"
346 });
347 }
348}
349
350pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
352 let mut builder = ignore::WalkBuilder::new(root);
353 apply_markdown_walk_options(&mut builder, &[root], options);
354 builder
355}
356
357pub struct MarkdownWorkspaceScan<'a> {
364 options: &'a MarkdownWalkOptions,
365 includes: &'a [String],
366 excludes: &'a ExcludeMatchers,
367}
368
369impl<'a> MarkdownWorkspaceScan<'a> {
370 pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
371 Self {
372 options,
373 includes,
374 excludes,
375 }
376 }
377
378 pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
380 let mut files = Vec::new();
381 for root in roots {
382 let selection = RootSelection::new(root, self.includes);
383 let mut builder = markdown_walk_builder(root, self.options);
384 selection.configure_walk(&mut builder);
385
386 for result in builder.build() {
387 match result {
388 Ok(entry)
389 if entry.file_type().is_some_and(|file_type| file_type.is_file())
390 && selection.is_lintable(entry.path())
391 && !self.excluded(root, entry.path()) =>
392 {
393 files.push(entry.into_path());
394 }
395 Ok(_) => {}
396 Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
397 }
398 }
399 }
400 files.sort();
401 files.dedup();
402 files
403 }
404
405 pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
407 let Some(root) = roots
408 .iter()
409 .filter(|root| path.starts_with(root))
410 .max_by_key(|root| root.components().count())
411 else {
412 return false;
413 };
414
415 let selection = RootSelection::new(root, self.includes);
416 if !selection.selects(path) || self.excluded(root, path) {
417 return true;
418 }
419
420 if self.options.skip_vendor_dirs
421 && let Ok(relative) = path.strip_prefix(root)
422 && relative.components().any(|component| {
423 matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
424 })
425 {
426 return true;
427 }
428
429 let target = path.to_path_buf();
430 let mut builder = markdown_walk_builder(root, self.options);
431 selection.configure_walk(&mut builder);
432 builder.filter_entry(move |entry| target.starts_with(entry.path()));
434 !builder.build().flatten().any(|entry| entry.path() == path)
435 }
436
437 fn excluded(&self, root: &Path, path: &Path) -> bool {
438 self.excludes
439 .excludes_file(path_relative_to(path, root).as_deref(), path)
440 }
441}
442
443struct RootSelection {
444 lintable: LintablePathSelector,
445 overrides: Option<ignore::overrides::Override>,
446}
447
448impl RootSelection {
449 fn new(root: &Path, includes: &[String]) -> Self {
450 let normalized: Vec<String> = includes
451 .iter()
452 .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
453 .collect();
454 let overrides = if normalized.is_empty() {
455 None
456 } else {
457 let mut builder = ignore::overrides::OverrideBuilder::new(root);
458 for pattern in &normalized {
459 if let Err(error) = builder.add(pattern) {
460 log::warn!("Invalid include pattern '{pattern}': {error}");
461 }
462 }
463 builder.build().ok()
464 };
465 Self {
466 lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
467 overrides,
468 }
469 }
470
471 fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
472 if let Err(error) = self.lintable.configure_types(builder) {
473 log::warn!("Failed to configure workspace source types: {error}");
474 }
475 if let Some(overrides) = &self.overrides {
476 builder.overrides(overrides.clone());
477 }
478 }
479
480 fn selects(&self, path: &Path) -> bool {
481 self.overrides
482 .as_ref()
483 .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
484 && self.is_lintable(path)
485 }
486
487 fn is_lintable(&self, path: &Path) -> bool {
488 self.lintable.keeps(path)
489 }
490}
491
492fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
507 if let Some(rest) = path.strip_prefix(r"\\?\UNC")
510 && rest.starts_with('\\')
511 {
512 return Cow::Owned(format!(r"\{rest}"));
513 }
514 let Some(rest) = path.strip_prefix(r"\\?\") else {
515 return Cow::Borrowed(path);
516 };
517 let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
518 if is_drive_path {
519 Cow::Borrowed(rest)
520 } else {
521 Cow::Borrowed(path)
522 }
523}
524
525pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
533 let canonical = path.canonicalize().ok()?;
534 if !cfg!(windows) {
535 return Some(canonical);
536 }
537 let as_str = canonical.to_string_lossy();
538 Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
539}
540
541fn home_dir() -> Option<PathBuf> {
549 #[cfg(feature = "native")]
550 {
551 use etcetera::{BaseStrategy, choose_base_strategy};
552 choose_base_strategy()
553 .ok()
554 .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
555 }
556 #[cfg(not(feature = "native"))]
557 {
558 None
559 }
560}
561
562pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
576 if !has_home_prefix(pattern) {
579 return Cow::Borrowed(pattern);
580 }
581 expand_home_prefix_impl(pattern, home_dir().as_deref())
582}
583
584fn has_home_prefix(pattern: &str) -> bool {
586 pattern == "~" || pattern.starts_with("~/")
587}
588
589fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
590 let Some(suffix) = (if pattern == "~" {
591 Some("")
592 } else {
593 pattern.strip_prefix("~/")
594 }) else {
595 return Cow::Borrowed(pattern);
596 };
597 let Some(home) = home else {
598 return Cow::Borrowed(pattern);
599 };
600
601 let home = normalize_pattern_separators(home.to_string_lossy());
602 let home = home.trim_end_matches('/');
603 if suffix.is_empty() {
604 Cow::Owned(home.to_string())
605 } else {
606 Cow::Owned(format!("{home}/{suffix}"))
607 }
608}
609
610fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
615 if cfg!(windows) && path.contains('\\') {
616 Cow::Owned(path.replace('\\', "/"))
617 } else {
618 path
619 }
620}
621
622pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
633 let expanded = expand_home_prefix(pattern);
634 let Some(base) = base else {
635 return expanded.into_owned();
636 };
637 if !is_absolute_pattern(&expanded) {
638 return expanded.into_owned();
639 }
640
641 let path = Path::new(expanded.as_ref());
644 let relative = path.strip_prefix(base).ok().or_else(|| {
645 let canonical = canonicalize_for_matching(base)?;
646 path.strip_prefix(canonical).ok()
647 });
648 match relative {
649 Some(relative) => normalize_pattern_separators(relative.to_string_lossy()).into_owned(),
650 None => expanded.into_owned(),
651 }
652}
653
654pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
665 let pattern = expand_home_prefix(pattern);
666 let base = pattern.trim_end_matches('/');
667 let final_component = base.rsplit('/').next().unwrap_or(base);
668
669 if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
670 return vec![pattern.to_string()];
671 }
672
673 vec![
674 base.to_string(), format!("{base}/**"), ]
677}
678
679pub fn exclude_override_rule(pattern: &str) -> String {
684 if pattern.starts_with('!') {
685 pattern.to_string()
686 } else {
687 format!("!{pattern}")
688 }
689}
690
691pub fn exclude_pattern_compiles(pattern: &str) -> bool {
698 expand_directory_pattern(pattern).iter().all(|expanded| {
699 Glob::new(expanded).is_ok()
700 && ignore::overrides::OverrideBuilder::new(Path::new("."))
701 .add(&exclude_override_rule(expanded))
702 .is_ok()
703 })
704}
705
706pub fn include_pattern_compiles(pattern: &str) -> bool {
715 ignore::overrides::OverrideBuilder::new(Path::new("."))
716 .add(&expand_home_prefix(pattern))
717 .is_ok()
718}
719
720pub struct ExcludeMatchers {
727 matchers: Vec<(String, GlobMatcher)>,
728 has_absolute: bool,
732 pub invalid: Vec<(String, String)>,
735}
736
737pub fn is_absolute_pattern(pattern: &str) -> bool {
741 pattern.starts_with('/') || Path::new(pattern).is_absolute()
742}
743
744impl ExcludeMatchers {
745 pub fn new(patterns: &[String]) -> Self {
746 let mut matchers = Vec::new();
747 let mut invalid = Vec::new();
748 let mut has_absolute = false;
749 for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
750 has_absolute |= is_absolute_pattern(&pattern);
751 match Glob::new(&pattern) {
752 Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
753 Err(e) => invalid.push((pattern, e.to_string())),
754 }
755 }
756 Self {
757 matchers,
758 has_absolute,
759 invalid,
760 }
761 }
762
763 pub fn is_empty(&self) -> bool {
764 self.matchers.is_empty()
765 }
766
767 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
769 self.matchers
770 .iter()
771 .find(|(_, matcher)| matcher.is_match(relative_path))
772 .map(|(pattern, _)| pattern.as_str())
773 }
774
775 pub fn is_match(&self, relative_path: &str) -> bool {
776 self.matched_pattern(relative_path).is_some()
777 }
778
779 pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
796 if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
797 return Some(pattern);
798 }
799 if !self.has_absolute {
800 return None;
801 }
802 let canonical = canonicalize_for_matching(absolute);
803 let absolute = canonical.as_deref().unwrap_or(absolute);
804 self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
805 }
806
807 pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
809 self.matched_pattern_for_file(relative, absolute).is_some()
810 }
811}
812
813pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
822 let canonical_base = base.canonicalize().ok()?;
823 let canonical_path = path.canonicalize().ok()?;
824 canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
825 let rel = rel.to_string_lossy();
826 if cfg!(windows) {
827 rel.replace('\\', "/")
828 } else {
829 rel.to_string()
830 }
831 })
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use std::fs;
838 use tempfile::tempdir;
839
840 #[test]
841 fn markdown_extensions_match_case_insensitively() {
842 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
843 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
844 }
845 for ext in ["rs", "txt", "mdq", ""] {
846 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
847 }
848 assert!(has_markdown_extension(Path::new("a/b/README.md")));
849 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
850 assert!(!has_markdown_extension(Path::new("no_extension")));
851 assert!(!has_markdown_extension(Path::new("lib.rs")));
852 }
853
854 #[test]
855 fn lintable_selector_makes_adapter_capabilities_explicit() {
856 let dir = tempdir().unwrap();
857 let root = dir.path();
858 fs::create_dir_all(root.join("docs")).unwrap();
859 fs::create_dir_all(root.join("templates")).unwrap();
860 fs::create_dir_all(root.join("src")).unwrap();
861 for relative in [
862 "docs/guide.md",
863 "docs/notes.txt",
864 "templates/page.md.jinja",
865 "src/lib.rs",
866 "src/upper.RS",
867 ] {
868 fs::write(root.join(relative), "content\n").unwrap();
869 }
870 let includes = vec![
871 "docs/**".to_string(),
872 "templates/**/*.md.jinja".to_string(),
873 "src/**/*.rs".to_string(),
874 ];
875
876 let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
877 assert!(markdown.keeps(&root.join("docs/guide.md")));
878 assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
879 assert!(!markdown.keeps(&root.join("docs/notes.txt")));
880 assert!(
881 !markdown.keeps(&root.join("src/lib.rs")),
882 "an LSP must not parse a complete Rust source file as Markdown"
883 );
884
885 let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
886 assert!(rustdoc.keeps(&root.join("src/lib.rs")));
887 assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
888 assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
889
890 let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
891 assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
892 }
893
894 #[test]
895 fn workspace_scan_rejects_explicit_rust_includes() {
896 let dir = tempdir().unwrap();
897 let root = dir.path().to_path_buf();
898 fs::create_dir(root.join("src")).unwrap();
899 fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
900 fs::write(root.join("README.md"), "# Readme\n").unwrap();
901
902 let options = MarkdownWalkOptions {
903 respect_gitignore: false,
904 skip_vendor_dirs: true,
905 };
906 let includes = vec!["src/**/*.rs".to_string()];
907 let excludes = ExcludeMatchers::new(&[]);
908 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
909
910 assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
911 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
912 }
913
914 #[test]
915 fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
916 let dir = tempdir().unwrap();
917 let root = dir.path().to_path_buf();
918 fs::create_dir(root.join("docs")).unwrap();
919 fs::create_dir(root.join("templates")).unwrap();
920 fs::write(root.join("README.md"), "# Root\n").unwrap();
921 fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
922 fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
923 fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
924
925 let options = MarkdownWalkOptions {
926 respect_gitignore: false,
927 skip_vendor_dirs: true,
928 };
929 let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
930 let excludes = ExcludeMatchers::new(&[]);
931 let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
932
933 let names: Vec<String> = scan
936 .collect(std::slice::from_ref(&root))
937 .iter()
938 .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
939 .collect();
940 assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
941
942 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
943 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
944 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
945 }
946
947 #[test]
948 fn workspace_scan_does_not_prune_a_vendor_named_root() {
949 let dir = tempdir().unwrap();
950 let root = dir.path().join("target");
951 fs::create_dir(&root).unwrap();
952 fs::write(root.join("README.md"), "# Root\n").unwrap();
953 fs::create_dir(root.join("target")).unwrap();
954 fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
955
956 let options = MarkdownWalkOptions {
957 respect_gitignore: false,
958 skip_vendor_dirs: true,
959 };
960 let excludes = ExcludeMatchers::new(&[]);
961 let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
962
963 assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
964 assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
965 assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
966 }
967
968 #[test]
969 fn the_type_glob_selects_exactly_what_counts_as_markdown() {
970 assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
971
972 let mut builder = globset::GlobSetBuilder::new();
975 for ext in MARKDOWN_EXTENSIONS {
976 builder.add(
977 globset::GlobBuilder::new(&any_case_extension_glob(ext))
978 .literal_separator(true)
979 .build()
980 .unwrap(),
981 );
982 }
983 let globs = builder.build().unwrap();
984
985 for ext in MARKDOWN_EXTENSIONS {
986 for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
987 let name = format!("README.{spelling}");
988 assert!(
989 globs.is_match(&name),
990 "{name} is markdown by extension but no type glob selects it"
991 );
992 assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
993 }
994 }
995
996 for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
998 assert!(!globs.is_match(name), "{name} should not be selected");
999 }
1000 }
1001
1002 fn capitalize(ext: &str) -> String {
1003 let mut chars = ext.chars();
1004 match chars.next() {
1005 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1006 None => String::new(),
1007 }
1008 }
1009
1010 #[test]
1011 fn walk_includes_hidden_files() {
1012 let temp = tempdir().unwrap();
1013 fs::create_dir_all(temp.path().join(".github")).unwrap();
1014 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
1015 fs::write(temp.path().join("README.md"), "# hi").unwrap();
1016
1017 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1018 .build()
1019 .flatten()
1020 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1021 .map(|e| e.path().to_path_buf())
1022 .collect();
1023 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
1024 assert!(files.iter().any(|p| p.ends_with("README.md")));
1025 }
1026
1027 #[test]
1028 fn walk_honors_gitignore_when_enabled_only() {
1029 let temp = tempdir().unwrap();
1030 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
1031 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
1032 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1033
1034 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
1035 markdown_walk_builder(
1036 temp.path(),
1037 &MarkdownWalkOptions {
1038 respect_gitignore: respect,
1039 ..Default::default()
1040 },
1041 )
1042 .build()
1043 .flatten()
1044 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1045 .map(|e| e.path().to_path_buf())
1046 .collect()
1047 };
1048
1049 let respected = walk(true);
1050 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
1051 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
1052
1053 let unrespected = walk(false);
1054 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
1055 }
1056
1057 #[test]
1058 fn a_gitignore_above_the_repository_root_stays_outside_it() {
1059 let temp = tempdir().unwrap();
1060 fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
1061 let repo = temp.path().join("repo");
1062 fs::create_dir_all(repo.join(".git")).unwrap();
1063 fs::write(repo.join("kept.md"), "# hi").unwrap();
1064
1065 let walk = |root: &Path| -> Vec<std::path::PathBuf> {
1066 markdown_walk_builder(root, &MarkdownWalkOptions::default())
1067 .build()
1068 .flatten()
1069 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1070 .map(|e| e.path().to_path_buf())
1071 .collect()
1072 };
1073
1074 assert!(
1075 walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1076 "git reads no gitignore above the repository root, so neither does the walk"
1077 );
1078
1079 fs::remove_dir(repo.join(".git")).unwrap();
1082 assert!(
1083 !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1084 "with no repository to bound it, the walk keeps reading upward"
1085 );
1086 }
1087
1088 #[test]
1089 fn the_repository_boundary_needs_every_root_to_have_one() {
1090 let temp = tempdir().unwrap();
1091 let inside = temp.path().join("repo/docs");
1092 fs::create_dir_all(&inside).unwrap();
1093 fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
1094 let outside = temp.path().join("plain");
1095 fs::create_dir_all(&outside).unwrap();
1096
1097 assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
1098 assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
1099
1100 assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
1104 assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
1105
1106 let worktree = temp.path().join("worktree");
1109 fs::create_dir_all(&worktree).unwrap();
1110 fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1111 assert!(stops_at_repository_root(&[&worktree]));
1112 }
1113
1114 #[test]
1115 fn walk_honors_markdownlintignore() {
1116 let temp = tempdir().unwrap();
1117 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
1118 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
1119 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1120
1121 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1122 .build()
1123 .flatten()
1124 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1125 .map(|e| e.path().to_path_buf())
1126 .collect();
1127 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
1128 assert!(files.iter().any(|p| p.ends_with("kept.md")));
1129 }
1130
1131 #[test]
1132 fn vendor_dirs_skipped_only_when_requested() {
1133 let temp = tempdir().unwrap();
1134 for dir in ["node_modules", "target", "src"] {
1135 fs::create_dir_all(temp.path().join(dir)).unwrap();
1136 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
1137 }
1138
1139 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
1140 markdown_walk_builder(
1141 temp.path(),
1142 &MarkdownWalkOptions {
1143 skip_vendor_dirs: skip,
1144 respect_gitignore: false,
1148 },
1149 )
1150 .build()
1151 .flatten()
1152 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1153 .map(|e| e.path().to_path_buf())
1154 .collect()
1155 };
1156
1157 let skipped = walk(true);
1158 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1159 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
1160 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
1161
1162 let unskipped = walk(false);
1163 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1164 }
1165
1166 #[test]
1167 fn explicit_file_name_glob_extracts_literal_extensions() {
1168 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
1169 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
1170 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
1171 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
1172 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
1173 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
1174 }
1175
1176 #[test]
1177 fn explicit_file_name_glob_rejects_unpinned_patterns() {
1178 for pattern in [
1179 "docs/",
1180 "docs/**",
1181 "docs",
1182 "*",
1183 "**",
1184 "**/*",
1185 "*.*",
1186 "*.md*",
1187 "*.{md,jinja}",
1188 "*.md?",
1189 "data.[ch]",
1190 "!drafts/*.md.jinja",
1191 "",
1192 "**/Makefile",
1193 "*.",
1194 ] {
1195 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
1196 }
1197 }
1198
1199 #[test]
1200 fn explicit_include_matchers_match_full_relative_paths() {
1201 let matchers = ExplicitIncludeMatchers::new(&[
1202 "**/*.md.jinja".to_string(),
1203 "docs/**".to_string(),
1204 "templates/NOTES.tmpl".to_string(),
1205 ]);
1206 assert!(!matchers.is_empty());
1207 assert!(matchers.matches_relative_path("test.md.jinja"));
1208 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
1209 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
1210 assert!(!matchers.matches_relative_path("docs/anything.txt"));
1212 assert!(!matchers.matches_relative_path("test.jinja"));
1213 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
1216 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
1217
1218 let globs: Vec<_> = matchers.file_name_globs().collect();
1219 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
1220 }
1221
1222 #[test]
1223 fn explicit_include_matchers_follow_gitignore_anchoring() {
1224 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
1226 assert!(unanchored.matches_relative_path("test.md.jinja"));
1227 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
1228
1229 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
1231 assert!(anchored.matches_relative_path("docs/a.txt"));
1232 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
1233 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
1234
1235 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
1237 assert!(rooted.matches_relative_path("NOTES.tmpl"));
1238 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
1239 }
1240
1241 #[test]
1242 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
1243 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
1244 assert!(matchers.is_empty());
1245 assert!(!matchers.matches_relative_path("x.md.jinja"));
1246 }
1247
1248 #[test]
1249 fn explicit_include_matchers_skip_invalid_globs() {
1250 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
1253 assert!(matchers.matches_relative_path("ok.md.jinja"));
1254 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
1255 }
1256
1257 #[test]
1258 fn exclude_matchers_expand_directory_patterns() {
1259 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
1260 assert!(matchers.is_match("drafts"));
1261 assert!(
1262 matchers.is_match("drafts/inner.md"),
1263 "directory pattern must match contents"
1264 );
1265 assert!(matchers.is_match("note.tmp.md"));
1266 assert!(!matchers.is_match("docs/guide.md"));
1267 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
1268 assert!(matchers.invalid.is_empty());
1269 }
1270
1271 #[test]
1272 fn expand_home_prefix_expands_only_a_leading_tilde() {
1273 let home = Path::new("/home/dev");
1274 assert_eq!(
1275 expand_home_prefix_impl("~/.cursor/plans", Some(home)),
1276 "/home/dev/.cursor/plans"
1277 );
1278 assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
1279 assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
1280 }
1281
1282 #[test]
1283 fn expand_home_prefix_leaves_interior_tildes_alone() {
1284 let home = Path::new("/home/dev");
1285 for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
1287 assert_eq!(
1288 expand_home_prefix_impl(pattern, Some(home)),
1289 pattern,
1290 "{pattern:?} must be left as written"
1291 );
1292 }
1293 }
1294
1295 #[test]
1296 fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
1297 assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
1298 }
1299
1300 #[test]
1301 fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
1302 let temp = tempdir().unwrap();
1303 let base = canonicalize_for_matching(temp.path()).unwrap();
1306 let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
1307 assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
1308 }
1309
1310 #[test]
1311 fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
1312 let temp = tempdir().unwrap();
1315 let canonical = canonicalize_for_matching(temp.path()).unwrap();
1316 let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
1317 assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
1318 }
1319
1320 #[test]
1321 fn normalize_pattern_for_base_leaves_other_patterns_alone() {
1322 let temp = tempdir().unwrap();
1323 let base = canonicalize_for_matching(temp.path()).unwrap();
1324 assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
1326 assert_eq!(
1329 normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
1330 "/somewhere/else/**"
1331 );
1332 assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
1334 }
1335
1336 #[test]
1337 fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
1338 assert_eq!(
1341 strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1342 r"C:\Users\dev\AppData\Local\Temp\x"
1343 );
1344 assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1345 assert_eq!(
1347 strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1348 r"\\server\share\docs"
1349 );
1350 }
1351
1352 #[test]
1353 fn strip_verbatim_prefix_leaves_other_paths_alone() {
1354 for path in [
1355 "/home/dev/docs",
1356 r"C:\Users\dev",
1357 r"\\server\share",
1358 r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1360 r"\\?\",
1361 "",
1362 ] {
1363 assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1364 }
1365 }
1366
1367 #[test]
1368 fn expand_directory_pattern_expands_a_literal_final_component() {
1369 assert_eq!(
1372 expand_directory_pattern("**/.cursor/plans"),
1373 vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1374 );
1375 assert_eq!(
1376 expand_directory_pattern("docs/**/drafts"),
1377 vec!["docs/**/drafts", "docs/**/drafts/**"]
1378 );
1379 assert_eq!(
1381 expand_directory_pattern("logs/{a,b}"),
1382 vec!["logs/{a,b}", "logs/{a,b}/**"]
1383 );
1384 }
1385
1386 #[test]
1387 fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1388 for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1391 assert_eq!(
1392 expand_directory_pattern(pattern),
1393 vec![pattern.to_string()],
1394 "{pattern:?} must not gain a contents-expansion"
1395 );
1396 }
1397 }
1398
1399 #[test]
1400 fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1401 let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1402 let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1403 assert!(
1404 matchers.excludes_file(None, excluded),
1405 "an absolute pattern must match the absolute path when there is no relative form"
1406 );
1407 assert_eq!(
1408 matchers.matched_pattern_for_file(None, excluded),
1409 Some("/home/dev/.cursor/plans/**")
1410 );
1411 assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1414 assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1415 }
1416
1417 #[test]
1418 fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1419 let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1422 assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1423 assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1424 }
1425
1426 #[test]
1427 fn exclude_matchers_report_invalid_patterns() {
1428 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1429 assert_eq!(matchers.invalid.len(), 1);
1430 assert_eq!(matchers.invalid[0].0, "[");
1431 assert!(matchers.is_match("ok.md"));
1432 }
1433
1434 #[test]
1435 fn path_relative_to_strips_through_symlinked_base() {
1436 let temp = tempdir().unwrap();
1437 let base = temp.path().join("base");
1438 fs::create_dir_all(base.join("docs")).unwrap();
1439 fs::write(base.join("docs/a.md"), "# hi").unwrap();
1440
1441 assert_eq!(
1442 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
1443 Some("docs/a.md")
1444 );
1445 assert_eq!(
1446 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
1447 Some("a.md")
1448 );
1449 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
1450 }
1451}