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::borrow::Cow;
19use std::ffi::OsStr;
20use std::path::{Path, PathBuf};
21
22/// Glob metacharacters recognized when deciding whether an include pattern
23/// names files explicitly.
24const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
25
26/// The file-name glob of an `include` pattern that explicitly names files,
27/// if it does.
28///
29/// A pattern names files explicitly when its final path component pins a
30/// literal dotted suffix: a wildcard stem ending in a literal extension
31/// chain (`**/*.md.jinja` yields `*.md.jinja`) or a fully literal file name
32/// with an extension (`templates/NOTES.tmpl` yields `NOTES.tmpl`). Such
33/// patterns widen the lintable-file filter beyond the standard markdown
34/// extensions: the user has spelled out exactly which files to process.
35///
36/// Directory patterns (`docs/`, `docs/**`), bare wildcards (`*`, `**/*`),
37/// patterns whose extension itself contains wildcards (`*.md*`,
38/// `*.{md,jinja}`), and negations (`!drafts/*.md.jinja`) yield `None`; they
39/// express "look here" or "not this", not "this exact kind of file", so the
40/// markdown-only filter stays in force for them.
41pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
42    if pattern.starts_with('!') {
43        return None;
44    }
45    let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
46    if file_name.is_empty() {
47        return None;
48    }
49    // The literal tail after the last glob metacharacter (the whole
50    // component when there is none) must end in a non-empty extension.
51    let literal_tail = match file_name.rfind(GLOB_METACHARS) {
52        Some(idx) => &file_name[idx + 1..],
53        None => file_name,
54    };
55    match literal_tail.rsplit_once('.') {
56        Some((_, ext)) if !ext.is_empty() => Some(file_name),
57        _ => None,
58    }
59}
60
61/// Compiled matchers for the explicitly-named files in a set of config
62/// `include` patterns (see [`explicit_file_name_glob`]).
63///
64/// The CLI walker consults this in two places that otherwise restrict
65/// discovery to markdown extensions: the walker's file-type filter and the
66/// final lintable-file filter. The type filter can only match file names,
67/// so it uses the (over-inclusive) file-name globs; the final filter is
68/// the precise gate and matches the full pattern against the root-relative
69/// path. Without the path check, a broad sibling pattern like `docs/**`
70/// would inherit the non-standard-extension allowance of an explicit
71/// pattern like `templates/NOTES.tmpl` for every file sharing its name.
72///
73/// Path matching follows gitignore anchoring: patterns without a `/` match
74/// at any depth, patterns with one are anchored to the root the relative
75/// path was computed against. `*` does not cross directory separators.
76///
77/// Invalid globs are skipped silently; the caller's override handling
78/// already warns about unparseable include patterns.
79pub struct ExplicitIncludeMatchers {
80    matchers: Vec<ExplicitInclude>,
81}
82
83struct ExplicitInclude {
84    file_name_glob: String,
85    path_matcher: GlobMatcher,
86}
87
88impl ExplicitIncludeMatchers {
89    pub fn new(patterns: &[String]) -> Self {
90        let matchers = patterns
91            .iter()
92            .filter_map(|pattern| {
93                let file_name_glob = explicit_file_name_glob(pattern)?;
94                let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
95                    anchored.to_string()
96                } else if pattern.contains('/') {
97                    pattern.clone()
98                } else {
99                    format!("**/{pattern}")
100                };
101                let path_matcher = globset::GlobBuilder::new(&path_glob)
102                    .literal_separator(true)
103                    .build()
104                    .ok()?
105                    .compile_matcher();
106                Some(ExplicitInclude {
107                    file_name_glob: file_name_glob.to_string(),
108                    path_matcher,
109                })
110            })
111            .collect();
112        Self { matchers }
113    }
114
115    pub fn is_empty(&self) -> bool {
116        self.matchers.is_empty()
117    }
118
119    /// The file-name globs, e.g. for registering on a walker type filter.
120    pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
121        self.matchers.iter().map(|m| m.file_name_glob.as_str())
122    }
123
124    /// Whether the root-relative `path` matches any explicit include
125    /// pattern in full.
126    pub fn matches_relative_path(&self, path: &str) -> bool {
127        self.matchers.iter().any(|m| m.path_matcher.is_match(path))
128    }
129}
130
131/// File extensions rumdl treats as markdown, lowercase.
132pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
133
134/// Whether `ext` is a markdown extension. Matches case-insensitively so
135/// conventional variants like `Rmd` (and shouting-case `MD`) qualify.
136#[inline]
137pub fn is_markdown_extension(ext: &OsStr) -> bool {
138    ext.to_str()
139        .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
140}
141
142/// Whether `path` has a markdown extension.
143#[inline]
144pub fn has_markdown_extension(path: &Path) -> bool {
145    path.extension().is_some_and(is_markdown_extension)
146}
147
148/// Ignore-handling options applied to a markdown discovery walk.
149#[derive(Debug, Clone)]
150pub struct MarkdownWalkOptions {
151    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
152    /// and parent ignore files. Driven by `global.respect_gitignore`.
153    pub respect_gitignore: bool,
154    /// Skip `.git`, `node_modules`, and `target` directories outright, even
155    /// when gitignore handling is disabled or would not cover them.
156    pub skip_vendor_dirs: bool,
157}
158
159impl Default for MarkdownWalkOptions {
160    fn default() -> Self {
161        Self {
162            respect_gitignore: true,
163            skip_vendor_dirs: false,
164        }
165    }
166}
167
168/// Apply the shared ignore-handling configuration to a walker.
169///
170/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
171/// same as a visible one); generated content is kept out by gitignore
172/// semantics and, for callers that opt in, the vendor-directory skip.
173/// `.markdownlintignore` is honored for markdownlint compatibility.
174pub fn apply_markdown_walk_options(builder: &mut ignore::WalkBuilder, options: &MarkdownWalkOptions) {
175    let gitignore = options.respect_gitignore;
176    builder
177        .ignore(gitignore)
178        .git_ignore(gitignore)
179        .git_global(gitignore)
180        .git_exclude(gitignore)
181        .parents(gitignore)
182        .hidden(false)
183        // Honor ignore files even outside a git repository.
184        .require_git(false)
185        .add_custom_ignore_filename(".markdownlintignore");
186
187    if options.skip_vendor_dirs {
188        builder.filter_entry(|entry| {
189            let name = entry.file_name().to_str().unwrap_or("");
190            name != ".git" && name != "node_modules" && name != "target"
191        });
192    }
193}
194
195/// Build a walker over `root` configured with the shared options.
196pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
197    let mut builder = ignore::WalkBuilder::new(root);
198    apply_markdown_walk_options(&mut builder, options);
199    builder
200}
201
202/// Drop Windows' verbatim `\\?\` prefix from a canonicalized path string.
203///
204/// `std::fs::canonicalize` returns the verbatim form (`\\?\C:\Users\dev`) on
205/// Windows. That form is useless for pattern matching: it does not compare
206/// equal to the ordinary paths rumdl works with, and normalizing its
207/// separators for globbing mangles it into `//?/C:/Users/dev`, which matches
208/// nothing. Only a drive path (`\\?\C:\...`) and a UNC share
209/// (`\\?\UNC\server\share` -> `\\server\share`) are unwrapped; any other
210/// verbatim path names a device namespace that has no ordinary equivalent, so
211/// it is left alone.
212///
213/// Pure string logic, compiled on every platform so it stays under test where
214/// Windows is not available. Only the call sites are Windows-specific, and on
215/// other platforms no path ever carries this prefix.
216fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
217    // `\\?\UNC\server\share` -> `\\server\share`. The remainder already starts
218    // with one separator, so restoring the UNC form needs one more prepended.
219    if let Some(rest) = path.strip_prefix(r"\\?\UNC")
220        && rest.starts_with('\\')
221    {
222        return Cow::Owned(format!(r"\{rest}"));
223    }
224    let Some(rest) = path.strip_prefix(r"\\?\") else {
225        return Cow::Borrowed(path);
226    };
227    let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
228    if is_drive_path {
229        Cow::Borrowed(rest)
230    } else {
231        Cow::Borrowed(path)
232    }
233}
234
235/// Canonicalize `path` for pattern matching, or `None` when it cannot be
236/// resolved (a missing or unreadable file).
237///
238/// Canonical form is what patterns are matched against, so a symlinked
239/// location (`/home/dev` -> `/mnt/dev`, or a macOS `/var` -> `/private/var`)
240/// still matches. Windows' verbatim prefix is removed (see
241/// [`strip_verbatim_prefix`]).
242pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
243    let canonical = path.canonicalize().ok()?;
244    if !cfg!(windows) {
245        return Some(canonical);
246    }
247    let as_str = canonical.to_string_lossy();
248    Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
249}
250
251/// The user's home directory, or `None` when it cannot be resolved.
252///
253/// Canonicalized for matching (see [`canonicalize_for_matching`]), falling
254/// back to the path as reported when it cannot be canonicalized.
255///
256/// Wasm and WASI builds have no home directory to resolve, so patterns keep
257/// their `~` there (see [`expand_home_prefix`]).
258fn home_dir() -> Option<PathBuf> {
259    #[cfg(feature = "native")]
260    {
261        use etcetera::{BaseStrategy, choose_base_strategy};
262        choose_base_strategy()
263            .ok()
264            .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
265    }
266    #[cfg(not(feature = "native"))]
267    {
268        None
269    }
270}
271
272/// Expand a leading `~` in a path pattern to the user's home directory, so a
273/// user-level config (`~/.config/rumdl/rumdl.toml`) can name a home path
274/// without hardcoding a username.
275///
276/// Only a bare `~` and a `~/` prefix expand. `~` is a legal filename character
277/// everywhere else (editor backups like `notes.md~`, a literal `docs/~drafts`),
278/// so it is left alone there. `~user` is not expanded either: resolving another
279/// user's home needs the password database, and treating it as the current
280/// user's home would silently match the wrong directory.
281///
282/// The expansion is a glob pattern, so separators are normalized to `/` on
283/// Windows: `\` is globset's escape character, and matched paths are normalized
284/// the same way (see [`path_relative_to`]).
285pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
286    // Resolve the home directory only for a pattern that references it: every
287    // other pattern would otherwise pay for the lookup and its canonicalization.
288    if !has_home_prefix(pattern) {
289        return Cow::Borrowed(pattern);
290    }
291    expand_home_prefix_impl(pattern, home_dir().as_deref())
292}
293
294/// Whether `pattern` starts with a home reference (`~` or `~/`).
295fn has_home_prefix(pattern: &str) -> bool {
296    pattern == "~" || pattern.starts_with("~/")
297}
298
299fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
300    let Some(suffix) = (if pattern == "~" {
301        Some("")
302    } else {
303        pattern.strip_prefix("~/")
304    }) else {
305        return Cow::Borrowed(pattern);
306    };
307    let Some(home) = home else {
308        return Cow::Borrowed(pattern);
309    };
310
311    let home = normalize_pattern_separators(home.to_string_lossy());
312    let home = home.trim_end_matches('/');
313    if suffix.is_empty() {
314        Cow::Owned(home.to_string())
315    } else {
316        Cow::Owned(format!("{home}/{suffix}"))
317    }
318}
319
320/// Normalize path separators to `/` for glob matching. On Windows `\` is
321/// globset's escape character, so a native path must be rewritten before it can
322/// be used as - or matched against - a pattern. No-op on Unix, where `\` is a
323/// legal filename character.
324fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
325    if cfg!(windows) && path.contains('\\') {
326        Cow::Owned(path.replace('\\', "/"))
327    } else {
328        path
329    }
330}
331
332/// Normalize a config path pattern for matching against paths discovered under
333/// `base`: expand a leading `~`, then rewrite an absolute pattern as one
334/// relative to `base` when `base` contains it.
335///
336/// The rewrite is what makes an absolute pattern usable as a walker override:
337/// the `ignore` crate reads a leading `/` as "anchored to the walk base", so
338/// `/home/dev/docs/**` would otherwise be understood as
339/// `<base>/home/dev/docs/**` and match nothing. A pattern pointing outside
340/// `base` is left absolute - nothing under this walk can match it, which is the
341/// correct outcome.
342pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
343    let expanded = expand_home_prefix(pattern);
344    let Some(base) = base else {
345        return expanded.into_owned();
346    };
347    if !is_absolute_pattern(&expanded) {
348        return expanded.into_owned();
349    }
350
351    // Try the base as given and canonicalized, so a symlinked or
352    // non-canonical base (macOS `/var`, a Windows 8.3 short name) still strips.
353    let path = Path::new(expanded.as_ref());
354    let relative = path.strip_prefix(base).ok().or_else(|| {
355        let canonical = canonicalize_for_matching(base)?;
356        path.strip_prefix(canonical).ok()
357    });
358    match relative {
359        Some(relative) => normalize_pattern_separators(relative.to_string_lossy()).into_owned(),
360        None => expanded.into_owned(),
361    }
362}
363
364/// Expands directory-style patterns to also match files within them.
365/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
366/// the directory itself and all contents recursively. A leading `~` is
367/// expanded first (see [`expand_home_prefix`]).
368///
369/// The expansion is driven by the pattern's *final* component: it names a
370/// directory only when it holds no wildcard. `docs/*` therefore stays as
371/// written (it names direct children, and `docs/*/**` would newly exclude
372/// nested contents), while `**/.cursor/plans` gains its contents-expansion
373/// despite the wildcard earlier in the pattern.
374pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
375    let pattern = expand_home_prefix(pattern);
376    let base = pattern.trim_end_matches('/');
377    let final_component = base.rsplit('/').next().unwrap_or(base);
378
379    if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
380        return vec![pattern.to_string()];
381    }
382
383    vec![
384        base.to_string(),     // Match the directory itself
385        format!("{base}/**"), // Match everything underneath
386    ]
387}
388
389/// Compiled `exclude` patterns with directory-pattern expansion applied.
390///
391/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
392/// root-relative path (the CLI relativizes against the project root, the
393/// LSP against the containing workspace root) so patterns like
394/// `docs/drafts` behave identically everywhere.
395pub struct ExcludeMatchers {
396    matchers: Vec<(String, GlobMatcher)>,
397    /// Whether any pattern is absolute, i.e. whether matching has to consider
398    /// a file's absolute path at all. Keeps the common (all-relative) case
399    /// from paying for the canonicalization that check needs.
400    has_absolute: bool,
401    /// Patterns that failed to compile, with their errors. Callers decide
402    /// how to surface these (CLI prints to stderr, LSP logs).
403    pub invalid: Vec<(String, String)>,
404}
405
406/// Whether `pattern` names an absolute location. A leading `/` counts on every
407/// platform: patterns use `/` separators, so a Unix-style path stays absolute
408/// when the same config is read on Windows.
409pub fn is_absolute_pattern(pattern: &str) -> bool {
410    pattern.starts_with('/') || Path::new(pattern).is_absolute()
411}
412
413impl ExcludeMatchers {
414    pub fn new(patterns: &[String]) -> Self {
415        let mut matchers = Vec::new();
416        let mut invalid = Vec::new();
417        let mut has_absolute = false;
418        for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
419            has_absolute |= is_absolute_pattern(&pattern);
420            match Glob::new(&pattern) {
421                Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
422                Err(e) => invalid.push((pattern, e.to_string())),
423            }
424        }
425        Self {
426            matchers,
427            has_absolute,
428            invalid,
429        }
430    }
431
432    pub fn is_empty(&self) -> bool {
433        self.matchers.is_empty()
434    }
435
436    /// The first pattern matching `relative_path`, if any.
437    pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
438        self.matchers
439            .iter()
440            .find(|(_, matcher)| matcher.is_match(relative_path))
441            .map(|(pattern, _)| pattern.as_str())
442    }
443
444    pub fn is_match(&self, relative_path: &str) -> bool {
445        self.matched_pattern(relative_path).is_some()
446    }
447
448    /// The first pattern matching a file, if any.
449    ///
450    /// Both forms of the file are tried: its `relative` form (how patterns are
451    /// normally written - relative to the project or workspace root) and its
452    /// absolute path, which is what an absolute pattern matches. Absolute
453    /// patterns reach config either written literally or through `~` expansion,
454    /// and the walker's overrides cannot apply them (the `ignore` crate anchors
455    /// a leading `/` to the walk root), so this is where they take effect.
456    ///
457    /// Checking the absolute path cannot widen a relative pattern: globs are
458    /// anchored at the start of the matched string, so `drafts/**` never
459    /// matches `/home/dev/proj/drafts/note.md`.
460    ///
461    /// `absolute` is canonicalized before matching, since an expanded `~`
462    /// resolves to a canonical location. Files that cannot be canonicalized
463    /// (already deleted, unreadable) are matched as given.
464    pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
465        if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
466            return Some(pattern);
467        }
468        if !self.has_absolute {
469            return None;
470        }
471        let canonical = canonicalize_for_matching(absolute);
472        let absolute = canonical.as_deref().unwrap_or(absolute);
473        self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
474    }
475
476    /// Whether any pattern matches the file (see [`matched_pattern_for_file`](Self::matched_pattern_for_file)).
477    pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
478        self.matched_pattern_for_file(relative, absolute).is_some()
479    }
480}
481
482/// Relativize `path` against `base` for exclude-pattern matching,
483/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
484/// path-representation differences don't defeat the prefix strip. Returns
485/// `None` when `path` is not under `base`.
486///
487/// Separators are normalized to `/` on Windows, following the project
488/// convention for path strings; globset matches either form, but log
489/// output and assertions see one canonical shape.
490pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
491    let canonical_base = base.canonicalize().ok()?;
492    let canonical_path = path.canonicalize().ok()?;
493    canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
494        let rel = rel.to_string_lossy();
495        if cfg!(windows) {
496            rel.replace('\\', "/")
497        } else {
498            rel.to_string()
499        }
500    })
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use std::fs;
507    use tempfile::tempdir;
508
509    #[test]
510    fn markdown_extensions_match_case_insensitively() {
511        for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
512            assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
513        }
514        for ext in ["rs", "txt", "mdq", ""] {
515            assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
516        }
517        assert!(has_markdown_extension(Path::new("a/b/README.md")));
518        assert!(has_markdown_extension(Path::new("notebook.Rmd")));
519        assert!(!has_markdown_extension(Path::new("no_extension")));
520        assert!(!has_markdown_extension(Path::new("lib.rs")));
521    }
522
523    #[test]
524    fn walk_includes_hidden_files() {
525        let temp = tempdir().unwrap();
526        fs::create_dir_all(temp.path().join(".github")).unwrap();
527        fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
528        fs::write(temp.path().join("README.md"), "# hi").unwrap();
529
530        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
531            .build()
532            .flatten()
533            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
534            .map(|e| e.path().to_path_buf())
535            .collect();
536        assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
537        assert!(files.iter().any(|p| p.ends_with("README.md")));
538    }
539
540    #[test]
541    fn walk_honors_gitignore_when_enabled_only() {
542        let temp = tempdir().unwrap();
543        fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
544        fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
545        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
546
547        let walk = |respect: bool| -> Vec<std::path::PathBuf> {
548            markdown_walk_builder(
549                temp.path(),
550                &MarkdownWalkOptions {
551                    respect_gitignore: respect,
552                    ..Default::default()
553                },
554            )
555            .build()
556            .flatten()
557            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
558            .map(|e| e.path().to_path_buf())
559            .collect()
560        };
561
562        let respected = walk(true);
563        assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
564        assert!(respected.iter().any(|p| p.ends_with("kept.md")));
565
566        let unrespected = walk(false);
567        assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
568    }
569
570    #[test]
571    fn walk_honors_markdownlintignore() {
572        let temp = tempdir().unwrap();
573        fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
574        fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
575        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
576
577        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
578            .build()
579            .flatten()
580            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
581            .map(|e| e.path().to_path_buf())
582            .collect();
583        assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
584        assert!(files.iter().any(|p| p.ends_with("kept.md")));
585    }
586
587    #[test]
588    fn vendor_dirs_skipped_only_when_requested() {
589        let temp = tempdir().unwrap();
590        for dir in ["node_modules", "target", "src"] {
591            fs::create_dir_all(temp.path().join(dir)).unwrap();
592            fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
593        }
594
595        let walk = |skip: bool| -> Vec<std::path::PathBuf> {
596            markdown_walk_builder(
597                temp.path(),
598                &MarkdownWalkOptions {
599                    skip_vendor_dirs: skip,
600                    // Disable gitignore handling so ambient .gitignore files in the
601                    // temp directory's ancestry cannot mask the vendor-dir filtering
602                    // this test exercises.
603                    respect_gitignore: false,
604                },
605            )
606            .build()
607            .flatten()
608            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
609            .map(|e| e.path().to_path_buf())
610            .collect()
611        };
612
613        let skipped = walk(true);
614        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
615        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
616        assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
617
618        let unskipped = walk(false);
619        assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
620    }
621
622    #[test]
623    fn explicit_file_name_glob_extracts_literal_extensions() {
624        assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
625        assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
626        assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
627        assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
628        assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
629        assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
630    }
631
632    #[test]
633    fn explicit_file_name_glob_rejects_unpinned_patterns() {
634        for pattern in [
635            "docs/",
636            "docs/**",
637            "docs",
638            "*",
639            "**",
640            "**/*",
641            "*.*",
642            "*.md*",
643            "*.{md,jinja}",
644            "*.md?",
645            "data.[ch]",
646            "!drafts/*.md.jinja",
647            "",
648            "**/Makefile",
649            "*.",
650        ] {
651            assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
652        }
653    }
654
655    #[test]
656    fn explicit_include_matchers_match_full_relative_paths() {
657        let matchers = ExplicitIncludeMatchers::new(&[
658            "**/*.md.jinja".to_string(),
659            "docs/**".to_string(),
660            "templates/NOTES.tmpl".to_string(),
661        ]);
662        assert!(!matchers.is_empty());
663        assert!(matchers.matches_relative_path("test.md.jinja"));
664        assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
665        assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
666        // The directory pattern must not widen the filter to arbitrary files.
667        assert!(!matchers.matches_relative_path("docs/anything.txt"));
668        assert!(!matchers.matches_relative_path("test.jinja"));
669        // A broad sibling pattern must not inherit the literal pattern's
670        // allowance for files that merely share its name.
671        assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
672        assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
673
674        let globs: Vec<_> = matchers.file_name_globs().collect();
675        assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
676    }
677
678    #[test]
679    fn explicit_include_matchers_follow_gitignore_anchoring() {
680        // No slash: matches at any depth.
681        let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
682        assert!(unanchored.matches_relative_path("test.md.jinja"));
683        assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
684
685        // Slash: anchored to the root, and `*` does not cross separators.
686        let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
687        assert!(anchored.matches_relative_path("docs/a.txt"));
688        assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
689        assert!(!anchored.matches_relative_path("other/docs/a.txt"));
690
691        // Leading slash: anchored, slash stripped for matching.
692        let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
693        assert!(rooted.matches_relative_path("NOTES.tmpl"));
694        assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
695    }
696
697    #[test]
698    fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
699        let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
700        assert!(matchers.is_empty());
701        assert!(!matchers.matches_relative_path("x.md.jinja"));
702    }
703
704    #[test]
705    fn explicit_include_matchers_skip_invalid_globs() {
706        // The unclosed bracket pins a literal `.tmpl` suffix but fails glob
707        // compilation; it must be skipped without poisoning valid patterns.
708        let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
709        assert!(matchers.matches_relative_path("ok.md.jinja"));
710        assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
711    }
712
713    #[test]
714    fn exclude_matchers_expand_directory_patterns() {
715        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
716        assert!(matchers.is_match("drafts"));
717        assert!(
718            matchers.is_match("drafts/inner.md"),
719            "directory pattern must match contents"
720        );
721        assert!(matchers.is_match("note.tmp.md"));
722        assert!(!matchers.is_match("docs/guide.md"));
723        assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
724        assert!(matchers.invalid.is_empty());
725    }
726
727    #[test]
728    fn expand_home_prefix_expands_only_a_leading_tilde() {
729        let home = Path::new("/home/dev");
730        assert_eq!(
731            expand_home_prefix_impl("~/.cursor/plans", Some(home)),
732            "/home/dev/.cursor/plans"
733        );
734        assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
735        assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
736    }
737
738    #[test]
739    fn expand_home_prefix_leaves_interior_tildes_alone() {
740        let home = Path::new("/home/dev");
741        // `~` is a legal filename character; only a leading `~/` is a home reference.
742        for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
743            assert_eq!(
744                expand_home_prefix_impl(pattern, Some(home)),
745                pattern,
746                "{pattern:?} must be left as written"
747            );
748        }
749    }
750
751    #[test]
752    fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
753        assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
754    }
755
756    #[test]
757    fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
758        let temp = tempdir().unwrap();
759        // Canonicalize the way production does, so the pattern has the shape an
760        // expanded `~` produces (on Windows that means no verbatim prefix).
761        let base = canonicalize_for_matching(temp.path()).unwrap();
762        let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
763        assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
764    }
765
766    #[test]
767    fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
768        // The base as handed to us (a symlinked `/var` on macOS, a Windows 8.3
769        // short name) must still strip.
770        let temp = tempdir().unwrap();
771        let canonical = canonicalize_for_matching(temp.path()).unwrap();
772        let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
773        assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
774    }
775
776    #[test]
777    fn normalize_pattern_for_base_leaves_other_patterns_alone() {
778        let temp = tempdir().unwrap();
779        let base = canonicalize_for_matching(temp.path()).unwrap();
780        // Relative patterns are already base-relative.
781        assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
782        // An absolute pattern outside the base stays absolute: nothing under
783        // this walk can match it, which is the correct outcome.
784        assert_eq!(
785            normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
786            "/somewhere/else/**"
787        );
788        // With no base there is nothing to rewrite against.
789        assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
790    }
791
792    #[test]
793    fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
794        // The exact shape `canonicalize` returns on Windows. Left unstripped it
795        // normalizes to `//?/C:/...`, which matches nothing.
796        assert_eq!(
797            strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
798            r"C:\Users\dev\AppData\Local\Temp\x"
799        );
800        assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
801        // UNC shares unwrap to their ordinary `\\server\share` form.
802        assert_eq!(
803            strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
804            r"\\server\share\docs"
805        );
806    }
807
808    #[test]
809    fn strip_verbatim_prefix_leaves_other_paths_alone() {
810        for path in [
811            "/home/dev/docs",
812            r"C:\Users\dev",
813            r"\\server\share",
814            // A device namespace has no ordinary equivalent to unwrap to.
815            r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
816            r"\\?\",
817            "",
818        ] {
819            assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
820        }
821    }
822
823    #[test]
824    fn expand_directory_pattern_expands_a_literal_final_component() {
825        // A glob earlier in the pattern must not block contents-expansion: the
826        // final component names a directory, so its contents are excluded too.
827        assert_eq!(
828            expand_directory_pattern("**/.cursor/plans"),
829            vec!["**/.cursor/plans", "**/.cursor/plans/**"]
830        );
831        assert_eq!(
832            expand_directory_pattern("docs/**/drafts"),
833            vec!["docs/**/drafts", "docs/**/drafts/**"]
834        );
835        // Alternation names literal directories, so it keeps its expansion.
836        assert_eq!(
837            expand_directory_pattern("logs/{a,b}"),
838            vec!["logs/{a,b}", "logs/{a,b}/**"]
839        );
840    }
841
842    #[test]
843    fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
844        // `docs/*` names direct children only; expanding it to `docs/*/**` would
845        // newly exclude nested contents.
846        for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
847            assert_eq!(
848                expand_directory_pattern(pattern),
849                vec![pattern.to_string()],
850                "{pattern:?} must not gain a contents-expansion"
851            );
852        }
853    }
854
855    #[test]
856    fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
857        let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
858        let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
859        assert!(
860            matchers.excludes_file(None, excluded),
861            "an absolute pattern must match the absolute path when there is no relative form"
862        );
863        assert_eq!(
864            matchers.matched_pattern_for_file(None, excluded),
865            Some("/home/dev/.cursor/plans/**")
866        );
867        // A file inside a project root still has a relative form; the absolute
868        // pattern must match it through the absolute path.
869        assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
870        assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
871    }
872
873    #[test]
874    fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
875        // Relative patterns are anchored at the start of the matched string, so
876        // adding the absolute-path check must not widen them into `**/drafts`.
877        let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
878        assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
879        assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
880    }
881
882    #[test]
883    fn exclude_matchers_report_invalid_patterns() {
884        let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
885        assert_eq!(matchers.invalid.len(), 1);
886        assert_eq!(matchers.invalid[0].0, "[");
887        assert!(matchers.is_match("ok.md"));
888    }
889
890    #[test]
891    fn path_relative_to_strips_through_symlinked_base() {
892        let temp = tempdir().unwrap();
893        let base = temp.path().join("base");
894        fs::create_dir_all(base.join("docs")).unwrap();
895        fs::write(base.join("docs/a.md"), "# hi").unwrap();
896
897        assert_eq!(
898            path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
899            Some("docs/a.md")
900        );
901        assert_eq!(
902            path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
903            Some("a.md")
904        );
905        assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
906    }
907}