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/// File extensions rumdl treats as markdown, lowercase.
22pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
23
24/// Whether `ext` is a markdown extension. Matches case-insensitively so
25/// conventional variants like `Rmd` (and shouting-case `MD`) qualify.
26#[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/// Whether `path` has a markdown extension.
33#[inline]
34pub fn has_markdown_extension(path: &Path) -> bool {
35    path.extension().is_some_and(is_markdown_extension)
36}
37
38/// Ignore-handling options applied to a markdown discovery walk.
39#[derive(Debug, Clone)]
40pub struct MarkdownWalkOptions {
41    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
42    /// and parent ignore files. Driven by `global.respect_gitignore`.
43    pub respect_gitignore: bool,
44    /// Skip `.git`, `node_modules`, and `target` directories outright, even
45    /// when gitignore handling is disabled or would not cover them.
46    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
58/// Apply the shared ignore-handling configuration to a walker.
59///
60/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
61/// same as a visible one); generated content is kept out by gitignore
62/// semantics and, for callers that opt in, the vendor-directory skip.
63/// `.markdownlintignore` is honored for markdownlint compatibility.
64pub 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        // Honor ignore files even outside a git repository.
74        .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
85/// Build a walker over `root` configured with the shared options.
86pub 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
92/// Expands directory-style patterns to also match files within them.
93/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
94/// the directory itself and all contents recursively.
95///
96/// Patterns containing glob characters (*, ?, [) are returned unchanged.
97pub 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(),     // Match the directory itself
105        format!("{base}/**"), // Match everything underneath
106    ]
107}
108
109/// Compiled `exclude` patterns with directory-pattern expansion applied.
110///
111/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
112/// root-relative path (the CLI relativizes against the project root, the
113/// LSP against the containing workspace root) so patterns like
114/// `docs/drafts` behave identically everywhere.
115pub struct ExcludeMatchers {
116    matchers: Vec<(String, GlobMatcher)>,
117    /// Patterns that failed to compile, with their errors. Callers decide
118    /// how to surface these (CLI prints to stderr, LSP logs).
119    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    /// The first pattern matching `relative_path`, if any.
140    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
152/// Relativize `path` against `base` for exclude-pattern matching,
153/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
154/// path-representation differences don't defeat the prefix strip. Returns
155/// `None` when `path` is not under `base`.
156///
157/// Separators are normalized to `/` on Windows, following the project
158/// convention for path strings; globset matches either form, but log
159/// output and assertions see one canonical shape.
160pub 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}