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