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/// A glob selecting `ext` in any letter case, as `*.[mM][dD]` for `md`.
149///
150/// Walk type globs match case-sensitively, so a plain `*.md` hides `README.MD`
151/// from a directory scan even though [`is_markdown_extension`] calls it
152/// markdown and naming the file on the command line lints it. Deriving the glob
153/// from the same extension keeps the walk's filter from being narrower than the
154/// definition it stands in for.
155pub fn any_case_extension_glob(ext: &str) -> String {
156    let mut glob = String::with_capacity(2 + ext.len() * 4);
157    glob.push_str("*.");
158    for ch in ext.chars() {
159        if ch.is_ascii_alphabetic() {
160            glob.push('[');
161            glob.push(ch.to_ascii_lowercase());
162            glob.push(ch.to_ascii_uppercase());
163            glob.push(']');
164        } else {
165            glob.push(ch);
166        }
167    }
168    glob
169}
170
171/// Ignore-handling options applied to a markdown discovery walk.
172#[derive(Debug, Clone)]
173pub struct MarkdownWalkOptions {
174    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
175    /// and parent ignore files. Driven by `global.respect_gitignore`.
176    pub respect_gitignore: bool,
177    /// Skip `.git`, `node_modules`, and `target` directories outright, even
178    /// when gitignore handling is disabled or would not cover them.
179    pub skip_vendor_dirs: bool,
180}
181
182impl Default for MarkdownWalkOptions {
183    fn default() -> Self {
184        Self {
185            respect_gitignore: true,
186            skip_vendor_dirs: false,
187        }
188    }
189}
190
191/// Whether a walk over `roots` stops reading gitignores at the repository root.
192///
193/// Git reads no `.gitignore` above the repository root, so a walk that does hides
194/// files `git check-ignore` reports as visible. Worse, such a file can hide a
195/// whole directory, and a pruned directory is never descended into, so no include
196/// pattern gets the chance to name anything inside it.
197///
198/// Outside a repository there is no root to stop at, and ignore files are all a
199/// walk has to go on, so there they keep applying upward. One walk has one
200/// setting for all of its roots, so the boundary is only applied when every root
201/// has a repository to bound it.
202pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
203    !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
204}
205
206/// Whether `path` sits inside a git or jujutsu repository.
207///
208/// A `.git` entry is a directory in an ordinary clone and a file in a worktree or
209/// submodule, so existence alone is the marker. This recognizes a repository the
210/// same way the walker does, which is what puts the boundary in the same place.
211fn in_repository(path: &Path) -> bool {
212    let Ok(absolute) = std::fs::canonicalize(path) else {
213        return false;
214    };
215    absolute
216        .ancestors()
217        .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
218}
219
220/// Apply the shared ignore-handling configuration to a walker over `roots`.
221///
222/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
223/// same as a visible one); generated content is kept out by gitignore
224/// semantics and, for callers that opt in, the vendor-directory skip.
225/// `.markdownlintignore` is honored for markdownlint compatibility.
226///
227/// The roots decide where gitignore reading stops, so a caller passes the same
228/// ones it walks.
229pub fn apply_markdown_walk_options<P: AsRef<Path>>(
230    builder: &mut ignore::WalkBuilder,
231    roots: &[P],
232    options: &MarkdownWalkOptions,
233) {
234    let gitignore = options.respect_gitignore;
235    builder
236        .ignore(gitignore)
237        .git_ignore(gitignore)
238        .git_global(gitignore)
239        .git_exclude(gitignore)
240        .parents(gitignore)
241        .hidden(false)
242        // This setting does double duty in the walker: it gates gitignore
243        // handling on a repository being present, and it is what stops the walk
244        // reading gitignores above the repository root. Inside a repository both
245        // are wanted. Outside one, requiring a repository would drop `.gitignore`
246        // handling entirely, and there is no root to stop at in any case.
247        .require_git(stops_at_repository_root(roots))
248        .add_custom_ignore_filename(".markdownlintignore");
249
250    if options.skip_vendor_dirs {
251        builder.filter_entry(|entry| {
252            let name = entry.file_name().to_str().unwrap_or("");
253            name != ".git" && name != "node_modules" && name != "target"
254        });
255    }
256}
257
258/// Build a walker over `root` configured with the shared options.
259pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
260    let mut builder = ignore::WalkBuilder::new(root);
261    apply_markdown_walk_options(&mut builder, &[root], options);
262    builder
263}
264
265/// Drop Windows' verbatim `\\?\` prefix from a canonicalized path string.
266///
267/// `std::fs::canonicalize` returns the verbatim form (`\\?\C:\Users\dev`) on
268/// Windows. That form is useless for pattern matching: it does not compare
269/// equal to the ordinary paths rumdl works with, and normalizing its
270/// separators for globbing mangles it into `//?/C:/Users/dev`, which matches
271/// nothing. Only a drive path (`\\?\C:\...`) and a UNC share
272/// (`\\?\UNC\server\share` -> `\\server\share`) are unwrapped; any other
273/// verbatim path names a device namespace that has no ordinary equivalent, so
274/// it is left alone.
275///
276/// Pure string logic, compiled on every platform so it stays under test where
277/// Windows is not available. Only the call sites are Windows-specific, and on
278/// other platforms no path ever carries this prefix.
279fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
280    // `\\?\UNC\server\share` -> `\\server\share`. The remainder already starts
281    // with one separator, so restoring the UNC form needs one more prepended.
282    if let Some(rest) = path.strip_prefix(r"\\?\UNC")
283        && rest.starts_with('\\')
284    {
285        return Cow::Owned(format!(r"\{rest}"));
286    }
287    let Some(rest) = path.strip_prefix(r"\\?\") else {
288        return Cow::Borrowed(path);
289    };
290    let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
291    if is_drive_path {
292        Cow::Borrowed(rest)
293    } else {
294        Cow::Borrowed(path)
295    }
296}
297
298/// Canonicalize `path` for pattern matching, or `None` when it cannot be
299/// resolved (a missing or unreadable file).
300///
301/// Canonical form is what patterns are matched against, so a symlinked
302/// location (`/home/dev` -> `/mnt/dev`, or a macOS `/var` -> `/private/var`)
303/// still matches. Windows' verbatim prefix is removed (see
304/// [`strip_verbatim_prefix`]).
305pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
306    let canonical = path.canonicalize().ok()?;
307    if !cfg!(windows) {
308        return Some(canonical);
309    }
310    let as_str = canonical.to_string_lossy();
311    Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
312}
313
314/// The user's home directory, or `None` when it cannot be resolved.
315///
316/// Canonicalized for matching (see [`canonicalize_for_matching`]), falling
317/// back to the path as reported when it cannot be canonicalized.
318///
319/// Wasm and WASI builds have no home directory to resolve, so patterns keep
320/// their `~` there (see [`expand_home_prefix`]).
321fn home_dir() -> Option<PathBuf> {
322    #[cfg(feature = "native")]
323    {
324        use etcetera::{BaseStrategy, choose_base_strategy};
325        choose_base_strategy()
326            .ok()
327            .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
328    }
329    #[cfg(not(feature = "native"))]
330    {
331        None
332    }
333}
334
335/// Expand a leading `~` in a path pattern to the user's home directory, so a
336/// user-level config (`~/.config/rumdl/rumdl.toml`) can name a home path
337/// without hardcoding a username.
338///
339/// Only a bare `~` and a `~/` prefix expand. `~` is a legal filename character
340/// everywhere else (editor backups like `notes.md~`, a literal `docs/~drafts`),
341/// so it is left alone there. `~user` is not expanded either: resolving another
342/// user's home needs the password database, and treating it as the current
343/// user's home would silently match the wrong directory.
344///
345/// The expansion is a glob pattern, so separators are normalized to `/` on
346/// Windows: `\` is globset's escape character, and matched paths are normalized
347/// the same way (see [`path_relative_to`]).
348pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
349    // Resolve the home directory only for a pattern that references it: every
350    // other pattern would otherwise pay for the lookup and its canonicalization.
351    if !has_home_prefix(pattern) {
352        return Cow::Borrowed(pattern);
353    }
354    expand_home_prefix_impl(pattern, home_dir().as_deref())
355}
356
357/// Whether `pattern` starts with a home reference (`~` or `~/`).
358fn has_home_prefix(pattern: &str) -> bool {
359    pattern == "~" || pattern.starts_with("~/")
360}
361
362fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
363    let Some(suffix) = (if pattern == "~" {
364        Some("")
365    } else {
366        pattern.strip_prefix("~/")
367    }) else {
368        return Cow::Borrowed(pattern);
369    };
370    let Some(home) = home else {
371        return Cow::Borrowed(pattern);
372    };
373
374    let home = normalize_pattern_separators(home.to_string_lossy());
375    let home = home.trim_end_matches('/');
376    if suffix.is_empty() {
377        Cow::Owned(home.to_string())
378    } else {
379        Cow::Owned(format!("{home}/{suffix}"))
380    }
381}
382
383/// Normalize path separators to `/` for glob matching. On Windows `\` is
384/// globset's escape character, so a native path must be rewritten before it can
385/// be used as - or matched against - a pattern. No-op on Unix, where `\` is a
386/// legal filename character.
387fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
388    if cfg!(windows) && path.contains('\\') {
389        Cow::Owned(path.replace('\\', "/"))
390    } else {
391        path
392    }
393}
394
395/// Normalize a config path pattern for matching against paths discovered under
396/// `base`: expand a leading `~`, then rewrite an absolute pattern as one
397/// relative to `base` when `base` contains it.
398///
399/// The rewrite is what makes an absolute pattern usable as a walker override:
400/// the `ignore` crate reads a leading `/` as "anchored to the walk base", so
401/// `/home/dev/docs/**` would otherwise be understood as
402/// `<base>/home/dev/docs/**` and match nothing. A pattern pointing outside
403/// `base` is left absolute - nothing under this walk can match it, which is the
404/// correct outcome.
405pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
406    let expanded = expand_home_prefix(pattern);
407    let Some(base) = base else {
408        return expanded.into_owned();
409    };
410    if !is_absolute_pattern(&expanded) {
411        return expanded.into_owned();
412    }
413
414    // Try the base as given and canonicalized, so a symlinked or
415    // non-canonical base (macOS `/var`, a Windows 8.3 short name) still strips.
416    let path = Path::new(expanded.as_ref());
417    let relative = path.strip_prefix(base).ok().or_else(|| {
418        let canonical = canonicalize_for_matching(base)?;
419        path.strip_prefix(canonical).ok()
420    });
421    match relative {
422        Some(relative) => normalize_pattern_separators(relative.to_string_lossy()).into_owned(),
423        None => expanded.into_owned(),
424    }
425}
426
427/// Expands directory-style patterns to also match files within them.
428/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
429/// the directory itself and all contents recursively. A leading `~` is
430/// expanded first (see [`expand_home_prefix`]).
431///
432/// The expansion is driven by the pattern's *final* component: it names a
433/// directory only when it holds no wildcard. `docs/*` therefore stays as
434/// written (it names direct children, and `docs/*/**` would newly exclude
435/// nested contents), while `**/.cursor/plans` gains its contents-expansion
436/// despite the wildcard earlier in the pattern.
437pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
438    let pattern = expand_home_prefix(pattern);
439    let base = pattern.trim_end_matches('/');
440    let final_component = base.rsplit('/').next().unwrap_or(base);
441
442    if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
443        return vec![pattern.to_string()];
444    }
445
446    vec![
447        base.to_string(),     // Match the directory itself
448        format!("{base}/**"), // Match everything underneath
449    ]
450}
451
452/// Compiled `exclude` patterns with directory-pattern expansion applied.
453///
454/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
455/// root-relative path (the CLI relativizes against the project root, the
456/// LSP against the containing workspace root) so patterns like
457/// `docs/drafts` behave identically everywhere.
458pub struct ExcludeMatchers {
459    matchers: Vec<(String, GlobMatcher)>,
460    /// Whether any pattern is absolute, i.e. whether matching has to consider
461    /// a file's absolute path at all. Keeps the common (all-relative) case
462    /// from paying for the canonicalization that check needs.
463    has_absolute: bool,
464    /// Patterns that failed to compile, with their errors. Callers decide
465    /// how to surface these (CLI prints to stderr, LSP logs).
466    pub invalid: Vec<(String, String)>,
467}
468
469/// Whether `pattern` names an absolute location. A leading `/` counts on every
470/// platform: patterns use `/` separators, so a Unix-style path stays absolute
471/// when the same config is read on Windows.
472pub fn is_absolute_pattern(pattern: &str) -> bool {
473    pattern.starts_with('/') || Path::new(pattern).is_absolute()
474}
475
476impl ExcludeMatchers {
477    pub fn new(patterns: &[String]) -> Self {
478        let mut matchers = Vec::new();
479        let mut invalid = Vec::new();
480        let mut has_absolute = false;
481        for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
482            has_absolute |= is_absolute_pattern(&pattern);
483            match Glob::new(&pattern) {
484                Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
485                Err(e) => invalid.push((pattern, e.to_string())),
486            }
487        }
488        Self {
489            matchers,
490            has_absolute,
491            invalid,
492        }
493    }
494
495    pub fn is_empty(&self) -> bool {
496        self.matchers.is_empty()
497    }
498
499    /// The first pattern matching `relative_path`, if any.
500    pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
501        self.matchers
502            .iter()
503            .find(|(_, matcher)| matcher.is_match(relative_path))
504            .map(|(pattern, _)| pattern.as_str())
505    }
506
507    pub fn is_match(&self, relative_path: &str) -> bool {
508        self.matched_pattern(relative_path).is_some()
509    }
510
511    /// The first pattern matching a file, if any.
512    ///
513    /// Both forms of the file are tried: its `relative` form (how patterns are
514    /// normally written - relative to the project or workspace root) and its
515    /// absolute path, which is what an absolute pattern matches. Absolute
516    /// patterns reach config either written literally or through `~` expansion,
517    /// and the walker's overrides cannot apply them (the `ignore` crate anchors
518    /// a leading `/` to the walk root), so this is where they take effect.
519    ///
520    /// Checking the absolute path cannot widen a relative pattern: globs are
521    /// anchored at the start of the matched string, so `drafts/**` never
522    /// matches `/home/dev/proj/drafts/note.md`.
523    ///
524    /// `absolute` is canonicalized before matching, since an expanded `~`
525    /// resolves to a canonical location. Files that cannot be canonicalized
526    /// (already deleted, unreadable) are matched as given.
527    pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
528        if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
529            return Some(pattern);
530        }
531        if !self.has_absolute {
532            return None;
533        }
534        let canonical = canonicalize_for_matching(absolute);
535        let absolute = canonical.as_deref().unwrap_or(absolute);
536        self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
537    }
538
539    /// Whether any pattern matches the file (see [`matched_pattern_for_file`](Self::matched_pattern_for_file)).
540    pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
541        self.matched_pattern_for_file(relative, absolute).is_some()
542    }
543}
544
545/// Relativize `path` against `base` for exclude-pattern matching,
546/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
547/// path-representation differences don't defeat the prefix strip. Returns
548/// `None` when `path` is not under `base`.
549///
550/// Separators are normalized to `/` on Windows, following the project
551/// convention for path strings; globset matches either form, but log
552/// output and assertions see one canonical shape.
553pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
554    let canonical_base = base.canonicalize().ok()?;
555    let canonical_path = path.canonicalize().ok()?;
556    canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
557        let rel = rel.to_string_lossy();
558        if cfg!(windows) {
559            rel.replace('\\', "/")
560        } else {
561            rel.to_string()
562        }
563    })
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use std::fs;
570    use tempfile::tempdir;
571
572    #[test]
573    fn markdown_extensions_match_case_insensitively() {
574        for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
575            assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
576        }
577        for ext in ["rs", "txt", "mdq", ""] {
578            assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
579        }
580        assert!(has_markdown_extension(Path::new("a/b/README.md")));
581        assert!(has_markdown_extension(Path::new("notebook.Rmd")));
582        assert!(!has_markdown_extension(Path::new("no_extension")));
583        assert!(!has_markdown_extension(Path::new("lib.rs")));
584    }
585
586    #[test]
587    fn the_type_glob_selects_exactly_what_counts_as_markdown() {
588        assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
589
590        // The glob stands in for `is_markdown_extension` inside a walk, so the
591        // two have to agree on every spelling, not just the lowercase one.
592        let mut builder = globset::GlobSetBuilder::new();
593        for ext in MARKDOWN_EXTENSIONS {
594            builder.add(
595                globset::GlobBuilder::new(&any_case_extension_glob(ext))
596                    .literal_separator(true)
597                    .build()
598                    .unwrap(),
599            );
600        }
601        let globs = builder.build().unwrap();
602
603        for ext in MARKDOWN_EXTENSIONS {
604            for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
605                let name = format!("README.{spelling}");
606                assert!(
607                    globs.is_match(&name),
608                    "{name} is markdown by extension but no type glob selects it"
609                );
610                assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
611            }
612        }
613
614        // Control: the glob widens case, not the extension set.
615        for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
616            assert!(!globs.is_match(name), "{name} should not be selected");
617        }
618    }
619
620    fn capitalize(ext: &str) -> String {
621        let mut chars = ext.chars();
622        match chars.next() {
623            Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
624            None => String::new(),
625        }
626    }
627
628    #[test]
629    fn walk_includes_hidden_files() {
630        let temp = tempdir().unwrap();
631        fs::create_dir_all(temp.path().join(".github")).unwrap();
632        fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
633        fs::write(temp.path().join("README.md"), "# hi").unwrap();
634
635        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
636            .build()
637            .flatten()
638            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
639            .map(|e| e.path().to_path_buf())
640            .collect();
641        assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
642        assert!(files.iter().any(|p| p.ends_with("README.md")));
643    }
644
645    #[test]
646    fn walk_honors_gitignore_when_enabled_only() {
647        let temp = tempdir().unwrap();
648        fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
649        fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
650        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
651
652        let walk = |respect: bool| -> Vec<std::path::PathBuf> {
653            markdown_walk_builder(
654                temp.path(),
655                &MarkdownWalkOptions {
656                    respect_gitignore: respect,
657                    ..Default::default()
658                },
659            )
660            .build()
661            .flatten()
662            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
663            .map(|e| e.path().to_path_buf())
664            .collect()
665        };
666
667        let respected = walk(true);
668        assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
669        assert!(respected.iter().any(|p| p.ends_with("kept.md")));
670
671        let unrespected = walk(false);
672        assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
673    }
674
675    #[test]
676    fn a_gitignore_above_the_repository_root_stays_outside_it() {
677        let temp = tempdir().unwrap();
678        fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
679        let repo = temp.path().join("repo");
680        fs::create_dir_all(repo.join(".git")).unwrap();
681        fs::write(repo.join("kept.md"), "# hi").unwrap();
682
683        let walk = |root: &Path| -> Vec<std::path::PathBuf> {
684            markdown_walk_builder(root, &MarkdownWalkOptions::default())
685                .build()
686                .flatten()
687                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
688                .map(|e| e.path().to_path_buf())
689                .collect()
690        };
691
692        assert!(
693            walk(&repo).iter().any(|p| p.ends_with("kept.md")),
694            "git reads no gitignore above the repository root, so neither does the walk"
695        );
696
697        // Control: outside a repository there is no root to stop at, and the
698        // ignore files above are all the walk has to go on.
699        fs::remove_dir(repo.join(".git")).unwrap();
700        assert!(
701            !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
702            "with no repository to bound it, the walk keeps reading upward"
703        );
704    }
705
706    #[test]
707    fn the_repository_boundary_needs_every_root_to_have_one() {
708        let temp = tempdir().unwrap();
709        let inside = temp.path().join("repo/docs");
710        fs::create_dir_all(&inside).unwrap();
711        fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
712        let outside = temp.path().join("plain");
713        fs::create_dir_all(&outside).unwrap();
714
715        assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
716        assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
717
718        // A walk has one setting for all of its roots. Bounding this one would
719        // strip the outside root of gitignore handling altogether, which is a
720        // worse answer than reading one file too many.
721        assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
722        assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
723
724        // A worktree and a submodule mark their root with a `.git` file rather
725        // than a directory, and both are still repository roots.
726        let worktree = temp.path().join("worktree");
727        fs::create_dir_all(&worktree).unwrap();
728        fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
729        assert!(stops_at_repository_root(&[&worktree]));
730    }
731
732    #[test]
733    fn walk_honors_markdownlintignore() {
734        let temp = tempdir().unwrap();
735        fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
736        fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
737        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
738
739        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
740            .build()
741            .flatten()
742            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
743            .map(|e| e.path().to_path_buf())
744            .collect();
745        assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
746        assert!(files.iter().any(|p| p.ends_with("kept.md")));
747    }
748
749    #[test]
750    fn vendor_dirs_skipped_only_when_requested() {
751        let temp = tempdir().unwrap();
752        for dir in ["node_modules", "target", "src"] {
753            fs::create_dir_all(temp.path().join(dir)).unwrap();
754            fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
755        }
756
757        let walk = |skip: bool| -> Vec<std::path::PathBuf> {
758            markdown_walk_builder(
759                temp.path(),
760                &MarkdownWalkOptions {
761                    skip_vendor_dirs: skip,
762                    // Disable gitignore handling so ambient .gitignore files in the
763                    // temp directory's ancestry cannot mask the vendor-dir filtering
764                    // this test exercises.
765                    respect_gitignore: false,
766                },
767            )
768            .build()
769            .flatten()
770            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
771            .map(|e| e.path().to_path_buf())
772            .collect()
773        };
774
775        let skipped = walk(true);
776        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
777        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
778        assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
779
780        let unskipped = walk(false);
781        assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
782    }
783
784    #[test]
785    fn explicit_file_name_glob_extracts_literal_extensions() {
786        assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
787        assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
788        assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
789        assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
790        assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
791        assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
792    }
793
794    #[test]
795    fn explicit_file_name_glob_rejects_unpinned_patterns() {
796        for pattern in [
797            "docs/",
798            "docs/**",
799            "docs",
800            "*",
801            "**",
802            "**/*",
803            "*.*",
804            "*.md*",
805            "*.{md,jinja}",
806            "*.md?",
807            "data.[ch]",
808            "!drafts/*.md.jinja",
809            "",
810            "**/Makefile",
811            "*.",
812        ] {
813            assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
814        }
815    }
816
817    #[test]
818    fn explicit_include_matchers_match_full_relative_paths() {
819        let matchers = ExplicitIncludeMatchers::new(&[
820            "**/*.md.jinja".to_string(),
821            "docs/**".to_string(),
822            "templates/NOTES.tmpl".to_string(),
823        ]);
824        assert!(!matchers.is_empty());
825        assert!(matchers.matches_relative_path("test.md.jinja"));
826        assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
827        assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
828        // The directory pattern must not widen the filter to arbitrary files.
829        assert!(!matchers.matches_relative_path("docs/anything.txt"));
830        assert!(!matchers.matches_relative_path("test.jinja"));
831        // A broad sibling pattern must not inherit the literal pattern's
832        // allowance for files that merely share its name.
833        assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
834        assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
835
836        let globs: Vec<_> = matchers.file_name_globs().collect();
837        assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
838    }
839
840    #[test]
841    fn explicit_include_matchers_follow_gitignore_anchoring() {
842        // No slash: matches at any depth.
843        let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
844        assert!(unanchored.matches_relative_path("test.md.jinja"));
845        assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
846
847        // Slash: anchored to the root, and `*` does not cross separators.
848        let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
849        assert!(anchored.matches_relative_path("docs/a.txt"));
850        assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
851        assert!(!anchored.matches_relative_path("other/docs/a.txt"));
852
853        // Leading slash: anchored, slash stripped for matching.
854        let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
855        assert!(rooted.matches_relative_path("NOTES.tmpl"));
856        assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
857    }
858
859    #[test]
860    fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
861        let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
862        assert!(matchers.is_empty());
863        assert!(!matchers.matches_relative_path("x.md.jinja"));
864    }
865
866    #[test]
867    fn explicit_include_matchers_skip_invalid_globs() {
868        // The unclosed bracket pins a literal `.tmpl` suffix but fails glob
869        // compilation; it must be skipped without poisoning valid patterns.
870        let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
871        assert!(matchers.matches_relative_path("ok.md.jinja"));
872        assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
873    }
874
875    #[test]
876    fn exclude_matchers_expand_directory_patterns() {
877        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
878        assert!(matchers.is_match("drafts"));
879        assert!(
880            matchers.is_match("drafts/inner.md"),
881            "directory pattern must match contents"
882        );
883        assert!(matchers.is_match("note.tmp.md"));
884        assert!(!matchers.is_match("docs/guide.md"));
885        assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
886        assert!(matchers.invalid.is_empty());
887    }
888
889    #[test]
890    fn expand_home_prefix_expands_only_a_leading_tilde() {
891        let home = Path::new("/home/dev");
892        assert_eq!(
893            expand_home_prefix_impl("~/.cursor/plans", Some(home)),
894            "/home/dev/.cursor/plans"
895        );
896        assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
897        assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
898    }
899
900    #[test]
901    fn expand_home_prefix_leaves_interior_tildes_alone() {
902        let home = Path::new("/home/dev");
903        // `~` is a legal filename character; only a leading `~/` is a home reference.
904        for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
905            assert_eq!(
906                expand_home_prefix_impl(pattern, Some(home)),
907                pattern,
908                "{pattern:?} must be left as written"
909            );
910        }
911    }
912
913    #[test]
914    fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
915        assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
916    }
917
918    #[test]
919    fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
920        let temp = tempdir().unwrap();
921        // Canonicalize the way production does, so the pattern has the shape an
922        // expanded `~` produces (on Windows that means no verbatim prefix).
923        let base = canonicalize_for_matching(temp.path()).unwrap();
924        let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
925        assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
926    }
927
928    #[test]
929    fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
930        // The base as handed to us (a symlinked `/var` on macOS, a Windows 8.3
931        // short name) must still strip.
932        let temp = tempdir().unwrap();
933        let canonical = canonicalize_for_matching(temp.path()).unwrap();
934        let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
935        assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
936    }
937
938    #[test]
939    fn normalize_pattern_for_base_leaves_other_patterns_alone() {
940        let temp = tempdir().unwrap();
941        let base = canonicalize_for_matching(temp.path()).unwrap();
942        // Relative patterns are already base-relative.
943        assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
944        // An absolute pattern outside the base stays absolute: nothing under
945        // this walk can match it, which is the correct outcome.
946        assert_eq!(
947            normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
948            "/somewhere/else/**"
949        );
950        // With no base there is nothing to rewrite against.
951        assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
952    }
953
954    #[test]
955    fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
956        // The exact shape `canonicalize` returns on Windows. Left unstripped it
957        // normalizes to `//?/C:/...`, which matches nothing.
958        assert_eq!(
959            strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
960            r"C:\Users\dev\AppData\Local\Temp\x"
961        );
962        assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
963        // UNC shares unwrap to their ordinary `\\server\share` form.
964        assert_eq!(
965            strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
966            r"\\server\share\docs"
967        );
968    }
969
970    #[test]
971    fn strip_verbatim_prefix_leaves_other_paths_alone() {
972        for path in [
973            "/home/dev/docs",
974            r"C:\Users\dev",
975            r"\\server\share",
976            // A device namespace has no ordinary equivalent to unwrap to.
977            r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
978            r"\\?\",
979            "",
980        ] {
981            assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
982        }
983    }
984
985    #[test]
986    fn expand_directory_pattern_expands_a_literal_final_component() {
987        // A glob earlier in the pattern must not block contents-expansion: the
988        // final component names a directory, so its contents are excluded too.
989        assert_eq!(
990            expand_directory_pattern("**/.cursor/plans"),
991            vec!["**/.cursor/plans", "**/.cursor/plans/**"]
992        );
993        assert_eq!(
994            expand_directory_pattern("docs/**/drafts"),
995            vec!["docs/**/drafts", "docs/**/drafts/**"]
996        );
997        // Alternation names literal directories, so it keeps its expansion.
998        assert_eq!(
999            expand_directory_pattern("logs/{a,b}"),
1000            vec!["logs/{a,b}", "logs/{a,b}/**"]
1001        );
1002    }
1003
1004    #[test]
1005    fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1006        // `docs/*` names direct children only; expanding it to `docs/*/**` would
1007        // newly exclude nested contents.
1008        for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1009            assert_eq!(
1010                expand_directory_pattern(pattern),
1011                vec![pattern.to_string()],
1012                "{pattern:?} must not gain a contents-expansion"
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1019        let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1020        let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1021        assert!(
1022            matchers.excludes_file(None, excluded),
1023            "an absolute pattern must match the absolute path when there is no relative form"
1024        );
1025        assert_eq!(
1026            matchers.matched_pattern_for_file(None, excluded),
1027            Some("/home/dev/.cursor/plans/**")
1028        );
1029        // A file inside a project root still has a relative form; the absolute
1030        // pattern must match it through the absolute path.
1031        assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1032        assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1033    }
1034
1035    #[test]
1036    fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1037        // Relative patterns are anchored at the start of the matched string, so
1038        // adding the absolute-path check must not widen them into `**/drafts`.
1039        let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1040        assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1041        assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1042    }
1043
1044    #[test]
1045    fn exclude_matchers_report_invalid_patterns() {
1046        let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1047        assert_eq!(matchers.invalid.len(), 1);
1048        assert_eq!(matchers.invalid[0].0, "[");
1049        assert!(matchers.is_match("ok.md"));
1050    }
1051
1052    #[test]
1053    fn path_relative_to_strips_through_symlinked_base() {
1054        let temp = tempdir().unwrap();
1055        let base = temp.path().join("base");
1056        fs::create_dir_all(base.join("docs")).unwrap();
1057        fs::write(base.join("docs/a.md"), "# hi").unwrap();
1058
1059        assert_eq!(
1060            path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
1061            Some("docs/a.md")
1062        );
1063        assert_eq!(
1064            path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
1065            Some("a.md")
1066        );
1067        assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
1068    }
1069}