Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

1//!
2//! Rule MD057: Existing relative links
3//!
4//! See [docs/md057.md](../../docs/md057.md) for full documentation, configuration, and examples.
5
6use crate::rule::{
7    CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12    FileIndex, LinkOrigin, Md057LinkTarget, URL_EXTRACT_ANGLE_BRACKET_REGEX, URL_EXTRACT_REGEX,
13    extract_cross_file_links, link_path_part, normalize_relative_path, url_decode,
14};
15use pulldown_cmark::LinkType;
16use regex::Regex;
17use std::borrow::Cow;
18use std::cell::{LazyCell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::env;
21use std::ffi::{OsStr, OsString};
22use std::path::{Component, Path, PathBuf};
23use std::sync::LazyLock;
24use std::sync::{Arc, Mutex};
25use std::time::SystemTime;
26use unicode_normalization::UnicodeNormalization;
27
28mod link_resolver;
29mod md057_config;
30use crate::utils::mkdocs_config::resolve_docs_dir;
31use crate::utils::obsidian_config::resolve_attachment_folder;
32use crate::utils::project_root::project_root;
33#[cfg(test)]
34use link_resolver::is_external_url;
35pub use link_resolver::{DocumentLinks, LinkResolution, LinkResolver};
36pub use md057_config::{AbsoluteLinksOption, MD057Config};
37
38thread_local! {
39    /// Which paths exist, as learned during the check running on this thread.
40    /// A check runs start to finish on one thread and clears the map when it
41    /// starts, so documents checked in parallel neither contend for one lock
42    /// nor clear each other's answers.
43    static FILE_EXISTENCE_CACHE: RefCell<HashMap<PathBuf, bool>> = RefCell::new(HashMap::new());
44}
45
46/// The shared map behind the directory cache. Each listing is an `Arc` so a
47/// lookup clones the handle and releases the lock before reading the names.
48type DirectoryListingCache = Arc<Mutex<HashMap<PathBuf, Arc<DirectoryListing>>>>;
49
50/// Directory listings, keyed by directory. One listing answers for every link
51/// naming an entry in that directory, and stands for as long as the
52/// directory's modification time does.
53static DIRECTORY_LISTING_CACHE: LazyLock<DirectoryListingCache> =
54    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
55
56/// Drops what one check learned about which paths exist, so the next check
57/// asks the filesystem again.
58///
59/// The directory listings are not dropped. Each carries the modification time
60/// it was read at and is read again once that time moves, which is a stat per
61/// lookup rather than a read of the whole directory per file checked.
62fn reset_file_existence_cache() {
63    FILE_EXISTENCE_CACHE.with_borrow_mut(HashMap::clear);
64}
65
66/// The entry names of one directory, in the spelling the filesystem stores,
67/// together with the moment they were read at.
68struct DirectoryListing {
69    /// The directory's modification time when the read began, or `None` where
70    /// the directory's metadata could not be read. A lookup keeps this listing
71    /// only while the directory still reports this time.
72    modified: Option<SystemTime>,
73    /// Whether the directory could be read in full. One that could not says
74    /// nothing about how its entries are spelled.
75    listed: bool,
76    /// Every entry name, compared byte for byte.
77    names: HashSet<OsString>,
78    /// The composed form of each entry name holding a character outside ASCII.
79    /// A macOS volume can store such a name decomposed while the link is typed
80    /// composed, and the two spell the same name.
81    composed: HashSet<String>,
82}
83
84impl DirectoryListing {
85    /// A listing that says nothing about how the directory's entries are
86    /// spelled, so every name under it is accepted.
87    fn unlisted(modified: Option<SystemTime>) -> Self {
88        Self {
89            modified,
90            listed: false,
91            names: HashSet::new(),
92            composed: HashSet::new(),
93        }
94    }
95
96    fn read(directory: &Path) -> Self {
97        // The time is taken before the entries are, so a change landing during
98        // the read leaves this listing behind the directory's own time and the
99        // next lookup reads again.
100        let modified = std::fs::metadata(directory)
101            .and_then(|metadata| metadata.modified())
102            .ok();
103        let mut names = HashSet::new();
104        let mut composed = HashSet::new();
105        let Ok(entries) = std::fs::read_dir(directory) else {
106            return Self::unlisted(modified);
107        };
108        for entry in entries {
109            let Ok(entry) = entry else {
110                // A name the kernel could not return is missing from these
111                // names, and a listing missing a name must not answer that the
112                // name is absent.
113                return Self::unlisted(modified);
114            };
115            let name = entry.file_name();
116            if let Some(text) = name.to_str()
117                && !text.is_ascii()
118            {
119                composed.insert(text.nfc().collect());
120            }
121            names.insert(name);
122        }
123        Self {
124            modified,
125            listed: true,
126            names,
127            composed,
128        }
129    }
130
131    /// Whether the directory holds an entry that spells `name`: either the
132    /// spelling as written, or the same name in composed form on one side or
133    /// the other. A name outside ASCII can compose to one inside it (the
134    /// Kelvin sign composes to the letter K), so an ASCII name is looked up
135    /// among the composed forms as well, and a composed form is looked up
136    /// among the entries as written as well as among their composed forms.
137    fn holds(&self, name: &OsStr) -> bool {
138        if !self.listed {
139            return true;
140        }
141        if self.names.contains(name) {
142            return true;
143        }
144        let Some(text) = name.to_str() else {
145            return false;
146        };
147        if text.is_ascii() {
148            return self.composed.contains(text);
149        }
150        let composed: String = text.nfc().collect();
151        self.composed.contains(composed.as_str()) || self.names.contains(OsStr::new(composed.as_str()))
152    }
153}
154
155/// The listing of `directory`, reused for every link naming an entry in it
156/// until the directory's modification time moves.
157///
158/// A lookup stats the directory and keeps a stored listing only while the time
159/// it recorded is the one the directory reports, so a directory of many
160/// documents linking to their siblings is read once rather than once per
161/// document. Creating, deleting or renaming an entry moves that time on APFS,
162/// ext4 and NTFS, so a process that lints the same tree repeatedly sees the
163/// change at its next lookup. Where the filesystem keeps the time coarsely,
164/// one second on HFS+ and two on FAT, a change inside the same tick as the
165/// read is seen once the directory next changes. A long-lived process holds
166/// one listing per directory its links reach.
167///
168/// The read happens with no lock held, so one slow directory does not stop
169/// other threads answering from the cache. Two threads reaching the same
170/// directory both read it and store the same names.
171fn directory_listing(directory: &Path) -> Arc<DirectoryListing> {
172    let Ok(modified) = std::fs::metadata(directory).and_then(|metadata| metadata.modified()) else {
173        // With no time to compare against, a stored listing cannot be told
174        // from one the directory has outgrown.
175        return Arc::new(DirectoryListing::read(directory));
176    };
177    match DIRECTORY_LISTING_CACHE.lock() {
178        Ok(cache) => {
179            if let Some(listing) = cache.get(directory)
180                && listing.modified == Some(modified)
181            {
182                return Arc::clone(listing);
183            }
184        }
185        Err(_) => return Arc::new(DirectoryListing::read(directory)), // Fallback to an uncached listing on mutex poison
186    }
187    let listing = Arc::new(DirectoryListing::read(directory));
188    match DIRECTORY_LISTING_CACHE.lock() {
189        Ok(mut cache) => {
190            // Two threads that raced on an unchanged directory store the same
191            // names. If the directory changed between their reads, the slower
192            // thread can store the older listing over the newer one; the next
193            // lookup's stat then finds a time that no longer matches and reads
194            // again, so a stale insert costs one extra read and never a wrong
195            // answer.
196            cache.insert(directory.to_path_buf(), Arc::clone(&listing));
197            listing
198        }
199        Err(_) => listing, // Fallback to an uncached listing on mutex poison
200    }
201}
202
203// Check if a file exists with caching
204fn file_exists_with_cache(path: &Path) -> bool {
205    if let Some(exists) = FILE_EXISTENCE_CACHE.with_borrow(|cache| cache.get(path).copied()) {
206        return exists;
207    }
208    let exists = path.exists();
209    FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(path.to_path_buf(), exists));
210    exists
211}
212
213/// Whether every component of `path` below `anchor` is spelled the way the
214/// filesystem stores it.
215///
216/// `anchor` is the directory the link resolves against, so everything above it
217/// is the path to the project rather than anything the link author wrote and is
218/// left alone. A `..` moves up without a listing check, and a directory that
219/// cannot be listed is accepted, so the walk only ever rejects a spelling a
220/// directory listing contradicts.
221///
222/// A `..` component moves the anchor up lexically, the way a URL resolver does,
223/// so a link through a symlinked directory is judged by the path as written
224/// rather than by the directory the symlink reaches.
225fn has_exact_case_components(anchor: &Path, path: &Path) -> bool {
226    let Ok(relative) = path.strip_prefix(anchor) else {
227        return true;
228    };
229    let mut directory = if anchor.as_os_str().is_empty() {
230        PathBuf::from(".")
231    } else {
232        anchor.to_path_buf()
233    };
234    for component in relative.components() {
235        match component {
236            Component::CurDir => {}
237            Component::ParentDir => {
238                // Only a named directory can be stepped out of by dropping it.
239                // Above a root, or past a `..` already on the path, the step
240                // has to be written out instead.
241                if matches!(directory.components().next_back(), Some(Component::Normal(_))) {
242                    directory.pop();
243                } else {
244                    directory.push("..");
245                }
246                // Dropping the only component of a relative path lands on the
247                // directory the process runs in.
248                if directory.as_os_str().is_empty() {
249                    directory.push(".");
250                }
251            }
252            Component::Normal(name) => {
253                if !directory_listing(&directory).holds(name) {
254                    return false;
255                }
256                directory.push(name);
257            }
258            // A relative path holds neither, and a path that does is not one
259            // this rule resolved against the anchor.
260            Component::RootDir | Component::Prefix(_) => return true,
261        }
262    }
263    true
264}
265
266/// Whether `path` exists with exactly the spelling it is written in.
267///
268/// A case-insensitive volume answers `exists` for a spelling it does not store,
269/// so macOS and Windows accept a link that 404s on every web server and breaks
270/// the moment the project is checked out on Linux. The filesystem's own answer
271/// is the cheap negative: a path that is not there is not there whatever its
272/// case. A path that is there is confirmed against the directory listings below
273/// `anchor`, which compare the stored name, as a server does.
274fn exists_exact_case(anchor: &Path, path: &Path) -> bool {
275    file_exists_with_cache(path) && has_exact_case_components(anchor, path)
276}
277
278/// The file a link path resolves to, or `None` when nothing is there.
279///
280/// An extensionless link is tried against the markdown extensions in turn, so
281/// `[Link](page)` resolves to `page.md`.
282fn resolve_existing_target(anchor: &Path, path: &Path) -> Option<PathBuf> {
283    // First, check exact path
284    if exists_exact_case(anchor, path) {
285        return Some(path.to_path_buf());
286    }
287
288    // If the path has no extension, try adding markdown extensions
289    if path.extension().is_none() {
290        for ext in MARKDOWN_EXTENSIONS {
291            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
292            let path_with_ext = path.with_extension(&ext[1..]);
293            if exists_exact_case(anchor, &path_with_ext) {
294                return Some(path_with_ext);
295            }
296        }
297    }
298
299    None
300}
301
302/// The markdown sources an `.html`/`.htm` link may be generated from, in
303/// `MARKDOWN_EXTENSIONS` order; empty for any other link.
304fn html_markdown_sources(path: &Path) -> impl Iterator<Item = PathBuf> + '_ {
305    let source = path
306        .extension()
307        .and_then(|ext| ext.to_str())
308        .filter(|ext| ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
309        .and(path.file_stem().and_then(|stem| stem.to_str()))
310        .zip(path.parent());
311    source.into_iter().flat_map(|(stem, parent)| {
312        MARKDOWN_EXTENSIONS
313            .iter()
314            .map(move |md_ext| parent.join(format!("{stem}{md_ext}")))
315    })
316}
317
318/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
319/// Matches: scheme:// or scheme: (per RFC 3986)
320/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
321static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
322    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
323
324// Current working directory
325static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
326
327/// Supported markdown file extensions
328const MARKDOWN_EXTENSIONS: &[&str] = &[
329    ".md",
330    ".markdown",
331    ".mdx",
332    ".mkd",
333    ".mkdn",
334    ".mdown",
335    ".mdwn",
336    ".qmd",
337    ".rmd",
338];
339
340/// Where one document's relative destinations resolve from.
341struct DocumentAnchor {
342    /// The document as the filesystem sees it, symlinks resolved. Links are
343    /// compared against it to find the ones that point back at their own
344    /// document.
345    self_path: Option<PathBuf>,
346    /// The directory relative destinations are joined to: the explicit base
347    /// when one is set, otherwise the directory holding `self_path`, so a
348    /// symlinked document resolves from its target's directory.
349    base_path: PathBuf,
350    /// The directories a destination is also looked up in after `base_path`.
351    search_paths: Vec<PathBuf>,
352}
353
354/// A document's anchor, worked out the first time a destination needs it.
355/// Finding it canonicalizes the document's path, a filesystem call per
356/// document, while most destinations never read it: an external URL, a
357/// fragment and an ignored absolute path are all settled from the text.
358type LazyAnchor<'a> = LazyCell<DocumentAnchor, Box<dyn FnOnce() -> DocumentAnchor + 'a>>;
359
360#[cfg(test)]
361thread_local! {
362    /// How many anchors this thread has worked out, so a test can tell a
363    /// check that never needed one from a check that resolved one.
364    static ANCHOR_RESOLUTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
365}
366
367/// A relative link that resolves to the file it is written in.
368#[derive(Debug, PartialEq, Eq)]
369enum SelfReferentialLink {
370    /// The link addresses the whole file, so there is no shorter way to write
371    /// the same destination.
372    WholeFile,
373    /// The link carries a fragment, which on its own reaches the same heading
374    /// without leaving the page.
375    Fragment(String),
376}
377
378#[cfg(feature = "blake3")]
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380enum DependencyPathState {
381    Missing,
382    File,
383    Directory,
384    Other,
385}
386
387/// Rule MD057: Existing relative links should point to valid files or directories.
388#[derive(Debug, Clone)]
389pub struct MD057ExistingRelativeLinks {
390    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
391    /// so it is shared across clones (the rule is cloned per config group and
392    /// for inline-config overrides); the base is resolved from the file under
393    /// check and consulted while validating that file's links.
394    base_path: Arc<Mutex<Option<PathBuf>>>,
395    /// Configuration for the rule
396    config: MD057Config,
397}
398
399impl Default for MD057ExistingRelativeLinks {
400    fn default() -> Self {
401        Self {
402            base_path: Arc::new(Mutex::new(None)),
403            config: MD057Config::default(),
404        }
405    }
406}
407
408impl MD057ExistingRelativeLinks {
409    /// Create a new instance with default settings
410    pub fn new() -> Self {
411        Self::default()
412    }
413
414    /// Set the base path for resolving relative links
415    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
416        let path = path.as_ref();
417        let dir_path = if path.is_file() {
418            path.parent().map(std::path::Path::to_path_buf)
419        } else {
420            Some(path.to_path_buf())
421        };
422
423        if let Ok(mut guard) = self.base_path.lock() {
424            *guard = dir_path;
425        }
426        self
427    }
428
429    pub fn from_config_struct(config: MD057Config) -> Self {
430        Self {
431            base_path: Arc::new(Mutex::new(None)),
432            config,
433        }
434    }
435
436    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
437    /// against the project root: absolute strings are taken verbatim, relative
438    /// strings are joined onto `project_root`.
439    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
440        if Path::new(path_str).is_absolute() {
441            PathBuf::from(path_str)
442        } else {
443            project_root.join(path_str)
444        }
445    }
446
447    /// External destinations and gh-aw output placeholders do not name files
448    /// relative to the Markdown source.
449    #[inline]
450    fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
451        link_resolver::is_non_file_destination(url, flavor)
452    }
453
454    /// Check if the URL is a fragment-only link (internal document link)
455    #[inline]
456    fn is_fragment_only_link(&self, url: &str) -> bool {
457        url.starts_with('#')
458    }
459
460    /// Check if the URL is an absolute path (starts with /)
461    /// These are typically routes for published documentation sites.
462    #[inline]
463    fn is_absolute_path(url: &str) -> bool {
464        url.starts_with('/')
465    }
466
467    /// Strip query parameters and fragments from a URL for file existence checking.
468    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
469    /// for `path/to/image.png` or `file.md` respectively.
470    ///
471    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
472    /// so we check for `?` first. If a URL has both, only the query is stripped here
473    /// (fragments are handled separately by the regex in `contribute_to_index`).
474    fn strip_query_and_fragment(url: &str) -> &str {
475        // Find the first occurrence of '?' or '#', whichever comes first
476        // This handles both standard URLs (? before #) and edge cases (# before ?)
477        let query_pos = url.find('?');
478        let fragment_pos = url.find('#');
479
480        match (query_pos, fragment_pos) {
481            (Some(q), Some(f)) => {
482                // Both exist - strip at whichever comes first
483                &url[..q.min(f)]
484            }
485            (Some(q), None) => &url[..q],
486            (None, Some(f)) => &url[..f],
487            (None, None) => url,
488        }
489    }
490
491    /// Resolve a relative link against a provided base path
492    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
493        base_path.join(link)
494    }
495
496    /// Compute additional search paths for fallback link resolution.
497    ///
498    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
499    /// with explicitly configured `search-paths`.
500    fn compute_search_paths(
501        &self,
502        flavor: crate::config::MarkdownFlavor,
503        source_file: Option<&Path>,
504        base_path: &Path,
505        project_root: &Path,
506    ) -> Vec<PathBuf> {
507        Self::search_paths_for(&self.config.search_paths, flavor, source_file, base_path, project_root)
508    }
509
510    /// The directories a relative destination is also looked up in, after the
511    /// linking document's own directory: the Obsidian attachment folder, then
512    /// each configured search path.
513    fn search_paths_for(
514        configured: &[String],
515        flavor: crate::config::MarkdownFlavor,
516        source_file: Option<&Path>,
517        base_path: &Path,
518        project_root: &Path,
519    ) -> Vec<PathBuf> {
520        let mut paths = Vec::new();
521
522        // Auto-detect Obsidian attachment folder
523        if flavor == crate::config::MarkdownFlavor::Obsidian
524            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
525            && attachment_dir != *base_path
526        {
527            paths.push(attachment_dir);
528        }
529
530        // Add explicitly configured search paths. Resolved relative to the
531        // discovered project root so paths are stable regardless of which
532        // subdirectory rumdl is invoked from.
533        for search_path in configured {
534            let resolved = Self::resolve_against_project_root(search_path, project_root);
535            if resolved != *base_path && !paths.contains(&resolved) {
536                paths.push(resolved);
537            }
538        }
539
540        paths
541    }
542
543    /// Record every filesystem destination this rule may validate.
544    ///
545    /// The index keeps frontmatter inputs regardless of the current rule config
546    /// because a content-matched entry can be reused by another config group.
547    fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
548        for destination in body_link_destinations(ctx) {
549            let url = destination.url;
550            if url.is_empty()
551                || (url.starts_with('`') && url.ends_with('`'))
552                || self.is_non_file_destination(url, ctx.flavor)
553                || self.is_fragment_only_link(url)
554            {
555                continue;
556            }
557            index.add_md057_link_target(Md057LinkTarget {
558                target: url.to_string(),
559                origin: LinkOrigin::Body,
560            });
561        }
562
563        for image in ctx.images() {
564            if ctx
565                .line_info(image.line)
566                .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
567                || matches!(image.link_type, LinkType::WikiLink { .. })
568                || ctx.is_in_shortcode(image.byte_offset)
569            {
570                continue;
571            }
572            let url = image.url.as_ref();
573            if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
574                continue;
575            }
576            index.add_md057_link_target(Md057LinkTarget {
577                target: url.to_string(),
578                origin: LinkOrigin::Body,
579            });
580        }
581
582        for reference in ctx.reference_definitions() {
583            if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
584                continue;
585            }
586            let url = reference.url.as_str();
587            if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
588                continue;
589            }
590            index.add_md057_link_target(Md057LinkTarget {
591                target: url.to_string(),
592                origin: LinkOrigin::Body,
593            });
594        }
595
596        for link in frontmatter_values::link_destinations(ctx) {
597            let line = ctx.lines[link.line - 1].content(ctx.content);
598            let url = &line[link.range];
599            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
600                continue;
601            }
602            index.add_md057_link_target(Md057LinkTarget {
603                target: url.to_string(),
604                origin: LinkOrigin::FrontMatter { field: link.field },
605            });
606        }
607    }
608
609    /// The first additional search path holding a link target, as the file it
610    /// resolves to there.
611    fn resolve_in_search_paths(
612        decoded_path: &str,
613        search_paths: &[PathBuf],
614        policy: Option<&crate::lint_context::LinkTargetPolicy>,
615    ) -> Option<PathBuf> {
616        search_paths
617            .iter()
618            .find_map(|dir| Self::resolve_target(dir, &dir.join(decoded_path), policy))
619    }
620
621    /// The file a link target names, resolved against the directory the link is
622    /// written relative to. That directory is the anchor for the spelling
623    /// check: the components below it are the link author's, the ones above it
624    /// belong to wherever the project happens to sit.
625    fn resolve_target(
626        anchor: &Path,
627        path: &Path,
628        policy: Option<&crate::lint_context::LinkTargetPolicy>,
629    ) -> Option<PathBuf> {
630        if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
631            return Some(supplied);
632        }
633        if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
634            return None;
635        }
636        resolve_existing_target(anchor, path)
637    }
638
639    fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
640        if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
641            format!("Relative link '{url}' target not in the supplied document set")
642        } else {
643            format!("Relative link '{url}' does not exist")
644        }
645    }
646
647    /// Check if a relative link can be compacted and return the simplified form.
648    ///
649    /// Returns `None` if compact-paths is disabled, the link has no traversal,
650    /// or the link is already the shortest form.
651    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
652    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
653        if !self.config.compact_paths {
654            return None;
655        }
656
657        // Split URL into path and suffix (fragment/query)
658        let path_end = url
659            .find('?')
660            .unwrap_or(url.len())
661            .min(url.find('#').unwrap_or(url.len()));
662        let path_part = &url[..path_end];
663        let suffix = &url[path_end..];
664
665        // URL-decode the path portion for filesystem resolution
666        let decoded_path = url_decode(path_part);
667
668        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
669    }
670
671    /// Classify a relative link that points back at the file it is written in.
672    ///
673    /// Returns `None` when the option is off, when the file under check is
674    /// unknown, or when the link addresses anything else. Fragment-only links
675    /// never reach here: they are already the form this reports towards.
676    ///
677    /// The link is resolved against the file's own directory first and then
678    /// against each search path, matching how the existence check resolves it:
679    /// a link that MD057 accepts through a search path is recognized here as
680    /// well, and one that already resolves next to the document is judged
681    /// against that target alone.
682    fn self_referential_link(
683        &self,
684        url: &str,
685        base_path: &Path,
686        search_paths: &[PathBuf],
687        source_file: Option<&Path>,
688        policy: Option<&crate::lint_context::LinkTargetPolicy>,
689    ) -> Option<SelfReferentialLink> {
690        if !self.config.self_referential_links {
691            return None;
692        }
693        let source_file = source_file?;
694
695        let path_part = Self::strip_query_and_fragment(url);
696        if path_part.is_empty() {
697            return None;
698        }
699        let suffix = &url[path_part.len()..];
700
701        let decoded_path = url_decode(path_part);
702        // First hit wins, as it does for the existence check: a target next to
703        // the document is the one the link addresses, and a search path only
704        // answers for a link that resolves nowhere else.
705        let resolved = std::iter::once(base_path)
706            .chain(search_paths.iter().map(PathBuf::as_path))
707            .find_map(|dir| {
708                Self::resolve_target(dir, &Self::resolve_link_path_with_base(&decoded_path, dir), policy)
709            })?;
710        if !Self::is_same_file(&resolved, source_file) {
711            return None;
712        }
713
714        // A bare fragment reaches the same place. A query string does not
715        // survive being detached from its path, and neither does an empty
716        // fragment, so those are reported without a suggestion.
717        match suffix.strip_prefix('#') {
718            Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
719            _ => Some(SelfReferentialLink::WholeFile),
720        }
721    }
722
723    /// Byte range of a reference definition's destination in the document.
724    ///
725    /// Both the label and the title can repeat the destination text, so the
726    /// search is bounded to what sits between the label's closing bracket and
727    /// the title. Anchoring anywhere else risks a fix rewriting the label,
728    /// which would leave every usage of the reference dangling.
729    fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
730        let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
731        let label_end = Self::label_end(def)?;
732        let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
733            title.saturating_sub(ref_def.byte_offset).min(def.len())
734        });
735        let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
736        let start = ref_def.byte_offset + offset;
737        Some(start..start + ref_def.url.len())
738    }
739
740    /// Offset just past the `]:` that closes a reference definition's label.
741    ///
742    /// A label may itself contain a bracket when the bracket is escaped, so
743    /// escapes are skipped rather than matched.
744    fn label_end(def: &str) -> Option<usize> {
745        let bytes = def.as_bytes();
746        let mut i = 0;
747        while i < bytes.len() {
748            match bytes[i] {
749                b'\\' => i += 2,
750                b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
751                _ => i += 1,
752            }
753        }
754        None
755    }
756
757    /// Whether this document has frontmatter the rule is configured to check.
758    /// Gates the body-link early exits, which would otherwise skip a document
759    /// whose only destinations sit in its frontmatter.
760    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
761        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
762    }
763
764    /// The warning text for an absolute destination, or `None` when the
765    /// configured handling accepts it.
766    fn absolute_link_message(
767        &self,
768        url: &str,
769        anchor: &LazyAnchor<'_>,
770        project_root: &Path,
771        policy: Option<&crate::lint_context::LinkTargetPolicy>,
772    ) -> Option<String> {
773        match self.config.absolute_links {
774            AbsoluteLinksOption::Ignore => None,
775            AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
776            AbsoluteLinksOption::RelativeToDocs => {
777                Self::validate_absolute_link_via_docs_dir(url, &anchor.base_path, policy)
778            }
779            AbsoluteLinksOption::RelativeToRoots => {
780                Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root, policy)
781            }
782        }
783    }
784
785    /// Report frontmatter values that read as relative destinations but point
786    /// at nothing.
787    ///
788    /// Only existence is checked. The compact-path and self-referential
789    /// suggestions stay out of frontmatter: both rewrite a destination, and a
790    /// frontmatter value is only ever a guess at being one.
791    fn check_front_matter(
792        &self,
793        ctx: &crate::lint_context::LintContext,
794        anchor: &LazyAnchor<'_>,
795        project_root: &Path,
796        warnings: &mut Vec<LintWarning>,
797    ) {
798        if !self.config.check_frontmatter {
799            return;
800        }
801
802        let ignored: HashSet<String> = self
803            .config
804            .ignore_frontmatter_fields
805            .iter()
806            .map(|field| field.to_lowercase())
807            .collect();
808
809        for link in frontmatter_values::link_destinations(ctx) {
810            if link.field_is_in(&ignored) {
811                continue;
812            }
813
814            let line = ctx.lines[link.line - 1].content(ctx.content);
815            let url = &line[link.range.clone()];
816
817            // A fragment belongs to MD051, which validates it against the
818            // document's own headings.
819            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
820                continue;
821            }
822
823            let column = byte_to_char_count(line, link.range.start);
824            let end_column = column + url.chars().count();
825
826            if Self::is_absolute_path(url) {
827                if let Some(message) = self.absolute_link_message(url, anchor, project_root, ctx.link_target_policy()) {
828                    warnings.push(LintWarning {
829                        rule_name: Some(self.name().to_string()),
830                        line: link.line,
831                        column,
832                        end_line: link.line,
833                        end_column,
834                        message,
835                        severity: Severity::Warning,
836                        fix: None,
837                    });
838                }
839                continue;
840            }
841
842            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
843                continue;
844            }
845
846            warnings.push(LintWarning {
847                rule_name: Some(self.name().to_string()),
848                line: link.line,
849                column,
850                end_line: link.line,
851                end_column,
852                message: Self::missing_relative_message(url, ctx.link_target_policy()),
853                severity: Severity::Error,
854                fix: None,
855            });
856        }
857    }
858
859    /// Whether a relative destination resolves to something on disk.
860    fn relative_target_exists(
861        url: &str,
862        base_path: &Path,
863        search_paths: &[PathBuf],
864        policy: Option<&crate::lint_context::LinkTargetPolicy>,
865    ) -> bool {
866        Self::resolve_relative(url, base_path, search_paths, policy).is_some()
867    }
868
869    /// The file or directory a relative destination resolves to.
870    ///
871    /// The destination is stripped of its query and fragment, percent-decoded,
872    /// then resolved against `base_path`, with two fallbacks: an `.html`/`.htm`
873    /// target resolves to the markdown source it is generated from, and any
874    /// target resolves through the first of `search_paths` that holds it.
875    fn resolve_relative(
876        url: &str,
877        base_path: &Path,
878        search_paths: &[PathBuf],
879        policy: Option<&crate::lint_context::LinkTargetPolicy>,
880    ) -> Option<PathBuf> {
881        let decoded_path = link_path_part(url);
882        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
883
884        // An extensionless link is also tried with each markdown extension.
885        if let Some(target) = Self::resolve_target(base_path, &resolved_path, policy) {
886            return Some(target);
887        }
888
889        if let Some(source) =
890            html_markdown_sources(&resolved_path).find_map(|source| Self::resolve_target(base_path, &source, policy))
891        {
892            return Some(source);
893        }
894
895        Self::resolve_in_search_paths(&decoded_path, search_paths, policy)
896    }
897
898    /// The anchor of the document at `source_file`. The caller guarantees a
899    /// source file or an explicit base, so a base path always exists.
900    fn document_anchor(
901        &self,
902        flavor: crate::config::MarkdownFlavor,
903        source_file: Option<&Path>,
904        explicit_base: Option<PathBuf>,
905        project_root: &Path,
906    ) -> DocumentAnchor {
907        #[cfg(test)]
908        ANCHOR_RESOLUTIONS.with(|count| count.set(count.get() + 1));
909        let self_path =
910            source_file.map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
911        let base_path = explicit_base.unwrap_or_else(|| {
912            self_path
913                .as_deref()
914                .and_then(Path::parent)
915                .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
916        });
917        let search_paths = self.compute_search_paths(flavor, source_file, &base_path, project_root);
918        DocumentAnchor {
919            self_path,
920            base_path,
921            search_paths,
922        }
923    }
924
925    /// Whether any enabled check can offer a fix. Broken links and absolute
926    /// links are reported without one, so the answer rests on the two options
927    /// that rewrite a destination.
928    fn produces_fixes(&self) -> bool {
929        self.config.compact_paths || self.config.self_referential_links
930    }
931
932    /// The warning text for a link that points at its own file.
933    fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
934        match self_link {
935            SelfReferentialLink::Fragment(fragment) => {
936                format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
937            }
938            SelfReferentialLink::WholeFile => {
939                format!("Relative link '{url}' points to the file it is in")
940            }
941        }
942    }
943
944    /// Whether two existing paths are the same file.
945    ///
946    /// Both sides are canonicalized, which settles symlinks and the platform's
947    /// path representation; the lexical form is the fallback for a path the
948    /// filesystem cannot answer for.
949    fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
950        // Cheap reject before the syscall: a different name is a different file.
951        if resolved.file_name() != source_file.file_name() {
952            return false;
953        }
954        match (resolved.canonicalize(), source_file.canonicalize()) {
955            (Ok(link), Ok(source)) => link == source,
956            _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
957        }
958    }
959
960    /// Validate an absolute link against the MkDocs `docs_dir`.
961    ///
962    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
963    /// Falls back to a generic warning if no mkdocs.yml is found.
964    fn validate_absolute_link_via_docs_dir(
965        url: &str,
966        source_path: &Path,
967        policy: Option<&crate::lint_context::LinkTargetPolicy>,
968    ) -> Option<String> {
969        let Some(resolution) = Self::resolve_absolute_via_docs_dir(url, source_path, policy) else {
970            return Some(format!(
971                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
972            ));
973        };
974
975        match resolution {
976            Resolution::Found(_) => None,
977            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
978                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
979                resolved.display()
980            )),
981            Resolution::NotFound { resolved } => Some(format!(
982                "Absolute link '{url}' resolves to '{}' which does not exist",
983                resolved.display()
984            )),
985        }
986    }
987
988    /// Validate an absolute link by resolving it against each configured root and the project root.
989    ///
990    /// Configured `roots` are tried first (first match wins), then the project
991    /// root is tried as an implicit fallback. The fallback supports links
992    /// written as literal absolute paths from the project root (e.g.
993    /// `/content/en/foo.md`) alongside links written relative to a configured
994    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
995    /// emitted only when no root — configured or implicit — contains the target.
996    fn validate_absolute_link_via_roots(
997        url: &str,
998        roots: &[String],
999        project_root: &Path,
1000        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1001    ) -> Option<String> {
1002        if Self::resolve_absolute_via_roots(url, roots, project_root, policy).is_some() {
1003            return None;
1004        }
1005
1006        let msg = if roots.is_empty() {
1007            format!("Absolute link '{url}' was not found under the project root")
1008        } else {
1009            format!("Absolute link '{url}' was not found under any configured root or the project root")
1010        };
1011        Some(msg)
1012    }
1013
1014    /// Resolve an absolute link under the MkDocs `docs_dir` of the document at
1015    /// `source_path`, or `None` when no mkdocs.yml governs it.
1016    ///
1017    /// MkDocs mode: an extensionless directory link must have index.md, so
1018    /// `require_index_for_dirs = true` applies to every directory hit.
1019    fn resolve_absolute_via_docs_dir(
1020        url: &str,
1021        source_path: &Path,
1022        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1023    ) -> Option<Resolution> {
1024        let docs_dir = resolve_docs_dir(source_path)?;
1025        let decoded = Self::prepare_absolute_url(url);
1026        Some(Self::resolve_under_root_with_opts(&docs_dir, &decoded, true, policy))
1027    }
1028
1029    /// The target of an absolute link under the first configured root that
1030    /// holds it, then under the project root.
1031    ///
1032    /// Filesystem mode: an existing directory is a valid target, so
1033    /// `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
1034    fn resolve_absolute_via_roots(
1035        url: &str,
1036        roots: &[String],
1037        project_root: &Path,
1038        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1039    ) -> Option<PathBuf> {
1040        let decoded = Self::prepare_absolute_url(url);
1041        roots
1042            .iter()
1043            .map(|root| Self::resolve_against_project_root(root, project_root))
1044            .chain(std::iter::once(project_root.to_path_buf()))
1045            .find_map(
1046                |root| match Self::resolve_under_root_with_opts(&root, &decoded, false, policy) {
1047                    Resolution::Found(target) => Some(target),
1048                    _ => None,
1049                },
1050            )
1051    }
1052
1053    /// Decode an absolute-link URL into a filesystem-relative path. Strips the
1054    /// leading `/`, query/fragment suffix, and percent-encoding.
1055    fn prepare_absolute_url(url: &str) -> String {
1056        let relative_url = url.trim_start_matches('/');
1057        let file_path = Self::strip_query_and_fragment(relative_url);
1058        url_decode(file_path)
1059    }
1060
1061    /// Try to resolve a decoded absolute-link path under a single root directory.
1062    ///
1063    /// `require_index_for_dirs` says what a link that lands on a directory means,
1064    /// and the two answers belong to two different worlds:
1065    ///
1066    /// - `true` (MkDocs / docs-dir mode): a URL is a route, and MkDocs serves
1067    ///   `/section` from `section/index.md`. A directory with no `index.md` is a
1068    ///   route that 404s, so it is reported.
1069    ///
1070    /// - `false` (roots / filesystem mode): a link names a path on disk, so an
1071    ///   existing directory is a valid target. That is what relative links already
1072    ///   do (they ask only whether the target is there), and the punctuation of
1073    ///   the link does not change it: `/adir`, `/adir/` and `/adir/#section` all
1074    ///   name the same directory, and no router is going to turn one of them into
1075    ///   `index.md`.
1076    ///
1077    /// Every path is resolved under `root_path`, which is therefore the anchor
1078    /// for the spelling check: a target is found only under the case the link
1079    /// writes it in.
1080    ///
1081    /// A run with a supplied document set consults it first, the way relative
1082    /// links do; a closed-world run never looks at disk.
1083    ///
1084    /// On disk, strategies apply in order:
1085    /// 1. A directory hit, answered by the mode as described above. Must be checked
1086    ///    before `resolve_existing_target`, because a directory is one of
1087    ///    the things a target can be.
1088    /// 2. Direct existence (with markdown-extension fallback for extensionless links).
1089    /// 3. `.html`/`.htm` links: look for a markdown source with the same stem.
1090    ///
1091    /// `Found` carries the file the link lands on: the section's `index.md` for
1092    /// a docs-dir route, the directory itself in filesystem mode.
1093    fn resolve_under_root_with_opts(
1094        root_path: &Path,
1095        decoded: &str,
1096        require_index_for_dirs: bool,
1097        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1098    ) -> Resolution {
1099        let resolved = root_path.join(decoded);
1100
1101        if let Some(policy) = policy {
1102            if let Some(resolution) = Self::resolve_supplied_under_root(&resolved, require_index_for_dirs, policy) {
1103                return resolution;
1104            }
1105            if !policy.allow_disk_fallback() {
1106                return Resolution::NotFound { resolved };
1107            }
1108        }
1109
1110        if resolved.is_dir() && exists_exact_case(root_path, &resolved) {
1111            if !require_index_for_dirs {
1112                return Resolution::Found(resolved);
1113            }
1114            let index = resolved.join("index.md");
1115            return if exists_exact_case(root_path, &index) {
1116                Resolution::Found(index)
1117            } else {
1118                Resolution::DirectoryWithoutIndex { resolved }
1119            };
1120        }
1121
1122        if let Some(target) = resolve_existing_target(root_path, &resolved) {
1123            return Resolution::Found(target);
1124        }
1125
1126        // For .html/.htm links, accept a matching markdown source in the same
1127        // directory: supports doc sites that compile .md to .html.
1128        if let Some(source) = html_markdown_sources(&resolved).find(|source| exists_exact_case(root_path, source)) {
1129            return Resolution::Found(source);
1130        }
1131
1132        Resolution::NotFound { resolved }
1133    }
1134
1135    /// `resolve_under_root_with_opts` against the supplied document set.
1136    ///
1137    /// `None` leaves the answer to disk in an open-world run. A supplied
1138    /// directory without a supplied `index.md` is only reported in a closed
1139    /// world, where disk cannot supply one.
1140    fn resolve_supplied_under_root(
1141        resolved: &Path,
1142        require_index_for_dirs: bool,
1143        policy: &crate::lint_context::LinkTargetPolicy,
1144    ) -> Option<Resolution> {
1145        let Some(supplied) = policy.resolve_supplied(resolved) else {
1146            return html_markdown_sources(resolved)
1147                .find(|source| policy.contains(source))
1148                .map(Resolution::Found);
1149        };
1150        if policy.contains(&supplied) || !require_index_for_dirs {
1151            return Some(Resolution::Found(supplied));
1152        }
1153        let index = supplied.join("index.md");
1154        if policy.contains(&index) {
1155            Some(Resolution::Found(index))
1156        } else if policy.allow_disk_fallback() {
1157            None
1158        } else {
1159            Some(Resolution::DirectoryWithoutIndex {
1160                resolved: resolved.to_path_buf(),
1161            })
1162        }
1163    }
1164}
1165
1166/// Cache invalidation for the on-disk lint cache, which only exists in builds
1167/// with a filesystem and the `blake3` hasher (native and WASI). The browser build
1168/// has neither, so nothing here can be reached from it.
1169#[cfg(feature = "blake3")]
1170impl MD057ExistingRelativeLinks {
1171    /// Fingerprint the filesystem facts that can change this file's MD057 verdict.
1172    ///
1173    /// The caller supplies path inputs from a content-matched workspace-index
1174    /// entry. Resolution stays here so cache validation follows the same config,
1175    /// project-root, search-path, and flavor rules as `check`.
1176    pub fn cache_dependency_fingerprint(
1177        &self,
1178        source_file: &Path,
1179        flavor: crate::config::MarkdownFlavor,
1180        file_index: &FileIndex,
1181    ) -> String {
1182        let mut hasher = blake3::Hasher::new();
1183        hasher.update(b"rumdl-md057-dependencies-v1");
1184        if file_index.md057_link_targets.is_empty() {
1185            return hasher.finalize().to_hex().to_string();
1186        }
1187
1188        let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
1189        let project_root = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1190        // Only a relative destination needs the document's own location, so a
1191        // document whose targets are all absolute never touches the filesystem
1192        // to resolve it.
1193        let anchor = LazyCell::new(|| self.document_anchor(flavor, Some(source_file), explicit_base, &project_root));
1194        let ignored_frontmatter_fields: HashSet<String> = self
1195            .config
1196            .ignore_frontmatter_fields
1197            .iter()
1198            .map(|field| field.to_lowercase())
1199            .collect();
1200
1201        for dependency in &file_index.md057_link_targets {
1202            if let LinkOrigin::FrontMatter { field } = &dependency.origin
1203                && (!self.config.check_frontmatter
1204                    || field
1205                        .as_ref()
1206                        .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1207            {
1208                continue;
1209            }
1210
1211            let url = dependency.target.as_str();
1212            if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1213                continue;
1214            }
1215
1216            Self::hash_bytes(&mut hasher, url.as_bytes());
1217            if Self::is_absolute_path(url) {
1218                match self.config.absolute_links {
1219                    AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1220                    AbsoluteLinksOption::RelativeToDocs => {
1221                        hasher.update(b"docs");
1222                        if let Some(docs_dir) = resolve_docs_dir(source_file) {
1223                            Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1224                        } else {
1225                            hasher.update(b"no-docs-dir");
1226                        }
1227                    }
1228                    AbsoluteLinksOption::RelativeToRoots => {
1229                        hasher.update(b"roots");
1230                        let decoded = Self::prepare_absolute_url(url);
1231                        let mut found = false;
1232                        for root in &self.config.roots {
1233                            let root_path = Self::resolve_against_project_root(root, &project_root);
1234                            if Self::observe_under_root(&mut hasher, &root_path, &decoded, false) {
1235                                found = true;
1236                                break;
1237                            }
1238                        }
1239                        if !found {
1240                            Self::observe_under_root(&mut hasher, &project_root, &decoded, false);
1241                        }
1242                    }
1243                }
1244            } else {
1245                hasher.update(b"relative");
1246                if self.config.self_referential_links
1247                    && Self::observe_self_referential_resolution(
1248                        &mut hasher,
1249                        url,
1250                        &anchor.base_path,
1251                        &anchor.search_paths,
1252                        anchor.self_path.as_deref().unwrap_or(source_file),
1253                    )
1254                {
1255                    continue;
1256                }
1257                Self::observe_relative_resolution(&mut hasher, url, &anchor.base_path, &anchor.search_paths);
1258            }
1259        }
1260
1261        hasher.finalize().to_hex().to_string()
1262    }
1263
1264    fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1265        hasher.update(&(bytes.len() as u64).to_le_bytes());
1266        hasher.update(bytes);
1267    }
1268
1269    fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1270        #[cfg(unix)]
1271        {
1272            use std::os::unix::ffi::OsStrExt;
1273            Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1274        }
1275        #[cfg(windows)]
1276        {
1277            use std::os::windows::ffi::OsStrExt;
1278            let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1279            Self::hash_bytes(hasher, &encoded);
1280        }
1281        #[cfg(not(any(unix, windows)))]
1282        Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1283    }
1284
1285    /// Record one path and what is there, judged the way the check judges it: a
1286    /// target the filesystem answers for under a spelling it does not store
1287    /// counts as missing, so renaming a file to the case its links use changes
1288    /// the fingerprint and the cached verdict is thrown away.
1289    fn observe_path(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> DependencyPathState {
1290        Self::hash_path(hasher, path);
1291        let state = if exists_exact_case(anchor, path) {
1292            match std::fs::metadata(path) {
1293                Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1294                Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1295                Ok(_) => DependencyPathState::Other,
1296                Err(_) => DependencyPathState::Missing,
1297            }
1298        } else {
1299            DependencyPathState::Missing
1300        };
1301        hasher.update(&[match state {
1302            DependencyPathState::Missing => 0,
1303            DependencyPathState::File => 1,
1304            DependencyPathState::Directory => 2,
1305            DependencyPathState::Other => 3,
1306        }]);
1307        state
1308    }
1309
1310    fn observe_existing_target(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> Option<PathBuf> {
1311        if Self::observe_path(hasher, anchor, path) != DependencyPathState::Missing {
1312            return Some(path.to_path_buf());
1313        }
1314        if path.extension().is_none() {
1315            for extension in MARKDOWN_EXTENSIONS {
1316                let candidate = path.with_extension(&extension[1..]);
1317                if Self::observe_path(hasher, anchor, &candidate) != DependencyPathState::Missing {
1318                    return Some(candidate);
1319                }
1320            }
1321        }
1322        None
1323    }
1324
1325    fn observe_self_referential_resolution(
1326        hasher: &mut blake3::Hasher,
1327        url: &str,
1328        base_path: &Path,
1329        search_paths: &[PathBuf],
1330        source_file: &Path,
1331    ) -> bool {
1332        let decoded = url_decode(Self::strip_query_and_fragment(url));
1333        for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1334            let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1335            if let Some(resolved) = Self::observe_existing_target(hasher, directory, &candidate) {
1336                let canonical = resolved.canonicalize().unwrap_or(resolved);
1337                hasher.update(b"resolved-identity");
1338                Self::hash_path(hasher, &canonical);
1339                return Self::is_same_file(&canonical, source_file);
1340            }
1341        }
1342        false
1343    }
1344
1345    fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1346        let decoded = url_decode(Self::strip_query_and_fragment(url));
1347        let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1348        if Self::observe_existing_target(hasher, base_path, &resolved).is_some() {
1349            return;
1350        }
1351
1352        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1353            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1354            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1355            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1356                Self::observe_path(hasher, base_path, &parent.join(format!("{stem}{extension}")))
1357                    != DependencyPathState::Missing
1358            })
1359        {
1360            return;
1361        }
1362
1363        for search_path in search_paths {
1364            if Self::observe_existing_target(hasher, search_path, &search_path.join(&decoded)).is_some() {
1365                return;
1366            }
1367        }
1368    }
1369
1370    fn observe_absolute_resolution(
1371        hasher: &mut blake3::Hasher,
1372        root: &Path,
1373        url: &str,
1374        require_index_for_dirs: bool,
1375    ) -> bool {
1376        let decoded = Self::prepare_absolute_url(url);
1377        Self::observe_under_root(hasher, root, &decoded, require_index_for_dirs)
1378    }
1379
1380    /// Mirror of `resolve_under_root_with_opts` that records the filesystem facts
1381    /// the verdict rests on. Any change to the resolution order there has to land
1382    /// here too, or a cached verdict outlives the state that produced it.
1383    fn observe_under_root(
1384        hasher: &mut blake3::Hasher,
1385        root: &Path,
1386        decoded: &str,
1387        require_index_for_dirs: bool,
1388    ) -> bool {
1389        let resolved = root.join(decoded);
1390        let resolved_state = Self::observe_path(hasher, root, &resolved);
1391
1392        if resolved_state == DependencyPathState::Directory {
1393            if !require_index_for_dirs {
1394                return true;
1395            }
1396            return Self::observe_path(hasher, root, &resolved.join("index.md")) != DependencyPathState::Missing;
1397        }
1398
1399        if resolved_state != DependencyPathState::Missing {
1400            return true;
1401        }
1402        if resolved.extension().is_none()
1403            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1404                Self::observe_path(hasher, root, &resolved.with_extension(&extension[1..]))
1405                    != DependencyPathState::Missing
1406            })
1407        {
1408            return true;
1409        }
1410
1411        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1412            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1413            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1414        {
1415            return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1416                Self::observe_path(hasher, root, &parent.join(format!("{stem}{extension}")))
1417                    != DependencyPathState::Missing
1418            });
1419        }
1420
1421        false
1422    }
1423}
1424
1425/// Outcome of trying to resolve an absolute link under a single root directory.
1426/// Carries the resolved path on the failure variants so callers can build
1427/// specific error messages without recomputing it.
1428enum Resolution {
1429    Found(PathBuf),
1430    DirectoryWithoutIndex { resolved: PathBuf },
1431    NotFound { resolved: PathBuf },
1432}
1433
1434/// Search `re` in `line` starting at `expected_start`, accepting the match
1435/// only if it begins exactly there.
1436///
1437/// `Regex::captures_at` searches starting at the given offset but does not
1438/// require the match to *begin* there. A bracket's own destination that
1439/// fails to match at its own position (a fragment-only `(#bar)`, an empty
1440/// `()`) would otherwise silently slide forward and return a later,
1441/// unrelated match belonging to a different bracket on the same line.
1442/// Rejecting a non-anchored match treats that case as "no destination for
1443/// this bracket" instead of borrowing a sibling bracket's URL.
1444fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1445    let caps = re.captures_at(line, expected_start)?;
1446    if caps.get(0)?.start() != expected_start {
1447        return None;
1448    }
1449    Some(caps)
1450}
1451
1452/// The destination of one inline link, located in the document.
1453struct BodyLinkDestination<'a> {
1454    /// The destination without its fragment. This is the path the existence
1455    /// check resolves and the text a warning names.
1456    url: &'a str,
1457    /// The fragment that follows the destination, `#` included, or empty when
1458    /// the link carries none.
1459    fragment: &'a str,
1460    /// Byte range of `url` in the document, used for the warning position.
1461    url_range: std::ops::Range<usize>,
1462    /// Byte range covering the destination together with its fragment. A
1463    /// rewrite spans this range so the fragment survives the edit.
1464    fix_range: std::ops::Range<usize>,
1465}
1466
1467impl<'a> BodyLinkDestination<'a> {
1468    /// The destination as the document spells it, fragment included. A
1469    /// destination carrying no fragment is borrowed rather than rebuilt.
1470    fn full_url(&self) -> Cow<'a, str> {
1471        if self.fragment.is_empty() {
1472            Cow::Borrowed(self.url)
1473        } else {
1474            Cow::Owned(format!("{}{}", self.url, self.fragment))
1475        }
1476    }
1477}
1478
1479/// Every inline link destination in the document body, in document order.
1480///
1481/// The check pass and the cross-file index pass both read destinations through
1482/// this one iterator, so they always see the same set of links. Each
1483/// destination is located inside the link's own source span, which is what
1484/// lets a link whose text wraps onto another line be read at all.
1485///
1486/// Only `LinkType::Inline` qualifies, because that is the one shape whose
1487/// destination is a path written in the link itself. The gate excludes a
1488/// reference link, whose destination is written in the definition that both
1489/// passes read separately; a wiki link, whose target names a vault entry
1490/// rather than a path relative to this file; and an autolink or an email
1491/// address, which are spelled between angle brackets and carry no destination
1492/// to extract at all. Destinations in frontmatter, PyMdown blocks, code spans,
1493/// math spans and template shortcodes are skipped as well, as is a link
1494/// written inside an image's description, which renders as alt text rather
1495/// than as a hyperlink.
1496fn body_link_destinations<'ctx>(
1497    ctx: &'ctx crate::lint_context::LintContext<'_>,
1498) -> impl Iterator<Item = BodyLinkDestination<'ctx>> + 'ctx {
1499    // Links and images both leave the parser sorted by start offset, so one
1500    // sweep over the links finds the images around each of them. Images
1501    // either nest or are disjoint, never partially overlap, so the images
1502    // open at any offset form a chain with each inside the one below it. A
1503    // stack of them, popped once the sweep passes an image's end, holds
1504    // exactly the images around the current offset, and every image is pushed
1505    // and popped once, so the sweep is linear in images plus links.
1506    let mut pending_images = ctx.images().iter().peekable();
1507    let mut open_images: Vec<&crate::lint_context::ParsedImage<'_>> = Vec::new();
1508    let mut previous_link_offset = 0usize;
1509    ctx.links().iter().filter_map(move |link| {
1510        debug_assert!(
1511            link.byte_offset >= previous_link_offset,
1512            "the links arrive sorted by start offset, which is what lets one forward pass over the images find the ones around each link"
1513        );
1514        previous_link_offset = link.byte_offset;
1515        if !matches!(link.link_type, LinkType::Inline) {
1516            return None;
1517        }
1518        if ctx
1519            .line_info(link.line)
1520            .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1521        {
1522            return None;
1523        }
1524        if ctx.is_in_code_span_byte(link.byte_offset)
1525            || ctx.is_in_math_span(link.byte_offset)
1526            || ctx.is_in_shortcode(link.byte_offset)
1527        {
1528            return None;
1529        }
1530        // Link syntax inside an image's description renders as the text of an
1531        // `alt` attribute and never as a hyperlink, so its destination names
1532        // nothing. The whole link has to lie inside the image: an image used
1533        // as a link's text starts after the link does, which leaves the link's
1534        // own destination readable.
1535        while let Some(image) = pending_images.next_if(|image| image.byte_offset <= link.byte_offset) {
1536            close_images_ending_by(&mut open_images, image.byte_offset);
1537            open_images.push(image);
1538        }
1539        close_images_ending_by(&mut open_images, link.byte_offset);
1540        if open_images
1541            .iter()
1542            .any(|image| link.byte_end <= image.byte_end && renders_as_image(ctx, image))
1543        {
1544            return None;
1545        }
1546        locate_destination(ctx.content, link)
1547    })
1548}
1549
1550/// Pops every open image the sweep has passed the end of. The stack is a
1551/// chain of nested images, so the ends shrink from bottom to top and the
1552/// first image still open past `offset` stops the popping.
1553fn close_images_ending_by(open_images: &mut Vec<&crate::lint_context::ParsedImage<'_>>, offset: usize) {
1554    while open_images.last().is_some_and(|image| image.byte_end <= offset) {
1555        open_images.pop();
1556    }
1557}
1558
1559/// Whether an image record is something the renderer turns into an image.
1560///
1561/// A reference image is only an image once its definition is found. Without
1562/// one, the renderer leaves every bracket as text and whatever is written
1563/// between them keeps its own meaning, so a link in there is a real link. An
1564/// inline image is always an image, an empty destination included, because the
1565/// parentheses are what make it one.
1566fn renders_as_image(ctx: &crate::lint_context::LintContext<'_>, image: &crate::lint_context::ParsedImage<'_>) -> bool {
1567    if !image.is_reference {
1568        return true;
1569    }
1570    image
1571        .reference_id
1572        .as_ref()
1573        .is_some_and(|id| ctx.reference_definition(id).is_some())
1574}
1575
1576/// The destination written inside one link's source span.
1577///
1578/// The search is anchored at the `]` that closes the link text, so a link text
1579/// carrying a newline is read exactly like one written on a single line. That
1580/// bracket comes from the parse: the parsed text is the source between the
1581/// brackets, so the closing one sits just past it. Taking it from the parse
1582/// rather than walking the bytes again is what keeps a label holding a code
1583/// span, an escaped bracket or inline HTML with a bracket in an attribute
1584/// value from being read as ending somewhere else. A shape whose text is not
1585/// the source between brackets, an autolink above all, lands on something
1586/// other than `](` and carries no destination. The angle-bracketed spelling is
1587/// tried first, because a path holding parentheses is only read whole in that
1588/// form.
1589fn locate_destination<'a>(
1590    content: &'a str,
1591    link: &crate::lint_context::ParsedLink<'_>,
1592) -> Option<BodyLinkDestination<'a>> {
1593    let span = content.get(link.byte_offset..link.byte_end)?;
1594    let anchor = 1 + link.text.len();
1595    if !span.get(anchor..).is_some_and(|rest| rest.starts_with("](")) {
1596        return None;
1597    }
1598    let caps = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, span, anchor)
1599        .or_else(|| extract_url_at(&URL_EXTRACT_REGEX, span, anchor))?;
1600    let url_group = caps.get(1)?;
1601    let fragment = caps.get(2);
1602
1603    let span_start = link.byte_offset;
1604    let url_range = span_start + url_group.start()..span_start + url_group.end();
1605    let fix_end = fragment.map_or(url_range.end, |group| span_start + group.end());
1606
1607    Some(BodyLinkDestination {
1608        url: url_group.as_str().trim(),
1609        fragment: fragment.map_or("", |group| group.as_str()),
1610        fix_range: url_range.start..fix_end,
1611        url_range,
1612    })
1613}
1614
1615impl Rule for MD057ExistingRelativeLinks {
1616    fn name(&self) -> &'static str {
1617        "MD057"
1618    }
1619
1620    fn description(&self) -> &'static str {
1621        "Relative links should point to existing files"
1622    }
1623
1624    fn category(&self) -> RuleCategory {
1625        RuleCategory::Link
1626    }
1627
1628    fn skippable_by_category(&self) -> bool {
1629        // A frontmatter path is a link this rule resolves, and the document
1630        // holding it needs no link syntax anywhere else.
1631        !self.config.check_frontmatter
1632    }
1633
1634    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1635        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1636    }
1637
1638    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1639        let content = ctx.content;
1640
1641        if content.is_empty() {
1642            return Ok(Vec::new());
1643        }
1644
1645        // Early returns for performance. A document whose only destinations
1646        // sit in its frontmatter has no link syntax to find, so the body-link
1647        // shortcuts only apply when frontmatter is not being checked.
1648        let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1649        if !has_body_links && !self.checks_front_matter_of(ctx) {
1650            return Ok(Vec::new());
1651        }
1652
1653        // Reset the filesystem caches for a fresh run
1654        reset_file_existence_cache();
1655
1656        let mut warnings = Vec::new();
1657
1658        // Read the explicit base path (set via `with_path()` in tests) once; it
1659        // doubles as both the per-file base path and the project root override
1660        // for absolute-link resolution.
1661        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1662
1663        // Project root used for absolute-link resolution against configured
1664        // `roots` and as the implicit fallback root. The explicit base wins
1665        // when set; otherwise the discovered project root is used.
1666        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1667
1668        // Without a source file or an explicit base there is no directory to
1669        // resolve relative links from, so nothing can be validated.
1670        let source_file = ctx.source_file();
1671        if explicit_base.is_none() && source_file.is_none() {
1672            return Ok(warnings);
1673        }
1674        // Each file resolves from its own directory, never from a base path
1675        // cached by an earlier check.
1676        let anchor: LazyAnchor<'_> = LazyCell::new(Box::new(|| {
1677            self.document_anchor(ctx.flavor, source_file, explicit_base, &project_root)
1678        }));
1679
1680        // Destinations come from the parse, so a link whose text wraps onto
1681        // another line is read the same as one written on a single line. Every
1682        // report on a link points at its destination, which sits on the line
1683        // the link ends on rather than the line it starts on.
1684        for destination in body_link_destinations(ctx) {
1685            let url = destination.url;
1686
1687            // Skip empty URLs
1688            if url.is_empty() {
1689                continue;
1690            }
1691
1692            // Skip rustdoc intra-doc links (backtick-wrapped URLs)
1693            // These are Rust API references, not file paths
1694            // Example: [`f32::is_subnormal`], [`Vec::push`]
1695            if url.starts_with('`') && url.ends_with('`') {
1696                continue;
1697            }
1698
1699            // Skip external URLs and fragment-only links
1700            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1701                continue;
1702            }
1703
1704            // Handle absolute paths based on config
1705            if Self::is_absolute_path(url) {
1706                if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1707                {
1708                    let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1709                    warnings.push(LintWarning {
1710                        rule_name: Some(self.name().to_string()),
1711                        line,
1712                        column,
1713                        end_line: line,
1714                        end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1715                        message,
1716                        severity: Severity::Warning,
1717                        fix: None,
1718                    });
1719                }
1720                continue;
1721            }
1722
1723            // The compaction and the self-link check both read the destination
1724            // together with its fragment, because both rewrite the whole of it.
1725            let full_url = destination.full_url();
1726
1727            // A link back into the current file. Reported instead of
1728            // the compaction below, whose shorter path would still
1729            // be a link the reader should not follow, and instead
1730            // of the existence check, which this target passes.
1731            if let Some(self_link) = self.self_referential_link(
1732                &full_url,
1733                &anchor.base_path,
1734                &anchor.search_paths,
1735                anchor.self_path.as_deref(),
1736                ctx.link_target_policy(),
1737            ) {
1738                let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1739                warnings.push(LintWarning {
1740                    rule_name: Some(self.name().to_string()),
1741                    line,
1742                    column,
1743                    end_line: line,
1744                    end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1745                    message: Self::self_referential_message(&full_url, &self_link),
1746                    severity: Severity::Warning,
1747                    fix: match &self_link {
1748                        SelfReferentialLink::Fragment(fragment) => {
1749                            Some(Fix::new(destination.fix_range.clone(), fragment.clone()))
1750                        }
1751                        SelfReferentialLink::WholeFile => None,
1752                    },
1753                });
1754                continue;
1755            }
1756
1757            if let Some(suggestion) = self.compact_path_suggestion(&full_url, &anchor.base_path) {
1758                let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1759                warnings.push(LintWarning {
1760                    rule_name: Some(self.name().to_string()),
1761                    line,
1762                    column,
1763                    end_line: line,
1764                    end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1765                    message: format!("Relative link '{full_url}' can be simplified to '{suggestion}'"),
1766                    severity: Severity::Warning,
1767                    fix: Some(Fix::new(destination.fix_range.clone(), suggestion)),
1768                });
1769            }
1770
1771            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1772                continue;
1773            }
1774
1775            // File doesn't exist and no source file found
1776            let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1777            warnings.push(LintWarning {
1778                rule_name: Some(self.name().to_string()),
1779                line,
1780                column,
1781                end_line: line,
1782                end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1783                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1784                severity: Severity::Error,
1785                fix: None,
1786            });
1787        }
1788
1789        // Also process images - they have URLs already parsed
1790        for image in ctx.images() {
1791            // Skip images inside PyMdown blocks (MkDocs flavor)
1792            if ctx
1793                .line_info(image.line)
1794                .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1795            {
1796                continue;
1797            }
1798
1799            // A wiki embed names a vault entry, not a path relative to this
1800            // file: `![[diagram.png]]` resolves wherever the attachment lives.
1801            // The links loop already leaves `[[diagram.png]]` alone.
1802            if matches!(image.link_type, LinkType::WikiLink { .. }) {
1803                continue;
1804            }
1805
1806            // Image syntax inside a template shortcode tag is a parameter the
1807            // template resolves, not a path relative to this file.
1808            if ctx.is_in_shortcode(image.byte_offset) {
1809                continue;
1810            }
1811
1812            let url = image.url.as_ref();
1813
1814            // Skip empty URLs
1815            if url.is_empty() {
1816                continue;
1817            }
1818
1819            // Skip external URLs and fragment-only links
1820            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1821                continue;
1822            }
1823
1824            // Handle absolute paths based on config
1825            if Self::is_absolute_path(url) {
1826                if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1827                {
1828                    warnings.push(LintWarning {
1829                        rule_name: Some(self.name().to_string()),
1830                        line: image.line,
1831                        column: image.start_col + 1,
1832                        end_line: image.line,
1833                        end_column: image.start_col + 1 + url.chars().count(),
1834                        message,
1835                        severity: Severity::Warning,
1836                        fix: None,
1837                    });
1838                }
1839                continue;
1840            }
1841
1842            // Check for unnecessary path traversal (compact-paths)
1843            if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1844                // Find the URL position within the image syntax using document byte offsets.
1845                // Search from image.byte_offset (the `!` character) to locate the URL string.
1846                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1847                    let fix_byte_start = image.byte_offset + url_offset;
1848                    let fix_byte_end = fix_byte_start + url.len();
1849                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1850                });
1851
1852                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1853                let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1854                // The fix range is a document byte offset; the displayed column is
1855                // the corresponding character offset within the line.
1856                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1857                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1858                });
1859                warnings.push(LintWarning {
1860                    rule_name: Some(self.name().to_string()),
1861                    line: image.line,
1862                    column: url_col,
1863                    end_line: image.line,
1864                    end_column: url_col + url.chars().count(),
1865                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1866                    severity: Severity::Warning,
1867                    fix,
1868                });
1869            }
1870
1871            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1872                continue;
1873            }
1874
1875            // File doesn't exist and no source file found
1876            // Images already have correct position from parser
1877            warnings.push(LintWarning {
1878                rule_name: Some(self.name().to_string()),
1879                line: image.line,
1880                column: image.start_col + 1,
1881                end_line: image.line,
1882                end_column: image.start_col + 1 + url.chars().count(),
1883                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1884                severity: Severity::Error,
1885                fix: None,
1886            });
1887        }
1888
1889        // Also process reference definitions: [ref]: ./path.md
1890        for ref_def in ctx.reference_definitions() {
1891            if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1892                continue;
1893            }
1894            let url = &ref_def.url;
1895
1896            // Skip empty URLs
1897            if url.is_empty() {
1898                continue;
1899            }
1900
1901            // Skip external URLs and fragment-only links
1902            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1903                continue;
1904            }
1905
1906            // Where this definition's destination sits, shared by every report
1907            // on it. Without a located destination a warning falls back to the
1908            // start of the definition's line and offers no fix.
1909            let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1910            let (line, col) = url_range
1911                .as_ref()
1912                .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1913            let end_col = col + url.chars().count();
1914
1915            // Handle absolute paths based on config
1916            if Self::is_absolute_path(url) {
1917                if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1918                {
1919                    warnings.push(LintWarning {
1920                        rule_name: Some(self.name().to_string()),
1921                        line,
1922                        column: col,
1923                        end_line: line,
1924                        end_column: end_col,
1925                        message,
1926                        severity: Severity::Warning,
1927                        fix: None,
1928                    });
1929                }
1930                continue;
1931            }
1932
1933            // A definition whose destination is the file holding it.
1934            if let Some(self_link) = self.self_referential_link(
1935                url,
1936                &anchor.base_path,
1937                &anchor.search_paths,
1938                anchor.self_path.as_deref(),
1939                ctx.link_target_policy(),
1940            ) {
1941                warnings.push(LintWarning {
1942                    rule_name: Some(self.name().to_string()),
1943                    line,
1944                    column: col,
1945                    end_line: line,
1946                    end_column: end_col,
1947                    message: Self::self_referential_message(url, &self_link),
1948                    severity: Severity::Warning,
1949                    fix: match (&self_link, &url_range) {
1950                        (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1951                            Some(Fix::new(range.clone(), fragment.clone()))
1952                        }
1953                        _ => None,
1954                    },
1955                });
1956                continue;
1957            }
1958
1959            // Check for unnecessary path traversal (compact-paths)
1960            if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1961                warnings.push(LintWarning {
1962                    rule_name: Some(self.name().to_string()),
1963                    line,
1964                    column: col,
1965                    end_line: line,
1966                    end_column: end_col,
1967                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1968                    severity: Severity::Warning,
1969                    fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1970                });
1971            }
1972
1973            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1974                continue;
1975            }
1976
1977            // File doesn't exist and no source file found
1978            warnings.push(LintWarning {
1979                rule_name: Some(self.name().to_string()),
1980                line,
1981                column: col,
1982                end_line: line,
1983                end_column: end_col,
1984                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1985                severity: Severity::Error,
1986                fix: None,
1987            });
1988        }
1989
1990        self.check_front_matter(ctx, &anchor, &project_root, &mut warnings);
1991
1992        Ok(warnings)
1993    }
1994
1995    fn fix_capability(&self) -> FixCapability {
1996        if self.produces_fixes() {
1997            FixCapability::ConditionallyFixable
1998        } else {
1999            FixCapability::Unfixable
2000        }
2001    }
2002
2003    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
2004        if !self.produces_fixes() {
2005            return Ok(ctx.content.to_string());
2006        }
2007
2008        let warnings = self.check(ctx)?;
2009        let warnings =
2010            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
2011        let mut content = ctx.content.to_string();
2012
2013        // Collect fixable warnings (compact-paths) sorted by byte offset descending
2014        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
2015        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
2016
2017        // Applying fixes right-to-left lets each range stay valid against the
2018        // still-unshifted content to its left. A duplicate or overlapping fix
2019        // would otherwise be applied a second time against content already
2020        // rewritten by an earlier fix, corrupting it; skip any fix whose range
2021        // overlaps the one most recently applied.
2022        let mut last_applied_start: Option<usize> = None;
2023        for fix in fixes {
2024            if let Some(prev_start) = last_applied_start
2025                && fix.range.end > prev_start
2026            {
2027                continue;
2028            }
2029            if fix.range.end <= content.len() {
2030                content.replace_range(fix.range.clone(), &fix.replacement);
2031                last_applied_start = Some(fix.range.start);
2032            }
2033        }
2034
2035        Ok(content)
2036    }
2037
2038    fn as_any(&self) -> &dyn std::any::Any {
2039        self
2040    }
2041
2042    crate::impl_rule_config_sections!(MD057Config);
2043
2044    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
2045    where
2046        Self: Sized,
2047    {
2048        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
2049        // The flavor is deliberately not captured here: Obsidian attachment-folder
2050        // detection reads `ctx.flavor`, which resolves per file, so a rule built
2051        // once for a workspace still honors a per-file flavor override.
2052        Box::new(Self::from_config_struct(rule_config))
2053    }
2054
2055    fn cross_file_scope(&self) -> CrossFileScope {
2056        CrossFileScope::Workspace
2057    }
2058
2059    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
2060        self.contribute_dependency_targets(ctx, index);
2061
2062        // Use the shared utility for cross-file link extraction
2063        // This ensures consistent position tracking between CLI and LSP
2064        let links = extract_cross_file_links(ctx);
2065        for link in links.relative {
2066            index.add_cross_file_link(link);
2067        }
2068        // Root-relative links are not linted, but indexing them keeps the cached
2069        // index complete so the LSP can resolve them for find-references.
2070        for link in links.root_relative {
2071            index.add_root_relative_link(link);
2072        }
2073    }
2074
2075    fn cross_file_check(
2076        &self,
2077        _file_path: &Path,
2078        _file_index: &FileIndex,
2079        _workspace_index: &crate::workspace_index::WorkspaceIndex,
2080    ) -> LintResult {
2081        // All link targets are already validated by check() on each per-file pass.
2082        // check() resolves relative links against the file's own directory, handles
2083        // configured search paths, and applies the absolute_links config.
2084        // Validating them here too would produce identical duplicate warnings for
2085        // every broken link. (#631)
2086        //
2087        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
2088        // remains in place to support future cross-file analyses (e.g. heading-anchor
2089        // validation across files).
2090        Ok(Vec::new())
2091    }
2092}
2093
2094/// Compute the shortest relative path from `from_dir` to `to_path`.
2095///
2096/// Both paths must be normalized (no `.` or `..` components).
2097/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
2098fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
2099    let from_components: Vec<_> = from_dir.components().collect();
2100    let to_components: Vec<_> = to_path.components().collect();
2101
2102    // Find common prefix length
2103    let common_len = from_components
2104        .iter()
2105        .zip(to_components.iter())
2106        .take_while(|(a, b)| a == b)
2107        .count();
2108
2109    let mut result = PathBuf::new();
2110
2111    // Go up for each remaining component in from_dir
2112    for _ in common_len..from_components.len() {
2113        result.push("..");
2114    }
2115
2116    // Append remaining components from to_path
2117    for component in &to_components[common_len..] {
2118        result.push(component);
2119    }
2120
2121    result
2122}
2123
2124/// Check if a relative link path can be shortened.
2125///
2126/// Given the source directory and the raw link path, computes whether there's
2127/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
2128/// be simplified, `None` if it's already optimal.
2129fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
2130    let link_path = Path::new(raw_link_path);
2131
2132    // Only check paths that contain traversal (../ or ./)
2133    let has_traversal = link_path
2134        .components()
2135        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
2136
2137    if !has_traversal {
2138        return None;
2139    }
2140
2141    // Resolve: source_dir + raw_link_path, then normalize
2142    let combined = source_dir.join(link_path);
2143    let normalized_target = normalize_relative_path(&combined);
2144
2145    // Compute shortest path from source_dir back to the normalized target
2146    let normalized_source = normalize_relative_path(source_dir);
2147    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
2148
2149    // Compare against the raw link path — if it differs, the path can be compacted
2150    if shortest != link_path {
2151        let compact = shortest.to_string_lossy().to_string();
2152        // Avoid suggesting empty path
2153        if compact.is_empty() {
2154            return None;
2155        }
2156        // Markdown links always use forward slashes regardless of platform
2157        Some(compact.replace('\\', "/"))
2158    } else {
2159        None
2160    }
2161}
2162
2163#[cfg(test)]
2164mod tests {
2165    use super::*;
2166    use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
2167    use std::fs::File;
2168    use std::io::Write;
2169    use tempfile::tempdir;
2170
2171    #[test]
2172    fn test_strip_query_and_fragment() {
2173        // Test query parameter stripping
2174        assert_eq!(
2175            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
2176            "file.png"
2177        );
2178        assert_eq!(
2179            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
2180            "file.png"
2181        );
2182        assert_eq!(
2183            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
2184            "file.png"
2185        );
2186
2187        // Test fragment stripping
2188        assert_eq!(
2189            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
2190            "file.md"
2191        );
2192        assert_eq!(
2193            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
2194            "file.md"
2195        );
2196
2197        // Test both query and fragment (query comes first, per RFC 3986)
2198        assert_eq!(
2199            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
2200            "file.md"
2201        );
2202
2203        // Test no query or fragment
2204        assert_eq!(
2205            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
2206            "file.png"
2207        );
2208
2209        // Test with path
2210        assert_eq!(
2211            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
2212            "path/to/image.png"
2213        );
2214        assert_eq!(
2215            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
2216            "path/to/image.png"
2217        );
2218
2219        // Edge case: fragment before query (non-standard but possible)
2220        assert_eq!(
2221            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
2222            "file.md"
2223        );
2224    }
2225
2226    #[test]
2227    fn test_url_decode() {
2228        // Simple space encoding
2229        assert_eq!(url_decode("penguin%20with%20space.jpg"), "penguin with space.jpg");
2230
2231        // Path with encoded spaces
2232        assert_eq!(url_decode("assets/my%20file%20name.png"), "assets/my file name.png");
2233
2234        // Multiple encoded characters
2235        assert_eq!(url_decode("hello%20world%21.md"), "hello world!.md");
2236
2237        // Lowercase hex
2238        assert_eq!(url_decode("%2f%2e%2e"), "/..");
2239
2240        // Uppercase hex
2241        assert_eq!(url_decode("%2F%2E%2E"), "/..");
2242
2243        // Mixed case hex
2244        assert_eq!(url_decode("%2f%2E%2e"), "/..");
2245
2246        // No encoding - return as-is
2247        assert_eq!(url_decode("normal-file.md"), "normal-file.md");
2248
2249        // Incomplete percent encoding - leave as-is
2250        assert_eq!(url_decode("file%2.txt"), "file%2.txt");
2251
2252        // Percent at end - leave as-is
2253        assert_eq!(url_decode("file%"), "file%");
2254
2255        // Invalid hex digits - leave as-is
2256        assert_eq!(url_decode("file%GG.txt"), "file%GG.txt");
2257
2258        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
2259        assert_eq!(url_decode("file+name.txt"), "file+name.txt");
2260
2261        // Empty string
2262        assert_eq!(url_decode(""), "");
2263
2264        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
2265        assert_eq!(url_decode("caf%C3%A9.md"), "café.md");
2266
2267        // Multiple consecutive encoded characters
2268        assert_eq!(url_decode("%20%20%20"), "   ");
2269
2270        // Encoded path separators
2271        assert_eq!(url_decode("path%2Fto%2Ffile.md"), "path/to/file.md");
2272
2273        // Mixed encoded and non-encoded
2274        assert_eq!(url_decode("hello%20world/foo%20bar.md"), "hello world/foo bar.md");
2275
2276        // Special characters that are commonly encoded
2277        assert_eq!(url_decode("file%5B1%5D.md"), "file[1].md");
2278
2279        // Percent at position that looks like encoding but isn't valid
2280        assert_eq!(url_decode("100%pure.md"), "100%pure.md");
2281    }
2282
2283    #[test]
2284    fn test_url_encoded_filenames() {
2285        // Create a temporary directory for test files
2286        let temp_dir = tempdir().unwrap();
2287        let base_path = temp_dir.path();
2288
2289        // Create a file with spaces in the name
2290        let file_with_spaces = base_path.join("penguin with space.jpg");
2291        File::create(&file_with_spaces)
2292            .unwrap()
2293            .write_all(b"image data")
2294            .unwrap();
2295
2296        // Create a subdirectory with spaces
2297        let subdir = base_path.join("my images");
2298        std::fs::create_dir(&subdir).unwrap();
2299        let nested_file = subdir.join("photo 1.png");
2300        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2301
2302        // Test content with URL-encoded links
2303        let content = r#"
2304# Test Document with URL-Encoded Links
2305
2306![Penguin](penguin%20with%20space.jpg)
2307![Photo](my%20images/photo%201.png)
2308![Missing](missing%20file.jpg)
2309"#;
2310
2311        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2312
2313        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2314        let result = rule.check(&ctx).unwrap();
2315
2316        // Should only have one warning for the missing file
2317        assert_eq!(
2318            result.len(),
2319            1,
2320            "Should only warn about missing%20file.jpg. Got: {result:?}"
2321        );
2322        assert!(
2323            result[0].message.contains("missing%20file.jpg"),
2324            "Warning should mention the URL-encoded filename"
2325        );
2326    }
2327
2328    #[test]
2329    fn test_external_urls() {
2330        // Common web protocols
2331        assert!(is_external_url("https://example.com"));
2332        assert!(is_external_url("http://example.com"));
2333        assert!(is_external_url("ftp://example.com"));
2334        assert!(is_external_url("www.example.com"));
2335        assert!(is_external_url("example.com"));
2336
2337        // Special URI schemes
2338        assert!(is_external_url("file:///path/to/file"));
2339        assert!(is_external_url("smb://server/share"));
2340        assert!(is_external_url("macappstores://apps.apple.com/"));
2341        assert!(is_external_url("mailto:user@example.com"));
2342        assert!(is_external_url("tel:+1234567890"));
2343        assert!(is_external_url("data:text/plain;base64,SGVsbG8="));
2344        assert!(is_external_url("javascript:void(0)"));
2345        assert!(is_external_url("ssh://git@github.com/repo"));
2346        assert!(is_external_url("git://github.com/repo.git"));
2347
2348        // Email addresses without mailto: protocol
2349        // These are clearly not file links and should be skipped
2350        assert!(is_external_url("user@example.com"));
2351        assert!(is_external_url("steering@kubernetes.io"));
2352        assert!(is_external_url("john.doe+filter@company.co.uk"));
2353        assert!(is_external_url("user_name@sub.domain.com"));
2354        assert!(is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2355
2356        // Template variables should be skipped (not checked as relative links)
2357        assert!(is_external_url("{{URL}}")); // Handlebars/Mustache
2358        assert!(is_external_url("{{#URL}}")); // Handlebars block helper
2359        assert!(is_external_url("{{> partial}}")); // Handlebars partial
2360        assert!(is_external_url("{{ variable }}")); // Mustache with spaces
2361        assert!(is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
2362        assert!(is_external_url("{{")); // Even partial matches (regex edge case)
2363
2364        // Absolute paths are NOT external (handled separately via is_absolute_path)
2365        // By default they are ignored, but can be configured to warn
2366        assert!(!is_external_url("/api/v1/users"));
2367        assert!(!is_external_url("/blog/2024/release.html"));
2368        assert!(!is_external_url("/react/hooks/use-state.html"));
2369        assert!(!is_external_url("/pkg/runtime"));
2370        assert!(!is_external_url("/doc/go1compat"));
2371        assert!(!is_external_url("/index.html"));
2372        assert!(!is_external_url("/assets/logo.png"));
2373
2374        // But is_absolute_path should detect them
2375        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2376        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2377        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2378        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2379        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2380
2381        // Framework path aliases should be skipped (resolved by build tools)
2382        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
2383        assert!(is_external_url("~/assets/image.png"));
2384        assert!(is_external_url("~/components/Button.vue"));
2385        assert!(is_external_url("~assets/logo.svg")); // Nuxt style without /
2386
2387        // @ prefix (common in Vue, webpack, Vite aliases)
2388        assert!(is_external_url("@/components/Header.vue"));
2389        assert!(is_external_url("@images/photo.jpg"));
2390        assert!(is_external_url("@assets/styles.css"));
2391
2392        // Relative paths should NOT be external (should be validated)
2393        assert!(!is_external_url("./relative/path.md"));
2394        assert!(!is_external_url("relative/path.md"));
2395        assert!(!is_external_url("../parent/path.md"));
2396    }
2397
2398    #[test]
2399    fn test_dot_com_only_skips_bare_domains() {
2400        // Bare domains ending in .com are treated as external (skipped).
2401        assert!(is_external_url("example.com"));
2402        assert!(is_external_url("sub.example.com"));
2403
2404        // A relative path that merely ends in ".com" must NOT be skipped:
2405        // it contains a path separator, so it is a relative file reference
2406        // that should be validated, not assumed external.
2407        assert!(!is_external_url("../../vendor.com"));
2408        assert!(!is_external_url("./vendor.com"));
2409        assert!(!is_external_url("docs/vendor.com"));
2410    }
2411
2412    #[test]
2413    fn test_framework_path_aliases() {
2414        // Create a temporary directory for test files
2415        let temp_dir = tempdir().unwrap();
2416        let base_path = temp_dir.path();
2417
2418        // Test content with framework path aliases (should all be skipped)
2419        let content = r#"
2420# Framework Path Aliases
2421
2422![Image 1](~/assets/penguin.jpg)
2423![Image 2](~assets/logo.svg)
2424![Image 3](@images/photo.jpg)
2425![Image 4](@/components/icon.svg)
2426[Link](@/pages/about.md)
2427
2428This is a [real missing link](missing.md) that should be flagged.
2429"#;
2430
2431        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2432
2433        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2434        let result = rule.check(&ctx).unwrap();
2435
2436        // Should only have one warning for the real missing link
2437        assert_eq!(
2438            result.len(),
2439            1,
2440            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2441        );
2442        assert!(
2443            result[0].message.contains("missing.md"),
2444            "Warning should be for missing.md"
2445        );
2446    }
2447
2448    #[test]
2449    fn test_url_decode_security_path_traversal() {
2450        // Ensure URL decoding doesn't enable path traversal attacks
2451        // The decoded path is still validated against the base path
2452        let temp_dir = tempdir().unwrap();
2453        let base_path = temp_dir.path();
2454
2455        // Create a file in the temp directory
2456        let file_in_base = base_path.join("safe.md");
2457        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2458
2459        // Test with encoded path traversal attempt
2460        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
2461        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
2462        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
2463        let content = r#"
2464[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2465[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2466[Safe link](safe.md)
2467"#;
2468
2469        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2470
2471        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2472        let result = rule.check(&ctx).unwrap();
2473
2474        // The traversal attempts should still be flagged as missing
2475        // (they don't exist relative to base_path after decoding)
2476        assert_eq!(
2477            result.len(),
2478            2,
2479            "Should have warnings for traversal attempts. Got: {result:?}"
2480        );
2481    }
2482
2483    #[test]
2484    fn test_url_encoded_utf8_filenames() {
2485        // Test with actual UTF-8 encoded filenames
2486        let temp_dir = tempdir().unwrap();
2487        let base_path = temp_dir.path();
2488
2489        // Create files with unicode names
2490        let cafe_file = base_path.join("café.md");
2491        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2492
2493        let content = r#"
2494[Café link](caf%C3%A9.md)
2495[Missing unicode](r%C3%A9sum%C3%A9.md)
2496"#;
2497
2498        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2499
2500        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2501        let result = rule.check(&ctx).unwrap();
2502
2503        // Should only warn about the missing file
2504        assert_eq!(
2505            result.len(),
2506            1,
2507            "Should only warn about missing résumé.md. Got: {result:?}"
2508        );
2509        assert!(
2510            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2511            "Warning should mention the URL-encoded filename"
2512        );
2513    }
2514
2515    #[test]
2516    fn test_url_encoded_emoji_filenames() {
2517        // URL-encoded emoji paths should be correctly resolved
2518        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
2519        let temp_dir = tempdir().unwrap();
2520        let base_path = temp_dir.path();
2521
2522        // Create directory with emoji in name: 👤 Personal
2523        let emoji_dir = base_path.join("👤 Personal");
2524        std::fs::create_dir(&emoji_dir).unwrap();
2525
2526        // Create file in that directory: TV Shows.md
2527        let file_path = emoji_dir.join("TV Shows.md");
2528        File::create(&file_path)
2529            .unwrap()
2530            .write_all(b"# TV Shows\n\nContent here.")
2531            .unwrap();
2532
2533        // Test content with URL-encoded emoji link
2534        // %F0%9F%91%A4 = 👤, %20 = space
2535        let content = r#"
2536# Test Document
2537
2538[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2539[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2540"#;
2541
2542        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2543
2544        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2545        let result = rule.check(&ctx).unwrap();
2546
2547        // Should only warn about the missing file, not the valid emoji path
2548        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2549        assert!(
2550            result[0].message.contains("Missing.md"),
2551            "Warning should be for Missing.md, got: {}",
2552            result[0].message
2553        );
2554    }
2555
2556    #[test]
2557    fn test_no_warnings_without_base_path() {
2558        let rule = MD057ExistingRelativeLinks::new();
2559        let content = "[Link](missing.md)";
2560
2561        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2562        let result = rule.check(&ctx).unwrap();
2563        assert!(result.is_empty(), "Should have no warnings without base path");
2564    }
2565
2566    #[test]
2567    fn test_existing_and_missing_links() {
2568        // Create a temporary directory for test files
2569        let temp_dir = tempdir().unwrap();
2570        let base_path = temp_dir.path();
2571
2572        // Create an existing file
2573        let exists_path = base_path.join("exists.md");
2574        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2575
2576        // Verify the file exists
2577        assert!(exists_path.exists(), "exists.md should exist for this test");
2578
2579        // Create test content with both existing and missing links
2580        let content = r#"
2581# Test Document
2582
2583[Valid Link](exists.md)
2584[Invalid Link](missing.md)
2585[External Link](https://example.com)
2586[Media Link](image.jpg)
2587        "#;
2588
2589        // Initialize rule with the base path (default: check all files including media)
2590        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2591
2592        // Test the rule
2593        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2594        let result = rule.check(&ctx).unwrap();
2595
2596        // Should have two warnings: missing.md and image.jpg (both don't exist)
2597        assert_eq!(result.len(), 2);
2598        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2599        assert!(messages.iter().any(|m| m.contains("missing.md")));
2600        assert!(messages.iter().any(|m| m.contains("image.jpg")));
2601    }
2602
2603    #[test]
2604    fn test_angle_bracket_links() {
2605        // Create a temporary directory for test files
2606        let temp_dir = tempdir().unwrap();
2607        let base_path = temp_dir.path();
2608
2609        // Create an existing file
2610        let exists_path = base_path.join("exists.md");
2611        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2612
2613        // Create test content with angle bracket links
2614        let content = r#"
2615# Test Document
2616
2617[Valid Link](<exists.md>)
2618[Invalid Link](<missing.md>)
2619[External Link](<https://example.com>)
2620    "#;
2621
2622        // Test with default settings
2623        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2624
2625        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2626        let result = rule.check(&ctx).unwrap();
2627
2628        // Should have one warning for missing.md
2629        assert_eq!(result.len(), 1, "Should have exactly one warning");
2630        assert!(
2631            result[0].message.contains("missing.md"),
2632            "Warning should mention missing.md"
2633        );
2634    }
2635
2636    #[test]
2637    fn test_angle_bracket_links_with_parens() {
2638        // Create a temporary directory for test files
2639        let temp_dir = tempdir().unwrap();
2640        let base_path = temp_dir.path();
2641
2642        // Create directory structure with parentheses in path
2643        let app_dir = base_path.join("app");
2644        std::fs::create_dir(&app_dir).unwrap();
2645        let upload_dir = app_dir.join("(upload)");
2646        std::fs::create_dir(&upload_dir).unwrap();
2647        let page_file = upload_dir.join("page.tsx");
2648        File::create(&page_file)
2649            .unwrap()
2650            .write_all(b"export default function Page() {}")
2651            .unwrap();
2652
2653        // Create test content with angle bracket links containing parentheses
2654        let content = r#"
2655# Test Document with Paths Containing Parens
2656
2657[Upload Page](<app/(upload)/page.tsx>)
2658[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2659[Missing](<app/(missing)/file.md>)
2660"#;
2661
2662        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2663
2664        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2665        let result = rule.check(&ctx).unwrap();
2666
2667        // Should only have one warning for the missing file
2668        assert_eq!(
2669            result.len(),
2670            1,
2671            "Should have exactly one warning for missing file. Got: {result:?}"
2672        );
2673        assert!(
2674            result[0].message.contains("app/(missing)/file.md"),
2675            "Warning should mention app/(missing)/file.md"
2676        );
2677    }
2678
2679    #[test]
2680    fn test_balanced_parentheses_in_link_paths() {
2681        // Reproduces https://github.com/rvben/rumdl/issues/830:
2682        // links whose file path contains balanced parentheses used to be
2683        // truncated at the first ')' and reported as missing even when the
2684        // target exists on disk.
2685        let temp_dir = tempdir().unwrap();
2686        let base_path = temp_dir.path();
2687
2688        // Existing file with parens in its name
2689        let paren_file = base_path.join("file(inner).md");
2690        File::create(&paren_file)
2691            .unwrap()
2692            .write_all(b"# file(inner).md\n")
2693            .unwrap();
2694
2695        // Existing file inside a folder with parens in its name
2696        let folder = base_path.join("folder(inner)");
2697        std::fs::create_dir(&folder).unwrap();
2698        File::create(folder.join("file.md"))
2699            .unwrap()
2700            .write_all(b"# folder(inner)/file.md\n")
2701            .unwrap();
2702
2703        let content = r#"
2704# Test cases
2705
2706[File with parenthesis exists](file(inner).md)
2707[Folder with parenthesis exists](folder(inner)/file.md)
2708[Missing with parenthesis](missing(inner).md)
2709"#;
2710
2711        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2712
2713        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2714        let result = rule.check(&ctx).unwrap();
2715
2716        // The two existing targets must not warn, and the missing one must be
2717        // reported with its full, untruncated path.
2718        assert_eq!(
2719            result.len(),
2720            1,
2721            "Expected exactly one warning (the missing file). Got: {result:?}"
2722        );
2723        assert!(
2724            result[0].message.contains("missing(inner).md"),
2725            "Warning should name the full path `missing(inner).md`, got: {}",
2726            result[0].message
2727        );
2728    }
2729
2730    #[test]
2731    fn test_all_file_types_checked() {
2732        // Create a temporary directory for test files
2733        let temp_dir = tempdir().unwrap();
2734        let base_path = temp_dir.path();
2735
2736        // Create a test with various file types - all should be checked
2737        let content = r#"
2738[Image Link](image.jpg)
2739[Video Link](video.mp4)
2740[Markdown Link](document.md)
2741[PDF Link](file.pdf)
2742"#;
2743
2744        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2745
2746        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2747        let result = rule.check(&ctx).unwrap();
2748
2749        // Should warn about all missing files regardless of extension
2750        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2751    }
2752
2753    #[test]
2754    fn test_code_span_detection() {
2755        let rule = MD057ExistingRelativeLinks::new();
2756
2757        // Create a temporary directory for test files
2758        let temp_dir = tempdir().unwrap();
2759        let base_path = temp_dir.path();
2760
2761        let rule = rule.with_path(base_path);
2762
2763        // Test with document structure
2764        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2765
2766        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767        let result = rule.check(&ctx).unwrap();
2768
2769        // Should only find the real link, not the one in code
2770        assert_eq!(result.len(), 1, "Should only flag the real link");
2771        assert!(result[0].message.contains("nonexistent.md"));
2772    }
2773
2774    #[test]
2775    fn test_inline_code_spans() {
2776        // Create a temporary directory for test files
2777        let temp_dir = tempdir().unwrap();
2778        let base_path = temp_dir.path();
2779
2780        // Create test content with links in inline code spans
2781        let content = r#"
2782# Test Document
2783
2784This is a normal link: [Link](missing.md)
2785
2786This is a code span with a link: `[Link](another-missing.md)`
2787
2788Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2789
2790    "#;
2791
2792        // Initialize rule with the base path
2793        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2794
2795        // Test the rule
2796        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2797        let result = rule.check(&ctx).unwrap();
2798
2799        // Should only have warning for the normal link, not for links in code spans
2800        assert_eq!(result.len(), 1, "Should have exactly one warning");
2801        assert!(
2802            result[0].message.contains("missing.md"),
2803            "Warning should be for missing.md"
2804        );
2805        assert!(
2806            !result.iter().any(|w| w.message.contains("another-missing.md")),
2807            "Should not warn about link in code span"
2808        );
2809        assert!(
2810            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2811            "Should not warn about link in inline code"
2812        );
2813    }
2814
2815    #[test]
2816    fn test_extensionless_link_resolution() {
2817        // Create a temporary directory for test files
2818        let temp_dir = tempdir().unwrap();
2819        let base_path = temp_dir.path();
2820
2821        // Create a markdown file WITHOUT specifying .md extension in the link
2822        let page_path = base_path.join("page.md");
2823        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2824
2825        // Test content with extensionless link that should resolve to page.md
2826        let content = r#"
2827# Test Document
2828
2829[Link without extension](page)
2830[Link with extension](page.md)
2831[Missing link](nonexistent)
2832"#;
2833
2834        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2835
2836        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2837        let result = rule.check(&ctx).unwrap();
2838
2839        // Should only have warning for nonexistent link
2840        // Both "page" and "page.md" should resolve to the same file
2841        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2842        assert!(
2843            result[0].message.contains("nonexistent"),
2844            "Warning should be for 'nonexistent' not 'page'"
2845        );
2846    }
2847
2848    // Cross-file validation tests
2849    #[test]
2850    fn test_cross_file_scope() {
2851        let rule = MD057ExistingRelativeLinks::new();
2852        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2853    }
2854
2855    #[test]
2856    fn test_contribute_to_index_extracts_markdown_links() {
2857        let rule = MD057ExistingRelativeLinks::new();
2858        let content = r#"
2859# Document
2860
2861[Link to docs](./docs/guide.md)
2862[Link with fragment](./other.md#section)
2863[External link](https://example.com)
2864[Image link](image.png)
2865[Media file](video.mp4)
2866"#;
2867
2868        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2869        let mut index = FileIndex::new();
2870        rule.contribute_to_index(&ctx, &mut index);
2871
2872        // Should only index markdown file links
2873        assert_eq!(index.cross_file_links.len(), 2);
2874
2875        // Check first link
2876        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2877        assert_eq!(index.cross_file_links[0].fragment, "");
2878
2879        // Check second link (with fragment)
2880        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2881        assert_eq!(index.cross_file_links[1].fragment, "section");
2882    }
2883
2884    #[test]
2885    fn test_contribute_to_index_skips_external_and_anchors() {
2886        let rule = MD057ExistingRelativeLinks::new();
2887        let content = r#"
2888# Document
2889
2890[External](https://example.com)
2891[Another external](http://example.org)
2892[Fragment only](#section)
2893[FTP link](ftp://files.example.com)
2894[Mail link](mailto:test@example.com)
2895[WWW link](www.example.com)
2896"#;
2897
2898        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899        let mut index = FileIndex::new();
2900        rule.contribute_to_index(&ctx, &mut index);
2901
2902        // Should not index any of these
2903        assert_eq!(index.cross_file_links.len(), 0);
2904    }
2905
2906    #[test]
2907    fn test_cross_file_check_valid_link() {
2908        use crate::workspace_index::WorkspaceIndex;
2909
2910        let rule = MD057ExistingRelativeLinks::new();
2911
2912        // Create a workspace index with the target file
2913        let mut workspace_index = WorkspaceIndex::new();
2914        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2915
2916        // Create file index with a link to an existing file
2917        let mut file_index = FileIndex::new();
2918        file_index.add_cross_file_link(CrossFileLinkIndex {
2919            target_path: "guide.md".to_string(),
2920            fragment: "".to_string(),
2921            line: 5,
2922            column: 1,
2923            origin: LinkOrigin::Body,
2924        });
2925
2926        // Run cross-file check from docs/index.md
2927        let warnings = rule
2928            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2929            .unwrap();
2930
2931        // Should have no warnings - file exists
2932        assert!(warnings.is_empty());
2933    }
2934
2935    #[test]
2936    fn test_cross_file_check_missing_link() {
2937        // cross_file_check delegates all validation to check() to avoid duplicates.
2938        // It always returns empty — the per-file check() path is authoritative.
2939        use crate::workspace_index::WorkspaceIndex;
2940
2941        let rule = MD057ExistingRelativeLinks::new();
2942        let workspace_index = WorkspaceIndex::new();
2943
2944        let mut file_index = FileIndex::new();
2945        file_index.add_cross_file_link(CrossFileLinkIndex {
2946            target_path: "missing.md".to_string(),
2947            fragment: "".to_string(),
2948            line: 5,
2949            column: 1,
2950            origin: LinkOrigin::Body,
2951        });
2952
2953        let warnings = rule
2954            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2955            .unwrap();
2956
2957        // cross_file_check defers to check(); it produces no warnings of its own.
2958        assert!(
2959            warnings.is_empty(),
2960            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2961        );
2962    }
2963
2964    #[test]
2965    fn test_cross_file_check_parent_path() {
2966        use crate::workspace_index::WorkspaceIndex;
2967
2968        let rule = MD057ExistingRelativeLinks::new();
2969
2970        // Create a workspace index with the target file at the root
2971        let mut workspace_index = WorkspaceIndex::new();
2972        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2973
2974        // Create file index with a parent path link
2975        let mut file_index = FileIndex::new();
2976        file_index.add_cross_file_link(CrossFileLinkIndex {
2977            target_path: "../readme.md".to_string(),
2978            fragment: "".to_string(),
2979            line: 5,
2980            column: 1,
2981            origin: LinkOrigin::Body,
2982        });
2983
2984        // Run cross-file check from docs/guide.md
2985        let warnings = rule
2986            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2987            .unwrap();
2988
2989        // Should have no warnings - file exists at normalized path
2990        assert!(warnings.is_empty());
2991    }
2992
2993    #[test]
2994    fn test_cross_file_check_html_link_with_md_source() {
2995        // Test that .html links are accepted when corresponding .md source exists
2996        // This supports mdBook and similar doc generators that compile .md to .html
2997        use crate::workspace_index::WorkspaceIndex;
2998
2999        let rule = MD057ExistingRelativeLinks::new();
3000
3001        // Create a workspace index with the .md source file
3002        let mut workspace_index = WorkspaceIndex::new();
3003        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
3004
3005        // Create file index with an .html link (from another rule like MD051)
3006        let mut file_index = FileIndex::new();
3007        file_index.add_cross_file_link(CrossFileLinkIndex {
3008            target_path: "guide.html".to_string(),
3009            fragment: "section".to_string(),
3010            line: 10,
3011            column: 5,
3012            origin: LinkOrigin::Body,
3013        });
3014
3015        // Run cross-file check from docs/index.md
3016        let warnings = rule
3017            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3018            .unwrap();
3019
3020        // Should have no warnings - .md source exists for the .html link
3021        assert!(
3022            warnings.is_empty(),
3023            "Expected no warnings for .html link with .md source, got: {warnings:?}"
3024        );
3025    }
3026
3027    #[test]
3028    fn test_cross_file_check_html_link_without_source() {
3029        // cross_file_check delegates all validation to check() to avoid duplicates.
3030        // Verifying that .html links without a matching .md source are caught is
3031        // already covered by test_html_link_with_md_source (check() path).
3032        use crate::workspace_index::WorkspaceIndex;
3033
3034        let rule = MD057ExistingRelativeLinks::new();
3035        let workspace_index = WorkspaceIndex::new();
3036
3037        let mut file_index = FileIndex::new();
3038        file_index.add_cross_file_link(CrossFileLinkIndex {
3039            target_path: "missing.html".to_string(),
3040            fragment: "".to_string(),
3041            line: 10,
3042            column: 5,
3043            origin: LinkOrigin::Body,
3044        });
3045
3046        let warnings = rule
3047            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3048            .unwrap();
3049
3050        // cross_file_check defers to check(); it produces no warnings of its own.
3051        assert!(
3052            warnings.is_empty(),
3053            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
3054        );
3055    }
3056
3057    #[test]
3058    fn test_normalize_path_function() {
3059        // Test simple cases
3060        assert_eq!(
3061            normalize_relative_path(Path::new("docs/guide.md")),
3062            PathBuf::from("docs/guide.md")
3063        );
3064
3065        // Test current directory removal
3066        assert_eq!(
3067            normalize_relative_path(Path::new("./docs/guide.md")),
3068            PathBuf::from("docs/guide.md")
3069        );
3070
3071        // Test parent directory resolution
3072        assert_eq!(
3073            normalize_relative_path(Path::new("docs/sub/../guide.md")),
3074            PathBuf::from("docs/guide.md")
3075        );
3076
3077        // Test multiple parent directories
3078        assert_eq!(
3079            normalize_relative_path(Path::new("a/b/c/../../d.md")),
3080            PathBuf::from("a/d.md")
3081        );
3082    }
3083
3084    #[test]
3085    fn test_html_link_with_md_source() {
3086        // Links to .html files should pass if corresponding .md source exists
3087        let temp_dir = tempdir().unwrap();
3088        let base_path = temp_dir.path();
3089
3090        // Create guide.md (source file)
3091        let md_file = base_path.join("guide.md");
3092        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3093
3094        let content = r#"
3095[Read the guide](guide.html)
3096[Also here](getting-started.html)
3097"#;
3098
3099        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3100        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3101        let result = rule.check(&ctx).unwrap();
3102
3103        // guide.html passes (guide.md exists), getting-started.html fails
3104        assert_eq!(
3105            result.len(),
3106            1,
3107            "Should only warn about missing source. Got: {result:?}"
3108        );
3109        assert!(result[0].message.contains("getting-started.html"));
3110    }
3111
3112    #[test]
3113    fn test_htm_link_with_md_source() {
3114        // .htm extension should also check for markdown source
3115        let temp_dir = tempdir().unwrap();
3116        let base_path = temp_dir.path();
3117
3118        let md_file = base_path.join("page.md");
3119        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
3120
3121        let content = "[Page](page.htm)";
3122
3123        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3124        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3125        let result = rule.check(&ctx).unwrap();
3126
3127        assert!(
3128            result.is_empty(),
3129            "Should not warn when .md source exists for .htm link"
3130        );
3131    }
3132
3133    #[test]
3134    fn test_html_link_finds_various_markdown_extensions() {
3135        // Should find .mdx, .markdown, etc. as source files
3136        let temp_dir = tempdir().unwrap();
3137        let base_path = temp_dir.path();
3138
3139        File::create(base_path.join("doc.md")).unwrap();
3140        File::create(base_path.join("tutorial.mdx")).unwrap();
3141        File::create(base_path.join("guide.markdown")).unwrap();
3142
3143        let content = r#"
3144[Doc](doc.html)
3145[Tutorial](tutorial.html)
3146[Guide](guide.html)
3147"#;
3148
3149        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3150        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3151        let result = rule.check(&ctx).unwrap();
3152
3153        assert!(
3154            result.is_empty(),
3155            "Should find all markdown variants as source files. Got: {result:?}"
3156        );
3157    }
3158
3159    #[test]
3160    fn test_html_link_in_subdirectory() {
3161        // Should find markdown source in subdirectories
3162        let temp_dir = tempdir().unwrap();
3163        let base_path = temp_dir.path();
3164
3165        let docs_dir = base_path.join("docs");
3166        std::fs::create_dir(&docs_dir).unwrap();
3167        File::create(docs_dir.join("guide.md"))
3168            .unwrap()
3169            .write_all(b"# Guide")
3170            .unwrap();
3171
3172        let content = "[Guide](docs/guide.html)";
3173
3174        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3175        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3176        let result = rule.check(&ctx).unwrap();
3177
3178        assert!(result.is_empty(), "Should find markdown source in subdirectory");
3179    }
3180
3181    #[test]
3182    fn test_absolute_path_skipped_in_check() {
3183        // Test that absolute paths are skipped during link validation
3184        // This fixes the bug where /pkg/runtime was being flagged
3185        let temp_dir = tempdir().unwrap();
3186        let base_path = temp_dir.path();
3187
3188        let content = r#"
3189# Test Document
3190
3191[Go Runtime](/pkg/runtime)
3192[Go Runtime with Fragment](/pkg/runtime#section)
3193[API Docs](/api/v1/users)
3194[Blog Post](/blog/2024/release.html)
3195[React Hook](/react/hooks/use-state.html)
3196"#;
3197
3198        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3199        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3200        let result = rule.check(&ctx).unwrap();
3201
3202        // Should have NO warnings - all absolute paths should be skipped
3203        assert!(
3204            result.is_empty(),
3205            "Absolute paths should be skipped. Got warnings: {result:?}"
3206        );
3207    }
3208
3209    #[test]
3210    fn test_absolute_path_skipped_in_cross_file_check() {
3211        // Test that absolute paths are skipped in cross_file_check()
3212        use crate::workspace_index::WorkspaceIndex;
3213
3214        let rule = MD057ExistingRelativeLinks::new();
3215
3216        // Create an empty workspace index (no files exist)
3217        let workspace_index = WorkspaceIndex::new();
3218
3219        // Create file index with absolute path links (should be skipped)
3220        let mut file_index = FileIndex::new();
3221        file_index.add_cross_file_link(CrossFileLinkIndex {
3222            target_path: "/pkg/runtime.md".to_string(),
3223            fragment: "".to_string(),
3224            line: 5,
3225            column: 1,
3226            origin: LinkOrigin::Body,
3227        });
3228        file_index.add_cross_file_link(CrossFileLinkIndex {
3229            target_path: "/api/v1/users.md".to_string(),
3230            fragment: "section".to_string(),
3231            line: 10,
3232            column: 1,
3233            origin: LinkOrigin::Body,
3234        });
3235
3236        // Run cross-file check
3237        let warnings = rule
3238            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3239            .unwrap();
3240
3241        // Should have NO warnings - absolute paths should be skipped
3242        assert!(
3243            warnings.is_empty(),
3244            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
3245        );
3246    }
3247
3248    #[test]
3249    fn test_protocol_relative_url_not_skipped() {
3250        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
3251        // They should still be caught by is_external_url() though
3252        let temp_dir = tempdir().unwrap();
3253        let base_path = temp_dir.path();
3254
3255        let content = r#"
3256# Test Document
3257
3258[External](//example.com/page)
3259[Another](//cdn.example.com/asset.js)
3260"#;
3261
3262        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3263        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3264        let result = rule.check(&ctx).unwrap();
3265
3266        // Should have NO warnings - protocol-relative URLs are external and should be skipped
3267        assert!(
3268            result.is_empty(),
3269            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3270        );
3271    }
3272
3273    #[test]
3274    fn test_email_addresses_skipped() {
3275        // Test that email addresses without mailto: are skipped
3276        // These are clearly not file links (the @ symbol is definitive)
3277        let temp_dir = tempdir().unwrap();
3278        let base_path = temp_dir.path();
3279
3280        let content = r#"
3281# Test Document
3282
3283[Contact](user@example.com)
3284[Steering](steering@kubernetes.io)
3285[Support](john.doe+filter@company.co.uk)
3286[User](user_name@sub.domain.com)
3287"#;
3288
3289        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3290        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3291        let result = rule.check(&ctx).unwrap();
3292
3293        // Should have NO warnings - email addresses are clearly not file links and should be skipped
3294        assert!(
3295            result.is_empty(),
3296            "Email addresses should be skipped. Got warnings: {result:?}"
3297        );
3298    }
3299
3300    #[test]
3301    fn test_email_addresses_vs_file_paths() {
3302        // Test that email addresses (anything with @) are skipped
3303        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
3304        let temp_dir = tempdir().unwrap();
3305        let base_path = temp_dir.path();
3306
3307        let content = r#"
3308# Test Document
3309
3310[Email](user@example.com)  <!-- Should be skipped (email) -->
3311[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
3312[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
3313"#;
3314
3315        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3316        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3317        let result = rule.check(&ctx).unwrap();
3318
3319        // All should be skipped - anything with @ is treated as an email
3320        assert!(
3321            result.is_empty(),
3322            "All email addresses should be skipped. Got: {result:?}"
3323        );
3324    }
3325
3326    #[test]
3327    fn test_diagnostic_position_accuracy() {
3328        // Test that diagnostics point to the URL, not the link text
3329        let temp_dir = tempdir().unwrap();
3330        let base_path = temp_dir.path();
3331
3332        // Position markers:     0         1         2         3
3333        //                       0123456789012345678901234567890123456789
3334        let content = "prefix [text](missing.md) suffix";
3335        //             The URL "missing.md" starts at 0-indexed position 14
3336        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
3337
3338        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3339        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3340        let result = rule.check(&ctx).unwrap();
3341
3342        assert_eq!(result.len(), 1, "Should have exactly one warning");
3343        assert_eq!(result[0].line, 1, "Should be on line 1");
3344        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3345        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3346    }
3347
3348    #[test]
3349    fn test_diagnostic_position_non_ascii_link() {
3350        // Issue #670: columns are character offsets, not byte offsets. The CJK
3351        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
3352        let temp_dir = tempdir().unwrap();
3353        let base_path = temp_dir.path();
3354
3355        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
3356        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
3357        // and ends past character column 21, i.e. end_column 22.
3358        let content = "你好你好[你好](not-exist.md) bar";
3359
3360        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3361        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3362        let result = rule.check(&ctx).unwrap();
3363
3364        assert_eq!(result.len(), 1, "Should have exactly one warning");
3365        assert_eq!(result[0].line, 1, "Should be on line 1");
3366        assert_eq!(
3367            result[0].column, 10,
3368            "Column must be a character offset, not a byte offset"
3369        );
3370        assert_eq!(result[0].end_column, 22, "End column must be character-based");
3371    }
3372
3373    #[test]
3374    fn test_diagnostic_position_angle_brackets() {
3375        // Test position accuracy with angle bracket links
3376        let temp_dir = tempdir().unwrap();
3377        let base_path = temp_dir.path();
3378
3379        // Position markers:     0         1         2
3380        //                       012345678901234567890
3381        let content = "[link](<missing.md>)";
3382        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
3383
3384        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3385        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3386        let result = rule.check(&ctx).unwrap();
3387
3388        assert_eq!(result.len(), 1, "Should have exactly one warning");
3389        assert_eq!(result[0].line, 1, "Should be on line 1");
3390        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3391    }
3392
3393    #[test]
3394    fn test_diagnostic_position_multiline() {
3395        // Test that line numbers are correct for links on different lines
3396        let temp_dir = tempdir().unwrap();
3397        let base_path = temp_dir.path();
3398
3399        let content = r#"# Title
3400Some text on line 2
3401[link on line 3](missing1.md)
3402More text
3403[link on line 5](missing2.md)"#;
3404
3405        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3406        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3407        let result = rule.check(&ctx).unwrap();
3408
3409        assert_eq!(result.len(), 2, "Should have two warnings");
3410
3411        // First warning should be on line 3
3412        assert_eq!(result[0].line, 3, "First warning should be on line 3");
3413        assert!(result[0].message.contains("missing1.md"));
3414
3415        // Second warning should be on line 5
3416        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3417        assert!(result[1].message.contains("missing2.md"));
3418    }
3419
3420    #[test]
3421    fn test_diagnostic_position_with_spaces() {
3422        // Test position with URLs that have spaces in parentheses
3423        let temp_dir = tempdir().unwrap();
3424        let base_path = temp_dir.path();
3425
3426        let content = "[link]( missing.md )";
3427        //             0123456789012345678901
3428        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
3429        //             which is 1-indexed column 9
3430
3431        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3432        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3433        let result = rule.check(&ctx).unwrap();
3434
3435        assert_eq!(result.len(), 1, "Should have exactly one warning");
3436        // The regex captures the URL without leading/trailing spaces
3437        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3438    }
3439
3440    #[test]
3441    fn test_diagnostic_position_image() {
3442        // Test that image diagnostics also have correct positions
3443        let temp_dir = tempdir().unwrap();
3444        let base_path = temp_dir.path();
3445
3446        let content = "![alt text](missing.jpg)";
3447
3448        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3449        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3450        let result = rule.check(&ctx).unwrap();
3451
3452        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3453        assert_eq!(result[0].line, 1);
3454        // Images use start_col from the parser, which should point to the URL
3455        assert!(result[0].column > 0, "Should have valid column position");
3456        assert!(result[0].message.contains("missing.jpg"));
3457    }
3458
3459    #[test]
3460    fn test_diagnostic_position_non_ascii_image() {
3461        // Issue #670: image columns are character offsets, not byte offsets.
3462        let temp_dir = tempdir().unwrap();
3463        let base_path = temp_dir.path();
3464
3465        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
3466        // The image syntax starts at the '!' which is 1-indexed character column 5.
3467        let content = "你好你好![你好](not-exist.png)";
3468
3469        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3470        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3471        let result = rule.check(&ctx).unwrap();
3472
3473        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3474        assert_eq!(result[0].line, 1, "Should be on line 1");
3475        assert_eq!(
3476            result[0].column, 5,
3477            "Column must be a character offset, not a byte offset"
3478        );
3479        assert!(result[0].message.contains("not-exist.png"));
3480    }
3481
3482    #[test]
3483    fn test_diagnostic_position_non_ascii_reference_def() {
3484        // Issue #670: reference-definition columns are character offsets. A
3485        // multi-byte label shifts the URL's byte offset away from its character
3486        // column.
3487        let temp_dir = tempdir().unwrap();
3488        let base_path = temp_dir.path();
3489
3490        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
3491        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
3492        let content = "[你好]: not-exist.md";
3493
3494        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3495        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3496        let result = rule.check(&ctx).unwrap();
3497
3498        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3499        assert_eq!(result[0].line, 1, "Should be on line 1");
3500        assert_eq!(
3501            result[0].column, 7,
3502            "Column must be a character offset, not a byte offset"
3503        );
3504        assert_eq!(result[0].end_column, 19, "End column must be character-based");
3505    }
3506
3507    #[test]
3508    fn test_wikilinks_skipped() {
3509        // Wikilinks should not trigger MD057 warnings
3510        // They use a different linking system (e.g., Obsidian, wiki software)
3511        let temp_dir = tempdir().unwrap();
3512        let base_path = temp_dir.path();
3513
3514        let content = r#"# Test Document
3515
3516[[Microsoft#Windows OS]]
3517[[SomePage]]
3518[[Page With Spaces]]
3519[[path/to/page#section]]
3520[[page|Display Text]]
3521
3522This is a [real missing link](missing.md) that should be flagged.
3523"#;
3524
3525        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3526        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3527        let result = rule.check(&ctx).unwrap();
3528
3529        // Should only warn about the regular markdown link, not wikilinks
3530        assert_eq!(
3531            result.len(),
3532            1,
3533            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3534        );
3535        assert!(
3536            result[0].message.contains("missing.md"),
3537            "Warning should be for missing.md, not wikilinks"
3538        );
3539    }
3540
3541    #[test]
3542    fn test_wiki_embeds_skipped() {
3543        // A wiki embed names a vault entry, not a path relative to this file,
3544        // so `![[diagram.png]]` is not a missing relative link even though no
3545        // such file sits next to the document.
3546        let temp_dir = tempdir().unwrap();
3547        let base_path = temp_dir.path();
3548
3549        let content = r#"# Test Document
3550
3551![[diagram.png]]
3552![[subfolder/diagram.png]]
3553![[diagram.png|300]]
3554![[Some Note]]
3555
3556This is a [real missing link](missing.md) that should be flagged.
3557"#;
3558
3559        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3560        for flavor in [
3561            crate::config::MarkdownFlavor::Obsidian,
3562            crate::config::MarkdownFlavor::Standard,
3563        ] {
3564            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3565            let result = rule.check(&ctx).unwrap();
3566
3567            assert_eq!(
3568                result.len(),
3569                1,
3570                "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3571            );
3572            assert!(result[0].message.contains("missing.md"));
3573        }
3574    }
3575
3576    #[test]
3577    fn test_wikilinks_not_added_to_index() {
3578        // Wikilinks should not be added to the cross-file link index
3579        let temp_dir = tempdir().unwrap();
3580        let base_path = temp_dir.path();
3581
3582        let content = r#"# Test Document
3583
3584[[Microsoft#Windows OS]]
3585[[SomePage#section]]
3586[Regular Link](other.md)
3587"#;
3588
3589        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3590        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3591
3592        let mut file_index = FileIndex::new();
3593        rule.contribute_to_index(&ctx, &mut file_index);
3594
3595        // Should only have the regular markdown link (if it's a markdown file)
3596        // Wikilinks should not be added
3597        let cross_file_links = &file_index.cross_file_links;
3598        assert_eq!(
3599            cross_file_links.len(),
3600            1,
3601            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3602        );
3603        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3604    }
3605
3606    #[test]
3607    fn test_reference_definition_missing_file() {
3608        // Reference definitions [ref]: ./path.md should be checked
3609        let temp_dir = tempdir().unwrap();
3610        let base_path = temp_dir.path();
3611
3612        let content = r#"# Test Document
3613
3614[test]: ./missing.md
3615[example]: ./nonexistent.html
3616
3617Use [test] and [example] here.
3618"#;
3619
3620        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3621        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3622        let result = rule.check(&ctx).unwrap();
3623
3624        // Should have warnings for both reference definitions
3625        assert_eq!(
3626            result.len(),
3627            2,
3628            "Should have warnings for missing reference definition targets. Got: {result:?}"
3629        );
3630        assert!(
3631            result.iter().any(|w| w.message.contains("missing.md")),
3632            "Should warn about missing.md"
3633        );
3634        assert!(
3635            result.iter().any(|w| w.message.contains("nonexistent.html")),
3636            "Should warn about nonexistent.html"
3637        );
3638    }
3639
3640    #[test]
3641    fn test_reference_definition_existing_file() {
3642        // Reference definitions to existing files should NOT trigger warnings
3643        let temp_dir = tempdir().unwrap();
3644        let base_path = temp_dir.path();
3645
3646        // Create an existing file
3647        let exists_path = base_path.join("exists.md");
3648        File::create(&exists_path)
3649            .unwrap()
3650            .write_all(b"# Existing file")
3651            .unwrap();
3652
3653        let content = r#"# Test Document
3654
3655[test]: ./exists.md
3656
3657Use [test] here.
3658"#;
3659
3660        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3661        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3662        let result = rule.check(&ctx).unwrap();
3663
3664        // Should have NO warnings since the file exists
3665        assert!(
3666            result.is_empty(),
3667            "Should not warn about existing file. Got: {result:?}"
3668        );
3669    }
3670
3671    #[test]
3672    fn test_reference_definition_external_url_skipped() {
3673        // Reference definitions with external URLs should be skipped
3674        let temp_dir = tempdir().unwrap();
3675        let base_path = temp_dir.path();
3676
3677        let content = r#"# Test Document
3678
3679[google]: https://google.com
3680[example]: http://example.org
3681[mail]: mailto:test@example.com
3682[ftp]: ftp://files.example.com
3683[local]: ./missing.md
3684
3685Use [google], [example], [mail], [ftp], [local] here.
3686"#;
3687
3688        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3689        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3690        let result = rule.check(&ctx).unwrap();
3691
3692        // Should only warn about the local missing file, not external URLs
3693        assert_eq!(
3694            result.len(),
3695            1,
3696            "Should only warn about local missing file. Got: {result:?}"
3697        );
3698        assert!(
3699            result[0].message.contains("missing.md"),
3700            "Warning should be for missing.md"
3701        );
3702    }
3703
3704    #[test]
3705    fn test_reference_definition_fragment_only_skipped() {
3706        // Reference definitions with fragment-only URLs should be skipped
3707        let temp_dir = tempdir().unwrap();
3708        let base_path = temp_dir.path();
3709
3710        let content = r#"# Test Document
3711
3712[section]: #my-section
3713
3714Use [section] here.
3715"#;
3716
3717        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3718        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3719        let result = rule.check(&ctx).unwrap();
3720
3721        // Should have NO warnings for fragment-only links
3722        assert!(
3723            result.is_empty(),
3724            "Should not warn about fragment-only reference. Got: {result:?}"
3725        );
3726    }
3727
3728    #[test]
3729    fn test_reference_definition_column_position() {
3730        // Test that column position points to the URL in the reference definition
3731        let temp_dir = tempdir().unwrap();
3732        let base_path = temp_dir.path();
3733
3734        // Position markers:     0         1         2
3735        //                       0123456789012345678901
3736        let content = "[ref]: ./missing.md";
3737        //             The URL "./missing.md" starts at 0-indexed position 7
3738        //             which is 1-indexed column 8
3739
3740        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3741        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3742        let result = rule.check(&ctx).unwrap();
3743
3744        assert_eq!(result.len(), 1, "Should have exactly one warning");
3745        assert_eq!(result[0].line, 1, "Should be on line 1");
3746        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3747    }
3748
3749    #[test]
3750    fn test_reference_definition_html_with_md_source() {
3751        // Reference definitions to .html files should pass if corresponding .md source exists
3752        let temp_dir = tempdir().unwrap();
3753        let base_path = temp_dir.path();
3754
3755        // Create guide.md (source file)
3756        let md_file = base_path.join("guide.md");
3757        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3758
3759        let content = r#"# Test Document
3760
3761[guide]: ./guide.html
3762[missing]: ./missing.html
3763
3764Use [guide] and [missing] here.
3765"#;
3766
3767        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3768        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3769        let result = rule.check(&ctx).unwrap();
3770
3771        // guide.html passes (guide.md exists), missing.html fails
3772        assert_eq!(
3773            result.len(),
3774            1,
3775            "Should only warn about missing source. Got: {result:?}"
3776        );
3777        assert!(result[0].message.contains("missing.html"));
3778    }
3779
3780    #[test]
3781    fn test_reference_definition_url_encoded() {
3782        // Reference definitions with URL-encoded paths should be decoded before checking
3783        let temp_dir = tempdir().unwrap();
3784        let base_path = temp_dir.path();
3785
3786        // Create a file with spaces in the name
3787        let file_with_spaces = base_path.join("file with spaces.md");
3788        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3789
3790        let content = r#"# Test Document
3791
3792[spaces]: ./file%20with%20spaces.md
3793[missing]: ./missing%20file.md
3794
3795Use [spaces] and [missing] here.
3796"#;
3797
3798        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3799        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3800        let result = rule.check(&ctx).unwrap();
3801
3802        // Should only warn about the missing file
3803        assert_eq!(
3804            result.len(),
3805            1,
3806            "Should only warn about missing URL-encoded file. Got: {result:?}"
3807        );
3808        assert!(result[0].message.contains("missing%20file.md"));
3809    }
3810
3811    #[test]
3812    fn test_inline_and_reference_both_checked() {
3813        // Both inline links and reference definitions should be checked
3814        let temp_dir = tempdir().unwrap();
3815        let base_path = temp_dir.path();
3816
3817        let content = r#"# Test Document
3818
3819[inline link](./inline-missing.md)
3820[ref]: ./ref-missing.md
3821
3822Use [ref] here.
3823"#;
3824
3825        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3826        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3827        let result = rule.check(&ctx).unwrap();
3828
3829        // Should warn about both the inline link and the reference definition
3830        assert_eq!(
3831            result.len(),
3832            2,
3833            "Should warn about both inline and reference links. Got: {result:?}"
3834        );
3835        assert!(
3836            result.iter().any(|w| w.message.contains("inline-missing.md")),
3837            "Should warn about inline-missing.md"
3838        );
3839        assert!(
3840            result.iter().any(|w| w.message.contains("ref-missing.md")),
3841            "Should warn about ref-missing.md"
3842        );
3843    }
3844
3845    #[test]
3846    fn test_footnote_definitions_not_flagged() {
3847        // Regression test for issue #286: footnote definitions should not be
3848        // treated as reference definitions and flagged as broken links
3849        let rule = MD057ExistingRelativeLinks::default();
3850
3851        let content = r#"# Title
3852
3853A footnote[^1].
3854
3855[^1]: [link](https://www.google.com).
3856"#;
3857
3858        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3859        let result = rule.check(&ctx).unwrap();
3860
3861        assert!(
3862            result.is_empty(),
3863            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3864        );
3865    }
3866
3867    #[test]
3868    fn test_footnote_with_relative_link_inside() {
3869        // Footnotes containing relative links should not be checked
3870        // (the footnote content is not a URL, it's content that may contain links)
3871        let rule = MD057ExistingRelativeLinks::default();
3872
3873        let content = r#"# Title
3874
3875See the footnote[^1].
3876
3877[^1]: Check out [this file](./existing.md) for more info.
3878[^2]: Also see [missing](./does-not-exist.md).
3879"#;
3880
3881        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3882        let result = rule.check(&ctx).unwrap();
3883
3884        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
3885        // but the footnote definition itself should not be treated as a reference definition
3886        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
3887        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
3888        for warning in &result {
3889            assert!(
3890                !warning.message.contains("[this file]"),
3891                "Footnote content should not be treated as URL: {warning:?}"
3892            );
3893            assert!(
3894                !warning.message.contains("[missing]"),
3895                "Footnote content should not be treated as URL: {warning:?}"
3896            );
3897        }
3898    }
3899
3900    #[test]
3901    fn test_mixed_footnotes_and_reference_definitions() {
3902        // Ensure regular reference definitions are still checked while footnotes are skipped
3903        let temp_dir = tempdir().unwrap();
3904        let base_path = temp_dir.path();
3905
3906        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3907
3908        let content = r#"# Title
3909
3910A footnote[^1] and a [ref link][myref].
3911
3912[^1]: This is a footnote with [link](https://example.com).
3913
3914[myref]: ./missing-file.md "This should be checked"
3915"#;
3916
3917        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3918        let result = rule.check(&ctx).unwrap();
3919
3920        // Should only warn about the regular reference definition, not the footnote
3921        assert_eq!(
3922            result.len(),
3923            1,
3924            "Should only warn about the regular reference definition. Got: {result:?}"
3925        );
3926        assert!(
3927            result[0].message.contains("missing-file.md"),
3928            "Should warn about missing-file.md in reference definition"
3929        );
3930    }
3931
3932    #[test]
3933    fn test_absolute_links_ignore_by_default() {
3934        // By default, absolute links are ignored (not validated)
3935        let temp_dir = tempdir().unwrap();
3936        let base_path = temp_dir.path();
3937
3938        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3939
3940        let content = r#"# Links
3941
3942[API docs](/api/v1/users)
3943[Blog post](/blog/2024/release.html)
3944![Logo](/assets/logo.png)
3945
3946[ref]: /docs/reference.md
3947"#;
3948
3949        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3950        let result = rule.check(&ctx).unwrap();
3951
3952        // No warnings - absolute links are ignored by default
3953        assert!(
3954            result.is_empty(),
3955            "Absolute links should be ignored by default. Got: {result:?}"
3956        );
3957    }
3958
3959    #[test]
3960    fn test_absolute_links_warn_config() {
3961        // When configured to warn, absolute links should generate warnings
3962        let temp_dir = tempdir().unwrap();
3963        let base_path = temp_dir.path();
3964
3965        let config = MD057Config {
3966            absolute_links: AbsoluteLinksOption::Warn,
3967            ..Default::default()
3968        };
3969        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3970
3971        let content = r#"# Links
3972
3973[API docs](/api/v1/users)
3974[Blog post](/blog/2024/release.html)
3975"#;
3976
3977        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3978        let result = rule.check(&ctx).unwrap();
3979
3980        // Should have 2 warnings for the 2 absolute links
3981        assert_eq!(
3982            result.len(),
3983            2,
3984            "Should warn about both absolute links. Got: {result:?}"
3985        );
3986        assert!(
3987            result[0].message.contains("cannot be validated locally"),
3988            "Warning should explain why: {}",
3989            result[0].message
3990        );
3991        assert!(
3992            result[0].message.contains("/api/v1/users"),
3993            "Warning should include the link path"
3994        );
3995    }
3996
3997    #[test]
3998    fn test_absolute_links_warn_images() {
3999        // Images with absolute paths should also warn when configured
4000        let temp_dir = tempdir().unwrap();
4001        let base_path = temp_dir.path();
4002
4003        let config = MD057Config {
4004            absolute_links: AbsoluteLinksOption::Warn,
4005            ..Default::default()
4006        };
4007        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4008
4009        let content = r#"# Images
4010
4011![Logo](/assets/logo.png)
4012"#;
4013
4014        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4015        let result = rule.check(&ctx).unwrap();
4016
4017        assert_eq!(
4018            result.len(),
4019            1,
4020            "Should warn about absolute image path. Got: {result:?}"
4021        );
4022        assert!(
4023            result[0].message.contains("/assets/logo.png"),
4024            "Warning should include the image path"
4025        );
4026    }
4027
4028    #[test]
4029    fn test_absolute_links_warn_reference_definitions() {
4030        // Reference definitions with absolute paths should also warn when configured
4031        let temp_dir = tempdir().unwrap();
4032        let base_path = temp_dir.path();
4033
4034        let config = MD057Config {
4035            absolute_links: AbsoluteLinksOption::Warn,
4036            ..Default::default()
4037        };
4038        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4039
4040        let content = r#"# Reference
4041
4042See the [docs][ref].
4043
4044[ref]: /docs/reference.md
4045"#;
4046
4047        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4048        let result = rule.check(&ctx).unwrap();
4049
4050        assert_eq!(
4051            result.len(),
4052            1,
4053            "Should warn about absolute reference definition. Got: {result:?}"
4054        );
4055        assert!(
4056            result[0].message.contains("/docs/reference.md"),
4057            "Warning should include the reference path"
4058        );
4059    }
4060
4061    #[test]
4062    fn test_search_paths_inline_link() {
4063        let temp_dir = tempdir().unwrap();
4064        let base_path = temp_dir.path();
4065
4066        // Create an "assets" directory with an image
4067        let assets_dir = base_path.join("assets");
4068        std::fs::create_dir_all(&assets_dir).unwrap();
4069        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
4070
4071        let config = MD057Config {
4072            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4073            ..Default::default()
4074        };
4075        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4076
4077        let content = "# Test\n\n[Photo](photo.png)\n";
4078        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4079        let result = rule.check(&ctx).unwrap();
4080
4081        assert!(
4082            result.is_empty(),
4083            "Should find photo.png via search-paths. Got: {result:?}"
4084        );
4085    }
4086
4087    #[test]
4088    fn test_search_paths_image() {
4089        let temp_dir = tempdir().unwrap();
4090        let base_path = temp_dir.path();
4091
4092        let assets_dir = base_path.join("attachments");
4093        std::fs::create_dir_all(&assets_dir).unwrap();
4094        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
4095
4096        let config = MD057Config {
4097            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4098            ..Default::default()
4099        };
4100        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4101
4102        let content = "# Test\n\n![Diagram](diagram.svg)\n";
4103        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4104        let result = rule.check(&ctx).unwrap();
4105
4106        assert!(
4107            result.is_empty(),
4108            "Should find diagram.svg via search-paths. Got: {result:?}"
4109        );
4110    }
4111
4112    #[test]
4113    fn test_search_paths_reference_definition() {
4114        let temp_dir = tempdir().unwrap();
4115        let base_path = temp_dir.path();
4116
4117        let assets_dir = base_path.join("images");
4118        std::fs::create_dir_all(&assets_dir).unwrap();
4119        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
4120
4121        let config = MD057Config {
4122            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4123            ..Default::default()
4124        };
4125        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4126
4127        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
4128        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4129        let result = rule.check(&ctx).unwrap();
4130
4131        assert!(
4132            result.is_empty(),
4133            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
4134        );
4135    }
4136
4137    #[test]
4138    fn test_search_paths_still_warns_when_truly_missing() {
4139        let temp_dir = tempdir().unwrap();
4140        let base_path = temp_dir.path();
4141
4142        let assets_dir = base_path.join("assets");
4143        std::fs::create_dir_all(&assets_dir).unwrap();
4144
4145        let config = MD057Config {
4146            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4147            ..Default::default()
4148        };
4149        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4150
4151        let content = "# Test\n\n![Missing](nonexistent.png)\n";
4152        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4153        let result = rule.check(&ctx).unwrap();
4154
4155        assert_eq!(
4156            result.len(),
4157            1,
4158            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
4159        );
4160    }
4161
4162    #[test]
4163    fn test_search_paths_nonexistent_directory() {
4164        let temp_dir = tempdir().unwrap();
4165        let base_path = temp_dir.path();
4166
4167        let config = MD057Config {
4168            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
4169            ..Default::default()
4170        };
4171        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4172
4173        let content = "# Test\n\n![Missing](photo.png)\n";
4174        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4175        let result = rule.check(&ctx).unwrap();
4176
4177        assert_eq!(
4178            result.len(),
4179            1,
4180            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
4181        );
4182    }
4183
4184    #[test]
4185    fn test_obsidian_attachment_folder_named() {
4186        let temp_dir = tempdir().unwrap();
4187        let vault = temp_dir.path().join("vault");
4188        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4189        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4190        std::fs::create_dir_all(vault.join("notes")).unwrap();
4191
4192        std::fs::write(
4193            vault.join(".obsidian/app.json"),
4194            r#"{"attachmentFolderPath": "Attachments"}"#,
4195        )
4196        .unwrap();
4197        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4198
4199        let notes_dir = vault.join("notes");
4200        let source_file = notes_dir.join("test.md");
4201        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
4202
4203        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4204
4205        let content = "# Test\n\n![Photo](photo.png)\n";
4206        let ctx =
4207            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4208        let result = rule.check(&ctx).unwrap();
4209
4210        assert!(
4211            result.is_empty(),
4212            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
4213        );
4214    }
4215
4216    #[test]
4217    fn test_obsidian_attachment_same_folder_as_file() {
4218        let temp_dir = tempdir().unwrap();
4219        let vault = temp_dir.path().join("vault-rf");
4220        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4221        std::fs::create_dir_all(vault.join("notes")).unwrap();
4222
4223        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
4224
4225        // Image in the same directory as the file — default behavior, no extra search needed
4226        let notes_dir = vault.join("notes");
4227        let source_file = notes_dir.join("test.md");
4228        std::fs::write(&source_file, "placeholder").unwrap();
4229        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
4230
4231        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4232
4233        let content = "# Test\n\n![Photo](photo.png)\n";
4234        let ctx =
4235            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4236        let result = rule.check(&ctx).unwrap();
4237
4238        assert!(
4239            result.is_empty(),
4240            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
4241        );
4242    }
4243
4244    #[test]
4245    fn test_obsidian_not_triggered_without_obsidian_flavor() {
4246        let temp_dir = tempdir().unwrap();
4247        let vault = temp_dir.path().join("vault-nf");
4248        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4249        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4250        std::fs::create_dir_all(vault.join("notes")).unwrap();
4251
4252        std::fs::write(
4253            vault.join(".obsidian/app.json"),
4254            r#"{"attachmentFolderPath": "Attachments"}"#,
4255        )
4256        .unwrap();
4257        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4258
4259        let notes_dir = vault.join("notes");
4260        let source_file = notes_dir.join("test.md");
4261        std::fs::write(&source_file, "placeholder").unwrap();
4262
4263        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4264
4265        let content = "# Test\n\n![Photo](photo.png)\n";
4266        // Standard flavor — NOT Obsidian
4267        let ctx =
4268            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4269        let result = rule.check(&ctx).unwrap();
4270
4271        assert_eq!(
4272            result.len(),
4273            1,
4274            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4275        );
4276    }
4277
4278    #[test]
4279    fn test_search_paths_combined_with_obsidian() {
4280        let temp_dir = tempdir().unwrap();
4281        let vault = temp_dir.path().join("vault-combo");
4282        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4283        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4284        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4285        std::fs::create_dir_all(vault.join("notes")).unwrap();
4286
4287        std::fs::write(
4288            vault.join(".obsidian/app.json"),
4289            r#"{"attachmentFolderPath": "Attachments"}"#,
4290        )
4291        .unwrap();
4292        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4293        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4294
4295        let notes_dir = vault.join("notes");
4296        let source_file = notes_dir.join("test.md");
4297        std::fs::write(&source_file, "placeholder").unwrap();
4298
4299        let extra_assets_dir = vault.join("extra-assets");
4300        let config = MD057Config {
4301            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4302            ..Default::default()
4303        };
4304        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
4305
4306        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
4307        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
4308        let ctx =
4309            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4310        let result = rule.check(&ctx).unwrap();
4311
4312        assert!(
4313            result.is_empty(),
4314            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4315        );
4316    }
4317
4318    #[test]
4319    fn test_obsidian_attachment_subfolder_under_file() {
4320        let temp_dir = tempdir().unwrap();
4321        let vault = temp_dir.path().join("vault-sub");
4322        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4323        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4324
4325        std::fs::write(
4326            vault.join(".obsidian/app.json"),
4327            r#"{"attachmentFolderPath": "./assets"}"#,
4328        )
4329        .unwrap();
4330        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4331
4332        let notes_dir = vault.join("notes");
4333        let source_file = notes_dir.join("test.md");
4334        std::fs::write(&source_file, "placeholder").unwrap();
4335
4336        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4337
4338        let content = "# Test\n\n![Photo](photo.png)\n";
4339        let ctx =
4340            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4341        let result = rule.check(&ctx).unwrap();
4342
4343        assert!(
4344            result.is_empty(),
4345            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4346        );
4347    }
4348
4349    #[test]
4350    fn test_obsidian_attachment_vault_root() {
4351        let temp_dir = tempdir().unwrap();
4352        let vault = temp_dir.path().join("vault-root");
4353        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4354        std::fs::create_dir_all(vault.join("notes")).unwrap();
4355
4356        // Empty string = vault root
4357        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4358        std::fs::write(vault.join("photo.png"), "fake").unwrap();
4359
4360        let notes_dir = vault.join("notes");
4361        let source_file = notes_dir.join("test.md");
4362        std::fs::write(&source_file, "placeholder").unwrap();
4363
4364        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4365
4366        let content = "# Test\n\n![Photo](photo.png)\n";
4367        let ctx =
4368            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4369        let result = rule.check(&ctx).unwrap();
4370
4371        assert!(
4372            result.is_empty(),
4373            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4374        );
4375    }
4376
4377    #[test]
4378    fn test_search_paths_multiple_directories() {
4379        let temp_dir = tempdir().unwrap();
4380        let base_path = temp_dir.path();
4381
4382        let dir_a = base_path.join("dir-a");
4383        let dir_b = base_path.join("dir-b");
4384        std::fs::create_dir_all(&dir_a).unwrap();
4385        std::fs::create_dir_all(&dir_b).unwrap();
4386        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4387        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4388
4389        let config = MD057Config {
4390            search_paths: vec![
4391                dir_a.to_string_lossy().into_owned(),
4392                dir_b.to_string_lossy().into_owned(),
4393            ],
4394            ..Default::default()
4395        };
4396        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4397
4398        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
4399        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4400        let result = rule.check(&ctx).unwrap();
4401
4402        assert!(
4403            result.is_empty(),
4404            "Should find files across multiple search paths. Got: {result:?}"
4405        );
4406    }
4407
4408    /// MD057 validates every link target in `check()`, so its `cross_file_check`
4409    /// deliberately reports nothing: emitting there too would double every broken
4410    /// link warning.
4411    ///
4412    /// The target here does not exist anywhere the rule would look, so a
4413    /// `cross_file_check` that started resolving paths would report it and fail
4414    /// this test. The paired `check()` call is the positive control proving the
4415    /// link really is broken, which is what keeps the empty result meaningful.
4416    #[test]
4417    fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4418        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4419
4420        let temp_dir = tempdir().unwrap();
4421        let base_path = temp_dir.path();
4422
4423        let file_path = base_path.join("README.md");
4424        let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4425        std::fs::write(&file_path, content).unwrap();
4426
4427        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4428
4429        let ctx = crate::lint_context::LintContext::new(
4430            content,
4431            crate::config::MarkdownFlavor::Standard,
4432            Some(file_path.clone()),
4433        );
4434        let per_file = rule.check(&ctx).unwrap();
4435        assert_eq!(
4436            per_file.len(),
4437            1,
4438            "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4439        );
4440
4441        let mut file_index = FileIndex::default();
4442        file_index.cross_file_links.push(CrossFileLinkIndex {
4443            target_path: "missing-guide.md".to_string(),
4444            fragment: String::new(),
4445            line: 3,
4446            column: 1,
4447            origin: LinkOrigin::Body,
4448        });
4449
4450        let result = rule
4451            .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4452            .unwrap();
4453
4454        assert!(
4455            result.is_empty(),
4456            "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4457        );
4458    }
4459
4460    #[test]
4461    fn test_check_clears_stale_cache() {
4462        // Verify that check() resets the file existence cache so stale entries from
4463        // a previous lint cycle do not suppress valid warnings.
4464        let temp_dir = tempdir().unwrap();
4465        let base_path = temp_dir.path();
4466
4467        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4468
4469        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
4470        let phantom_path = base_path.join("phantom.md");
4471        FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(phantom_path.clone(), true));
4472
4473        let content = "[phantom](phantom.md)\n";
4474        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4475        let warnings = rule.check(&ctx).unwrap();
4476
4477        // check() must reset the cache; stale "exists=true" must not suppress the warning.
4478        assert_eq!(
4479            warnings.len(),
4480            1,
4481            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4482        );
4483        assert!(warnings[0].message.contains("phantom.md"));
4484    }
4485
4486    #[test]
4487    fn test_check_does_not_carry_over_cache_between_runs() {
4488        // Two consecutive check() calls should each start with a fresh cache.
4489        let temp_dir = tempdir().unwrap();
4490        let base_path = temp_dir.path();
4491
4492        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4493
4494        let content = "[missing](nonexistent.md)\n";
4495        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4496
4497        // First run: file doesn't exist — warning expected.
4498        let warnings_1 = rule.check(&ctx).unwrap();
4499        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4500
4501        // Inject a stale "exists = true" entry for the resolved path.
4502        let nonexistent_path = base_path.join("nonexistent.md");
4503        FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(nonexistent_path.clone(), true));
4504
4505        // Second run: cache says file exists, but check() should reset it first.
4506        let warnings_2 = rule.check(&ctx).unwrap();
4507        assert_eq!(
4508            warnings_2.len(),
4509            1,
4510            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4511        );
4512    }
4513
4514    /// Checks `content` as the file `name` in `dir` and counts the anchors the
4515    /// check worked out.
4516    fn anchor_resolutions_for(dir: &Path, name: &str, content: &str, config: MD057Config) -> (Vec<LintWarning>, usize) {
4517        let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4518        let warnings = self_referential_links_tests::check_as_file(dir, name, content, config);
4519        (warnings, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4520    }
4521
4522    #[test]
4523    fn a_document_without_relative_destinations_never_resolves_its_anchor() {
4524        let temp_dir = tempdir().unwrap();
4525        let content = "[web](https://example.com/a.md) [top](#top) [abs](/docs/a.md)\n\
4526                       ![img](https://example.com/i.png) ![abs](/i.png)\n\n\
4527                       [ref]: https://example.com/r.md\n[aref]: /r.md\n";
4528        let (warnings, resolutions) =
4529            anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4530        assert!(warnings.is_empty(), "{warnings:?}");
4531        assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4532    }
4533
4534    #[test]
4535    fn a_document_resolves_its_anchor_once_for_every_relative_destination() {
4536        let temp_dir = tempdir().unwrap();
4537        std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4538        let content = "[a](there.md) [b](missing.md) ![c](missing.png)\n\n[d]: there.md\n";
4539        let (warnings, resolutions) =
4540            anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4541        assert_eq!(warnings.len(), 2, "{warnings:?}");
4542        assert_eq!(resolutions, 1);
4543    }
4544
4545    #[test]
4546    fn an_absolute_link_resolved_from_the_docs_dir_resolves_the_anchor() {
4547        let temp_dir = tempdir().unwrap();
4548        std::fs::write(temp_dir.path().join("mkdocs.yml"), "site_name: x\n").unwrap();
4549        std::fs::create_dir(temp_dir.path().join("docs")).unwrap();
4550        let config = MD057Config {
4551            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4552            ..Default::default()
4553        };
4554        let (warnings, resolutions) =
4555            anchor_resolutions_for(&temp_dir.path().join("docs"), "doc.md", "[abs](/missing.md)\n", config);
4556        assert_eq!(warnings.len(), 1, "{warnings:?}");
4557        assert!(warnings[0].message.contains("does not exist"), "{warnings:?}");
4558        assert_eq!(resolutions, 1);
4559    }
4560
4561    /// Fingerprints `content` as the file `doc.md` in `dir`, counting the anchors
4562    /// the fingerprint worked out.
4563    #[cfg(feature = "blake3")]
4564    fn fingerprint_anchor_resolutions(dir: &Path, content: &str) -> (String, usize) {
4565        let source_file = dir.join("doc.md");
4566        std::fs::write(&source_file, content).unwrap();
4567        let rule = MD057ExistingRelativeLinks::default();
4568        let ctx = crate::lint_context::LintContext::new(
4569            content,
4570            crate::config::MarkdownFlavor::Standard,
4571            Some(source_file.clone()),
4572        );
4573        let mut file_index = FileIndex::new();
4574        rule.contribute_to_index(&ctx, &mut file_index);
4575        // Each CLI run is a fresh process, so no existence answer carries over.
4576        reset_file_existence_cache();
4577        let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4578        let fingerprint =
4579            rule.cache_dependency_fingerprint(&source_file, crate::config::MarkdownFlavor::Standard, &file_index);
4580        (fingerprint, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4581    }
4582
4583    #[cfg(feature = "blake3")]
4584    #[test]
4585    fn a_fingerprint_over_absolute_destinations_never_resolves_the_anchor() {
4586        let temp_dir = tempdir().unwrap();
4587        let (_, resolutions) =
4588            fingerprint_anchor_resolutions(temp_dir.path(), "[a](/docs/a.md) ![b](/i.png)\n\n[c]: /r.md\n");
4589        assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4590    }
4591
4592    #[cfg(feature = "blake3")]
4593    #[test]
4594    fn a_fingerprint_over_relative_destinations_resolves_the_anchor_once_and_tracks_them() {
4595        let temp_dir = tempdir().unwrap();
4596        let content = "[a](there.md) [b](missing.md)\n\n[c]: there.md\n";
4597        let (missing, resolutions) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4598        assert_eq!(resolutions, 1);
4599        std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4600        let (present, _) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4601        assert_ne!(missing, present, "creating a link target must change the fingerprint");
4602    }
4603
4604    // --- Bug #631: duplicate warnings for broken relative links ---
4605
4606    /// Regression test: a single broken relative link must produce exactly one
4607    /// warning across both check() and cross_file_check(). Previously, each
4608    /// code path emitted an identical warning independently, causing duplicates.
4609    #[test]
4610    fn test_no_duplicate_warnings_for_broken_relative_link() {
4611        use crate::workspace_index::WorkspaceIndex;
4612
4613        let temp_dir = tempdir().unwrap();
4614        let base_path = temp_dir.path();
4615
4616        // The broken link target does NOT exist on disk.
4617        let source_file = base_path.join("index.md");
4618        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4619
4620        let content = "[broken](does/not/exist.md)\n";
4621
4622        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4623
4624        // Collect warnings from check() (per-file path)
4625        let ctx = crate::lint_context::LintContext::new(
4626            content,
4627            crate::config::MarkdownFlavor::Standard,
4628            Some(source_file.clone()),
4629        );
4630        let check_warnings = rule.check(&ctx).unwrap();
4631
4632        // Collect warnings from cross_file_check() (workspace-index path)
4633        let mut file_index = FileIndex::new();
4634        rule.contribute_to_index(&ctx, &mut file_index);
4635        let workspace_index = WorkspaceIndex::new();
4636        let cross_warnings = rule
4637            .cross_file_check(&source_file, &file_index, &workspace_index)
4638            .unwrap();
4639
4640        let total = check_warnings.len() + cross_warnings.len();
4641        assert_eq!(
4642            total, 1,
4643            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4644             check={check_warnings:?}, cross={cross_warnings:?}"
4645        );
4646    }
4647
4648    // --- Bug #632: absolute directory links incorrectly flagged ---
4649
4650    /// With absolute-links = "relative_to_roots", links to existing targets must
4651    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
4652    #[test]
4653    fn test_absolute_dir_link_accepted_relative_to_roots() {
4654        let temp_dir = tempdir().unwrap();
4655        let root = temp_dir.path();
4656
4657        // Create directory `d` with a file inside (but no index.md)
4658        let dir_d = root.join("d");
4659        std::fs::create_dir_all(&dir_d).unwrap();
4660        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4661
4662        // Content exercises all four matrix cells:
4663        //   relative file, relative dir, absolute file, absolute dir
4664        let content = "\
4665[absolute dir](/d)\n\
4666[relative dir](d)\n\
4667[absolute file](/d/foo.md)\n\
4668[relative file](d/foo.md)\n";
4669
4670        let config = MD057Config {
4671            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4672            roots: vec![],
4673            ..Default::default()
4674        };
4675        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4676
4677        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4678        let result = rule.check(&ctx).unwrap();
4679
4680        assert!(
4681            result.is_empty(),
4682            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4683        );
4684    }
4685
4686    /// In filesystem mode every spelling of a link to an existing directory is
4687    /// accepted, and a link to a directory that does not exist is still reported.
4688    /// The trailing slash is punctuation here: nothing routes `/d/` to `d/index.md`
4689    /// outside a site generator, and the relative form `../d/` is already accepted
4690    /// by `path.exists()` with no such requirement. (#863)
4691    #[test]
4692    fn test_absolute_directory_link_is_accepted_however_it_is_spelled() {
4693        let temp_dir = tempdir().unwrap();
4694        let root = temp_dir.path();
4695
4696        // Create directory `d` WITHOUT index.md
4697        let dir_d = root.join("d");
4698        std::fs::create_dir_all(&dir_d).unwrap();
4699        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4700
4701        let content = "\
4702[no slash](/d)\n\
4703[trailing slash](/d/)\n\
4704[trailing slash and fragment](/d/#intro)\n\
4705[relative](d/)\n";
4706
4707        let config = MD057Config {
4708            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4709            roots: vec![],
4710            ..Default::default()
4711        };
4712        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4713
4714        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4715        let result = rule.check(&ctx).unwrap();
4716
4717        assert!(
4718            result.is_empty(),
4719            "Every spelling of a link to an existing directory must agree. Got: {result:?}"
4720        );
4721    }
4722
4723    /// The control for the test above: dropping the index.md requirement must not
4724    /// have dropped the existence check with it.
4725    #[test]
4726    fn test_absolute_directory_link_to_a_missing_directory_is_still_reported() {
4727        let temp_dir = tempdir().unwrap();
4728        let root = temp_dir.path();
4729
4730        let content = "[gone](/nodir/)\n";
4731
4732        let config = MD057Config {
4733            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4734            roots: vec![],
4735            ..Default::default()
4736        };
4737        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4738
4739        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4740        let result = rule.check(&ctx).unwrap();
4741
4742        assert_eq!(
4743            result.len(),
4744            1,
4745            "A directory link naming nothing on disk must still be reported. Got: {result:?}"
4746        );
4747    }
4748
4749    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
4750    /// is absent. This is tested via the full check() path with RelativeToDocs config
4751    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
4752    #[test]
4753    fn test_docs_dir_variant_still_enforces_index_md() {
4754        let temp_dir = tempdir().unwrap();
4755        let root = temp_dir.path();
4756
4757        // Create a minimal mkdocs.yml pointing at a "docs" directory
4758        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4759
4760        // Create docs/section/ WITHOUT index.md
4761        let docs_dir = root.join("docs");
4762        std::fs::create_dir_all(&docs_dir).unwrap();
4763        let section_dir = docs_dir.join("section");
4764        std::fs::create_dir_all(&section_dir).unwrap();
4765        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4766
4767        // Create the source markdown file inside docs/
4768        let source_file = docs_dir.join("index.md");
4769        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4770
4771        let config = MD057Config {
4772            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4773            ..Default::default()
4774        };
4775        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4776
4777        let content = "[sec](/section)\n";
4778        let ctx = crate::lint_context::LintContext::new(
4779            content,
4780            crate::config::MarkdownFlavor::Standard,
4781            Some(source_file.clone()),
4782        );
4783        let result = rule.check(&ctx).unwrap();
4784
4785        // MkDocs enforces index.md for directory links, so this should be flagged.
4786        assert_eq!(
4787            result.len(),
4788            1,
4789            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4790        );
4791        assert!(
4792            result[0].message.contains("index.md") || result[0].message.contains("section"),
4793            "Message should mention the directory or missing index.md: {}",
4794            result[0].message
4795        );
4796    }
4797
4798    /// MkDocs mode is where the index.md requirement belongs, and every spelling
4799    /// of a directory link has to reach it — including one whose trailing slash is
4800    /// hidden behind a fragment (`/guide/#intro`). This is the control proving the
4801    /// roots-mode change did not disable the check everywhere.
4802    #[test]
4803    fn test_docs_mode_requires_index_for_every_spelling_of_a_directory_link() {
4804        let temp_dir = tempdir().unwrap();
4805        let root = temp_dir.path();
4806        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4807
4808        // Create docs/guide WITHOUT index.md
4809        let docs_dir = root.join("docs");
4810        let guide_dir = docs_dir.join("guide");
4811        std::fs::create_dir_all(&guide_dir).unwrap();
4812        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4813
4814        let source_file = docs_dir.join("t.md");
4815        let content = "\
4816[no slash](/guide)\n\
4817[trailing slash](/guide/)\n\
4818[trailing slash and fragment](/guide/#intro)\n";
4819        std::fs::write(&source_file, content).unwrap();
4820
4821        let config = MD057Config {
4822            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4823            ..Default::default()
4824        };
4825        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4826        let ctx = crate::lint_context::LintContext::new(
4827            content,
4828            crate::config::MarkdownFlavor::Standard,
4829            Some(source_file.clone()),
4830        );
4831        let result = rule.check(&ctx).unwrap();
4832
4833        assert_eq!(
4834            result.len(),
4835            3,
4836            "Every directory link must be routed through the index.md check. Got: {result:?}"
4837        );
4838        for warning in &result {
4839            assert!(
4840                warning.message.contains("which has no index.md"),
4841                "The message must name the reason, not report the directory as missing: {}",
4842                warning.message
4843            );
4844        }
4845    }
4846}
4847
4848#[cfg(test)]
4849mod self_referential_links_tests {
4850    use super::*;
4851    use tempfile::tempdir;
4852
4853    /// A document written to `dir/<name>`, checked as itself.
4854    pub(super) fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4855        let source_file = dir.join(name);
4856        std::fs::write(&source_file, content).unwrap();
4857        let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4858        let ctx =
4859            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4860        rule.check(&ctx).unwrap()
4861    }
4862
4863    fn enabled() -> MD057Config {
4864        MD057Config {
4865            self_referential_links: true,
4866            ..Default::default()
4867        }
4868    }
4869
4870    #[test]
4871    fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4872        let temp_dir = tempdir().unwrap();
4873        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4874        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4875
4876        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4877        assert_eq!(
4878            result[0].message,
4879            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4880        );
4881        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4882        assert_eq!(fix.replacement, "#level-2-heading");
4883        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4884    }
4885
4886    #[test]
4887    fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4888        let temp_dir = tempdir().unwrap();
4889        let content = "# Title\n\nSee [this file](test.md).\n";
4890        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4891
4892        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4893        assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4894        assert!(
4895            result[0].fix.is_none(),
4896            "Dropping the link would change the document, so there is no fix"
4897        );
4898    }
4899
4900    #[test]
4901    fn test_the_check_is_off_by_default() {
4902        let temp_dir = tempdir().unwrap();
4903        let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4904        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4905
4906        assert!(result.is_empty(), "Off by default. Got: {result:?}");
4907    }
4908
4909    #[test]
4910    fn test_a_link_to_another_file_is_left_alone() {
4911        let temp_dir = tempdir().unwrap();
4912        std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4913        let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4914        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4915
4916        assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4917    }
4918
4919    #[test]
4920    fn test_a_self_link_written_with_traversal_reports_once() {
4921        let temp_dir = tempdir().unwrap();
4922        let sub_dir = temp_dir.path().join("sub");
4923        std::fs::create_dir_all(&sub_dir).unwrap();
4924
4925        let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4926        let config = MD057Config {
4927            self_referential_links: true,
4928            compact_paths: true,
4929            ..Default::default()
4930        };
4931        let result = check_as_file(&sub_dir, "test.md", content, config);
4932
4933        assert_eq!(
4934            result.len(),
4935            1,
4936            "A compacted path would still be a link back to this file. Got: {result:?}"
4937        );
4938        assert_eq!(
4939            result[0].message,
4940            "Relative link '../sub/test.md' points to the file it is in"
4941        );
4942    }
4943
4944    #[test]
4945    fn test_compact_paths_still_reports_a_link_to_another_file() {
4946        let temp_dir = tempdir().unwrap();
4947        let sub_dir = temp_dir.path().join("sub");
4948        std::fs::create_dir_all(&sub_dir).unwrap();
4949        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4950
4951        let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4952        let config = MD057Config {
4953            self_referential_links: true,
4954            compact_paths: true,
4955            ..Default::default()
4956        };
4957        let result = check_as_file(&sub_dir, "test.md", content, config);
4958
4959        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4960        assert_eq!(
4961            result[0].message,
4962            "Relative link '../sub/other.md' can be simplified to 'other.md'"
4963        );
4964    }
4965
4966    #[test]
4967    fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4968        let temp_dir = tempdir().unwrap();
4969        let content = "# Title\n\nSee [this file](test#title).\n";
4970        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4971
4972        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4973        assert_eq!(
4974            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4975            Some("#title"),
4976            "Got: {result:?}"
4977        );
4978    }
4979
4980    #[test]
4981    fn test_a_reference_definition_pointing_at_its_own_file() {
4982        let temp_dir = tempdir().unwrap();
4983        let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4984        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4985
4986        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4987        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4988        assert_eq!(fix.replacement, "#level-2-heading");
4989        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4990    }
4991
4992    #[test]
4993    fn test_a_reference_definition_whose_label_repeats_the_destination() {
4994        let temp_dir = tempdir().unwrap();
4995        let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4996        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4997
4998        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4999        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5000        // The label reads the same as the destination, so an unanchored search
5001        // would rewrite the label and orphan the usage above.
5002        assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
5003        let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
5004            .fix(&crate::lint_context::LintContext::new(
5005                content,
5006                crate::config::MarkdownFlavor::Standard,
5007                Some(temp_dir.path().join("test.md")),
5008            ))
5009            .unwrap();
5010        assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
5011        assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
5012    }
5013
5014    #[test]
5015    fn test_a_self_link_resolved_through_a_search_path() {
5016        let temp_dir = tempdir().unwrap();
5017        let guide_dir = temp_dir.path().join("docs/guide");
5018        std::fs::create_dir_all(&guide_dir).unwrap();
5019        let config = MD057Config {
5020            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
5021            ..enabled()
5022        };
5023        let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
5024        let result = check_as_file(&guide_dir, "test.md", content, config);
5025
5026        assert_eq!(
5027            result.len(),
5028            1,
5029            "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
5030        );
5031        assert_eq!(
5032            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
5033            Some("#title"),
5034            "Got: {result:?}"
5035        );
5036    }
5037
5038    #[test]
5039    fn test_a_target_next_to_the_document_outranks_a_search_path() {
5040        let temp_dir = tempdir().unwrap();
5041        let guide_dir = temp_dir.path().join("docs/guide");
5042        std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
5043        std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
5044        let config = MD057Config {
5045            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
5046            ..enabled()
5047        };
5048        let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
5049        let result = check_as_file(&guide_dir, "test.md", content, config);
5050
5051        assert!(
5052            result.is_empty(),
5053            "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
5054        );
5055    }
5056
5057    #[test]
5058    fn test_an_image_pointing_at_its_own_file_is_not_reported() {
5059        let temp_dir = tempdir().unwrap();
5060        let content = "# Title\n\n![not a navigation link](test.md)\n";
5061        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
5062
5063        assert!(
5064            result.is_empty(),
5065            "An image is not a link the reader follows. Got: {result:?}"
5066        );
5067    }
5068
5069    #[test]
5070    fn test_a_query_string_is_reported_without_a_suggestion() {
5071        let temp_dir = tempdir().unwrap();
5072        let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
5073        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
5074
5075        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5076        assert!(
5077            result[0].fix.is_none(),
5078            "A query does not survive losing its path. Got: {result:?}"
5079        );
5080    }
5081
5082    #[test]
5083    fn test_fix_rewrites_the_document_and_settles() {
5084        let temp_dir = tempdir().unwrap();
5085        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
5086        let source_file = temp_dir.path().join("test.md");
5087        std::fs::write(&source_file, content).unwrap();
5088
5089        let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
5090        let ctx = crate::lint_context::LintContext::new(
5091            content,
5092            crate::config::MarkdownFlavor::Standard,
5093            Some(source_file.clone()),
5094        );
5095        let fixed = rule.fix(&ctx).unwrap();
5096        assert_eq!(
5097            fixed,
5098            "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
5099        );
5100
5101        let refixed =
5102            crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
5103        assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
5104    }
5105
5106    #[test]
5107    fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
5108        let unfixable = MD057ExistingRelativeLinks::default();
5109        assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
5110
5111        let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
5112        assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
5113    }
5114
5115    #[test]
5116    fn test_the_option_is_read_from_kebab_and_snake_case() {
5117        let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
5118        assert!(kebab.self_referential_links);
5119
5120        let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
5121        assert!(snake.self_referential_links);
5122    }
5123
5124    fn front_matter_checked() -> MD057Config {
5125        MD057Config {
5126            check_frontmatter: true,
5127            ..Default::default()
5128        }
5129    }
5130
5131    #[test]
5132    fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
5133        let temp_dir = tempdir().unwrap();
5134        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5135        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5136
5137        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5138        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5139        assert_eq!(result[0].line, 2);
5140        assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
5141        assert_eq!(result[0].end_column, 23);
5142    }
5143
5144    #[test]
5145    fn test_frontmatter_paths_are_not_checked_by_default() {
5146        let temp_dir = tempdir().unwrap();
5147        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5148        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5149
5150        assert!(
5151            result.is_empty(),
5152            "Frontmatter is only checked on request. Got: {result:?}"
5153        );
5154    }
5155
5156    #[test]
5157    fn test_an_existing_frontmatter_path_is_not_reported() {
5158        let temp_dir = tempdir().unwrap();
5159        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5160        let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
5161        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5162
5163        assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
5164        assert_eq!(result[0].line, 3);
5165    }
5166
5167    #[test]
5168    fn test_an_ignored_frontmatter_field_is_not_checked() {
5169        let temp_dir = tempdir().unwrap();
5170        let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
5171        let config = MD057Config {
5172            check_frontmatter: true,
5173            ignore_frontmatter_fields: vec!["Image".to_string()],
5174            ..Default::default()
5175        };
5176        let result = check_as_file(temp_dir.path(), "test.md", content, config);
5177
5178        assert_eq!(
5179            result.len(),
5180            1,
5181            "The ignored field is skipped and the other is not. Got: {result:?}"
5182        );
5183        assert_eq!(result[0].line, 3);
5184    }
5185
5186    #[test]
5187    fn test_an_external_frontmatter_url_is_not_reported() {
5188        let temp_dir = tempdir().unwrap();
5189        let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
5190        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5191
5192        assert!(
5193            result.is_empty(),
5194            "An external URL has no local target. Got: {result:?}"
5195        );
5196    }
5197
5198    #[test]
5199    fn test_a_frontmatter_fragment_is_left_to_md051() {
5200        let temp_dir = tempdir().unwrap();
5201        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
5202        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5203
5204        assert!(
5205            result.is_empty(),
5206            "A fragment names a heading, not a file. Got: {result:?}"
5207        );
5208    }
5209
5210    #[test]
5211    fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
5212        let temp_dir = tempdir().unwrap();
5213        let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
5214
5215        let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
5216        assert!(
5217            ignored.is_empty(),
5218            "Absolute paths are ignored by default. Got: {ignored:?}"
5219        );
5220
5221        let warning_config = MD057Config {
5222            check_frontmatter: true,
5223            absolute_links: AbsoluteLinksOption::Warn,
5224            ..Default::default()
5225        };
5226        let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
5227        assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
5228        assert_eq!(
5229            warned[0].message,
5230            "Absolute link '/docs/guide.md' cannot be validated locally"
5231        );
5232    }
5233
5234    #[test]
5235    fn test_a_frontmatter_path_carrying_a_query_is_checked() {
5236        let temp_dir = tempdir().unwrap();
5237        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5238        let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
5239        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5240
5241        assert_eq!(
5242            result.len(),
5243            1,
5244            "A query names no file, so only the missing target is reported. Got: {result:?}"
5245        );
5246        assert_eq!(result[0].line, 2);
5247        assert_eq!(
5248            result[0].message,
5249            "Relative link 'docs/missing.md?raw=true' does not exist"
5250        );
5251    }
5252
5253    #[test]
5254    fn test_prose_in_frontmatter_is_not_read_as_a_path() {
5255        let temp_dir = tempdir().unwrap();
5256        let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
5257        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5258
5259        assert!(
5260            result.is_empty(),
5261            "Only path-shaped values are destinations. Got: {result:?}"
5262        );
5263    }
5264}
5265
5266/// An inline link whose text carries a newline is checked like any other.
5267///
5268/// The destination of such a link sits on the line the link ends on, so every
5269/// report on it names that line and the column the destination starts at.
5270#[cfg(test)]
5271mod wrapped_link_text_tests {
5272    use super::self_referential_links_tests::check_as_file;
5273    use super::*;
5274    use tempfile::tempdir;
5275
5276    #[test]
5277    fn test_a_wrapped_link_in_a_list_item_is_reported() {
5278        let temp_dir = tempdir().unwrap();
5279        let content = "- Items reimbursable by the various [one-off\n  expense](does-not-exist-anywhere)\n  budgets.\n";
5280        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5281
5282        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5283        assert_eq!(
5284            result[0].message,
5285            "Relative link 'does-not-exist-anywhere' does not exist"
5286        );
5287        assert_eq!(result[0].line, 2, "The destination sits on the second line");
5288        assert_eq!(result[0].end_line, 2);
5289        // Line 2 is `  expense](does-not-exist-anywhere)`, so the destination
5290        // starts at the twelfth character and runs 23 characters.
5291        assert_eq!(result[0].column, 12);
5292        assert_eq!(result[0].end_column, 35);
5293    }
5294
5295    #[test]
5296    fn test_a_wrapped_link_in_a_paragraph_is_reported() {
5297        let temp_dir = tempdir().unwrap();
5298        let content = "Paragraph with [wrapped\ntext](also-missing) here.\n";
5299        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5300
5301        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5302        assert_eq!(result[0].message, "Relative link 'also-missing' does not exist");
5303        assert_eq!(result[0].line, 2, "The destination sits on the second line");
5304        // Line 2 is `text](also-missing) here.`, so the destination starts at
5305        // the seventh character and runs 12 characters.
5306        assert_eq!(result[0].column, 7);
5307        assert_eq!(result[0].end_column, 19);
5308    }
5309
5310    #[test]
5311    fn test_a_wrapped_link_carrying_a_title_is_reported() {
5312        let temp_dir = tempdir().unwrap();
5313        let content = "[wrapped\ntext](missing-titled.md \"t\")\n";
5314        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5315
5316        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5317        assert_eq!(result[0].message, "Relative link 'missing-titled.md' does not exist");
5318        assert_eq!(result[0].line, 2);
5319        // The title is not part of the destination, so the warning ends with
5320        // the path at the twenty-fourth character.
5321        assert_eq!(result[0].column, 7);
5322        assert_eq!(result[0].end_column, 24);
5323    }
5324
5325    #[test]
5326    fn test_a_wrapped_link_to_an_existing_file_is_left_alone() {
5327        let temp_dir = tempdir().unwrap();
5328        std::fs::write(temp_dir.path().join("target.md"), "# Target\n").unwrap();
5329        let content = "Paragraph with [wrapped\ntext](target.md) here.\n";
5330        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5331
5332        assert!(result.is_empty(), "The target exists. Got: {result:?}");
5333    }
5334
5335    #[test]
5336    fn test_a_wrapped_link_target_reaches_the_dependency_index() {
5337        let rule = MD057ExistingRelativeLinks::new();
5338        let content = "Paragraph with [wrapped\ntext](./docs/guide.md) here.\n";
5339
5340        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5341        let mut index = FileIndex::new();
5342        rule.contribute_to_index(&ctx, &mut index);
5343
5344        let targets: Vec<&str> = index
5345            .md057_link_targets
5346            .iter()
5347            .map(|target| target.target.as_str())
5348            .collect();
5349        assert_eq!(
5350            targets,
5351            vec!["./docs/guide.md"],
5352            "The index must record the target so a cached verdict is invalidated when the file appears"
5353        );
5354    }
5355
5356    #[test]
5357    fn test_a_wrapped_link_is_compacted_over_the_right_bytes() {
5358        let temp_dir = tempdir().unwrap();
5359        let sub_dir = temp_dir.path().join("sub");
5360        std::fs::create_dir_all(&sub_dir).unwrap();
5361        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
5362
5363        let content = "See [the long way\nround](../sub/other.md#part) here.\n";
5364        let config = MD057Config {
5365            compact_paths: true,
5366            ..Default::default()
5367        };
5368        let result = check_as_file(&sub_dir, "test.md", content, config);
5369
5370        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
5371        assert_eq!(
5372            result[0].message,
5373            "Relative link '../sub/other.md#part' can be simplified to 'other.md#part'"
5374        );
5375        assert_eq!(result[0].line, 2);
5376        let fix = result[0].fix.as_ref().expect("a compaction is fixable");
5377        assert_eq!(&content[fix.range.clone()], "../sub/other.md#part");
5378        assert_eq!(fix.replacement, "other.md#part");
5379    }
5380
5381    #[test]
5382    fn test_a_wrapped_self_link_is_reduced_over_the_right_bytes() {
5383        let temp_dir = tempdir().unwrap();
5384        let content = "# Title\n\nSee [the section\nbelow](test.md#level-2-heading).\n\n## Level 2 heading\n";
5385        let config = MD057Config {
5386            self_referential_links: true,
5387            ..Default::default()
5388        };
5389        let result = check_as_file(temp_dir.path(), "test.md", content, config);
5390
5391        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5392        assert_eq!(
5393            result[0].message,
5394            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
5395        );
5396        assert_eq!(result[0].line, 4);
5397        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5398        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
5399        assert_eq!(fix.replacement, "#level-2-heading");
5400    }
5401}
5402
5403/// The destination is read from inside the link's label, never from past it.
5404///
5405/// A label can spell `](` in a code span, and a destination title can spell it
5406/// in plain text. Neither opens a destination, and a shape that carries no
5407/// destination at all yields nothing to report and nothing to rewrite.
5408#[cfg(test)]
5409mod destination_boundary_tests {
5410    use super::self_referential_links_tests::check_as_file;
5411    use super::*;
5412    use tempfile::tempdir;
5413
5414    fn compacting() -> MD057Config {
5415        MD057Config {
5416            compact_paths: true,
5417            ..Default::default()
5418        }
5419    }
5420
5421    /// A label holding an unmatched backtick still ends where the parse closes
5422    /// it. The destination is the one the label closes on, not the `](` the
5423    /// title happens to contain.
5424    #[test]
5425    fn test_a_title_spelling_a_bracket_paren_is_not_the_destination() {
5426        let temp_dir = tempdir().unwrap();
5427        std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5428        let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5429
5430        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5431
5432        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5433        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5434        assert_eq!(result[0].line, 1);
5435        // `missing.md` opens at the thirteenth character, just past `](`.
5436        assert_eq!(result[0].column, 13);
5437        assert_eq!(result[0].end_column, 23);
5438        assert!(result[0].fix.is_none(), "A missing target carries no fix");
5439    }
5440
5441    /// The same document with compaction on. The path inside the title looks
5442    /// compactable, so a report naming it would rewrite the title.
5443    #[test]
5444    fn test_compaction_never_rewrites_a_title() {
5445        let temp_dir = tempdir().unwrap();
5446        std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5447        let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5448
5449        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5450
5451        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5452        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5453        assert_eq!(result[0].column, 13);
5454        assert!(
5455            result.iter().all(|warning| warning.fix.is_none()),
5456            "Nothing in this document is rewritable. Got: {result:?}"
5457        );
5458    }
5459
5460    /// Brackets nest inside a label, so the first `]` is not the one that
5461    /// closes it.
5462    #[test]
5463    fn test_a_nested_bracket_does_not_close_the_label() {
5464        let temp_dir = tempdir().unwrap();
5465        let content = "[see [note]](./missing.md)\n";
5466
5467        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5468
5469        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5470        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5471        // `./missing.md` opens at the fourteenth character, past both brackets.
5472        assert_eq!(result[0].column, 14);
5473        assert_eq!(result[0].end_column, 26);
5474    }
5475
5476    /// An autolink is a URL between angle brackets. Its text is the URL itself,
5477    /// so a `](` written in it opens no destination and nothing in it is a path
5478    /// this rule may rewrite.
5479    #[test]
5480    fn test_an_autolink_carries_no_destination() {
5481        let temp_dir = tempdir().unwrap();
5482        std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5483        let content = "<https://example.com/](./existing.md)> [ok](existing.md)\n";
5484
5485        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5486
5487        assert!(result.is_empty(), "An autolink is not a relative link. Got: {result:?}");
5488    }
5489
5490    /// Control for the autolink case: an email autolink is the same shape.
5491    #[test]
5492    fn test_an_email_autolink_carries_no_destination() {
5493        let temp_dir = tempdir().unwrap();
5494        let content = "<user@example.com>\n";
5495
5496        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5497
5498        assert!(
5499            result.is_empty(),
5500            "An email autolink is not a relative link. Got: {result:?}"
5501        );
5502    }
5503
5504    /// A code span holds its own `](`, and the span runs past it to the second
5505    /// run of backticks. The link closes on the bracket after that run, so the
5506    /// path inside the code span is not a destination and nothing about it is
5507    /// reportable or rewritable.
5508    #[test]
5509    fn test_a_code_span_spelling_a_bracket_paren_is_not_the_destination() {
5510        let temp_dir = tempdir().unwrap();
5511        std::fs::write(temp_dir.path().join("exists.md"), "# Exists\n").unwrap();
5512        let content = "[``a\n](./exists.md)``](exists.md)\n";
5513
5514        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5515
5516        assert!(
5517            result.is_empty(),
5518            "Both destinations exist, so nothing is reported. Got: {result:?}"
5519        );
5520        assert!(
5521            result.iter().all(|warning| warning.fix.is_none()),
5522            "Nothing here is rewritable, the code span least of all. Got: {result:?}"
5523        );
5524    }
5525
5526    /// Inline HTML in a label can spell `[` inside an attribute value. The
5527    /// parse reads the attribute as text rather than as an opening bracket, so
5528    /// the label closes where it is written to.
5529    #[test]
5530    fn test_a_bracket_in_an_html_attribute_does_not_open_a_label() {
5531        let temp_dir = tempdir().unwrap();
5532        let content = "[<i title=\"[\">text</i>](missing.md)\n";
5533
5534        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5535
5536        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5537        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5538        assert_eq!(result[0].line, 1);
5539        // `missing.md` opens at the twenty fifth character, just past `](`.
5540        assert_eq!(result[0].column, 25);
5541        assert_eq!(result[0].end_column, 35);
5542    }
5543
5544    /// Link syntax inside an image's description is not a link. The renderer
5545    /// puts the description in an `alt` attribute, where a hyperlink cannot
5546    /// exist, so its destination names no target this document reaches.
5547    #[test]
5548    fn test_a_link_inside_an_image_description_is_not_a_link() {
5549        let temp_dir = tempdir().unwrap();
5550        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5551        let content = "![an [example](missing.md)](exists.png)\n";
5552
5553        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5554
5555        assert!(
5556            result.is_empty(),
5557            "The description of an image carries no link. Got: {result:?}"
5558        );
5559    }
5560
5561    /// The control for the case above. A link cannot nest inside a link, so
5562    /// the outer brackets here are literal text and the inner link is the only
5563    /// one in the line. Nothing about it sits inside an image.
5564    #[test]
5565    fn test_a_link_inside_literal_brackets_is_still_a_link() {
5566        let temp_dir = tempdir().unwrap();
5567        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5568        let content = "[outer [inner](missing2.md)](exists.png)\n";
5569
5570        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5571
5572        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5573        assert_eq!(result[0].message, "Relative link 'missing2.md' does not exist");
5574        assert_eq!(result[0].line, 1);
5575        // `missing2.md` opens at the sixteenth character, just past `](`.
5576        assert_eq!(result[0].column, 16);
5577    }
5578
5579    /// The second control. An image is still read by the image pass, which the
5580    /// link pass does not touch.
5581    #[test]
5582    fn test_an_image_of_its_own_is_still_reported() {
5583        let temp_dir = tempdir().unwrap();
5584        let content = "![plain](missing.png)\n";
5585
5586        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5587
5588        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5589        assert_eq!(result[0].message, "Relative link 'missing.png' does not exist");
5590        assert_eq!(result[0].column, 1);
5591    }
5592
5593    /// The third control. An image used as a link's text leaves the link
5594    /// itself outside the image, so the link's own destination is still read.
5595    #[test]
5596    fn test_an_image_used_as_link_text_leaves_the_link_readable() {
5597        let temp_dir = tempdir().unwrap();
5598        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5599        let content = "[![alt](exists.png)](missing.md)\n";
5600
5601        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5602
5603        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5604        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5605        // `missing.md` opens at the twenty second character.
5606        assert_eq!(result[0].column, 22);
5607    }
5608
5609    /// The image used as link text is a collapsed reference. The label closes
5610    /// on the bracket after the image's `[]`, and the link's own destination
5611    /// is read from past that bracket.
5612    #[test]
5613    fn test_a_link_wrapping_a_collapsed_reference_image_is_still_read() {
5614        let temp_dir = tempdir().unwrap();
5615        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5616        let content = "[![alt][]](missing.md)\n\n[alt]: exists.png\n";
5617
5618        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5619
5620        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5621        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5622        assert_eq!(result[0].line, 1);
5623        // `missing.md` opens at the twelfth character, just past `](`.
5624        assert_eq!(result[0].column, 12);
5625        assert_eq!(result[0].end_column, 22);
5626    }
5627
5628    /// A reference image with no definition is not an image. The renderer
5629    /// leaves its brackets as text, so a link written between them is a real
5630    /// link and its destination is a real target. All three reference
5631    /// spellings behave the same way.
5632    #[test]
5633    fn test_a_link_inside_an_undefined_reference_image_is_a_link() {
5634        for content in [
5635            "![alt [x](missing.md)][nodef]\n",
5636            "![alt [x](missing.md)][]\n",
5637            "![alt [x](missing.md)]\n",
5638        ] {
5639            let temp_dir = tempdir().unwrap();
5640
5641            let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5642
5643            assert_eq!(result.len(), 1, "Expected one warning for {content:?}. Got: {result:?}");
5644            assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5645            // `missing.md` opens at the eleventh character, just past `](`.
5646            assert_eq!(result[0].column, 11, "Wrong column for {content:?}");
5647        }
5648    }
5649
5650    /// An inline image with an empty destination is still an image, so what is
5651    /// written between its brackets is still alt text.
5652    #[test]
5653    fn test_an_image_with_an_empty_destination_is_still_an_image() {
5654        let temp_dir = tempdir().unwrap();
5655        let content = "![alt [x](missing.md)]()\n";
5656
5657        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5658
5659        assert!(
5660            result.is_empty(),
5661            "An image with no destination is still an image. Got: {result:?}"
5662        );
5663    }
5664
5665    /// An image's description can hold another image before the link. The
5666    /// inner image ends before the link starts, so the image that contains
5667    /// the link is the outer one, and the link is alt text all the same.
5668    #[test]
5669    fn test_a_link_after_an_inner_image_is_still_inside_the_outer_image() {
5670        let temp_dir = tempdir().unwrap();
5671        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5672        let content = "![outer ![inner](exists.png) [link](missing.md)](exists.png)\n";
5673
5674        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5675
5676        assert!(
5677            result.is_empty(),
5678            "The link sits inside the outer image's description. Got: {result:?}"
5679        );
5680    }
5681
5682    /// The control for the case above. Without the outer image, the link
5683    /// follows an image rather than sitting inside one.
5684    #[test]
5685    fn test_a_link_after_an_image_is_a_link() {
5686        let temp_dir = tempdir().unwrap();
5687        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5688        let content = "![inner](exists.png) [link](missing.md)\n";
5689
5690        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5691
5692        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5693        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5694        // `missing.md` opens at the twenty ninth character, just past `](`.
5695        assert_eq!(result[0].column, 29);
5696    }
5697
5698    /// A reference image whose definition exists is an image like any other.
5699    #[test]
5700    fn test_a_link_inside_a_defined_reference_image_is_not_a_link() {
5701        let temp_dir = tempdir().unwrap();
5702        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5703        let content = "![alt [x](missing.md)][def]\n\n[def]: exists.png\n";
5704
5705        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5706
5707        assert!(
5708            result.is_empty(),
5709            "The description of a resolved reference image carries no link. Got: {result:?}"
5710        );
5711    }
5712
5713    /// A destination may start on the line after the `](` that opens it. The
5714    /// report names the line the destination is written on.
5715    #[test]
5716    fn test_a_destination_on_the_next_line_is_reported_there() {
5717        let temp_dir = tempdir().unwrap();
5718        let content = "[a](\n  ./gone.md)\n";
5719
5720        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5721
5722        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5723        assert_eq!(result[0].message, "Relative link './gone.md' does not exist");
5724        assert_eq!(result[0].line, 2, "The destination sits on the second line");
5725        assert_eq!(result[0].end_line, 2);
5726        // Line 2 is `  ./gone.md)`, so the destination opens at the third character.
5727        assert_eq!(result[0].column, 3);
5728        assert_eq!(result[0].end_column, 12);
5729    }
5730}
5731
5732/// A link target has to be spelled the way the filesystem stores it.
5733///
5734/// macOS and Windows resolve a differently cased spelling to the same file, so
5735/// a link that 404s once the project is served, or once it is checked out on
5736/// Linux, passes on the machine it was written on. On Linux the metadata probe
5737/// alone reports every one of these rows, so the walk is only observable on a
5738/// case-insensitive volume.
5739#[cfg(test)]
5740mod exact_case_tests {
5741    use super::self_referential_links_tests::check_as_file;
5742    use super::*;
5743    use tempfile::tempdir;
5744
5745    /// Whether the volume holding `dir` resolves a differently cased spelling
5746    /// to the same entry. Only such a volume can hide a case mismatch, so the
5747    /// rows that turn on one skip elsewhere, where the filesystem cannot
5748    /// produce the difference they assert.
5749    fn volume_folds_case(dir: &Path) -> bool {
5750        let probe = dir.join("case-fold-probe.tmp");
5751        std::fs::write(&probe, "").unwrap();
5752        let folded = dir.join("CASE-FOLD-PROBE.TMP").exists();
5753        std::fs::remove_file(&probe).unwrap();
5754        folded
5755    }
5756
5757    /// `Foo.md` and `Docs/Guide.md` on disk, linked in every spelling from the
5758    /// report: the four case mismatches, the three exact spellings and a target
5759    /// that is not there at all.
5760    fn write_case_fixture(dir: &Path) {
5761        std::fs::write(dir.join("Foo.md"), "# Foo\n").unwrap();
5762        std::fs::create_dir_all(dir.join("Docs")).unwrap();
5763        std::fs::write(dir.join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5764    }
5765
5766    /// The control for every row below: on a volume that folds case the
5767    /// filesystem answers for a spelling it does not store, which is the whole
5768    /// reason the spelling has to be confirmed against the listing. On a volume
5769    /// that does not fold, the rows hold for the plainer reason that the path
5770    /// is simply not there.
5771    #[test]
5772    fn test_the_filesystem_answers_for_a_spelling_it_does_not_store() {
5773        let temp_dir = tempdir().unwrap();
5774        let anchor = temp_dir.path();
5775        write_case_fixture(anchor);
5776        reset_file_existence_cache();
5777
5778        assert_eq!(anchor.join("foo.md").exists(), volume_folds_case(anchor));
5779        assert!(!exists_exact_case(anchor, &anchor.join("foo.md")));
5780    }
5781
5782    #[test]
5783    fn test_only_the_stored_spelling_of_a_target_exists() {
5784        let temp_dir = tempdir().unwrap();
5785        let anchor = temp_dir.path();
5786        write_case_fixture(anchor);
5787        reset_file_existence_cache();
5788
5789        for target in ["Foo.md", "Docs/Guide.md", "Docs"] {
5790            assert!(
5791                exists_exact_case(anchor, &anchor.join(target)),
5792                "{target} is on disk under this spelling"
5793            );
5794        }
5795        for target in ["foo.md", "docs/Guide.md", "Docs/guide.md", "docs", "Bar.md"] {
5796            assert!(
5797                !exists_exact_case(anchor, &anchor.join(target)),
5798                "{target} is not on disk under this spelling"
5799            );
5800        }
5801    }
5802
5803    /// A target without an extension is looked for under each markdown
5804    /// extension, and every candidate answers for its own spelling.
5805    #[test]
5806    fn test_the_extension_fallback_keeps_the_case_of_the_target() {
5807        let temp_dir = tempdir().unwrap();
5808        let anchor = temp_dir.path();
5809        write_case_fixture(anchor);
5810        reset_file_existence_cache();
5811
5812        assert!(resolve_existing_target(anchor, &anchor.join("Foo")).is_some());
5813        assert!(resolve_existing_target(anchor, &anchor.join("foo")).is_none());
5814    }
5815
5816    /// A link out of a subdirectory names entries of the directory it climbs
5817    /// to, and those are checked like any other.
5818    #[test]
5819    fn test_a_target_reached_through_a_parent_step_is_checked() {
5820        let temp_dir = tempdir().unwrap();
5821        write_case_fixture(temp_dir.path());
5822        let anchor = temp_dir.path().join("Docs");
5823        reset_file_existence_cache();
5824
5825        assert!(exists_exact_case(&anchor, &anchor.join("..").join("Foo.md")));
5826        assert!(!exists_exact_case(&anchor, &anchor.join("..").join("foo.md")));
5827    }
5828
5829    /// The path to the project is not the link author's, so the anchor and
5830    /// everything above it are taken as given whatever their case.
5831    #[test]
5832    fn test_a_component_above_the_anchor_is_accepted() {
5833        let temp_dir = tempdir().unwrap();
5834        write_case_fixture(temp_dir.path());
5835        let anchor = temp_dir.path().join("docs");
5836        reset_file_existence_cache();
5837
5838        assert!(has_exact_case_components(&anchor, &anchor.join("Guide.md")));
5839    }
5840
5841    /// A symlinked directory is named by the link, and the name the symlink
5842    /// points at is the filesystem's business. Resolving the link to its
5843    /// target's path would compare that other name and report a link that works.
5844    #[cfg(unix)]
5845    #[test]
5846    fn test_a_link_through_a_symlinked_directory_exists() {
5847        let temp_dir = tempdir().unwrap();
5848        let anchor = temp_dir.path();
5849        std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5850        std::fs::write(anchor.join("shared").join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5851        std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5852        reset_file_existence_cache();
5853
5854        assert!(exists_exact_case(anchor, &anchor.join("docs").join("Guide.md")));
5855    }
5856
5857    /// macOS stores a name outside ASCII in whichever normalization it is
5858    /// created with and answers for either, so the two forms spell one name.
5859    #[cfg(target_os = "macos")]
5860    #[test]
5861    fn test_a_decomposed_name_matches_a_composed_link() {
5862        let temp_dir = tempdir().unwrap();
5863        let anchor = temp_dir.path();
5864        std::fs::write(anchor.join("cafe\u{301}.md"), "# Cafe\n").unwrap();
5865        reset_file_existence_cache();
5866
5867        assert!(exists_exact_case(anchor, &anchor.join("caf\u{e9}.md")));
5868        assert!(!exists_exact_case(anchor, &anchor.join("CAF\u{c9}.md")));
5869    }
5870
5871    #[test]
5872    fn test_a_link_reports_every_spelling_the_filesystem_does_not_store() {
5873        let temp_dir = tempdir().unwrap();
5874        write_case_fixture(temp_dir.path());
5875        let content = "\
5876[a](foo.md)\n\
5877[b](docs/Guide.md)\n\
5878[c](foo)\n\
5879[d](docs)\n\
5880[e](Foo.md)\n\
5881[f](Docs/Guide.md)\n\
5882[g](Docs)\n\
5883[h](Bar.md)\n";
5884
5885        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5886
5887        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5888        assert_eq!(
5889            messages,
5890            vec![
5891                "Relative link 'foo.md' does not exist",
5892                "Relative link 'docs/Guide.md' does not exist",
5893                "Relative link 'foo' does not exist",
5894                "Relative link 'docs' does not exist",
5895                "Relative link 'Bar.md' does not exist",
5896            ],
5897            "Got: {result:?}"
5898        );
5899    }
5900
5901    /// The reporter's own document: the file on disk is `changelog.md` and the
5902    /// link names `CHANGELOG.md`.
5903    #[test]
5904    fn test_the_reporters_link_is_reported() {
5905        let temp_dir = tempdir().unwrap();
5906        std::fs::write(temp_dir.path().join("changelog.md"), "").unwrap();
5907        let content = "# Test\n\nSee the [changelog](CHANGELOG.md).\n";
5908
5909        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5910
5911        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5912        assert_eq!(result[0].message, "Relative link 'CHANGELOG.md' does not exist");
5913        assert_eq!(result[0].line, 3);
5914        assert_eq!(result[0].column, 21);
5915    }
5916
5917    /// The markdown source behind an `.html` link is looked for under the stem
5918    /// the link spells, so a mis-cased stem finds nothing.
5919    #[test]
5920    fn test_the_html_fallback_keeps_the_case_of_the_stem() {
5921        let temp_dir = tempdir().unwrap();
5922        std::fs::write(temp_dir.path().join("Guide.md"), "# Guide\n").unwrap();
5923        let content = "[a](Guide.html)\n[b](guide.html)\n";
5924
5925        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5926
5927        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5928        assert_eq!(result[0].message, "Relative link 'guide.html' does not exist");
5929        assert_eq!(result[0].line, 2);
5930    }
5931
5932    /// A search path answers for the spelling it stores, like the document's
5933    /// own directory does.
5934    #[test]
5935    fn test_a_search_path_answers_for_the_exact_spelling() {
5936        let temp_dir = tempdir().unwrap();
5937        let assets = temp_dir.path().join("assets");
5938        std::fs::create_dir_all(&assets).unwrap();
5939        std::fs::write(assets.join("Logo.png"), "").unwrap();
5940        let config = MD057Config {
5941            search_paths: vec![assets.to_string_lossy().into_owned()],
5942            ..Default::default()
5943        };
5944        let content = "[a](Logo.png)\n[b](logo.png)\n";
5945
5946        let result = check_as_file(temp_dir.path(), "test.md", content, config);
5947
5948        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5949        assert_eq!(result[0].message, "Relative link 'logo.png' does not exist");
5950        assert_eq!(result[0].line, 2);
5951    }
5952
5953    /// An absolute link resolves under a root, and the components below that
5954    /// root are the link author's spelling just as a relative link's are.
5955    #[test]
5956    fn test_an_absolute_link_under_a_root_keeps_its_case() {
5957        let temp_dir = tempdir().unwrap();
5958        let root = temp_dir.path();
5959        write_case_fixture(root);
5960        let content = "[a](/Docs/Guide.md)\n[b](/docs/Guide.md)\n[c](/Docs)\n[d](/docs)\n";
5961
5962        let config = MD057Config {
5963            absolute_links: AbsoluteLinksOption::RelativeToRoots,
5964            roots: vec![],
5965            ..Default::default()
5966        };
5967        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
5968        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5969        let result = rule.check(&ctx).unwrap();
5970
5971        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5972        assert_eq!(
5973            messages,
5974            vec![
5975                "Absolute link '/docs/Guide.md' was not found under the project root",
5976                "Absolute link '/docs' was not found under the project root",
5977            ],
5978            "Got: {result:?}"
5979        );
5980    }
5981
5982    /// A `..` is resolved lexically, so `docs/../Foo.md` names `Foo.md` beside
5983    /// the document however `docs` is stored. Every renderer of the document
5984    /// resolves the link that way, and the reader gets whatever is at that
5985    /// path, so the rule reports what the reader sees.
5986    #[cfg(unix)]
5987    #[test]
5988    fn test_a_parent_step_is_resolved_lexically_through_a_symlink() {
5989        let temp_dir = tempdir().unwrap();
5990        let anchor = temp_dir.path();
5991        std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5992        std::fs::write(anchor.join("shared").join("Foo.md"), "# Foo\n").unwrap();
5993        std::fs::write(anchor.join("shared").join("Docs").join("Bar.md"), "# Bar\n").unwrap();
5994        std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5995        let content = "[a](docs/../Foo.md)\n[b](docs/Bar.md)\n[c](docs/bar.md)\n";
5996
5997        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5998
5999        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6000        assert_eq!(
6001            messages,
6002            vec![
6003                "Relative link 'docs/../Foo.md' does not exist",
6004                "Relative link 'docs/bar.md' does not exist",
6005            ],
6006            "Got: {result:?}"
6007        );
6008    }
6009
6010    /// Restores the mode a directory had when the guard was taken, so a test
6011    /// that takes read permission away gives it back even when an assertion
6012    /// between the two panics and the temporary directory still has to be
6013    /// removable.
6014    #[cfg(unix)]
6015    struct RestoreMode {
6016        directory: PathBuf,
6017        mode: u32,
6018    }
6019
6020    #[cfg(unix)]
6021    impl RestoreMode {
6022        fn take(directory: &Path) -> Self {
6023            use std::os::unix::fs::PermissionsExt;
6024            Self {
6025                directory: directory.to_path_buf(),
6026                mode: std::fs::metadata(directory).unwrap().permissions().mode(),
6027            }
6028        }
6029    }
6030
6031    #[cfg(unix)]
6032    impl Drop for RestoreMode {
6033        fn drop(&mut self) {
6034            use std::os::unix::fs::PermissionsExt;
6035            let _ = std::fs::set_permissions(&self.directory, std::fs::Permissions::from_mode(self.mode));
6036        }
6037    }
6038
6039    /// A directory the process may enter but not read says nothing about how
6040    /// its entries are spelled, so every name under it is accepted. That is the
6041    /// branch making the walk unable to report a link that is really there.
6042    #[cfg(unix)]
6043    #[test]
6044    fn test_a_directory_that_cannot_be_listed_is_accepted() {
6045        use std::os::unix::fs::PermissionsExt;
6046
6047        let temp_dir = tempdir().unwrap();
6048        let anchor = temp_dir.path();
6049        let locked = anchor.join("locked");
6050        std::fs::create_dir_all(&locked).unwrap();
6051        std::fs::write(locked.join("Target.md"), "# Target\n").unwrap();
6052
6053        let _restore = RestoreMode::take(&locked);
6054        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o111)).unwrap();
6055        if std::fs::read_dir(&locked).is_ok() {
6056            // A process with the privilege to read any directory cannot reach
6057            // the branch under test.
6058            return;
6059        }
6060        assert!(locked.join("Target.md").exists(), "the directory can still be entered");
6061        let content = "[a](locked/Target.md)\n";
6062
6063        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6064
6065        assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6066    }
6067
6068    /// The other direction of the same name: a composed name on disk answers
6069    /// for a decomposed link, because the two spell one name. Case is a
6070    /// difference in the name itself and is still reported.
6071    #[cfg(target_os = "macos")]
6072    #[test]
6073    fn test_a_composed_name_matches_a_decomposed_link() {
6074        let temp_dir = tempdir().unwrap();
6075        let anchor = temp_dir.path();
6076        std::fs::write(anchor.join("caf\u{e9}.md"), "# Cafe\n").unwrap();
6077        reset_file_existence_cache();
6078
6079        assert!(exists_exact_case(anchor, &anchor.join("cafe\u{301}.md")));
6080        assert!(!exists_exact_case(anchor, &anchor.join("CAFE\u{301}.md")));
6081
6082        let content = "[a](cafe\u{301}.md)\n[b](CAFE\u{301}.md)\n";
6083
6084        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6085
6086        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6087        assert_eq!(
6088            messages,
6089            vec!["Relative link 'CAFE\u{301}.md' does not exist"],
6090            "Got: {result:?}"
6091        );
6092    }
6093
6094    /// The Kelvin sign composes to the ASCII letter K, so a name written with
6095    /// it and the same name written with the letter spell one name, and the
6096    /// volume answers for both. A link in the ASCII spelling is accepted; a
6097    /// case difference is still reported.
6098    #[cfg(target_os = "macos")]
6099    #[test]
6100    fn test_a_name_composing_to_ascii_matches_an_ascii_link() {
6101        let temp_dir = tempdir().unwrap();
6102        let anchor = temp_dir.path();
6103        std::fs::write(anchor.join("\u{212a}elvin.md"), "# Kelvin\n").unwrap();
6104        reset_file_existence_cache();
6105
6106        assert!(exists_exact_case(anchor, &anchor.join("Kelvin.md")));
6107        assert!(!exists_exact_case(anchor, &anchor.join("kelvin.md")));
6108
6109        let content = "[a](Kelvin.md)\n[b](kelvin.md)\n";
6110
6111        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6112
6113        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6114        assert_eq!(
6115            messages,
6116            vec!["Relative link 'kelvin.md' does not exist"],
6117            "Got: {result:?}"
6118        );
6119    }
6120
6121    /// The other direction: an ASCII name on disk answers for a link whose
6122    /// spelling composes to it, because the two spell one name. Case is a
6123    /// difference in the name itself and is still reported.
6124    #[cfg(target_os = "macos")]
6125    #[test]
6126    fn test_an_ascii_name_matches_a_link_composing_to_it() {
6127        let temp_dir = tempdir().unwrap();
6128        let anchor = temp_dir.path();
6129        std::fs::write(anchor.join("Kelvin.md"), "# Kelvin\n").unwrap();
6130        reset_file_existence_cache();
6131
6132        assert!(exists_exact_case(anchor, &anchor.join("\u{212a}elvin.md")));
6133        assert!(!exists_exact_case(anchor, &anchor.join("\u{212a}ELVIN.md")));
6134
6135        let content = "[a](\u{212a}elvin.md)\n[b](\u{212a}ELVIN.md)\n";
6136
6137        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6138
6139        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6140        assert_eq!(
6141            messages,
6142            vec!["Relative link '\u{212a}ELVIN.md' does not exist"],
6143            "Got: {result:?}"
6144        );
6145    }
6146
6147    /// Puts a listing in the cache for `directory` naming only `Other.md`,
6148    /// recorded at `modified`. Any other name is absent from it, so a link to
6149    /// a file that is really on disk is reported for as long as this listing
6150    /// is the one a lookup accepts.
6151    ///
6152    /// The key is the directory's canonical path, which is what a check
6153    /// resolves a document's directory to and so what a lookup asks for.
6154    fn seed_listing(directory: &Path, modified: SystemTime) {
6155        let listing = DirectoryListing {
6156            modified: Some(modified),
6157            listed: true,
6158            names: HashSet::from([OsString::from("Other.md")]),
6159            composed: HashSet::new(),
6160        };
6161        let key = std::fs::canonicalize(directory).unwrap();
6162        DIRECTORY_LISTING_CACHE.lock().unwrap().insert(key, Arc::new(listing));
6163    }
6164
6165    /// A listing answers for as long as the directory's modification time
6166    /// stands, so checking many documents in one directory reads it once; and
6167    /// it is read again as soon as that time moves, so a file appearing
6168    /// between two checks is seen.
6169    #[test]
6170    fn test_a_listing_answers_until_the_directory_changes() {
6171        let temp_dir = tempdir().unwrap();
6172        let anchor = temp_dir.path();
6173        std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6174        // The document is written before the time is taken, so the check's own
6175        // write of it does not move the directory on.
6176        std::fs::write(anchor.join("test.md"), "").unwrap();
6177        let read_at = std::fs::metadata(anchor).unwrap().modified().unwrap();
6178        seed_listing(anchor, read_at);
6179
6180        let reported = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6181
6182        let messages: Vec<&str> = reported.iter().map(|warning| warning.message.as_str()).collect();
6183        assert_eq!(
6184            messages,
6185            vec!["Relative link 'README.md' does not exist"],
6186            "the seeded listing answers while the directory's time stands. Got: {reported:?}"
6187        );
6188
6189        std::fs::write(anchor.join("Another.md"), "# Another\n").unwrap();
6190        assert_ne!(
6191            std::fs::metadata(anchor).unwrap().modified().unwrap(),
6192            read_at,
6193            "creating an entry moves the directory's modification time"
6194        );
6195
6196        let accepted = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6197
6198        assert!(
6199            accepted.is_empty(),
6200            "the listing is read again once the directory has moved on. Got: {accepted:?}"
6201        );
6202    }
6203
6204    /// A listing recorded at a moment that is not the directory's is
6205    /// discarded, so a directory that changed while nothing was checking it is
6206    /// read again rather than answered from.
6207    #[test]
6208    fn test_a_listing_from_another_moment_is_discarded() {
6209        let temp_dir = tempdir().unwrap();
6210        let anchor = temp_dir.path();
6211        std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6212        seed_listing(anchor, SystemTime::UNIX_EPOCH);
6213
6214        let result = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6215
6216        assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6217    }
6218}