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, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
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/// How rumdl reads a file, decided by its name.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum FileKind {
239    /// Linted as Markdown, the whole file.
240    Markdown,
241    /// Rust source, linted through its doc comments only.
242    RustDocComments,
243    /// Source code rumdl cannot read: linting it as Markdown reports on the
244    /// program, and fixing it rewrites the program.
245    SourceCode,
246}
247
248/// Classify `path` by its file name.
249///
250/// In order: a Markdown final extension is Markdown; a lowercase `.rs` is Rust
251/// doc comments; a Markdown extension earlier in the name (`README.md.jinja`, a
252/// template of a Markdown file) or Literate CoffeeScript (`.litcoffee`, which is
253/// Markdown with code blocks) is Markdown; a final extension of a programming
254/// or data language is source code; anything else (`.txt`, no extension, an
255/// unknown one) stays Markdown, as rumdl has always treated a file it is
256/// pointed at.
257pub fn classify_file(path: &Path) -> FileKind {
258    if has_markdown_extension(path) {
259        return FileKind::Markdown;
260    }
261    if crate::doc_comment_lint::is_rust_source(path) {
262        return FileKind::RustDocComments;
263    }
264    let Some(extension) = path.extension().and_then(OsStr::to_str) else {
265        return FileKind::Markdown;
266    };
267    if extension.eq_ignore_ascii_case("litcoffee") || has_inner_markdown_extension(path) {
268        return FileKind::Markdown;
269    }
270    if crate::linguist_data::is_source_code_extension(extension) {
271        FileKind::SourceCode
272    } else {
273        FileKind::Markdown
274    }
275}
276
277/// Whether an extension before the final one is a Markdown extension, as `md`
278/// in `README.md.jinja`. Stems follow `Path` semantics, so a leading dot
279/// (`.md.swift`) names a hidden file, not an extension.
280fn has_inner_markdown_extension(path: &Path) -> bool {
281    let mut stem = path.file_stem();
282    while let Some(current) = stem {
283        let current = Path::new(current);
284        if has_markdown_extension(current) {
285            return true;
286        }
287        stem = current.extension().and(current.file_stem());
288    }
289    false
290}
291
292/// A glob selecting `ext` in any letter case, as `*.[mM][dD]` for `md`.
293///
294/// Walk type globs match case-sensitively, so a plain `*.md` hides `README.MD`
295/// from a directory scan even though [`is_markdown_extension`] calls it
296/// markdown and naming the file on the command line lints it. Deriving the glob
297/// from the same extension keeps the walk's filter from being narrower than the
298/// definition it stands in for.
299pub fn any_case_extension_glob(ext: &str) -> String {
300    let mut glob = String::with_capacity(2 + ext.len() * 4);
301    glob.push_str("*.");
302    for ch in ext.chars() {
303        if ch.is_ascii_alphabetic() {
304            glob.push('[');
305            glob.push(ch.to_ascii_lowercase());
306            glob.push(ch.to_ascii_uppercase());
307            glob.push(']');
308        } else {
309            glob.push(ch);
310        }
311    }
312    glob
313}
314
315/// The linter-specific ignore file, read on every directory walk.
316pub const MARKDOWNLINTIGNORE: &str = ".markdownlintignore";
317
318/// Ignore-handling options applied to a markdown discovery walk.
319#[derive(Debug, Clone)]
320pub struct MarkdownWalkOptions {
321    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
322    /// and parent ignore files. Driven by `global.respect_gitignore`.
323    pub respect_gitignore: bool,
324    /// Skip `.git`, `node_modules`, and `target` directories outright, even
325    /// when gitignore handling is disabled or would not cover them.
326    pub skip_vendor_dirs: bool,
327}
328
329impl Default for MarkdownWalkOptions {
330    fn default() -> Self {
331        Self {
332            respect_gitignore: true,
333            skip_vendor_dirs: false,
334        }
335    }
336}
337
338/// Whether a walk over `roots` stops reading gitignores at the repository root.
339///
340/// Git reads no `.gitignore` above the repository root, so a walk that does hides
341/// files `git check-ignore` reports as visible. Worse, such a file can hide a
342/// whole directory, and a pruned directory is never descended into, so no include
343/// pattern gets the chance to name anything inside it.
344///
345/// Outside a repository there is no root to stop at, and ignore files are all a
346/// walk has to go on, so there they keep applying upward. One walk has one
347/// setting for all of its roots, so the boundary is only applied when every root
348/// has a repository to bound it.
349pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
350    !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
351}
352
353/// Whether `path` sits inside a git or jujutsu repository.
354///
355/// A `.git` entry is a directory in an ordinary clone and a file in a worktree or
356/// submodule, so existence alone is the marker. This recognizes a repository the
357/// same way the walker does, which is what puts the boundary in the same place.
358fn in_repository(path: &Path) -> bool {
359    let Ok(absolute) = std::fs::canonicalize(path) else {
360        return false;
361    };
362    absolute
363        .ancestors()
364        .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
365}
366
367/// Apply the shared ignore-handling configuration to a walker over `roots`.
368///
369/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
370/// same as a visible one); generated content is kept out by gitignore
371/// semantics and, for callers that opt in, the vendor-directory skip.
372/// `.markdownlintignore` is honored for markdownlint compatibility, whatever
373/// `respect_gitignore` says: markdownlint-cli applies it independently of any
374/// gitignore handling, and it is a list written for the linter alone, so
375/// turning off git's ignore files is not a request to lint what it names.
376///
377/// The roots decide where gitignore reading stops, so a caller passes the same
378/// ones it walks.
379pub fn apply_markdown_walk_options<P: AsRef<Path>>(
380    builder: &mut ignore::WalkBuilder,
381    roots: &[P],
382    options: &MarkdownWalkOptions,
383) {
384    apply_walk_options_with_markdownlintignore(builder, roots, options, true);
385}
386
387/// [`apply_markdown_walk_options`], with `.markdownlintignore` switchable.
388///
389/// Only an explanation of an empty run turns it off, to find out which ignore
390/// source hid a file. A walk deciding what to lint always reads it.
391pub fn apply_walk_options_with_markdownlintignore<P: AsRef<Path>>(
392    builder: &mut ignore::WalkBuilder,
393    roots: &[P],
394    options: &MarkdownWalkOptions,
395    markdownlintignore: bool,
396) {
397    let gitignore = options.respect_gitignore;
398    builder
399        .ignore(gitignore)
400        .git_ignore(gitignore)
401        .git_global(gitignore)
402        .git_exclude(gitignore)
403        // Parent directories are read for every walk: each source toggle above
404        // still decides what they contribute, so with the gitignore family off
405        // a parent `.markdownlintignore` is the only file read from them.
406        .parents(true)
407        .hidden(false)
408        // This setting does double duty in the walker: it gates gitignore
409        // handling on a repository being present, and it is what stops the walk
410        // reading gitignores above the repository root. Inside a repository both
411        // are wanted. Outside one, requiring a repository would drop `.gitignore`
412        // handling entirely, and there is no root to stop at in any case.
413        .require_git(stops_at_repository_root(roots));
414    if markdownlintignore {
415        builder.add_custom_ignore_filename(MARKDOWNLINTIGNORE);
416    }
417
418    if options.skip_vendor_dirs {
419        let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
420        builder.filter_entry(move |entry| {
421            if roots.iter().any(|root| root == entry.path()) {
422                return true;
423            }
424            let name = entry.file_name().to_str().unwrap_or("");
425            name != ".git" && name != "node_modules" && name != "target"
426        });
427    }
428}
429
430/// Build a walker over `root` configured with the shared options.
431pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
432    let mut builder = ignore::WalkBuilder::new(root);
433    apply_markdown_walk_options(&mut builder, &[root], options);
434    builder
435}
436
437/// A complete, configured Markdown workspace scan.
438///
439/// This owns the selection policy shared by full scans and incremental file
440/// events: standard Markdown extensions, explicit nonstandard file includes,
441/// include filtering, excludes, ignore files, and optional vendor-directory
442/// pruning. Adapters choose the options; they do not reconstruct the policy.
443pub struct MarkdownWorkspaceScan<'a> {
444    options: &'a MarkdownWalkOptions,
445    includes: &'a [String],
446    excludes: &'a ExcludeMatchers,
447}
448
449impl<'a> MarkdownWorkspaceScan<'a> {
450    pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
451        Self {
452            options,
453            includes,
454            excludes,
455        }
456    }
457
458    /// Collect all selected files under `roots`.
459    pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
460        let mut files = Vec::new();
461        for root in roots {
462            let selection = RootSelection::new(root, self.includes);
463            let mut builder = markdown_walk_builder(root, self.options);
464            selection.configure_walk(&mut builder);
465
466            for result in builder.build() {
467                match result {
468                    Ok(entry)
469                        if entry.file_type().is_some_and(|file_type| file_type.is_file())
470                            && selection.is_lintable(entry.path())
471                            && !self.excluded(root, entry.path()) =>
472                    {
473                        files.push(entry.into_path());
474                    }
475                    Ok(_) => {}
476                    Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
477                }
478            }
479        }
480        files.sort();
481        files.dedup();
482        files
483    }
484
485    /// Whether an incremental file event would be absent from a full scan.
486    pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
487        let Some(root) = roots
488            .iter()
489            .filter(|root| path.starts_with(root))
490            .max_by_key(|root| root.components().count())
491        else {
492            return false;
493        };
494
495        let selection = RootSelection::new(root, self.includes);
496        if !selection.selects(path) || self.excluded(root, path) {
497            return true;
498        }
499
500        if self.options.skip_vendor_dirs
501            && let Ok(relative) = path.strip_prefix(root)
502            && relative.components().any(|component| {
503                matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
504            })
505        {
506            return true;
507        }
508
509        let target = path.to_path_buf();
510        let mut builder = markdown_walk_builder(root, self.options);
511        selection.configure_walk(&mut builder);
512        // `filter_entry` replaces the vendor filter, which was checked above.
513        builder.filter_entry(move |entry| target.starts_with(entry.path()));
514        !builder.build().flatten().any(|entry| entry.path() == path)
515    }
516
517    fn excluded(&self, root: &Path, path: &Path) -> bool {
518        self.excludes
519            .excludes_file(path_relative_to(path, root).as_deref(), path)
520    }
521}
522
523struct RootSelection {
524    lintable: LintablePathSelector,
525    overrides: Option<ignore::overrides::Override>,
526}
527
528impl RootSelection {
529    fn new(root: &Path, includes: &[String]) -> Self {
530        let normalized: Vec<String> = includes
531            .iter()
532            .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
533            .collect();
534        let overrides = if normalized.is_empty() {
535            None
536        } else {
537            let mut builder = ignore::overrides::OverrideBuilder::new(root);
538            for pattern in &normalized {
539                if let Err(error) = builder.add(pattern) {
540                    log::warn!("Invalid include pattern '{pattern}': {error}");
541                }
542            }
543            builder.build().ok()
544        };
545        Self {
546            lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
547            overrides,
548        }
549    }
550
551    fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
552        if let Err(error) = self.lintable.configure_types(builder) {
553            log::warn!("Failed to configure workspace source types: {error}");
554        }
555        if let Some(overrides) = &self.overrides {
556            builder.overrides(overrides.clone());
557        }
558    }
559
560    fn selects(&self, path: &Path) -> bool {
561        self.overrides
562            .as_ref()
563            .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
564            && self.is_lintable(path)
565    }
566
567    fn is_lintable(&self, path: &Path) -> bool {
568        self.lintable.keeps(path)
569    }
570}
571
572/// Drop Windows' verbatim `\\?\` prefix from a canonicalized path string.
573///
574/// `std::fs::canonicalize` returns the verbatim form (`\\?\C:\Users\dev`) on
575/// Windows. That form is useless for pattern matching: it does not compare
576/// equal to the ordinary paths rumdl works with, and normalizing its
577/// separators for globbing mangles it into `//?/C:/Users/dev`, which matches
578/// nothing. Only a drive path (`\\?\C:\...`) and a UNC share
579/// (`\\?\UNC\server\share` -> `\\server\share`) are unwrapped; any other
580/// verbatim path names a device namespace that has no ordinary equivalent, so
581/// it is left alone.
582///
583/// The prefix is recognized in whichever separator the path is written with:
584/// `\\?\` as `canonicalize` returns it, or `//?/` once the separators have
585/// been normalized. Displayed paths are stripped by the CLI's display layer;
586/// output formatters strip again because a path can reach them unchanged.
587///
588/// Pure string logic, compiled on every platform so it stays under test where
589/// Windows is not available. On other platforms no path ever carries this
590/// prefix.
591pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
592    let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
593        return Cow::Borrowed(path);
594    };
595    let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
596        return Cow::Borrowed(path);
597    };
598    // `\\?\UNC\server\share` -> `\\server\share`. The remainder already starts
599    // with one separator, so restoring the UNC form needs one more prepended.
600    if let Some(share) = rest.strip_prefix("UNC")
601        && share.starts_with(sep)
602    {
603        return Cow::Owned(format!("{sep}{share}"));
604    }
605    let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
606    if is_drive_path {
607        Cow::Borrowed(rest)
608    } else {
609        Cow::Borrowed(path)
610    }
611}
612
613/// Canonicalize `path` for pattern matching, or `None` when it cannot be
614/// resolved (a missing or unreadable file).
615///
616/// Canonical form is what patterns are matched against, so a symlinked
617/// location (`/home/dev` -> `/mnt/dev`, or a macOS `/var` -> `/private/var`)
618/// still matches. Windows' verbatim prefix is removed (see
619/// [`strip_verbatim_prefix`]).
620pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
621    let canonical = path.canonicalize().ok()?;
622    if !cfg!(windows) {
623        return Some(canonical);
624    }
625    let as_str = canonical.to_string_lossy();
626    Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
627}
628
629/// Resolve `path` to the absolute form patterns are matched against, whether or
630/// not anything exists there.
631///
632/// A path that exists resolves exactly as [`canonicalize_for_matching`] resolves
633/// it. One that does not (an unsaved editor buffer, a file not written yet) is
634/// resolved through its deepest existing ancestor: that ancestor is
635/// canonicalized and the missing remainder appended, with its `.` and `..`
636/// resolved lexically, so the file is spelled the way it will canonicalize once
637/// it exists. A relative path is taken relative to the working directory, which
638/// is how the filesystem reads it.
639pub fn resolve_for_matching(path: &Path) -> PathBuf {
640    if let Some(canonical) = canonicalize_for_matching(path) {
641        return canonical;
642    }
643    let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
644    let resolved = resolve_through_existing_ancestor(&absolute);
645    // A `..` in the missing remainder can climb back into directories that
646    // exist, and one of those may be a symlink. The lexical result holds no
647    // `..` any more, so resolving it once more settles it.
648    if absolute.components().any(|c| c == std::path::Component::ParentDir) {
649        resolve_through_existing_ancestor(&resolved)
650    } else {
651        resolved
652    }
653}
654
655/// Canonicalize the deepest ancestor of `absolute` that exists and append the
656/// rest of the path to it lexically (see [`resolve_for_matching`]).
657fn resolve_through_existing_ancestor(absolute: &Path) -> PathBuf {
658    for ancestor in absolute.ancestors() {
659        if let Some(canonical) = canonicalize_for_matching(ancestor) {
660            let remainder = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
661            if remainder.as_os_str().is_empty() {
662                return canonical;
663            }
664            return crate::workspace_index::normalize_relative_path(&canonical.join(remainder));
665        }
666    }
667    crate::workspace_index::normalize_relative_path(absolute)
668}
669
670/// The user's home directory, or `None` when it cannot be resolved.
671///
672/// Canonicalized for matching (see [`canonicalize_for_matching`]), falling
673/// back to the path as reported when it cannot be canonicalized.
674///
675/// Wasm and WASI builds have no home directory to resolve, so patterns keep
676/// their `~` there (see [`expand_home_prefix`]).
677fn home_dir() -> Option<PathBuf> {
678    #[cfg(feature = "native")]
679    {
680        use etcetera::{BaseStrategy, choose_base_strategy};
681        choose_base_strategy()
682            .ok()
683            .map(|s| canonicalize_for_matching(s.home_dir()).unwrap_or_else(|| s.home_dir().to_path_buf()))
684    }
685    #[cfg(not(feature = "native"))]
686    {
687        None
688    }
689}
690
691/// Expand a leading `~` in a path pattern to the user's home directory, so a
692/// user-level config (`~/.config/rumdl/rumdl.toml`) can name a home path
693/// without hardcoding a username.
694///
695/// Only a bare `~` and a `~/` prefix expand. `~` is a legal filename character
696/// everywhere else (editor backups like `notes.md~`, a literal `docs/~drafts`),
697/// so it is left alone there. `~user` is not expanded either: resolving another
698/// user's home needs the password database, and treating it as the current
699/// user's home would silently match the wrong directory.
700///
701/// The expansion is a glob pattern, so separators are normalized to `/` on
702/// Windows: `\` is globset's escape character, and matched paths are normalized
703/// the same way (see [`path_relative_to`]).
704pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
705    // Resolve the home directory only for a pattern that references it: every
706    // other pattern would otherwise pay for the lookup and its canonicalization.
707    if !has_home_prefix(pattern) {
708        return Cow::Borrowed(pattern);
709    }
710    expand_home_prefix_impl(pattern, home_dir().as_deref())
711}
712
713/// Whether `pattern` starts with a home reference (`~` or `~/`).
714fn has_home_prefix(pattern: &str) -> bool {
715    pattern == "~" || pattern.starts_with("~/")
716}
717
718fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
719    let Some(suffix) = (if pattern == "~" {
720        Some("")
721    } else {
722        pattern.strip_prefix("~/")
723    }) else {
724        return Cow::Borrowed(pattern);
725    };
726    let Some(home) = home else {
727        return Cow::Borrowed(pattern);
728    };
729
730    let home = normalize_pattern_separators(home.to_string_lossy());
731    let home = home.trim_end_matches('/');
732    if suffix.is_empty() {
733        Cow::Owned(home.to_string())
734    } else {
735        Cow::Owned(format!("{home}/{suffix}"))
736    }
737}
738
739/// Normalize path separators to `/` for glob matching. On Windows `\` is
740/// globset's escape character, so a native path must be rewritten before it can
741/// be used as - or matched against - a pattern. No-op on Unix, where `\` is a
742/// legal filename character.
743fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
744    if cfg!(windows) && path.contains('\\') {
745        Cow::Owned(path.replace('\\', "/"))
746    } else {
747        path
748    }
749}
750
751/// Normalize a config path pattern for matching against paths discovered under
752/// `base`: expand a leading `~`, then rewrite an absolute pattern as one
753/// relative to `base` when `base` contains it.
754///
755/// The rewrite is what makes an absolute pattern usable as a walker override:
756/// the `ignore` crate reads a leading `/` as "anchored to the walk base", so
757/// `/home/dev/docs/**` would otherwise be understood as
758/// `<base>/home/dev/docs/**` and match nothing. A pattern pointing outside
759/// `base` is left absolute - nothing under this walk can match it, which is the
760/// correct outcome.
761///
762/// A pattern can also name `base`'s location through a symlink
763/// (`/var/folders/…` for a base at `/private/var/folders/…`), which no strip of
764/// `base` in either form removes. Its leading literal components are then
765/// resolved, giving the same location in the base's own spelling. Only that
766/// prefix is rewritten and the strip consumes it, so what survives is the
767/// pattern as written. A pattern whose *first* component holds a wildcard or a
768/// brace alternation has no such prefix and stays absolute.
769pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
770    let expanded = expand_home_prefix(pattern);
771    let Some(base) = base else {
772        return expanded.into_owned();
773    };
774    if !is_absolute_pattern(&expanded) {
775        return expanded.into_owned();
776    }
777
778    if let Some(relative) = strip_base_prefix(Path::new(expanded.as_ref()), base) {
779        return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
780    }
781    if let Some(canonical_pattern) = canonicalize_pattern_prefix(&expanded)
782        && let Some(relative) = strip_base_prefix(Path::new(&canonical_pattern), base)
783    {
784        return normalize_pattern_separators(relative.to_string_lossy()).into_owned();
785    }
786    expanded.into_owned()
787}
788
789/// `pattern` with `base` removed, trying the base as given and canonicalized so
790/// a symlinked or non-canonical base (macOS `/var`, a Windows 8.3 short name)
791/// still strips. `None` when the pattern does not live under `base`.
792fn strip_base_prefix<'a>(pattern: &'a Path, base: &Path) -> Option<&'a Path> {
793    pattern.strip_prefix(base).ok().or_else(|| {
794        let canonical = canonicalize_for_matching(base)?;
795        pattern.strip_prefix(canonical).ok()
796    })
797}
798
799/// Expands directory-style patterns to also match files within them.
800/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
801/// the directory itself and all contents recursively. A leading `~` is
802/// expanded first (see [`expand_home_prefix`]).
803///
804/// The expansion is driven by the pattern's *final* component: it names a
805/// directory only when it holds no wildcard. `docs/*` therefore stays as
806/// written (it names direct children, and `docs/*/**` would newly exclude
807/// nested contents), while `**/.cursor/plans` gains its contents-expansion
808/// despite the wildcard earlier in the pattern.
809pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
810    let pattern = expand_home_prefix(pattern);
811    let base = pattern.trim_end_matches('/');
812    let final_component = base.rsplit('/').next().unwrap_or(base);
813
814    if final_component.is_empty() || final_component.contains(['*', '?', '[']) {
815        return vec![pattern.to_string()];
816    }
817
818    vec![
819        base.to_string(),     // Match the directory itself
820        format!("{base}/**"), // Match everything underneath
821    ]
822}
823
824/// The `ignore` override rule that excludes `pattern`.
825///
826/// The crate spells exclusion with a leading `!`; a pattern already carrying one
827/// passes through.
828pub fn exclude_override_rule(pattern: &str) -> String {
829    if pattern.starts_with('!') {
830        pattern.to_string()
831    } else {
832        format!("!{pattern}")
833    }
834}
835
836/// Whether every glob an `exclude` pattern turns into compiles.
837///
838/// An exclude pattern reaches two consumers: [`ExcludeMatchers`] compiles each
839/// expansion with `globset`, and the walker adds each as an `ignore` override.
840/// Both are mirrored here so a caller holding only the pattern can tell whether
841/// either would reject it, which is also when either would print it.
842pub fn exclude_pattern_compiles(pattern: &str) -> bool {
843    expand_directory_pattern(pattern).iter().all(|expanded| {
844        exclude_glob(expanded).is_ok()
845            && ignore::overrides::OverrideBuilder::new(Path::new("."))
846                .add(&exclude_override_rule(expanded))
847                .is_ok()
848    })
849}
850
851/// Whether an `include` pattern compiles as a walker override.
852///
853/// Answers for the pattern as given, which is only the form the walker uses once
854/// [`normalize_pattern_for_base`] has run: stripping a base prefix removes
855/// whatever the base's own name held, and an absolute pattern under a directory
856/// called `notes [2019-2021]` carries a character class over a descending range
857/// until the prefix comes off. Ask this about the pattern the walker is about to
858/// add, never about the one a config file spelled.
859pub fn include_pattern_compiles(pattern: &str) -> bool {
860    ignore::overrides::OverrideBuilder::new(Path::new("."))
861        .add(&expand_home_prefix(pattern))
862        .is_ok()
863}
864
865/// The globs matching everything one expanded exclude pattern removes: the
866/// pattern itself, the contents of a directory it matches when `with_contents`,
867/// and each of those at any depth when the pattern `floats` (see
868/// [`ExcludeMatchers::new`]).
869fn exclusion_globs(pattern: &str, with_contents: bool, floats: bool) -> Result<GlobSet, globset::Error> {
870    let mut located = vec![pattern.to_string()];
871    if with_contents && !pattern.ends_with("/**") {
872        located.push(format!("{}/**", pattern.trim_end_matches('/')));
873    }
874    if floats {
875        let anywhere: Vec<String> = located.iter().map(|glob| format!("**/{glob}")).collect();
876        located.extend(anywhere);
877    }
878    let mut builder = GlobSetBuilder::new();
879    for glob in &located {
880        builder.add(exclude_glob(glob)?);
881    }
882    builder.build()
883}
884
885/// One exclude glob, compiled the way `.gitignore` reads it: `*` and `?` match
886/// within one path component, and only `**` spans a `/`.
887fn exclude_glob(glob: &str) -> Result<Glob, globset::Error> {
888    GlobBuilder::new(glob).literal_separator(true).build()
889}
890
891/// One expanded `exclude` pattern and the globs matching what it excludes.
892struct ExcludeMatcher {
893    pattern: String,
894    globs: GlobSet,
895    /// Whether any spelling of the pattern is absolute, which is what makes a
896    /// file's absolute path worth matching against it.
897    absolute: bool,
898}
899
900/// Compiled `exclude` patterns with directory-pattern expansion applied.
901///
902/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
903/// root-relative path (the CLI relativizes against the project root, the
904/// LSP against the containing workspace root) so patterns like
905/// `docs/drafts` behave identically everywhere.
906pub struct ExcludeMatchers {
907    /// Each expanded pattern (see [`ExcludeMatchers::new`]).
908    matchers: Vec<ExcludeMatcher>,
909    /// Spellings of a file the absolute patterns reach through a symlink.
910    aliases: PathAliases,
911    /// Patterns that failed to compile, with their errors. Callers decide
912    /// how to surface these (CLI prints to stderr, LSP logs).
913    pub invalid: Vec<(String, String)>,
914}
915
916/// Whether `pattern` names an absolute location. A leading `/` counts on every
917/// platform: patterns use `/` separators, so a Unix-style path stays absolute
918/// when the same config is read on Windows.
919pub fn is_absolute_pattern(pattern: &str) -> bool {
920    pattern.starts_with('/') || Path::new(pattern).is_absolute()
921}
922
923/// Whether `pattern` names an absolute location in any of its spellings.
924///
925/// A brace alternation can put the absolute part past the start of the pattern
926/// (`{/opt,/srv}/docs/**`), where [`is_absolute_pattern`] cannot see it. Callers
927/// deciding whether to match a file's absolute path at all must ask this, or
928/// such a pattern is never given an absolute path to match.
929pub fn has_absolute_spelling(pattern: &str) -> bool {
930    if is_absolute_pattern(pattern) {
931        return true;
932    }
933    pattern.contains('{')
934        && expand_braces(pattern)
935            .iter()
936            .any(|spelling| is_absolute_pattern(spelling))
937}
938
939/// How many literal spellings one pattern's brace alternations may produce.
940///
941/// Expansion only *discovers* directory prefixes to canonicalize (see
942/// [`PathAliases`]); it never decides whether a pattern matches. Past this
943/// point a pattern like `{a,b}{c,d}{e,f}…` is multiplying out work that buys
944/// nothing, so the pattern is left unexpanded.
945const MAX_BRACE_EXPANSIONS: usize = 64;
946
947/// Every literal spelling of `pattern`'s brace alternations:
948/// `/{var/folders,tmp}/**` yields `/var/folders/**` and `/tmp/**`.
949///
950/// Returns just `pattern` when it holds no alternation, when its braces are
951/// unbalanced, or when expanding would exceed [`MAX_BRACE_EXPANSIONS`].
952/// Character classes are opaque, so a comma inside `[...]` stays literal.
953fn expand_braces(pattern: &str) -> Vec<String> {
954    let mut pending = vec![pattern.to_string()];
955    let mut expanded: Vec<String> = Vec::new();
956    while let Some(current) = pending.pop() {
957        let Some((prefix, alternatives, suffix)) = split_first_alternation(&current) else {
958            expanded.push(current);
959            continue;
960        };
961        if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
962            return vec![pattern.to_string()];
963        }
964        for alternative in alternatives {
965            pending.push(format!("{prefix}{alternative}{suffix}"));
966        }
967    }
968    expanded
969}
970
971/// Split `pattern` at its first top-level brace alternation into the text
972/// before it, its alternatives, and the text after it. `None` when there is no
973/// alternation to split on, including an unclosed `{`.
974///
975/// Empty alternatives are dropped, mirroring globset: it compiles `x{,y}` to
976/// `^x(?:y)$`, so `x` is not one of that pattern's spellings.
977fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
978    let bytes = pattern.as_bytes();
979    let mut open = None;
980    let mut depth = 0usize;
981    let mut in_class = false;
982    let mut alternatives = Vec::new();
983    let mut alternative_start = 0;
984    let mut index = 0;
985    while index < bytes.len() {
986        match bytes[index] {
987            b'\\' if !cfg!(windows) => index += 1,
988            b'[' if !in_class => in_class = true,
989            b']' if in_class => in_class = false,
990            _ if in_class => {}
991            b'{' => {
992                depth += 1;
993                if depth == 1 {
994                    open = Some(index);
995                    alternative_start = index + 1;
996                }
997            }
998            b',' if depth == 1 => {
999                alternatives.push(&pattern[alternative_start..index]);
1000                alternative_start = index + 1;
1001            }
1002            b'}' if depth > 0 => {
1003                depth -= 1;
1004                if depth == 0 {
1005                    alternatives.push(&pattern[alternative_start..index]);
1006                    alternatives.retain(|alternative| !alternative.is_empty());
1007                    if alternatives.is_empty() {
1008                        return None;
1009                    }
1010                    return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
1011                }
1012            }
1013            _ => {}
1014        }
1015        index += 1;
1016    }
1017    None
1018}
1019
1020/// The leading run of `pattern`'s path components that hold no glob
1021/// metacharacter: `/var/folders/**` yields `/var/folders`, `/var/log/app*.md`
1022/// yields `/var/log`, and a fully literal pattern yields itself.
1023///
1024/// `None` when the run is empty or names only the filesystem root, neither of
1025/// which can resolve to a different location. The result is always a prefix
1026/// slice of `pattern`, so the remainder can be re-attached by byte offset.
1027///
1028/// An escaped metacharacter (`\*` on Unix) simply ends the run early. That
1029/// yields a shorter prefix, never a wrong one.
1030fn literal_path_prefix(pattern: &str) -> Option<&str> {
1031    let mut end = 0;
1032    let mut saw_component = false;
1033    for component in pattern.split('/') {
1034        if component.contains(GLOB_METACHARS) {
1035            break;
1036        }
1037        saw_component |= !component.is_empty();
1038        // Skip past this component and the separator that follows it.
1039        end += component.len() + 1;
1040    }
1041    if !saw_component {
1042        return None;
1043    }
1044    // The loop counted a separator after the final component; the pattern only
1045    // has one when the run did not reach its end. A trailing separator is
1046    // dropped so the remainder re-attaches with exactly one.
1047    let prefix = &pattern[..(end - 1).min(pattern.len())];
1048    Some(prefix.strip_suffix('/').unwrap_or(prefix))
1049}
1050
1051/// `pattern` with its leading literal components resolved through symlinks, or
1052/// `None` when there is nothing to resolve, the prefix does not exist, or
1053/// resolving changes nothing.
1054fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
1055    let prefix = literal_path_prefix(pattern)?;
1056    let canonical = canonicalize_for_matching(Path::new(prefix))?;
1057    let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
1058    if canonical == prefix {
1059        return None;
1060    }
1061    Some(format!("{canonical}{}", &pattern[prefix.len()..]))
1062}
1063
1064/// Alternative spellings of a path, implied by the absolute patterns in a
1065/// configuration.
1066///
1067/// A pattern names a location the way the user wrote it (`/var/folders/**` on
1068/// macOS); the file it is matched against arrives canonicalized
1069/// (`/private/var/folders/…`), so the two never meet. Each pair recorded here
1070/// is one symlinked prefix some pattern reached a location through: the
1071/// canonical form of that pattern's leading literal components, and the
1072/// spelling the pattern used for them.
1073///
1074/// Rewriting the *path* rather than the pattern leaves globset the only
1075/// authority on what a pattern means, and cannot invent a match:
1076/// `canonicalize(as_written) == canonical` together with `path == canonical +
1077/// rest` say that `as_written + rest` names that same file. Brace alternations
1078/// are expanded only to find more prefixes to canonicalize, so an expansion
1079/// that disagrees with globset can cost a spelling, never fabricate one.
1080#[derive(Debug, Default)]
1081pub struct PathAliases {
1082    /// `(canonical prefix, the spelling a pattern used for it)`.
1083    prefixes: Vec<(PathBuf, String)>,
1084}
1085
1086impl PathAliases {
1087    /// Collect the symlinked prefixes `patterns` reach locations through.
1088    ///
1089    /// Each pattern is canonicalized once here, at cache-build time, so
1090    /// per-file matching pays no syscall.
1091    pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
1092        let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
1093        for pattern in patterns {
1094            let pattern = expand_home_prefix(pattern);
1095            if !has_absolute_spelling(&pattern) {
1096                continue;
1097            }
1098            for spelling in expand_braces(&pattern) {
1099                let Some(as_written) = literal_path_prefix(&spelling) else {
1100                    continue;
1101                };
1102                if !is_absolute_pattern(as_written) {
1103                    continue;
1104                }
1105                let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
1106                    continue;
1107                };
1108                if canonical == Path::new(as_written) {
1109                    continue;
1110                }
1111                let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
1112                if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
1113                    prefixes.push((canonical, as_written));
1114                }
1115            }
1116        }
1117        Self { prefixes }
1118    }
1119
1120    pub fn is_empty(&self) -> bool {
1121        self.prefixes.is_empty()
1122    }
1123
1124    /// The spellings of `path` reachable through a recorded prefix, as glob
1125    /// match candidates. Empty when no pattern reached `path`'s location
1126    /// through a symlink, which is every configuration that has none.
1127    pub fn spellings_of(&self, path: &Path) -> Vec<String> {
1128        self.prefixes
1129            .iter()
1130            .filter_map(|(canonical, as_written)| {
1131                let rest = path.strip_prefix(canonical).ok()?;
1132                if rest.as_os_str().is_empty() {
1133                    return Some(as_written.clone());
1134                }
1135                let rest = normalize_pattern_separators(rest.to_string_lossy());
1136                Some(format!("{as_written}/{rest}"))
1137            })
1138            .collect()
1139    }
1140}
1141
1142impl ExcludeMatchers {
1143    /// Compile `patterns` to exclude what the discovery walk excludes.
1144    ///
1145    /// The walk applies each pattern as an `ignore` override, which reads it the
1146    /// way `.gitignore` does, so a file named directly has to be matched by the
1147    /// same rules or it is linted where the walk skips it:
1148    ///
1149    /// - A pattern with no `/` except a trailing one matches at any depth:
1150    ///   `node_modules` excludes `packages/app/node_modules/readme.md`.
1151    /// - A pattern that matches a directory excludes everything in it, including
1152    ///   one whose final component is a wildcard: `draft?` excludes
1153    ///   `drafts/note.md`.
1154    /// - `*` and `?` match within one path component: `sub/*.md` excludes
1155    ///   `sub/top.md` but not `sub/a/b.md`.
1156    ///
1157    /// Relative patterns are otherwise anchored at the root the path is relative
1158    /// to, and an absolute pattern never floats.
1159    pub fn new(patterns: &[String]) -> Self {
1160        let mut matchers = Vec::new();
1161        let mut invalid = Vec::new();
1162        for written in patterns {
1163            let floats = !written.trim_end_matches('/').contains('/');
1164            let expansions = expand_directory_pattern(written);
1165            // A literal directory name already expanded to its own `/**` entry,
1166            // which the notice names. Only a wildcard final component is left
1167            // without one, and a directory it matches still has contents.
1168            let with_contents = expansions.len() == 1;
1169            for pattern in expansions {
1170                let absolute = has_absolute_spelling(&pattern);
1171                match exclusion_globs(&pattern, with_contents, floats && !absolute) {
1172                    Ok(globs) => matchers.push(ExcludeMatcher {
1173                        pattern,
1174                        globs,
1175                        absolute,
1176                    }),
1177                    Err(e) => invalid.push((pattern, e.to_string())),
1178                }
1179            }
1180        }
1181        let aliases = PathAliases::new(matchers.iter().map(|matcher| matcher.pattern.as_str()));
1182        Self {
1183            matchers,
1184            aliases,
1185            invalid,
1186        }
1187    }
1188
1189    pub fn is_empty(&self) -> bool {
1190        self.matchers.is_empty()
1191    }
1192
1193    /// The first pattern matching `relative_path`, if any.
1194    pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1195        self.matchers
1196            .iter()
1197            .find(|matcher| matcher.globs.is_match(relative_path))
1198            .map(|matcher| matcher.pattern.as_str())
1199    }
1200
1201    /// The first absolute pattern matching `absolute_path`, if any.
1202    fn matched_absolute_pattern(&self, absolute_path: &str) -> Option<&str> {
1203        self.matchers
1204            .iter()
1205            .find(|matcher| matcher.absolute && matcher.globs.is_match(absolute_path))
1206            .map(|matcher| matcher.pattern.as_str())
1207    }
1208
1209    pub fn is_match(&self, relative_path: &str) -> bool {
1210        self.matched_pattern(relative_path).is_some()
1211    }
1212
1213    /// The first pattern matching a file, if any.
1214    ///
1215    /// Both forms of the file are tried: its `relative` form (how patterns are
1216    /// normally written - relative to the project or workspace root) and its
1217    /// absolute path, which is what an absolute pattern matches. Absolute
1218    /// patterns reach config either written literally or through `~` expansion,
1219    /// and the walker's overrides cannot apply them (the `ignore` crate anchors
1220    /// a leading `/` to the walk root), so this is where they take effect.
1221    ///
1222    /// Only absolute patterns are matched against the absolute path. A relative
1223    /// pattern belongs to the root its path is relative to, and one that floats
1224    /// would otherwise match a directory above that root: `docs` would exclude
1225    /// every file of a project checked out at `/home/dev/docs/site`.
1226    ///
1227    /// `absolute` is canonicalized before matching, since an expanded `~`
1228    /// resolves to a canonical location. A file that does not exist (an unsaved
1229    /// buffer, one already deleted) is resolved through its deepest existing
1230    /// ancestor (see [`resolve_for_matching`]).
1231    ///
1232    /// The path as given is tried as well, because resolving can respell a
1233    /// location the pattern names directly: on macOS `/home` canonicalizes to
1234    /// `/System/Volumes/Data/home`, which `/home/dev/**` does not match.
1235    ///
1236    /// A pattern that named its location through a symlink (`/var/folders/**`
1237    /// for a macOS temp directory) never matches that canonical form, so the
1238    /// file's other spellings are tried too (see [`PathAliases`]).
1239    pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1240        if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1241            return Some(pattern);
1242        }
1243        // Resolving the path costs syscalls, which a run with only relative
1244        // patterns has no use for.
1245        if !self.matchers.iter().any(|matcher| matcher.absolute) {
1246            return None;
1247        }
1248        let resolved = resolve_for_matching(absolute);
1249        if let Some(pattern) = self.matched_absolute_pattern(&normalize_pattern_separators(resolved.to_string_lossy()))
1250        {
1251            return Some(pattern);
1252        }
1253        // A rooted path is what an absolute pattern can name: a pattern is
1254        // absolute when it starts with `/` (see `is_absolute_pattern`), and on
1255        // Windows `\home\dev` has a root without being `is_absolute`.
1256        if absolute.has_root()
1257            && resolved != absolute
1258            && let Some(pattern) =
1259                self.matched_absolute_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
1260        {
1261            return Some(pattern);
1262        }
1263        self.aliases
1264            .spellings_of(&resolved)
1265            .into_iter()
1266            .find_map(|alias| self.matched_absolute_pattern(&alias))
1267    }
1268
1269    /// Whether any pattern matches the file (see [`matched_pattern_for_file`](Self::matched_pattern_for_file)).
1270    pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1271        self.matched_pattern_for_file(relative, absolute).is_some()
1272    }
1273}
1274
1275/// Relativize `path` against `base` for exclude-pattern matching,
1276/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
1277/// path-representation differences don't defeat the prefix strip. Returns
1278/// `None` when `path` is not under `base`, or when `base` does not exist.
1279///
1280/// `path` need not exist: it is resolved through its deepest existing ancestor
1281/// (see [`resolve_for_matching`]), so a file about to be created relativizes
1282/// the way it will once it is there.
1283///
1284/// Separators are normalized to `/` on Windows, following the project
1285/// convention for path strings; globset matches either form, but log
1286/// output and assertions see one canonical shape.
1287pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1288    let canonical_base = canonicalize_for_matching(base)?;
1289    let resolved_path = resolve_for_matching(path);
1290    resolved_path.strip_prefix(&canonical_base).ok().map(|rel| {
1291        let rel = rel.to_string_lossy();
1292        if cfg!(windows) {
1293            rel.replace('\\', "/")
1294        } else {
1295            rel.to_string()
1296        }
1297    })
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303    use std::fs;
1304    use tempfile::tempdir;
1305
1306    #[test]
1307    fn markdown_extensions_match_case_insensitively() {
1308        for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
1309            assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
1310        }
1311        for ext in ["rs", "txt", "mdq", ""] {
1312            assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
1313        }
1314        assert!(has_markdown_extension(Path::new("a/b/README.md")));
1315        assert!(has_markdown_extension(Path::new("notebook.Rmd")));
1316        assert!(!has_markdown_extension(Path::new("no_extension")));
1317        assert!(!has_markdown_extension(Path::new("lib.rs")));
1318    }
1319
1320    #[test]
1321    fn lintable_selector_makes_adapter_capabilities_explicit() {
1322        let dir = tempdir().unwrap();
1323        let root = dir.path();
1324        fs::create_dir_all(root.join("docs")).unwrap();
1325        fs::create_dir_all(root.join("templates")).unwrap();
1326        fs::create_dir_all(root.join("src")).unwrap();
1327        for relative in [
1328            "docs/guide.md",
1329            "docs/notes.txt",
1330            "templates/page.md.jinja",
1331            "src/lib.rs",
1332            "src/upper.RS",
1333        ] {
1334            fs::write(root.join(relative), "content\n").unwrap();
1335        }
1336        let includes = vec![
1337            "docs/**".to_string(),
1338            "templates/**/*.md.jinja".to_string(),
1339            "src/**/*.rs".to_string(),
1340        ];
1341
1342        let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
1343        assert!(markdown.keeps(&root.join("docs/guide.md")));
1344        assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
1345        assert!(!markdown.keeps(&root.join("docs/notes.txt")));
1346        assert!(
1347            !markdown.keeps(&root.join("src/lib.rs")),
1348            "an LSP must not parse a complete Rust source file as Markdown"
1349        );
1350
1351        let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
1352        assert!(rustdoc.keeps(&root.join("src/lib.rs")));
1353        assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
1354        assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
1355
1356        let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
1357        assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
1358    }
1359
1360    #[test]
1361    fn workspace_scan_rejects_explicit_rust_includes() {
1362        let dir = tempdir().unwrap();
1363        let root = dir.path().to_path_buf();
1364        fs::create_dir(root.join("src")).unwrap();
1365        fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
1366        fs::write(root.join("README.md"), "# Readme\n").unwrap();
1367
1368        let options = MarkdownWalkOptions {
1369            respect_gitignore: false,
1370            skip_vendor_dirs: true,
1371        };
1372        let includes = vec!["src/**/*.rs".to_string()];
1373        let excludes = ExcludeMatchers::new(&[]);
1374        let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1375
1376        assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
1377        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
1378    }
1379
1380    #[test]
1381    fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
1382        let dir = tempdir().unwrap();
1383        let root = dir.path().to_path_buf();
1384        fs::create_dir(root.join("docs")).unwrap();
1385        fs::create_dir(root.join("templates")).unwrap();
1386        fs::write(root.join("README.md"), "# Root\n").unwrap();
1387        fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
1388        fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
1389        fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
1390
1391        let options = MarkdownWalkOptions {
1392            respect_gitignore: false,
1393            skip_vendor_dirs: true,
1394        };
1395        let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
1396        let excludes = ExcludeMatchers::new(&[]);
1397        let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
1398
1399        // The test creates these names itself, so normalizing separators
1400        // unconditionally is safe and keeps one expected value for every platform.
1401        let names: Vec<String> = scan
1402            .collect(std::slice::from_ref(&root))
1403            .iter()
1404            .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
1405            .collect();
1406        assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
1407
1408        assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
1409        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1410        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
1411    }
1412
1413    #[test]
1414    fn workspace_scan_does_not_prune_a_vendor_named_root() {
1415        let dir = tempdir().unwrap();
1416        let root = dir.path().join("target");
1417        fs::create_dir(&root).unwrap();
1418        fs::write(root.join("README.md"), "# Root\n").unwrap();
1419        fs::create_dir(root.join("target")).unwrap();
1420        fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
1421
1422        let options = MarkdownWalkOptions {
1423            respect_gitignore: false,
1424            skip_vendor_dirs: true,
1425        };
1426        let excludes = ExcludeMatchers::new(&[]);
1427        let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
1428
1429        assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
1430        assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
1431        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
1432    }
1433
1434    #[test]
1435    fn the_type_glob_selects_exactly_what_counts_as_markdown() {
1436        assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
1437
1438        // The glob stands in for `is_markdown_extension` inside a walk, so the
1439        // two have to agree on every spelling, not just the lowercase one.
1440        let mut builder = globset::GlobSetBuilder::new();
1441        for ext in MARKDOWN_EXTENSIONS {
1442            builder.add(
1443                globset::GlobBuilder::new(&any_case_extension_glob(ext))
1444                    .literal_separator(true)
1445                    .build()
1446                    .unwrap(),
1447            );
1448        }
1449        let globs = builder.build().unwrap();
1450
1451        for ext in MARKDOWN_EXTENSIONS {
1452            for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
1453                let name = format!("README.{spelling}");
1454                assert!(
1455                    globs.is_match(&name),
1456                    "{name} is markdown by extension but no type glob selects it"
1457                );
1458                assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
1459            }
1460        }
1461
1462        // Control: the glob widens case, not the extension set.
1463        for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
1464            assert!(!globs.is_match(name), "{name} should not be selected");
1465        }
1466    }
1467
1468    fn capitalize(ext: &str) -> String {
1469        let mut chars = ext.chars();
1470        match chars.next() {
1471            Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1472            None => String::new(),
1473        }
1474    }
1475
1476    #[test]
1477    fn walk_includes_hidden_files() {
1478        let temp = tempdir().unwrap();
1479        fs::create_dir_all(temp.path().join(".github")).unwrap();
1480        fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
1481        fs::write(temp.path().join("README.md"), "# hi").unwrap();
1482
1483        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1484            .build()
1485            .flatten()
1486            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1487            .map(|e| e.path().to_path_buf())
1488            .collect();
1489        assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
1490        assert!(files.iter().any(|p| p.ends_with("README.md")));
1491    }
1492
1493    #[test]
1494    fn walk_honors_gitignore_when_enabled_only() {
1495        let temp = tempdir().unwrap();
1496        fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
1497        fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
1498        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1499
1500        let walk = |respect: bool| -> Vec<std::path::PathBuf> {
1501            markdown_walk_builder(
1502                temp.path(),
1503                &MarkdownWalkOptions {
1504                    respect_gitignore: respect,
1505                    ..Default::default()
1506                },
1507            )
1508            .build()
1509            .flatten()
1510            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1511            .map(|e| e.path().to_path_buf())
1512            .collect()
1513        };
1514
1515        let respected = walk(true);
1516        assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
1517        assert!(respected.iter().any(|p| p.ends_with("kept.md")));
1518
1519        let unrespected = walk(false);
1520        assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
1521    }
1522
1523    #[test]
1524    fn a_gitignore_above_the_repository_root_stays_outside_it() {
1525        let temp = tempdir().unwrap();
1526        fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
1527        let repo = temp.path().join("repo");
1528        fs::create_dir_all(repo.join(".git")).unwrap();
1529        fs::write(repo.join("kept.md"), "# hi").unwrap();
1530
1531        let walk = |root: &Path| -> Vec<std::path::PathBuf> {
1532            markdown_walk_builder(root, &MarkdownWalkOptions::default())
1533                .build()
1534                .flatten()
1535                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1536                .map(|e| e.path().to_path_buf())
1537                .collect()
1538        };
1539
1540        assert!(
1541            walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1542            "git reads no gitignore above the repository root, so neither does the walk"
1543        );
1544
1545        // Control: outside a repository there is no root to stop at, and the
1546        // ignore files above are all the walk has to go on.
1547        fs::remove_dir(repo.join(".git")).unwrap();
1548        assert!(
1549            !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
1550            "with no repository to bound it, the walk keeps reading upward"
1551        );
1552    }
1553
1554    #[test]
1555    fn the_repository_boundary_needs_every_root_to_have_one() {
1556        let temp = tempdir().unwrap();
1557        let inside = temp.path().join("repo/docs");
1558        fs::create_dir_all(&inside).unwrap();
1559        fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
1560        let outside = temp.path().join("plain");
1561        fs::create_dir_all(&outside).unwrap();
1562
1563        assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
1564        assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
1565
1566        // A walk has one setting for all of its roots. Bounding this one would
1567        // strip the outside root of gitignore handling altogether, which is a
1568        // worse answer than reading one file too many.
1569        assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
1570        assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
1571
1572        // A worktree and a submodule mark their root with a `.git` file rather
1573        // than a directory, and both are still repository roots.
1574        let worktree = temp.path().join("worktree");
1575        fs::create_dir_all(&worktree).unwrap();
1576        fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1577        assert!(stops_at_repository_root(&[&worktree]));
1578    }
1579
1580    #[test]
1581    fn walk_honors_markdownlintignore() {
1582        let temp = tempdir().unwrap();
1583        fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
1584        fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
1585        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
1586
1587        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
1588            .build()
1589            .flatten()
1590            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1591            .map(|e| e.path().to_path_buf())
1592            .collect();
1593        assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
1594        assert!(files.iter().any(|p| p.ends_with("kept.md")));
1595    }
1596
1597    #[test]
1598    fn vendor_dirs_skipped_only_when_requested() {
1599        let temp = tempdir().unwrap();
1600        for dir in ["node_modules", "target", "src"] {
1601            fs::create_dir_all(temp.path().join(dir)).unwrap();
1602            fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
1603        }
1604
1605        let walk = |skip: bool| -> Vec<std::path::PathBuf> {
1606            markdown_walk_builder(
1607                temp.path(),
1608                &MarkdownWalkOptions {
1609                    skip_vendor_dirs: skip,
1610                    // Disable gitignore handling so ambient .gitignore files in the
1611                    // temp directory's ancestry cannot mask the vendor-dir filtering
1612                    // this test exercises.
1613                    respect_gitignore: false,
1614                },
1615            )
1616            .build()
1617            .flatten()
1618            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1619            .map(|e| e.path().to_path_buf())
1620            .collect()
1621        };
1622
1623        let skipped = walk(true);
1624        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1625        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
1626        assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
1627
1628        let unskipped = walk(false);
1629        assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
1630    }
1631
1632    #[test]
1633    fn explicit_file_name_glob_extracts_literal_extensions() {
1634        assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
1635        assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
1636        assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
1637        assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
1638        assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
1639        assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
1640    }
1641
1642    #[test]
1643    fn explicit_file_name_glob_rejects_unpinned_patterns() {
1644        for pattern in [
1645            "docs/",
1646            "docs/**",
1647            "docs",
1648            "*",
1649            "**",
1650            "**/*",
1651            "*.*",
1652            "*.md*",
1653            "*.{md,jinja}",
1654            "*.md?",
1655            "data.[ch]",
1656            "!drafts/*.md.jinja",
1657            "",
1658            "**/Makefile",
1659            "*.",
1660        ] {
1661            assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
1662        }
1663    }
1664
1665    #[test]
1666    fn explicit_include_matchers_match_full_relative_paths() {
1667        let matchers = ExplicitIncludeMatchers::new(&[
1668            "**/*.md.jinja".to_string(),
1669            "docs/**".to_string(),
1670            "templates/NOTES.tmpl".to_string(),
1671        ]);
1672        assert!(!matchers.is_empty());
1673        assert!(matchers.matches_relative_path("test.md.jinja"));
1674        assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
1675        assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
1676        // The directory pattern must not widen the filter to arbitrary files.
1677        assert!(!matchers.matches_relative_path("docs/anything.txt"));
1678        assert!(!matchers.matches_relative_path("test.jinja"));
1679        // A broad sibling pattern must not inherit the literal pattern's
1680        // allowance for files that merely share its name.
1681        assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
1682        assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
1683
1684        let globs: Vec<_> = matchers.file_name_globs().collect();
1685        assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
1686    }
1687
1688    #[test]
1689    fn explicit_include_matchers_follow_gitignore_anchoring() {
1690        // No slash: matches at any depth.
1691        let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
1692        assert!(unanchored.matches_relative_path("test.md.jinja"));
1693        assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
1694
1695        // Slash: anchored to the root, and `*` does not cross separators.
1696        let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
1697        assert!(anchored.matches_relative_path("docs/a.txt"));
1698        assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
1699        assert!(!anchored.matches_relative_path("other/docs/a.txt"));
1700
1701        // Leading slash: anchored, slash stripped for matching.
1702        let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
1703        assert!(rooted.matches_relative_path("NOTES.tmpl"));
1704        assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
1705    }
1706
1707    #[test]
1708    fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
1709        let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
1710        assert!(matchers.is_empty());
1711        assert!(!matchers.matches_relative_path("x.md.jinja"));
1712    }
1713
1714    #[test]
1715    fn explicit_include_matchers_skip_invalid_globs() {
1716        // The unclosed bracket pins a literal `.tmpl` suffix but fails glob
1717        // compilation; it must be skipped without poisoning valid patterns.
1718        let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
1719        assert!(matchers.matches_relative_path("ok.md.jinja"));
1720        assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
1721    }
1722
1723    #[test]
1724    fn exclude_matchers_expand_directory_patterns() {
1725        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
1726        assert!(matchers.is_match("drafts"));
1727        assert!(
1728            matchers.is_match("drafts/inner.md"),
1729            "directory pattern must match contents"
1730        );
1731        assert!(matchers.is_match("note.tmp.md"));
1732        assert!(!matchers.is_match("docs/guide.md"));
1733        assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
1734        assert!(matchers.invalid.is_empty());
1735    }
1736
1737    #[test]
1738    fn exclude_matchers_float_a_pattern_without_a_slash_to_any_depth() {
1739        let matchers =
1740            ExcludeMatchers::new(&["node_modules".to_string(), "build/".to_string(), "notes.md".to_string()]);
1741        for path in [
1742            "node_modules/readme.md",
1743            "packages/app/node_modules/readme.md",
1744            "build/out.md",
1745            "site/build/out.md",
1746            "notes.md",
1747            "docs/notes.md",
1748        ] {
1749            assert!(matchers.is_match(path), "{path} must be excluded");
1750        }
1751        for path in ["node_modules.md", "docs/my-notes.md", "rebuild/out.md", "docs/guide.md"] {
1752            assert!(!matchers.is_match(path), "{path} must not be excluded");
1753        }
1754        // The notice names the pattern as expanded, not the glob that floated it.
1755        assert_eq!(
1756            matchers.matched_pattern("packages/app/node_modules/readme.md"),
1757            Some("node_modules/**")
1758        );
1759    }
1760
1761    #[test]
1762    fn exclude_matchers_keep_a_pattern_containing_a_slash_anchored() {
1763        let matchers = ExcludeMatchers::new(&["docs/generated".to_string(), "sub/*.md".to_string()]);
1764        assert!(matchers.is_match("docs/generated/api.md"));
1765        assert!(!matchers.is_match("site/docs/generated/api.md"));
1766        assert!(matchers.is_match("sub/a.md"));
1767        assert!(!matchers.is_match("other/sub/a.md"));
1768    }
1769
1770    #[test]
1771    fn exclude_matchers_exclude_the_contents_of_a_directory_a_wildcard_matches() {
1772        let matchers = ExcludeMatchers::new(&["draft?".to_string(), "sub/[gh]en".to_string()]);
1773        assert!(matchers.is_match("drafts/note.md"));
1774        assert!(
1775            matchers.is_match("blog/drafts/note.md"),
1776            "a slashless wildcard floats too"
1777        );
1778        assert!(matchers.is_match("sub/gen/x.md"));
1779        assert!(matchers.is_match("sub/hen/deep/x.md"));
1780        assert!(!matchers.is_match("sub/pen/x.md"));
1781        assert!(!matchers.is_match("other/sub/gen/x.md"));
1782    }
1783
1784    #[test]
1785    fn exclude_matchers_report_an_invalid_pattern_once() {
1786        let matchers = ExcludeMatchers::new(&["[".to_string(), "docs".to_string()]);
1787        assert_eq!(matchers.invalid.len(), 1, "{:?}", matchers.invalid);
1788        assert!(matchers.is_match("docs/a.md"));
1789    }
1790
1791    #[test]
1792    fn expand_home_prefix_expands_only_a_leading_tilde() {
1793        let home = Path::new("/home/dev");
1794        assert_eq!(
1795            expand_home_prefix_impl("~/.cursor/plans", Some(home)),
1796            "/home/dev/.cursor/plans"
1797        );
1798        assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
1799        assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
1800    }
1801
1802    #[test]
1803    fn expand_home_prefix_leaves_interior_tildes_alone() {
1804        let home = Path::new("/home/dev");
1805        // `~` is a legal filename character; only a leading `~/` is a home reference.
1806        for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
1807            assert_eq!(
1808                expand_home_prefix_impl(pattern, Some(home)),
1809                pattern,
1810                "{pattern:?} must be left as written"
1811            );
1812        }
1813    }
1814
1815    #[test]
1816    fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
1817        assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
1818    }
1819
1820    #[test]
1821    fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
1822        let temp = tempdir().unwrap();
1823        // Canonicalize the way production does, so the pattern has the shape an
1824        // expanded `~` produces (on Windows that means no verbatim prefix).
1825        let base = canonicalize_for_matching(temp.path()).unwrap();
1826        let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
1827        assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
1828    }
1829
1830    #[test]
1831    fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
1832        // The base as handed to us (a symlinked `/var` on macOS, a Windows 8.3
1833        // short name) must still strip.
1834        let temp = tempdir().unwrap();
1835        let canonical = canonicalize_for_matching(temp.path()).unwrap();
1836        let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
1837        assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
1838    }
1839
1840    #[test]
1841    fn normalize_pattern_for_base_leaves_other_patterns_alone() {
1842        let temp = tempdir().unwrap();
1843        let base = canonicalize_for_matching(temp.path()).unwrap();
1844        // Relative patterns are already base-relative.
1845        assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
1846        // An absolute pattern outside the base stays absolute: nothing under
1847        // this walk can match it, which is the correct outcome.
1848        assert_eq!(
1849            normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
1850            "/somewhere/else/**"
1851        );
1852        // With no base there is nothing to rewrite against.
1853        assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
1854    }
1855
1856    #[test]
1857    fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
1858        // The exact shape `canonicalize` returns on Windows. Left unstripped it
1859        // normalizes to `//?/C:/...`, which matches nothing.
1860        assert_eq!(
1861            strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
1862            r"C:\Users\dev\AppData\Local\Temp\x"
1863        );
1864        assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
1865        // UNC shares unwrap to their ordinary `\\server\share` form.
1866        assert_eq!(
1867            strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
1868            r"\\server\share\docs"
1869        );
1870    }
1871
1872    #[test]
1873    fn strip_verbatim_prefix_unwraps_the_display_form() {
1874        // The same paths after display normalization has turned `\` into `/`.
1875        assert_eq!(
1876            strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
1877            "C:/Users/dev/AppData/Local/Temp/x"
1878        );
1879        assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
1880        assert_eq!(
1881            strip_verbatim_prefix("//?/UNC/server/share/docs"),
1882            "//server/share/docs"
1883        );
1884    }
1885
1886    #[test]
1887    fn strip_verbatim_prefix_leaves_other_paths_alone() {
1888        for path in [
1889            "/home/dev/docs",
1890            r"C:\Users\dev",
1891            "C:/Users/dev",
1892            r"\\server\share",
1893            "//server/share",
1894            // A device namespace has no ordinary equivalent to unwrap to.
1895            r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
1896            "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
1897            r"\\?\",
1898            "//?/",
1899            // A prefix written in one separator does not unwrap in the other.
1900            r"\\?/C:/Users/dev",
1901            "",
1902        ] {
1903            assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
1904        }
1905    }
1906
1907    #[test]
1908    fn expand_directory_pattern_expands_a_literal_final_component() {
1909        // A glob earlier in the pattern must not block contents-expansion: the
1910        // final component names a directory, so its contents are excluded too.
1911        assert_eq!(
1912            expand_directory_pattern("**/.cursor/plans"),
1913            vec!["**/.cursor/plans", "**/.cursor/plans/**"]
1914        );
1915        assert_eq!(
1916            expand_directory_pattern("docs/**/drafts"),
1917            vec!["docs/**/drafts", "docs/**/drafts/**"]
1918        );
1919        // Alternation names literal directories, so it keeps its expansion.
1920        assert_eq!(
1921            expand_directory_pattern("logs/{a,b}"),
1922            vec!["logs/{a,b}", "logs/{a,b}/**"]
1923        );
1924    }
1925
1926    #[test]
1927    fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
1928        // `docs/*` names direct children only; expanding it to `docs/*/**` would
1929        // newly exclude nested contents.
1930        for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
1931            assert_eq!(
1932                expand_directory_pattern(pattern),
1933                vec![pattern.to_string()],
1934                "{pattern:?} must not gain a contents-expansion"
1935            );
1936        }
1937    }
1938
1939    #[test]
1940    fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
1941        let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
1942        let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
1943        assert!(
1944            matchers.excludes_file(None, excluded),
1945            "an absolute pattern must match the absolute path when there is no relative form"
1946        );
1947        assert_eq!(
1948            matchers.matched_pattern_for_file(None, excluded),
1949            Some("/home/dev/.cursor/plans/**")
1950        );
1951        // A file inside a project root still has a relative form; the absolute
1952        // pattern must match it through the absolute path.
1953        assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
1954        assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
1955    }
1956
1957    #[test]
1958    fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
1959        let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
1960        assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
1961        assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
1962
1963        // An absolute pattern makes the absolute path worth matching, and the
1964        // floating `drafts` must still not match a directory above the root.
1965        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "/unrelated".to_string()]);
1966        let note = Path::new("/home/dev/drafts/site/note.md");
1967        assert_eq!(matchers.matched_pattern_for_file(None, note), None);
1968        assert_eq!(matchers.matched_pattern_for_file(Some("note.md"), note), None);
1969        assert_eq!(
1970            matchers.matched_pattern_for_file(Some("drafts/note.md"), Path::new("/home/dev/site/drafts/note.md")),
1971            Some("drafts/**")
1972        );
1973        assert_eq!(
1974            matchers.matched_pattern_for_file(None, Path::new("/unrelated/note.md")),
1975            Some("/unrelated/**")
1976        );
1977    }
1978
1979    #[test]
1980    fn exclude_matchers_match_a_wildcard_within_one_path_component() {
1981        let matchers = ExcludeMatchers::new(&["sub/a*b".to_string(), "notes/a?b.md".to_string()]);
1982        assert!(matchers.is_match("sub/aXb/y.md"));
1983        assert!(matchers.is_match("sub/ab/y.md"));
1984        assert!(!matchers.is_match("sub/a/keep/b/x.md"));
1985        assert!(matchers.is_match("notes/aXb.md"));
1986        assert!(!matchers.is_match("notes/a/b.md"));
1987
1988        let matchers = ExcludeMatchers::new(&["docs/**/draft.md".to_string()]);
1989        assert!(matchers.is_match("docs/draft.md"));
1990        assert!(matchers.is_match("docs/a/b/draft.md"));
1991    }
1992
1993    #[test]
1994    fn exclude_matchers_report_invalid_patterns() {
1995        let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
1996        assert_eq!(matchers.invalid.len(), 1);
1997        assert_eq!(matchers.invalid[0].0, "[");
1998        assert!(matchers.is_match("ok.md"));
1999    }
2000
2001    #[test]
2002    fn path_relative_to_strips_through_symlinked_base() {
2003        let temp = tempdir().unwrap();
2004        let base = temp.path().join("base");
2005        fs::create_dir_all(base.join("docs")).unwrap();
2006        fs::write(base.join("docs/a.md"), "# hi").unwrap();
2007
2008        assert_eq!(
2009            path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
2010            Some("docs/a.md")
2011        );
2012        assert_eq!(
2013            path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
2014            Some("a.md")
2015        );
2016        assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
2017        assert_eq!(
2018            path_relative_to(&base.join("docs/new/ghost.md"), &base).as_deref(),
2019            Some("docs/new/ghost.md"),
2020            "a file that does not exist yet relativizes the way it will once written"
2021        );
2022        assert_eq!(
2023            path_relative_to(&base.join("docs/a.md"), &base.join("missing")),
2024            None,
2025            "a base that does not exist contains nothing"
2026        );
2027    }
2028
2029    #[test]
2030    fn resolve_for_matching_spells_a_missing_file_the_way_it_will_canonicalize() {
2031        let temp = tempdir().unwrap();
2032        let root = canonicalize_for_matching(temp.path()).unwrap();
2033        fs::create_dir_all(root.join("docs")).unwrap();
2034        fs::write(root.join("docs").join("x.md"), "# x").unwrap();
2035
2036        // `temp.path()` is not canonical on macOS (`/var` -> `/private/var`), so
2037        // every row below also proves the existing ancestor was canonicalized.
2038        let cases = [
2039            ("docs/x.md", root.join("docs").join("x.md")),
2040            ("docs/ghost.md", root.join("docs").join("ghost.md")),
2041            (
2042                "docs/new/dir/ghost.md",
2043                root.join("docs").join("new").join("dir").join("ghost.md"),
2044            ),
2045            ("missing/../docs/./x.md", root.join("docs").join("x.md")),
2046            ("docs/missing/../../top.md", root.join("top.md")),
2047        ];
2048        for (relative, expected) in cases {
2049            assert_eq!(
2050                resolve_for_matching(&temp.path().join(relative)),
2051                expected,
2052                "{relative}"
2053            );
2054        }
2055    }
2056
2057    #[test]
2058    fn resolve_for_matching_takes_a_relative_path_from_the_working_directory() {
2059        let cwd = canonicalize_for_matching(&std::env::current_dir().unwrap()).unwrap();
2060        assert_eq!(
2061            resolve_for_matching(Path::new("no-such-dir-for-matching/ghost.md")),
2062            cwd.join("no-such-dir-for-matching").join("ghost.md")
2063        );
2064        assert_eq!(resolve_for_matching(Path::new("./Cargo.toml")), cwd.join("Cargo.toml"));
2065    }
2066
2067    #[cfg(unix)]
2068    #[test]
2069    fn resolve_for_matching_follows_a_symlink_reached_through_a_missing_directory() {
2070        let temp = tempdir().unwrap();
2071        let root = canonicalize_for_matching(temp.path()).unwrap();
2072        fs::create_dir_all(root.join("real")).unwrap();
2073        std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
2074
2075        assert_eq!(
2076            resolve_for_matching(&root.join("link").join("ghost.md")),
2077            root.join("real").join("ghost.md")
2078        );
2079        // Lexically `missing/../link/ghost.md` is `link/ghost.md`, and `link`
2080        // is only resolved by looking again once the `..` is gone.
2081        assert_eq!(
2082            resolve_for_matching(&root.join("missing").join("..").join("link").join("ghost.md")),
2083            root.join("real").join("ghost.md")
2084        );
2085    }
2086
2087    fn sorted(mut patterns: Vec<String>) -> Vec<String> {
2088        patterns.sort();
2089        patterns
2090    }
2091
2092    #[test]
2093    fn expand_braces_yields_every_alternative() {
2094        assert_eq!(
2095            sorted(expand_braces("/{var/folders,tmp}/**")),
2096            vec!["/tmp/**", "/var/folders/**"]
2097        );
2098        // Nesting expands too.
2099        assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
2100        // An empty alternative is dropped, as globset drops it.
2101        assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
2102        // Several groups multiply out.
2103        assert_eq!(
2104            sorted(expand_braces("/{a,b}/{c,d}.md")),
2105            vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
2106        );
2107    }
2108
2109    #[test]
2110    fn expand_braces_leaves_patterns_it_cannot_split() {
2111        // Nothing to split.
2112        assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
2113        // An unclosed brace is not an alternation.
2114        assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
2115        // A comma inside a character class is literal.
2116        assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
2117        // An alternation of nothing but empty alternatives is not a split.
2118        assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
2119        // Past the expansion cap the pattern is left alone: 2^7 = 128 > 64.
2120        let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
2121        assert_eq!(expand_braces(wide), vec![wide]);
2122    }
2123
2124    #[test]
2125    fn has_absolute_spelling_sees_past_a_leading_alternation() {
2126        assert!(has_absolute_spelling("/var/folders/**"));
2127        assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
2128        assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
2129        assert!(!has_absolute_spelling("docs/**"));
2130        assert!(!has_absolute_spelling("{docs,notes}/**"));
2131    }
2132
2133    #[test]
2134    fn expand_braces_agrees_with_globset() {
2135        // The expansion only discovers prefixes to canonicalize, but a
2136        // disagreement with globset would mean it is describing a different
2137        // pattern than the one that decides matches.
2138        let cases = [
2139            ("/{var/folders,tmp}/**", "/tmp/note.md"),
2140            ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
2141            ("/{var/folders,tmp}/**", "/opt/note.md"),
2142            ("/{a,b}/{c,d}.md", "/b/d.md"),
2143            ("/{a,b}/{c,d}.md", "/b/e.md"),
2144            ("x{,y}", "x"),
2145            ("x{,y}", "xy"),
2146            ("/var/[a,b]/**", "/var/a/n.md"),
2147            ("/var/[a,b]/**", "/var/,/n.md"),
2148            ("/var/folders/**", "/var/folders/n.md"),
2149        ];
2150        for (pattern, path) in cases {
2151            let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
2152            let expanded = expand_braces(pattern)
2153                .iter()
2154                .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
2155            assert_eq!(direct, expanded, "pattern {pattern} against {path}");
2156        }
2157    }
2158
2159    #[test]
2160    fn literal_path_prefix_stops_at_the_first_wildcard() {
2161        assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
2162        assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
2163        assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
2164        assert_eq!(literal_path_prefix("/var/"), Some("/var"));
2165        assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
2166        // Nothing literal to resolve.
2167        assert_eq!(literal_path_prefix("/**"), None);
2168        assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
2169        assert_eq!(literal_path_prefix("**/note.md"), None);
2170        // The result is always a prefix slice, so a remainder re-attaches by
2171        // byte offset.
2172        let pattern = "/var/folders/**";
2173        let prefix = literal_path_prefix(pattern).unwrap();
2174        assert_eq!(&pattern[prefix.len()..], "/**");
2175    }
2176
2177    /// `(real directory, symlink to it)` under a fresh temp dir. The symlink is
2178    /// how a pattern spells the location; the real directory is where a file
2179    /// canonicalizes to.
2180    #[cfg(unix)]
2181    fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
2182        let real = temp.join("real");
2183        fs::create_dir_all(real.join("notes")).unwrap();
2184        fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
2185        let link = temp.join("link");
2186        std::os::unix::fs::symlink(&real, &link).unwrap();
2187        (canonicalize_for_matching(&real).unwrap(), link)
2188    }
2189
2190    #[cfg(unix)]
2191    #[test]
2192    fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
2193        let temp = tempdir().unwrap();
2194        let (real, link) = symlinked_dir(temp.path());
2195        let pattern = format!("{}/notes/**", link.to_string_lossy());
2196
2197        let aliases = PathAliases::new([pattern.as_str()]);
2198        assert!(!aliases.is_empty());
2199        assert_eq!(
2200            aliases.spellings_of(&real.join("notes/scratch.md")),
2201            vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
2202        );
2203        // The alias is what makes the pattern match the canonical path.
2204        let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2205        assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
2206        assert!(
2207            aliases
2208                .spellings_of(&real.join("notes/scratch.md"))
2209                .iter()
2210                .any(|alias| matcher.is_match(alias))
2211        );
2212        // A path outside the recorded prefix has no alias.
2213        assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
2214    }
2215
2216    #[cfg(unix)]
2217    #[test]
2218    fn path_aliases_reach_through_a_brace_alternation() {
2219        // The prefix only exists once the alternation is expanded.
2220        let temp = tempdir().unwrap();
2221        let (real, link) = symlinked_dir(temp.path());
2222        let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
2223
2224        let aliases = PathAliases::new([pattern.as_str()]);
2225        let matcher = Glob::new(&pattern).unwrap().compile_matcher();
2226        let note = real.join("notes/scratch.md");
2227        assert!(!matcher.is_match(&note), "negative control");
2228        assert!(aliases.spellings_of(&note).iter().any(|alias| matcher.is_match(alias)));
2229    }
2230
2231    #[test]
2232    fn path_aliases_are_empty_without_a_symlinked_prefix() {
2233        let temp = tempdir().unwrap();
2234        let canonical = canonicalize_for_matching(temp.path()).unwrap();
2235        let canonical = canonical.to_string_lossy().replace('\\', "/");
2236        // Relative patterns, absolute patterns that already name their real
2237        // location, and prefixes that do not exist all record nothing.
2238        for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
2239            assert!(
2240                PathAliases::new([pattern]).is_empty(),
2241                "pattern {pattern} should record no alias"
2242            );
2243        }
2244    }
2245
2246    #[cfg(unix)]
2247    #[test]
2248    fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
2249        let temp = tempdir().unwrap();
2250        let (real, link) = symlinked_dir(temp.path());
2251        let note = real.join("notes/scratch.md");
2252
2253        let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
2254        assert!(matchers.excludes_file(None, &note));
2255
2256        // Negative controls: a sibling the pattern does not name, and a pattern
2257        // pointing somewhere else entirely.
2258        fs::write(real.join("other.md"), "# Other\n").unwrap();
2259        assert!(!matchers.excludes_file(None, &real.join("other.md")));
2260        let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
2261        assert!(!elsewhere.excludes_file(None, &note));
2262    }
2263
2264    #[cfg(unix)]
2265    #[test]
2266    fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
2267        let temp = tempdir().unwrap();
2268        let (real, link) = symlinked_dir(temp.path());
2269        let pattern = format!("{}/notes/*.md", link.to_string_lossy());
2270
2271        assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
2272        // A pattern naming a different location through the same symlink is
2273        // still outside a narrower base, and stays absolute.
2274        let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
2275        assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
2276    }
2277
2278    #[test]
2279    fn classify_file_reads_the_whole_name() {
2280        let cases = [
2281            ("README.md", FileKind::Markdown),
2282            ("README.MD", FileKind::Markdown),
2283            ("notes.txt", FileKind::Markdown),
2284            ("LICENSE", FileKind::Markdown),
2285            ("rules.mdc", FileKind::Markdown),
2286            ("page.html", FileKind::Markdown),
2287            ("lib.rs", FileKind::RustDocComments),
2288            // Only the spelling rustc and cargo use selects doc-comment linting.
2289            ("LIB.RS", FileKind::SourceCode),
2290            ("main.swift", FileKind::SourceCode),
2291            ("Main.KT", FileKind::SourceCode),
2292            ("config.yaml", FileKind::SourceCode),
2293            ("template.md.jinja", FileKind::Markdown),
2294            ("README.md.j2.in", FileKind::Markdown),
2295            ("README.md.swift", FileKind::Markdown),
2296            ("script.litcoffee", FileKind::Markdown),
2297            ("readme.swift.txt", FileKind::Markdown),
2298            // A leading dot names a hidden file: `.md` is not an extension here.
2299            (".md.swift", FileKind::SourceCode),
2300        ];
2301        for (name, expected) in cases {
2302            assert_eq!(classify_file(Path::new(name)), expected, "{name}");
2303            assert_eq!(
2304                classify_file(&Path::new("dir.md").join(name)),
2305                expected,
2306                "dir.md/{name}"
2307            );
2308        }
2309    }
2310
2311    #[test]
2312    fn source_code_extensions_are_sorted_and_exclude_document_formats() {
2313        let extensions = crate::linguist_data::SOURCE_CODE_EXTENSIONS;
2314        assert!(extensions.windows(2).all(|pair| pair[0] < pair[1]));
2315        for document in ["md", "txt", "asc", "rst", "html", "mdx", "ipynb", "tex"] {
2316            assert!(!crate::linguist_data::is_source_code_extension(document), "{document}");
2317        }
2318        for source in ["rs", "swift", "kt", "dart", "java", "py", "json", "SWIFT"] {
2319            assert!(crate::linguist_data::is_source_code_extension(source), "{source}");
2320        }
2321    }
2322}