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