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_pattern = 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 = path_glob(&path_pattern, true).ok()?.compile_matcher();
103                Some(ExplicitInclude {
104                    file_name_glob: file_name_glob.to_string(),
105                    path_matcher,
106                })
107            })
108            .collect();
109        Self { matchers }
110    }
111
112    pub fn is_empty(&self) -> bool {
113        self.matchers.is_empty()
114    }
115
116    /// The file-name globs, e.g. for registering on a walker type filter.
117    pub fn file_name_globs(&self) -> impl Iterator<Item = &str> {
118        self.matchers.iter().map(|m| m.file_name_glob.as_str())
119    }
120
121    /// Whether the root-relative `path` matches any explicit include
122    /// pattern in full.
123    pub fn matches_relative_path(&self, path: &str) -> bool {
124        self.matchers.iter().any(|m| m.path_matcher.is_match(path))
125    }
126}
127
128/// Source kinds an adapter can interpret after a path passes include matching.
129///
130/// The CLI can extract Markdown from Rust doc comments, while the language
131/// server indexes complete Markdown documents and must not parse a Rust source
132/// file as if the whole file were Markdown. A CLI `--include` is stronger still:
133/// it explicitly asks rumdl to process whatever the pattern selects.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum LintableFileMode {
136    Markdown,
137    MarkdownAndRust,
138    Any,
139}
140
141/// The shared final gate for files yielded by CLI and LSP discovery walks.
142///
143/// Include overrides decide *where* to look. This selector decides whether a
144/// matching file is a source the adapter can interpret. Explicit config
145/// includes can name template-like Markdown files beyond the standard
146/// extensions; Rust remains capability-gated even when explicitly named.
147pub struct LintablePathSelector {
148    base: Option<PathBuf>,
149    explicit: ExplicitIncludeMatchers,
150    mode: LintableFileMode,
151    /// Under [`LintableFileMode::Any`], the include patterns as written. A file
152    /// without a Markdown extension is kept only when one of them selects the
153    /// file itself, so a pattern naming a directory selects its Markdown.
154    named: Option<ignore::overrides::Override>,
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            named: None,
164        }
165    }
166
167    /// Restrict [`LintableFileMode::Any`] to Markdown plus the files `named`
168    /// selects directly. `named` is built from the include patterns as written,
169    /// without the directory expansion, on the walker's own base.
170    #[must_use]
171    pub fn with_named_files(mut self, named: ignore::overrides::Override) -> Self {
172        self.named = Some(named);
173        self
174    }
175
176    /// Whether an included path is a source this adapter can interpret.
177    pub fn keeps(&self, path: &Path) -> bool {
178        if self.mode == LintableFileMode::Any {
179            return self
180                .named
181                .as_ref()
182                .is_none_or(|named| has_markdown_extension(path) || named.matched(path, false).is_whitelist());
183        }
184        if has_markdown_extension(path) {
185            return true;
186        }
187
188        // Rust doc-comment extraction currently dispatches on lowercase `.rs`.
189        // Keep this capability gate identical to the downstream processor.
190        let is_rust = path.extension().and_then(OsStr::to_str) == Some("rs");
191        if is_rust {
192            return self.mode == LintableFileMode::MarkdownAndRust;
193        }
194
195        match self.base.as_deref().and_then(|base| path_relative_to(path, base)) {
196            Some(relative) => self.explicit.matches_relative_path(&relative),
197            // Outside the pattern base only unanchored patterns can still apply;
198            // matching the full path covers those.
199            None => self.explicit.matches_relative_path(&path.to_string_lossy()),
200        }
201    }
202
203    /// Whether the walk is left unfiltered by file type.
204    pub fn selects_every_file_type(&self) -> bool {
205        self.mode == LintableFileMode::Any
206    }
207
208    /// Apply the corresponding coarse file-type filter to a discovery walk.
209    /// [`Self::keeps`] remains the precise final gate because type filters only
210    /// see file names, not root-relative include paths.
211    pub fn configure_types(&self, builder: &mut ignore::WalkBuilder) -> Result<(), ignore::Error> {
212        if self.mode == LintableFileMode::Any {
213            return Ok(());
214        }
215
216        let mut types = ignore::types::TypesBuilder::new();
217        // The final selector admits exactly MARKDOWN_EXTENSIONS, Rust doc
218        // comments and explicit includes. Registering unrelated built-in types
219        // cannot add a lintable file, and builds their definitions for no use.
220        for extension in MARKDOWN_EXTENSIONS {
221            types.add("markdown", &any_case_extension_glob(extension))?;
222        }
223        types.select("markdown");
224        if self.mode == LintableFileMode::MarkdownAndRust {
225            types.add("rustdoc", "*.rs")?;
226            types.select("rustdoc");
227        }
228        for glob in self.explicit.file_name_globs() {
229            types.add("configinclude", type_filter_glob(glob))?;
230        }
231        if !self.explicit.is_empty() {
232            types.select("configinclude");
233        }
234        builder.types(types.build()?);
235        Ok(())
236    }
237}
238
239/// The file-name glob to register on the walker's type filter for an explicit
240/// include's `glob`.
241///
242/// The type filter compiles with `globset`'s platform default, which reads `\`
243/// as a path separator on Windows, so an escaped name would never be kept. A
244/// glob holding a backslash is widened to every name instead: the filter only
245/// narrows the walk, and [`LintablePathSelector::keeps`] applies the precise pattern.
246fn type_filter_glob(glob: &str) -> &str {
247    if glob.contains('\\') { "*" } else { glob }
248}
249
250/// File extensions rumdl treats as markdown, lowercase.
251pub const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
252
253/// Whether `ext` is a markdown extension. Matches case-insensitively so
254/// conventional variants like `Rmd` (and shouting-case `MD`) qualify.
255#[inline]
256pub fn is_markdown_extension(ext: &OsStr) -> bool {
257    ext.to_str()
258        .is_some_and(|s| MARKDOWN_EXTENSIONS.iter().any(|known| s.eq_ignore_ascii_case(known)))
259}
260
261/// Whether `path` has a markdown extension.
262#[inline]
263pub fn has_markdown_extension(path: &Path) -> bool {
264    path.extension().is_some_and(is_markdown_extension)
265}
266
267/// How rumdl reads a file, decided by its name.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum FileKind {
270    /// Linted as Markdown, the whole file.
271    Markdown,
272    /// Rust source, linted through its doc comments only.
273    RustDocComments,
274    /// Source code rumdl cannot read: linting it as Markdown reports on the
275    /// program, and fixing it rewrites the program.
276    SourceCode,
277}
278
279/// Classify `path` by its file name.
280///
281/// In order: a Markdown final extension is Markdown; a lowercase `.rs` is Rust
282/// doc comments; a Markdown extension earlier in the name (`README.md.jinja`, a
283/// template of a Markdown file) or Literate CoffeeScript (`.litcoffee`, which is
284/// Markdown with code blocks) is Markdown; a final extension of a programming
285/// or data language is source code; anything else (`.txt`, no extension, an
286/// unknown one) stays Markdown, as rumdl has always treated a file it is
287/// pointed at.
288pub fn classify_file(path: &Path) -> FileKind {
289    if has_markdown_extension(path) {
290        return FileKind::Markdown;
291    }
292    if crate::doc_comment_lint::is_rust_source(path) {
293        return FileKind::RustDocComments;
294    }
295    let Some(extension) = path.extension().and_then(OsStr::to_str) else {
296        return FileKind::Markdown;
297    };
298    if extension.eq_ignore_ascii_case("litcoffee") || has_inner_markdown_extension(path) {
299        return FileKind::Markdown;
300    }
301    if crate::linguist_data::is_source_code_extension(extension) {
302        FileKind::SourceCode
303    } else {
304        FileKind::Markdown
305    }
306}
307
308/// Whether an extension before the final one is a Markdown extension, as `md`
309/// in `README.md.jinja`. Stems follow `Path` semantics, so a leading dot
310/// (`.md.swift`) names a hidden file, not an extension.
311fn has_inner_markdown_extension(path: &Path) -> bool {
312    let mut stem = path.file_stem();
313    while let Some(current) = stem {
314        let current = Path::new(current);
315        if has_markdown_extension(current) {
316            return true;
317        }
318        stem = current.extension().and(current.file_stem());
319    }
320    false
321}
322
323/// A glob selecting `ext` in any letter case, as `*.[mM][dD]` for `md`.
324///
325/// Walk type globs match case-sensitively, so a plain `*.md` hides `README.MD`
326/// from a directory scan even though [`is_markdown_extension`] calls it
327/// markdown and naming the file on the command line lints it. Deriving the glob
328/// from the same extension keeps the walk's filter from being narrower than the
329/// definition it stands in for.
330pub fn any_case_extension_glob(ext: &str) -> String {
331    let mut glob = String::with_capacity(2 + ext.len() * 4);
332    glob.push_str("*.");
333    for ch in ext.chars() {
334        if ch.is_ascii_alphabetic() {
335            glob.push('[');
336            glob.push(ch.to_ascii_lowercase());
337            glob.push(ch.to_ascii_uppercase());
338            glob.push(']');
339        } else {
340            glob.push(ch);
341        }
342    }
343    glob
344}
345
346/// The linter-specific ignore file, read on every directory walk.
347pub const MARKDOWNLINTIGNORE: &str = ".markdownlintignore";
348
349/// Ignore-handling options applied to a markdown discovery walk.
350#[derive(Debug, Clone)]
351pub struct MarkdownWalkOptions {
352    /// Honor `.gitignore`, `.ignore`, global gitignore, `.git/info/exclude`,
353    /// and parent ignore files. Driven by `global.respect_gitignore`.
354    pub respect_gitignore: bool,
355    /// Skip `.git`, `node_modules`, and `target` directories outright, even
356    /// when gitignore handling is disabled or would not cover them.
357    pub skip_vendor_dirs: bool,
358}
359
360impl Default for MarkdownWalkOptions {
361    fn default() -> Self {
362        Self {
363            respect_gitignore: true,
364            skip_vendor_dirs: false,
365        }
366    }
367}
368
369/// Whether a walk over `roots` stops reading gitignores at the repository root.
370///
371/// Git reads no `.gitignore` above the repository root, so a walk that does hides
372/// files `git check-ignore` reports as visible. Worse, such a file can hide a
373/// whole directory, and a pruned directory is never descended into, so no include
374/// pattern gets the chance to name anything inside it.
375///
376/// Outside a repository there is no root to stop at, and ignore files are all a
377/// walk has to go on, so there they keep applying upward. One walk has one
378/// setting for all of its roots, so the boundary is only applied when every root
379/// has a repository to bound it.
380pub fn stops_at_repository_root<P: AsRef<Path>>(roots: &[P]) -> bool {
381    !roots.is_empty() && roots.iter().all(|root| in_repository(root.as_ref()))
382}
383
384/// Whether `path` sits inside a git or jujutsu repository.
385///
386/// A `.git` entry is a directory in an ordinary clone and a file in a worktree or
387/// submodule, so existence alone is the marker. This recognizes a repository the
388/// same way the walker does, which is what puts the boundary in the same place.
389fn in_repository(path: &Path) -> bool {
390    let Ok(absolute) = std::fs::canonicalize(path) else {
391        return false;
392    };
393    absolute
394        .ancestors()
395        .any(|dir| dir.join(".git").exists() || dir.join(".jj").exists())
396}
397
398/// Apply the shared ignore-handling configuration to a walker over `roots`.
399///
400/// Hidden entries are always walked (a hidden `docs/.pages.md` lints the
401/// same as a visible one); generated content is kept out by gitignore
402/// semantics and, for callers that opt in, the vendor-directory skip.
403/// `.markdownlintignore` is honored for markdownlint compatibility, whatever
404/// `respect_gitignore` says: markdownlint-cli applies it independently of any
405/// gitignore handling, and it is a list written for the linter alone, so
406/// turning off git's ignore files is not a request to lint what it names.
407///
408/// The roots decide where gitignore reading stops, so a caller passes the same
409/// ones it walks.
410pub fn apply_markdown_walk_options<P: AsRef<Path>>(
411    builder: &mut ignore::WalkBuilder,
412    roots: &[P],
413    options: &MarkdownWalkOptions,
414) {
415    apply_walk_options_with_markdownlintignore(builder, roots, options, true);
416}
417
418/// [`apply_markdown_walk_options`], with `.markdownlintignore` switchable.
419///
420/// Only an explanation of an empty run turns it off, to find out which ignore
421/// source hid a file. A walk deciding what to lint always reads it.
422pub fn apply_walk_options_with_markdownlintignore<P: AsRef<Path>>(
423    builder: &mut ignore::WalkBuilder,
424    roots: &[P],
425    options: &MarkdownWalkOptions,
426    markdownlintignore: bool,
427) {
428    let gitignore = options.respect_gitignore;
429    builder
430        .ignore(gitignore)
431        .git_ignore(gitignore)
432        .git_global(gitignore)
433        .git_exclude(gitignore)
434        // Parent directories are read for every walk: each source toggle above
435        // still decides what they contribute, so with the gitignore family off
436        // a parent `.markdownlintignore` is the only file read from them.
437        .parents(true)
438        .hidden(false)
439        // This setting does double duty in the walker: it gates gitignore
440        // handling on a repository being present, and it is what stops the walk
441        // reading gitignores above the repository root. Inside a repository both
442        // are wanted. Outside one, requiring a repository would drop `.gitignore`
443        // handling entirely, and there is no root to stop at in any case.
444        .require_git(stops_at_repository_root(roots));
445    if markdownlintignore {
446        builder.add_custom_ignore_filename(MARKDOWNLINTIGNORE);
447    }
448
449    if options.skip_vendor_dirs {
450        let roots: Vec<PathBuf> = roots.iter().map(|root| root.as_ref().to_path_buf()).collect();
451        builder.filter_entry(move |entry| {
452            if roots.iter().any(|root| root == entry.path()) {
453                return true;
454            }
455            let name = entry.file_name().to_str().unwrap_or("");
456            name != ".git" && name != "node_modules" && name != "target"
457        });
458    }
459}
460
461/// Directories that hold a version control system's internal state.
462const VCS_DIRECTORIES: &[&str] = &[".git", ".hg", ".svn", ".jj"];
463
464/// Keep a walk out of the version control directories beneath its roots.
465///
466/// A walk restricted to Markdown by the file type filter reaches only the
467/// Markdown a repository happens to hold there. A walk that selects every file
468/// type (`--include '*'`) would lint the repository's own config and hook files,
469/// and `rumdl fmt` would rewrite them. A root the caller names is still entered:
470/// the walker does not apply its entry filter to a root. Replaces any other
471/// entry filter on `builder`.
472pub fn skip_version_control_directories(builder: &mut ignore::WalkBuilder) {
473    builder.filter_entry(|entry| {
474        let is_dir = entry.file_type().is_some_and(|file_type| file_type.is_dir());
475        !(is_dir && VCS_DIRECTORIES.contains(&entry.file_name().to_str().unwrap_or("")))
476    });
477}
478
479/// Build a walker over `root` configured with the shared options.
480pub fn markdown_walk_builder(root: &Path, options: &MarkdownWalkOptions) -> ignore::WalkBuilder {
481    let mut builder = ignore::WalkBuilder::new(root);
482    apply_markdown_walk_options(&mut builder, &[root], options);
483    builder
484}
485
486/// A complete, configured Markdown workspace scan.
487///
488/// This owns the selection policy shared by full scans and incremental file
489/// events: standard Markdown extensions, explicit nonstandard file includes,
490/// include filtering, excludes, ignore files, and optional vendor-directory
491/// pruning. Adapters choose the options; they do not reconstruct the policy.
492pub struct MarkdownWorkspaceScan<'a> {
493    options: &'a MarkdownWalkOptions,
494    includes: &'a [String],
495    excludes: &'a ExcludeMatchers,
496}
497
498impl<'a> MarkdownWorkspaceScan<'a> {
499    pub fn new(options: &'a MarkdownWalkOptions, includes: &'a [String], excludes: &'a ExcludeMatchers) -> Self {
500        Self {
501            options,
502            includes,
503            excludes,
504        }
505    }
506
507    /// Collect all selected files under `roots`.
508    pub fn collect(&self, roots: &[PathBuf]) -> Vec<PathBuf> {
509        let mut files = Vec::new();
510        for root in roots {
511            let selection = RootSelection::new(root, self.includes);
512            let mut builder = markdown_walk_builder(root, self.options);
513            selection.configure_walk(&mut builder);
514
515            for result in builder.build() {
516                match result {
517                    Ok(entry)
518                        if entry.file_type().is_some_and(|file_type| file_type.is_file())
519                            && selection.is_lintable(entry.path())
520                            && !self.excluded(root, entry.path()) =>
521                    {
522                        files.push(entry.into_path());
523                    }
524                    Ok(_) => {}
525                    Err(error) => log::warn!("Error scanning {}: {error}", root.display()),
526                }
527            }
528        }
529        files.sort();
530        files.dedup();
531        files
532    }
533
534    /// Whether an incremental file event would be absent from a full scan.
535    pub fn path_is_ignored(&self, roots: &[PathBuf], path: &Path) -> bool {
536        let Some(root) = roots
537            .iter()
538            .filter(|root| path.starts_with(root))
539            .max_by_key(|root| root.components().count())
540        else {
541            return false;
542        };
543
544        let selection = RootSelection::new(root, self.includes);
545        if !selection.selects(path) || self.excluded(root, path) {
546            return true;
547        }
548
549        if self.options.skip_vendor_dirs
550            && let Ok(relative) = path.strip_prefix(root)
551            && relative.components().any(|component| {
552                matches!(component, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target")
553            })
554        {
555            return true;
556        }
557
558        let target = path.to_path_buf();
559        let mut builder = markdown_walk_builder(root, self.options);
560        selection.configure_walk(&mut builder);
561        // `filter_entry` replaces the vendor filter, which was checked above.
562        builder.filter_entry(move |entry| target.starts_with(entry.path()));
563        !builder.build().flatten().any(|entry| entry.path() == path)
564    }
565
566    fn excluded(&self, root: &Path, path: &Path) -> bool {
567        self.excludes
568            .excludes_file(path_relative_to(path, root).as_deref(), path)
569    }
570}
571
572struct RootSelection {
573    lintable: LintablePathSelector,
574    overrides: Option<ignore::overrides::Override>,
575}
576
577impl RootSelection {
578    fn new(root: &Path, includes: &[String]) -> Self {
579        let normalized: Vec<String> = includes
580            .iter()
581            .map(|pattern| normalize_pattern_for_base(pattern, Some(root)))
582            .collect();
583        let overrides = if normalized.is_empty() {
584            None
585        } else {
586            let mut builder = ignore::overrides::OverrideBuilder::new(root);
587            for pattern in &normalized {
588                for rule in expand_include_directory(pattern) {
589                    if let Err(error) = builder.add(&rule) {
590                        log::warn!("Invalid include pattern '{pattern}': {error}");
591                        break;
592                    }
593                }
594            }
595            builder.build().ok()
596        };
597        Self {
598            lintable: LintablePathSelector::new(Some(root), &normalized, LintableFileMode::Markdown),
599            overrides,
600        }
601    }
602
603    fn configure_walk(&self, builder: &mut ignore::WalkBuilder) {
604        if let Err(error) = self.lintable.configure_types(builder) {
605            log::warn!("Failed to configure workspace source types: {error}");
606        }
607        if let Some(overrides) = &self.overrides {
608            builder.overrides(overrides.clone());
609        }
610    }
611
612    fn selects(&self, path: &Path) -> bool {
613        self.overrides
614            .as_ref()
615            .is_none_or(|overrides| overrides.matched(path, false).is_whitelist())
616            && self.is_lintable(path)
617    }
618
619    fn is_lintable(&self, path: &Path) -> bool {
620        self.lintable.keeps(path)
621    }
622}
623
624/// Drop Windows' verbatim `\\?\` prefix from a canonicalized path string.
625///
626/// `std::fs::canonicalize` returns the verbatim form (`\\?\C:\Users\dev`) on
627/// Windows. That form is useless for pattern matching: it does not compare
628/// equal to the ordinary paths rumdl works with, and normalizing its
629/// separators for globbing mangles it into `//?/C:/Users/dev`, which matches
630/// nothing. Only a drive path (`\\?\C:\...`) and a UNC share
631/// (`\\?\UNC\server\share` -> `\\server\share`) are unwrapped; any other
632/// verbatim path names a device namespace that has no ordinary equivalent, so
633/// it is left alone.
634///
635/// The prefix is recognized in whichever separator the path is written with:
636/// `\\?\` as `canonicalize` returns it, or `//?/` once the separators have
637/// been normalized. Displayed paths are stripped by the CLI's display layer;
638/// output formatters strip again because a path can reach them unchanged.
639///
640/// Pure string logic, compiled on every platform so it stays under test where
641/// Windows is not available. On other platforms no path ever carries this
642/// prefix.
643pub fn strip_verbatim_prefix(path: &str) -> Cow<'_, str> {
644    let Some(sep) = path.chars().next().filter(|c| matches!(c, '\\' | '/')) else {
645        return Cow::Borrowed(path);
646    };
647    let Some(rest) = path.strip_prefix(&format!("{sep}{sep}?{sep}")) else {
648        return Cow::Borrowed(path);
649    };
650    // `\\?\UNC\server\share` -> `\\server\share`. The remainder already starts
651    // with one separator, so restoring the UNC form needs one more prepended.
652    if let Some(share) = rest.strip_prefix("UNC")
653        && share.starts_with(sep)
654    {
655        return Cow::Owned(format!("{sep}{share}"));
656    }
657    let is_drive_path = rest.as_bytes().get(1) == Some(&b':');
658    if is_drive_path {
659        Cow::Borrowed(rest)
660    } else {
661        Cow::Borrowed(path)
662    }
663}
664
665/// Canonicalize `path` for pattern matching, or `None` when it cannot be
666/// resolved (a missing or unreadable file).
667///
668/// Canonical form is what patterns are matched against, so a symlinked
669/// location (`/home/dev` -> `/mnt/dev`, or a macOS `/var` -> `/private/var`)
670/// still matches. Windows' verbatim prefix is removed (see
671/// [`strip_verbatim_prefix`]).
672pub fn canonicalize_for_matching(path: &Path) -> Option<PathBuf> {
673    let canonical = path.canonicalize().ok()?;
674    if !cfg!(windows) {
675        return Some(canonical);
676    }
677    let as_str = canonical.to_string_lossy();
678    Some(PathBuf::from(strip_verbatim_prefix(&as_str).as_ref()))
679}
680
681/// Resolve `path` to the absolute form patterns are matched against, whether or
682/// not anything exists there.
683///
684/// A path that exists resolves exactly as [`canonicalize_for_matching`] resolves
685/// it. One that does not (an unsaved editor buffer, a file not written yet) is
686/// resolved through its deepest existing ancestor: that ancestor is
687/// canonicalized and the missing remainder appended, with its `.` and `..`
688/// resolved lexically, so the file is spelled the way it will canonicalize once
689/// it exists. A relative path is taken relative to the working directory, which
690/// is how the filesystem reads it.
691pub fn resolve_for_matching(path: &Path) -> PathBuf {
692    if let Some(canonical) = canonicalize_for_matching(path) {
693        return canonical;
694    }
695    let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
696    let resolved = resolve_through_existing_ancestor(&absolute);
697    // A `..` in the missing remainder can climb back into directories that
698    // exist, and one of those may be a symlink. The lexical result holds no
699    // `..` any more, so resolving it once more settles it.
700    if absolute.components().any(|c| c == std::path::Component::ParentDir) {
701        resolve_through_existing_ancestor(&resolved)
702    } else {
703        resolved
704    }
705}
706
707/// Canonicalize the deepest ancestor of `absolute` that exists and append the
708/// rest of the path to it lexically (see [`resolve_for_matching`]).
709fn resolve_through_existing_ancestor(absolute: &Path) -> PathBuf {
710    for ancestor in absolute.ancestors() {
711        if let Some(canonical) = canonicalize_for_matching(ancestor) {
712            let remainder = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
713            if remainder.as_os_str().is_empty() {
714                return canonical;
715            }
716            return crate::workspace_index::normalize_relative_path(&canonical.join(remainder));
717        }
718    }
719    crate::workspace_index::normalize_relative_path(absolute)
720}
721
722/// The user's home directory, or `None` when it cannot be resolved.
723///
724/// Canonicalized for matching (see [`canonicalize_for_matching`]), falling
725/// back to the path as reported when it cannot be canonicalized.
726///
727/// Wasm and WASI builds have no home directory to resolve, so patterns keep
728/// their `~` there (see [`expand_home_prefix`]).
729fn home_dir() -> Option<PathBuf> {
730    crate::config::user_paths::process_home_path().map(|home| canonicalize_for_matching(&home).unwrap_or(home))
731}
732
733/// Expand a leading `~` in a path pattern to the user's home directory, so a
734/// user-level config (`~/.config/rumdl/rumdl.toml`) can name a home path
735/// without hardcoding a username.
736///
737/// Only a bare `~` and a `~/` prefix expand. `~` is a legal filename character
738/// everywhere else (editor backups like `notes.md~`, a literal `docs/~drafts`),
739/// so it is left alone there. `~user` is not expanded either: resolving another
740/// user's home needs the password database, and treating it as the current
741/// user's home would silently match the wrong directory.
742///
743/// The expansion is a glob pattern, so separators are normalized to `/` on
744/// Windows: `\` is globset's escape character, and matched paths are normalized
745/// the same way (see [`path_relative_to`]).
746pub fn expand_home_prefix(pattern: &str) -> Cow<'_, str> {
747    // Resolve the home directory only for a pattern that references it: every
748    // other pattern would otherwise pay for the lookup and its canonicalization.
749    if !has_home_prefix(pattern) {
750        return Cow::Borrowed(pattern);
751    }
752    expand_home_prefix_impl(pattern, home_dir().as_deref())
753}
754
755/// Whether `pattern` starts with a home reference (`~` or `~/`).
756fn has_home_prefix(pattern: &str) -> bool {
757    pattern == "~" || pattern.starts_with("~/")
758}
759
760fn expand_home_prefix_impl<'a>(pattern: &'a str, home: Option<&Path>) -> Cow<'a, str> {
761    let Some(suffix) = (if pattern == "~" {
762        Some("")
763    } else {
764        pattern.strip_prefix("~/")
765    }) else {
766        return Cow::Borrowed(pattern);
767    };
768    let Some(home) = home else {
769        return Cow::Borrowed(pattern);
770    };
771
772    let home = normalize_pattern_separators(home.to_string_lossy());
773    let home = home.trim_end_matches('/');
774    if suffix.is_empty() {
775        Cow::Owned(home.to_string())
776    } else {
777        Cow::Owned(format!("{home}/{suffix}"))
778    }
779}
780
781/// Normalize path separators to `/` for glob matching. On Windows `\` is
782/// globset's escape character, so a native path must be rewritten before it can
783/// be used as - or matched against - a pattern. No-op on Unix, where `\` is a
784/// legal filename character.
785fn normalize_pattern_separators(path: Cow<'_, str>) -> Cow<'_, str> {
786    if cfg!(windows) && path.contains('\\') {
787        Cow::Owned(path.replace('\\', "/"))
788    } else {
789        path
790    }
791}
792
793/// `pattern` as written, read for the platform and with, when `home`, a leading
794/// `~` expanded. On Windows a `\` is a path separator and becomes `/`, so no
795/// escape survives there; elsewhere it stays the escape. The text still holds
796/// every `.` and `..` component.
797fn spell_pattern(pattern: &str, home: bool, windows: bool) -> Cow<'_, str> {
798    let separated = if windows && pattern.contains('\\') {
799        Cow::Owned(pattern.replace('\\', "/"))
800    } else {
801        Cow::Borrowed(pattern)
802    };
803    // Canonical Windows paths use a device prefix whose `?` is not a glob.
804    let separated = if windows {
805        match strip_verbatim_prefix(&separated) {
806            Cow::Owned(path) => Cow::Owned(path),
807            Cow::Borrowed(path) if path.len() != separated.len() => Cow::Owned(path.to_string()),
808            Cow::Borrowed(_) => separated,
809        }
810    } else {
811        separated
812    };
813    if !home {
814        return separated;
815    }
816    match expand_home_prefix(&separated) {
817        Cow::Owned(expanded) => Cow::Owned(expanded),
818        Cow::Borrowed(_) => separated,
819    }
820}
821
822/// A pattern as every glob consumer compiles it: separators read for the
823/// platform, `~` expanded, and `.`/`..` components resolved (see
824/// `normalize_dot_segments`).
825pub fn prepare_pattern(pattern: &str) -> Cow<'_, str> {
826    prepare_pattern_for(pattern, cfg!(windows))
827}
828
829/// [`prepare_pattern`] for an explicit platform, so both readings of `\` are
830/// testable on any host.
831pub fn prepare_pattern_for(pattern: &str, windows: bool) -> Cow<'_, str> {
832    let spelled = spell_pattern(pattern, true, windows);
833    match normalize_dot_segments(&spelled, windows) {
834        Cow::Owned(normalized) => Cow::Owned(normalized),
835        Cow::Borrowed(_) => spelled,
836    }
837}
838
839/// Whether a pattern component names exactly itself: no glob metacharacter and
840/// no escape.
841fn is_plain_component(component: &str) -> bool {
842    !component.contains(GLOB_METACHARS) && !component.contains('\\')
843}
844
845/// The components of `pattern` between its top-level `/`, or `None` when its
846/// braces or character class are unbalanced.
847///
848/// A `/` inside a `[...]` class, inside `{...}` braces or after a `\` is part of
849/// its component. A component holding a brace is opaque to the callers.
850fn split_components(pattern: &str) -> Option<Vec<&str>> {
851    let bytes = pattern.as_bytes();
852    let mut parts = Vec::new();
853    let mut start = 0;
854    let mut depth = 0usize;
855    let mut in_class = false;
856    // The first `]` of a class (after an optional `!` or `^`) is a member.
857    let mut class_start = 0;
858    let mut index = 0;
859    while index < bytes.len() {
860        match bytes[index] {
861            b'\\' if !in_class => index += 1,
862            b'[' if !in_class => {
863                in_class = true;
864                class_start = index + 1;
865                if matches!(bytes.get(class_start), Some(b'!' | b'^')) {
866                    class_start += 1;
867                }
868            }
869            b']' if in_class && index == class_start => {}
870            b']' if in_class => in_class = false,
871            _ if in_class => {}
872            b'{' => depth += 1,
873            b'}' if depth > 0 => depth -= 1,
874            b'/' if depth == 0 => {
875                parts.push(&pattern[start..index]);
876                start = index + 1;
877            }
878            _ => {}
879        }
880        index += 1;
881    }
882    if in_class || depth != 0 {
883        return None;
884    }
885    parts.push(&pattern[start..]);
886    Some(parts)
887}
888
889/// Resolve the `.` and `..` components of a path pattern lexically, so
890/// `./docs`, `docs/./x` and `a/../docs` name the same files as `docs`.
891///
892/// - A `.` component and an interior empty one (`a//b`) are dropped. A trailing
893///   `/` is kept: it marks a directory.
894/// - A `..` removes the plain literal component before it. In an absolute
895///   pattern whose earlier components all exist on disk the `..` is kept
896///   instead, because the filesystem resolves it (`link/..` is the parent of
897///   the link's target, which [`PathAliases`] honours). A prefix that does not
898///   exist is resolved lexically. `/..` is `/`.
899/// - A `..` that cannot be resolved stays as written: one that leads a relative
900///   pattern, follows a wildcard or an escape, or sits in an absolute pattern
901///   after any component that is not a plain literal (so existence cannot be
902///   checked before the glob is expanded). Such a pattern matches nothing by
903///   itself, as it always has.
904/// - Roots are never removed: the leading `/` (a run of them collapses to one,
905///   except a UNC `//server/share` on Windows) and, on Windows, a drive
906///   (`C:`) and the `server`/`share` of a UNC path.
907///
908/// A relative pattern that would end up empty (`.`, `./`, `a/..`) is left as
909/// written. The result borrows `pattern` when nothing changed.
910fn normalize_dot_segments(pattern: &str, windows: bool) -> Cow<'_, str> {
911    if !(pattern.contains("//") || pattern.split('/').any(|part| part == "." || part == "..")) {
912        return Cow::Borrowed(pattern);
913    }
914    let Some(parts) = split_components(pattern) else {
915        return Cow::Borrowed(pattern);
916    };
917    let count = parts.len();
918    let leading_empty = parts.iter().take_while(|part| part.is_empty()).count();
919    let mut index = 0;
920    let mut lead = "";
921    let mut absolute = false;
922    let mut kept: Vec<&str> = Vec::new();
923    // Components at the front of `kept` that a `..` may not remove.
924    let mut protected = 0;
925    if leading_empty > 0 {
926        absolute = true;
927        let slashes = if leading_empty == count {
928            count - 1
929        } else {
930            leading_empty
931        };
932        index = leading_empty.min(count);
933        lead = if windows && slashes >= 2 { "//" } else { "/" };
934        if lead == "//" {
935            // The `server`/`share` of a UNC path, or `?`/`C:` of a verbatim one.
936            for part in parts.iter().skip(index).take(2).filter(|part| !part.is_empty()) {
937                kept.push(part);
938                index += 1;
939                protected += 1;
940            }
941        }
942    } else if windows
943        && let [letter, b':'] = parts[0].as_bytes()
944        && letter.is_ascii_alphabetic()
945    {
946        absolute = true;
947        kept.push(parts[0]);
948        protected = 1;
949        index = 1;
950    }
951
952    let mut opaque = false;
953    let mut trailing = false;
954    for (position, &part) in parts.iter().enumerate().skip(index) {
955        let last = position + 1 == count;
956        match part {
957            "" => trailing |= last,
958            "." => {}
959            ".." => {
960                let removable = kept.len() > protected
961                    && kept
962                        .last()
963                        .is_some_and(|prev| *prev != ".." && is_plain_component(prev));
964                if removable {
965                    if !absolute {
966                        kept.pop();
967                    } else if opaque {
968                        kept.push("..");
969                    } else {
970                        let prefix = format!("{lead}{}", kept.join("/"));
971                        if Path::new(&prefix).exists() {
972                            kept.push("..");
973                        } else {
974                            kept.pop();
975                        }
976                    }
977                } else if !(absolute && kept.len() == protected) {
978                    kept.push("..");
979                }
980            }
981            _ => {
982                opaque |= !is_plain_component(part);
983                kept.push(part);
984            }
985        }
986    }
987
988    if kept.is_empty() && !absolute {
989        return Cow::Borrowed(pattern);
990    }
991    let mut normalized = format!("{lead}{}", kept.join("/"));
992    // A bare drive (`C:`) is drive-relative: keep the `/` that makes it the root.
993    let bare_drive = lead.is_empty() && absolute && protected == 1 && kept.len() == 1;
994    if (trailing && !kept.is_empty()) || bare_drive {
995        normalized.push('/');
996    }
997    if normalized == pattern {
998        Cow::Borrowed(pattern)
999    } else {
1000        Cow::Owned(normalized)
1001    }
1002}
1003
1004/// `normalized` with a leading `/` when removing `.` components left it with no
1005/// `/` of its own.
1006///
1007/// A relative pattern with a `/` is anchored to the root, one without floats to
1008/// any depth. `./dir` only has its `/` through the `./`, so it must keep naming
1009/// the root's `dir` and not start to float. `spelled` is the pattern before
1010/// normalization. `ignore` reads a leading `/` as the anchor.
1011fn keep_anchored(spelled: &str, normalized: String) -> String {
1012    let floated = !trim_directory_slash(spelled).contains('/');
1013    let directory = trim_directory_slash(&normalized);
1014    if !floated && !directory.is_empty() && !directory.contains('/') {
1015        format!("/{normalized}")
1016    } else {
1017        normalized
1018    }
1019}
1020
1021/// Normalize a config path pattern for matching against paths discovered under
1022/// `base`: expand a leading `~`, then rewrite an absolute pattern as one
1023/// relative to `base` when `base` contains it.
1024///
1025/// The rewrite is what makes an absolute pattern usable as a walker override:
1026/// the `ignore` crate reads a leading `/` as "anchored to the walk base", so
1027/// `/home/dev/docs/**` would otherwise be understood as
1028/// `<base>/home/dev/docs/**` and match nothing. A pattern pointing outside
1029/// `base` is left absolute - nothing under this walk can match it, which is the
1030/// correct outcome.
1031///
1032/// A pattern can also name `base`'s location through a symlink
1033/// (`/var/folders/…` for a base at `/private/var/folders/…`), which no strip of
1034/// `base` in either form removes. Its leading literal components are then
1035/// resolved, giving the same location in the base's own spelling. Only that
1036/// prefix is rewritten and the strip consumes it, so what survives is the
1037/// pattern as written. A pattern whose *first* component holds a wildcard or a
1038/// brace alternation has no such prefix and stays absolute.
1039pub fn normalize_pattern_for_base(pattern: &str, base: Option<&Path>) -> String {
1040    normalize_pattern_for_base_for(pattern, base, cfg!(windows))
1041}
1042
1043/// [`normalize_pattern_for_base`] for an explicit platform (see
1044/// [`prepare_pattern_for`]).
1045///
1046/// `.` and `..` components are resolved first (see `normalize_dot_segments`).
1047/// A leading `!` is an `ignore` override negation, not part of the path: it is
1048/// set aside while the operand is prepared and put back. The pattern is left
1049/// with its dots where resolving them would turn a positive include into a
1050/// comment (`#`) or a negation (`!`) line.
1051pub fn normalize_pattern_for_base_for(pattern: &str, base: Option<&Path>, windows: bool) -> String {
1052    let (negated, operand) = match pattern.strip_prefix('!') {
1053        Some(operand) => (true, operand),
1054        None => (false, pattern),
1055    };
1056    let spelled = spell_pattern(operand, !negated, windows);
1057    let normalized = match normalize_dot_segments(&spelled, windows) {
1058        Cow::Owned(normalized) if negated || !(spelled.starts_with('#') || normalized.starts_with(['#', '!'])) => {
1059            normalized
1060        }
1061        _ => spelled.to_string(),
1062    };
1063    if negated {
1064        return format!("!{}", keep_anchored(&spelled, normalized));
1065    }
1066    let Some(base) = base else {
1067        return keep_anchored(&spelled, normalized);
1068    };
1069    if !is_absolute_pattern(&normalized) {
1070        return keep_anchored(&spelled, normalized);
1071    }
1072    let prepared = normalized;
1073
1074    if let Some(relative) = strip_base_prefix(&prepared, base) {
1075        return anchor_remainder(resolve_remainder_dots(relative, base).unwrap_or(prepared));
1076    }
1077    if let Some(canonical_pattern) = canonicalize_pattern_prefix(&prepared)
1078        && let Some(relative) = strip_base_prefix(&canonical_pattern, base)
1079    {
1080        return anchor_remainder(resolve_remainder_dots(relative, base).unwrap_or(prepared));
1081    }
1082    prepared
1083}
1084
1085/// `remainder` with a leading `/` when it is a single component.
1086///
1087/// A pattern written as an absolute path names one location. Once the base is
1088/// stripped, a remainder without a `/` (`<base>/top.md` -> `top.md`) would float
1089/// to every depth like a bare relative name, so it keeps the root anchor `ignore`
1090/// reads as a leading `/`. A remainder with an interior `/` is anchored already,
1091/// and an unstripped absolute pattern keeps its own leading `/`.
1092fn anchor_remainder(remainder: String) -> String {
1093    let directory = trim_directory_slash(&remainder);
1094    if directory.is_empty() || directory.contains('/') {
1095        remainder
1096    } else {
1097        format!("/{remainder}")
1098    }
1099}
1100
1101/// The pattern left after `base` was stripped, with any `..` that was kept for
1102/// the filesystem resolved against `base` itself, or `None` when that leaves the
1103/// base.
1104///
1105/// An absolute pattern keeps a `..` after an existing directory, and one under a
1106/// base whose own name holds a metacharacter (`/tmp/project[1]`) keeps every
1107/// `..`. Once the base is stripped the rest is relative to a location that is
1108/// known, so each such `..` is resolved there: the longest existing leading part
1109/// is canonicalized (symlinks honoured), the missing remainder is resolved
1110/// lexically, and the parent is expressed relative to the canonical base.
1111fn resolve_remainder_dots(remainder: &str, base: &Path) -> Option<String> {
1112    if !remainder.split('/').any(|part| part == "..") {
1113        return Some(remainder.to_string());
1114    }
1115    let parts = split_components(remainder)?;
1116    let canonical_base = canonicalize_for_matching(base)?;
1117    let mut kept: Vec<String> = Vec::new();
1118    let mut opaque = false;
1119    let mut trailing = false;
1120    for (position, part) in parts.iter().enumerate() {
1121        match *part {
1122            "" => trailing |= position + 1 == parts.len(),
1123            "." => {}
1124            ".." => {
1125                let removable = !opaque && kept.last().is_some_and(|prev| prev != ".." && is_plain_component(prev));
1126                if removable {
1127                    kept = parent_relative_to(&canonical_base, &kept)?;
1128                } else if kept.is_empty() && !opaque {
1129                    // Above the base: nothing under this walk can match it.
1130                    return None;
1131                } else {
1132                    kept.push("..".to_string());
1133                }
1134            }
1135            _ => {
1136                opaque |= !is_plain_component(part);
1137                kept.push((*part).to_string());
1138            }
1139        }
1140    }
1141    let mut resolved = kept.join("/");
1142    if trailing && !kept.is_empty() {
1143        resolved.push('/');
1144    }
1145    Some(resolved)
1146}
1147
1148/// The parent of `canonical_base/kept...` as components relative to
1149/// `canonical_base`, or `None` when it leaves the base or a component is not a
1150/// plain name.
1151fn parent_relative_to(canonical_base: &Path, kept: &[String]) -> Option<Vec<String>> {
1152    let full = kept
1153        .iter()
1154        .fold(canonical_base.to_path_buf(), |path, part| path.join(part));
1155    let resolved = full.ancestors().find_map(|ancestor| {
1156        let canonical = canonicalize_for_matching(ancestor)?;
1157        let rest = full.strip_prefix(ancestor).ok()?;
1158        Some(canonical.join(rest))
1159    })?;
1160    let relative = resolved.parent()?.strip_prefix(canonical_base).ok()?;
1161    relative
1162        .components()
1163        .map(|component| {
1164            let name = component.as_os_str().to_str()?;
1165            is_plain_component(name).then(|| name.to_string())
1166        })
1167        .collect()
1168}
1169
1170/// `pattern` with `base` removed, trying the base as given and canonicalized so
1171/// a symlinked or non-canonical base (macOS `/var`, a Windows 8.3 short name)
1172/// still strips. `None` when the pattern does not live under `base`.
1173fn strip_base_prefix<'a>(pattern: &'a str, base: &Path) -> Option<&'a str> {
1174    strip_base_text(pattern, base).or_else(|| {
1175        let canonical = canonicalize_for_matching(base)?;
1176        strip_base_text(pattern, &canonical)
1177    })
1178}
1179
1180/// `pattern` with the leading `base` removed, working on the text as written.
1181///
1182/// The pattern is not a path: a backslash in it is an escape, and a `Path`
1183/// would read it as a separator on Windows and drop it with the component. So
1184/// the base is only ever matched up to a `/` of the pattern, and what follows is
1185/// returned verbatim apart from the `.` components and separators a path
1186/// comparison would also have ignored (`/root/./docs`, `/root//docs/`).
1187fn strip_base_text<'a>(pattern: &'a str, base: &Path) -> Option<&'a str> {
1188    let boundaries = pattern
1189        .match_indices('/')
1190        .map(|(index, _)| index)
1191        .chain(std::iter::once(pattern.len()));
1192    for end in boundaries {
1193        // A root base (`/`, `C:/`) owns the separator that closes it.
1194        let rest = if Path::new(&pattern[..end]) == base {
1195            &pattern[end..]
1196        } else if end < pattern.len() && Path::new(&pattern[..=end]) == base {
1197            &pattern[end + 1..]
1198        } else {
1199            continue;
1200        };
1201        return Some(trim_relative_remainder(rest));
1202    }
1203    None
1204}
1205
1206/// The pattern left after its base: without leading `/` and `./` components and
1207/// without trailing `/` and `/.`, which name the directory itself.
1208fn trim_relative_remainder(mut rest: &str) -> &str {
1209    loop {
1210        if let Some(tail) = rest.strip_prefix('/').or_else(|| rest.strip_prefix("./")) {
1211            rest = tail;
1212        } else if rest == "." {
1213            rest = "";
1214        } else {
1215            break;
1216        }
1217    }
1218    loop {
1219        let trimmed = trim_directory_slash(rest);
1220        let trimmed = match trimmed.strip_suffix("/.") {
1221            // `\/.` has an escaped `/`: that dot is not a component of its own.
1222            Some(head) if trailing_backslashes(head).is_multiple_of(2) => head,
1223            _ => trimmed,
1224        };
1225        if trimmed.len() == rest.len() {
1226            return rest;
1227        }
1228        rest = trimmed;
1229    }
1230}
1231
1232fn trailing_backslashes(text: &str) -> usize {
1233    text.bytes().rev().take_while(|&byte| byte == b'\\').count()
1234}
1235
1236/// `pattern` without its trailing directory marker.
1237///
1238/// A trailing `/` run is dropped. When exactly one `/` was there and a backslash
1239/// then ends the text, it goes too, as `ignore` does: it escaped the `/` that was
1240/// just dropped, so `docs\/` names the directory `docs`. `docs\\/` becomes
1241/// `docs\`, a dangling escape that does not compile, which is also how the walker
1242/// reads it. After several slashes (`docs\//`) the backslash is kept and the
1243/// result is that same dangling escape, which is reported rather than silently
1244/// matching nothing.
1245fn trim_directory_slash(pattern: &str) -> &str {
1246    let trimmed = pattern.trim_end_matches('/');
1247    match pattern.len() - trimmed.len() {
1248        0 => pattern,
1249        1 => trimmed.strip_suffix('\\').unwrap_or(trimmed),
1250        _ => trimmed,
1251    }
1252}
1253
1254/// Expands directory-style patterns to also match files within them.
1255/// Pattern "dir/path" becomes ["dir/path", "dir/path/**"] to match both
1256/// the directory itself and all contents recursively. A leading `~` is
1257/// expanded first (see [`expand_home_prefix`]) and `.`/`..` components are
1258/// resolved (see [`prepare_pattern`]).
1259///
1260/// The result is the form the walker reads as `ignore` overrides, so a pattern
1261/// anchored to the root by an `./` keeps a leading `/` once the `./` is gone.
1262///
1263/// The expansion is driven by the pattern's *final* component: it names a
1264/// directory only when it holds no wildcard. `docs/*` therefore stays as
1265/// written (it names direct children, and `docs/*/**` would newly exclude
1266/// nested contents), while `**/.cursor/plans` gains its contents-expansion
1267/// despite the wildcard earlier in the pattern.
1268pub fn expand_directory_pattern(pattern: &str) -> Vec<String> {
1269    expand_directory_pattern_for(pattern, cfg!(windows))
1270}
1271
1272/// [`expand_directory_pattern`] for an explicit platform (see [`prepare_pattern_for`]).
1273pub fn expand_directory_pattern_for(pattern: &str, windows: bool) -> Vec<String> {
1274    let expanded = expand_exclude(pattern, windows);
1275    if expanded.walker_anchor {
1276        expanded.entries.iter().map(|entry| format!("/{entry}")).collect()
1277    } else {
1278        expanded.entries
1279    }
1280}
1281
1282/// The `ignore` override rules an `include` pattern turns into.
1283///
1284/// A whitelist glob that names a directory (`docs`) selects the directory entry
1285/// but none of the files beneath it, so the run lints nothing. The pattern is
1286/// therefore followed by a rule for the contents, the same expansion `exclude`
1287/// applies (see [`expand_directory_pattern`]): it is driven by the *final*
1288/// component, which names a directory only when it holds no wildcard.
1289///
1290/// The contents keep the pattern's own anchoring. A bare name floats like
1291/// `gitignore` and like the same `exclude`, so its contents are matched at any
1292/// depth (`**/docs/**`; plain `docs/**` would anchor to the root). A pattern with
1293/// a `/`, or a leading one, names one location and its contents stay under it.
1294///
1295/// Takes a pattern already run through [`normalize_pattern_for_base`]. A negation
1296/// and a comment (`#`) are returned as written: neither names files to select.
1297pub fn expand_include_directory(pattern: &str) -> Vec<String> {
1298    // A blank pattern is discarded by the glob builder; expanding it would turn
1299    // that no-op into a whitelist rule that selects nothing.
1300    if pattern.trim().is_empty() || pattern.starts_with(['!', '#']) {
1301        return vec![pattern.to_string()];
1302    }
1303    // `directory_entries` leaves `*`, `?` and `[` finals alone; a brace
1304    // alternation is a glob here too, so it names its entries and nothing below.
1305    let final_component = trim_directory_slash(pattern).rsplit('/').next().unwrap_or_default();
1306    if final_component.contains('{') {
1307        return vec![pattern.to_string()];
1308    }
1309    let mut rules = directory_entries(pattern);
1310    if let [directory, contents] = rules.as_mut_slice() {
1311        if !directory.contains('/') {
1312            *contents = format!("**/{directory}/**");
1313        }
1314        // The pattern itself stays as written, so a trailing `/` keeps it
1315        // matching directories only.
1316        *directory = pattern.to_string();
1317    }
1318    rules
1319}
1320
1321/// An `exclude` pattern after preparation.
1322struct ExpandedExclude {
1323    /// The directory itself and its contents, in the form `globset` matches
1324    /// against a root-relative path.
1325    entries: Vec<String>,
1326    /// Whether the pattern, as written, matches at any depth (gitignore: no `/`
1327    /// except a trailing one). Decided before `.` components are removed, which
1328    /// is what anchors `./dir`.
1329    floats: bool,
1330    /// Whether the walker needs a leading `/` on every entry to keep the
1331    /// pattern anchored once its `./` is gone.
1332    walker_anchor: bool,
1333}
1334
1335fn expand_exclude(pattern: &str, windows: bool) -> ExpandedExclude {
1336    let spelled = spell_pattern(pattern, true, windows);
1337    let floats = !trim_directory_slash(&spelled).contains('/');
1338    let prepared = match normalize_dot_segments(&spelled, windows) {
1339        Cow::Owned(normalized) => Cow::Owned(normalized),
1340        Cow::Borrowed(_) => spelled,
1341    };
1342    let directory = trim_directory_slash(&prepared);
1343    let walker_anchor = !floats && !is_absolute_pattern(&prepared) && !directory.is_empty() && !directory.contains('/');
1344    ExpandedExclude {
1345        entries: directory_entries(&prepared),
1346        floats,
1347        walker_anchor,
1348    }
1349}
1350
1351fn directory_entries(pattern: &str) -> Vec<String> {
1352    let base = trim_directory_slash(pattern);
1353    let final_component = base.rsplit('/').next().unwrap_or(base);
1354
1355    // A dangling escape names nothing: appending `/**` would turn it into an
1356    // escaped slash and revive a pattern the walker rejects, so it stays whole.
1357    let dangling_escape = trailing_backslashes(base) % 2 == 1;
1358    if final_component.is_empty() || final_component.contains(['*', '?', '[']) || dangling_escape {
1359        return vec![pattern.to_string()];
1360    }
1361
1362    vec![
1363        base.to_string(),     // Match the directory itself
1364        format!("{base}/**"), // Match everything underneath
1365    ]
1366}
1367
1368/// Why an `exclude` pattern with a leading `!` is rejected, and where the
1369/// exception it was reaching for can live instead.
1370pub const EXCLUDE_NEGATION_UNSUPPORTED: &str = "a leading '!' does not negate in exclude and is not supported; \
1371move both the broad exclusion and its exception into .markdownlintignore, which supports '!' re-inclusion";
1372
1373/// Whether `pattern` is an `exclude` negation (a leading `!`).
1374///
1375/// `exclude` has no re-inclusion: the walker reads its patterns as an unordered
1376/// set of `ignore` overrides and the post-walk matchers read them as globs, and
1377/// neither can honor an exception to an earlier pattern. Such a pattern is
1378/// rejected everywhere, so that it matches nothing in every consumer instead of
1379/// excluding on a walk and nothing on a named file. A literal leading bang is
1380/// still reachable by escaping it (`\!name`).
1381pub fn is_exclude_negation(pattern: &str) -> bool {
1382    pattern.starts_with('!')
1383}
1384
1385/// The `ignore` override rule that excludes `pattern`.
1386///
1387/// The crate spells exclusion with a leading `!`. Callers hand over only
1388/// patterns that are not negations (see [`is_exclude_negation`]), because a
1389/// leading `!` of the user's would otherwise be read as this marker.
1390pub fn exclude_override_rule(pattern: &str) -> String {
1391    format!("!{pattern}")
1392}
1393
1394/// Whether every glob an `exclude` pattern turns into compiles.
1395///
1396/// An exclude pattern reaches two consumers: [`ExcludeMatchers`] compiles each
1397/// expansion with `globset`, and the walker adds each as an `ignore` override.
1398/// Both are mirrored here so a caller holding only the pattern can tell whether
1399/// either would reject it, which is also when either would print it. A negation
1400/// is rejected by both (see [`is_exclude_negation`]).
1401pub fn exclude_pattern_compiles(pattern: &str) -> bool {
1402    if is_exclude_negation(pattern) {
1403        return false;
1404    }
1405    expand_directory_pattern(pattern).iter().all(|expanded| {
1406        exclude_glob(expanded).is_ok()
1407            && ignore::overrides::OverrideBuilder::new(Path::new("."))
1408                .add(&exclude_override_rule(expanded))
1409                .is_ok()
1410    })
1411}
1412
1413/// Whether an `include` pattern compiles as a walker override.
1414///
1415/// Answers for the pattern as given, which is only the form the walker uses once
1416/// [`normalize_pattern_for_base`] has run: stripping a base prefix removes
1417/// whatever the base's own name held, and an absolute pattern under a directory
1418/// called `notes [2019-2021]` carries a character class over a descending range
1419/// until the prefix comes off. Ask this about the pattern the walker is about to
1420/// add, never about the one a config file spelled.
1421pub fn include_pattern_compiles(pattern: &str) -> bool {
1422    ignore::overrides::OverrideBuilder::new(Path::new("."))
1423        .add(&prepare_pattern(pattern))
1424        .is_ok()
1425}
1426
1427/// The globs matching everything one expanded exclude pattern removes: the
1428/// pattern itself, the contents of a directory it matches when `with_contents`,
1429/// and each of those at any depth when the pattern `floats` (see
1430/// [`ExcludeMatchers::new`]).
1431fn exclusion_globs(pattern: &str, with_contents: bool, floats: bool, windows: bool) -> Result<GlobSet, globset::Error> {
1432    let mut located = vec![pattern.to_string()];
1433    // The directory form is what the walker compiles, so a dangling escape it
1434    // would reject is rejected here too, before a `/**` can turn it into `\/**`.
1435    let directory = trim_directory_slash(pattern);
1436    if directory.len() != pattern.len() {
1437        path_glob_for(directory, true, windows)?;
1438    }
1439    if with_contents && !pattern.ends_with("/**") {
1440        located.push(format!("{directory}/**"));
1441    }
1442    if floats {
1443        let anywhere: Vec<String> = located.iter().map(|glob| format!("**/{glob}")).collect();
1444        located.extend(anywhere);
1445    }
1446    let mut builder = GlobSetBuilder::new();
1447    for glob in &located {
1448        builder.add(path_glob_for(glob, true, windows)?);
1449    }
1450    builder.build()
1451}
1452
1453/// One exclude glob, compiled the way `.gitignore` reads it: `*` and `?` match
1454/// within one path component, and only `**` spans a `/`.
1455fn exclude_glob(glob: &str) -> Result<Glob, globset::Error> {
1456    path_glob(glob, true)
1457}
1458
1459/// A glob over `/`-separated paths, with `\` as the escape for the next
1460/// character on Unix and as a path separator on Windows (see [`path_glob_for`]).
1461///
1462/// This is how the walker's `ignore` overrides read the same patterns, so a
1463/// file is selected by the walk and by rumdl's own matching (named files, stdin,
1464/// the editor integration, `per-file-*` keys) or by neither. `globset` alone
1465/// would read `\` as a path separator on Windows, so the choice is made
1466/// explicitly. `/` is the separator patterns are written with everywhere.
1467///
1468/// `literal_separator` keeps `*` and `?` within one path component.
1469pub(crate) fn path_glob(pattern: &str, literal_separator: bool) -> Result<Glob, globset::Error> {
1470    path_glob_for(pattern, literal_separator, cfg!(windows))
1471}
1472
1473/// [`path_glob`] for an explicit platform. On Windows a `\` is a path
1474/// separator, as in [`prepare_pattern_for`], so it is never compiled as an
1475/// escape whichever route built the pattern.
1476pub(crate) fn path_glob_for(pattern: &str, literal_separator: bool, windows: bool) -> Result<Glob, globset::Error> {
1477    GlobBuilder::new(&spell_pattern(pattern, false, windows))
1478        .literal_separator(literal_separator)
1479        .backslash_escape(true)
1480        .build()
1481}
1482
1483/// One expanded `exclude` pattern and the globs matching what it excludes.
1484struct ExcludeMatcher {
1485    pattern: String,
1486    globs: GlobSet,
1487    /// Whether any spelling of the pattern is absolute, which is what makes a
1488    /// file's absolute path worth matching against it.
1489    absolute: bool,
1490}
1491
1492/// Compiled `exclude` patterns with directory-pattern expansion applied.
1493///
1494/// Match paths through [`matched_pattern`](Self::matched_pattern) using a
1495/// root-relative path (the CLI relativizes against the project root, the
1496/// LSP against the containing workspace root) so patterns like
1497/// `docs/drafts` behave identically everywhere.
1498pub struct ExcludeMatchers {
1499    /// Each expanded pattern (see [`ExcludeMatchers::new`]).
1500    matchers: Vec<ExcludeMatcher>,
1501    /// Spellings of a file the absolute patterns reach through a symlink.
1502    aliases: PathAliases,
1503    /// Patterns that failed to compile, with their errors. Callers decide
1504    /// how to surface these (CLI prints to stderr, LSP logs).
1505    pub invalid: Vec<(String, String)>,
1506}
1507
1508/// Whether `pattern` names an absolute location. A leading `/` counts on every
1509/// platform: patterns use `/` separators, so a Unix-style path stays absolute
1510/// when the same config is read on Windows.
1511pub fn is_absolute_pattern(pattern: &str) -> bool {
1512    pattern.starts_with('/') || Path::new(pattern).is_absolute()
1513}
1514
1515/// Whether `pattern` names an absolute location in any of its spellings.
1516///
1517/// A brace alternation can put the absolute part past the start of the pattern
1518/// (`{/opt,/srv}/docs/**`), where [`is_absolute_pattern`] cannot see it. Callers
1519/// deciding whether to match a file's absolute path at all must ask this, or
1520/// such a pattern is never given an absolute path to match.
1521pub fn has_absolute_spelling(pattern: &str) -> bool {
1522    if is_absolute_pattern(pattern) {
1523        return true;
1524    }
1525    pattern.contains('{')
1526        && expand_braces(pattern)
1527            .iter()
1528            .any(|spelling| is_absolute_pattern(spelling))
1529}
1530
1531/// How many literal spellings one pattern's brace alternations may produce.
1532///
1533/// Expansion only *discovers* directory prefixes to canonicalize (see
1534/// [`PathAliases`]); it never decides whether a pattern matches. Past this
1535/// point a pattern like `{a,b}{c,d}{e,f}…` is multiplying out work that buys
1536/// nothing, so the pattern is left unexpanded.
1537const MAX_BRACE_EXPANSIONS: usize = 64;
1538
1539/// Every literal spelling of `pattern`'s brace alternations:
1540/// `/{var/folders,tmp}/**` yields `/var/folders/**` and `/tmp/**`.
1541///
1542/// Returns just `pattern` when it holds no alternation, when its braces are
1543/// unbalanced, or when expanding would exceed [`MAX_BRACE_EXPANSIONS`].
1544/// Character classes are opaque, so a comma inside `[...]` stays literal.
1545fn expand_braces(pattern: &str) -> Vec<String> {
1546    let mut pending = vec![pattern.to_string()];
1547    let mut expanded: Vec<String> = Vec::new();
1548    while let Some(current) = pending.pop() {
1549        let Some((prefix, alternatives, suffix)) = split_first_alternation(&current) else {
1550            expanded.push(current);
1551            continue;
1552        };
1553        if pending.len() + expanded.len() + alternatives.len() > MAX_BRACE_EXPANSIONS {
1554            return vec![pattern.to_string()];
1555        }
1556        for alternative in alternatives {
1557            pending.push(format!("{prefix}{alternative}{suffix}"));
1558        }
1559    }
1560    expanded
1561}
1562
1563/// Split `pattern` at its first top-level brace alternation into the text
1564/// before it, its alternatives, and the text after it. `None` when there is no
1565/// alternation to split on, including an unclosed `{`.
1566///
1567/// Empty alternatives are dropped, mirroring globset: it compiles `x{,y}` to
1568/// `^x(?:y)$`, so `x` is not one of that pattern's spellings.
1569fn split_first_alternation(pattern: &str) -> Option<(&str, Vec<&str>, &str)> {
1570    let bytes = pattern.as_bytes();
1571    let mut open = None;
1572    let mut depth = 0usize;
1573    let mut in_class = false;
1574    // The first `]` of a class (after an optional `!` or `^`) is a member.
1575    let mut class_start = 0;
1576    let mut alternatives = Vec::new();
1577    let mut alternative_start = 0;
1578    let mut index = 0;
1579    while index < bytes.len() {
1580        match bytes[index] {
1581            b'\\' if !in_class => index += 1,
1582            b'[' if !in_class => {
1583                in_class = true;
1584                class_start = index + 1;
1585                if matches!(bytes.get(class_start), Some(b'!' | b'^')) {
1586                    class_start += 1;
1587                }
1588            }
1589            b']' if in_class && index == class_start => {}
1590            b']' if in_class => in_class = false,
1591            _ if in_class => {}
1592            b'{' => {
1593                depth += 1;
1594                if depth == 1 {
1595                    open = Some(index);
1596                    alternative_start = index + 1;
1597                }
1598            }
1599            b',' if depth == 1 => {
1600                alternatives.push(&pattern[alternative_start..index]);
1601                alternative_start = index + 1;
1602            }
1603            b'}' if depth > 0 => {
1604                depth -= 1;
1605                if depth == 0 {
1606                    alternatives.push(&pattern[alternative_start..index]);
1607                    alternatives.retain(|alternative| !alternative.is_empty());
1608                    if alternatives.is_empty() {
1609                        return None;
1610                    }
1611                    return Some((&pattern[..open?], alternatives, &pattern[index + 1..]));
1612                }
1613            }
1614            _ => {}
1615        }
1616        index += 1;
1617    }
1618    None
1619}
1620
1621/// The leading run of `pattern`'s path components that hold no glob
1622/// metacharacter: `/var/folders/**` yields `/var/folders`, `/var/log/app*.md`
1623/// yields `/var/log`, and a fully literal pattern yields itself.
1624///
1625/// `None` when the run is empty or names only the filesystem root, neither of
1626/// which can resolve to a different location. The result is always a prefix
1627/// slice of `pattern`, so the remainder can be re-attached by byte offset.
1628///
1629/// A component holding a backslash (an escaped metacharacter, `\*`) ends the run
1630/// early on every platform. That yields a shorter prefix, never a wrong one.
1631fn literal_path_prefix(pattern: &str) -> Option<&str> {
1632    let mut end = 0;
1633    let mut saw_component = false;
1634    for component in pattern.split('/') {
1635        // A backslash makes the component an escape sequence, not a name: the
1636        // text is not what the pattern matches, so it cannot be resolved as a path.
1637        if component.contains(GLOB_METACHARS) || component.contains('\\') {
1638            break;
1639        }
1640        saw_component |= !component.is_empty();
1641        // Skip past this component and the separator that follows it.
1642        end += component.len() + 1;
1643    }
1644    if !saw_component {
1645        return None;
1646    }
1647    // The loop counted a separator after the final component; the pattern only
1648    // has one when the run did not reach its end. A trailing separator is
1649    // dropped so the remainder re-attaches with exactly one.
1650    let prefix = &pattern[..(end - 1).min(pattern.len())];
1651    let prefix = prefix.strip_suffix('/').unwrap_or(prefix);
1652    // A bare drive designator (`C:`) is the current directory of that drive, not
1653    // a location a pattern names.
1654    let bare_drive = matches!(prefix.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic());
1655    (!bare_drive).then_some(prefix)
1656}
1657
1658/// `pattern` with its leading literal components resolved through symlinks, or
1659/// `None` when there is nothing to resolve, the prefix does not exist, or
1660/// resolving changes nothing.
1661fn canonicalize_pattern_prefix(pattern: &str) -> Option<String> {
1662    let prefix = literal_path_prefix(pattern)?;
1663    let canonical = canonicalize_for_matching(Path::new(prefix))?;
1664    let canonical = normalize_pattern_separators(canonical.to_string_lossy()).into_owned();
1665    if canonical == prefix {
1666        return None;
1667    }
1668    Some(format!("{canonical}{}", &pattern[prefix.len()..]))
1669}
1670
1671/// Alternative spellings of a path, implied by the absolute patterns in a
1672/// configuration.
1673///
1674/// A pattern names a location the way the user wrote it (`/var/folders/**` on
1675/// macOS); the file it is matched against arrives canonicalized
1676/// (`/private/var/folders/…`), so the two never meet. Each pair recorded here
1677/// is one symlinked prefix some pattern reached a location through: the
1678/// canonical form of that pattern's leading literal components, and the
1679/// spelling the pattern used for them.
1680///
1681/// Rewriting the *path* rather than the pattern leaves globset the only
1682/// authority on what a pattern means, and cannot invent a match:
1683/// `canonicalize(as_written) == canonical` together with `path == canonical +
1684/// rest` say that `as_written + rest` names that same file. Brace alternations
1685/// are expanded only to find more prefixes to canonicalize, so an expansion
1686/// that disagrees with globset can cost a spelling, never fabricate one.
1687#[derive(Debug, Default)]
1688pub struct PathAliases {
1689    /// `(canonical prefix, the spelling a pattern used for it)`.
1690    prefixes: Vec<(PathBuf, String)>,
1691}
1692
1693impl PathAliases {
1694    /// Collect the symlinked prefixes `patterns` reach locations through.
1695    ///
1696    /// Each pattern is canonicalized once here, at cache-build time, so
1697    /// per-file matching pays no syscall.
1698    pub fn new<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Self {
1699        let mut prefixes: Vec<(PathBuf, String)> = Vec::new();
1700        for pattern in patterns {
1701            let pattern = prepare_pattern(pattern);
1702            if !has_absolute_spelling(&pattern) {
1703                continue;
1704            }
1705            for spelling in expand_braces(&pattern) {
1706                let Some(as_written) = literal_path_prefix(&spelling) else {
1707                    continue;
1708                };
1709                if !is_absolute_pattern(as_written) {
1710                    continue;
1711                }
1712                let Some(canonical) = canonicalize_for_matching(Path::new(as_written)) else {
1713                    continue;
1714                };
1715                if canonical == Path::new(as_written) {
1716                    continue;
1717                }
1718                let as_written = normalize_pattern_separators(Cow::Borrowed(as_written)).into_owned();
1719                if !prefixes.iter().any(|(c, w)| c == &canonical && w == &as_written) {
1720                    prefixes.push((canonical, as_written));
1721                }
1722            }
1723        }
1724        Self { prefixes }
1725    }
1726
1727    pub fn is_empty(&self) -> bool {
1728        self.prefixes.is_empty()
1729    }
1730
1731    /// The spellings of `path` reachable through a recorded prefix, as glob
1732    /// match candidates. Empty when no pattern reached `path`'s location
1733    /// through a symlink, which is every configuration that has none.
1734    pub fn spellings_of(&self, path: &Path) -> Vec<String> {
1735        self.prefixes
1736            .iter()
1737            .filter_map(|(canonical, as_written)| {
1738                let rest = path.strip_prefix(canonical).ok()?;
1739                if rest.as_os_str().is_empty() {
1740                    return Some(as_written.clone());
1741                }
1742                let rest = normalize_pattern_separators(rest.to_string_lossy());
1743                Some(format!("{as_written}/{rest}"))
1744            })
1745            .collect()
1746    }
1747}
1748
1749impl ExcludeMatchers {
1750    /// Compile `patterns` to exclude what the discovery walk excludes.
1751    ///
1752    /// The walk applies each pattern as an `ignore` override, which reads it the
1753    /// way `.gitignore` does, so a file named directly has to be matched by the
1754    /// same rules or it is linted where the walk skips it:
1755    ///
1756    /// - A pattern with no `/` except a trailing one matches at any depth:
1757    ///   `node_modules` excludes `packages/app/node_modules/readme.md`.
1758    /// - A pattern that matches a directory excludes everything in it, including
1759    ///   one whose final component is a wildcard: `draft?` excludes
1760    ///   `drafts/note.md`.
1761    /// - `*` and `?` match within one path component: `sub/*.md` excludes
1762    ///   `sub/top.md` but not `sub/a/b.md`.
1763    ///
1764    /// Relative patterns are otherwise anchored at the root the path is relative
1765    /// to, and an absolute pattern never floats.
1766    pub fn new(patterns: &[String]) -> Self {
1767        Self::for_platform(patterns, cfg!(windows))
1768    }
1769
1770    /// [`ExcludeMatchers::new`] for an explicit platform (see [`prepare_pattern_for`]).
1771    pub fn for_platform(patterns: &[String], windows: bool) -> Self {
1772        let mut matchers = Vec::new();
1773        let mut invalid = Vec::new();
1774        for written in patterns {
1775            if is_exclude_negation(written) {
1776                // The same pattern listed twice is one problem, not two.
1777                if !invalid.iter().any(|(pattern, _): &(String, String)| pattern == written) {
1778                    invalid.push((written.clone(), EXCLUDE_NEGATION_UNSUPPORTED.to_string()));
1779                }
1780                continue;
1781            }
1782            let ExpandedExclude {
1783                entries: expansions,
1784                floats,
1785                ..
1786            } = expand_exclude(written, windows);
1787            // A literal directory name already expanded to its own `/**` entry,
1788            // which the notice names. Only a wildcard final component is left
1789            // without one, and a directory it matches still has contents.
1790            let with_contents = expansions.len() == 1;
1791            for pattern in expansions {
1792                let absolute = has_absolute_spelling(&pattern);
1793                match exclusion_globs(&pattern, with_contents, floats && !absolute, windows) {
1794                    Ok(globs) => matchers.push(ExcludeMatcher {
1795                        pattern,
1796                        globs,
1797                        absolute,
1798                    }),
1799                    Err(e) => invalid.push((pattern, e.to_string())),
1800                }
1801            }
1802        }
1803        let aliases = PathAliases::new(matchers.iter().map(|matcher| matcher.pattern.as_str()));
1804        Self {
1805            matchers,
1806            aliases,
1807            invalid,
1808        }
1809    }
1810
1811    pub fn is_empty(&self) -> bool {
1812        self.matchers.is_empty()
1813    }
1814
1815    /// The first pattern matching `relative_path`, if any.
1816    pub fn matched_pattern(&self, relative_path: &str) -> Option<&str> {
1817        self.matchers
1818            .iter()
1819            .find(|matcher| matcher.globs.is_match(relative_path))
1820            .map(|matcher| matcher.pattern.as_str())
1821    }
1822
1823    /// The first absolute pattern matching `absolute_path`, if any.
1824    fn matched_absolute_pattern(&self, absolute_path: &str) -> Option<&str> {
1825        self.matchers
1826            .iter()
1827            .find(|matcher| matcher.absolute && matcher.globs.is_match(absolute_path))
1828            .map(|matcher| matcher.pattern.as_str())
1829    }
1830
1831    pub fn is_match(&self, relative_path: &str) -> bool {
1832        self.matched_pattern(relative_path).is_some()
1833    }
1834
1835    /// The first pattern matching a file, if any.
1836    ///
1837    /// Both forms of the file are tried: its `relative` form (how patterns are
1838    /// normally written - relative to the project or workspace root) and its
1839    /// absolute path, which is what an absolute pattern matches. Absolute
1840    /// patterns reach config either written literally or through `~` expansion,
1841    /// and the walker's overrides cannot apply them (the `ignore` crate anchors
1842    /// a leading `/` to the walk root), so this is where they take effect.
1843    ///
1844    /// Only absolute patterns are matched against the absolute path. A relative
1845    /// pattern belongs to the root its path is relative to, and one that floats
1846    /// would otherwise match a directory above that root: `docs` would exclude
1847    /// every file of a project checked out at `/home/dev/docs/site`.
1848    ///
1849    /// `absolute` is canonicalized before matching, since an expanded `~`
1850    /// resolves to a canonical location. A file that does not exist (an unsaved
1851    /// buffer, one already deleted) is resolved through its deepest existing
1852    /// ancestor (see [`resolve_for_matching`]).
1853    ///
1854    /// The path as given is tried as well, because resolving can respell a
1855    /// location the pattern names directly: on macOS `/home` canonicalizes to
1856    /// `/System/Volumes/Data/home`, which `/home/dev/**` does not match.
1857    ///
1858    /// A pattern that named its location through a symlink (`/var/folders/**`
1859    /// for a macOS temp directory) never matches that canonical form, so the
1860    /// file's other spellings are tried too (see [`PathAliases`]).
1861    pub fn matched_pattern_for_file(&self, relative: Option<&str>, absolute: &Path) -> Option<&str> {
1862        if let Some(pattern) = relative.and_then(|rel| self.matched_pattern(rel)) {
1863            return Some(pattern);
1864        }
1865        // Resolving the path costs syscalls, which a run with only relative
1866        // patterns has no use for.
1867        if !self.matchers.iter().any(|matcher| matcher.absolute) {
1868            return None;
1869        }
1870        let resolved = resolve_for_matching(absolute);
1871        if let Some(pattern) = self.matched_absolute_pattern(&normalize_pattern_separators(resolved.to_string_lossy()))
1872        {
1873            return Some(pattern);
1874        }
1875        // A rooted path is what an absolute pattern can name: a pattern is
1876        // absolute when it starts with `/` (see `is_absolute_pattern`), and on
1877        // Windows `\home\dev` has a root without being `is_absolute`.
1878        if absolute.has_root()
1879            && resolved != absolute
1880            && let Some(pattern) =
1881                self.matched_absolute_pattern(&normalize_pattern_separators(absolute.to_string_lossy()))
1882        {
1883            return Some(pattern);
1884        }
1885        self.aliases
1886            .spellings_of(&resolved)
1887            .into_iter()
1888            .find_map(|alias| self.matched_absolute_pattern(&alias))
1889    }
1890
1891    /// Whether any pattern matches the file (see [`matched_pattern_for_file`](Self::matched_pattern_for_file)).
1892    pub fn excludes_file(&self, relative: Option<&str>, absolute: &Path) -> bool {
1893        self.matched_pattern_for_file(relative, absolute).is_some()
1894    }
1895}
1896
1897/// Relativize `path` against `base` for exclude-pattern matching,
1898/// canonicalizing both sides so symlinks (e.g. macOS `/tmp`) and Windows
1899/// path-representation differences don't defeat the prefix strip. Returns
1900/// `None` when `path` is not under `base`, or when `base` does not exist.
1901///
1902/// `path` need not exist: it is resolved through its deepest existing ancestor
1903/// (see [`resolve_for_matching`]), so a file about to be created relativizes
1904/// the way it will once it is there.
1905///
1906/// Separators are normalized to `/` on Windows, following the project
1907/// convention for path strings; globset matches either form, but log
1908/// output and assertions see one canonical shape.
1909pub fn path_relative_to(path: &Path, base: &Path) -> Option<String> {
1910    let canonical_base = canonicalize_for_matching(base)?;
1911    let resolved_path = resolve_for_matching(path);
1912    resolved_path.strip_prefix(&canonical_base).ok().map(|rel| {
1913        let rel = rel.to_string_lossy();
1914        if cfg!(windows) {
1915            rel.replace('\\', "/")
1916        } else {
1917            rel.to_string()
1918        }
1919    })
1920}
1921
1922#[cfg(test)]
1923mod tests {
1924    #[test]
1925    fn type_filter_preserves_legacy_final_file_selection() {
1926        use super::*;
1927        let dir = tempfile::tempdir().unwrap();
1928        let root = dir.path();
1929        for name in [
1930            "README.md",
1931            "notes.MD",
1932            "mixed.mArKdOwN",
1933            "page.mdx",
1934            "index.mkd",
1935            "a.mkdn",
1936            "a.mdown",
1937            "a.mdwn",
1938            "a.qmd",
1939            "a.Rmd",
1940            "a.mkdown",
1941            "a.rs",
1942            "a.RS",
1943            "a.json",
1944            "a.tmpl",
1945            "文档.MD",
1946            "no-extension",
1947        ] {
1948            std::fs::write(root.join(name), "body\n").unwrap();
1949        }
1950        for mode in [
1951            LintableFileMode::Markdown,
1952            LintableFileMode::MarkdownAndRust,
1953            LintableFileMode::Any,
1954        ] {
1955            for includes in [vec![], vec!["a.tmpl".to_string()], vec!["*.json".to_string()]] {
1956                let selector = LintablePathSelector::new(Some(root), &includes, mode);
1957                let mut old = ignore::WalkBuilder::new(root);
1958                if mode != LintableFileMode::Any {
1959                    let mut types = ignore::types::TypesBuilder::new();
1960                    types.add_defaults();
1961                    for ext in MARKDOWN_EXTENSIONS {
1962                        types.add("markdown", &any_case_extension_glob(ext)).unwrap();
1963                    }
1964                    types.select("markdown");
1965                    if mode == LintableFileMode::MarkdownAndRust {
1966                        types.add("rustdoc", "*.rs").unwrap();
1967                        types.select("rustdoc");
1968                    }
1969                    for glob in selector.explicit.file_name_globs() {
1970                        types.add("configinclude", type_filter_glob(glob)).unwrap();
1971                    }
1972                    if !selector.explicit.is_empty() {
1973                        types.select("configinclude");
1974                    }
1975                    old.types(types.build().unwrap());
1976                }
1977                let mut new = ignore::WalkBuilder::new(root);
1978                selector.configure_types(&mut new).unwrap();
1979                let collect = |builder: ignore::WalkBuilder| {
1980                    let mut files: Vec<_> = builder
1981                        .build()
1982                        .map(Result::unwrap)
1983                        .filter(|entry| {
1984                            entry.file_type().is_some_and(|kind| kind.is_file()) && selector.keeps(entry.path())
1985                        })
1986                        .map(|entry| entry.path().strip_prefix(root).unwrap().to_path_buf())
1987                        .collect();
1988                    files.sort();
1989                    files
1990                };
1991                assert_eq!(collect(new), collect(old), "{mode:?} {includes:?}");
1992            }
1993        }
1994    }
1995
1996    use super::*;
1997    use std::fs;
1998    use tempfile::tempdir;
1999
2000    #[test]
2001    fn markdown_extensions_match_case_insensitively() {
2002        for ext in ["md", "MD", "Rmd", "rmd", "MarkDown", "qmd", "mdx"] {
2003            assert!(is_markdown_extension(OsStr::new(ext)), "{ext} should match");
2004        }
2005        for ext in ["rs", "txt", "mdq", ""] {
2006            assert!(!is_markdown_extension(OsStr::new(ext)), "{ext} should not match");
2007        }
2008        assert!(has_markdown_extension(Path::new("a/b/README.md")));
2009        assert!(has_markdown_extension(Path::new("notebook.Rmd")));
2010        assert!(!has_markdown_extension(Path::new("no_extension")));
2011        assert!(!has_markdown_extension(Path::new("lib.rs")));
2012    }
2013
2014    #[test]
2015    fn lintable_selector_makes_adapter_capabilities_explicit() {
2016        let dir = tempdir().unwrap();
2017        let root = dir.path();
2018        fs::create_dir_all(root.join("docs")).unwrap();
2019        fs::create_dir_all(root.join("templates")).unwrap();
2020        fs::create_dir_all(root.join("src")).unwrap();
2021        for relative in [
2022            "docs/guide.md",
2023            "docs/notes.txt",
2024            "templates/page.md.jinja",
2025            "src/lib.rs",
2026            "src/upper.RS",
2027        ] {
2028            fs::write(root.join(relative), "content\n").unwrap();
2029        }
2030        let includes = vec![
2031            "docs/**".to_string(),
2032            "templates/**/*.md.jinja".to_string(),
2033            "src/**/*.rs".to_string(),
2034        ];
2035
2036        let markdown = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Markdown);
2037        assert!(markdown.keeps(&root.join("docs/guide.md")));
2038        assert!(markdown.keeps(&root.join("templates/page.md.jinja")));
2039        assert!(!markdown.keeps(&root.join("docs/notes.txt")));
2040        assert!(
2041            !markdown.keeps(&root.join("src/lib.rs")),
2042            "an LSP must not parse a complete Rust source file as Markdown"
2043        );
2044
2045        let rustdoc = LintablePathSelector::new(Some(root), &includes, LintableFileMode::MarkdownAndRust);
2046        assert!(rustdoc.keeps(&root.join("src/lib.rs")));
2047        assert!(!rustdoc.keeps(&root.join("src/upper.RS")));
2048        assert!(!rustdoc.keeps(&root.join("docs/notes.txt")));
2049
2050        let unrestricted = LintablePathSelector::new(Some(root), &includes, LintableFileMode::Any);
2051        assert!(unrestricted.keeps(&root.join("docs/notes.txt")));
2052    }
2053
2054    #[test]
2055    fn workspace_scan_rejects_explicit_rust_includes() {
2056        let dir = tempdir().unwrap();
2057        let root = dir.path().to_path_buf();
2058        fs::create_dir(root.join("src")).unwrap();
2059        fs::write(root.join("src/lib.rs"), "/// # Not a document\n").unwrap();
2060        fs::write(root.join("README.md"), "# Readme\n").unwrap();
2061
2062        let options = MarkdownWalkOptions {
2063            respect_gitignore: false,
2064            skip_vendor_dirs: true,
2065        };
2066        let includes = vec!["src/**/*.rs".to_string()];
2067        let excludes = ExcludeMatchers::new(&[]);
2068        let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
2069
2070        assert!(scan.collect(std::slice::from_ref(&root)).is_empty());
2071        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("src/lib.rs")));
2072    }
2073
2074    #[test]
2075    fn workspace_scan_applies_includes_to_standard_and_explicit_files() {
2076        let dir = tempdir().unwrap();
2077        let root = dir.path().to_path_buf();
2078        fs::create_dir(root.join("docs")).unwrap();
2079        fs::create_dir(root.join("templates")).unwrap();
2080        fs::write(root.join("README.md"), "# Root\n").unwrap();
2081        fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
2082        fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
2083        fs::write(root.join("templates/page.txt"), "not markdown\n").unwrap();
2084
2085        let options = MarkdownWalkOptions {
2086            respect_gitignore: false,
2087            skip_vendor_dirs: true,
2088        };
2089        let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
2090        let excludes = ExcludeMatchers::new(&[]);
2091        let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
2092
2093        // The test creates these names itself, so normalizing separators
2094        // unconditionally is safe and keeps one expected value for every platform.
2095        let names: Vec<String> = scan
2096            .collect(std::slice::from_ref(&root))
2097            .iter()
2098            .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
2099            .collect();
2100        assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
2101
2102        assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.md.jinja")));
2103        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
2104        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("templates/page.txt")));
2105    }
2106
2107    #[test]
2108    fn workspace_scan_selects_the_markdown_beneath_an_included_directory() {
2109        let dir = tempdir().unwrap();
2110        let root = dir.path().to_path_buf();
2111        for path in [
2112            "docs/guide.md",
2113            "docs/sub/deep.md",
2114            "docs/image.png",
2115            "other/docs/x.md",
2116            "README.md",
2117        ] {
2118            fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
2119            fs::write(root.join(path), "# T\n").unwrap();
2120        }
2121        let options = MarkdownWalkOptions {
2122            respect_gitignore: false,
2123            skip_vendor_dirs: true,
2124        };
2125        let excludes = ExcludeMatchers::new(&[]);
2126        let names = |includes: &[&str]| -> Vec<String> {
2127            let includes: Vec<String> = includes.iter().map(ToString::to_string).collect();
2128            let scan = MarkdownWorkspaceScan::new(&options, &includes, &excludes);
2129            let mut names: Vec<String> = scan
2130                .collect(std::slice::from_ref(&root))
2131                .iter()
2132                .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
2133                .collect();
2134            names.sort();
2135            names
2136        };
2137
2138        // A bare name floats, as the same `exclude` does.
2139        assert_eq!(
2140            names(&["docs"]),
2141            ["docs/guide.md", "docs/sub/deep.md", "other/docs/x.md"]
2142        );
2143        assert_eq!(
2144            names(&["docs/"]),
2145            ["docs/guide.md", "docs/sub/deep.md", "other/docs/x.md"]
2146        );
2147        // A pattern with a `/`, or an `./`, names one location.
2148        assert_eq!(names(&["./docs"]), ["docs/guide.md", "docs/sub/deep.md"]);
2149        assert_eq!(names(&["other/docs"]), ["other/docs/x.md"]);
2150        // A comment names nothing.
2151        assert_eq!(names(&["./docs", "#other"]), ["docs/guide.md", "docs/sub/deep.md"]);
2152    }
2153
2154    #[test]
2155    fn workspace_scan_does_not_prune_a_vendor_named_root() {
2156        let dir = tempdir().unwrap();
2157        let root = dir.path().join("target");
2158        fs::create_dir(&root).unwrap();
2159        fs::write(root.join("README.md"), "# Root\n").unwrap();
2160        fs::create_dir(root.join("target")).unwrap();
2161        fs::write(root.join("target/generated.md"), "# Generated\n").unwrap();
2162
2163        let options = MarkdownWalkOptions {
2164            respect_gitignore: false,
2165            skip_vendor_dirs: true,
2166        };
2167        let excludes = ExcludeMatchers::new(&[]);
2168        let scan = MarkdownWorkspaceScan::new(&options, &[], &excludes);
2169
2170        assert_eq!(scan.collect(std::slice::from_ref(&root)), vec![root.join("README.md")]);
2171        assert!(!scan.path_is_ignored(std::slice::from_ref(&root), &root.join("README.md")));
2172        assert!(scan.path_is_ignored(std::slice::from_ref(&root), &root.join("target/generated.md")));
2173    }
2174
2175    #[test]
2176    fn the_type_glob_selects_exactly_what_counts_as_markdown() {
2177        assert_eq!(any_case_extension_glob("md"), "*.[mM][dD]");
2178
2179        // The glob stands in for `is_markdown_extension` inside a walk, so the
2180        // two have to agree on every spelling, not just the lowercase one.
2181        let mut builder = globset::GlobSetBuilder::new();
2182        for ext in MARKDOWN_EXTENSIONS {
2183            builder.add(
2184                globset::GlobBuilder::new(&any_case_extension_glob(ext))
2185                    .literal_separator(true)
2186                    .build()
2187                    .unwrap(),
2188            );
2189        }
2190        let globs = builder.build().unwrap();
2191
2192        for ext in MARKDOWN_EXTENSIONS {
2193            for spelling in [ext.to_ascii_lowercase(), ext.to_ascii_uppercase(), capitalize(ext)] {
2194                let name = format!("README.{spelling}");
2195                assert!(
2196                    globs.is_match(&name),
2197                    "{name} is markdown by extension but no type glob selects it"
2198                );
2199                assert!(is_markdown_extension(OsStr::new(&spelling)), "{spelling} should match");
2200            }
2201        }
2202
2203        // Control: the glob widens case, not the extension set.
2204        for name in ["lib.rs", "notes.txt", "README.mdq", "README.m"] {
2205            assert!(!globs.is_match(name), "{name} should not be selected");
2206        }
2207    }
2208
2209    fn capitalize(ext: &str) -> String {
2210        let mut chars = ext.chars();
2211        match chars.next() {
2212            Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
2213            None => String::new(),
2214        }
2215    }
2216
2217    #[test]
2218    fn walk_includes_hidden_files() {
2219        let temp = tempdir().unwrap();
2220        fs::create_dir_all(temp.path().join(".github")).unwrap();
2221        fs::write(temp.path().join(".github/PULL_REQUEST_TEMPLATE.md"), "# hi").unwrap();
2222        fs::write(temp.path().join("README.md"), "# hi").unwrap();
2223
2224        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
2225            .build()
2226            .flatten()
2227            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2228            .map(|e| e.path().to_path_buf())
2229            .collect();
2230        assert!(files.iter().any(|p| p.ends_with(".github/PULL_REQUEST_TEMPLATE.md")));
2231        assert!(files.iter().any(|p| p.ends_with("README.md")));
2232    }
2233
2234    #[test]
2235    fn walk_honors_gitignore_when_enabled_only() {
2236        let temp = tempdir().unwrap();
2237        fs::write(temp.path().join(".gitignore"), "ignored.md\n").unwrap();
2238        fs::write(temp.path().join("ignored.md"), "# hi").unwrap();
2239        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
2240
2241        let walk = |respect: bool| -> Vec<std::path::PathBuf> {
2242            markdown_walk_builder(
2243                temp.path(),
2244                &MarkdownWalkOptions {
2245                    respect_gitignore: respect,
2246                    ..Default::default()
2247                },
2248            )
2249            .build()
2250            .flatten()
2251            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2252            .map(|e| e.path().to_path_buf())
2253            .collect()
2254        };
2255
2256        let respected = walk(true);
2257        assert!(!respected.iter().any(|p| p.ends_with("ignored.md")));
2258        assert!(respected.iter().any(|p| p.ends_with("kept.md")));
2259
2260        let unrespected = walk(false);
2261        assert!(unrespected.iter().any(|p| p.ends_with("ignored.md")));
2262    }
2263
2264    #[test]
2265    fn a_gitignore_above_the_repository_root_stays_outside_it() {
2266        let temp = tempdir().unwrap();
2267        fs::write(temp.path().join(".gitignore"), "*.md\n").unwrap();
2268        let repo = temp.path().join("repo");
2269        fs::create_dir_all(repo.join(".git")).unwrap();
2270        fs::write(repo.join("kept.md"), "# hi").unwrap();
2271
2272        let walk = |root: &Path| -> Vec<std::path::PathBuf> {
2273            markdown_walk_builder(root, &MarkdownWalkOptions::default())
2274                .build()
2275                .flatten()
2276                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2277                .map(|e| e.path().to_path_buf())
2278                .collect()
2279        };
2280
2281        assert!(
2282            walk(&repo).iter().any(|p| p.ends_with("kept.md")),
2283            "git reads no gitignore above the repository root, so neither does the walk"
2284        );
2285
2286        // Control: outside a repository there is no root to stop at, and the
2287        // ignore files above are all the walk has to go on.
2288        fs::remove_dir(repo.join(".git")).unwrap();
2289        assert!(
2290            !walk(&repo).iter().any(|p| p.ends_with("kept.md")),
2291            "with no repository to bound it, the walk keeps reading upward"
2292        );
2293    }
2294
2295    #[test]
2296    fn the_repository_boundary_needs_every_root_to_have_one() {
2297        let temp = tempdir().unwrap();
2298        let inside = temp.path().join("repo/docs");
2299        fs::create_dir_all(&inside).unwrap();
2300        fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
2301        let outside = temp.path().join("plain");
2302        fs::create_dir_all(&outside).unwrap();
2303
2304        assert!(stops_at_repository_root(&[&inside]), "a root under a repository root");
2305        assert!(!stops_at_repository_root(&[&outside]), "a root under no repository");
2306
2307        // A walk has one setting for all of its roots. Bounding this one would
2308        // strip the outside root of gitignore handling altogether, which is a
2309        // worse answer than reading one file too many.
2310        assert!(!stops_at_repository_root(&[inside.as_path(), outside.as_path()]));
2311        assert!(!stops_at_repository_root(&[] as &[&Path]), "no root is no repository");
2312
2313        // A worktree and a submodule mark their root with a `.git` file rather
2314        // than a directory, and both are still repository roots.
2315        let worktree = temp.path().join("worktree");
2316        fs::create_dir_all(&worktree).unwrap();
2317        fs::write(worktree.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
2318        assert!(stops_at_repository_root(&[&worktree]));
2319    }
2320
2321    #[test]
2322    fn walk_honors_markdownlintignore() {
2323        let temp = tempdir().unwrap();
2324        fs::write(temp.path().join(".markdownlintignore"), "legacy.md\n").unwrap();
2325        fs::write(temp.path().join("legacy.md"), "# hi").unwrap();
2326        fs::write(temp.path().join("kept.md"), "# hi").unwrap();
2327
2328        let files: Vec<_> = markdown_walk_builder(temp.path(), &MarkdownWalkOptions::default())
2329            .build()
2330            .flatten()
2331            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2332            .map(|e| e.path().to_path_buf())
2333            .collect();
2334        assert!(!files.iter().any(|p| p.ends_with("legacy.md")));
2335        assert!(files.iter().any(|p| p.ends_with("kept.md")));
2336    }
2337
2338    #[test]
2339    fn vendor_dirs_skipped_only_when_requested() {
2340        let temp = tempdir().unwrap();
2341        for dir in ["node_modules", "target", "src"] {
2342            fs::create_dir_all(temp.path().join(dir)).unwrap();
2343            fs::write(temp.path().join(dir).join("doc.md"), "# hi").unwrap();
2344        }
2345
2346        let walk = |skip: bool| -> Vec<std::path::PathBuf> {
2347            markdown_walk_builder(
2348                temp.path(),
2349                &MarkdownWalkOptions {
2350                    skip_vendor_dirs: skip,
2351                    // Disable gitignore handling so ambient .gitignore files in the
2352                    // temp directory's ancestry cannot mask the vendor-dir filtering
2353                    // this test exercises.
2354                    respect_gitignore: false,
2355                },
2356            )
2357            .build()
2358            .flatten()
2359            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2360            .map(|e| e.path().to_path_buf())
2361            .collect()
2362        };
2363
2364        let skipped = walk(true);
2365        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
2366        assert!(!skipped.iter().any(|p| p.to_string_lossy().contains("target")));
2367        assert!(skipped.iter().any(|p| p.ends_with("src/doc.md")));
2368
2369        let unskipped = walk(false);
2370        assert!(unskipped.iter().any(|p| p.to_string_lossy().contains("node_modules")));
2371    }
2372
2373    #[test]
2374    fn explicit_file_name_glob_extracts_literal_extensions() {
2375        assert_eq!(explicit_file_name_glob("**/*.md.jinja"), Some("*.md.jinja"));
2376        assert_eq!(explicit_file_name_glob("*.md.jinja"), Some("*.md.jinja"));
2377        assert_eq!(explicit_file_name_glob("docs/*.txt"), Some("*.txt"));
2378        assert_eq!(explicit_file_name_glob("templates/NOTES.tmpl"), Some("NOTES.tmpl"));
2379        assert_eq!(explicit_file_name_glob("*.md"), Some("*.md"));
2380        assert_eq!(explicit_file_name_glob("a/b/c/*.md.tmpl"), Some("*.md.tmpl"));
2381    }
2382
2383    #[test]
2384    fn explicit_file_name_glob_rejects_unpinned_patterns() {
2385        for pattern in [
2386            "docs/",
2387            "docs/**",
2388            "docs",
2389            "*",
2390            "**",
2391            "**/*",
2392            "*.*",
2393            "*.md*",
2394            "*.{md,jinja}",
2395            "*.md?",
2396            "data.[ch]",
2397            "!drafts/*.md.jinja",
2398            "",
2399            "**/Makefile",
2400            "*.",
2401        ] {
2402            assert_eq!(explicit_file_name_glob(pattern), None, "{pattern:?} should not qualify");
2403        }
2404    }
2405
2406    #[test]
2407    fn explicit_include_matchers_match_full_relative_paths() {
2408        let matchers = ExplicitIncludeMatchers::new(&[
2409            "**/*.md.jinja".to_string(),
2410            "docs/**".to_string(),
2411            "templates/NOTES.tmpl".to_string(),
2412        ]);
2413        assert!(!matchers.is_empty());
2414        assert!(matchers.matches_relative_path("test.md.jinja"));
2415        assert!(matchers.matches_relative_path("a/b/test.md.jinja"));
2416        assert!(matchers.matches_relative_path("templates/NOTES.tmpl"));
2417        // The directory pattern must not widen the filter to arbitrary files.
2418        assert!(!matchers.matches_relative_path("docs/anything.txt"));
2419        assert!(!matchers.matches_relative_path("test.jinja"));
2420        // A broad sibling pattern must not inherit the literal pattern's
2421        // allowance for files that merely share its name.
2422        assert!(!matchers.matches_relative_path("docs/NOTES.tmpl"));
2423        assert!(!matchers.matches_relative_path("x/templates/NOTES.tmpl"));
2424
2425        let globs: Vec<_> = matchers.file_name_globs().collect();
2426        assert_eq!(globs, vec!["*.md.jinja", "NOTES.tmpl"]);
2427    }
2428
2429    #[test]
2430    fn explicit_include_matchers_follow_gitignore_anchoring() {
2431        // No slash: matches at any depth.
2432        let unanchored = ExplicitIncludeMatchers::new(&["*.md.jinja".to_string()]);
2433        assert!(unanchored.matches_relative_path("test.md.jinja"));
2434        assert!(unanchored.matches_relative_path("a/b/test.md.jinja"));
2435
2436        // Slash: anchored to the root, and `*` does not cross separators.
2437        let anchored = ExplicitIncludeMatchers::new(&["docs/*.txt".to_string()]);
2438        assert!(anchored.matches_relative_path("docs/a.txt"));
2439        assert!(!anchored.matches_relative_path("docs/sub/a.txt"));
2440        assert!(!anchored.matches_relative_path("other/docs/a.txt"));
2441
2442        // Leading slash: anchored, slash stripped for matching.
2443        let rooted = ExplicitIncludeMatchers::new(&["/NOTES.tmpl".to_string()]);
2444        assert!(rooted.matches_relative_path("NOTES.tmpl"));
2445        assert!(!rooted.matches_relative_path("docs/NOTES.tmpl"));
2446    }
2447
2448    #[test]
2449    fn explicit_include_matchers_empty_for_directory_and_wildcard_patterns() {
2450        let matchers = ExplicitIncludeMatchers::new(&["docs/".to_string(), "**/*".to_string()]);
2451        assert!(matchers.is_empty());
2452        assert!(!matchers.matches_relative_path("x.md.jinja"));
2453    }
2454
2455    #[test]
2456    fn explicit_include_matchers_skip_invalid_globs() {
2457        // The unclosed bracket pins a literal `.tmpl` suffix but fails glob
2458        // compilation; it must be skipped without poisoning valid patterns.
2459        let matchers = ExplicitIncludeMatchers::new(&["bad[.tmpl".to_string(), "**/*.md.jinja".to_string()]);
2460        assert!(matchers.matches_relative_path("ok.md.jinja"));
2461        assert_eq!(matchers.file_name_globs().collect::<Vec<_>>(), vec!["*.md.jinja"]);
2462    }
2463
2464    #[test]
2465    fn exclude_matchers_expand_directory_patterns() {
2466        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "*.tmp.md".to_string()]);
2467        assert!(matchers.is_match("drafts"));
2468        assert!(
2469            matchers.is_match("drafts/inner.md"),
2470            "directory pattern must match contents"
2471        );
2472        assert!(matchers.is_match("note.tmp.md"));
2473        assert!(!matchers.is_match("docs/guide.md"));
2474        assert_eq!(matchers.matched_pattern("drafts/inner.md"), Some("drafts/**"));
2475        assert!(matchers.invalid.is_empty());
2476    }
2477
2478    #[test]
2479    fn exclude_matchers_float_a_pattern_without_a_slash_to_any_depth() {
2480        let matchers =
2481            ExcludeMatchers::new(&["node_modules".to_string(), "build/".to_string(), "notes.md".to_string()]);
2482        for path in [
2483            "node_modules/readme.md",
2484            "packages/app/node_modules/readme.md",
2485            "build/out.md",
2486            "site/build/out.md",
2487            "notes.md",
2488            "docs/notes.md",
2489        ] {
2490            assert!(matchers.is_match(path), "{path} must be excluded");
2491        }
2492        for path in ["node_modules.md", "docs/my-notes.md", "rebuild/out.md", "docs/guide.md"] {
2493            assert!(!matchers.is_match(path), "{path} must not be excluded");
2494        }
2495        // The notice names the pattern as expanded, not the glob that floated it.
2496        assert_eq!(
2497            matchers.matched_pattern("packages/app/node_modules/readme.md"),
2498            Some("node_modules/**")
2499        );
2500    }
2501
2502    #[test]
2503    fn exclude_matchers_keep_a_pattern_containing_a_slash_anchored() {
2504        let matchers = ExcludeMatchers::new(&["docs/generated".to_string(), "sub/*.md".to_string()]);
2505        assert!(matchers.is_match("docs/generated/api.md"));
2506        assert!(!matchers.is_match("site/docs/generated/api.md"));
2507        assert!(matchers.is_match("sub/a.md"));
2508        assert!(!matchers.is_match("other/sub/a.md"));
2509    }
2510
2511    #[test]
2512    fn exclude_matchers_exclude_the_contents_of_a_directory_a_wildcard_matches() {
2513        let matchers = ExcludeMatchers::new(&["draft?".to_string(), "sub/[gh]en".to_string()]);
2514        assert!(matchers.is_match("drafts/note.md"));
2515        assert!(
2516            matchers.is_match("blog/drafts/note.md"),
2517            "a slashless wildcard floats too"
2518        );
2519        assert!(matchers.is_match("sub/gen/x.md"));
2520        assert!(matchers.is_match("sub/hen/deep/x.md"));
2521        assert!(!matchers.is_match("sub/pen/x.md"));
2522        assert!(!matchers.is_match("other/sub/gen/x.md"));
2523    }
2524
2525    #[test]
2526    fn exclude_matchers_report_an_invalid_pattern_once() {
2527        let matchers = ExcludeMatchers::new(&["[".to_string(), "docs".to_string()]);
2528        assert_eq!(matchers.invalid.len(), 1, "{:?}", matchers.invalid);
2529        assert!(matchers.is_match("docs/a.md"));
2530    }
2531
2532    #[test]
2533    fn expand_home_prefix_expands_only_a_leading_tilde() {
2534        let home = Path::new("/home/dev");
2535        assert_eq!(
2536            expand_home_prefix_impl("~/.cursor/plans", Some(home)),
2537            "/home/dev/.cursor/plans"
2538        );
2539        assert_eq!(expand_home_prefix_impl("~", Some(home)), "/home/dev");
2540        assert_eq!(expand_home_prefix_impl("~/", Some(home)), "/home/dev");
2541    }
2542
2543    #[test]
2544    fn expand_home_prefix_leaves_interior_tildes_alone() {
2545        let home = Path::new("/home/dev");
2546        // `~` is a legal filename character; only a leading `~/` is a home reference.
2547        for pattern in ["backup.md~", "docs/~drafts/**", "~user/docs", "**/*~", "!~/secret"] {
2548            assert_eq!(
2549                expand_home_prefix_impl(pattern, Some(home)),
2550                pattern,
2551                "{pattern:?} must be left as written"
2552            );
2553        }
2554    }
2555
2556    #[test]
2557    fn expand_home_prefix_without_a_home_leaves_the_pattern_as_written() {
2558        assert_eq!(expand_home_prefix_impl("~/.cursor/plans", None), "~/.cursor/plans");
2559    }
2560
2561    #[test]
2562    fn normalize_pattern_for_base_rewrites_absolute_patterns_under_the_base() {
2563        let temp = tempdir().unwrap();
2564        // Canonicalize the way production does, so the pattern has the shape an
2565        // expanded `~` produces (on Windows that means no verbatim prefix).
2566        let base = canonicalize_for_matching(temp.path()).unwrap();
2567        let pattern = format!("{}/docs/**", base.to_string_lossy().replace('\\', "/"));
2568        assert_eq!(normalize_pattern_for_base(&pattern, Some(&base)), "docs/**");
2569    }
2570
2571    #[test]
2572    fn normalize_pattern_for_base_strips_through_a_non_canonical_base() {
2573        // The base as handed to us (a symlinked `/var` on macOS, a Windows 8.3
2574        // short name) must still strip.
2575        let temp = tempdir().unwrap();
2576        let canonical = canonicalize_for_matching(temp.path()).unwrap();
2577        let pattern = format!("{}/docs/**", canonical.to_string_lossy().replace('\\', "/"));
2578        assert_eq!(normalize_pattern_for_base(&pattern, Some(temp.path())), "docs/**");
2579    }
2580
2581    #[test]
2582    fn normalize_pattern_for_base_leaves_other_patterns_alone() {
2583        let temp = tempdir().unwrap();
2584        let base = canonicalize_for_matching(temp.path()).unwrap();
2585        // Relative patterns are already base-relative.
2586        assert_eq!(normalize_pattern_for_base("docs/**", Some(&base)), "docs/**");
2587        // An absolute pattern outside the base stays absolute: nothing under
2588        // this walk can match it, which is the correct outcome.
2589        assert_eq!(
2590            normalize_pattern_for_base("/somewhere/else/**", Some(&base)),
2591            "/somewhere/else/**"
2592        );
2593        // With no base there is nothing to rewrite against.
2594        assert_eq!(normalize_pattern_for_base("/abs/docs/**", None), "/abs/docs/**");
2595    }
2596
2597    #[test]
2598    fn strip_verbatim_prefix_unwraps_windows_canonical_paths() {
2599        // The exact shape `canonicalize` returns on Windows. Left unstripped it
2600        // normalizes to `//?/C:/...`, which matches nothing.
2601        assert_eq!(
2602            strip_verbatim_prefix(r"\\?\C:\Users\dev\AppData\Local\Temp\x"),
2603            r"C:\Users\dev\AppData\Local\Temp\x"
2604        );
2605        assert_eq!(strip_verbatim_prefix(r"\\?\C:\"), r"C:\");
2606        // UNC shares unwrap to their ordinary `\\server\share` form.
2607        assert_eq!(
2608            strip_verbatim_prefix(r"\\?\UNC\server\share\docs"),
2609            r"\\server\share\docs"
2610        );
2611    }
2612
2613    #[test]
2614    fn strip_verbatim_prefix_unwraps_the_display_form() {
2615        // The same paths after display normalization has turned `\` into `/`.
2616        assert_eq!(
2617            strip_verbatim_prefix("//?/C:/Users/dev/AppData/Local/Temp/x"),
2618            "C:/Users/dev/AppData/Local/Temp/x"
2619        );
2620        assert_eq!(strip_verbatim_prefix("//?/C:/"), "C:/");
2621        assert_eq!(
2622            strip_verbatim_prefix("//?/UNC/server/share/docs"),
2623            "//server/share/docs"
2624        );
2625    }
2626
2627    #[test]
2628    fn strip_verbatim_prefix_leaves_other_paths_alone() {
2629        for path in [
2630            "/home/dev/docs",
2631            r"C:\Users\dev",
2632            "C:/Users/dev",
2633            r"\\server\share",
2634            "//server/share",
2635            // A device namespace has no ordinary equivalent to unwrap to.
2636            r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\docs",
2637            "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/docs",
2638            r"\\?\",
2639            "//?/",
2640            // A prefix written in one separator does not unwrap in the other.
2641            r"\\?/C:/Users/dev",
2642            "",
2643        ] {
2644            assert_eq!(strip_verbatim_prefix(path), path, "{path:?} must be left as written");
2645        }
2646    }
2647
2648    #[test]
2649    fn a_negated_exclude_pattern_is_invalid_and_matches_nothing() {
2650        let matchers = ExcludeMatchers::new(&[
2651            "!docs/a.md".to_string(),
2652            "!".to_string(),
2653            "!docs/a.md".to_string(),
2654            "docs/b.md".to_string(),
2655        ]);
2656        let reported: Vec<&str> = matchers.invalid.iter().map(|(pattern, _)| pattern.as_str()).collect();
2657        assert_eq!(reported, ["!docs/a.md", "!"], "each distinct negation once");
2658        assert!(
2659            matchers
2660                .invalid
2661                .iter()
2662                .all(|(_, error)| error == EXCLUDE_NEGATION_UNSUPPORTED)
2663        );
2664        assert!(!matchers.is_match("docs/a.md"));
2665        assert!(!matchers.is_match("!"));
2666        assert!(!matchers.is_match("!docs/a.md"));
2667        assert!(
2668            matchers.is_match("docs/b.md"),
2669            "an ordinary pattern beside it still applies"
2670        );
2671    }
2672
2673    #[test]
2674    #[cfg(not(windows))]
2675    fn an_escaped_leading_bang_is_a_literal_exclude() {
2676        let matchers = ExcludeMatchers::new(&[r"\!bang.md".to_string()]);
2677        assert!(matchers.invalid.is_empty(), "{:?}", matchers.invalid);
2678        assert!(matchers.is_match("!bang.md"));
2679        assert!(exclude_pattern_compiles(r"\!bang.md"));
2680        assert!(!is_exclude_negation(r"\!bang.md"));
2681    }
2682
2683    #[test]
2684    #[cfg(not(windows))]
2685    fn an_escaped_leading_bang_preserves_subsequent_escapes() {
2686        for pattern in [r"\!foo\[bar].md", r"\!foo\[bar\].md"] {
2687            let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
2688            assert!(matchers.invalid.is_empty(), "{pattern}: {:?}", matchers.invalid);
2689            assert!(matchers.is_match("!foo[bar].md"), "{pattern}");
2690            assert!(matchers.is_match("docs/!foo[bar].md"), "{pattern}");
2691            assert!(!matchers.is_match("!foo/b.md"), "{pattern}");
2692            assert!(!matchers.is_match("!foob.md"), "{pattern}");
2693        }
2694    }
2695
2696    /// Off Windows the escape reading must not depend on the platform default of
2697    /// `globset`; on Windows a `\` is a separator (see
2698    /// `windows_reads_a_backslash_as_a_separator_and_unix_as_an_escape`).
2699    #[test]
2700    #[cfg(not(windows))]
2701    fn exclude_glob_reads_a_backslash_as_an_escape_off_windows() {
2702        let escaped = exclude_glob(r"\!bang.md").unwrap().compile_matcher();
2703        assert!(escaped.is_match("!bang.md"));
2704        assert!(!escaped.is_match(r"\!bang.md"));
2705
2706        // The reading the Windows default produces: no escape, so the pattern
2707        // does not name `!bang.md`. This is what `exclude_glob` must not inherit.
2708        let unescaped = GlobBuilder::new(r"\!bang.md")
2709            .literal_separator(true)
2710            .backslash_escape(false)
2711            .build()
2712            .unwrap()
2713            .compile_matcher();
2714        assert!(!unescaped.is_match("!bang.md"));
2715    }
2716
2717    /// The post-walk matchers and the walker's `ignore` overrides read an
2718    /// escaped pattern the same way, so a name is excluded by both or by neither.
2719    #[test]
2720    #[cfg(not(windows))]
2721    fn exclude_matchers_and_walker_overrides_agree_on_escaped_patterns() {
2722        let names = ["!bang.md", "[x].md", "x.md", "a*b.md", "axb.md", r"\!bang.md"];
2723        for pattern in [r"\!bang.md", r"\[x\].md", r"a\*b.md"] {
2724            let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
2725            assert!(matchers.invalid.is_empty(), "{pattern}: {:?}", matchers.invalid);
2726            let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
2727            builder.add(&exclude_override_rule(pattern)).unwrap();
2728            let walker = builder.build().unwrap();
2729            for name in names {
2730                let by_walker = walker.matched(Path::new("/root").join(name), false).is_ignore();
2731                assert_eq!(
2732                    matchers.is_match(name),
2733                    by_walker,
2734                    "pattern {pattern} on {name}: matchers vs walker"
2735                );
2736            }
2737            assert!(
2738                matchers.is_match(names[0]) || pattern != r"\!bang.md",
2739                "the escaped bang names the literal file"
2740            );
2741        }
2742    }
2743
2744    #[test]
2745    fn exclude_pattern_compiles_rejects_a_negation() {
2746        assert!(!exclude_pattern_compiles("!x"));
2747        assert!(!exclude_pattern_compiles("!"));
2748        assert!(exclude_pattern_compiles("x"));
2749    }
2750
2751    #[test]
2752    fn expand_directory_pattern_expands_a_literal_final_component() {
2753        // A glob earlier in the pattern must not block contents-expansion: the
2754        // final component names a directory, so its contents are excluded too.
2755        assert_eq!(
2756            expand_directory_pattern("**/.cursor/plans"),
2757            vec!["**/.cursor/plans", "**/.cursor/plans/**"]
2758        );
2759        assert_eq!(
2760            expand_directory_pattern("docs/**/drafts"),
2761            vec!["docs/**/drafts", "docs/**/drafts/**"]
2762        );
2763        // Alternation names literal directories, so it keeps its expansion.
2764        assert_eq!(
2765            expand_directory_pattern("logs/{a,b}"),
2766            vec!["logs/{a,b}", "logs/{a,b}/**"]
2767        );
2768    }
2769
2770    #[test]
2771    fn expand_include_directory_adds_the_contents_with_the_patterns_own_anchoring() {
2772        // A bare name floats, so its contents are matched at any depth.
2773        assert_eq!(expand_include_directory("docs"), ["docs", "**/docs/**"]);
2774        // The pattern itself stays as written, so a trailing `/` keeps it directory-only.
2775        assert_eq!(expand_include_directory("docs/"), ["docs/", "**/docs/**"]);
2776        // A `/` anywhere but the end, or a leading one, anchors the contents too.
2777        assert_eq!(expand_include_directory("a/docs"), ["a/docs", "a/docs/**"]);
2778        assert_eq!(expand_include_directory("/docs"), ["/docs", "/docs/**"]);
2779        assert_eq!(expand_include_directory("/abs/docs/"), ["/abs/docs/", "/abs/docs/**"]);
2780        assert_eq!(expand_include_directory("**/docs"), ["**/docs", "**/docs/**"]);
2781    }
2782
2783    #[test]
2784    fn expand_include_directory_leaves_other_patterns_alone() {
2785        for pattern in [
2786            "*.md",
2787            "docs/*",
2788            "docs/**",
2789            "docs/**/",
2790            "d?cs",
2791            "docs/[ab]",
2792            "docs/{public,private}",
2793            "!drafts",
2794            "#docs",
2795            "docs\\",
2796            "",
2797            " ",
2798            "\t",
2799        ] {
2800            assert_eq!(expand_include_directory(pattern), [pattern], "{pattern:?}");
2801        }
2802    }
2803
2804    #[test]
2805    fn expand_directory_pattern_leaves_a_wildcard_final_component_alone() {
2806        // `docs/*` names direct children only; expanding it to `docs/*/**` would
2807        // newly exclude nested contents.
2808        for pattern in ["docs/*", "*.tmp.md", "build/**", "data.[ch]", "notes?"] {
2809            assert_eq!(
2810                expand_directory_pattern(pattern),
2811                vec![pattern.to_string()],
2812                "{pattern:?} must not gain a contents-expansion"
2813            );
2814        }
2815    }
2816
2817    #[test]
2818    fn exclude_matchers_match_an_absolute_pattern_against_an_absolute_path() {
2819        let matchers = ExcludeMatchers::new(&["/home/dev/.cursor/plans".to_string()]);
2820        let excluded = Path::new("/home/dev/.cursor/plans/plan.md");
2821        assert!(
2822            matchers.excludes_file(None, excluded),
2823            "an absolute pattern must match the absolute path when there is no relative form"
2824        );
2825        assert_eq!(
2826            matchers.matched_pattern_for_file(None, excluded),
2827            Some("/home/dev/.cursor/plans/**")
2828        );
2829        // A file inside a project root still has a relative form; the absolute
2830        // pattern must match it through the absolute path.
2831        assert!(matchers.excludes_file(Some(".cursor/plans/plan.md"), excluded));
2832        assert!(!matchers.excludes_file(Some("docs/guide.md"), Path::new("/home/dev/docs/guide.md")));
2833    }
2834
2835    #[test]
2836    fn exclude_matchers_do_not_let_relative_patterns_match_absolute_paths() {
2837        let matchers = ExcludeMatchers::new(&["drafts".to_string()]);
2838        assert!(!matchers.excludes_file(None, Path::new("/home/dev/proj/drafts/note.md")));
2839        assert!(matchers.excludes_file(Some("drafts/note.md"), Path::new("/home/dev/proj/drafts/note.md")));
2840
2841        // An absolute pattern makes the absolute path worth matching, and the
2842        // floating `drafts` must still not match a directory above the root.
2843        let matchers = ExcludeMatchers::new(&["drafts".to_string(), "/unrelated".to_string()]);
2844        let note = Path::new("/home/dev/drafts/site/note.md");
2845        assert_eq!(matchers.matched_pattern_for_file(None, note), None);
2846        assert_eq!(matchers.matched_pattern_for_file(Some("note.md"), note), None);
2847        assert_eq!(
2848            matchers.matched_pattern_for_file(Some("drafts/note.md"), Path::new("/home/dev/site/drafts/note.md")),
2849            Some("drafts/**")
2850        );
2851        assert_eq!(
2852            matchers.matched_pattern_for_file(None, Path::new("/unrelated/note.md")),
2853            Some("/unrelated/**")
2854        );
2855    }
2856
2857    #[test]
2858    fn exclude_matchers_match_a_wildcard_within_one_path_component() {
2859        let matchers = ExcludeMatchers::new(&["sub/a*b".to_string(), "notes/a?b.md".to_string()]);
2860        assert!(matchers.is_match("sub/aXb/y.md"));
2861        assert!(matchers.is_match("sub/ab/y.md"));
2862        assert!(!matchers.is_match("sub/a/keep/b/x.md"));
2863        assert!(matchers.is_match("notes/aXb.md"));
2864        assert!(!matchers.is_match("notes/a/b.md"));
2865
2866        let matchers = ExcludeMatchers::new(&["docs/**/draft.md".to_string()]);
2867        assert!(matchers.is_match("docs/draft.md"));
2868        assert!(matchers.is_match("docs/a/b/draft.md"));
2869    }
2870
2871    #[test]
2872    fn exclude_matchers_report_invalid_patterns() {
2873        let matchers = ExcludeMatchers::new(&["[".to_string(), "ok.md".to_string()]);
2874        assert_eq!(matchers.invalid.len(), 1);
2875        assert_eq!(matchers.invalid[0].0, "[");
2876        assert!(matchers.is_match("ok.md"));
2877    }
2878
2879    #[test]
2880    fn path_relative_to_strips_through_symlinked_base() {
2881        let temp = tempdir().unwrap();
2882        let base = temp.path().join("base");
2883        fs::create_dir_all(base.join("docs")).unwrap();
2884        fs::write(base.join("docs/a.md"), "# hi").unwrap();
2885
2886        assert_eq!(
2887            path_relative_to(&base.join("docs/a.md"), &base).as_deref(),
2888            Some("docs/a.md")
2889        );
2890        assert_eq!(
2891            path_relative_to(&base.join("docs/a.md"), &base.join("docs")).as_deref(),
2892            Some("a.md")
2893        );
2894        assert_eq!(path_relative_to(temp.path(), &base), None, "path outside base");
2895        assert_eq!(
2896            path_relative_to(&base.join("docs/new/ghost.md"), &base).as_deref(),
2897            Some("docs/new/ghost.md"),
2898            "a file that does not exist yet relativizes the way it will once written"
2899        );
2900        assert_eq!(
2901            path_relative_to(&base.join("docs/a.md"), &base.join("missing")),
2902            None,
2903            "a base that does not exist contains nothing"
2904        );
2905    }
2906
2907    #[test]
2908    fn resolve_for_matching_spells_a_missing_file_the_way_it_will_canonicalize() {
2909        let temp = tempdir().unwrap();
2910        let root = canonicalize_for_matching(temp.path()).unwrap();
2911        fs::create_dir_all(root.join("docs")).unwrap();
2912        fs::write(root.join("docs").join("x.md"), "# x").unwrap();
2913
2914        // `temp.path()` is not canonical on macOS (`/var` -> `/private/var`), so
2915        // every row below also proves the existing ancestor was canonicalized.
2916        let cases = [
2917            ("docs/x.md", root.join("docs").join("x.md")),
2918            ("docs/ghost.md", root.join("docs").join("ghost.md")),
2919            (
2920                "docs/new/dir/ghost.md",
2921                root.join("docs").join("new").join("dir").join("ghost.md"),
2922            ),
2923            ("missing/../docs/./x.md", root.join("docs").join("x.md")),
2924            ("docs/missing/../../top.md", root.join("top.md")),
2925        ];
2926        for (relative, expected) in cases {
2927            assert_eq!(
2928                resolve_for_matching(&temp.path().join(relative)),
2929                expected,
2930                "{relative}"
2931            );
2932        }
2933    }
2934
2935    #[test]
2936    fn resolve_for_matching_takes_a_relative_path_from_the_working_directory() {
2937        let cwd = canonicalize_for_matching(&std::env::current_dir().unwrap()).unwrap();
2938        assert_eq!(
2939            resolve_for_matching(Path::new("no-such-dir-for-matching/ghost.md")),
2940            cwd.join("no-such-dir-for-matching").join("ghost.md")
2941        );
2942        assert_eq!(resolve_for_matching(Path::new("./Cargo.toml")), cwd.join("Cargo.toml"));
2943    }
2944
2945    #[cfg(unix)]
2946    #[test]
2947    fn resolve_for_matching_follows_a_symlink_reached_through_a_missing_directory() {
2948        let temp = tempdir().unwrap();
2949        let root = canonicalize_for_matching(temp.path()).unwrap();
2950        fs::create_dir_all(root.join("real")).unwrap();
2951        std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
2952
2953        assert_eq!(
2954            resolve_for_matching(&root.join("link").join("ghost.md")),
2955            root.join("real").join("ghost.md")
2956        );
2957        // Lexically `missing/../link/ghost.md` is `link/ghost.md`, and `link`
2958        // is only resolved by looking again once the `..` is gone.
2959        assert_eq!(
2960            resolve_for_matching(&root.join("missing").join("..").join("link").join("ghost.md")),
2961            root.join("real").join("ghost.md")
2962        );
2963    }
2964
2965    fn sorted(mut patterns: Vec<String>) -> Vec<String> {
2966        patterns.sort();
2967        patterns
2968    }
2969
2970    #[test]
2971    fn expand_braces_yields_every_alternative() {
2972        assert_eq!(
2973            sorted(expand_braces("/{var/folders,tmp}/**")),
2974            vec!["/tmp/**", "/var/folders/**"]
2975        );
2976        // Nesting expands too.
2977        assert_eq!(sorted(expand_braces("a{b,{c,d}}e")), vec!["abe", "ace", "ade"]);
2978        // An empty alternative is dropped, as globset drops it.
2979        assert_eq!(sorted(expand_braces("x{,y}")), vec!["xy"]);
2980        // Several groups multiply out.
2981        assert_eq!(
2982            sorted(expand_braces("/{a,b}/{c,d}.md")),
2983            vec!["/a/c.md", "/a/d.md", "/b/c.md", "/b/d.md"]
2984        );
2985    }
2986
2987    #[test]
2988    fn expand_braces_leaves_patterns_it_cannot_split() {
2989        // Nothing to split.
2990        assert_eq!(expand_braces("/var/folders/**"), vec!["/var/folders/**"]);
2991        // An unclosed brace is not an alternation.
2992        assert_eq!(expand_braces("/var/{a,b/**"), vec!["/var/{a,b/**"]);
2993        // A comma inside a character class is literal.
2994        assert_eq!(expand_braces("/var/[a,b]/**"), vec!["/var/[a,b]/**"]);
2995        // An alternation of nothing but empty alternatives is not a split.
2996        assert_eq!(expand_braces("x{,}"), vec!["x{,}"]);
2997        // Past the expansion cap the pattern is left alone: 2^7 = 128 > 64.
2998        let wide = "/{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/**";
2999        assert_eq!(expand_braces(wide), vec![wide]);
3000    }
3001
3002    #[test]
3003    fn has_absolute_spelling_sees_past_a_leading_alternation() {
3004        assert!(has_absolute_spelling("/var/folders/**"));
3005        assert!(has_absolute_spelling("{/opt,/srv}/docs/**"));
3006        assert!(has_absolute_spelling("/{var/folders,tmp}/**"));
3007        assert!(!has_absolute_spelling("docs/**"));
3008        assert!(!has_absolute_spelling("{docs,notes}/**"));
3009    }
3010
3011    #[test]
3012    fn expand_braces_agrees_with_globset() {
3013        // The expansion only discovers prefixes to canonicalize, but a
3014        // disagreement with globset would mean it is describing a different
3015        // pattern than the one that decides matches.
3016        let cases = [
3017            ("/{var/folders,tmp}/**", "/tmp/note.md"),
3018            ("/{var/folders,tmp}/**", "/var/folders/x/note.md"),
3019            ("/{var/folders,tmp}/**", "/opt/note.md"),
3020            ("/{a,b}/{c,d}.md", "/b/d.md"),
3021            ("/{a,b}/{c,d}.md", "/b/e.md"),
3022            ("x{,y}", "x"),
3023            ("x{,y}", "xy"),
3024            ("/var/[a,b]/**", "/var/a/n.md"),
3025            ("/var/[a,b]/**", "/var/,/n.md"),
3026            ("/var/folders/**", "/var/folders/n.md"),
3027        ];
3028        for (pattern, path) in cases {
3029            let direct = Glob::new(pattern).unwrap().compile_matcher().is_match(path);
3030            let expanded = expand_braces(pattern)
3031                .iter()
3032                .any(|p| Glob::new(p).unwrap().compile_matcher().is_match(path));
3033            assert_eq!(direct, expanded, "pattern {pattern} against {path}");
3034        }
3035    }
3036
3037    #[test]
3038    fn literal_path_prefix_stops_at_the_first_wildcard() {
3039        assert_eq!(literal_path_prefix("/var/folders/**"), Some("/var/folders"));
3040        assert_eq!(literal_path_prefix("/var/log/app*.md"), Some("/var/log"));
3041        assert_eq!(literal_path_prefix("/var/note.md"), Some("/var/note.md"));
3042        assert_eq!(literal_path_prefix("/var/"), Some("/var"));
3043        assert_eq!(literal_path_prefix("docs/**"), Some("docs"));
3044        // Nothing literal to resolve.
3045        assert_eq!(literal_path_prefix("/**"), None);
3046        assert_eq!(literal_path_prefix("/{var,tmp}/**"), None);
3047        assert_eq!(literal_path_prefix("**/note.md"), None);
3048        // The result is always a prefix slice, so a remainder re-attaches by
3049        // byte offset.
3050        let pattern = "/var/folders/**";
3051        let prefix = literal_path_prefix(pattern).unwrap();
3052        assert_eq!(&pattern[prefix.len()..], "/**");
3053    }
3054
3055    /// `(real directory, symlink to it)` under a fresh temp dir. The symlink is
3056    /// how a pattern spells the location; the real directory is where a file
3057    /// canonicalizes to.
3058    #[cfg(unix)]
3059    fn symlinked_dir(temp: &Path) -> (PathBuf, PathBuf) {
3060        let real = temp.join("real");
3061        fs::create_dir_all(real.join("notes")).unwrap();
3062        fs::write(real.join("notes/scratch.md"), "# Note\n").unwrap();
3063        let link = temp.join("link");
3064        std::os::unix::fs::symlink(&real, &link).unwrap();
3065        (canonicalize_for_matching(&real).unwrap(), link)
3066    }
3067
3068    #[cfg(unix)]
3069    #[test]
3070    fn path_aliases_spell_a_path_the_way_a_pattern_named_it() {
3071        let temp = tempdir().unwrap();
3072        let (real, link) = symlinked_dir(temp.path());
3073        let pattern = format!("{}/notes/**", link.to_string_lossy());
3074
3075        let aliases = PathAliases::new([pattern.as_str()]);
3076        assert!(!aliases.is_empty());
3077        assert_eq!(
3078            aliases.spellings_of(&real.join("notes/scratch.md")),
3079            vec![format!("{}/notes/scratch.md", link.to_string_lossy())]
3080        );
3081        // The alias is what makes the pattern match the canonical path.
3082        let matcher = Glob::new(&pattern).unwrap().compile_matcher();
3083        assert!(!matcher.is_match(real.join("notes/scratch.md")), "negative control");
3084        assert!(
3085            aliases
3086                .spellings_of(&real.join("notes/scratch.md"))
3087                .iter()
3088                .any(|alias| matcher.is_match(alias))
3089        );
3090        // A path outside the recorded prefix has no alias.
3091        assert!(aliases.spellings_of(Path::new("/somewhere/else/note.md")).is_empty());
3092    }
3093
3094    #[cfg(unix)]
3095    #[test]
3096    fn path_aliases_reach_through_a_brace_alternation() {
3097        // The prefix only exists once the alternation is expanded.
3098        let temp = tempdir().unwrap();
3099        let (real, link) = symlinked_dir(temp.path());
3100        let pattern = format!("{{/nowhere,{}}}/notes/**", link.to_string_lossy());
3101
3102        let aliases = PathAliases::new([pattern.as_str()]);
3103        let matcher = Glob::new(&pattern).unwrap().compile_matcher();
3104        let note = real.join("notes/scratch.md");
3105        assert!(!matcher.is_match(&note), "negative control");
3106        assert!(aliases.spellings_of(&note).iter().any(|alias| matcher.is_match(alias)));
3107    }
3108
3109    #[test]
3110    fn path_aliases_are_empty_without_a_symlinked_prefix() {
3111        let temp = tempdir().unwrap();
3112        let canonical = canonicalize_for_matching(temp.path()).unwrap();
3113        let canonical = canonical.to_string_lossy().replace('\\', "/");
3114        // Relative patterns, absolute patterns that already name their real
3115        // location, and prefixes that do not exist all record nothing.
3116        for pattern in ["docs/**", &format!("{canonical}/docs/**"), "/nonexistent/xyz/**"] {
3117            assert!(
3118                PathAliases::new([pattern]).is_empty(),
3119                "pattern {pattern} should record no alias"
3120            );
3121        }
3122    }
3123
3124    #[cfg(unix)]
3125    #[test]
3126    fn exclude_matchers_match_a_file_a_pattern_named_through_a_symlink() {
3127        let temp = tempdir().unwrap();
3128        let (real, link) = symlinked_dir(temp.path());
3129        let note = real.join("notes/scratch.md");
3130
3131        let matchers = ExcludeMatchers::new(&[format!("{}/notes/**", link.to_string_lossy())]);
3132        assert!(matchers.excludes_file(None, &note));
3133
3134        // Negative controls: a sibling the pattern does not name, and a pattern
3135        // pointing somewhere else entirely.
3136        fs::write(real.join("other.md"), "# Other\n").unwrap();
3137        assert!(!matchers.excludes_file(None, &real.join("other.md")));
3138        let elsewhere = ExcludeMatchers::new(&[format!("{}/elsewhere/**", link.to_string_lossy())]);
3139        assert!(!elsewhere.excludes_file(None, &note));
3140    }
3141
3142    #[cfg(unix)]
3143    #[test]
3144    fn normalize_pattern_for_base_strips_a_pattern_written_through_a_symlink() {
3145        let temp = tempdir().unwrap();
3146        let (real, link) = symlinked_dir(temp.path());
3147        let pattern = format!("{}/notes/*.md", link.to_string_lossy());
3148
3149        assert_eq!(normalize_pattern_for_base(&pattern, Some(&real)), "notes/*.md");
3150        // A pattern naming a different location through the same symlink is
3151        // still outside a narrower base, and stays absolute.
3152        let outside = format!("{}/elsewhere/*.md", link.to_string_lossy());
3153        assert_eq!(normalize_pattern_for_base(&outside, Some(&real.join("notes"))), outside);
3154    }
3155
3156    #[test]
3157    fn classify_file_reads_the_whole_name() {
3158        let cases = [
3159            ("README.md", FileKind::Markdown),
3160            ("README.MD", FileKind::Markdown),
3161            ("notes.txt", FileKind::Markdown),
3162            ("LICENSE", FileKind::Markdown),
3163            ("rules.mdc", FileKind::Markdown),
3164            ("page.html", FileKind::Markdown),
3165            ("lib.rs", FileKind::RustDocComments),
3166            // Only the spelling rustc and cargo use selects doc-comment linting.
3167            ("LIB.RS", FileKind::SourceCode),
3168            ("main.swift", FileKind::SourceCode),
3169            ("Main.KT", FileKind::SourceCode),
3170            ("config.yaml", FileKind::SourceCode),
3171            ("template.md.jinja", FileKind::Markdown),
3172            ("README.md.j2.in", FileKind::Markdown),
3173            ("README.md.swift", FileKind::Markdown),
3174            ("script.litcoffee", FileKind::Markdown),
3175            ("readme.swift.txt", FileKind::Markdown),
3176            // A leading dot names a hidden file: `.md` is not an extension here.
3177            (".md.swift", FileKind::SourceCode),
3178        ];
3179        for (name, expected) in cases {
3180            assert_eq!(classify_file(Path::new(name)), expected, "{name}");
3181            assert_eq!(
3182                classify_file(&Path::new("dir.md").join(name)),
3183                expected,
3184                "dir.md/{name}"
3185            );
3186        }
3187    }
3188
3189    #[test]
3190    fn source_code_extensions_are_sorted_and_exclude_document_formats() {
3191        let extensions = crate::linguist_data::SOURCE_CODE_EXTENSIONS;
3192        assert!(extensions.windows(2).all(|pair| pair[0] < pair[1]));
3193        for document in ["md", "txt", "asc", "rst", "html", "mdx", "ipynb", "tex"] {
3194            assert!(!crate::linguist_data::is_source_code_extension(document), "{document}");
3195        }
3196        for source in ["rs", "swift", "kt", "dart", "java", "py", "json", "SWIFT"] {
3197            assert!(crate::linguist_data::is_source_code_extension(source), "{source}");
3198        }
3199    }
3200
3201    /// The verdict `ignore` gives an `include` pattern on a top-level name, the
3202    /// way the walker builds its include override.
3203    #[cfg(not(windows))]
3204    fn include_override_matches(pattern: &str, name: &str) -> bool {
3205        let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3206        builder.add(pattern).unwrap();
3207        builder
3208            .build()
3209            .unwrap()
3210            .matched(Path::new("/root").join(name), false)
3211            .is_whitelist()
3212    }
3213
3214    /// An explicit include is matched twice, by the walker's override and by
3215    /// `ExplicitIncludeMatchers`. A backslash is an escape in the first on every
3216    /// platform, so it has to be one in the second, or a file the walk found is
3217    /// dropped (or one it skipped is kept) on Windows only.
3218    #[test]
3219    #[cfg(not(windows))]
3220    fn explicit_include_matchers_and_walker_overrides_agree_on_escaped_patterns() {
3221        let names = ["!bang.md", "[x].md", "x.md", "a*b.md", "axb.md", r"\!bang.md"];
3222        for pattern in [r"\!bang.md", r"\[x\].md", r"a\*b.md", "[x].md", "a*b.md"] {
3223            let matchers = ExplicitIncludeMatchers::new(&[pattern.to_string()]);
3224            assert!(!matchers.is_empty(), "{pattern} is an explicit file include");
3225            for name in names {
3226                assert_eq!(
3227                    matchers.matches_relative_path(name),
3228                    include_override_matches(pattern, name),
3229                    "include {pattern} on {name}: matchers vs walker"
3230                );
3231            }
3232        }
3233        // Positive controls: the escaped patterns do select their literal file.
3234        for (pattern, name) in [
3235            (r"\!bang.md", "!bang.md"),
3236            (r"\[x\].md", "[x].md"),
3237            (r"a\*b.md", "a*b.md"),
3238        ] {
3239            let matchers = ExplicitIncludeMatchers::new(&[pattern.to_string()]);
3240            assert!(matchers.matches_relative_path(name), "{pattern} should match {name}");
3241            assert!(include_override_matches(pattern, name), "walker: {pattern} on {name}");
3242        }
3243        // Negative controls: an escaped metacharacter is not a wildcard or a class.
3244        let escaped_class = ExplicitIncludeMatchers::new(&[r"\[x\].md".to_string()]);
3245        assert!(!escaped_class.matches_relative_path("x.md"));
3246        let escaped_star = ExplicitIncludeMatchers::new(&[r"a\*b.md".to_string()]);
3247        assert!(!escaped_star.matches_relative_path("axb.md"));
3248    }
3249
3250    /// A backslash inside a character class is a literal there (`globset`), so
3251    /// the class `[a\]` ends at the `]` and the comma after it separates two
3252    /// alternatives. Skipping the byte after every backslash would read `\]` as an
3253    /// escape and swallow the rest of the pattern into the class.
3254    #[test]
3255    fn split_first_alternation_reads_a_backslash_inside_a_class_literally() {
3256        let (prefix, alternatives, suffix) = split_first_alternation(r"{[a\],/tmp}/notes/*.md").unwrap();
3257        assert_eq!(prefix, "");
3258        assert_eq!(alternatives, vec![r"[a\]", "/tmp"]);
3259        assert_eq!(suffix, "/notes/*.md");
3260        assert!(has_absolute_spelling(r"{[a\],/tmp}/notes/*.md"));
3261    }
3262
3263    /// The first `]` after `[` (or `[!`) is a member of the class, not its end.
3264    #[test]
3265    fn split_first_alternation_reads_a_leading_close_bracket_as_a_class_member() {
3266        for pattern in [r"{[]a],/tmp}/*.md", r"{[!]a],/tmp}/*.md", r"{[^]a],/tmp}/*.md"] {
3267            let (_, alternatives, suffix) = split_first_alternation(pattern).unwrap();
3268            assert_eq!(alternatives.len(), 2, "{pattern}: {alternatives:?}");
3269            assert_eq!(alternatives[1], "/tmp", "{pattern}");
3270            assert_eq!(suffix, "/*.md", "{pattern}");
3271            assert!(has_absolute_spelling(pattern), "{pattern}");
3272            let glob = path_glob(pattern, false).unwrap().compile_matcher();
3273            assert!(glob.is_match("/tmp/x.md"), "{pattern}: globset agrees");
3274        }
3275        // A brace and a comma inside the class: only the leading `]` rule keeps
3276        // them from ending the alternation early.
3277        let pattern = "{[]},a],/tmp}/*.md";
3278        let (_, alternatives, suffix) = split_first_alternation(pattern).unwrap();
3279        assert_eq!(alternatives, vec!["[]},a]", "/tmp"]);
3280        assert_eq!(suffix, "/*.md");
3281        assert!(has_absolute_spelling(pattern));
3282        assert!(
3283            path_glob(pattern, false)
3284                .unwrap()
3285                .compile_matcher()
3286                .is_match("/tmp/x.md")
3287        );
3288    }
3289
3290    /// Outside a class a backslash escapes the next character on every platform.
3291    #[test]
3292    fn split_first_alternation_treats_an_escaped_brace_as_a_literal() {
3293        assert!(
3294            split_first_alternation(r"a\{b,c}").is_none(),
3295            "an escaped brace opens nothing"
3296        );
3297        let (prefix, alternatives, suffix) = split_first_alternation(r"a\\{b,c}").unwrap();
3298        assert_eq!((prefix, alternatives, suffix), (r"a\\", vec!["b", "c"], ""));
3299        let (_, alternatives, _) = split_first_alternation(r"{a\,b,c}").unwrap();
3300        assert_eq!(alternatives, vec![r"a\,b", "c"], "an escaped comma does not split");
3301    }
3302
3303    /// A component holding a backslash is not a plain literal on any platform:
3304    /// it is an escape sequence, and canonicalizing it as a path component would
3305    /// resolve the wrong name.
3306    #[test]
3307    fn literal_path_prefix_ends_at_a_component_holding_a_backslash() {
3308        assert_eq!(literal_path_prefix(r"/alias/foo\bar/x"), Some("/alias"));
3309        assert_eq!(literal_path_prefix(r"/alias/foo\[x\]/x"), Some("/alias"));
3310        assert_eq!(literal_path_prefix(r"foo\bar/x"), None);
3311        // A run that is only a drive designator names no directory.
3312        assert_eq!(literal_path_prefix(r"C:/foo\bar/*.tmpl"), None);
3313        assert_eq!(literal_path_prefix("/alias/plain/x"), Some("/alias/plain/x"));
3314        assert_eq!(literal_path_prefix("C:/**"), None);
3315        assert_eq!(literal_path_prefix("C:/proj/**"), Some("C:/proj"));
3316    }
3317
3318    /// `path_glob` is the one construction every rumdl-side matcher shares, so
3319    /// its reading of `\` is pinned directly: an escape, never a separator.
3320    #[test]
3321    #[cfg(not(windows))]
3322    fn path_glob_reads_a_backslash_as_an_escape_and_keeps_the_separator_option() {
3323        let escaped = path_glob(r"\[x\].md", true).unwrap().compile_matcher();
3324        assert!(escaped.is_match("[x].md"));
3325        assert!(!escaped.is_match("x.md"));
3326        assert!(!escaped.is_match(r"\[x\].md"));
3327        assert!(!escaped.is_match("/[x/].md"), "a backslash is not a slash");
3328
3329        let star = path_glob(r"a\*b.md", true).unwrap().compile_matcher();
3330        assert!(star.is_match("a*b.md"));
3331        assert!(!star.is_match("axb.md"));
3332
3333        // `literal_separator` still decides whether `*` crosses a `/`.
3334        assert!(!path_glob("*.md", true).unwrap().compile_matcher().is_match("a/x.md"));
3335        assert!(path_glob("*.md", false).unwrap().compile_matcher().is_match("a/x.md"));
3336
3337        // A dangling escape does not compile, as in the walker.
3338        assert!(path_glob(r"docs\", true).is_err());
3339    }
3340
3341    #[test]
3342    fn trim_directory_slash_drops_an_escape_left_without_a_character() {
3343        assert_eq!(trim_directory_slash("docs"), "docs");
3344        assert_eq!(trim_directory_slash("docs/"), "docs");
3345        assert_eq!(trim_directory_slash("docs//"), "docs");
3346        assert_eq!(trim_directory_slash(r"docs\/"), "docs");
3347        // `ignore` drops one backslash unconditionally, leaving a dangling escape.
3348        assert_eq!(trim_directory_slash(r"docs\\/"), r"docs\");
3349        assert_eq!(trim_directory_slash(r"docs\\\/"), r"docs\\");
3350        // Several slashes: not the form `ignore` unescapes, so the backslash stays.
3351        assert_eq!(trim_directory_slash(r"docs\//"), r"docs\");
3352        // Without a trailing slash a dangling backslash is the caller's error.
3353        assert_eq!(trim_directory_slash(r"docs\"), r"docs\");
3354        assert_eq!(trim_directory_slash(r"a\/b/"), r"a\/b");
3355    }
3356
3357    /// `docs\/` is a directory marker whose slash was escaped. The walker's
3358    /// overrides read it as the directory `docs`, so the matchers must too, or a
3359    /// named file under it is linted where the walk skipped it.
3360    #[test]
3361    #[cfg(not(windows))]
3362    fn an_escaped_trailing_slash_excludes_the_directory_like_the_walker() {
3363        let pattern = r"docs\/";
3364        assert!(exclude_pattern_compiles(pattern));
3365        let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
3366        assert!(matchers.invalid.is_empty(), "{:?}", matchers.invalid);
3367        assert!(matchers.is_match("docs/x.md"));
3368        assert!(matchers.is_match("a/docs/x.md"));
3369        assert!(!matchers.is_match("other/x.md"), "negative control");
3370
3371        let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3372        for expanded in expand_directory_pattern(pattern) {
3373            builder.add(&exclude_override_rule(&expanded)).unwrap();
3374        }
3375        let walker = builder.build().unwrap();
3376        // The walk prunes an excluded directory, so the directory is what it
3377        // decides on; the matchers see the file beneath it.
3378        for (dir, file) in [
3379            ("docs", "docs/x.md"),
3380            ("a/docs", "a/docs/x.md"),
3381            ("other", "other/x.md"),
3382        ] {
3383            assert_eq!(
3384                matchers.is_match(file),
3385                walker.matched(Path::new("/root").join(dir), true).is_ignore(),
3386                "{dir}"
3387            );
3388        }
3389        assert_eq!(expand_directory_pattern(pattern), vec!["docs", "docs/**"]);
3390        // `ignore` drops one backslash with the slash, leaving a dangling escape it
3391        // rejects; the matchers reject the same patterns, wildcard or not.
3392        for dangling in [r"docs\\/", r"docs*\\/", r"docs\"] {
3393            let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3394            assert!(
3395                builder.add(&exclude_override_rule(dangling)).is_err(),
3396                "{dangling}: walker"
3397            );
3398            assert!(!exclude_pattern_compiles(dangling), "{dangling}: pattern check");
3399            let matchers = ExcludeMatchers::new(&[dangling.to_string()]);
3400            assert!(!matchers.invalid.is_empty(), "{dangling}: matchers reject it");
3401            for file in [r"docs\/x.md", "docs/x.md", "docs/sub/x.md"] {
3402                assert!(!matchers.is_match(file), "{dangling}: matches nothing, not {file}");
3403            }
3404        }
3405        // After several slashes `ignore` keeps the backslash and accepts a rule
3406        // that can never match; here it is reported, and still excludes nothing.
3407        let several = ExcludeMatchers::new(&[r"docs\//".to_string()]);
3408        assert!(!several.invalid.is_empty());
3409        assert!(!several.is_match("docs/x.md") && !several.is_match(r"docs\/x.md"));
3410    }
3411
3412    /// The walk's type filter is built with `globset`'s platform default, which
3413    /// reads `\` as a separator on Windows. A backslashed include name is
3414    /// therefore registered as `*`, on every platform, and the selector's own
3415    /// `keeps` makes the precise decision. With no include override in the way,
3416    /// the walk shows what the type filter alone admits.
3417    #[test]
3418    #[cfg(not(windows))]
3419    fn configure_types_admits_every_name_for_a_backslashed_explicit_include() {
3420        let dir = tempfile::tempdir().unwrap();
3421        for name in ["[x].txt", "x.txt", "y.md", "z.png"] {
3422            fs::write(dir.path().join(name), "").unwrap();
3423        }
3424        let walked = |includes: &[String]| {
3425            let selector = LintablePathSelector::new(Some(dir.path()), includes, LintableFileMode::Markdown);
3426            let mut builder = ignore::WalkBuilder::new(dir.path());
3427            selector.configure_types(&mut builder).unwrap();
3428            let mut names: Vec<String> = builder
3429                .build()
3430                .filter_map(Result::ok)
3431                .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
3432                .map(|entry| entry.file_name().to_string_lossy().into_owned())
3433                .collect();
3434            names.sort();
3435            names
3436        };
3437        assert_eq!(
3438            walked(&[r"\[x\].txt".to_string()]),
3439            ["[x].txt", "x.txt", "y.md", "z.png"],
3440            "the escaped name widens the type filter to every file"
3441        );
3442        // Control: a plain name registers as itself and keeps the filter narrow.
3443        assert_eq!(walked(&["x.txt".to_string()]), ["x.txt", "y.md"]);
3444        // And `keeps` is the precise gate the widened filter hands over to.
3445        let selector = LintablePathSelector::new(
3446            Some(dir.path()),
3447            &[r"\[x\].txt".to_string()],
3448            LintableFileMode::Markdown,
3449        );
3450        assert!(selector.keeps(&dir.path().join("[x].txt")));
3451        assert!(!selector.keeps(&dir.path().join("x.txt")));
3452        assert!(!selector.keeps(&dir.path().join("z.png")));
3453    }
3454
3455    #[test]
3456    #[cfg(not(windows))]
3457    fn type_filter_glob_widens_a_name_holding_a_backslash() {
3458        assert_eq!(type_filter_glob("*.md.jinja"), "*.md.jinja");
3459        assert_eq!(type_filter_glob(r"\[x\].txt"), "*");
3460        assert_eq!(type_filter_glob(r"a\*b.md"), "*");
3461    }
3462
3463    /// The strip must agree with a path-component strip wherever the pattern
3464    /// holds no backslash, so the textual rewrite loses nothing the path-based
3465    /// one handled.
3466    #[test]
3467    fn strip_base_text_agrees_with_a_path_strip_when_there_is_no_backslash() {
3468        let legacy = |pattern: &str, base: &Path| {
3469            Path::new(pattern)
3470                .strip_prefix(base)
3471                .ok()
3472                .map(|rest| rest.to_string_lossy().into_owned())
3473        };
3474        let root = Path::new("/root");
3475        let cases = [
3476            "/root/docs/**",
3477            "/root/./docs",
3478            "/root//docs",
3479            "/root/docs/",
3480            "/root/docs/**/.",
3481            "/root/docs/../../.",
3482            "/./root/.//",
3483            "/root",
3484            "/root/",
3485            "/rooted/docs",
3486            "/other/docs",
3487            "docs/**",
3488            "/root/a/b/c.md",
3489        ];
3490        for pattern in cases {
3491            assert_eq!(
3492                strip_base_text(pattern, root).map(str::to_string),
3493                legacy(pattern, root),
3494                "{pattern}"
3495            );
3496        }
3497        for pattern in ["/docs/**", "/", "/docs/", "/docs/./x"] {
3498            assert_eq!(
3499                strip_base_text(pattern, Path::new("/")).map(str::to_string),
3500                legacy(pattern, Path::new("/")),
3501                "root base: {pattern}"
3502            );
3503        }
3504    }
3505
3506    /// A backslash is never a separator for the base: `/root\[x\]` is a different
3507    /// name from `/root`, and an escape in the remainder survives the strip.
3508    #[test]
3509    fn strip_base_text_keeps_escapes_and_never_treats_a_backslash_as_a_boundary() {
3510        let root = Path::new("/root");
3511        assert_eq!(strip_base_text(r"/root\[x\]", root), None);
3512        assert_eq!(strip_base_text(r"/root\docs", root), None);
3513        assert_eq!(strip_base_text(r"/root/\[x\].md", root), Some(r"\[x\].md"));
3514        assert_eq!(
3515            strip_base_text(r"/root/docs/\[notes\].tmpl", root),
3516            Some(r"docs/\[notes\].tmpl")
3517        );
3518        assert_eq!(strip_base_text(r"/root/docs\/", root), Some("docs"));
3519        assert_eq!(
3520            strip_base_text(r"/root/docs\/.", root),
3521            Some(r"docs\/."),
3522            "an escaped slash is no component"
3523        );
3524    }
3525
3526    #[test]
3527    #[cfg(not(windows))]
3528    fn normalize_pattern_for_base_keeps_escapes_in_the_rewritten_pattern() {
3529        let root = Path::new("/root");
3530        assert_eq!(
3531            normalize_pattern_for_base(r"/root/docs/\[notes\].tmpl", Some(root)),
3532            r"docs/\[notes\].tmpl"
3533        );
3534        assert_eq!(
3535            normalize_pattern_for_base(r"/root/\!bang.md", Some(root)),
3536            r"/\!bang.md"
3537        );
3538        // The rewritten include still selects the escaped name.
3539        let matcher = ExplicitIncludeMatchers::new(&[normalize_pattern_for_base("/root/./docs/*.tmpl", Some(root))]);
3540        assert!(matcher.matches_relative_path("docs/x.tmpl"));
3541        assert!(!matcher.matches_relative_path("other/x.tmpl"));
3542    }
3543
3544    fn dots(pattern: &str) -> String {
3545        normalize_dot_segments(pattern, false).into_owned()
3546    }
3547
3548    fn dots_windows(pattern: &str) -> String {
3549        normalize_dot_segments(pattern, true).into_owned()
3550    }
3551
3552    #[test]
3553    fn dot_segments_drop_dot_components_and_cancel_literal_dot_dot() {
3554        for (pattern, expected) in [
3555            ("./dir", "dir"),
3556            ("dir/./x", "dir/x"),
3557            ("./dir/", "dir/"),
3558            ("dir/.", "dir"),
3559            ("dir/./", "dir/"),
3560            ("a/b/../c", "a/c"),
3561            ("a/../b/../c", "c"),
3562            ("a/b/../../dir/**", "dir/**"),
3563            ("nonexist/../dir/**", "dir/**"),
3564            ("a//b", "a/b"),
3565            ("a//../b", "b"),
3566            ("*/x/../y", "*/y"),
3567            ("**/./x", "**/x"),
3568            ("dir/plain", "dir/plain"),
3569        ] {
3570            assert_eq!(dots(pattern), expected, "{pattern}");
3571            assert_eq!(dots(expected), expected, "idempotent on {expected}");
3572        }
3573    }
3574
3575    #[test]
3576    fn dot_segments_leave_what_cannot_be_resolved() {
3577        for pattern in [
3578            ".",
3579            "./",
3580            "a/..",
3581            "../x",
3582            "../../x",
3583            "*/../x",
3584            "dir/**/../x",
3585            "{a,b}/../x",
3586            r"a\*/../x",
3587            "x/[/../y",
3588            "x/{a/../y",
3589            "x/[/]/../y",
3590            "[!]/../x",
3591        ] {
3592            assert_eq!(dots(pattern), pattern, "{pattern}");
3593        }
3594        // A slash inside a class or a brace is not a component boundary.
3595        assert_eq!(dots("[a/b]/./x"), "[a/b]/x");
3596        assert_eq!(dots("{a/b,c}/./x"), "{a/b,c}/x");
3597    }
3598
3599    #[test]
3600    fn dot_segments_resolve_an_absolute_prefix_lexically_only_when_it_is_missing() {
3601        let dots = |pattern: &str| normalize_dot_segments(pattern, cfg!(windows)).into_owned();
3602        let temp = tempdir().unwrap();
3603        let root = canonicalize_for_matching(temp.path()).unwrap();
3604        let root = root.to_string_lossy().replace('\\', "/");
3605        fs::create_dir_all(format!("{root}/a/b")).unwrap();
3606
3607        assert_eq!(dots(&format!("{root}/./dir")), format!("{root}/dir"));
3608        assert_eq!(dots(&format!("{root}/nonexist/../dir")), format!("{root}/dir"));
3609        // The prefix exists, so the filesystem resolves the `..` (it may be a symlink).
3610        assert_eq!(dots(&format!("{root}/a/../dir")), format!("{root}/a/../dir"));
3611        assert_eq!(dots(&format!("{root}/a/b/../../dir")), format!("{root}/a/b/../../dir"));
3612        // After a wildcard or brace existence cannot be checked: every later `..` stays.
3613        for opaque in ["*", "{a,b}", r"\*"] {
3614            let pattern = format!("{root}/{opaque}/nonexist/../dir");
3615            assert_eq!(dots(&pattern), pattern, "{pattern}");
3616        }
3617        assert_eq!(dots("/zz-missing/x/../y"), "/zz-missing/y");
3618    }
3619
3620    #[test]
3621    fn dot_segments_never_remove_a_root() {
3622        assert_eq!(dots("/.."), "/");
3623        assert_eq!(dots("/../x"), "/x");
3624        assert_eq!(dots("/zz-missing/../../x"), "/x");
3625        // Unix: a run of leading slashes is one root.
3626        assert_eq!(dots("//zz-missing/x/../y"), "/zz-missing/y");
3627        // Windows: a drive and a UNC share are roots; Unix reads `C:` as a name.
3628        assert_eq!(dots_windows("C:/proj/./docs"), "C:/proj/docs");
3629        assert_eq!(dots_windows("C:/../x"), "C:/x");
3630        assert_eq!(dots_windows("C:/.."), "C:/");
3631        assert_eq!(dots_windows("C:/."), "C:/");
3632        assert_eq!(dots_windows("C:/./"), "C:/");
3633        assert_eq!(dots_windows("C:/proj/.."), "C:/");
3634        assert_eq!(dots_windows("//server/share/../x"), "//server/share/x");
3635        assert_eq!(dots_windows("//?/C:/zz-missing/../x"), "//?/C:/x");
3636        assert_eq!(dots_windows("//server/share/./x"), "//server/share/x");
3637        assert_eq!(dots("C:/../x"), "x");
3638    }
3639
3640    #[test]
3641    fn dot_segments_leave_a_symlinked_prefix_to_the_filesystem() {
3642        #[cfg(unix)]
3643        {
3644            let temp = tempdir().unwrap();
3645            let root = temp.path().canonicalize().unwrap();
3646            fs::create_dir_all(root.join("a/b")).unwrap();
3647            fs::create_dir_all(root.join("a/dir")).unwrap();
3648            std::os::unix::fs::symlink(root.join("a/b"), root.join("link")).unwrap();
3649            let pattern = format!("{}/link/../dir", root.display());
3650            assert_eq!(dots(&pattern), pattern, "link/.. is a/, not the root");
3651            let aliases = PathAliases::new([pattern.as_str()]);
3652            assert!(!aliases.is_empty(), "the filesystem still resolves it for the aliases");
3653            let matchers = ExcludeMatchers::new(&[format!("{}/link/../dir/**", root.display())]);
3654            fs::write(root.join("a/dir/e.md"), "x").unwrap();
3655            fs::create_dir_all(root.join("dir")).unwrap();
3656            fs::write(root.join("dir/f.md"), "x").unwrap();
3657            assert!(matchers.excludes_file(None, &root.join("a/dir/e.md")));
3658            assert!(!matchers.excludes_file(None, &root.join("dir/f.md")));
3659        }
3660    }
3661
3662    #[test]
3663    fn a_dot_dot_that_cannot_resolve_still_matches_through_another_patterns_alias() {
3664        // The aliases are shared across patterns, so this pair excludes a file
3665        // neither excludes alone; unresolved `..` must not be rewritten away.
3666        #[cfg(unix)]
3667        {
3668            let temp = tempdir().unwrap();
3669            let root = temp.path().canonicalize().unwrap();
3670            fs::create_dir_all(root.join("a")).unwrap();
3671            fs::create_dir_all(root.join("docs")).unwrap();
3672            fs::write(root.join("docs/x.md"), "x").unwrap();
3673            let first = format!("{}/*/../docs/**", root.display());
3674            let second = format!("{}/a/../*.tmp.md", root.display());
3675            let file = root.join("docs/x.md");
3676            assert!(!ExcludeMatchers::new(std::slice::from_ref(&first)).excludes_file(None, &file));
3677            assert!(!ExcludeMatchers::new(std::slice::from_ref(&second)).excludes_file(None, &file));
3678            let both = ExcludeMatchers::new(&[first, second]);
3679            assert!(both.invalid.is_empty(), "{:?}", both.invalid);
3680            assert!(both.excludes_file(None, &file));
3681        }
3682    }
3683
3684    #[test]
3685    fn a_dotted_exclude_stays_anchored_in_the_walker_and_the_matchers() {
3686        assert_eq!(expand_directory_pattern_for("./dir", false), ["/dir", "/dir/**"]);
3687        assert_eq!(expand_directory_pattern_for("./dir/", false), ["/dir", "/dir/**"]);
3688        assert_eq!(expand_directory_pattern_for("./a/b", false), ["a/b", "a/b/**"]);
3689        assert_eq!(expand_directory_pattern_for("dir/../dir", false), ["/dir", "/dir/**"]);
3690        assert_eq!(expand_directory_pattern_for("./*.md", false), ["/*.md"]);
3691        // Control: floating patterns are left to float.
3692        assert_eq!(expand_directory_pattern_for("dir", false), ["dir", "dir/**"]);
3693        assert_eq!(expand_directory_pattern_for("*.md", false), ["*.md"]);
3694
3695        let names = [
3696            "top.md",
3697            "other/top.md",
3698            "dir/a.md",
3699            "other/dir/a.md",
3700            "dir/sub/b.md",
3701            "a/b/d.md",
3702        ];
3703        for pattern in [
3704            "./top.md",
3705            "top.md",
3706            "./dir",
3707            "dir/./sub",
3708            "a/../dir/",
3709            "./*.md",
3710            "./a/b",
3711        ] {
3712            let matchers = ExcludeMatchers::new(&[pattern.to_string()]);
3713            assert!(matchers.invalid.is_empty(), "{pattern}: {:?}", matchers.invalid);
3714            let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3715            for entry in expand_directory_pattern(pattern) {
3716                builder.add(&exclude_override_rule(&entry)).unwrap();
3717            }
3718            let walker = builder.build().unwrap();
3719            for name in names {
3720                let by_walker = walker.matched(Path::new("/root").join(name), false).is_ignore();
3721                assert_eq!(
3722                    matchers.is_match(name),
3723                    by_walker,
3724                    "pattern {pattern} on {name}: matchers vs walker"
3725                );
3726            }
3727        }
3728        let anchored = ExcludeMatchers::new(&["./top.md".to_string()]);
3729        assert!(anchored.is_match("top.md"));
3730        assert!(!anchored.is_match("other/top.md"), "./top.md must not float");
3731        let floating = ExcludeMatchers::new(&["top.md".to_string()]);
3732        assert!(floating.is_match("other/top.md"));
3733    }
3734
3735    #[test]
3736    fn include_normalization_keeps_anchoring_and_guards_override_lines() {
3737        let n = |pattern: &str| normalize_pattern_for_base_for(pattern, None, false);
3738        assert_eq!(n("./top.md"), "/top.md");
3739        assert_eq!(n("./dir/"), "/dir/");
3740        assert_eq!(n("dir/./x"), "dir/x");
3741        assert_eq!(n("./a/b"), "a/b");
3742        assert_eq!(n("top.md"), "top.md");
3743        assert_eq!(n("!./top.md"), "!/top.md");
3744        assert_eq!(n("!./dir/x"), "!dir/x");
3745        // A positive include that already is a comment stays one; a pattern that
3746        // would turn into a comment or a negation is left as written.
3747        assert_eq!(n("#dir/../docs/**"), "#dir/../docs/**");
3748        assert_eq!(n("a/../#x"), "a/../#x");
3749        assert_eq!(n("./!x"), "./!x");
3750        // Behind a negation marker `#` and `!` are ordinary name characters.
3751        assert_eq!(n("!#dir/../docs/**"), "!docs/**");
3752        assert_eq!(n("!./!x/**"), "!!x/**");
3753        for pattern in ["./top.md", "!./dir/x", "dir/../#x", "./dir/"] {
3754            let once = n(pattern);
3755            assert_eq!(n(&once), once, "idempotent on {pattern}");
3756        }
3757    }
3758
3759    #[test]
3760    fn include_normalization_resolves_dots_in_an_absolute_pattern_under_the_base() {
3761        let temp = tempdir().unwrap();
3762        let root = canonicalize_for_matching(temp.path()).unwrap();
3763        fs::create_dir_all(root.join("docs")).unwrap();
3764        let text = root.to_string_lossy().replace('\\', "/");
3765        let n = |suffix: &str| normalize_pattern_for_base_for(&format!("{text}/{suffix}"), Some(&root), cfg!(windows));
3766        assert_eq!(n("./docs/**"), "docs/**");
3767        assert_eq!(n("nonexist/../docs/**"), "docs/**");
3768        assert_eq!(n("docs/../notes/**"), "notes/**");
3769        // The base strip drops a trailing `/`, as it always has.
3770        assert_eq!(n("docs/./../notes/"), "/notes");
3771        let explicit = ExplicitIncludeMatchers::new(&[n("x.tmpl")]);
3772        assert!(explicit.matches_relative_path("x.tmpl"));
3773        assert!(!explicit.matches_relative_path("a/x.tmpl"));
3774        // A single component keeps the root anchor an absolute pattern had.
3775        assert_eq!(n("top.md"), "/top.md");
3776        assert_eq!(n("docs/../top.md"), "/top.md");
3777        assert_eq!(n("*.md"), "/*.md");
3778        assert_eq!(n("dir/top.md"), "dir/top.md");
3779        // Outside the base: left absolute, nothing under the walk matches it.
3780        assert_eq!(n("../elsewhere/**"), format!("{text}/../elsewhere/**"));
3781        assert_eq!(n("docs/../../elsewhere/**"), format!("{text}/docs/../../elsewhere/**"));
3782    }
3783
3784    #[test]
3785    fn include_normalization_resolves_dots_under_a_base_holding_metacharacters() {
3786        let temp = tempdir().unwrap();
3787        let base = canonicalize_for_matching(temp.path()).unwrap().join("project[1]");
3788        fs::create_dir_all(base.join("sub")).unwrap();
3789        let text = base.to_string_lossy().replace('\\', "/");
3790        let n = |suffix: &str| normalize_pattern_for_base_for(&format!("{text}/{suffix}"), Some(&base), cfg!(windows));
3791        assert_eq!(n("nonexist/../docs/**"), "docs/**");
3792        assert_eq!(n("sub/../docs/**"), "docs/**");
3793        assert_eq!(n("./docs/**"), "docs/**");
3794        assert_eq!(n("../out/**"), format!("{text}/../out/**"));
3795    }
3796
3797    #[cfg(unix)]
3798    #[test]
3799    fn include_normalization_follows_a_symlink_before_dot_dot() {
3800        let temp = tempdir().unwrap();
3801        let root = temp.path().canonicalize().unwrap();
3802        fs::create_dir_all(root.join("x/y")).unwrap();
3803        std::os::unix::fs::symlink(root.join("x/y"), root.join("link")).unwrap();
3804        let pattern = format!("{}/link/../docs/**", root.display());
3805        assert_eq!(
3806            normalize_pattern_for_base_for(&pattern, Some(&root), false),
3807            "x/docs/**"
3808        );
3809    }
3810
3811    #[test]
3812    fn per_file_keys_normalize_dots_and_keep_a_bang_as_a_name_character() {
3813        assert_eq!(prepare_pattern_for("./dir/x.md", false), "dir/x.md");
3814        assert_eq!(prepare_pattern_for("a/../dir/x.md", false), "dir/x.md");
3815        assert_eq!(prepare_pattern_for("!dir/../f.md", false), "f.md");
3816        // `!.` is an ordinary name here, not a `.` component.
3817        assert_eq!(prepare_pattern_for("!./f.md", false), "!./f.md");
3818        assert_eq!(prepare_pattern_for("a/!x/../f.md", false), "a/f.md");
3819        assert_eq!(prepare_pattern_for("../x.md", false), "../x.md");
3820    }
3821
3822    #[test]
3823    fn windows_reads_a_backslash_as_a_separator_and_unix_as_an_escape() {
3824        // Windows: separators, so the directory forms work.
3825        assert_eq!(prepare_pattern_for(r"docs\**\", true), "docs/**/");
3826        assert_eq!(prepare_pattern_for(r"docs\sub\*.md", true), "docs/sub/*.md");
3827        assert_eq!(prepare_pattern_for(r"..\x", true), "../x");
3828        assert_eq!(prepare_pattern_for(r".\docs\..\notes", true), "notes");
3829        assert_eq!(prepare_pattern_for(r"C:\proj\.\docs", true), "C:/proj/docs");
3830        assert_eq!(expand_directory_pattern_for(r"docs\**\", true), ["docs/**/"]);
3831        assert_eq!(
3832            expand_directory_pattern_for(r"docs\sub", true),
3833            ["docs/sub", "docs/sub/**"]
3834        );
3835        assert_eq!(expand_directory_pattern_for(r".\dir", true), ["/dir", "/dir/**"]);
3836        assert_eq!(normalize_pattern_for_base_for(r".\docs\*.md", None, true), "docs/*.md");
3837        assert_eq!(normalize_pattern_for_base_for(r"!.\docs\x", None, true), "!docs/x");
3838
3839        let separator = path_glob_for(r"docs\*.md", true, true).unwrap().compile_matcher();
3840        assert!(separator.is_match("docs/a.md"));
3841        assert!(
3842            path_glob_for(r"docs\", true, true).is_ok(),
3843            "no dangling escape on Windows"
3844        );
3845        let matchers = ExcludeMatchers::for_platform(&[r"docs\**".to_string()], true);
3846        assert!(matchers.invalid.is_empty(), "{:?}", matchers.invalid);
3847        assert!(matchers.is_match("docs/a.md"));
3848        assert!(matchers.is_match("docs/sub/b.md"));
3849        assert!(!matchers.is_match("other/a.md"));
3850
3851        // A literal bang or metacharacter is spelled with a class or a brace.
3852        let bang = path_glob_for("{!}name.md", true, true).unwrap().compile_matcher();
3853        assert!(bang.is_match("!name.md"));
3854        let star = path_glob_for("a[*]b.md", true, true).unwrap().compile_matcher();
3855        assert!(star.is_match("a*b.md") && !star.is_match("axb.md"));
3856        // The walker's `ignore` overrides read the same spellings.
3857        let mut builder = ignore::overrides::OverrideBuilder::new(Path::new("/root"));
3858        for entry in expand_directory_pattern_for("{!}name.md", true)
3859            .into_iter()
3860            .chain(expand_directory_pattern_for("a[*]b.md", true))
3861        {
3862            builder.add(&exclude_override_rule(&entry)).unwrap();
3863        }
3864        let walker = builder.build().unwrap();
3865        for hit in ["!name.md", "a*b.md"] {
3866            assert!(walker.matched(Path::new("/root").join(hit), false).is_ignore(), "{hit}");
3867        }
3868        for miss in ["name.md", "axb.md"] {
3869            assert!(
3870                !walker.matched(Path::new("/root").join(miss), false).is_ignore(),
3871                "{miss}"
3872            );
3873        }
3874
3875        // Unix: control, the backslash is still the escape.
3876        assert_eq!(prepare_pattern_for(r"docs\**", false), r"docs\**");
3877        assert_eq!(prepare_pattern_for(r"\!name", false), r"\!name");
3878        let escaped = path_glob_for(r"\!name.md", true, false).unwrap().compile_matcher();
3879        assert!(escaped.is_match("!name.md"));
3880        let unix = ExcludeMatchers::for_platform(&[r"docs\**".to_string()], false);
3881        assert!(!unix.is_match("docs/a.md"), "an escaped star names a file called docs*");
3882        assert!(path_glob_for(r"docs\", true, false).is_err());
3883    }
3884
3885    #[test]
3886    fn windows_verbatim_patterns_are_unwrapped_before_glob_parsing() {
3887        for pattern in [r"\\?\C:\repo\docs\..\*.md", "//?/C:/repo/docs/../*.md"] {
3888            assert_eq!(prepare_pattern_for(pattern, true), "C:/repo/*.md");
3889            let matcher = path_glob_for(pattern, true, true).unwrap().compile_matcher();
3890            assert!(matcher.is_match("C:/repo/docs/../top.md"));
3891            assert!(!matcher.is_match("D:/repo/docs/../top.md"));
3892        }
3893        assert_eq!(
3894            prepare_pattern_for("//?/UNC/server/share/docs/../*.md", true),
3895            "//server/share/*.md"
3896        );
3897        assert_eq!(prepare_pattern_for("//?/C:/repo/./*.md", false), "/?/C:/repo/*.md");
3898    }
3899
3900    #[test]
3901    fn path_aliases_see_through_a_dot_in_the_prefix() {
3902        #[cfg(unix)]
3903        {
3904            let temp = tempdir().unwrap();
3905            let root = temp.path().canonicalize().unwrap();
3906            fs::create_dir_all(root.join("real/docs")).unwrap();
3907            std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
3908            let aliases = PathAliases::new([format!("{}/./alias/docs/**", root.display()).as_str()]);
3909            assert_eq!(
3910                aliases.spellings_of(&root.join("real/docs/x.md")),
3911                vec![format!("{}/alias/docs/x.md", root.display())]
3912            );
3913        }
3914    }
3915}