1use globset::{Glob, GlobMatcher};
18use std::ffi::OsStr;
19use std::path::Path;
20
21const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
24
25pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
41 if pattern.starts_with('!') {
42 return None;
43 }
44 let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
45 if file_name.is_empty() {
46 return None;
47 }
48 let literal_tail = match file_name.rfind(GLOB_METACHARS) {
51 Some(idx) => &file_name[idx + 1..],
52 None => file_name,
53 };
54 match literal_tail.rsplit_once('.') {
55 Some((_, ext)) if !ext.is_empty() => Some(file_name),
56 _ => None,
57 }
58}
59
60pub struct ExplicitIncludeMatchers {
79 matchers: Vec<ExplicitInclude>,
80}
81
82struct ExplicitInclude {
83 file_name_glob: String,
84 path_matcher: GlobMatcher,
85}
86
87impl ExplicitIncludeMatchers {
88 pub fn new(patterns: &[String]) -> Self {
89 let matchers = patterns
90 .iter()
91 .filter_map(|pattern| {
92 let file_name_glob = explicit_file_name_glob(pattern)?;
93 let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
94 anchored.to_string()
95 } else if pattern.contains('/') {
96 pattern.clone()
97 } else {
98 format!("**/{pattern}")
99 };
100 let path_matcher = globset::GlobBuilder::new(&path_glob)
101 .literal_separator(true)
102 .build()
103 .ok()?
104 .compile_matcher();
105 Some(ExplicitInclude {
106 file_name_glob: file_name_glob.to_string(),
107 path_matcher,
108 })
109 })
110 .collect();
111 Self { matchers }
112 }
113
114 pub fn is_empty(&self) -> bool {
115 self.matchers.is_empty()
116 }
117
118 pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
120 self.matchers.iter().map(|m| m.file_name_glob.as_str())
121 }
122
123 pub fn matches_relative_path(&self, path: &str) -> bool {
126 self.matchers.iter().any(|m| m.path_matcher.is_match(path))
127 }
128}
129
130pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
132
133#[inline]
136pub fn is_markdown_extension(ext: &OsStr) -> bool {
137 ext.to_str()
138 .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
139}
140
141#[inline]
143pub fn has_markdown_extension(path: &Path) -> bool {
144 path.extension().is_some_and(is_markdown_extension)
145}
146
147#[derive(Debug, Clone)]
149pub struct MarkdownWalkOptions {
150 pub respect_gitignore: bool,
153 pub skip_vendor_dirs: bool,
156}
157
158impl Default for MarkdownWalkOptions {
159 fn default() -> Self {
160 Self {
161 respect_gitignore: true,
162 skip_vendor_dirs: false,
163 }
164 }
165}
166
167pub fn apply_markdown_walk_options(builder: &mut ignore::WalkBuilder, options: &MarkdownWalkOptions) {
174 let gitignore = options.respect_gitignore;
175 builder
176 .ignore(gitignore)
177 .git_ignore(gitignore)
178 .git_global(gitignore)
179 .git_exclude(gitignore)
180 .parents(gitignore)
181 .hidden(false)
182 .require_git(false)
184 .add_custom_ignore_filename(".markdownlintignore");
185
186 if options.skip_vendor_dirs {
187 builder.filter_entry(|entry| {
188 let name = entry.file_name().to_str().unwrap_or("");
189 name != ".git" && name != "node_modules" && name != "target"
190 });
191 }
192}
193
194pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
196 let mut builder = ignore::WalkBuilder::new(root);
197 apply_markdown_walk_options(&mut builder, options);
198 builder
199}
200
201pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
207 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
208 return vec![pattern.to_string()];
209 }
210
211 let base = pattern.trim_end_matches('/');
212 vec![
213 base.to_string(), format!("{base}/**"), ]
216}
217
218pub struct ExcludeMatchers {
225 matchers: Vec<(String, GlobMatcher)>,
226 pub invalid: Vec<(String, String)>,
229}
230
231impl ExcludeMatchers {
232 pub fn new(patterns: &[String]) -> Self {
233 let mut matchers = Vec::new();
234 let mut invalid = Vec::new();
235 for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
236 match Glob::new(&pattern) {
237 Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
238 Err(e) => invalid.push((pattern, e.to_string())),
239 }
240 }
241 Self { matchers, invalid }
242 }
243
244 pub fn is_empty(&self) -> bool {
245 self.matchers.is_empty()
246 }
247
248 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
250 self.matchers
251 .iter()
252 .find(|(_, matcher)| matcher.is_match(relative_path))
253 .map(|(pattern, _)| pattern.as_str())
254 }
255
256 pub fn is_match(&self, relative_path: &str) -> bool {
257 self.matched_pattern(relative_path).is_some()
258 }
259}
260
261pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
270 let canonical_base = base.canonicalize().ok()?;
271 let canonical_path = path.canonicalize().ok()?;
272 canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
273 let rel = rel.to_string_lossy();
274 if cfg!(windows) {
275 rel.replace('\\', "/")
276 } else {
277 rel.to_string()
278 }
279 })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use std::fs;
286 use tempfile::tempdir;
287
288 #[test]
289 fn markdown_extensions_match_case_insensitively() {
290 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
291 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
292 }
293 for ext in ["rs", "txt", "mdq", ""] {
294 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
295 }
296 assert!(has_markdown_extension(Path::new("a/b/README.md")));
297 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
298 assert!(!has_markdown_extension(Path::new("no_extension")));
299 assert!(!has_markdown_extension(Path::new("lib.rs")));
300 }
301
302 #[test]
303 fn walk_includes_hidden_files() {
304 let temp = tempdir().unwrap();
305 fs::create_dir_all(temp.path().join(".github")).unwrap();
306 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
307 fs::write(temp.path().join("README.md"), "# hi").unwrap();
308
309 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
310 .build()
311 .flatten()
312 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
313 .map(|e| e.path().to_path_buf())
314 .collect();
315 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
316 assert!(files.iter().any(|p| p.ends_with("README.md")));
317 }
318
319 #[test]
320 fn walk_honors_gitignore_when_enabled_only() {
321 let temp = tempdir().unwrap();
322 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
323 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
324 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
325
326 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
327 markdown_walk_builder(
328 temp.path(),
329 &MarkdownWalkOptions {
330 respect_gitignore: respect,
331 ..Default::default()
332 },
333 )
334 .build()
335 .flatten()
336 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
337 .map(|e| e.path().to_path_buf())
338 .collect()
339 };
340
341 let respected = walk(true);
342 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
343 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
344
345 let unrespected = walk(false);
346 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
347 }
348
349 #[test]
350 fn walk_honors_markdownlintignore() {
351 let temp = tempdir().unwrap();
352 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
353 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
354 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
355
356 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
357 .build()
358 .flatten()
359 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
360 .map(|e| e.path().to_path_buf())
361 .collect();
362 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
363 assert!(files.iter().any(|p| p.ends_with("kept.md")));
364 }
365
366 #[test]
367 fn vendor_dirs_skipped_only_when_requested() {
368 let temp = tempdir().unwrap();
369 for dir in ["node_modules", "target", "src"] {
370 fs::create_dir_all(temp.path().join(dir)).unwrap();
371 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
372 }
373
374 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
375 markdown_walk_builder(
376 temp.path(),
377 &MarkdownWalkOptions {
378 skip_vendor_dirs: skip,
379 respect_gitignore: false,
383 },
384 )
385 .build()
386 .flatten()
387 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
388 .map(|e| e.path().to_path_buf())
389 .collect()
390 };
391
392 let skipped = walk(true);
393 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
394 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
395 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
396
397 let unskipped = walk(false);
398 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
399 }
400
401 #[test]
402 fn explicit_file_name_glob_extracts_literal_extensions() {
403 assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
404 assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
405 assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
406 assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
407 assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
408 assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
409 }
410
411 #[test]
412 fn explicit_file_name_glob_rejects_unpinned_patterns() {
413 for pattern in [
414 "docs/",
415 "docs/**",
416 "docs",
417 "*",
418 "**",
419 "**/*",
420 "*.*",
421 "*.md*",
422 "*.{md,jinja}",
423 "*.md?",
424 "data.[ch]",
425 "!drafts/*.md.jinja",
426 "",
427 "**/Makefile",
428 "*.",
429 ] {
430 assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
431 }
432 }
433
434 #[test]
435 fn explicit_include_matchers_match_full_relative_paths() {
436 let matchers = ExplicitIncludeMatchers::new(&[
437 "**/*.md.jinja".to_string(),
438 "docs/**".to_string(),
439 "templates/NOTES.tmpl".to_string(),
440 ]);
441 assert!(!matchers.is_empty());
442 assert!(matchers.matches_relative_path("test.md.jinja"));
443 assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
444 assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
445 assert!(!matchers.matches_relative_path("docs/anything.txt"));
447 assert!(!matchers.matches_relative_path("test.jinja"));
448 assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
451 assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
452
453 let globs: Vec<_> = matchers.file_name_globs().collect();
454 assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
455 }
456
457 #[test]
458 fn explicit_include_matchers_follow_gitignore_anchoring() {
459 let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
461 assert!(unanchored.matches_relative_path("test.md.jinja"));
462 assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
463
464 let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
466 assert!(anchored.matches_relative_path("docs/a.txt"));
467 assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
468 assert!(!anchored.matches_relative_path("other/docs/a.txt"));
469
470 let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
472 assert!(rooted.matches_relative_path("NOTES.tmpl"));
473 assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
474 }
475
476 #[test]
477 fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
478 let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
479 assert!(matchers.is_empty());
480 assert!(!matchers.matches_relative_path("x.md.jinja"));
481 }
482
483 #[test]
484 fn explicit_include_matchers_skip_invalid_globs() {
485 let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
488 assert!(matchers.matches_relative_path("ok.md.jinja"));
489 assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
490 }
491
492 #[test]
493 fn exclude_matchers_expand_directory_patterns() {
494 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
495 assert!(matchers.is_match("drafts"));
496 assert!(
497 matchers.is_match("drafts/inner.md"),
498 "directory pattern must match contents"
499 );
500 assert!(matchers.is_match("note.tmp.md"));
501 assert!(!matchers.is_match("docs/guide.md"));
502 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
503 assert!(matchers.invalid.is_empty());
504 }
505
506 #[test]
507 fn exclude_matchers_report_invalid_patterns() {
508 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
509 assert_eq!(matchers.invalid.len(), 1);
510 assert_eq!(matchers.invalid[0].0, "[");
511 assert!(matchers.is_match("ok.md"));
512 }
513
514 #[test]
515 fn path_relative_to_strips_through_symlinked_base() {
516 let temp = tempdir().unwrap();
517 let base = temp.path().join("base");
518 fs::create_dir_all(base.join("docs")).unwrap();
519 fs::write(base.join("docs/a.md"), "# hi").unwrap();
520
521 assert_eq!(
522 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
523 Some("docs/a.md")
524 );
525 assert_eq!(
526 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
527 Some("a.md")
528 );
529 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
530 }
531}