Skip to main content

rumdl_lib/
discovery.rs

1//! Shared markdown file discovery semantics.
2//!
3//! The CLI walker (`file_processor::discovery` in the binary crate) and the
4//! LSP workspace index scanner answer the same question: which files does
5//! rumdl process here? The pieces of that answer that must never diverge
6//! live in this module:
7//!
8//! - the markdown extension set and how it is matched,
9//! - how ignore-file handling (`.gitignore`, `.markdownlintignore`, hidden
10//!   entries) is configured on a walker,
11//! - how `exclude` patterns from config are expanded and matched.
12//!
13//! Callers still differ deliberately: the LSP skips `.git`/`node_modules`/
14//! `target` outright as an editor-performance safety net, while the CLI
15//! walks whatever gitignore semantics allow.
16
17use globset::{Glob, GlobMatcher};
18use std::ffi::OsStr;
19use std::path::Path;
20
21/// Glob metacharacters recognized when deciding whether an include pattern
22/// names files explicitly.
23const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
24
25/// The file-name glob of an `include` pattern that explicitly names files,
26/// if it does.
27///
28/// A pattern names files explicitly when its final path component pins a
29/// literal dotted suffix: a wildcard stem ending in a literal extension
30/// chain (`**/*.md.jinja` yields `*.md.jinja`) or a fully literal file name
31/// with an extension (`templates/NOTES.tmpl` yields `NOTES.tmpl`). Such
32/// patterns widen the lintable-file filter beyond the standard markdown
33/// extensions: the user has spelled out exactly which files to process.
34///
35/// Directory patterns (`docs/`, `docs/**`), bare wildcards (`*`, `**/*`),
36/// patterns whose extension itself contains wildcards (`*.md*`,
37/// `*.{md,jinja}`), and negations (`!drafts/*.md.jinja`) yield `None`; they
38/// express "look here" or "not this", not "this exact kind of file", so the
39/// markdown-only filter stays in force for them.
40pub 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    // The literal tail after the last glob metacharacter (the whole
49    // component when there is none) must end in a non-empty extension.
50    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
60/// Compiled matchers for the explicitly-named files in a set of config
61/// `include` patterns (see [`explicit_file_name_glob`]).
62///
63/// The CLI walker consults this in two places that otherwise restrict
64/// discovery to markdown extensions: the walker's file-type filter and the
65/// final lintable-file filter. The type filter can only match file names,
66/// so it uses the (over-inclusive) file-name globs; the final filter is
67/// the precise gate and matches the full pattern against the root-relative
68/// path. Without the path check, a broad sibling pattern like `docs/**`
69/// would inherit the non-standard-extension allowance of an explicit
70/// pattern like `templates/NOTES.tmpl` for every file sharing its name.
71///
72/// Path matching follows gitignore anchoring: patterns without a `/` match
73/// at any depth, patterns with one are anchored to the root the relative
74/// path was computed against. `*` does not cross directory separators.
75///
76/// Invalid globs are skipped silently; the caller's override handling
77/// already warns about unparseable include patterns.
78pub 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    /// The file-name globs, e.g. for registering on a walker type filter.
119    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    /// Whether the root-relative `path` matches any explicit include
124    /// pattern in full.
125    pub fn matches_relative_path(&self, path: &str) -> bool {
126        self.matchers.iter().any(|m| m.path_matcher.is_match(path))
127    }
128}
129
130/// File extensions rumdl treats as markdown, lowercase.
131pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
132
133/// Whether `ext` is a markdown extension. Matches case-insensitively so
134/// conventional variants like `Rmd` (and shouting-case `MD`) qualify.
135#[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/// Whether `path` has a markdown extension.
142#[inline]
143pub fn has_markdown_extension(path: &Path) -> bool {
144    path.extension().is_some_and(is_markdown_extension)
145}
146
147/// Ignore-handling options applied to a markdown discovery walk.
148#[derive(Debug, Clone)]
149pub struct MarkdownWalkOptions {
150    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
151    /// and parent ignore files. Driven by `global.respect_gitignore`.
152    pub respect_gitignore: bool,
153    /// Skip `.git`, `node_modules`, and `target` directories outright, even
154    /// when gitignore handling is disabled or would not cover them.
155    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
167/// Apply the shared ignore-handling configuration to a walker.
168///
169/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
170/// same as a visible one); generated content is kept out by gitignore
171/// semantics and, for callers that opt in, the vendor-directory skip.
172/// `.markdownlintignore` is honored for markdownlint compatibility.
173pub 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        // Honor ignore files even outside a git repository.
183        .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
194/// Build a walker over `root` configured with the shared options.
195pub 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
201/// Expands directory-style patterns to also match files within them.
202/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
203/// the directory itself and all contents recursively.
204///
205/// Patterns containing glob characters (*, ?, [) are returned unchanged.
206pub 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(),     // Match the directory itself
214        format!("{base}/**"), // Match everything underneath
215    ]
216}
217
218/// Compiled `exclude` patterns with directory-pattern expansion applied.
219///
220/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
221/// root-relative path (the CLI relativizes against the project root, the
222/// LSP against the containing workspace root) so patterns like
223/// `docs/drafts` behave identically everywhere.
224pub struct ExcludeMatchers {
225    matchers: Vec<(String, GlobMatcher)>,
226    /// Patterns that failed to compile, with their errors. Callers decide
227    /// how to surface these (CLI prints to stderr, LSP logs).
228    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    /// The first pattern matching `relative_path`, if any.
249    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
261/// Relativize `path` against `base` for exclude-pattern matching,
262/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
263/// path-representation differences don't defeat the prefix strip. Returns
264/// `None` when `path` is not under `base`.
265///
266/// Separators are normalized to `/` on Windows, following the project
267/// convention for path strings; globset matches either form, but log
268/// output and assertions see one canonical shape.
269pub 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                    // Disable gitignore handling so ambient .gitignore files in the
380                    // temp directory's ancestry cannot mask the vendor-dir filtering
381                    // this test exercises.
382                    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        // The directory pattern must not widen the filter to arbitrary files.
446        assert!(!matchers.matches_relative_path("docs/anything.txt"));
447        assert!(!matchers.matches_relative_path("test.jinja"));
448        // A broad sibling pattern must not inherit the literal pattern's
449        // allowance for files that merely share its name.
450        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        // No slash: matches at any depth.
460        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        // Slash: anchored to the root, and `*` does not cross separators.
465        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        // Leading slash: anchored, slash stripped for matching.
471        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        // The unclosed bracket pins a literal `.tmpl` suffix but fails glob
486        // compilation; it must be skipped without poisoning valid patterns.
487        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}