1use globset::{Glob, GlobMatcher};
18use std::ffi::OsStr;
19use std::path::Path;
20
21pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
23
24#[inline]
27pub fn is_markdown_extension(ext: &OsStr) -> bool {
28 ext.to_str()
29 .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
30}
31
32#[inline]
34pub fn has_markdown_extension(path: &Path) -> bool {
35 path.extension().is_some_and(is_markdown_extension)
36}
37
38#[derive(Debug, Clone)]
40pub struct MarkdownWalkOptions {
41 pub respect_gitignore: bool,
44 pub skip_vendor_dirs: bool,
47}
48
49impl Default for MarkdownWalkOptions {
50 fn default() -> Self {
51 Self {
52 respect_gitignore: true,
53 skip_vendor_dirs: false,
54 }
55 }
56}
57
58pub fn apply_markdown_walk_options(builder: &mut ignore::WalkBuilder, options: &MarkdownWalkOptions) {
65 let gitignore = options.respect_gitignore;
66 builder
67 .ignore(gitignore)
68 .git_ignore(gitignore)
69 .git_global(gitignore)
70 .git_exclude(gitignore)
71 .parents(gitignore)
72 .hidden(false)
73 .require_git(false)
75 .add_custom_ignore_filename(".markdownlintignore");
76
77 if options.skip_vendor_dirs {
78 builder.filter_entry(|entry| {
79 let name = entry.file_name().to_str().unwrap_or("");
80 name != ".git" && name != "node_modules" && name != "target"
81 });
82 }
83}
84
85pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
87 let mut builder = ignore::WalkBuilder::new(root);
88 apply_markdown_walk_options(&mut builder, options);
89 builder
90}
91
92pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
98 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
99 return vec![pattern.to_string()];
100 }
101
102 let base = pattern.trim_end_matches('/');
103 vec![
104 base.to_string(), format!("{base}/**"), ]
107}
108
109pub struct ExcludeMatchers {
116 matchers: Vec<(String, GlobMatcher)>,
117 pub invalid: Vec<(String, String)>,
120}
121
122impl ExcludeMatchers {
123 pub fn new(patterns: &[String]) -> Self {
124 let mut matchers = Vec::new();
125 let mut invalid = Vec::new();
126 for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
127 match Glob::new(&pattern) {
128 Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
129 Err(e) => invalid.push((pattern, e.to_string())),
130 }
131 }
132 Self { matchers, invalid }
133 }
134
135 pub fn is_empty(&self) -> bool {
136 self.matchers.is_empty()
137 }
138
139 pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
141 self.matchers
142 .iter()
143 .find(|(_, matcher)| matcher.is_match(relative_path))
144 .map(|(pattern, _)| pattern.as_str())
145 }
146
147 pub fn is_match(&self, relative_path: &str) -> bool {
148 self.matched_pattern(relative_path).is_some()
149 }
150}
151
152pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
161 let canonical_base = base.canonicalize().ok()?;
162 let canonical_path = path.canonicalize().ok()?;
163 canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
164 let rel = rel.to_string_lossy();
165 if cfg!(windows) {
166 rel.replace('\\', "/")
167 } else {
168 rel.to_string()
169 }
170 })
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use std::fs;
177 use tempfile::tempdir;
178
179 #[test]
180 fn markdown_extensions_match_case_insensitively() {
181 for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
182 assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
183 }
184 for ext in ["rs", "txt", "mdq", ""] {
185 assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
186 }
187 assert!(has_markdown_extension(Path::new("a/b/README.md")));
188 assert!(has_markdown_extension(Path::new("notebook.Rmd")));
189 assert!(!has_markdown_extension(Path::new("no_extension")));
190 assert!(!has_markdown_extension(Path::new("lib.rs")));
191 }
192
193 #[test]
194 fn walk_includes_hidden_files() {
195 let temp = tempdir().unwrap();
196 fs::create_dir_all(temp.path().join(".github")).unwrap();
197 fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
198 fs::write(temp.path().join("README.md"), "# hi").unwrap();
199
200 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
201 .build()
202 .flatten()
203 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
204 .map(|e| e.path().to_path_buf())
205 .collect();
206 assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
207 assert!(files.iter().any(|p| p.ends_with("README.md")));
208 }
209
210 #[test]
211 fn walk_honors_gitignore_when_enabled_only() {
212 let temp = tempdir().unwrap();
213 fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
214 fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
215 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
216
217 let walk = |respect: bool| -> Vec<std::path::PathBuf> {
218 markdown_walk_builder(
219 temp.path(),
220 &MarkdownWalkOptions {
221 respect_gitignore: respect,
222 ..Default::default()
223 },
224 )
225 .build()
226 .flatten()
227 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
228 .map(|e| e.path().to_path_buf())
229 .collect()
230 };
231
232 let respected = walk(true);
233 assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
234 assert!(respected.iter().any(|p| p.ends_with("kept.md")));
235
236 let unrespected = walk(false);
237 assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
238 }
239
240 #[test]
241 fn walk_honors_markdownlintignore() {
242 let temp = tempdir().unwrap();
243 fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
244 fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
245 fs::write(temp.path().join("kept.md"), "# hi").unwrap();
246
247 let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
248 .build()
249 .flatten()
250 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
251 .map(|e| e.path().to_path_buf())
252 .collect();
253 assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
254 assert!(files.iter().any(|p| p.ends_with("kept.md")));
255 }
256
257 #[test]
258 fn vendor_dirs_skipped_only_when_requested() {
259 let temp = tempdir().unwrap();
260 for dir in ["node_modules", "target", "src"] {
261 fs::create_dir_all(temp.path().join(dir)).unwrap();
262 fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
263 }
264
265 let walk = |skip: bool| -> Vec<std::path::PathBuf> {
266 markdown_walk_builder(
267 temp.path(),
268 &MarkdownWalkOptions {
269 skip_vendor_dirs: skip,
270 ..Default::default()
271 },
272 )
273 .build()
274 .flatten()
275 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
276 .map(|e| e.path().to_path_buf())
277 .collect()
278 };
279
280 let skipped = walk(true);
281 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
282 assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
283 assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
284
285 let unskipped = walk(false);
286 assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
287 }
288
289 #[test]
290 fn exclude_matchers_expand_directory_patterns() {
291 let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
292 assert!(matchers.is_match("drafts"));
293 assert!(
294 matchers.is_match("drafts/inner.md"),
295 "directory pattern must match contents"
296 );
297 assert!(matchers.is_match("note.tmp.md"));
298 assert!(!matchers.is_match("docs/guide.md"));
299 assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
300 assert!(matchers.invalid.is_empty());
301 }
302
303 #[test]
304 fn exclude_matchers_report_invalid_patterns() {
305 let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
306 assert_eq!(matchers.invalid.len(), 1);
307 assert_eq!(matchers.invalid[0].0, "[");
308 assert!(matchers.is_match("ok.md"));
309 }
310
311 #[test]
312 fn path_relative_to_strips_through_symlinked_base() {
313 let temp = tempdir().unwrap();
314 let base = temp.path().join("base");
315 fs::create_dir_all(base.join("docs")).unwrap();
316 fs::write(base.join("docs/a.md"), "# hi").unwrap();
317
318 assert_eq!(
319 path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
320 Some("docs/a.md")
321 );
322 assert_eq!(
323 path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
324 Some("a.md")
325 );
326 assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
327 }
328}