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//! - the final source-kind gate for each adapter's capabilities,
10//! - how ignore-file handling (`.gitignore`, `.markdownlintignore`, hidden
11//!   entries) is configured on a walker,
12//! - how `exclude` patterns from config are expanded and matched.
13//!
14//! Callers still differ deliberately: the LSP skips `.git`/`node_modules`/
15//! `target` outright as an editor-performance safety net, while the CLI
16//! walks whatever gitignore semantics allow.
17
18use globset::{Glob, GlobMatcher};
19use std::borrow::Cow;
20use std::ffi::OsStr;
21use std::path::{Path, PathBuf};
22
23/// Glob metacharacters recognized when deciding whether an include pattern
24/// names files explicitly.
25const GLOB_METACHARS: &[char] = &['*', '?', '[', ']', '{', '}'];
26
27/// The file-name glob of an `include` pattern that explicitly names files,
28/// if it does.
29///
30/// A pattern names files explicitly when its final path component pins a
31/// literal dotted suffix: a wildcard stem ending in a literal extension
32/// chain (`**/*.md.jinja` yields `*.md.jinja`) or a fully literal file name
33/// with an extension (`templates/NOTES.tmpl` yields `NOTES.tmpl`). Such
34/// patterns widen the lintable-file filter beyond the standard markdown
35/// extensions: the user has spelled out exactly which files to process.
36///
37/// Directory patterns (`docs/`, `docs/**`), bare wildcards (`*`, `**/*`),
38/// patterns whose extension itself contains wildcards (`*.md*`,
39/// `*.{md,jinja}`), and negations (`!drafts/*.md.jinja`) yield `None`; they
40/// express "look here" or "not this", not "this exact kind of file", so the
41/// markdown-only filter stays in force for them.
42pub fn explicit_file_name_glob(pattern: &str) -> Option<&str> {
43    if pattern.starts_with('!') {
44        return None;
45    }
46    let file_name = pattern.rsplit('/').next().unwrap_or(pattern);
47    if file_name.is_empty() {
48        return None;
49    }
50    // The literal tail after the last glob metacharacter (the whole
51    // component when there is none) must end in a non-empty extension.
52    let literal_tail = match file_name.rfind(GLOB_METACHARS) {
53        Some(idx) => &file_name[idx + 1..],
54        None => file_name,
55    };
56    match literal_tail.rsplit_once('.') {
57        Some((_, ext)) if !ext.is_empty() => Some(file_name),
58        _ => None,
59    }
60}
61
62/// Compiled matchers for the explicitly-named files in a set of config
63/// `include` patterns (see [`explicit_file_name_glob`]).
64///
65/// The CLI walker consults this in two places that otherwise restrict
66/// discovery to markdown extensions: the walker's file-type filter and the
67/// final lintable-file filter. The type filter can only match file names,
68/// so it uses the (over-inclusive) file-name globs; the final filter is
69/// the precise gate and matches the full pattern against the root-relative
70/// path. Without the path check, a broad sibling pattern like `docs/**`
71/// would inherit the non-standard-extension allowance of an explicit
72/// pattern like `templates/NOTES.tmpl` for every file sharing its name.
73///
74/// Path matching follows gitignore anchoring: patterns without a `/` match
75/// at any depth, patterns with one are anchored to the root the relative
76/// path was computed against. `*` does not cross directory separators.
77///
78/// Invalid globs are skipped silently; the caller's override handling
79/// already warns about unparseable include patterns.
80pub struct ExplicitIncludeMatchers {
81    matchers: Vec<ExplicitInclude>,
82}
83
84struct ExplicitInclude {
85    file_name_glob: String,
86    path_matcher: GlobMatcher,
87}
88
89impl ExplicitIncludeMatchers {
90    pub fn new(patterns: &[String]) -> Self {
91        let matchers = patterns
92            .iter()
93            .filter_map(|pattern| {
94                let file_name_glob = explicit_file_name_glob(pattern)?;
95                let path_glob = if let Some(anchored) = pattern.strip_prefix('/') {
96                    anchored.to_string()
97                } else if pattern.contains('/') {
98                    pattern.clone()
99                } else {
100                    format!("**/{pattern}")
101                };
102                let path_matcher = globset::GlobBuilder::new(&path_glob)
103                    .literal_separator(true)
104                    .build()
105                    .ok()?
106                    .compile_matcher();
107                Some(ExplicitInclude {
108                    file_name_glob: file_name_glob.to_string(),
109                    path_matcher,
110                })
111            })
112            .collect();
113        Self { matchers }
114    }
115
116    pub fn is_empty(&self) -> bool {
117        self.matchers.is_empty()
118    }
119
120    /// The file-name globs, e.g. for registering on a walker type filter.
121    pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
122        self.matchers.iter().map(|m| m.file_name_glob.as_str())
123    }
124
125    /// Whether the root-relative `path` matches any explicit include
126    /// pattern in full.
127    pub fn matches_relative_path(&self, path: &str) -> bool {
128        self.matchers.iter().any(|m| m.path_matcher.is_match(path))
129    }
130}
131
132/// Source kinds an adapter can interpret after a path passes include matching.
133///
134/// The CLI can extract Markdown from Rust doc comments, while the language
135/// server indexes complete Markdown documents and must not parse a Rust source
136/// file as if the whole file were Markdown. A CLI `--include` is stronger still:
137/// it explicitly asks rumdl to process whatever the pattern selects.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum LintableFileMode {
140    Markdown,
141    MarkdownAndRust,
142    Any,
143}
144
145/// The shared final gate for files yielded by CLI and LSP discovery walks.
146///
147/// Include overrides decide *where* to look. This selector decides whether a
148/// matching file is a source the adapter can interpret. Explicit config
149/// includes can name template-like Markdown files beyond the standard
150/// extensions; Rust remains capability-gated even when explicitly named.
151pub struct LintablePathSelector {
152    base: Option<PathBuf>,
153    explicit: ExplicitIncludeMatchers,
154    mode: LintableFileMode,
155}
156
157impl LintablePathSelector {
158    pub fn new(base: Option<&Path>, includes: &[String], mode: LintableFileMode) -> Self {
159        Self {
160            base: base.map(Path::to_path_buf),
161            explicit: ExplicitIncludeMatchers::new(includes),
162            mode,
163        }
164    }
165
166    /// Whether an included path is a source this adapter can interpret.
167    pub fn keeps(&self, path: &Path) -> bool {
168        if self.mode == LintableFileMode::Any {
169            return true;
170        }
171        if has_markdown_extension(path) {
172            return true;
173        }
174
175        // Rust doc-comment extraction currently dispatches on lowercase `.rs`.
176        // Keep this capability gate identical to the downstream processor.
177        let is_rust = path.extension().and_then(OsStr::to_str) == Some("rs");
178        if is_rust {
179            return self.mode == LintableFileMode::MarkdownAndRust;
180        }
181
182        match self.base.as_deref().and_then(|base| path_relative_to(path, base)) {
183            Some(relative) => self.explicit.matches_relative_path(&relative),
184            // Outside the pattern base only unanchored patterns can still apply;
185            // matching the full path covers those.
186            None => self.explicit.matches_relative_path(&path.to_string_lossy()),
187        }
188    }
189
190    /// Apply the corresponding coarse file-type filter to a discovery walk.
191    /// [`Self::keeps`] remains the precise final gate because type filters only
192    /// see file names, not root-relative include paths.
193    pub fn configure_types(&self, builder: &mut ignore::WalkBuilder) -> Result<(), ignore::Error> {
194        if self.mode == LintableFileMode::Any {
195            return Ok(());
196        }
197
198        let mut types = ignore::types::TypesBuilder::new();
199        types.add_defaults();
200        for extension in MARKDOWN_EXTENSIONS {
201            types.add("markdown", &any_case_extension_glob(extension))?;
202        }
203        types.select("markdown");
204        if self.mode == LintableFileMode::MarkdownAndRust {
205            types.add("rustdoc", "*.rs")?;
206            types.select("rustdoc");
207        }
208        for glob in self.explicit.file_name_globs() {
209            types.add("configinclude", glob)?;
210        }
211        if !self.explicit.is_empty() {
212            types.select("configinclude");
213        }
214        builder.types(types.build()?);
215        Ok(())
216    }
217}
218
219/// File extensions rumdl treats as markdown, lowercase.
220pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
221
222/// Whether `ext` is a markdown extension. Matches case-insensitively so
223/// conventional variants like `Rmd` (and shouting-case `MD`) qualify.
224#[inline]
225pub fn is_markdown_extension(ext: &OsStr) -> bool {
226    ext.to_str()
227        .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
228}
229
230/// Whether `path` has a markdown extension.
231#[inline]
232pub fn has_markdown_extension(path: &Path) -> bool {
233    path.extension().is_some_and(is_markdown_extension)
234}
235
236/// A glob selecting `ext` in any letter case, as `*.[mM][dD]` for `md`.
237///
238/// Walk type globs match case-sensitively, so a plain `*.md` hides `README.MD`
239/// from a directory scan even though [`is_markdown_extension`] calls it
240/// markdown and naming the file on the command line lints it. Deriving the glob
241/// from the same extension keeps the walk's filter from being narrower than the
242/// definition it stands in for.
243pub fn any_case_extension_glob(ext: &str) -> String {
244    let mut glob = String::with_capacity(2 + ext.len() * 4);
245    glob.push_str("*.");
246    for ch in ext.chars() {
247        if ch.is_ascii_alphabetic() {
248            glob.push('[');
249            glob.push(ch.to_ascii_lowercase());
250            glob.push(ch.to_ascii_uppercase());
251            glob.push(']');
252        } else {
253            glob.push(ch);
254        }
255    }
256    glob
257}
258
259/// Ignore-handling options applied to a markdown discovery walk.
260#[derive(Debug, Clone)]
261pub struct MarkdownWalkOptions {
262    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
263    /// and parent ignore files. Driven by `global.respect_gitignore`.
264    pub respect_gitignore: bool,
265    /// Skip `.git`, `node_modules`, and `target` directories outright, even
266    /// when gitignore handling is disabled or would not cover them.
267    pub skip_vendor_dirs: bool,
268}
269
270impl Default for MarkdownWalkOptions {
271    fn default() -> Self {
272        Self {
273            respect_gitignore: true,
274            skip_vendor_dirs: false,
275        }
276    }
277}
278
279/// Whether a walk over `roots` stops reading gitignores at the repository root.
280///
281/// Git reads no `.gitignore` above the repository root, so a walk that does hides
282/// files `git check-ignore` reports as visible. Worse, such a file can hide a
283/// whole directory, and a pruned directory is never descended into, so no include
284/// pattern gets the chance to name anything inside it.
285///
286/// Outside a repository there is no root to stop at, and ignore files are all a
287/// walk has to go on, so there they keep applying upward. One walk has one
288/// setting for all of its roots, so the boundary is only applied when every root
289/// has a repository to bound it.
290pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
291    !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
292}
293
294/// Whether `path` sits inside a git or jujutsu repository.
295///
296/// A `.git` entry is a directory in an ordinary clone and a file in a worktree or
297/// submodule, so existence alone is the marker. This recognizes a repository the
298/// same way the walker does, which is what puts the boundary in the same place.
299fn in_repository(path: &Path) -> bool {
300    let Ok(absolute) = std::fs::canonicalize(path) else {
301        return false;
302    };
303    absolute
304        .ancestors()
305        .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
306}
307
308/// Apply the shared ignore-handling configuration to a walker over `roots`.
309///
310/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
311/// same as a visible one); generated content is kept out by gitignore
312/// semantics and, for callers that opt in, the vendor-directory skip.
313/// `.markdownlintignore` is honored for markdownlint compatibility.
314///
315/// The roots decide where gitignore reading stops, so a caller passes the same
316/// ones it walks.
317pub fn apply_markdown_walk_options<P: AsRef<Path>>(
318    builder: &mut ignore::WalkBuilder,
319    roots: &[P],
320    options: &MarkdownWalkOptions,
321) {
322    let gitignore = options.respect_gitignore;
323    builder
324        .ignore(gitignore)
325        .git_ignore(gitignore)
326        .git_global(gitignore)
327        .git_exclude(gitignore)
328        .parents(gitignore)
329        .hidden(false)
330        // This setting does double duty in the walker: it gates gitignore
331        // handling on a repository being present, and it is what stops the walk
332        // reading gitignores above the repository root. Inside a repository both
333        // are wanted. Outside one, requiring a repository would drop `.gitignore`
334        // handling entirely, and there is no root to stop at in any case.
335        .require_git(stops_at_repository_root(roots))
336        .add_custom_ignore_filename(".markdownlintignore");
337
338    if options.skip_vendor_dirs {
339        let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
340        builder.filter_entry(move |entry| {
341            if roots.iter().any(|root| root == entry.path()) {
342                return true;
343            }
344            let name = entry.file_name().to_str().unwrap_or("");
345            name != ".git" && name != "node_modules" && name != "target"
346        });
347    }
348}
349
350/// Build a walker over `root` configured with the shared options.
351pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
352    let mut builder = ignore::WalkBuilder::new(root);
353    apply_markdown_walk_options(&mut builder, &[root], options);
354    builder
355}
356
357/// A complete, configured Markdown workspace scan.
358///
359/// This owns the selection policy shared by full scans and incremental file
360/// events: standard Markdown extensions, explicit nonstandard file includes,
361/// include filtering, excludes, ignore files, and optional vendor-directory
362/// pruning. Adapters choose the options; they do not reconstruct the policy.
363pub struct MarkdownWorkspaceScan<'a> {
364    options: &'a MarkdownWalkOptions,
365    includes: &'a [String],
366    excludes: &'a ExcludeMatchers,
367}
368
369impl<'a> MarkdownWorkspaceScan<'a> {
370    pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
371        Self {
372            options,
373            includes,
374            excludes,
375        }
376    }
377
378    /// Collect all selected files under `roots`.
379    pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
380        let mut files = Vec::new();
381        for root in roots {
382            let selection = RootSelection::new(root, self.includes);
383            let mut builder = markdown_walk_builder(root, self.options);
384            selection.configure_walk(&mut builder);
385
386            for result in builder.build() {
387                match result {
388                    Ok(entry)
389                        if entry.file_type().is_some_and(|file_type| file_type.is_file())
390                            && selection.is_lintable(entry.path())
391                            && !self.excluded(root, entry.path()) =>
392                    {
393                        files.push(entry.into_path());
394                    }
395                    Ok(_) => {}
396                    Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
397                }
398            }
399        }
400        files.sort();
401        files.dedup();
402        files
403    }
404
405    /// Whether an incremental file event would be absent from a full scan.
406    pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
407        let Some(root) = roots
408            .iter()
409            .filter(|root| path.starts_with(root))
410            .max_by_key(|root| root.components().count())
411        else {
412            return false;
413        };
414
415        let selection = RootSelection::new(root, self.includes);
416        if !selection.selects(path) || self.excluded(root, path) {
417            return true;
418        }
419
420        if self.options.skip_vendor_dirs
421            && let Ok(relative) = path.strip_prefix(root)
422            && relative.components().any(|component| {
423                matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
424            })
425        {
426            return true;
427        }
428
429        let target = path.to_path_buf();
430        let mut builder = markdown_walk_builder(root, self.options);
431        selection.configure_walk(&mut builder);
432        // `filter_entry` replaces the vendor filter, which was checked above.
433        builder.filter_entry(move |entry| target.starts_with(entry.path()));
434        !builder.build().flatten().any(|entry| entry.path() == path)
435    }
436
437    fn excluded(&self, root: &Path, path: &Path) -> bool {
438        self.excludes
439            .excludes_file(path_relative_to(path, root).as_deref(), path)
440    }
441}
442
443struct RootSelection {
444    lintable: LintablePathSelector,
445    overrides: Option<ignore::overrides::Override>,
446}
447
448impl RootSelection {
449    fn new(root: &Path, includes: &[String]) -> Self {
450        let normalized: Vec<String> = includes
451            .iter()
452            .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
453            .collect();
454        let overrides = if normalized.is_empty() {
455            None
456        } else {
457            let mut builder = ignore::overrides::OverrideBuilder::new(root);
458            for pattern in &normalized {
459                if let Err(error) = builder.add(pattern) {
460                    log::warn!("Invalid include pattern '{pattern}': {error}");
461                }
462            }
463            builder.build().ok()
464        };
465        Self {
466            lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
467            overrides,
468        }
469    }
470
471    fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
472        if let Err(error) = self.lintable.configure_types(builder) {
473            log::warn!("Failed to configure workspace source types: {error}");
474        }
475        if let Some(overrides) = &self.overrides {
476            builder.overrides(overrides.clone());
477        }
478    }
479
480    fn selects(&self, path: &Path) -> bool {
481        self.overrides
482            .as_ref()
483            .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
484            && self.is_lintable(path)
485    }
486
487    fn is_lintable(&self, path: &Path) -> bool {
488        self.lintable.keeps(path)
489    }
490}
491
492/// Drop Windows' verbatim `\\?\` prefix from a canonicalized path string.
493///
494/// `std::fs::canonicalize` returns the verbatim form (`\\?\C:\Users\dev`) on
495/// Windows. That form is useless for pattern matching: it does not compare
496/// equal to the ordinary paths rumdl works with, and normalizing its
497/// separators for globbing mangles it into `//?/C:/Users/dev`, which matches
498/// nothing. Only a drive path (`\\?\C:\...`) and a UNC share
499/// (`\\?\UNC\server\share` -> `\\server\share`) are unwrapped; any other
500/// verbatim path names a device namespace that has no ordinary equivalent, so
501/// it is left alone.
502///
503/// The prefix is recognized in whichever separator the path is written with:
504/// `\\?\` as `canonicalize` returns it, or `//?/` once the separators have
505/// been normalized. Displayed paths are stripped by the CLI's display layer;
506/// output formatters strip again because a path can reach them unchanged.
507///
508/// Pure string logic, compiled on every platform so it stays under test where
509/// Windows is not available. On other platforms no path ever carries this
510/// prefix.
511pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
512    let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
513        return Cow::Borrowed(path);
514    };
515    let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
516        return Cow::Borrowed(path);
517    };
518    // `\\?\UNC\server\share` -> `\\server\share`. The remainder already starts
519    // with one separator, so restoring the UNC form needs one more prepended.
520    if let Some(share) = rest.strip_prefix("UNC")
521        && share.starts_with(sep)
522    {
523        return Cow::Owned(format!("{sep}{share}"));
524    }
525    let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
526    if is_drive_path {
527        Cow::Borrowed(rest)
528    } else {
529        Cow::Borrowed(path)
530    }
531}
532
533/// Canonicalize `path` for pattern matching, or `None` when it cannot be
534/// resolved (a missing or unreadable file).
535///
536/// Canonical form is what patterns are matched against, so a symlinked
537/// location (`/home/dev` -> `/mnt/dev`, or a macOS `/var` -> `/private/var`)
538/// still matches. Windows' verbatim prefix is removed (see
539/// [`strip_verbatim_prefix`]).
540pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
541    let canonical = path.canonicalize().ok()?;
542    if !cfg!(windows) {
543        return Some(canonical);
544    }
545    let as_str = canonical.to_string_lossy();
546    Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
547}
548
549/// The user's home directory, or `None` when it cannot be resolved.
550///
551/// Canonicalized for matching (see [`canonicalize_for_matching`]), falling
552/// back to the path as reported when it cannot be canonicalized.
553///
554/// Wasm and WASI builds have no home directory to resolve, so patterns keep
555/// their `~` there (see [`expand_home_prefix`]).
556fn home_dir() -> Option<PathBuf> {
557    #[cfg(feature = "native")]
558    {
559        use etcetera::{BaseStrategy, choose_base_strategy};
560        choose_base_strategy()
561            .ok()
562            .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
563    }
564    #[cfg(not(feature = "native"))]
565    {
566        None
567    }
568}
569
570/// Expand a leading `~` in a path pattern to the user's home directory, so a
571/// user-level config (`~/.config/rumdl/rumdl.toml`) can name a home path
572/// without hardcoding a username.
573///
574/// Only a bare `~` and a `~/` prefix expand. `~` is a legal filename character
575/// everywhere else (editor backups like `notes.md~`, a literal `docs/~drafts`),
576/// so it is left alone there. `~user` is not expanded either: resolving another
577/// user's home needs the password database, and treating it as the current
578/// user's home would silently match the wrong directory.
579///
580/// The expansion is a glob pattern, so separators are normalized to `/` on
581/// Windows: `\` is globset's escape character, and matched paths are normalized
582/// the same way (see [`path_relative_to`]).
583pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
584    // Resolve the home directory only for a pattern that references it: every
585    // other pattern would otherwise pay for the lookup and its canonicalization.
586    if !has_home_prefix(pattern) {
587        return Cow::Borrowed(pattern);
588    }
589    expand_home_prefix_impl(pattern, home_dir().as_deref())
590}
591
592/// Whether `pattern` starts with a home reference (`~` or `~/`).
593fn has_home_prefix(pattern: &str) -> bool {
594    pattern == "~" || pattern.starts_with("~/")
595}
596
597fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
598    let Some(suffix) = (if pattern == "~" {
599        Some("")
600    } else {
601        pattern.strip_prefix("~/")
602    }) else {
603        return Cow::Borrowed(pattern);
604    };
605    let Some(home) = home else {
606        return Cow::Borrowed(pattern);
607    };
608
609    let home = normalize_pattern_separators(home.to_string_lossy());
610    let home = home.trim_end_matches('/');
611    if suffix.is_empty() {
612        Cow::Owned(home.to_string())
613    } else {
614        Cow::Owned(format!("{home}/{suffix}"))
615    }
616}
617
618/// Normalize path separators to `/` for glob matching. On Windows `\` is
619/// globset's escape character, so a native path must be rewritten before it can
620/// be used as - or matched against - a pattern. No-op on Unix, where `\` is a
621/// legal filename character.
622fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
623    if cfg!(windows) && path.contains('\\') {
624        Cow::Owned(path.replace('\\', "/"))
625    } else {
626        path
627    }
628}
629
630/// Normalize a config path pattern for matching against paths discovered under
631/// `base`: expand a leading `~`, then rewrite an absolute pattern as one
632/// relative to `base` when `base` contains it.
633///
634/// The rewrite is what makes an absolute pattern usable as a walker override:
635/// the `ignore` crate reads a leading `/` as "anchored to the walk base", so
636/// `/home/dev/docs/**` would otherwise be understood as
637/// `<base>/home/dev/docs/**` and match nothing. A pattern pointing outside
638/// `base` is left absolute - nothing under this walk can match it, which is the
639/// correct outcome.
640///
641/// A pattern can also name `base`'s location through a symlink
642/// (`/var/folders/…` for a base at `/private/var/folders/…`), which no strip of
643/// `base` in either form removes. Its leading literal components are then
644/// resolved, giving the same location in the base's own spelling. Only that
645/// prefix is rewritten and the strip consumes it, so what survives is the
646/// pattern as written. A pattern whose *first* component holds a wildcard or a
647/// brace alternation has no such prefix and stays absolute.
648pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
649    let expanded = expand_home_prefix(pattern);
650    let Some(base) = base else {
651        return expanded.into_owned();
652    };
653    if !is_absolute_pattern(&expanded) {
654        return expanded.into_owned();
655    }
656
657    if let Some(relative) = strip_base_prefix(Path::new(expanded.as_ref()), base) {
658        return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
659    }
660    if let Some(canonical_pattern) = canonicalize_pattern_prefix(&expanded)
661        && let Some(relative) = strip_base_prefix(Path::new(&canonical_pattern), base)
662    {
663        return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
664    }
665    expanded.into_owned()
666}
667
668/// `pattern` with `base` removed, trying the base as given and canonicalized so
669/// a symlinked or non-canonical base (macOS `/var`, a Windows 8.3 short name)
670/// still strips. `None` when the pattern does not live under `base`.
671fn strip_base_prefix<'a>(pattern: &'a Path, base: &Path) -> Option<&'a Path> {
672    pattern.strip_prefix(base).ok().or_else(|| {
673        let canonical = canonicalize_for_matching(base)?;
674        pattern.strip_prefix(canonical).ok()
675    })
676}
677
678/// Expands directory-style patterns to also match files within them.
679/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
680/// the directory itself and all contents recursively. A leading `~` is
681/// expanded first (see [`expand_home_prefix`]).
682///
683/// The expansion is driven by the pattern's *final* component: it names a
684/// directory only when it holds no wildcard. `docs/*` therefore stays as
685/// written (it names direct children, and `docs/*/**` would newly exclude
686/// nested contents), while `**/.cursor/plans` gains its contents-expansion
687/// despite the wildcard earlier in the pattern.
688pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
689    let pattern = expand_home_prefix(pattern);
690    let base = pattern.trim_end_matches('/');
691    let final_component = base.rsplit('/').next().unwrap_or(base);
692
693    if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
694        return vec![pattern.to_string()];
695    }
696
697    vec![
698        base.to_string(),     // Match the directory itself
699        format!("{base}/**"), // Match everything underneath
700    ]
701}
702
703/// The `ignore` override rule that excludes `pattern`.
704///
705/// The crate spells exclusion with a leading `!`; a pattern already carrying one
706/// passes through.
707pub fn exclude_override_rule(pattern: &str) -> String {
708    if pattern.starts_with('!') {
709        pattern.to_string()
710    } else {
711        format!("!{pattern}")
712    }
713}
714
715/// Whether every glob an `exclude` pattern turns into compiles.
716///
717/// An exclude pattern reaches two consumers: [`ExcludeMatchers`] compiles each
718/// expansion with `globset`, and the walker adds each as an `ignore` override.
719/// Both are mirrored here so a caller holding only the pattern can tell whether
720/// either would reject it, which is also when either would print it.
721pub fn exclude_pattern_compiles(pattern: &str) -> bool {
722    expand_directory_pattern(pattern).iter().all(|expanded| {
723        Glob::new(expanded).is_ok()
724            && ignore::overrides::OverrideBuilder::new(Path::new("."))
725                .add(&exclude_override_rule(expanded))
726                .is_ok()
727    })
728}
729
730/// Whether an `include` pattern compiles as a walker override.
731///
732/// Answers for the pattern as given, which is only the form the walker uses once
733/// [`normalize_pattern_for_base`] has run: stripping a base prefix removes
734/// whatever the base's own name held, and an absolute pattern under a directory
735/// called `notes [2019-2021]` carries a character class over a descending range
736/// until the prefix comes off. Ask this about the pattern the walker is about to
737/// add, never about the one a config file spelled.
738pub fn include_pattern_compiles(pattern: &str) -> bool {
739    ignore::overrides::OverrideBuilder::new(Path::new("."))
740        .add(&expand_home_prefix(pattern))
741        .is_ok()
742}
743
744/// Compiled `exclude` patterns with directory-pattern expansion applied.
745///
746/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
747/// root-relative path (the CLI relativizes against the project root, the
748/// LSP against the containing workspace root) so patterns like
749/// `docs/drafts` behave identically everywhere.
750pub struct ExcludeMatchers {
751    matchers: Vec<(String, GlobMatcher)>,
752    /// Whether any pattern is absolute, i.e. whether matching has to consider
753    /// a file's absolute path at all. Keeps the common (all-relative) case
754    /// from paying for the canonicalization that check needs.
755    has_absolute: bool,
756    /// Spellings of a file the absolute patterns reach through a symlink.
757    aliases: PathAliases,
758    /// Patterns that failed to compile, with their errors. Callers decide
759    /// how to surface these (CLI prints to stderr, LSP logs).
760    pub invalid: Vec<(String, String)>,
761}
762
763/// Whether `pattern` names an absolute location. A leading `/` counts on every
764/// platform: patterns use `/` separators, so a Unix-style path stays absolute
765/// when the same config is read on Windows.
766pub fn is_absolute_pattern(pattern: &str) -> bool {
767    pattern.starts_with('/') || Path::new(pattern).is_absolute()
768}
769
770/// Whether `pattern` names an absolute location in any of its spellings.
771///
772/// A brace alternation can put the absolute part past the start of the pattern
773/// (`{/opt,/srv}/docs/**`), where [`is_absolute_pattern`] cannot see it. Callers
774/// deciding whether to match a file's absolute path at all must ask this, or
775/// such a pattern is never given an absolute path to match.
776pub fn has_absolute_spelling(pattern: &str) -> bool {
777    if is_absolute_pattern(pattern) {
778        return true;
779    }
780    pattern.contains('{')
781        && expand_braces(pattern)
782            .iter()
783            .any(|spelling| is_absolute_pattern(spelling))
784}
785
786/// How many literal spellings one pattern's brace alternations may produce.
787///
788/// Expansion only *discovers* directory prefixes to canonicalize (see
789/// [`PathAliases`]); it never decides whether a pattern matches. Past this
790/// point a pattern like `{a,b}{c,d}{e,f}…` is multiplying out work that buys
791/// nothing, so the pattern is left unexpanded.
792const MAX_BRACE_EXPANSIONS: usize = 64;
793
794/// Every literal spelling of `pattern`'s brace alternations:
795/// `/{var/folders,tmp}/**` yields `/var/folders/**` and `/tmp/**`.
796///
797/// Returns just `pattern` when it holds no alternation, when its braces are
798/// unbalanced, or when expanding would exceed [`MAX_BRACE_EXPANSIONS`].
799/// Character classes are opaque, so a comma inside `[...]` stays literal.
800fn expand_braces(pattern: &str) -> Vec<String> {
801    let mut pending = vec![pattern.to_string()];
802    let mut expanded: Vec<String> = Vec::new();
803    while let Some(current) = pending.pop() {
804        let Some((prefix, alternatives, suffix)) = split_first_alternation(&current) else {
805            expanded.push(current);
806            continue;
807        };
808        if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
809            return vec![pattern.to_string()];
810        }
811        for alternative in alternatives {
812            pending.push(format!("{prefix}{alternative}{suffix}"));
813        }
814    }
815    expanded
816}
817
818/// Split `pattern` at its first top-level brace alternation into the text
819/// before it, its alternatives, and the text after it. `None` when there is no
820/// alternation to split on, including an unclosed `{`.
821///
822/// Empty alternatives are dropped, mirroring globset: it compiles `x{,y}` to
823/// `^x(?:y)$`, so `x` is not one of that pattern's spellings.
824fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
825    let bytes = pattern.as_bytes();
826    let mut open = None;
827    let mut depth = 0usize;
828    let mut in_class = false;
829    let mut alternatives = Vec::new();
830    let mut alternative_start = 0;
831    let mut index = 0;
832    while index < bytes.len() {
833        match bytes[index] {
834            b'\\' if !cfg!(windows) => index += 1,
835            b'[' if !in_class => in_class = true,
836            b']' if in_class => in_class = false,
837            _ if in_class => {}
838            b'{' => {
839                depth += 1;
840                if depth == 1 {
841                    open = Some(index);
842                    alternative_start = index + 1;
843                }
844            }
845            b',' if depth == 1 => {
846                alternatives.push(&pattern[alternative_start..index]);
847                alternative_start = index + 1;
848            }
849            b'}' if depth > 0 => {
850                depth -= 1;
851                if depth == 0 {
852                    alternatives.push(&pattern[alternative_start..index]);
853                    alternatives.retain(|alternative| !alternative.is_empty());
854                    if alternatives.is_empty() {
855                        return None;
856                    }
857                    return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
858                }
859            }
860            _ => {}
861        }
862        index += 1;
863    }
864    None
865}
866
867/// The leading run of `pattern`'s path components that hold no glob
868/// metacharacter: `/var/folders/**` yields `/var/folders`, `/var/log/app*.md`
869/// yields `/var/log`, and a fully literal pattern yields itself.
870///
871/// `None` when the run is empty or names only the filesystem root, neither of
872/// which can resolve to a different location. The result is always a prefix
873/// slice of `pattern`, so the remainder can be re-attached by byte offset.
874///
875/// An escaped metacharacter (`\*` on Unix) simply ends the run early. That
876/// yields a shorter prefix, never a wrong one.
877fn literal_path_prefix(pattern: &str) -> Option<&str> {
878    let mut end = 0;
879    let mut saw_component = false;
880    for component in pattern.split('/') {
881        if component.contains(GLOB_METACHARS) {
882            break;
883        }
884        saw_component |= !component.is_empty();
885        // Skip past this component and the separator that follows it.
886        end += component.len() + 1;
887    }
888    if !saw_component {
889        return None;
890    }
891    // The loop counted a separator after the final component; the pattern only
892    // has one when the run did not reach its end. A trailing separator is
893    // dropped so the remainder re-attaches with exactly one.
894    let prefix = &pattern[..(end - 1).min(pattern.len())];
895    Some(prefix.strip_suffix('/').unwrap_or(prefix))
896}
897
898/// `pattern` with its leading literal components resolved through symlinks, or
899/// `None` when there is nothing to resolve, the prefix does not exist, or
900/// resolving changes nothing.
901fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
902    let prefix = literal_path_prefix(pattern)?;
903    let canonical = canonicalize_for_matching(Path::new(prefix))?;
904    let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
905    if canonical == prefix {
906        return None;
907    }
908    Some(format!("{canonical}{}", &pattern[prefix.len()..]))
909}
910
911/// Alternative spellings of a path, implied by the absolute patterns in a
912/// configuration.
913///
914/// A pattern names a location the way the user wrote it (`/var/folders/**` on
915/// macOS); the file it is matched against arrives canonicalized
916/// (`/private/var/folders/…`), so the two never meet. Each pair recorded here
917/// is one symlinked prefix some pattern reached a location through: the
918/// canonical form of that pattern's leading literal components, and the
919/// spelling the pattern used for them.
920///
921/// Rewriting the *path* rather than the pattern leaves globset the only
922/// authority on what a pattern means, and cannot invent a match:
923/// `canonicalize(as_written) == canonical` together with `path == canonical +
924/// rest` say that `as_written + rest` names that same file. Brace alternations
925/// are expanded only to find more prefixes to canonicalize, so an expansion
926/// that disagrees with globset can cost a spelling, never fabricate one.
927#[derive(Debug, Default)]
928pub struct PathAliases {
929    /// `(canonical prefix, the spelling a pattern used for it)`.
930    prefixes: Vec<(PathBuf, String)>,
931}
932
933impl PathAliases {
934    /// Collect the symlinked prefixes `patterns` reach locations through.
935    ///
936    /// Each pattern is canonicalized once here, at cache-build time, so
937    /// per-file matching pays no syscall.
938    pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
939        let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
940        for pattern in patterns {
941            let pattern = expand_home_prefix(pattern);
942            if !has_absolute_spelling(&pattern) {
943                continue;
944            }
945            for spelling in expand_braces(&pattern) {
946                let Some(as_written) = literal_path_prefix(&spelling) else {
947                    continue;
948                };
949                if !is_absolute_pattern(as_written) {
950                    continue;
951                }
952                let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
953                    continue;
954                };
955                if canonical == Path::new(as_written) {
956                    continue;
957                }
958                let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
959                if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
960                    prefixes.push((canonical, as_written));
961                }
962            }
963        }
964        Self { prefixes }
965    }
966
967    pub fn is_empty(&self) -> bool {
968        self.prefixes.is_empty()
969    }
970
971    /// The spellings of `path` reachable through a recorded prefix, as glob
972    /// match candidates. Empty when no pattern reached `path`'s location
973    /// through a symlink, which is every configuration that has none.
974    pub fn spellings_of(&self, path: &Path) -> Vec<String> {
975        self.prefixes
976            .iter()
977            .filter_map(|(canonical, as_written)| {
978                let rest = path.strip_prefix(canonical).ok()?;
979                if rest.as_os_str().is_empty() {
980                    return Some(as_written.clone());
981                }
982                let rest = normalize_pattern_separators(rest.to_string_lossy());
983                Some(format!("{as_written}/{rest}"))
984            })
985            .collect()
986    }
987}
988
989impl ExcludeMatchers {
990    pub fn new(patterns: &[String]) -> Self {
991        let mut matchers = Vec::new();
992        let mut invalid = Vec::new();
993        let mut has_absolute = false;
994        for pattern in patterns.iter().flat_map(|p| expand_directory_pattern(p)) {
995            has_absolute |= has_absolute_spelling(&pattern);
996            match Glob::new(&pattern) {
997                Ok(glob) => matchers.push((pattern, glob.compile_matcher())),
998                Err(e) => invalid.push((pattern, e.to_string())),
999            }
1000        }
1001        let aliases = PathAliases::new(matchers.iter().map(|(pattern, _)| pattern.as_str()));
1002        Self {
1003            matchers,
1004            has_absolute,
1005            aliases,
1006            invalid,
1007        }
1008    }
1009
1010    pub fn is_empty(&self) -> bool {
1011        self.matchers.is_empty()
1012    }
1013
1014    /// The first pattern matching `relative_path`, if any.
1015    pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1016        self.matchers
1017            .iter()
1018            .find(|(_, matcher)| matcher.is_match(relative_path))
1019            .map(|(pattern, _)| pattern.as_str())
1020    }
1021
1022    pub fn is_match(&self, relative_path: &str) -> bool {
1023        self.matched_pattern(relative_path).is_some()
1024    }
1025
1026    /// The first pattern matching a file, if any.
1027    ///
1028    /// Both forms of the file are tried: its `relative` form (how patterns are
1029    /// normally written - relative to the project or workspace root) and its
1030    /// absolute path, which is what an absolute pattern matches. Absolute
1031    /// patterns reach config either written literally or through `~` expansion,
1032    /// and the walker's overrides cannot apply them (the `ignore` crate anchors
1033    /// a leading `/` to the walk root), so this is where they take effect.
1034    ///
1035    /// Checking the absolute path cannot widen a relative pattern: globs are
1036    /// anchored at the start of the matched string, so `drafts/**` never
1037    /// matches `/home/dev/proj/drafts/note.md`.
1038    ///
1039    /// `absolute` is canonicalized before matching, since an expanded `~`
1040    /// resolves to a canonical location. Files that cannot be canonicalized
1041    /// (already deleted, unreadable) are matched as given.
1042    ///
1043    /// A pattern that named its location through a symlink (`/var/folders/**`
1044    /// for a macOS temp directory) never matches that canonical form, so the
1045    /// file's other spellings are tried too (see [`PathAliases`]).
1046    pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1047        if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1048            return Some(pattern);
1049        }
1050        if !self.has_absolute {
1051            return None;
1052        }
1053        let canonical = canonicalize_for_matching(absolute);
1054        let absolute = canonical.as_deref().unwrap_or(absolute);
1055        if let Some(pattern) = self.matched_pattern(&normalize_pattern_separators(absolute.to_string_lossy())) {
1056            return Some(pattern);
1057        }
1058        self.aliases
1059            .spellings_of(absolute)
1060            .into_iter()
1061            .find_map(|alias| self.matched_pattern(&alias))
1062    }
1063
1064    /// Whether any pattern matches the file (see [`matched_pattern_for_file`](Self::matched_pattern_for_file)).
1065    pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1066        self.matched_pattern_for_file(relative, absolute).is_some()
1067    }
1068}
1069
1070/// Relativize `path` against `base` for exclude-pattern matching,
1071/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
1072/// path-representation differences don't defeat the prefix strip. Returns
1073/// `None` when `path` is not under `base`.
1074///
1075/// Separators are normalized to `/` on Windows, following the project
1076/// convention for path strings; globset matches either form, but log
1077/// output and assertions see one canonical shape.
1078pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1079    let canonical_base = base.canonicalize().ok()?;
1080    let canonical_path = path.canonicalize().ok()?;
1081    canonical_path.strip_prefix(&canonical_base).ok().map(|rel| {
1082        let rel = rel.to_string_lossy();
1083        if cfg!(windows) {
1084            rel.replace('\\', "/")
1085        } else {
1086            rel.to_string()
1087        }
1088    })
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094    use std::fs;
1095    use tempfile::tempdir;
1096
1097    #[test]
1098    fn markdown_extensions_match_case_insensitively() {
1099        for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
1100            assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
1101        }
1102        for ext in ["rs", "txt", "mdq", ""] {
1103            assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
1104        }
1105        assert!(has_markdown_extension(Path::new("a/b/README.md")));
1106        assert!(has_markdown_extension(Path::new("notebook.Rmd")));
1107        assert!(!has_markdown_extension(Path::new("no_extension")));
1108        assert!(!has_markdown_extension(Path::new("lib.rs")));
1109    }
1110
1111    #[test]
1112    fn lintable_selector_makes_adapter_capabilities_explicit() {
1113        let dir = tempdir().unwrap();
1114        let root = dir.path();
1115        fs::create_dir_all(root.join("docs")).unwrap();
1116        fs::create_dir_all(root.join("templates")).unwrap();
1117        fs::create_dir_all(root.join("src")).unwrap();
1118        for relative in [
1119            "docs/guide.md",
1120            "docs/notes.txt",
1121            "templates/page.md.jinja",
1122            "src/lib.rs",
1123            "src/upper.RS",
1124        ] {
1125            fs::write(root.join(relative), "content\n").unwrap();
1126        }
1127        let includes = vec![
1128            "docs/**".to_string(),
1129            "templates/**/*.md.jinja".to_string(),
1130            "src/**/*.rs".to_string(),
1131        ];
1132
1133        let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
1134        assert!(markdown.keeps(&root.join("docs/guide.md")));
1135        assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
1136        assert!(!markdown.keeps(&root.join("docs/notes.txt")));
1137        assert!(
1138            !markdown.keeps(&root.join("src/lib.rs")),
1139            "an LSP must not parse a complete Rust source file as Markdown"
1140        );
1141
1142        let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
1143        assert!(rustdoc.keeps(&root.join("src/lib.rs")));
1144        assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
1145        assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
1146
1147        let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
1148        assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
1149    }
1150
1151    #[test]
1152    fn workspace_scan_rejects_explicit_rust_includes() {
1153        let dir = tempdir().unwrap();
1154        let root = dir.path().to_path_buf();
1155        fs::create_dir(root.join("src")).unwrap();
1156        fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
1157        fs::write(root.join("README.md"), "# Readme\n").unwrap();
1158
1159        let options = MarkdownWalkOptions {
1160            respect_gitignore: false,
1161            skip_vendor_dirs: true,
1162        };
1163        let includes = vec!["src/**/*.rs".to_string()];
1164        let excludes = ExcludeMatchers::new(&[]);
1165        let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1166
1167        assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
1168        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
1169    }
1170
1171    #[test]
1172    fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
1173        let dir = tempdir().unwrap();
1174        let root = dir.path().to_path_buf();
1175        fs::create_dir(root.join("docs")).unwrap();
1176        fs::create_dir(root.join("templates")).unwrap();
1177        fs::write(root.join("README.md"), "# Root\n").unwrap();
1178        fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
1179        fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
1180        fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
1181
1182        let options = MarkdownWalkOptions {
1183            respect_gitignore: false,
1184            skip_vendor_dirs: true,
1185        };
1186        let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
1187        let excludes = ExcludeMatchers::new(&[]);
1188        let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1189
1190        // The test creates these names itself, so normalizing separators
1191        // unconditionally is safe and keeps one expected value for every platform.
1192        let names: Vec<String> = scan
1193            .collect(std::slice::from_ref(&root))
1194            .iter()
1195            .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
1196            .collect();
1197        assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
1198
1199        assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
1200        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1201        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
1202    }
1203
1204    #[test]
1205    fn workspace_scan_does_not_prune_a_vendor_named_root() {
1206        let dir = tempdir().unwrap();
1207        let root = dir.path().join("target");
1208        fs::create_dir(&root).unwrap();
1209        fs::write(root.join("README.md"), "# Root\n").unwrap();
1210        fs::create_dir(root.join("target")).unwrap();
1211        fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
1212
1213        let options = MarkdownWalkOptions {
1214            respect_gitignore: false,
1215            skip_vendor_dirs: true,
1216        };
1217        let excludes = ExcludeMatchers::new(&[]);
1218        let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
1219
1220        assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
1221        assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1222        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
1223    }
1224
1225    #[test]
1226    fn the_type_glob_selects_exactly_what_counts_as_markdown() {
1227        assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
1228
1229        // The glob stands in for `is_markdown_extension` inside a walk, so the
1230        // two have to agree on every spelling, not just the lowercase one.
1231        let mut builder = globset::GlobSetBuilder::new();
1232        for ext in MARKDOWN_EXTENSIONS {
1233            builder.add(
1234                globset::GlobBuilder::new(&any_case_extension_glob(ext))
1235                    .literal_separator(true)
1236                    .build()
1237                    .unwrap(),
1238            );
1239        }
1240        let globs = builder.build().unwrap();
1241
1242        for ext in MARKDOWN_EXTENSIONS {
1243            for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
1244                let name = format!("README.{spelling}");
1245                assert!(
1246                    globs.is_match(&name),
1247                    "{name} is markdown by extension but no type glob selects it"
1248                );
1249                assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
1250            }
1251        }
1252
1253        // Control: the glob widens case, not the extension set.
1254        for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
1255            assert!(!globs.is_match(name), "{name} should not be selected");
1256        }
1257    }
1258
1259    fn capitalize(ext: &str) -> String {
1260        let mut chars = ext.chars();
1261        match chars.next() {
1262            Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1263            None => String::new(),
1264        }
1265    }
1266
1267    #[test]
1268    fn walk_includes_hidden_files() {
1269        let temp = tempdir().unwrap();
1270        fs::create_dir_all(temp.path().join(".github")).unwrap();
1271        fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
1272        fs::write(temp.path().join("README.md"), "# hi").unwrap();
1273
1274        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1275            .build()
1276            .flatten()
1277            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1278            .map(|e| e.path().to_path_buf())
1279            .collect();
1280        assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
1281        assert!(files.iter().any(|p| p.ends_with("README.md")));
1282    }
1283
1284    #[test]
1285    fn walk_honors_gitignore_when_enabled_only() {
1286        let temp = tempdir().unwrap();
1287        fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
1288        fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
1289        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1290
1291        let walk = |respect: bool| -> Vec<std::path::PathBuf> {
1292            markdown_walk_builder(
1293                temp.path(),
1294                &MarkdownWalkOptions {
1295                    respect_gitignore: respect,
1296                    ..Default::default()
1297                },
1298            )
1299            .build()
1300            .flatten()
1301            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1302            .map(|e| e.path().to_path_buf())
1303            .collect()
1304        };
1305
1306        let respected = walk(true);
1307        assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
1308        assert!(respected.iter().any(|p| p.ends_with("kept.md")));
1309
1310        let unrespected = walk(false);
1311        assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
1312    }
1313
1314    #[test]
1315    fn a_gitignore_above_the_repository_root_stays_outside_it() {
1316        let temp = tempdir().unwrap();
1317        fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
1318        let repo = temp.path().join("repo");
1319        fs::create_dir_all(repo.join(".git")).unwrap();
1320        fs::write(repo.join("kept.md"), "# hi").unwrap();
1321
1322        let walk = |root: &Path| -> Vec<std::path::PathBuf> {
1323            markdown_walk_builder(root, &MarkdownWalkOptions::default())
1324                .build()
1325                .flatten()
1326                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1327                .map(|e| e.path().to_path_buf())
1328                .collect()
1329        };
1330
1331        assert!(
1332            walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1333            "git reads no gitignore above the repository root, so neither does the walk"
1334        );
1335
1336        // Control: outside a repository there is no root to stop at, and the
1337        // ignore files above are all the walk has to go on.
1338        fs::remove_dir(repo.join(".git")).unwrap();
1339        assert!(
1340            !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1341            "with no repository to bound it, the walk keeps reading upward"
1342        );
1343    }
1344
1345    #[test]
1346    fn the_repository_boundary_needs_every_root_to_have_one() {
1347        let temp = tempdir().unwrap();
1348        let inside = temp.path().join("repo/docs");
1349        fs::create_dir_all(&inside).unwrap();
1350        fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
1351        let outside = temp.path().join("plain");
1352        fs::create_dir_all(&outside).unwrap();
1353
1354        assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
1355        assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
1356
1357        // A walk has one setting for all of its roots. Bounding this one would
1358        // strip the outside root of gitignore handling altogether, which is a
1359        // worse answer than reading one file too many.
1360        assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
1361        assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
1362
1363        // A worktree and a submodule mark their root with a `.git` file rather
1364        // than a directory, and both are still repository roots.
1365        let worktree = temp.path().join("worktree");
1366        fs::create_dir_all(&worktree).unwrap();
1367        fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1368        assert!(stops_at_repository_root(&[&worktree]));
1369    }
1370
1371    #[test]
1372    fn walk_honors_markdownlintignore() {
1373        let temp = tempdir().unwrap();
1374        fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
1375        fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
1376        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1377
1378        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1379            .build()
1380            .flatten()
1381            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1382            .map(|e| e.path().to_path_buf())
1383            .collect();
1384        assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
1385        assert!(files.iter().any(|p| p.ends_with("kept.md")));
1386    }
1387
1388    #[test]
1389    fn vendor_dirs_skipped_only_when_requested() {
1390        let temp = tempdir().unwrap();
1391        for dir in ["node_modules", "target", "src"] {
1392            fs::create_dir_all(temp.path().join(dir)).unwrap();
1393            fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
1394        }
1395
1396        let walk = |skip: bool| -> Vec<std::path::PathBuf> {
1397            markdown_walk_builder(
1398                temp.path(),
1399                &MarkdownWalkOptions {
1400                    skip_vendor_dirs: skip,
1401                    // Disable gitignore handling so ambient .gitignore files in the
1402                    // temp directory's ancestry cannot mask the vendor-dir filtering
1403                    // this test exercises.
1404                    respect_gitignore: false,
1405                },
1406            )
1407            .build()
1408            .flatten()
1409            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1410            .map(|e| e.path().to_path_buf())
1411            .collect()
1412        };
1413
1414        let skipped = walk(true);
1415        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1416        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
1417        assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
1418
1419        let unskipped = walk(false);
1420        assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1421    }
1422
1423    #[test]
1424    fn explicit_file_name_glob_extracts_literal_extensions() {
1425        assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
1426        assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
1427        assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
1428        assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
1429        assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
1430        assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
1431    }
1432
1433    #[test]
1434    fn explicit_file_name_glob_rejects_unpinned_patterns() {
1435        for pattern in [
1436            "docs/",
1437            "docs/**",
1438            "docs",
1439            "*",
1440            "**",
1441            "**/*",
1442            "*.*",
1443            "*.md*",
1444            "*.{md,jinja}",
1445            "*.md?",
1446            "data.[ch]",
1447            "!drafts/*.md.jinja",
1448            "",
1449            "**/Makefile",
1450            "*.",
1451        ] {
1452            assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
1453        }
1454    }
1455
1456    #[test]
1457    fn explicit_include_matchers_match_full_relative_paths() {
1458        let matchers = ExplicitIncludeMatchers::new(&[
1459            "**/*.md.jinja".to_string(),
1460            "docs/**".to_string(),
1461            "templates/NOTES.tmpl".to_string(),
1462        ]);
1463        assert!(!matchers.is_empty());
1464        assert!(matchers.matches_relative_path("test.md.jinja"));
1465        assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
1466        assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
1467        // The directory pattern must not widen the filter to arbitrary files.
1468        assert!(!matchers.matches_relative_path("docs/anything.txt"));
1469        assert!(!matchers.matches_relative_path("test.jinja"));
1470        // A broad sibling pattern must not inherit the literal pattern's
1471        // allowance for files that merely share its name.
1472        assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
1473        assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
1474
1475        let globs: Vec<_> = matchers.file_name_globs().collect();
1476        assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
1477    }
1478
1479    #[test]
1480    fn explicit_include_matchers_follow_gitignore_anchoring() {
1481        // No slash: matches at any depth.
1482        let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
1483        assert!(unanchored.matches_relative_path("test.md.jinja"));
1484        assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
1485
1486        // Slash: anchored to the root, and `*` does not cross separators.
1487        let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
1488        assert!(anchored.matches_relative_path("docs/a.txt"));
1489        assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
1490        assert!(!anchored.matches_relative_path("other/docs/a.txt"));
1491
1492        // Leading slash: anchored, slash stripped for matching.
1493        let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
1494        assert!(rooted.matches_relative_path("NOTES.tmpl"));
1495        assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
1496    }
1497
1498    #[test]
1499    fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
1500        let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
1501        assert!(matchers.is_empty());
1502        assert!(!matchers.matches_relative_path("x.md.jinja"));
1503    }
1504
1505    #[test]
1506    fn explicit_include_matchers_skip_invalid_globs() {
1507        // The unclosed bracket pins a literal `.tmpl` suffix but fails glob
1508        // compilation; it must be skipped without poisoning valid patterns.
1509        let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
1510        assert!(matchers.matches_relative_path("ok.md.jinja"));
1511        assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
1512    }
1513
1514    #[test]
1515    fn exclude_matchers_expand_directory_patterns() {
1516        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
1517        assert!(matchers.is_match("drafts"));
1518        assert!(
1519            matchers.is_match("drafts/inner.md"),
1520            "directory pattern must match contents"
1521        );
1522        assert!(matchers.is_match("note.tmp.md"));
1523        assert!(!matchers.is_match("docs/guide.md"));
1524        assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
1525        assert!(matchers.invalid.is_empty());
1526    }
1527
1528    #[test]
1529    fn expand_home_prefix_expands_only_a_leading_tilde() {
1530        let home = Path::new("/home/dev");
1531        assert_eq!(
1532            expand_home_prefix_impl("~/.cursor/plans", Some(home)),
1533            "/home/dev/.cursor/plans"
1534        );
1535        assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
1536        assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
1537    }
1538
1539    #[test]
1540    fn expand_home_prefix_leaves_interior_tildes_alone() {
1541        let home = Path::new("/home/dev");
1542        // `~` is a legal filename character; only a leading `~/` is a home reference.
1543        for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
1544            assert_eq!(
1545                expand_home_prefix_impl(pattern, Some(home)),
1546                pattern,
1547                "{pattern:?} must be left as written"
1548            );
1549        }
1550    }
1551
1552    #[test]
1553    fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
1554        assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
1555    }
1556
1557    #[test]
1558    fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
1559        let temp = tempdir().unwrap();
1560        // Canonicalize the way production does, so the pattern has the shape an
1561        // expanded `~` produces (on Windows that means no verbatim prefix).
1562        let base = canonicalize_for_matching(temp.path()).unwrap();
1563        let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
1564        assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
1565    }
1566
1567    #[test]
1568    fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
1569        // The base as handed to us (a symlinked `/var` on macOS, a Windows 8.3
1570        // short name) must still strip.
1571        let temp = tempdir().unwrap();
1572        let canonical = canonicalize_for_matching(temp.path()).unwrap();
1573        let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
1574        assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
1575    }
1576
1577    #[test]
1578    fn normalize_pattern_for_base_leaves_other_patterns_alone() {
1579        let temp = tempdir().unwrap();
1580        let base = canonicalize_for_matching(temp.path()).unwrap();
1581        // Relative patterns are already base-relative.
1582        assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
1583        // An absolute pattern outside the base stays absolute: nothing under
1584        // this walk can match it, which is the correct outcome.
1585        assert_eq!(
1586            normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
1587            "/somewhere/else/**"
1588        );
1589        // With no base there is nothing to rewrite against.
1590        assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
1591    }
1592
1593    #[test]
1594    fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
1595        // The exact shape `canonicalize` returns on Windows. Left unstripped it
1596        // normalizes to `//?/C:/...`, which matches nothing.
1597        assert_eq!(
1598            strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1599            r"C:\Users\dev\AppData\Local\Temp\x"
1600        );
1601        assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1602        // UNC shares unwrap to their ordinary `\\server\share` form.
1603        assert_eq!(
1604            strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1605            r"\\server\share\docs"
1606        );
1607    }
1608
1609    #[test]
1610    fn strip_verbatim_prefix_unwraps_the_display_form() {
1611        // The same paths after display normalization has turned `\` into `/`.
1612        assert_eq!(
1613            strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
1614            "C:/Users/dev/AppData/Local/Temp/x"
1615        );
1616        assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
1617        assert_eq!(
1618            strip_verbatim_prefix("//?/UNC/server/share/docs"),
1619            "//server/share/docs"
1620        );
1621    }
1622
1623    #[test]
1624    fn strip_verbatim_prefix_leaves_other_paths_alone() {
1625        for path in [
1626            "/home/dev/docs",
1627            r"C:\Users\dev",
1628            "C:/Users/dev",
1629            r"\\server\share",
1630            "//server/share",
1631            // A device namespace has no ordinary equivalent to unwrap to.
1632            r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1633            "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
1634            r"\\?\",
1635            "//?/",
1636            // A prefix written in one separator does not unwrap in the other.
1637            r"\\?/C:/Users/dev",
1638            "",
1639        ] {
1640            assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1641        }
1642    }
1643
1644    #[test]
1645    fn expand_directory_pattern_expands_a_literal_final_component() {
1646        // A glob earlier in the pattern must not block contents-expansion: the
1647        // final component names a directory, so its contents are excluded too.
1648        assert_eq!(
1649            expand_directory_pattern("**/.cursor/plans"),
1650            vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1651        );
1652        assert_eq!(
1653            expand_directory_pattern("docs/**/drafts"),
1654            vec!["docs/**/drafts", "docs/**/drafts/**"]
1655        );
1656        // Alternation names literal directories, so it keeps its expansion.
1657        assert_eq!(
1658            expand_directory_pattern("logs/{a,b}"),
1659            vec!["logs/{a,b}", "logs/{a,b}/**"]
1660        );
1661    }
1662
1663    #[test]
1664    fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1665        // `docs/*` names direct children only; expanding it to `docs/*/**` would
1666        // newly exclude nested contents.
1667        for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1668            assert_eq!(
1669                expand_directory_pattern(pattern),
1670                vec![pattern.to_string()],
1671                "{pattern:?} must not gain a contents-expansion"
1672            );
1673        }
1674    }
1675
1676    #[test]
1677    fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1678        let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1679        let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1680        assert!(
1681            matchers.excludes_file(None, excluded),
1682            "an absolute pattern must match the absolute path when there is no relative form"
1683        );
1684        assert_eq!(
1685            matchers.matched_pattern_for_file(None, excluded),
1686            Some("/home/dev/.cursor/plans/**")
1687        );
1688        // A file inside a project root still has a relative form; the absolute
1689        // pattern must match it through the absolute path.
1690        assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1691        assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1692    }
1693
1694    #[test]
1695    fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1696        // Relative patterns are anchored at the start of the matched string, so
1697        // adding the absolute-path check must not widen them into `**/drafts`.
1698        let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1699        assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1700        assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1701    }
1702
1703    #[test]
1704    fn exclude_matchers_report_invalid_patterns() {
1705        let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1706        assert_eq!(matchers.invalid.len(), 1);
1707        assert_eq!(matchers.invalid[0].0, "[");
1708        assert!(matchers.is_match("ok.md"));
1709    }
1710
1711    #[test]
1712    fn path_relative_to_strips_through_symlinked_base() {
1713        let temp = tempdir().unwrap();
1714        let base = temp.path().join("base");
1715        fs::create_dir_all(base.join("docs")).unwrap();
1716        fs::write(base.join("docs/a.md"), "# hi").unwrap();
1717
1718        assert_eq!(
1719            path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
1720            Some("docs/a.md")
1721        );
1722        assert_eq!(
1723            path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
1724            Some("a.md")
1725        );
1726        assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
1727    }
1728
1729    fn sorted(mut patterns: Vec<String>) -> Vec<String> {
1730        patterns.sort();
1731        patterns
1732    }
1733
1734    #[test]
1735    fn expand_braces_yields_every_alternative() {
1736        assert_eq!(
1737            sorted(expand_braces("/{var/folders,tmp}/**")),
1738            vec!["/tmp/**", "/var/folders/**"]
1739        );
1740        // Nesting expands too.
1741        assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
1742        // An empty alternative is dropped, as globset drops it.
1743        assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
1744        // Several groups multiply out.
1745        assert_eq!(
1746            sorted(expand_braces("/{a,b}/{c,d}.md")),
1747            vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
1748        );
1749    }
1750
1751    #[test]
1752    fn expand_braces_leaves_patterns_it_cannot_split() {
1753        // Nothing to split.
1754        assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
1755        // An unclosed brace is not an alternation.
1756        assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
1757        // A comma inside a character class is literal.
1758        assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
1759        // An alternation of nothing but empty alternatives is not a split.
1760        assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
1761        // Past the expansion cap the pattern is left alone: 2^7 = 128 > 64.
1762        let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
1763        assert_eq!(expand_braces(wide), vec![wide]);
1764    }
1765
1766    #[test]
1767    fn has_absolute_spelling_sees_past_a_leading_alternation() {
1768        assert!(has_absolute_spelling("/var/folders/**"));
1769        assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
1770        assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
1771        assert!(!has_absolute_spelling("docs/**"));
1772        assert!(!has_absolute_spelling("{docs,notes}/**"));
1773    }
1774
1775    #[test]
1776    fn expand_braces_agrees_with_globset() {
1777        // The expansion only discovers prefixes to canonicalize, but a
1778        // disagreement with globset would mean it is describing a different
1779        // pattern than the one that decides matches.
1780        let cases = [
1781            ("/{var/folders,tmp}/**", "/tmp/note.md"),
1782            ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
1783            ("/{var/folders,tmp}/**", "/opt/note.md"),
1784            ("/{a,b}/{c,d}.md", "/b/d.md"),
1785            ("/{a,b}/{c,d}.md", "/b/e.md"),
1786            ("x{,y}", "x"),
1787            ("x{,y}", "xy"),
1788            ("/var/[a,b]/**", "/var/a/n.md"),
1789            ("/var/[a,b]/**", "/var/,/n.md"),
1790            ("/var/folders/**", "/var/folders/n.md"),
1791        ];
1792        for (pattern, path) in cases {
1793            let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
1794            let expanded = expand_braces(pattern)
1795                .iter()
1796                .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
1797            assert_eq!(direct, expanded, "pattern {pattern} against {path}");
1798        }
1799    }
1800
1801    #[test]
1802    fn literal_path_prefix_stops_at_the_first_wildcard() {
1803        assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
1804        assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
1805        assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
1806        assert_eq!(literal_path_prefix("/var/"), Some("/var"));
1807        assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
1808        // Nothing literal to resolve.
1809        assert_eq!(literal_path_prefix("/**"), None);
1810        assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
1811        assert_eq!(literal_path_prefix("**/note.md"), None);
1812        // The result is always a prefix slice, so a remainder re-attaches by
1813        // byte offset.
1814        let pattern = "/var/folders/**";
1815        let prefix = literal_path_prefix(pattern).unwrap();
1816        assert_eq!(&pattern[prefix.len()..], "/**");
1817    }
1818
1819    /// `(real directory, symlink to it)` under a fresh temp dir. The symlink is
1820    /// how a pattern spells the location; the real directory is where a file
1821    /// canonicalizes to.
1822    #[cfg(unix)]
1823    fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
1824        let real = temp.join("real");
1825        fs::create_dir_all(real.join("notes")).unwrap();
1826        fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
1827        let link = temp.join("link");
1828        std::os::unix::fs::symlink(&real, &link).unwrap();
1829        (canonicalize_for_matching(&real).unwrap(), link)
1830    }
1831
1832    #[cfg(unix)]
1833    #[test]
1834    fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
1835        let temp = tempdir().unwrap();
1836        let (real, link) = symlinked_dir(temp.path());
1837        let pattern = format!("{}/notes/**", link.to_string_lossy());
1838
1839        let aliases = PathAliases::new([pattern.as_str()]);
1840        assert!(!aliases.is_empty());
1841        assert_eq!(
1842            aliases.spellings_of(&real.join("notes/scratch.md")),
1843            vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
1844        );
1845        // The alias is what makes the pattern match the canonical path.
1846        let matcher = Glob::new(&pattern).unwrap().compile_matcher();
1847        assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
1848        assert!(
1849            aliases
1850                .spellings_of(&real.join("notes/scratch.md"))
1851                .iter()
1852                .any(|alias| matcher.is_match(alias))
1853        );
1854        // A path outside the recorded prefix has no alias.
1855        assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
1856    }
1857
1858    #[cfg(unix)]
1859    #[test]
1860    fn path_aliases_reach_through_a_brace_alternation() {
1861        // The prefix only exists once the alternation is expanded.
1862        let temp = tempdir().unwrap();
1863        let (real, link) = symlinked_dir(temp.path());
1864        let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
1865
1866        let aliases = PathAliases::new([pattern.as_str()]);
1867        let matcher = Glob::new(&pattern).unwrap().compile_matcher();
1868        let note = real.join("notes/scratch.md");
1869        assert!(!matcher.is_match(&note), "negative control");
1870        assert!(aliases.spellings_of(&note).iter().any(|alias| matcher.is_match(alias)));
1871    }
1872
1873    #[test]
1874    fn path_aliases_are_empty_without_a_symlinked_prefix() {
1875        let temp = tempdir().unwrap();
1876        let canonical = canonicalize_for_matching(temp.path()).unwrap();
1877        let canonical = canonical.to_string_lossy().replace('\\', "/");
1878        // Relative patterns, absolute patterns that already name their real
1879        // location, and prefixes that do not exist all record nothing.
1880        for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
1881            assert!(
1882                PathAliases::new([pattern]).is_empty(),
1883                "pattern {pattern} should record no alias"
1884            );
1885        }
1886    }
1887
1888    #[cfg(unix)]
1889    #[test]
1890    fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
1891        let temp = tempdir().unwrap();
1892        let (real, link) = symlinked_dir(temp.path());
1893        let note = real.join("notes/scratch.md");
1894
1895        let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
1896        assert!(matchers.excludes_file(None, &note));
1897
1898        // Negative controls: a sibling the pattern does not name, and a pattern
1899        // pointing somewhere else entirely.
1900        fs::write(real.join("other.md"), "# Other\n").unwrap();
1901        assert!(!matchers.excludes_file(None, &real.join("other.md")));
1902        let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
1903        assert!(!elsewhere.excludes_file(None, &note));
1904    }
1905
1906    #[cfg(unix)]
1907    #[test]
1908    fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
1909        let temp = tempdir().unwrap();
1910        let (real, link) = symlinked_dir(temp.path());
1911        let pattern = format!("{}/notes/*.md", link.to_string_lossy());
1912
1913        assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
1914        // A pattern naming a different location through the same symlink is
1915        // still outside a narrower base, and stays absolute.
1916        let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
1917        assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
1918    }
1919}