Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

1//!
2//! Rule MD057: Existing relative links
3//!
4//! See [docs/md057.md](../../docs/md057.md) for full documentation, configuration, and examples.
5
6use crate::rule::{
7    CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12    FileIndex, LinkOrigin, Md057LinkTarget, extract_cross_file_links, normalize_relative_path,
13};
14use pulldown_cmark::LinkType;
15use regex::Regex;
16use std::collections::{HashMap, HashSet};
17use std::env;
18use std::path::{Path, PathBuf};
19use std::sync::LazyLock;
20use std::sync::{Arc, Mutex};
21
22mod md057_config;
23use crate::utils::mkdocs_config::resolve_docs_dir;
24use crate::utils::obsidian_config::resolve_attachment_folder;
25use crate::utils::project_root::discover_project_root_from;
26pub use md057_config::{AbsoluteLinksOption, MD057Config};
27
28// Thread-safe cache for file existence checks to avoid redundant filesystem operations
29static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
30    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
31
32// Reset the file existence cache (typically between rule runs)
33fn reset_file_existence_cache() {
34    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
35        cache.clear();
36    }
37}
38
39// Check if a file exists with caching
40fn file_exists_with_cache(path: &Path) -> bool {
41    match FILE_EXISTENCE_CACHE.lock() {
42        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
43        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
44    }
45}
46
47/// Check if a file exists, also trying markdown extensions for extensionless links.
48/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
49fn file_exists_or_markdown_extension(path: &Path) -> bool {
50    resolve_existing_target(path).is_some()
51}
52
53/// The file a link path resolves to, or `None` when nothing is there.
54///
55/// An extensionless link is tried against the markdown extensions in turn, so
56/// `[Link](page)` resolves to `page.md`. Callers that only need existence go
57/// through `file_exists_or_markdown_extension`; the resolved path itself
58/// matters when the answer has to be compared against another file.
59fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
60    // First, check exact path
61    if file_exists_with_cache(path) {
62        return Some(path.to_path_buf());
63    }
64
65    // If the path has no extension, try adding markdown extensions
66    if path.extension().is_none() {
67        for ext in MARKDOWN_EXTENSIONS {
68            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
69            let path_with_ext = path.with_extension(&ext[1..]);
70            if file_exists_with_cache(&path_with_ext) {
71                return Some(path_with_ext);
72            }
73        }
74    }
75
76    None
77}
78
79// Regex to match the start of a link - simplified for performance
80static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
81
82/// Regex to extract the URL from an angle-bracketed markdown link
83/// Format: `](<URL>)` or `](<URL> "title")`
84/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
85static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
86    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
87
88/// Regex to extract the URL from a normal markdown link (without angle brackets)
89/// Format: `](URL)` or `](URL "title")`
90static URL_EXTRACT_REGEX: LazyLock<Regex> =
91    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
92
93/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
94/// Matches: scheme:// or scheme: (per RFC 3986)
95/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
96static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
97    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
98
99// Current working directory
100static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
101
102/// Project root discovered once at process start by walking up from CWD looking
103/// for `.git`, `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`. Used as
104/// the anchor for resolving non-absolute paths in `roots` and `search-paths`,
105/// and as the implicit fallback root for absolute-link validation. Tests that
106/// pass a path via `with_path()` bypass this discovery.
107static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
108
109/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
110/// Returns None for non-hex characters.
111#[inline]
112fn hex_digit_to_value(byte: u8) -> Option<u8> {
113    match byte {
114        b'0'..=b'9' => Some(byte - b'0'),
115        b'a'..=b'f' => Some(byte - b'a' + 10),
116        b'A'..=b'F' => Some(byte - b'A' + 10),
117        _ => None,
118    }
119}
120
121/// Supported markdown file extensions
122const MARKDOWN_EXTENSIONS: &[&str] = &[
123    ".md",
124    ".markdown",
125    ".mdx",
126    ".mkd",
127    ".mkdn",
128    ".mdown",
129    ".mdwn",
130    ".qmd",
131    ".rmd",
132];
133
134/// A relative link that resolves to the file it is written in.
135#[derive(Debug, PartialEq, Eq)]
136enum SelfReferentialLink {
137    /// The link addresses the whole file, so there is no shorter way to write
138    /// the same destination.
139    WholeFile,
140    /// The link carries a fragment, which on its own reaches the same heading
141    /// without leaving the page.
142    Fragment(String),
143}
144
145#[cfg(feature = "blake3")]
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum DependencyPathState {
148    Missing,
149    File,
150    Directory,
151    Other,
152}
153
154/// Rule MD057: Existing relative links should point to valid files or directories.
155#[derive(Debug, Clone)]
156pub struct MD057ExistingRelativeLinks {
157    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
158    /// so it is shared across clones (the rule is cloned per config group and
159    /// for inline-config overrides); the base is resolved from the file under
160    /// check and consulted while validating that file's links.
161    base_path: Arc<Mutex<Option<PathBuf>>>,
162    /// Configuration for the rule
163    config: MD057Config,
164}
165
166impl Default for MD057ExistingRelativeLinks {
167    fn default() -> Self {
168        Self {
169            base_path: Arc::new(Mutex::new(None)),
170            config: MD057Config::default(),
171        }
172    }
173}
174
175impl MD057ExistingRelativeLinks {
176    /// Create a new instance with default settings
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Set the base path for resolving relative links
182    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
183        let path = path.as_ref();
184        let dir_path = if path.is_file() {
185            path.parent().map(std::path::Path::to_path_buf)
186        } else {
187            Some(path.to_path_buf())
188        };
189
190        if let Ok(mut guard) = self.base_path.lock() {
191            *guard = dir_path;
192        }
193        self
194    }
195
196    pub fn from_config_struct(config: MD057Config) -> Self {
197        Self {
198            base_path: Arc::new(Mutex::new(None)),
199            config,
200        }
201    }
202
203    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
204    /// against the project root: absolute strings are taken verbatim, relative
205    /// strings are joined onto `project_root`.
206    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
207        if Path::new(path_str).is_absolute() {
208            PathBuf::from(path_str)
209        } else {
210            project_root.join(path_str)
211        }
212    }
213
214    /// Check if a URL is external or should be skipped for validation.
215    ///
216    /// Returns `true` (skip validation) for:
217    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
218    /// - Bare domains: `www.example.com`, `example.com`
219    /// - Email addresses: `user@example.com` (without `mailto:`)
220    /// - Template variables: `{{URL}}`, `{{% include %}}`
221    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
222    ///
223    /// Returns `false` (validate) for:
224    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
225    #[inline]
226    fn is_external_url(&self, url: &str) -> bool {
227        if url.is_empty() {
228            return false;
229        }
230
231        // Quick checks for common external URL patterns
232        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
233            return true;
234        }
235
236        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
237        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
238        if url.starts_with("{{") || url.starts_with("{%") {
239            return true;
240        }
241
242        // Simple check: if URL contains @, it's almost certainly an email address
243        // File paths with @ are extremely rare, so this is a safe heuristic
244        if url.contains('@') {
245            return true; // It's an email address, skip it
246        }
247
248        // Bare domain check (e.g., "example.com")
249        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
250        // Links like [text](nodejs.org/path) without a protocol are broken -
251        // they'll be treated as relative paths by markdown renderers.
252        // Flagging them helps users find missing protocols.
253        // We only skip .com as a minimal safety net for the most common case.
254        // Require the absence of a path separator so a relative file reference
255        // that merely ends in ".com" (e.g. "../../vendor.com") is still
256        // validated rather than assumed to be a bare domain.
257        if !url.contains('/') && url.ends_with(".com") {
258            return true;
259        }
260
261        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
262        // These are not filesystem paths but module/asset aliases
263        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
264        if url.starts_with('~') || url.starts_with('@') {
265            return true;
266        }
267
268        // All other cases (relative paths, etc.) are not external
269        false
270    }
271
272    /// Check if the URL is a fragment-only link (internal document link)
273    #[inline]
274    fn is_fragment_only_link(&self, url: &str) -> bool {
275        url.starts_with('#')
276    }
277
278    /// Check if the URL is an absolute path (starts with /)
279    /// These are typically routes for published documentation sites.
280    #[inline]
281    fn is_absolute_path(url: &str) -> bool {
282        url.starts_with('/')
283    }
284
285    /// Decode URL percent-encoded sequences in a path.
286    /// Converts `%20` to space, `%2F` to `/`, etc.
287    /// Returns the original string if decoding fails or produces invalid UTF-8.
288    fn url_decode(path: &str) -> String {
289        // Quick check: if no percent sign, return as-is
290        if !path.contains('%') {
291            return path.to_string();
292        }
293
294        let bytes = path.as_bytes();
295        let mut result = Vec::with_capacity(bytes.len());
296        let mut i = 0;
297
298        while i < bytes.len() {
299            if bytes[i] == b'%' && i + 2 < bytes.len() {
300                // Try to parse the two hex digits following %
301                let hex1 = bytes[i + 1];
302                let hex2 = bytes[i + 2];
303                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
304                    result.push(d1 * 16 + d2);
305                    i += 3;
306                    continue;
307                }
308            }
309            result.push(bytes[i]);
310            i += 1;
311        }
312
313        // Convert to UTF-8, falling back to original if invalid
314        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
315    }
316
317    /// Strip query parameters and fragments from a URL for file existence checking.
318    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
319    /// for `path/to/image.png` or `file.md` respectively.
320    ///
321    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
322    /// so we check for `?` first. If a URL has both, only the query is stripped here
323    /// (fragments are handled separately by the regex in `contribute_to_index`).
324    fn strip_query_and_fragment(url: &str) -> &str {
325        // Find the first occurrence of '?' or '#', whichever comes first
326        // This handles both standard URLs (? before #) and edge cases (# before ?)
327        let query_pos = url.find('?');
328        let fragment_pos = url.find('#');
329
330        match (query_pos, fragment_pos) {
331            (Some(q), Some(f)) => {
332                // Both exist - strip at whichever comes first
333                &url[..q.min(f)]
334            }
335            (Some(q), None) => &url[..q],
336            (None, Some(f)) => &url[..f],
337            (None, None) => url,
338        }
339    }
340
341    /// Resolve a relative link against a provided base path
342    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
343        base_path.join(link)
344    }
345
346    /// Compute additional search paths for fallback link resolution.
347    ///
348    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
349    /// with explicitly configured `search-paths`.
350    fn compute_search_paths(
351        &self,
352        flavor: crate::config::MarkdownFlavor,
353        source_file: Option<&Path>,
354        base_path: &Path,
355        project_root: &Path,
356    ) -> Vec<PathBuf> {
357        let mut paths = Vec::new();
358
359        // Auto-detect Obsidian attachment folder
360        if flavor == crate::config::MarkdownFlavor::Obsidian
361            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
362            && attachment_dir != *base_path
363        {
364            paths.push(attachment_dir);
365        }
366
367        // Add explicitly configured search paths. Resolved relative to the
368        // discovered project root so paths are stable regardless of which
369        // subdirectory rumdl is invoked from.
370        for search_path in &self.config.search_paths {
371            let resolved = Self::resolve_against_project_root(search_path, project_root);
372            if resolved != *base_path && !paths.contains(&resolved) {
373                paths.push(resolved);
374            }
375        }
376
377        paths
378    }
379
380    /// Record every filesystem destination this rule may validate.
381    ///
382    /// The index keeps frontmatter inputs regardless of the current rule config
383    /// because a content-matched entry can be reused by another config group.
384    fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
385        if !ctx.links().is_empty() {
386            let lines = ctx.raw_lines();
387            let mut processed_lines = HashSet::new();
388
389            for link in ctx.links() {
390                let line_index = link.line - 1;
391                if line_index >= lines.len()
392                    || ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block)
393                    || !processed_lines.insert(line_index)
394                {
395                    continue;
396                }
397                let line = lines[line_index];
398                if !line.contains("](") {
399                    continue;
400                }
401
402                let line_start_byte = ctx.line_start_byte(link.line).unwrap_or(0);
403                for link_match in LINK_START_REGEX.find_iter(line) {
404                    if link_match.as_str().starts_with('!') {
405                        let escapes = line[..link_match.start()]
406                            .bytes()
407                            .rev()
408                            .take_while(|&byte| byte == b'\\')
409                            .count();
410                        if escapes % 2 == 0 {
411                            continue;
412                        }
413                    }
414
415                    let absolute_start = line_start_byte + link_match.start();
416                    if ctx.is_in_code_span_byte(absolute_start)
417                        || ctx.is_in_math_span(absolute_start)
418                        || ctx.is_in_shortcode(absolute_start)
419                    {
420                        continue;
421                    }
422                    let expected_start = link_match.end() - 1;
423                    let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, expected_start)
424                        .and_then(|caps| caps.get(1).map(|url| (caps, url)))
425                        .or_else(|| {
426                            extract_url_at(&URL_EXTRACT_REGEX, line, expected_start)
427                                .and_then(|caps| caps.get(1).map(|url| (caps, url)))
428                        });
429                    let Some((_, url_match)) = caps_and_url else {
430                        continue;
431                    };
432                    let url = url_match.as_str().trim();
433                    if url.is_empty()
434                        || (url.starts_with('`') && url.ends_with('`'))
435                        || self.is_external_url(url)
436                        || self.is_fragment_only_link(url)
437                    {
438                        continue;
439                    }
440                    index.add_md057_link_target(Md057LinkTarget {
441                        target: url.to_string(),
442                        origin: LinkOrigin::Body,
443                    });
444                }
445            }
446        }
447
448        for image in ctx.images() {
449            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block)
450                || matches!(image.link_type, LinkType::WikiLink { .. })
451                || ctx.is_in_shortcode(image.byte_offset)
452            {
453                continue;
454            }
455            let url = image.url.as_ref();
456            if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
457                continue;
458            }
459            index.add_md057_link_target(Md057LinkTarget {
460                target: url.to_string(),
461                origin: LinkOrigin::Body,
462            });
463        }
464
465        for reference in ctx.reference_definitions() {
466            let url = reference.url.as_str();
467            if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
468                continue;
469            }
470            index.add_md057_link_target(Md057LinkTarget {
471                target: url.to_string(),
472                origin: LinkOrigin::Body,
473            });
474        }
475
476        for link in frontmatter_values::link_destinations(ctx) {
477            let line = ctx.lines[link.line - 1].content(ctx.content);
478            let url = &line[link.range];
479            if self.is_external_url(url) || self.is_fragment_only_link(url) {
480                continue;
481            }
482            index.add_md057_link_target(Md057LinkTarget {
483                target: url.to_string(),
484                origin: LinkOrigin::FrontMatter { field: link.field },
485            });
486        }
487    }
488
489    /// Check if a link target exists in any of the additional search paths.
490    fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
491        search_paths.iter().any(|dir| {
492            let candidate = dir.join(decoded_path);
493            file_exists_or_markdown_extension(&candidate)
494        })
495    }
496
497    /// Check if a relative link can be compacted and return the simplified form.
498    ///
499    /// Returns `None` if compact-paths is disabled, the link has no traversal,
500    /// or the link is already the shortest form.
501    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
502    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
503        if !self.config.compact_paths {
504            return None;
505        }
506
507        // Split URL into path and suffix (fragment/query)
508        let path_end = url
509            .find('?')
510            .unwrap_or(url.len())
511            .min(url.find('#').unwrap_or(url.len()));
512        let path_part = &url[..path_end];
513        let suffix = &url[path_end..];
514
515        // URL-decode the path portion for filesystem resolution
516        let decoded_path = Self::url_decode(path_part);
517
518        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
519    }
520
521    /// Classify a relative link that points back at the file it is written in.
522    ///
523    /// Returns `None` when the option is off, when the file under check is
524    /// unknown, or when the link addresses anything else. Fragment-only links
525    /// never reach here: they are already the form this reports towards.
526    ///
527    /// The link is resolved against the file's own directory first and then
528    /// against each search path, matching how the existence check resolves it:
529    /// a link that MD057 accepts through a search path is recognized here as
530    /// well, and one that already resolves next to the document is judged
531    /// against that target alone.
532    fn self_referential_link(
533        &self,
534        url: &str,
535        base_path: &Path,
536        search_paths: &[PathBuf],
537        source_file: Option<&Path>,
538    ) -> Option<SelfReferentialLink> {
539        if !self.config.self_referential_links {
540            return None;
541        }
542        let source_file = source_file?;
543
544        let path_part = Self::strip_query_and_fragment(url);
545        if path_part.is_empty() {
546            return None;
547        }
548        let suffix = &url[path_part.len()..];
549
550        let decoded_path = Self::url_decode(path_part);
551        // First hit wins, as it does for the existence check: a target next to
552        // the document is the one the link addresses, and a search path only
553        // answers for a link that resolves nowhere else.
554        let resolved = std::iter::once(base_path)
555            .chain(search_paths.iter().map(PathBuf::as_path))
556            .find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
557        if !Self::is_same_file(&resolved, source_file) {
558            return None;
559        }
560
561        // A bare fragment reaches the same place. A query string does not
562        // survive being detached from its path, and neither does an empty
563        // fragment, so those are reported without a suggestion.
564        match suffix.strip_prefix('#') {
565            Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
566            _ => Some(SelfReferentialLink::WholeFile),
567        }
568    }
569
570    /// Byte range of a reference definition's destination in the document.
571    ///
572    /// Both the label and the title can repeat the destination text, so the
573    /// search is bounded to what sits between the label's closing bracket and
574    /// the title. Anchoring anywhere else risks a fix rewriting the label,
575    /// which would leave every usage of the reference dangling.
576    fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
577        let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
578        let label_end = Self::label_end(def)?;
579        let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
580            title.saturating_sub(ref_def.byte_offset).min(def.len())
581        });
582        let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
583        let start = ref_def.byte_offset + offset;
584        Some(start..start + ref_def.url.len())
585    }
586
587    /// Offset just past the `]:` that closes a reference definition's label.
588    ///
589    /// A label may itself contain a bracket when the bracket is escaped, so
590    /// escapes are skipped rather than matched.
591    fn label_end(def: &str) -> Option<usize> {
592        let bytes = def.as_bytes();
593        let mut i = 0;
594        while i < bytes.len() {
595            match bytes[i] {
596                b'\\' => i += 2,
597                b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
598                _ => i += 1,
599            }
600        }
601        None
602    }
603
604    /// Whether this document has frontmatter the rule is configured to check.
605    /// Gates the body-link early exits, which would otherwise skip a document
606    /// whose only destinations sit in its frontmatter.
607    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
608        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
609    }
610
611    /// The warning text for an absolute destination, or `None` when the
612    /// configured handling accepts it.
613    fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
614        match self.config.absolute_links {
615            AbsoluteLinksOption::Ignore => None,
616            AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
617            AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
618            AbsoluteLinksOption::RelativeToRoots => {
619                Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
620            }
621        }
622    }
623
624    /// Report frontmatter values that read as relative destinations but point
625    /// at nothing.
626    ///
627    /// Only existence is checked. The compact-path and self-referential
628    /// suggestions stay out of frontmatter: both rewrite a destination, and a
629    /// frontmatter value is only ever a guess at being one.
630    fn check_front_matter(
631        &self,
632        ctx: &crate::lint_context::LintContext,
633        base_path: &Path,
634        search_paths: &[PathBuf],
635        project_root: &Path,
636        warnings: &mut Vec<LintWarning>,
637    ) {
638        if !self.config.check_frontmatter {
639            return;
640        }
641
642        let ignored: HashSet<String> = self
643            .config
644            .ignore_frontmatter_fields
645            .iter()
646            .map(|field| field.to_lowercase())
647            .collect();
648
649        for link in frontmatter_values::link_destinations(ctx) {
650            if link.field_is_in(&ignored) {
651                continue;
652            }
653
654            let line = ctx.lines[link.line - 1].content(ctx.content);
655            let url = &line[link.range.clone()];
656
657            // A fragment belongs to MD051, which validates it against the
658            // document's own headings.
659            if self.is_external_url(url) || self.is_fragment_only_link(url) {
660                continue;
661            }
662
663            let column = byte_to_char_count(line, link.range.start);
664            let end_column = column + url.chars().count();
665
666            if Self::is_absolute_path(url) {
667                if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
668                    warnings.push(LintWarning {
669                        rule_name: Some(self.name().to_string()),
670                        line: link.line,
671                        column,
672                        end_line: link.line,
673                        end_column,
674                        message,
675                        severity: Severity::Warning,
676                        fix: None,
677                    });
678                }
679                continue;
680            }
681
682            if Self::relative_target_exists(url, base_path, search_paths) {
683                continue;
684            }
685
686            warnings.push(LintWarning {
687                rule_name: Some(self.name().to_string()),
688                line: link.line,
689                column,
690                end_line: link.line,
691                end_column,
692                message: format!("Relative link '{url}' does not exist"),
693                severity: Severity::Error,
694                fix: None,
695            });
696        }
697    }
698
699    /// Whether a relative destination resolves to something on disk.
700    ///
701    /// The destination is stripped of its query and fragment, percent-decoded,
702    /// then resolved against `base_path`, with two fallbacks: an `.html`/`.htm`
703    /// target passes when the markdown source it is generated from exists, and
704    /// any target passes when one of `search_paths` holds it.
705    fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
706        let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
707        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
708
709        // An extensionless link is also tried with each markdown extension.
710        if file_exists_or_markdown_extension(&resolved_path) {
711            return true;
712        }
713
714        if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
715            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
716            && let (Some(stem), Some(parent)) = (
717                resolved_path.file_stem().and_then(|s| s.to_str()),
718                resolved_path.parent(),
719            )
720            && MARKDOWN_EXTENSIONS
721                .iter()
722                .any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
723        {
724            return true;
725        }
726
727        Self::exists_in_search_paths(&decoded_path, search_paths)
728    }
729
730    /// Whether any enabled check can offer a fix. Broken links and absolute
731    /// links are reported without one, so the answer rests on the two options
732    /// that rewrite a destination.
733    fn produces_fixes(&self) -> bool {
734        self.config.compact_paths || self.config.self_referential_links
735    }
736
737    /// The warning text for a link that points at its own file.
738    fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
739        match self_link {
740            SelfReferentialLink::Fragment(fragment) => {
741                format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
742            }
743            SelfReferentialLink::WholeFile => {
744                format!("Relative link '{url}' points to the file it is in")
745            }
746        }
747    }
748
749    /// Whether two existing paths are the same file.
750    ///
751    /// Both sides are canonicalized, which settles symlinks and the platform's
752    /// path representation; the lexical form is the fallback for a path the
753    /// filesystem cannot answer for.
754    fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
755        // Cheap reject before the syscall: a different name is a different file.
756        if resolved.file_name() != source_file.file_name() {
757            return false;
758        }
759        match (resolved.canonicalize(), source_file.canonicalize()) {
760            (Ok(link), Ok(source)) => link == source,
761            _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
762        }
763    }
764
765    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
766    ///
767    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
768    /// Falls back to a generic warning if no mkdocs.yml is found.
769    /// Validate an absolute link against the MkDocs `docs_dir`.
770    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
771        let Some(docs_dir) = resolve_docs_dir(source_path) else {
772            return Some(format!(
773                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
774            ));
775        };
776
777        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
778
779        // MkDocs mode: an extensionless directory link must have index.md.
780        // `require_index_for_dirs = true` enforces this for all directory hits.
781        match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
782            Resolution::Found => None,
783            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
784                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
785                resolved.display()
786            )),
787            Resolution::NotFound { resolved } => Some(format!(
788                "Absolute link '{url}' resolves to '{}' which does not exist",
789                resolved.display()
790            )),
791        }
792    }
793
794    /// Validate an absolute link by resolving it against each configured root and the project root.
795    ///
796    /// Configured `roots` are tried first (first match wins), then the project
797    /// root is tried as an implicit fallback. The fallback supports links
798    /// written as literal absolute paths from the project root (e.g.
799    /// `/content/en/foo.md`) alongside links written relative to a configured
800    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
801    /// emitted only when no root — configured or implicit — contains the target.
802    fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
803        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
804
805        for root in roots {
806            let root_path = Self::resolve_against_project_root(root, project_root);
807            // Filesystem mode: an existing directory without trailing slash is valid.
808            // `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
809            if matches!(
810                Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
811                Resolution::Found
812            ) {
813                return None;
814            }
815        }
816
817        if matches!(
818            // Filesystem mode: see above.
819            Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
820            Resolution::Found
821        ) {
822            return None;
823        }
824
825        let msg = if roots.is_empty() {
826            format!("Absolute link '{url}' was not found under the project root")
827        } else {
828            format!("Absolute link '{url}' was not found under any configured root or the project root")
829        };
830        Some(msg)
831    }
832
833    /// Decode an absolute-link URL into a filesystem-relative path and a
834    /// directory-link flag. Strips the leading `/`, query/fragment suffix, and
835    /// percent-encoding.
836    fn prepare_absolute_url(url: &str) -> (String, bool) {
837        let relative_url = url.trim_start_matches('/');
838        let file_path = Self::strip_query_and_fragment(relative_url);
839        let decoded = Self::url_decode(file_path);
840        let is_directory_link = url.ends_with('/') || decoded.is_empty();
841        (decoded, is_directory_link)
842    }
843
844    /// Try to resolve a decoded absolute-link path under a single root directory.
845    ///
846    /// `require_index_for_dirs` controls how extensionless links that resolve to a
847    /// directory are treated:
848    ///
849    /// - `true` (MkDocs / docs-dir mode): a directory must contain `index.md` to be
850    ///   considered valid, even when the link has no trailing slash. This matches
851    ///   MkDocs' URL routing convention where `/section` serves `section/index.md`.
852    ///
853    /// - `false` (roots / filesystem mode): an existing directory is accepted as a
854    ///   valid target for an extensionless link, matching the behavior of relative
855    ///   links (which use `path.exists()`). Only an explicit trailing-slash link
856    ///   (`is_directory_link == true`) still requires `index.md`.
857    ///
858    /// Applies resolution strategies in order:
859    /// 1. Directory-style links (explicit `/` suffix or `require_index_for_dirs`):
860    ///    look for `<resolved>/index.md`; report `DirectoryWithoutIndex` on failure.
861    /// 2. Filesystem-mode directory hit (`require_index_for_dirs == false` and
862    ///    `is_directory_link == false`): accept the existing directory as `Found`.
863    /// 3. Direct existence (with markdown-extension fallback for extensionless links).
864    /// 4. `.html`/`.htm` links: look for a markdown source with the same stem.
865    fn resolve_under_root_with_opts(
866        root_path: &Path,
867        decoded: &str,
868        is_directory_link: bool,
869        require_index_for_dirs: bool,
870    ) -> Resolution {
871        let resolved = root_path.join(decoded);
872
873        let is_dir = resolved.is_dir();
874
875        // When the link explicitly ends with `/` or the caller requires index.md
876        // for all directory hits (MkDocs mode), apply the stricter check first.
877        // Must be checked before `file_exists_or_markdown_extension` because
878        // `path.exists()` returns `true` for directories.
879        if is_directory_link || (require_index_for_dirs && is_dir) {
880            let index_path = resolved.join("index.md");
881            if file_exists_with_cache(&index_path) {
882                return Resolution::Found;
883            }
884            if is_dir {
885                return Resolution::DirectoryWithoutIndex { resolved };
886            }
887        }
888
889        // Filesystem mode (roots): an existing directory without a trailing slash
890        // is valid — mirrors how relative links accept directories via `path.exists()`.
891        // Exclude decoded paths that end with `/`: a URL like `/guide/#intro` strips
892        // the fragment to `guide/`, so `decoded` carries the trailing slash even though
893        // `is_directory_link` is false (the raw URL ends with `#intro`, not `/`).
894        let decoded_has_trailing_slash = decoded.ends_with('/');
895        if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
896            return Resolution::Found;
897        }
898
899        if file_exists_or_markdown_extension(&resolved) {
900            return Resolution::Found;
901        }
902
903        // For .html/.htm links, accept a matching markdown source in the same
904        // directory — supports doc sites that compile .md to .html.
905        if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
906            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
907            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
908        {
909            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
910                let source_path = parent.join(format!("{stem}{md_ext}"));
911                file_exists_with_cache(&source_path)
912            });
913            if has_md_source {
914                return Resolution::Found;
915            }
916        }
917
918        Resolution::NotFound { resolved }
919    }
920}
921
922/// Cache invalidation for the on-disk lint cache, which only exists in builds
923/// with a filesystem and the `blake3` hasher (native and WASI). The browser build
924/// has neither, so nothing here can be reached from it.
925#[cfg(feature = "blake3")]
926impl MD057ExistingRelativeLinks {
927    /// Fingerprint the filesystem facts that can change this file's MD057 verdict.
928    ///
929    /// The caller supplies path inputs from a content-matched workspace-index
930    /// entry. Resolution stays here so cache validation follows the same config,
931    /// project-root, search-path, and flavor rules as `check`.
932    pub fn cache_dependency_fingerprint(
933        &self,
934        source_file: &Path,
935        flavor: crate::config::MarkdownFlavor,
936        file_index: &FileIndex,
937    ) -> String {
938        let mut hasher = blake3::Hasher::new();
939        hasher.update(b"rumdl-md057-dependencies-v1");
940        if file_index.md057_link_targets.is_empty() {
941            return hasher.finalize().to_hex().to_string();
942        }
943
944        let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
945        let project_root = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
946        let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
947        let base_path = explicit_base.unwrap_or_else(|| {
948            resolved_source
949                .parent()
950                .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
951        });
952        let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
953        let ignored_frontmatter_fields: HashSet<String> = self
954            .config
955            .ignore_frontmatter_fields
956            .iter()
957            .map(|field| field.to_lowercase())
958            .collect();
959
960        for dependency in &file_index.md057_link_targets {
961            if let LinkOrigin::FrontMatter { field } = &dependency.origin
962                && (!self.config.check_frontmatter
963                    || field
964                        .as_ref()
965                        .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
966            {
967                continue;
968            }
969
970            let url = dependency.target.as_str();
971            if self.is_external_url(url) || self.is_fragment_only_link(url) {
972                continue;
973            }
974
975            Self::hash_bytes(&mut hasher, url.as_bytes());
976            if Self::is_absolute_path(url) {
977                match self.config.absolute_links {
978                    AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
979                    AbsoluteLinksOption::RelativeToDocs => {
980                        hasher.update(b"docs");
981                        if let Some(docs_dir) = resolve_docs_dir(source_file) {
982                            Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
983                        } else {
984                            hasher.update(b"no-docs-dir");
985                        }
986                    }
987                    AbsoluteLinksOption::RelativeToRoots => {
988                        hasher.update(b"roots");
989                        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
990                        let mut found = false;
991                        for root in &self.config.roots {
992                            let root_path = Self::resolve_against_project_root(root, &project_root);
993                            if Self::observe_under_root(&mut hasher, &root_path, &decoded, is_directory_link, false) {
994                                found = true;
995                                break;
996                            }
997                        }
998                        if !found {
999                            Self::observe_under_root(&mut hasher, &project_root, &decoded, is_directory_link, false);
1000                        }
1001                    }
1002                }
1003            } else {
1004                hasher.update(b"relative");
1005                if self.config.self_referential_links
1006                    && Self::observe_self_referential_resolution(
1007                        &mut hasher,
1008                        url,
1009                        &base_path,
1010                        &search_paths,
1011                        &resolved_source,
1012                    )
1013                {
1014                    continue;
1015                }
1016                Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
1017            }
1018        }
1019
1020        hasher.finalize().to_hex().to_string()
1021    }
1022
1023    fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1024        hasher.update(&(bytes.len() as u64).to_le_bytes());
1025        hasher.update(bytes);
1026    }
1027
1028    fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1029        #[cfg(unix)]
1030        {
1031            use std::os::unix::ffi::OsStrExt;
1032            Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1033        }
1034        #[cfg(windows)]
1035        {
1036            use std::os::windows::ffi::OsStrExt;
1037            let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1038            Self::hash_bytes(hasher, &encoded);
1039        }
1040        #[cfg(not(any(unix, windows)))]
1041        Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1042    }
1043
1044    fn observe_path(hasher: &mut blake3::Hasher, path: &Path) -> DependencyPathState {
1045        Self::hash_path(hasher, path);
1046        let state = match std::fs::metadata(path) {
1047            Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1048            Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1049            Ok(_) => DependencyPathState::Other,
1050            Err(_) => DependencyPathState::Missing,
1051        };
1052        hasher.update(&[match state {
1053            DependencyPathState::Missing => 0,
1054            DependencyPathState::File => 1,
1055            DependencyPathState::Directory => 2,
1056            DependencyPathState::Other => 3,
1057        }]);
1058        state
1059    }
1060
1061    fn observe_existing_target(hasher: &mut blake3::Hasher, path: &Path) -> Option<PathBuf> {
1062        if Self::observe_path(hasher, path) != DependencyPathState::Missing {
1063            return Some(path.to_path_buf());
1064        }
1065        if path.extension().is_none() {
1066            for extension in MARKDOWN_EXTENSIONS {
1067                let candidate = path.with_extension(&extension[1..]);
1068                if Self::observe_path(hasher, &candidate) != DependencyPathState::Missing {
1069                    return Some(candidate);
1070                }
1071            }
1072        }
1073        None
1074    }
1075
1076    fn observe_self_referential_resolution(
1077        hasher: &mut blake3::Hasher,
1078        url: &str,
1079        base_path: &Path,
1080        search_paths: &[PathBuf],
1081        source_file: &Path,
1082    ) -> bool {
1083        let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1084        for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1085            let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1086            if let Some(resolved) = Self::observe_existing_target(hasher, &candidate) {
1087                let canonical = resolved.canonicalize().unwrap_or(resolved);
1088                hasher.update(b"resolved-identity");
1089                Self::hash_path(hasher, &canonical);
1090                return Self::is_same_file(&canonical, source_file);
1091            }
1092        }
1093        false
1094    }
1095
1096    fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1097        let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1098        let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1099        if Self::observe_existing_target(hasher, &resolved).is_some() {
1100            return;
1101        }
1102
1103        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1104            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1105            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1106            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1107                Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1108            })
1109        {
1110            return;
1111        }
1112
1113        for search_path in search_paths {
1114            if Self::observe_existing_target(hasher, &search_path.join(&decoded)).is_some() {
1115                return;
1116            }
1117        }
1118    }
1119
1120    fn observe_absolute_resolution(
1121        hasher: &mut blake3::Hasher,
1122        root: &Path,
1123        url: &str,
1124        require_index_for_dirs: bool,
1125    ) -> bool {
1126        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1127        Self::observe_under_root(hasher, root, &decoded, is_directory_link, require_index_for_dirs)
1128    }
1129
1130    fn observe_under_root(
1131        hasher: &mut blake3::Hasher,
1132        root: &Path,
1133        decoded: &str,
1134        is_directory_link: bool,
1135        require_index_for_dirs: bool,
1136    ) -> bool {
1137        let resolved = root.join(decoded);
1138        let resolved_state = Self::observe_path(hasher, &resolved);
1139        let is_dir = resolved_state == DependencyPathState::Directory;
1140
1141        if is_directory_link || (require_index_for_dirs && is_dir) {
1142            if Self::observe_path(hasher, &resolved.join("index.md")) != DependencyPathState::Missing {
1143                return true;
1144            }
1145            if is_dir {
1146                return false;
1147            }
1148        }
1149
1150        if !require_index_for_dirs && !is_directory_link && !decoded.ends_with('/') && is_dir {
1151            return true;
1152        }
1153        if resolved_state != DependencyPathState::Missing {
1154            return true;
1155        }
1156        if resolved.extension().is_none()
1157            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1158                Self::observe_path(hasher, &resolved.with_extension(&extension[1..])) != DependencyPathState::Missing
1159            })
1160        {
1161            return true;
1162        }
1163
1164        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1165            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1166            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1167        {
1168            return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1169                Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1170            });
1171        }
1172
1173        false
1174    }
1175}
1176
1177/// Outcome of trying to resolve an absolute link under a single root directory.
1178/// Carries the resolved path on the failure variants so callers can build
1179/// specific error messages without recomputing it.
1180enum Resolution {
1181    Found,
1182    DirectoryWithoutIndex { resolved: PathBuf },
1183    NotFound { resolved: PathBuf },
1184}
1185
1186/// Search `re` in `line` starting at `expected_start`, accepting the match
1187/// only if it begins exactly there.
1188///
1189/// `Regex::captures_at` searches starting at the given offset but does not
1190/// require the match to *begin* there. A bracket's own destination that
1191/// fails to match at its own position (a fragment-only `(#bar)`, an empty
1192/// `()`) would otherwise silently slide forward and return a later,
1193/// unrelated match belonging to a different bracket on the same line.
1194/// Rejecting a non-anchored match treats that case as "no destination for
1195/// this bracket" instead of borrowing a sibling bracket's URL.
1196fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1197    let caps = re.captures_at(line, expected_start)?;
1198    if caps.get(0)?.start() != expected_start {
1199        return None;
1200    }
1201    Some(caps)
1202}
1203
1204impl Rule for MD057ExistingRelativeLinks {
1205    fn name(&self) -> &'static str {
1206        "MD057"
1207    }
1208
1209    fn description(&self) -> &'static str {
1210        "Relative links should point to existing files"
1211    }
1212
1213    fn category(&self) -> RuleCategory {
1214        RuleCategory::Link
1215    }
1216
1217    fn skippable_by_category(&self) -> bool {
1218        // A frontmatter path is a link this rule resolves, and the document
1219        // holding it needs no link syntax anywhere else.
1220        !self.config.check_frontmatter
1221    }
1222
1223    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1224        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1225    }
1226
1227    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1228        let content = ctx.content;
1229
1230        if content.is_empty() {
1231            return Ok(Vec::new());
1232        }
1233
1234        // Early returns for performance. A document whose only destinations
1235        // sit in its frontmatter has no link syntax to find, so the body-link
1236        // shortcuts only apply when frontmatter is not being checked.
1237        let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1238        if !has_body_links && !self.checks_front_matter_of(ctx) {
1239            return Ok(Vec::new());
1240        }
1241
1242        // Reset the file existence cache for a fresh run
1243        reset_file_existence_cache();
1244
1245        let mut warnings = Vec::new();
1246
1247        // Read the explicit base path (set via `with_path()` in tests) once; it
1248        // doubles as both the per-file base path and the project root override
1249        // for absolute-link resolution.
1250        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1251
1252        // Project root used for absolute-link resolution against configured
1253        // `roots` and as the implicit fallback root. The explicit base wins
1254        // when set; otherwise the discovered project root is used.
1255        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
1256
1257        // The file under check, as the filesystem sees it. Links are compared
1258        // against it to find the ones that point back at their own document.
1259        let self_path: Option<PathBuf> = ctx
1260            .source_file()
1261            .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
1262
1263        // Determine base path for resolving relative links.
1264        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
1265        // This ensures each file resolves links relative to its own directory.
1266        let base_path: Option<PathBuf> = {
1267            if explicit_base.is_some() {
1268                explicit_base
1269            } else if let Some(ref resolved_file) = self_path {
1270                // Resolve symlinks to get the actual file location
1271                // This ensures relative links are resolved from the target's directory,
1272                // not the symlink's directory
1273                resolved_file
1274                    .parent()
1275                    .map(std::path::Path::to_path_buf)
1276                    .or_else(|| Some(CURRENT_DIR.clone()))
1277            } else {
1278                // No source file available - cannot validate relative links
1279                None
1280            }
1281        };
1282
1283        // If we still don't have a base path, we can't validate relative links
1284        let Some(base_path) = base_path else {
1285            return Ok(warnings);
1286        };
1287
1288        // Compute additional search paths for fallback link resolution
1289        let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
1290
1291        // Use LintContext links instead of expensive regex parsing
1292        if !ctx.links().is_empty() {
1293            // Document source locations preserve offsets across all line ending types.
1294
1295            // Pre-collected lines from context
1296            let lines = ctx.raw_lines();
1297
1298            // Track which lines we've already processed to avoid duplicates
1299            // (ctx.links() may have multiple entries for the same line, especially with malformed markdown)
1300            let mut processed_lines = std::collections::HashSet::new();
1301
1302            for link in ctx.links() {
1303                let line_idx = link.line - 1;
1304                if line_idx >= lines.len() {
1305                    continue;
1306                }
1307
1308                // Skip lines inside PyMdown blocks
1309                if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1310                    continue;
1311                }
1312
1313                // Skip if we've already processed this line
1314                if !processed_lines.insert(line_idx) {
1315                    continue;
1316                }
1317
1318                let line = lines[line_idx];
1319
1320                // Quick check for link pattern in this line
1321                if !line.contains("](") {
1322                    continue;
1323                }
1324
1325                // Find all links in this line using optimized regex
1326                for link_match in LINK_START_REGEX.find_iter(line) {
1327                    // Skip image syntax (`![...]`) here, images are already fully
1328                    // validated by the dedicated ctx.images() loop below, and processing
1329                    // them again here would duplicate that warning. A bang preceded by
1330                    // an odd number of backslashes is escaped, literal text per
1331                    // CommonMark, making the bracket a normal link that the image loop
1332                    // never sees, so it must stay in this loop.
1333                    if link_match.as_str().starts_with('!') {
1334                        let escapes = line[..link_match.start()]
1335                            .bytes()
1336                            .rev()
1337                            .take_while(|&b| b == b'\\')
1338                            .count();
1339                        if escapes % 2 == 0 {
1340                            continue;
1341                        }
1342                    }
1343
1344                    let start_pos = link_match.start();
1345                    let end_pos = link_match.end();
1346
1347                    // Calculate the absolute position through the document context.
1348                    let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
1349                    let absolute_start_pos = line_start_byte + start_pos;
1350
1351                    // Skip if this link is in a code span
1352                    if ctx.is_in_code_span_byte(absolute_start_pos) {
1353                        continue;
1354                    }
1355
1356                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
1357                    if ctx.is_in_math_span(absolute_start_pos) {
1358                        continue;
1359                    }
1360
1361                    // Skip if this link is inside a template shortcode tag. The
1362                    // tag is an argument list read by a template, so a path in it
1363                    // is resolved by the site generator's own rules rather than
1364                    // relative to this file.
1365                    if ctx.is_in_shortcode(absolute_start_pos) {
1366                        continue;
1367                    }
1368
1369                    // Find the URL part after the link text
1370                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
1371                    // Then fall back to normal URL regex. Both searches are anchored to
1372                    // this bracket's own position so a destination that cannot match
1373                    // here (fragment-only, empty) yields no URL instead of borrowing
1374                    // the next bracket's destination.
1375                    let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1376                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1377                        .or_else(|| {
1378                            extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1379                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1380                        });
1381
1382                    if let Some((caps, url_group)) = caps_and_url {
1383                        let url = url_group.as_str().trim();
1384
1385                        // Skip empty URLs
1386                        if url.is_empty() {
1387                            continue;
1388                        }
1389
1390                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
1391                        // These are Rust API references, not file paths
1392                        // Example: [`f32::is_subnormal`], [`Vec::push`]
1393                        if url.starts_with('`') && url.ends_with('`') {
1394                            continue;
1395                        }
1396
1397                        // Skip external URLs and fragment-only links
1398                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
1399                            continue;
1400                        }
1401
1402                        // Handle absolute paths based on config
1403                        if Self::is_absolute_path(url) {
1404                            if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1405                                warnings.push(LintWarning {
1406                                    rule_name: Some(self.name().to_string()),
1407                                    line: link.line,
1408                                    column: byte_to_char_count(line, url_group.start()),
1409                                    end_line: link.line,
1410                                    end_column: byte_to_char_count(line, url_group.end()),
1411                                    message,
1412                                    severity: Severity::Warning,
1413                                    fix: None,
1414                                });
1415                            }
1416                            continue;
1417                        }
1418
1419                        // Check for unnecessary path traversal (compact-paths)
1420                        // Reconstruct full URL including fragment (regex group 2)
1421                        // since url_group (group 1) contains only the path part
1422                        let full_url_for_compact = if let Some(frag) = caps.get(2) {
1423                            format!("{url}{}", frag.as_str())
1424                        } else {
1425                            url.to_string()
1426                        };
1427                        // A link back into the current file. Reported instead of
1428                        // the compaction below, whose shorter path would still
1429                        // be a link the reader should not follow, and instead
1430                        // of the existence check, which this target passes.
1431                        if let Some(self_link) = self.self_referential_link(
1432                            &full_url_for_compact,
1433                            &base_path,
1434                            &extra_search_paths,
1435                            self_path.as_deref(),
1436                        ) {
1437                            let url_start = url_group.start();
1438                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1439                            let fix_byte_start = line_start_byte + url_start;
1440                            let fix_byte_end = line_start_byte + url_end;
1441                            warnings.push(LintWarning {
1442                                rule_name: Some(self.name().to_string()),
1443                                line: link.line,
1444                                column: byte_to_char_count(line, url_start),
1445                                end_line: link.line,
1446                                end_column: byte_to_char_count(line, url_end),
1447                                message: Self::self_referential_message(&full_url_for_compact, &self_link),
1448                                severity: Severity::Warning,
1449                                fix: match &self_link {
1450                                    SelfReferentialLink::Fragment(fragment) => {
1451                                        Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1452                                    }
1453                                    SelfReferentialLink::WholeFile => None,
1454                                },
1455                            });
1456                            continue;
1457                        }
1458
1459                        if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1460                            let url_start = url_group.start();
1461                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1462                            let fix_byte_start = line_start_byte + url_start;
1463                            let fix_byte_end = line_start_byte + url_end;
1464                            warnings.push(LintWarning {
1465                                rule_name: Some(self.name().to_string()),
1466                                line: link.line,
1467                                column: byte_to_char_count(line, url_start),
1468                                end_line: link.line,
1469                                end_column: byte_to_char_count(line, url_end),
1470                                message: format!(
1471                                    "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1472                                ),
1473                                severity: Severity::Warning,
1474                                fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1475                            });
1476                        }
1477
1478                        if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1479                            continue;
1480                        }
1481
1482                        // File doesn't exist and no source file found
1483                        // Use actual URL position from regex capture group
1484                        // Note: capture group positions are absolute within the line string
1485                        let url_start = url_group.start();
1486                        let url_end = url_group.end();
1487
1488                        warnings.push(LintWarning {
1489                            rule_name: Some(self.name().to_string()),
1490                            line: link.line,
1491                            column: byte_to_char_count(line, url_start),
1492                            end_line: link.line,
1493                            end_column: byte_to_char_count(line, url_end),
1494                            message: format!("Relative link '{url}' does not exist"),
1495                            severity: Severity::Error,
1496                            fix: None,
1497                        });
1498                    }
1499                }
1500            }
1501        }
1502
1503        // Also process images - they have URLs already parsed
1504        for image in ctx.images() {
1505            // Skip images inside PyMdown blocks (MkDocs flavor)
1506            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1507                continue;
1508            }
1509
1510            // A wiki embed names a vault entry, not a path relative to this
1511            // file: `![[diagram.png]]` resolves wherever the attachment lives.
1512            // The links loop already leaves `[[diagram.png]]` alone.
1513            if matches!(image.link_type, LinkType::WikiLink { .. }) {
1514                continue;
1515            }
1516
1517            // Image syntax inside a template shortcode tag is a parameter the
1518            // template resolves, not a path relative to this file.
1519            if ctx.is_in_shortcode(image.byte_offset) {
1520                continue;
1521            }
1522
1523            let url = image.url.as_ref();
1524
1525            // Skip empty URLs
1526            if url.is_empty() {
1527                continue;
1528            }
1529
1530            // Skip external URLs and fragment-only links
1531            if self.is_external_url(url) || self.is_fragment_only_link(url) {
1532                continue;
1533            }
1534
1535            // Handle absolute paths based on config
1536            if Self::is_absolute_path(url) {
1537                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1538                    warnings.push(LintWarning {
1539                        rule_name: Some(self.name().to_string()),
1540                        line: image.line,
1541                        column: image.start_col + 1,
1542                        end_line: image.line,
1543                        end_column: image.start_col + 1 + url.chars().count(),
1544                        message,
1545                        severity: Severity::Warning,
1546                        fix: None,
1547                    });
1548                }
1549                continue;
1550            }
1551
1552            // Check for unnecessary path traversal (compact-paths)
1553            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1554                // Find the URL position within the image syntax using document byte offsets.
1555                // Search from image.byte_offset (the `!` character) to locate the URL string.
1556                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1557                    let fix_byte_start = image.byte_offset + url_offset;
1558                    let fix_byte_end = fix_byte_start + url.len();
1559                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1560                });
1561
1562                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1563                let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1564                // The fix range is a document byte offset; the displayed column is
1565                // the corresponding character offset within the line.
1566                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1567                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1568                });
1569                warnings.push(LintWarning {
1570                    rule_name: Some(self.name().to_string()),
1571                    line: image.line,
1572                    column: url_col,
1573                    end_line: image.line,
1574                    end_column: url_col + url.chars().count(),
1575                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1576                    severity: Severity::Warning,
1577                    fix,
1578                });
1579            }
1580
1581            if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1582                continue;
1583            }
1584
1585            // File doesn't exist and no source file found
1586            // Images already have correct position from parser
1587            warnings.push(LintWarning {
1588                rule_name: Some(self.name().to_string()),
1589                line: image.line,
1590                column: image.start_col + 1,
1591                end_line: image.line,
1592                end_column: image.start_col + 1 + url.chars().count(),
1593                message: format!("Relative link '{url}' does not exist"),
1594                severity: Severity::Error,
1595                fix: None,
1596            });
1597        }
1598
1599        // Also process reference definitions: [ref]: ./path.md
1600        for ref_def in ctx.reference_definitions() {
1601            let url = &ref_def.url;
1602
1603            // Skip empty URLs
1604            if url.is_empty() {
1605                continue;
1606            }
1607
1608            // Skip external URLs and fragment-only links
1609            if self.is_external_url(url) || self.is_fragment_only_link(url) {
1610                continue;
1611            }
1612
1613            // Where this definition's destination sits, shared by every report
1614            // on it. Without a located destination a warning falls back to the
1615            // start of the definition's line and offers no fix.
1616            let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1617            let (line, col) = url_range
1618                .as_ref()
1619                .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1620            let end_col = col + url.chars().count();
1621
1622            // Handle absolute paths based on config
1623            if Self::is_absolute_path(url) {
1624                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1625                    warnings.push(LintWarning {
1626                        rule_name: Some(self.name().to_string()),
1627                        line,
1628                        column: col,
1629                        end_line: line,
1630                        end_column: end_col,
1631                        message,
1632                        severity: Severity::Warning,
1633                        fix: None,
1634                    });
1635                }
1636                continue;
1637            }
1638
1639            // A definition whose destination is the file holding it.
1640            if let Some(self_link) =
1641                self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
1642            {
1643                warnings.push(LintWarning {
1644                    rule_name: Some(self.name().to_string()),
1645                    line,
1646                    column: col,
1647                    end_line: line,
1648                    end_column: end_col,
1649                    message: Self::self_referential_message(url, &self_link),
1650                    severity: Severity::Warning,
1651                    fix: match (&self_link, &url_range) {
1652                        (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1653                            Some(Fix::new(range.clone(), fragment.clone()))
1654                        }
1655                        _ => None,
1656                    },
1657                });
1658                continue;
1659            }
1660
1661            // Check for unnecessary path traversal (compact-paths)
1662            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1663                warnings.push(LintWarning {
1664                    rule_name: Some(self.name().to_string()),
1665                    line,
1666                    column: col,
1667                    end_line: line,
1668                    end_column: end_col,
1669                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1670                    severity: Severity::Warning,
1671                    fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1672                });
1673            }
1674
1675            if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1676                continue;
1677            }
1678
1679            // File doesn't exist and no source file found
1680            warnings.push(LintWarning {
1681                rule_name: Some(self.name().to_string()),
1682                line,
1683                column: col,
1684                end_line: line,
1685                end_column: end_col,
1686                message: format!("Relative link '{url}' does not exist"),
1687                severity: Severity::Error,
1688                fix: None,
1689            });
1690        }
1691
1692        self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1693
1694        Ok(warnings)
1695    }
1696
1697    fn fix_capability(&self) -> FixCapability {
1698        if self.produces_fixes() {
1699            FixCapability::ConditionallyFixable
1700        } else {
1701            FixCapability::Unfixable
1702        }
1703    }
1704
1705    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1706        if !self.produces_fixes() {
1707            return Ok(ctx.content.to_string());
1708        }
1709
1710        let warnings = self.check(ctx)?;
1711        let warnings =
1712            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1713        let mut content = ctx.content.to_string();
1714
1715        // Collect fixable warnings (compact-paths) sorted by byte offset descending
1716        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1717        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1718
1719        // Applying fixes right-to-left lets each range stay valid against the
1720        // still-unshifted content to its left. A duplicate or overlapping fix
1721        // would otherwise be applied a second time against content already
1722        // rewritten by an earlier fix, corrupting it; skip any fix whose range
1723        // overlaps the one most recently applied.
1724        let mut last_applied_start: Option<usize> = None;
1725        for fix in fixes {
1726            if let Some(prev_start) = last_applied_start
1727                && fix.range.end > prev_start
1728            {
1729                continue;
1730            }
1731            if fix.range.end <= content.len() {
1732                content.replace_range(fix.range.clone(), &fix.replacement);
1733                last_applied_start = Some(fix.range.start);
1734            }
1735        }
1736
1737        Ok(content)
1738    }
1739
1740    fn as_any(&self) -> &dyn std::any::Any {
1741        self
1742    }
1743
1744    crate::impl_rule_config_sections!(MD057Config);
1745
1746    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1747    where
1748        Self: Sized,
1749    {
1750        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1751        // The flavor is deliberately not captured here: Obsidian attachment-folder
1752        // detection reads `ctx.flavor`, which resolves per file, so a rule built
1753        // once for a workspace still honors a per-file flavor override.
1754        Box::new(Self::from_config_struct(rule_config))
1755    }
1756
1757    fn cross_file_scope(&self) -> CrossFileScope {
1758        CrossFileScope::Workspace
1759    }
1760
1761    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1762        self.contribute_dependency_targets(ctx, index);
1763
1764        // Use the shared utility for cross-file link extraction
1765        // This ensures consistent position tracking between CLI and LSP
1766        let links = extract_cross_file_links(ctx);
1767        for link in links.relative {
1768            index.add_cross_file_link(link);
1769        }
1770        // Root-relative links are not linted, but indexing them keeps the cached
1771        // index complete so the LSP can resolve them for find-references.
1772        for link in links.root_relative {
1773            index.add_root_relative_link(link);
1774        }
1775    }
1776
1777    fn cross_file_check(
1778        &self,
1779        _file_path: &Path,
1780        _file_index: &FileIndex,
1781        _workspace_index: &crate::workspace_index::WorkspaceIndex,
1782    ) -> LintResult {
1783        // All link targets are already validated by check() on each per-file pass.
1784        // check() resolves relative links against the file's own directory, handles
1785        // configured search paths, and applies the absolute_links config.
1786        // Validating them here too would produce identical duplicate warnings for
1787        // every broken link. (#631)
1788        //
1789        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
1790        // remains in place to support future cross-file analyses (e.g. heading-anchor
1791        // validation across files).
1792        Ok(Vec::new())
1793    }
1794}
1795
1796/// Compute the shortest relative path from `from_dir` to `to_path`.
1797///
1798/// Both paths must be normalized (no `.` or `..` components).
1799/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1800fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1801    let from_components: Vec<_> = from_dir.components().collect();
1802    let to_components: Vec<_> = to_path.components().collect();
1803
1804    // Find common prefix length
1805    let common_len = from_components
1806        .iter()
1807        .zip(to_components.iter())
1808        .take_while(|(a, b)| a == b)
1809        .count();
1810
1811    let mut result = PathBuf::new();
1812
1813    // Go up for each remaining component in from_dir
1814    for _ in common_len..from_components.len() {
1815        result.push("..");
1816    }
1817
1818    // Append remaining components from to_path
1819    for component in &to_components[common_len..] {
1820        result.push(component);
1821    }
1822
1823    result
1824}
1825
1826/// Check if a relative link path can be shortened.
1827///
1828/// Given the source directory and the raw link path, computes whether there's
1829/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1830/// be simplified, `None` if it's already optimal.
1831fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1832    let link_path = Path::new(raw_link_path);
1833
1834    // Only check paths that contain traversal (../ or ./)
1835    let has_traversal = link_path
1836        .components()
1837        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1838
1839    if !has_traversal {
1840        return None;
1841    }
1842
1843    // Resolve: source_dir + raw_link_path, then normalize
1844    let combined = source_dir.join(link_path);
1845    let normalized_target = normalize_relative_path(&combined);
1846
1847    // Compute shortest path from source_dir back to the normalized target
1848    let normalized_source = normalize_relative_path(source_dir);
1849    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1850
1851    // Compare against the raw link path — if it differs, the path can be compacted
1852    if shortest != link_path {
1853        let compact = shortest.to_string_lossy().to_string();
1854        // Avoid suggesting empty path
1855        if compact.is_empty() {
1856            return None;
1857        }
1858        // Markdown links always use forward slashes regardless of platform
1859        Some(compact.replace('\\', "/"))
1860    } else {
1861        None
1862    }
1863}
1864
1865#[cfg(test)]
1866mod tests {
1867    use super::*;
1868    use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1869    use std::fs::File;
1870    use std::io::Write;
1871    use tempfile::tempdir;
1872
1873    #[test]
1874    fn test_strip_query_and_fragment() {
1875        // Test query parameter stripping
1876        assert_eq!(
1877            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1878            "file.png"
1879        );
1880        assert_eq!(
1881            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1882            "file.png"
1883        );
1884        assert_eq!(
1885            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1886            "file.png"
1887        );
1888
1889        // Test fragment stripping
1890        assert_eq!(
1891            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1892            "file.md"
1893        );
1894        assert_eq!(
1895            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1896            "file.md"
1897        );
1898
1899        // Test both query and fragment (query comes first, per RFC 3986)
1900        assert_eq!(
1901            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1902            "file.md"
1903        );
1904
1905        // Test no query or fragment
1906        assert_eq!(
1907            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1908            "file.png"
1909        );
1910
1911        // Test with path
1912        assert_eq!(
1913            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1914            "path/to/image.png"
1915        );
1916        assert_eq!(
1917            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1918            "path/to/image.png"
1919        );
1920
1921        // Edge case: fragment before query (non-standard but possible)
1922        assert_eq!(
1923            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1924            "file.md"
1925        );
1926    }
1927
1928    #[test]
1929    fn test_url_decode() {
1930        // Simple space encoding
1931        assert_eq!(
1932            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1933            "penguin with space.jpg"
1934        );
1935
1936        // Path with encoded spaces
1937        assert_eq!(
1938            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1939            "assets/my file name.png"
1940        );
1941
1942        // Multiple encoded characters
1943        assert_eq!(
1944            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1945            "hello world!.md"
1946        );
1947
1948        // Lowercase hex
1949        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1950
1951        // Uppercase hex
1952        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1953
1954        // Mixed case hex
1955        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1956
1957        // No encoding - return as-is
1958        assert_eq!(
1959            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1960            "normal-file.md"
1961        );
1962
1963        // Incomplete percent encoding - leave as-is
1964        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1965
1966        // Percent at end - leave as-is
1967        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1968
1969        // Invalid hex digits - leave as-is
1970        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1971
1972        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
1973        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1974
1975        // Empty string
1976        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1977
1978        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
1979        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1980
1981        // Multiple consecutive encoded characters
1982        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
1983
1984        // Encoded path separators
1985        assert_eq!(
1986            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1987            "path/to/file.md"
1988        );
1989
1990        // Mixed encoded and non-encoded
1991        assert_eq!(
1992            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1993            "hello world/foo bar.md"
1994        );
1995
1996        // Special characters that are commonly encoded
1997        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1998
1999        // Percent at position that looks like encoding but isn't valid
2000        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
2001    }
2002
2003    #[test]
2004    fn test_url_encoded_filenames() {
2005        // Create a temporary directory for test files
2006        let temp_dir = tempdir().unwrap();
2007        let base_path = temp_dir.path();
2008
2009        // Create a file with spaces in the name
2010        let file_with_spaces = base_path.join("penguin with space.jpg");
2011        File::create(&file_with_spaces)
2012            .unwrap()
2013            .write_all(b"image data")
2014            .unwrap();
2015
2016        // Create a subdirectory with spaces
2017        let subdir = base_path.join("my images");
2018        std::fs::create_dir(&subdir).unwrap();
2019        let nested_file = subdir.join("photo 1.png");
2020        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2021
2022        // Test content with URL-encoded links
2023        let content = r#"
2024# Test Document with URL-Encoded Links
2025
2026![Penguin](penguin%20with%20space.jpg)
2027![Photo](my%20images/photo%201.png)
2028![Missing](missing%20file.jpg)
2029"#;
2030
2031        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2032
2033        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034        let result = rule.check(&ctx).unwrap();
2035
2036        // Should only have one warning for the missing file
2037        assert_eq!(
2038            result.len(),
2039            1,
2040            "Should only warn about missing%20file.jpg. Got: {result:?}"
2041        );
2042        assert!(
2043            result[0].message.contains("missing%20file.jpg"),
2044            "Warning should mention the URL-encoded filename"
2045        );
2046    }
2047
2048    #[test]
2049    fn test_external_urls() {
2050        let rule = MD057ExistingRelativeLinks::new();
2051
2052        // Common web protocols
2053        assert!(rule.is_external_url("https://example.com"));
2054        assert!(rule.is_external_url("http://example.com"));
2055        assert!(rule.is_external_url("ftp://example.com"));
2056        assert!(rule.is_external_url("www.example.com"));
2057        assert!(rule.is_external_url("example.com"));
2058
2059        // Special URI schemes
2060        assert!(rule.is_external_url("file:///path/to/file"));
2061        assert!(rule.is_external_url("smb://server/share"));
2062        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
2063        assert!(rule.is_external_url("mailto:user@example.com"));
2064        assert!(rule.is_external_url("tel:+1234567890"));
2065        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
2066        assert!(rule.is_external_url("javascript:void(0)"));
2067        assert!(rule.is_external_url("ssh://git@github.com/repo"));
2068        assert!(rule.is_external_url("git://github.com/repo.git"));
2069
2070        // Email addresses without mailto: protocol
2071        // These are clearly not file links and should be skipped
2072        assert!(rule.is_external_url("user@example.com"));
2073        assert!(rule.is_external_url("steering@kubernetes.io"));
2074        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
2075        assert!(rule.is_external_url("user_name@sub.domain.com"));
2076        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2077
2078        // Template variables should be skipped (not checked as relative links)
2079        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
2080        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
2081        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
2082        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
2083        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
2084        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
2085
2086        // Absolute paths are NOT external (handled separately via is_absolute_path)
2087        // By default they are ignored, but can be configured to warn
2088        assert!(!rule.is_external_url("/api/v1/users"));
2089        assert!(!rule.is_external_url("/blog/2024/release.html"));
2090        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
2091        assert!(!rule.is_external_url("/pkg/runtime"));
2092        assert!(!rule.is_external_url("/doc/go1compat"));
2093        assert!(!rule.is_external_url("/index.html"));
2094        assert!(!rule.is_external_url("/assets/logo.png"));
2095
2096        // But is_absolute_path should detect them
2097        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2098        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2099        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2100        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2101        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2102
2103        // Framework path aliases should be skipped (resolved by build tools)
2104        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
2105        assert!(rule.is_external_url("~/assets/image.png"));
2106        assert!(rule.is_external_url("~/components/Button.vue"));
2107        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
2108
2109        // @ prefix (common in Vue, webpack, Vite aliases)
2110        assert!(rule.is_external_url("@/components/Header.vue"));
2111        assert!(rule.is_external_url("@images/photo.jpg"));
2112        assert!(rule.is_external_url("@assets/styles.css"));
2113
2114        // Relative paths should NOT be external (should be validated)
2115        assert!(!rule.is_external_url("./relative/path.md"));
2116        assert!(!rule.is_external_url("relative/path.md"));
2117        assert!(!rule.is_external_url("../parent/path.md"));
2118    }
2119
2120    #[test]
2121    fn test_dot_com_only_skips_bare_domains() {
2122        let rule = MD057ExistingRelativeLinks::new();
2123
2124        // Bare domains ending in .com are treated as external (skipped).
2125        assert!(rule.is_external_url("example.com"));
2126        assert!(rule.is_external_url("sub.example.com"));
2127
2128        // A relative path that merely ends in ".com" must NOT be skipped:
2129        // it contains a path separator, so it is a relative file reference
2130        // that should be validated, not assumed external.
2131        assert!(!rule.is_external_url("../../vendor.com"));
2132        assert!(!rule.is_external_url("./vendor.com"));
2133        assert!(!rule.is_external_url("docs/vendor.com"));
2134    }
2135
2136    #[test]
2137    fn test_framework_path_aliases() {
2138        // Create a temporary directory for test files
2139        let temp_dir = tempdir().unwrap();
2140        let base_path = temp_dir.path();
2141
2142        // Test content with framework path aliases (should all be skipped)
2143        let content = r#"
2144# Framework Path Aliases
2145
2146![Image 1](~/assets/penguin.jpg)
2147![Image 2](~assets/logo.svg)
2148![Image 3](@images/photo.jpg)
2149![Image 4](@/components/icon.svg)
2150[Link](@/pages/about.md)
2151
2152This is a [real missing link](missing.md) that should be flagged.
2153"#;
2154
2155        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2156
2157        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158        let result = rule.check(&ctx).unwrap();
2159
2160        // Should only have one warning for the real missing link
2161        assert_eq!(
2162            result.len(),
2163            1,
2164            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2165        );
2166        assert!(
2167            result[0].message.contains("missing.md"),
2168            "Warning should be for missing.md"
2169        );
2170    }
2171
2172    #[test]
2173    fn test_url_decode_security_path_traversal() {
2174        // Ensure URL decoding doesn't enable path traversal attacks
2175        // The decoded path is still validated against the base path
2176        let temp_dir = tempdir().unwrap();
2177        let base_path = temp_dir.path();
2178
2179        // Create a file in the temp directory
2180        let file_in_base = base_path.join("safe.md");
2181        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2182
2183        // Test with encoded path traversal attempt
2184        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
2185        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
2186        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
2187        let content = r#"
2188[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2189[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2190[Safe link](safe.md)
2191"#;
2192
2193        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2194
2195        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2196        let result = rule.check(&ctx).unwrap();
2197
2198        // The traversal attempts should still be flagged as missing
2199        // (they don't exist relative to base_path after decoding)
2200        assert_eq!(
2201            result.len(),
2202            2,
2203            "Should have warnings for traversal attempts. Got: {result:?}"
2204        );
2205    }
2206
2207    #[test]
2208    fn test_url_encoded_utf8_filenames() {
2209        // Test with actual UTF-8 encoded filenames
2210        let temp_dir = tempdir().unwrap();
2211        let base_path = temp_dir.path();
2212
2213        // Create files with unicode names
2214        let cafe_file = base_path.join("café.md");
2215        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2216
2217        let content = r#"
2218[Café link](caf%C3%A9.md)
2219[Missing unicode](r%C3%A9sum%C3%A9.md)
2220"#;
2221
2222        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2223
2224        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2225        let result = rule.check(&ctx).unwrap();
2226
2227        // Should only warn about the missing file
2228        assert_eq!(
2229            result.len(),
2230            1,
2231            "Should only warn about missing résumé.md. Got: {result:?}"
2232        );
2233        assert!(
2234            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2235            "Warning should mention the URL-encoded filename"
2236        );
2237    }
2238
2239    #[test]
2240    fn test_url_encoded_emoji_filenames() {
2241        // URL-encoded emoji paths should be correctly resolved
2242        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
2243        let temp_dir = tempdir().unwrap();
2244        let base_path = temp_dir.path();
2245
2246        // Create directory with emoji in name: 👤 Personal
2247        let emoji_dir = base_path.join("👤 Personal");
2248        std::fs::create_dir(&emoji_dir).unwrap();
2249
2250        // Create file in that directory: TV Shows.md
2251        let file_path = emoji_dir.join("TV Shows.md");
2252        File::create(&file_path)
2253            .unwrap()
2254            .write_all(b"# TV Shows\n\nContent here.")
2255            .unwrap();
2256
2257        // Test content with URL-encoded emoji link
2258        // %F0%9F%91%A4 = 👤, %20 = space
2259        let content = r#"
2260# Test Document
2261
2262[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2263[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2264"#;
2265
2266        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2267
2268        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269        let result = rule.check(&ctx).unwrap();
2270
2271        // Should only warn about the missing file, not the valid emoji path
2272        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2273        assert!(
2274            result[0].message.contains("Missing.md"),
2275            "Warning should be for Missing.md, got: {}",
2276            result[0].message
2277        );
2278    }
2279
2280    #[test]
2281    fn test_no_warnings_without_base_path() {
2282        let rule = MD057ExistingRelativeLinks::new();
2283        let content = "[Link](missing.md)";
2284
2285        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286        let result = rule.check(&ctx).unwrap();
2287        assert!(result.is_empty(), "Should have no warnings without base path");
2288    }
2289
2290    #[test]
2291    fn test_existing_and_missing_links() {
2292        // Create a temporary directory for test files
2293        let temp_dir = tempdir().unwrap();
2294        let base_path = temp_dir.path();
2295
2296        // Create an existing file
2297        let exists_path = base_path.join("exists.md");
2298        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2299
2300        // Verify the file exists
2301        assert!(exists_path.exists(), "exists.md should exist for this test");
2302
2303        // Create test content with both existing and missing links
2304        let content = r#"
2305# Test Document
2306
2307[Valid Link](exists.md)
2308[Invalid Link](missing.md)
2309[External Link](https://example.com)
2310[Media Link](image.jpg)
2311        "#;
2312
2313        // Initialize rule with the base path (default: check all files including media)
2314        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2315
2316        // Test the rule
2317        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2318        let result = rule.check(&ctx).unwrap();
2319
2320        // Should have two warnings: missing.md and image.jpg (both don't exist)
2321        assert_eq!(result.len(), 2);
2322        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2323        assert!(messages.iter().any(|m| m.contains("missing.md")));
2324        assert!(messages.iter().any(|m| m.contains("image.jpg")));
2325    }
2326
2327    #[test]
2328    fn test_angle_bracket_links() {
2329        // Create a temporary directory for test files
2330        let temp_dir = tempdir().unwrap();
2331        let base_path = temp_dir.path();
2332
2333        // Create an existing file
2334        let exists_path = base_path.join("exists.md");
2335        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2336
2337        // Create test content with angle bracket links
2338        let content = r#"
2339# Test Document
2340
2341[Valid Link](<exists.md>)
2342[Invalid Link](<missing.md>)
2343[External Link](<https://example.com>)
2344    "#;
2345
2346        // Test with default settings
2347        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2348
2349        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350        let result = rule.check(&ctx).unwrap();
2351
2352        // Should have one warning for missing.md
2353        assert_eq!(result.len(), 1, "Should have exactly one warning");
2354        assert!(
2355            result[0].message.contains("missing.md"),
2356            "Warning should mention missing.md"
2357        );
2358    }
2359
2360    #[test]
2361    fn test_angle_bracket_links_with_parens() {
2362        // Create a temporary directory for test files
2363        let temp_dir = tempdir().unwrap();
2364        let base_path = temp_dir.path();
2365
2366        // Create directory structure with parentheses in path
2367        let app_dir = base_path.join("app");
2368        std::fs::create_dir(&app_dir).unwrap();
2369        let upload_dir = app_dir.join("(upload)");
2370        std::fs::create_dir(&upload_dir).unwrap();
2371        let page_file = upload_dir.join("page.tsx");
2372        File::create(&page_file)
2373            .unwrap()
2374            .write_all(b"export default function Page() {}")
2375            .unwrap();
2376
2377        // Create test content with angle bracket links containing parentheses
2378        let content = r#"
2379# Test Document with Paths Containing Parens
2380
2381[Upload Page](<app/(upload)/page.tsx>)
2382[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2383[Missing](<app/(missing)/file.md>)
2384"#;
2385
2386        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2387
2388        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389        let result = rule.check(&ctx).unwrap();
2390
2391        // Should only have one warning for the missing file
2392        assert_eq!(
2393            result.len(),
2394            1,
2395            "Should have exactly one warning for missing file. Got: {result:?}"
2396        );
2397        assert!(
2398            result[0].message.contains("app/(missing)/file.md"),
2399            "Warning should mention app/(missing)/file.md"
2400        );
2401    }
2402
2403    #[test]
2404    fn test_all_file_types_checked() {
2405        // Create a temporary directory for test files
2406        let temp_dir = tempdir().unwrap();
2407        let base_path = temp_dir.path();
2408
2409        // Create a test with various file types - all should be checked
2410        let content = r#"
2411[Image Link](image.jpg)
2412[Video Link](video.mp4)
2413[Markdown Link](document.md)
2414[PDF Link](file.pdf)
2415"#;
2416
2417        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2418
2419        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420        let result = rule.check(&ctx).unwrap();
2421
2422        // Should warn about all missing files regardless of extension
2423        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2424    }
2425
2426    #[test]
2427    fn test_code_span_detection() {
2428        let rule = MD057ExistingRelativeLinks::new();
2429
2430        // Create a temporary directory for test files
2431        let temp_dir = tempdir().unwrap();
2432        let base_path = temp_dir.path();
2433
2434        let rule = rule.with_path(base_path);
2435
2436        // Test with document structure
2437        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2438
2439        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2440        let result = rule.check(&ctx).unwrap();
2441
2442        // Should only find the real link, not the one in code
2443        assert_eq!(result.len(), 1, "Should only flag the real link");
2444        assert!(result[0].message.contains("nonexistent.md"));
2445    }
2446
2447    #[test]
2448    fn test_inline_code_spans() {
2449        // Create a temporary directory for test files
2450        let temp_dir = tempdir().unwrap();
2451        let base_path = temp_dir.path();
2452
2453        // Create test content with links in inline code spans
2454        let content = r#"
2455# Test Document
2456
2457This is a normal link: [Link](missing.md)
2458
2459This is a code span with a link: `[Link](another-missing.md)`
2460
2461Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2462
2463    "#;
2464
2465        // Initialize rule with the base path
2466        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2467
2468        // Test the rule
2469        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2470        let result = rule.check(&ctx).unwrap();
2471
2472        // Should only have warning for the normal link, not for links in code spans
2473        assert_eq!(result.len(), 1, "Should have exactly one warning");
2474        assert!(
2475            result[0].message.contains("missing.md"),
2476            "Warning should be for missing.md"
2477        );
2478        assert!(
2479            !result.iter().any(|w| w.message.contains("another-missing.md")),
2480            "Should not warn about link in code span"
2481        );
2482        assert!(
2483            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2484            "Should not warn about link in inline code"
2485        );
2486    }
2487
2488    #[test]
2489    fn test_extensionless_link_resolution() {
2490        // Create a temporary directory for test files
2491        let temp_dir = tempdir().unwrap();
2492        let base_path = temp_dir.path();
2493
2494        // Create a markdown file WITHOUT specifying .md extension in the link
2495        let page_path = base_path.join("page.md");
2496        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2497
2498        // Test content with extensionless link that should resolve to page.md
2499        let content = r#"
2500# Test Document
2501
2502[Link without extension](page)
2503[Link with extension](page.md)
2504[Missing link](nonexistent)
2505"#;
2506
2507        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2508
2509        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2510        let result = rule.check(&ctx).unwrap();
2511
2512        // Should only have warning for nonexistent link
2513        // Both "page" and "page.md" should resolve to the same file
2514        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2515        assert!(
2516            result[0].message.contains("nonexistent"),
2517            "Warning should be for 'nonexistent' not 'page'"
2518        );
2519    }
2520
2521    // Cross-file validation tests
2522    #[test]
2523    fn test_cross_file_scope() {
2524        let rule = MD057ExistingRelativeLinks::new();
2525        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2526    }
2527
2528    #[test]
2529    fn test_contribute_to_index_extracts_markdown_links() {
2530        let rule = MD057ExistingRelativeLinks::new();
2531        let content = r#"
2532# Document
2533
2534[Link to docs](./docs/guide.md)
2535[Link with fragment](./other.md#section)
2536[External link](https://example.com)
2537[Image link](image.png)
2538[Media file](video.mp4)
2539"#;
2540
2541        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2542        let mut index = FileIndex::new();
2543        rule.contribute_to_index(&ctx, &mut index);
2544
2545        // Should only index markdown file links
2546        assert_eq!(index.cross_file_links.len(), 2);
2547
2548        // Check first link
2549        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2550        assert_eq!(index.cross_file_links[0].fragment, "");
2551
2552        // Check second link (with fragment)
2553        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2554        assert_eq!(index.cross_file_links[1].fragment, "section");
2555    }
2556
2557    #[test]
2558    fn test_contribute_to_index_skips_external_and_anchors() {
2559        let rule = MD057ExistingRelativeLinks::new();
2560        let content = r#"
2561# Document
2562
2563[External](https://example.com)
2564[Another external](http://example.org)
2565[Fragment only](#section)
2566[FTP link](ftp://files.example.com)
2567[Mail link](mailto:test@example.com)
2568[WWW link](www.example.com)
2569"#;
2570
2571        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2572        let mut index = FileIndex::new();
2573        rule.contribute_to_index(&ctx, &mut index);
2574
2575        // Should not index any of these
2576        assert_eq!(index.cross_file_links.len(), 0);
2577    }
2578
2579    #[test]
2580    fn test_cross_file_check_valid_link() {
2581        use crate::workspace_index::WorkspaceIndex;
2582
2583        let rule = MD057ExistingRelativeLinks::new();
2584
2585        // Create a workspace index with the target file
2586        let mut workspace_index = WorkspaceIndex::new();
2587        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2588
2589        // Create file index with a link to an existing file
2590        let mut file_index = FileIndex::new();
2591        file_index.add_cross_file_link(CrossFileLinkIndex {
2592            target_path: "guide.md".to_string(),
2593            fragment: "".to_string(),
2594            line: 5,
2595            column: 1,
2596            origin: LinkOrigin::Body,
2597        });
2598
2599        // Run cross-file check from docs/index.md
2600        let warnings = rule
2601            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2602            .unwrap();
2603
2604        // Should have no warnings - file exists
2605        assert!(warnings.is_empty());
2606    }
2607
2608    #[test]
2609    fn test_cross_file_check_missing_link() {
2610        // cross_file_check delegates all validation to check() to avoid duplicates.
2611        // It always returns empty — the per-file check() path is authoritative.
2612        use crate::workspace_index::WorkspaceIndex;
2613
2614        let rule = MD057ExistingRelativeLinks::new();
2615        let workspace_index = WorkspaceIndex::new();
2616
2617        let mut file_index = FileIndex::new();
2618        file_index.add_cross_file_link(CrossFileLinkIndex {
2619            target_path: "missing.md".to_string(),
2620            fragment: "".to_string(),
2621            line: 5,
2622            column: 1,
2623            origin: LinkOrigin::Body,
2624        });
2625
2626        let warnings = rule
2627            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2628            .unwrap();
2629
2630        // cross_file_check defers to check(); it produces no warnings of its own.
2631        assert!(
2632            warnings.is_empty(),
2633            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2634        );
2635    }
2636
2637    #[test]
2638    fn test_cross_file_check_parent_path() {
2639        use crate::workspace_index::WorkspaceIndex;
2640
2641        let rule = MD057ExistingRelativeLinks::new();
2642
2643        // Create a workspace index with the target file at the root
2644        let mut workspace_index = WorkspaceIndex::new();
2645        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2646
2647        // Create file index with a parent path link
2648        let mut file_index = FileIndex::new();
2649        file_index.add_cross_file_link(CrossFileLinkIndex {
2650            target_path: "../readme.md".to_string(),
2651            fragment: "".to_string(),
2652            line: 5,
2653            column: 1,
2654            origin: LinkOrigin::Body,
2655        });
2656
2657        // Run cross-file check from docs/guide.md
2658        let warnings = rule
2659            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2660            .unwrap();
2661
2662        // Should have no warnings - file exists at normalized path
2663        assert!(warnings.is_empty());
2664    }
2665
2666    #[test]
2667    fn test_cross_file_check_html_link_with_md_source() {
2668        // Test that .html links are accepted when corresponding .md source exists
2669        // This supports mdBook and similar doc generators that compile .md to .html
2670        use crate::workspace_index::WorkspaceIndex;
2671
2672        let rule = MD057ExistingRelativeLinks::new();
2673
2674        // Create a workspace index with the .md source file
2675        let mut workspace_index = WorkspaceIndex::new();
2676        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2677
2678        // Create file index with an .html link (from another rule like MD051)
2679        let mut file_index = FileIndex::new();
2680        file_index.add_cross_file_link(CrossFileLinkIndex {
2681            target_path: "guide.html".to_string(),
2682            fragment: "section".to_string(),
2683            line: 10,
2684            column: 5,
2685            origin: LinkOrigin::Body,
2686        });
2687
2688        // Run cross-file check from docs/index.md
2689        let warnings = rule
2690            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2691            .unwrap();
2692
2693        // Should have no warnings - .md source exists for the .html link
2694        assert!(
2695            warnings.is_empty(),
2696            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2697        );
2698    }
2699
2700    #[test]
2701    fn test_cross_file_check_html_link_without_source() {
2702        // cross_file_check delegates all validation to check() to avoid duplicates.
2703        // Verifying that .html links without a matching .md source are caught is
2704        // already covered by test_html_link_with_md_source (check() path).
2705        use crate::workspace_index::WorkspaceIndex;
2706
2707        let rule = MD057ExistingRelativeLinks::new();
2708        let workspace_index = WorkspaceIndex::new();
2709
2710        let mut file_index = FileIndex::new();
2711        file_index.add_cross_file_link(CrossFileLinkIndex {
2712            target_path: "missing.html".to_string(),
2713            fragment: "".to_string(),
2714            line: 10,
2715            column: 5,
2716            origin: LinkOrigin::Body,
2717        });
2718
2719        let warnings = rule
2720            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2721            .unwrap();
2722
2723        // cross_file_check defers to check(); it produces no warnings of its own.
2724        assert!(
2725            warnings.is_empty(),
2726            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2727        );
2728    }
2729
2730    #[test]
2731    fn test_normalize_path_function() {
2732        // Test simple cases
2733        assert_eq!(
2734            normalize_relative_path(Path::new("docs/guide.md")),
2735            PathBuf::from("docs/guide.md")
2736        );
2737
2738        // Test current directory removal
2739        assert_eq!(
2740            normalize_relative_path(Path::new("./docs/guide.md")),
2741            PathBuf::from("docs/guide.md")
2742        );
2743
2744        // Test parent directory resolution
2745        assert_eq!(
2746            normalize_relative_path(Path::new("docs/sub/../guide.md")),
2747            PathBuf::from("docs/guide.md")
2748        );
2749
2750        // Test multiple parent directories
2751        assert_eq!(
2752            normalize_relative_path(Path::new("a/b/c/../../d.md")),
2753            PathBuf::from("a/d.md")
2754        );
2755    }
2756
2757    #[test]
2758    fn test_html_link_with_md_source() {
2759        // Links to .html files should pass if corresponding .md source exists
2760        let temp_dir = tempdir().unwrap();
2761        let base_path = temp_dir.path();
2762
2763        // Create guide.md (source file)
2764        let md_file = base_path.join("guide.md");
2765        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2766
2767        let content = r#"
2768[Read the guide](guide.html)
2769[Also here](getting-started.html)
2770"#;
2771
2772        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2773        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2774        let result = rule.check(&ctx).unwrap();
2775
2776        // guide.html passes (guide.md exists), getting-started.html fails
2777        assert_eq!(
2778            result.len(),
2779            1,
2780            "Should only warn about missing source. Got: {result:?}"
2781        );
2782        assert!(result[0].message.contains("getting-started.html"));
2783    }
2784
2785    #[test]
2786    fn test_htm_link_with_md_source() {
2787        // .htm extension should also check for markdown source
2788        let temp_dir = tempdir().unwrap();
2789        let base_path = temp_dir.path();
2790
2791        let md_file = base_path.join("page.md");
2792        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2793
2794        let content = "[Page](page.htm)";
2795
2796        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2797        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2798        let result = rule.check(&ctx).unwrap();
2799
2800        assert!(
2801            result.is_empty(),
2802            "Should not warn when .md source exists for .htm link"
2803        );
2804    }
2805
2806    #[test]
2807    fn test_html_link_finds_various_markdown_extensions() {
2808        // Should find .mdx, .markdown, etc. as source files
2809        let temp_dir = tempdir().unwrap();
2810        let base_path = temp_dir.path();
2811
2812        File::create(base_path.join("doc.md")).unwrap();
2813        File::create(base_path.join("tutorial.mdx")).unwrap();
2814        File::create(base_path.join("guide.markdown")).unwrap();
2815
2816        let content = r#"
2817[Doc](doc.html)
2818[Tutorial](tutorial.html)
2819[Guide](guide.html)
2820"#;
2821
2822        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2823        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824        let result = rule.check(&ctx).unwrap();
2825
2826        assert!(
2827            result.is_empty(),
2828            "Should find all markdown variants as source files. Got: {result:?}"
2829        );
2830    }
2831
2832    #[test]
2833    fn test_html_link_in_subdirectory() {
2834        // Should find markdown source in subdirectories
2835        let temp_dir = tempdir().unwrap();
2836        let base_path = temp_dir.path();
2837
2838        let docs_dir = base_path.join("docs");
2839        std::fs::create_dir(&docs_dir).unwrap();
2840        File::create(docs_dir.join("guide.md"))
2841            .unwrap()
2842            .write_all(b"# Guide")
2843            .unwrap();
2844
2845        let content = "[Guide](docs/guide.html)";
2846
2847        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2848        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2849        let result = rule.check(&ctx).unwrap();
2850
2851        assert!(result.is_empty(), "Should find markdown source in subdirectory");
2852    }
2853
2854    #[test]
2855    fn test_absolute_path_skipped_in_check() {
2856        // Test that absolute paths are skipped during link validation
2857        // This fixes the bug where /pkg/runtime was being flagged
2858        let temp_dir = tempdir().unwrap();
2859        let base_path = temp_dir.path();
2860
2861        let content = r#"
2862# Test Document
2863
2864[Go Runtime](/pkg/runtime)
2865[Go Runtime with Fragment](/pkg/runtime#section)
2866[API Docs](/api/v1/users)
2867[Blog Post](/blog/2024/release.html)
2868[React Hook](/react/hooks/use-state.html)
2869"#;
2870
2871        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2872        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2873        let result = rule.check(&ctx).unwrap();
2874
2875        // Should have NO warnings - all absolute paths should be skipped
2876        assert!(
2877            result.is_empty(),
2878            "Absolute paths should be skipped. Got warnings: {result:?}"
2879        );
2880    }
2881
2882    #[test]
2883    fn test_absolute_path_skipped_in_cross_file_check() {
2884        // Test that absolute paths are skipped in cross_file_check()
2885        use crate::workspace_index::WorkspaceIndex;
2886
2887        let rule = MD057ExistingRelativeLinks::new();
2888
2889        // Create an empty workspace index (no files exist)
2890        let workspace_index = WorkspaceIndex::new();
2891
2892        // Create file index with absolute path links (should be skipped)
2893        let mut file_index = FileIndex::new();
2894        file_index.add_cross_file_link(CrossFileLinkIndex {
2895            target_path: "/pkg/runtime.md".to_string(),
2896            fragment: "".to_string(),
2897            line: 5,
2898            column: 1,
2899            origin: LinkOrigin::Body,
2900        });
2901        file_index.add_cross_file_link(CrossFileLinkIndex {
2902            target_path: "/api/v1/users.md".to_string(),
2903            fragment: "section".to_string(),
2904            line: 10,
2905            column: 1,
2906            origin: LinkOrigin::Body,
2907        });
2908
2909        // Run cross-file check
2910        let warnings = rule
2911            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2912            .unwrap();
2913
2914        // Should have NO warnings - absolute paths should be skipped
2915        assert!(
2916            warnings.is_empty(),
2917            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2918        );
2919    }
2920
2921    #[test]
2922    fn test_protocol_relative_url_not_skipped() {
2923        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
2924        // They should still be caught by is_external_url() though
2925        let temp_dir = tempdir().unwrap();
2926        let base_path = temp_dir.path();
2927
2928        let content = r#"
2929# Test Document
2930
2931[External](//example.com/page)
2932[Another](//cdn.example.com/asset.js)
2933"#;
2934
2935        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2936        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2937        let result = rule.check(&ctx).unwrap();
2938
2939        // Should have NO warnings - protocol-relative URLs are external and should be skipped
2940        assert!(
2941            result.is_empty(),
2942            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2943        );
2944    }
2945
2946    #[test]
2947    fn test_email_addresses_skipped() {
2948        // Test that email addresses without mailto: are skipped
2949        // These are clearly not file links (the @ symbol is definitive)
2950        let temp_dir = tempdir().unwrap();
2951        let base_path = temp_dir.path();
2952
2953        let content = r#"
2954# Test Document
2955
2956[Contact](user@example.com)
2957[Steering](steering@kubernetes.io)
2958[Support](john.doe+filter@company.co.uk)
2959[User](user_name@sub.domain.com)
2960"#;
2961
2962        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2963        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2964        let result = rule.check(&ctx).unwrap();
2965
2966        // Should have NO warnings - email addresses are clearly not file links and should be skipped
2967        assert!(
2968            result.is_empty(),
2969            "Email addresses should be skipped. Got warnings: {result:?}"
2970        );
2971    }
2972
2973    #[test]
2974    fn test_email_addresses_vs_file_paths() {
2975        // Test that email addresses (anything with @) are skipped
2976        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
2977        let temp_dir = tempdir().unwrap();
2978        let base_path = temp_dir.path();
2979
2980        let content = r#"
2981# Test Document
2982
2983[Email](user@example.com)  <!-- Should be skipped (email) -->
2984[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
2985[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
2986"#;
2987
2988        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2989        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2990        let result = rule.check(&ctx).unwrap();
2991
2992        // All should be skipped - anything with @ is treated as an email
2993        assert!(
2994            result.is_empty(),
2995            "All email addresses should be skipped. Got: {result:?}"
2996        );
2997    }
2998
2999    #[test]
3000    fn test_diagnostic_position_accuracy() {
3001        // Test that diagnostics point to the URL, not the link text
3002        let temp_dir = tempdir().unwrap();
3003        let base_path = temp_dir.path();
3004
3005        // Position markers:     0         1         2         3
3006        //                       0123456789012345678901234567890123456789
3007        let content = "prefix [text](missing.md) suffix";
3008        //             The URL "missing.md" starts at 0-indexed position 14
3009        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
3010
3011        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3012        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3013        let result = rule.check(&ctx).unwrap();
3014
3015        assert_eq!(result.len(), 1, "Should have exactly one warning");
3016        assert_eq!(result[0].line, 1, "Should be on line 1");
3017        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3018        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3019    }
3020
3021    #[test]
3022    fn test_diagnostic_position_non_ascii_link() {
3023        // Issue #670: columns are character offsets, not byte offsets. The CJK
3024        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
3025        let temp_dir = tempdir().unwrap();
3026        let base_path = temp_dir.path();
3027
3028        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
3029        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
3030        // and ends past character column 21, i.e. end_column 22.
3031        let content = "你好你好[你好](not-exist.md) bar";
3032
3033        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3034        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3035        let result = rule.check(&ctx).unwrap();
3036
3037        assert_eq!(result.len(), 1, "Should have exactly one warning");
3038        assert_eq!(result[0].line, 1, "Should be on line 1");
3039        assert_eq!(
3040            result[0].column, 10,
3041            "Column must be a character offset, not a byte offset"
3042        );
3043        assert_eq!(result[0].end_column, 22, "End column must be character-based");
3044    }
3045
3046    #[test]
3047    fn test_diagnostic_position_angle_brackets() {
3048        // Test position accuracy with angle bracket links
3049        let temp_dir = tempdir().unwrap();
3050        let base_path = temp_dir.path();
3051
3052        // Position markers:     0         1         2
3053        //                       012345678901234567890
3054        let content = "[link](<missing.md>)";
3055        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
3056
3057        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3058        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3059        let result = rule.check(&ctx).unwrap();
3060
3061        assert_eq!(result.len(), 1, "Should have exactly one warning");
3062        assert_eq!(result[0].line, 1, "Should be on line 1");
3063        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3064    }
3065
3066    #[test]
3067    fn test_diagnostic_position_multiline() {
3068        // Test that line numbers are correct for links on different lines
3069        let temp_dir = tempdir().unwrap();
3070        let base_path = temp_dir.path();
3071
3072        let content = r#"# Title
3073Some text on line 2
3074[link on line 3](missing1.md)
3075More text
3076[link on line 5](missing2.md)"#;
3077
3078        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3079        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3080        let result = rule.check(&ctx).unwrap();
3081
3082        assert_eq!(result.len(), 2, "Should have two warnings");
3083
3084        // First warning should be on line 3
3085        assert_eq!(result[0].line, 3, "First warning should be on line 3");
3086        assert!(result[0].message.contains("missing1.md"));
3087
3088        // Second warning should be on line 5
3089        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3090        assert!(result[1].message.contains("missing2.md"));
3091    }
3092
3093    #[test]
3094    fn test_diagnostic_position_with_spaces() {
3095        // Test position with URLs that have spaces in parentheses
3096        let temp_dir = tempdir().unwrap();
3097        let base_path = temp_dir.path();
3098
3099        let content = "[link]( missing.md )";
3100        //             0123456789012345678901
3101        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
3102        //             which is 1-indexed column 9
3103
3104        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3105        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3106        let result = rule.check(&ctx).unwrap();
3107
3108        assert_eq!(result.len(), 1, "Should have exactly one warning");
3109        // The regex captures the URL without leading/trailing spaces
3110        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3111    }
3112
3113    #[test]
3114    fn test_diagnostic_position_image() {
3115        // Test that image diagnostics also have correct positions
3116        let temp_dir = tempdir().unwrap();
3117        let base_path = temp_dir.path();
3118
3119        let content = "![alt text](missing.jpg)";
3120
3121        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3122        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3123        let result = rule.check(&ctx).unwrap();
3124
3125        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3126        assert_eq!(result[0].line, 1);
3127        // Images use start_col from the parser, which should point to the URL
3128        assert!(result[0].column > 0, "Should have valid column position");
3129        assert!(result[0].message.contains("missing.jpg"));
3130    }
3131
3132    #[test]
3133    fn test_diagnostic_position_non_ascii_image() {
3134        // Issue #670: image columns are character offsets, not byte offsets.
3135        let temp_dir = tempdir().unwrap();
3136        let base_path = temp_dir.path();
3137
3138        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
3139        // The image syntax starts at the '!' which is 1-indexed character column 5.
3140        let content = "你好你好![你好](not-exist.png)";
3141
3142        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3143        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3144        let result = rule.check(&ctx).unwrap();
3145
3146        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3147        assert_eq!(result[0].line, 1, "Should be on line 1");
3148        assert_eq!(
3149            result[0].column, 5,
3150            "Column must be a character offset, not a byte offset"
3151        );
3152        assert!(result[0].message.contains("not-exist.png"));
3153    }
3154
3155    #[test]
3156    fn test_diagnostic_position_non_ascii_reference_def() {
3157        // Issue #670: reference-definition columns are character offsets. A
3158        // multi-byte label shifts the URL's byte offset away from its character
3159        // column.
3160        let temp_dir = tempdir().unwrap();
3161        let base_path = temp_dir.path();
3162
3163        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
3164        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
3165        let content = "[你好]: not-exist.md";
3166
3167        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3168        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3169        let result = rule.check(&ctx).unwrap();
3170
3171        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3172        assert_eq!(result[0].line, 1, "Should be on line 1");
3173        assert_eq!(
3174            result[0].column, 7,
3175            "Column must be a character offset, not a byte offset"
3176        );
3177        assert_eq!(result[0].end_column, 19, "End column must be character-based");
3178    }
3179
3180    #[test]
3181    fn test_wikilinks_skipped() {
3182        // Wikilinks should not trigger MD057 warnings
3183        // They use a different linking system (e.g., Obsidian, wiki software)
3184        let temp_dir = tempdir().unwrap();
3185        let base_path = temp_dir.path();
3186
3187        let content = r#"# Test Document
3188
3189[[Microsoft#Windows OS]]
3190[[SomePage]]
3191[[Page With Spaces]]
3192[[path/to/page#section]]
3193[[page|Display Text]]
3194
3195This is a [real missing link](missing.md) that should be flagged.
3196"#;
3197
3198        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3199        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3200        let result = rule.check(&ctx).unwrap();
3201
3202        // Should only warn about the regular markdown link, not wikilinks
3203        assert_eq!(
3204            result.len(),
3205            1,
3206            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3207        );
3208        assert!(
3209            result[0].message.contains("missing.md"),
3210            "Warning should be for missing.md, not wikilinks"
3211        );
3212    }
3213
3214    #[test]
3215    fn test_wiki_embeds_skipped() {
3216        // A wiki embed names a vault entry, not a path relative to this file,
3217        // so `![[diagram.png]]` is not a missing relative link even though no
3218        // such file sits next to the document.
3219        let temp_dir = tempdir().unwrap();
3220        let base_path = temp_dir.path();
3221
3222        let content = r#"# Test Document
3223
3224![[diagram.png]]
3225![[subfolder/diagram.png]]
3226![[diagram.png|300]]
3227![[Some Note]]
3228
3229This is a [real missing link](missing.md) that should be flagged.
3230"#;
3231
3232        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3233        for flavor in [
3234            crate::config::MarkdownFlavor::Obsidian,
3235            crate::config::MarkdownFlavor::Standard,
3236        ] {
3237            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3238            let result = rule.check(&ctx).unwrap();
3239
3240            assert_eq!(
3241                result.len(),
3242                1,
3243                "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3244            );
3245            assert!(result[0].message.contains("missing.md"));
3246        }
3247    }
3248
3249    #[test]
3250    fn test_wikilinks_not_added_to_index() {
3251        // Wikilinks should not be added to the cross-file link index
3252        let temp_dir = tempdir().unwrap();
3253        let base_path = temp_dir.path();
3254
3255        let content = r#"# Test Document
3256
3257[[Microsoft#Windows OS]]
3258[[SomePage#section]]
3259[Regular Link](other.md)
3260"#;
3261
3262        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3263        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3264
3265        let mut file_index = FileIndex::new();
3266        rule.contribute_to_index(&ctx, &mut file_index);
3267
3268        // Should only have the regular markdown link (if it's a markdown file)
3269        // Wikilinks should not be added
3270        let cross_file_links = &file_index.cross_file_links;
3271        assert_eq!(
3272            cross_file_links.len(),
3273            1,
3274            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3275        );
3276        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3277    }
3278
3279    #[test]
3280    fn test_reference_definition_missing_file() {
3281        // Reference definitions [ref]: ./path.md should be checked
3282        let temp_dir = tempdir().unwrap();
3283        let base_path = temp_dir.path();
3284
3285        let content = r#"# Test Document
3286
3287[test]: ./missing.md
3288[example]: ./nonexistent.html
3289
3290Use [test] and [example] here.
3291"#;
3292
3293        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3294        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3295        let result = rule.check(&ctx).unwrap();
3296
3297        // Should have warnings for both reference definitions
3298        assert_eq!(
3299            result.len(),
3300            2,
3301            "Should have warnings for missing reference definition targets. Got: {result:?}"
3302        );
3303        assert!(
3304            result.iter().any(|w| w.message.contains("missing.md")),
3305            "Should warn about missing.md"
3306        );
3307        assert!(
3308            result.iter().any(|w| w.message.contains("nonexistent.html")),
3309            "Should warn about nonexistent.html"
3310        );
3311    }
3312
3313    #[test]
3314    fn test_reference_definition_existing_file() {
3315        // Reference definitions to existing files should NOT trigger warnings
3316        let temp_dir = tempdir().unwrap();
3317        let base_path = temp_dir.path();
3318
3319        // Create an existing file
3320        let exists_path = base_path.join("exists.md");
3321        File::create(&exists_path)
3322            .unwrap()
3323            .write_all(b"# Existing file")
3324            .unwrap();
3325
3326        let content = r#"# Test Document
3327
3328[test]: ./exists.md
3329
3330Use [test] here.
3331"#;
3332
3333        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3334        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3335        let result = rule.check(&ctx).unwrap();
3336
3337        // Should have NO warnings since the file exists
3338        assert!(
3339            result.is_empty(),
3340            "Should not warn about existing file. Got: {result:?}"
3341        );
3342    }
3343
3344    #[test]
3345    fn test_reference_definition_external_url_skipped() {
3346        // Reference definitions with external URLs should be skipped
3347        let temp_dir = tempdir().unwrap();
3348        let base_path = temp_dir.path();
3349
3350        let content = r#"# Test Document
3351
3352[google]: https://google.com
3353[example]: http://example.org
3354[mail]: mailto:test@example.com
3355[ftp]: ftp://files.example.com
3356[local]: ./missing.md
3357
3358Use [google], [example], [mail], [ftp], [local] here.
3359"#;
3360
3361        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3362        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3363        let result = rule.check(&ctx).unwrap();
3364
3365        // Should only warn about the local missing file, not external URLs
3366        assert_eq!(
3367            result.len(),
3368            1,
3369            "Should only warn about local missing file. Got: {result:?}"
3370        );
3371        assert!(
3372            result[0].message.contains("missing.md"),
3373            "Warning should be for missing.md"
3374        );
3375    }
3376
3377    #[test]
3378    fn test_reference_definition_fragment_only_skipped() {
3379        // Reference definitions with fragment-only URLs should be skipped
3380        let temp_dir = tempdir().unwrap();
3381        let base_path = temp_dir.path();
3382
3383        let content = r#"# Test Document
3384
3385[section]: #my-section
3386
3387Use [section] here.
3388"#;
3389
3390        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3391        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3392        let result = rule.check(&ctx).unwrap();
3393
3394        // Should have NO warnings for fragment-only links
3395        assert!(
3396            result.is_empty(),
3397            "Should not warn about fragment-only reference. Got: {result:?}"
3398        );
3399    }
3400
3401    #[test]
3402    fn test_reference_definition_column_position() {
3403        // Test that column position points to the URL in the reference definition
3404        let temp_dir = tempdir().unwrap();
3405        let base_path = temp_dir.path();
3406
3407        // Position markers:     0         1         2
3408        //                       0123456789012345678901
3409        let content = "[ref]: ./missing.md";
3410        //             The URL "./missing.md" starts at 0-indexed position 7
3411        //             which is 1-indexed column 8
3412
3413        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3414        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3415        let result = rule.check(&ctx).unwrap();
3416
3417        assert_eq!(result.len(), 1, "Should have exactly one warning");
3418        assert_eq!(result[0].line, 1, "Should be on line 1");
3419        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3420    }
3421
3422    #[test]
3423    fn test_reference_definition_html_with_md_source() {
3424        // Reference definitions to .html files should pass if corresponding .md source exists
3425        let temp_dir = tempdir().unwrap();
3426        let base_path = temp_dir.path();
3427
3428        // Create guide.md (source file)
3429        let md_file = base_path.join("guide.md");
3430        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3431
3432        let content = r#"# Test Document
3433
3434[guide]: ./guide.html
3435[missing]: ./missing.html
3436
3437Use [guide] and [missing] here.
3438"#;
3439
3440        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3441        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3442        let result = rule.check(&ctx).unwrap();
3443
3444        // guide.html passes (guide.md exists), missing.html fails
3445        assert_eq!(
3446            result.len(),
3447            1,
3448            "Should only warn about missing source. Got: {result:?}"
3449        );
3450        assert!(result[0].message.contains("missing.html"));
3451    }
3452
3453    #[test]
3454    fn test_reference_definition_url_encoded() {
3455        // Reference definitions with URL-encoded paths should be decoded before checking
3456        let temp_dir = tempdir().unwrap();
3457        let base_path = temp_dir.path();
3458
3459        // Create a file with spaces in the name
3460        let file_with_spaces = base_path.join("file with spaces.md");
3461        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3462
3463        let content = r#"# Test Document
3464
3465[spaces]: ./file%20with%20spaces.md
3466[missing]: ./missing%20file.md
3467
3468Use [spaces] and [missing] here.
3469"#;
3470
3471        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3472        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3473        let result = rule.check(&ctx).unwrap();
3474
3475        // Should only warn about the missing file
3476        assert_eq!(
3477            result.len(),
3478            1,
3479            "Should only warn about missing URL-encoded file. Got: {result:?}"
3480        );
3481        assert!(result[0].message.contains("missing%20file.md"));
3482    }
3483
3484    #[test]
3485    fn test_inline_and_reference_both_checked() {
3486        // Both inline links and reference definitions should be checked
3487        let temp_dir = tempdir().unwrap();
3488        let base_path = temp_dir.path();
3489
3490        let content = r#"# Test Document
3491
3492[inline link](./inline-missing.md)
3493[ref]: ./ref-missing.md
3494
3495Use [ref] here.
3496"#;
3497
3498        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3499        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3500        let result = rule.check(&ctx).unwrap();
3501
3502        // Should warn about both the inline link and the reference definition
3503        assert_eq!(
3504            result.len(),
3505            2,
3506            "Should warn about both inline and reference links. Got: {result:?}"
3507        );
3508        assert!(
3509            result.iter().any(|w| w.message.contains("inline-missing.md")),
3510            "Should warn about inline-missing.md"
3511        );
3512        assert!(
3513            result.iter().any(|w| w.message.contains("ref-missing.md")),
3514            "Should warn about ref-missing.md"
3515        );
3516    }
3517
3518    #[test]
3519    fn test_footnote_definitions_not_flagged() {
3520        // Regression test for issue #286: footnote definitions should not be
3521        // treated as reference definitions and flagged as broken links
3522        let rule = MD057ExistingRelativeLinks::default();
3523
3524        let content = r#"# Title
3525
3526A footnote[^1].
3527
3528[^1]: [link](https://www.google.com).
3529"#;
3530
3531        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3532        let result = rule.check(&ctx).unwrap();
3533
3534        assert!(
3535            result.is_empty(),
3536            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3537        );
3538    }
3539
3540    #[test]
3541    fn test_footnote_with_relative_link_inside() {
3542        // Footnotes containing relative links should not be checked
3543        // (the footnote content is not a URL, it's content that may contain links)
3544        let rule = MD057ExistingRelativeLinks::default();
3545
3546        let content = r#"# Title
3547
3548See the footnote[^1].
3549
3550[^1]: Check out [this file](./existing.md) for more info.
3551[^2]: Also see [missing](./does-not-exist.md).
3552"#;
3553
3554        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3555        let result = rule.check(&ctx).unwrap();
3556
3557        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
3558        // but the footnote definition itself should not be treated as a reference definition
3559        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
3560        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
3561        for warning in &result {
3562            assert!(
3563                !warning.message.contains("[this file]"),
3564                "Footnote content should not be treated as URL: {warning:?}"
3565            );
3566            assert!(
3567                !warning.message.contains("[missing]"),
3568                "Footnote content should not be treated as URL: {warning:?}"
3569            );
3570        }
3571    }
3572
3573    #[test]
3574    fn test_mixed_footnotes_and_reference_definitions() {
3575        // Ensure regular reference definitions are still checked while footnotes are skipped
3576        let temp_dir = tempdir().unwrap();
3577        let base_path = temp_dir.path();
3578
3579        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3580
3581        let content = r#"# Title
3582
3583A footnote[^1] and a [ref link][myref].
3584
3585[^1]: This is a footnote with [link](https://example.com).
3586
3587[myref]: ./missing-file.md "This should be checked"
3588"#;
3589
3590        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3591        let result = rule.check(&ctx).unwrap();
3592
3593        // Should only warn about the regular reference definition, not the footnote
3594        assert_eq!(
3595            result.len(),
3596            1,
3597            "Should only warn about the regular reference definition. Got: {result:?}"
3598        );
3599        assert!(
3600            result[0].message.contains("missing-file.md"),
3601            "Should warn about missing-file.md in reference definition"
3602        );
3603    }
3604
3605    #[test]
3606    fn test_absolute_links_ignore_by_default() {
3607        // By default, absolute links are ignored (not validated)
3608        let temp_dir = tempdir().unwrap();
3609        let base_path = temp_dir.path();
3610
3611        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3612
3613        let content = r#"# Links
3614
3615[API docs](/api/v1/users)
3616[Blog post](/blog/2024/release.html)
3617![Logo](/assets/logo.png)
3618
3619[ref]: /docs/reference.md
3620"#;
3621
3622        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3623        let result = rule.check(&ctx).unwrap();
3624
3625        // No warnings - absolute links are ignored by default
3626        assert!(
3627            result.is_empty(),
3628            "Absolute links should be ignored by default. Got: {result:?}"
3629        );
3630    }
3631
3632    #[test]
3633    fn test_absolute_links_warn_config() {
3634        // When configured to warn, absolute links should generate warnings
3635        let temp_dir = tempdir().unwrap();
3636        let base_path = temp_dir.path();
3637
3638        let config = MD057Config {
3639            absolute_links: AbsoluteLinksOption::Warn,
3640            ..Default::default()
3641        };
3642        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3643
3644        let content = r#"# Links
3645
3646[API docs](/api/v1/users)
3647[Blog post](/blog/2024/release.html)
3648"#;
3649
3650        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3651        let result = rule.check(&ctx).unwrap();
3652
3653        // Should have 2 warnings for the 2 absolute links
3654        assert_eq!(
3655            result.len(),
3656            2,
3657            "Should warn about both absolute links. Got: {result:?}"
3658        );
3659        assert!(
3660            result[0].message.contains("cannot be validated locally"),
3661            "Warning should explain why: {}",
3662            result[0].message
3663        );
3664        assert!(
3665            result[0].message.contains("/api/v1/users"),
3666            "Warning should include the link path"
3667        );
3668    }
3669
3670    #[test]
3671    fn test_absolute_links_warn_images() {
3672        // Images with absolute paths should also warn when configured
3673        let temp_dir = tempdir().unwrap();
3674        let base_path = temp_dir.path();
3675
3676        let config = MD057Config {
3677            absolute_links: AbsoluteLinksOption::Warn,
3678            ..Default::default()
3679        };
3680        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3681
3682        let content = r#"# Images
3683
3684![Logo](/assets/logo.png)
3685"#;
3686
3687        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3688        let result = rule.check(&ctx).unwrap();
3689
3690        assert_eq!(
3691            result.len(),
3692            1,
3693            "Should warn about absolute image path. Got: {result:?}"
3694        );
3695        assert!(
3696            result[0].message.contains("/assets/logo.png"),
3697            "Warning should include the image path"
3698        );
3699    }
3700
3701    #[test]
3702    fn test_absolute_links_warn_reference_definitions() {
3703        // Reference definitions with absolute paths should also warn when configured
3704        let temp_dir = tempdir().unwrap();
3705        let base_path = temp_dir.path();
3706
3707        let config = MD057Config {
3708            absolute_links: AbsoluteLinksOption::Warn,
3709            ..Default::default()
3710        };
3711        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3712
3713        let content = r#"# Reference
3714
3715See the [docs][ref].
3716
3717[ref]: /docs/reference.md
3718"#;
3719
3720        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3721        let result = rule.check(&ctx).unwrap();
3722
3723        assert_eq!(
3724            result.len(),
3725            1,
3726            "Should warn about absolute reference definition. Got: {result:?}"
3727        );
3728        assert!(
3729            result[0].message.contains("/docs/reference.md"),
3730            "Warning should include the reference path"
3731        );
3732    }
3733
3734    #[test]
3735    fn test_search_paths_inline_link() {
3736        let temp_dir = tempdir().unwrap();
3737        let base_path = temp_dir.path();
3738
3739        // Create an "assets" directory with an image
3740        let assets_dir = base_path.join("assets");
3741        std::fs::create_dir_all(&assets_dir).unwrap();
3742        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3743
3744        let config = MD057Config {
3745            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3746            ..Default::default()
3747        };
3748        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3749
3750        let content = "# Test\n\n[Photo](photo.png)\n";
3751        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3752        let result = rule.check(&ctx).unwrap();
3753
3754        assert!(
3755            result.is_empty(),
3756            "Should find photo.png via search-paths. Got: {result:?}"
3757        );
3758    }
3759
3760    #[test]
3761    fn test_search_paths_image() {
3762        let temp_dir = tempdir().unwrap();
3763        let base_path = temp_dir.path();
3764
3765        let assets_dir = base_path.join("attachments");
3766        std::fs::create_dir_all(&assets_dir).unwrap();
3767        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3768
3769        let config = MD057Config {
3770            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3771            ..Default::default()
3772        };
3773        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3774
3775        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3776        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3777        let result = rule.check(&ctx).unwrap();
3778
3779        assert!(
3780            result.is_empty(),
3781            "Should find diagram.svg via search-paths. Got: {result:?}"
3782        );
3783    }
3784
3785    #[test]
3786    fn test_search_paths_reference_definition() {
3787        let temp_dir = tempdir().unwrap();
3788        let base_path = temp_dir.path();
3789
3790        let assets_dir = base_path.join("images");
3791        std::fs::create_dir_all(&assets_dir).unwrap();
3792        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3793
3794        let config = MD057Config {
3795            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3796            ..Default::default()
3797        };
3798        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3799
3800        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3801        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3802        let result = rule.check(&ctx).unwrap();
3803
3804        assert!(
3805            result.is_empty(),
3806            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3807        );
3808    }
3809
3810    #[test]
3811    fn test_search_paths_still_warns_when_truly_missing() {
3812        let temp_dir = tempdir().unwrap();
3813        let base_path = temp_dir.path();
3814
3815        let assets_dir = base_path.join("assets");
3816        std::fs::create_dir_all(&assets_dir).unwrap();
3817
3818        let config = MD057Config {
3819            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3820            ..Default::default()
3821        };
3822        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3823
3824        let content = "# Test\n\n![Missing](nonexistent.png)\n";
3825        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3826        let result = rule.check(&ctx).unwrap();
3827
3828        assert_eq!(
3829            result.len(),
3830            1,
3831            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3832        );
3833    }
3834
3835    #[test]
3836    fn test_search_paths_nonexistent_directory() {
3837        let temp_dir = tempdir().unwrap();
3838        let base_path = temp_dir.path();
3839
3840        let config = MD057Config {
3841            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3842            ..Default::default()
3843        };
3844        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3845
3846        let content = "# Test\n\n![Missing](photo.png)\n";
3847        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3848        let result = rule.check(&ctx).unwrap();
3849
3850        assert_eq!(
3851            result.len(),
3852            1,
3853            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3854        );
3855    }
3856
3857    #[test]
3858    fn test_obsidian_attachment_folder_named() {
3859        let temp_dir = tempdir().unwrap();
3860        let vault = temp_dir.path().join("vault");
3861        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3862        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3863        std::fs::create_dir_all(vault.join("notes")).unwrap();
3864
3865        std::fs::write(
3866            vault.join(".obsidian/app.json"),
3867            r#"{"attachmentFolderPath": "Attachments"}"#,
3868        )
3869        .unwrap();
3870        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3871
3872        let notes_dir = vault.join("notes");
3873        let source_file = notes_dir.join("test.md");
3874        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
3875
3876        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3877
3878        let content = "# Test\n\n![Photo](photo.png)\n";
3879        let ctx =
3880            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3881        let result = rule.check(&ctx).unwrap();
3882
3883        assert!(
3884            result.is_empty(),
3885            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3886        );
3887    }
3888
3889    #[test]
3890    fn test_obsidian_attachment_same_folder_as_file() {
3891        let temp_dir = tempdir().unwrap();
3892        let vault = temp_dir.path().join("vault-rf");
3893        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3894        std::fs::create_dir_all(vault.join("notes")).unwrap();
3895
3896        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3897
3898        // Image in the same directory as the file — default behavior, no extra search needed
3899        let notes_dir = vault.join("notes");
3900        let source_file = notes_dir.join("test.md");
3901        std::fs::write(&source_file, "placeholder").unwrap();
3902        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3903
3904        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3905
3906        let content = "# Test\n\n![Photo](photo.png)\n";
3907        let ctx =
3908            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3909        let result = rule.check(&ctx).unwrap();
3910
3911        assert!(
3912            result.is_empty(),
3913            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3914        );
3915    }
3916
3917    #[test]
3918    fn test_obsidian_not_triggered_without_obsidian_flavor() {
3919        let temp_dir = tempdir().unwrap();
3920        let vault = temp_dir.path().join("vault-nf");
3921        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3922        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3923        std::fs::create_dir_all(vault.join("notes")).unwrap();
3924
3925        std::fs::write(
3926            vault.join(".obsidian/app.json"),
3927            r#"{"attachmentFolderPath": "Attachments"}"#,
3928        )
3929        .unwrap();
3930        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3931
3932        let notes_dir = vault.join("notes");
3933        let source_file = notes_dir.join("test.md");
3934        std::fs::write(&source_file, "placeholder").unwrap();
3935
3936        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3937
3938        let content = "# Test\n\n![Photo](photo.png)\n";
3939        // Standard flavor — NOT Obsidian
3940        let ctx =
3941            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3942        let result = rule.check(&ctx).unwrap();
3943
3944        assert_eq!(
3945            result.len(),
3946            1,
3947            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3948        );
3949    }
3950
3951    #[test]
3952    fn test_search_paths_combined_with_obsidian() {
3953        let temp_dir = tempdir().unwrap();
3954        let vault = temp_dir.path().join("vault-combo");
3955        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3956        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3957        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3958        std::fs::create_dir_all(vault.join("notes")).unwrap();
3959
3960        std::fs::write(
3961            vault.join(".obsidian/app.json"),
3962            r#"{"attachmentFolderPath": "Attachments"}"#,
3963        )
3964        .unwrap();
3965        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3966        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3967
3968        let notes_dir = vault.join("notes");
3969        let source_file = notes_dir.join("test.md");
3970        std::fs::write(&source_file, "placeholder").unwrap();
3971
3972        let extra_assets_dir = vault.join("extra-assets");
3973        let config = MD057Config {
3974            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3975            ..Default::default()
3976        };
3977        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
3978
3979        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
3980        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
3981        let ctx =
3982            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3983        let result = rule.check(&ctx).unwrap();
3984
3985        assert!(
3986            result.is_empty(),
3987            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3988        );
3989    }
3990
3991    #[test]
3992    fn test_obsidian_attachment_subfolder_under_file() {
3993        let temp_dir = tempdir().unwrap();
3994        let vault = temp_dir.path().join("vault-sub");
3995        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3996        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3997
3998        std::fs::write(
3999            vault.join(".obsidian/app.json"),
4000            r#"{"attachmentFolderPath": "./assets"}"#,
4001        )
4002        .unwrap();
4003        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4004
4005        let notes_dir = vault.join("notes");
4006        let source_file = notes_dir.join("test.md");
4007        std::fs::write(&source_file, "placeholder").unwrap();
4008
4009        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4010
4011        let content = "# Test\n\n![Photo](photo.png)\n";
4012        let ctx =
4013            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4014        let result = rule.check(&ctx).unwrap();
4015
4016        assert!(
4017            result.is_empty(),
4018            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4019        );
4020    }
4021
4022    #[test]
4023    fn test_obsidian_attachment_vault_root() {
4024        let temp_dir = tempdir().unwrap();
4025        let vault = temp_dir.path().join("vault-root");
4026        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4027        std::fs::create_dir_all(vault.join("notes")).unwrap();
4028
4029        // Empty string = vault root
4030        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4031        std::fs::write(vault.join("photo.png"), "fake").unwrap();
4032
4033        let notes_dir = vault.join("notes");
4034        let source_file = notes_dir.join("test.md");
4035        std::fs::write(&source_file, "placeholder").unwrap();
4036
4037        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4038
4039        let content = "# Test\n\n![Photo](photo.png)\n";
4040        let ctx =
4041            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4042        let result = rule.check(&ctx).unwrap();
4043
4044        assert!(
4045            result.is_empty(),
4046            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4047        );
4048    }
4049
4050    #[test]
4051    fn test_search_paths_multiple_directories() {
4052        let temp_dir = tempdir().unwrap();
4053        let base_path = temp_dir.path();
4054
4055        let dir_a = base_path.join("dir-a");
4056        let dir_b = base_path.join("dir-b");
4057        std::fs::create_dir_all(&dir_a).unwrap();
4058        std::fs::create_dir_all(&dir_b).unwrap();
4059        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4060        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4061
4062        let config = MD057Config {
4063            search_paths: vec![
4064                dir_a.to_string_lossy().into_owned(),
4065                dir_b.to_string_lossy().into_owned(),
4066            ],
4067            ..Default::default()
4068        };
4069        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4070
4071        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
4072        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4073        let result = rule.check(&ctx).unwrap();
4074
4075        assert!(
4076            result.is_empty(),
4077            "Should find files across multiple search paths. Got: {result:?}"
4078        );
4079    }
4080
4081    /// MD057 validates every link target in `check()`, so its `cross_file_check`
4082    /// deliberately reports nothing: emitting there too would double every broken
4083    /// link warning.
4084    ///
4085    /// The target here does not exist anywhere the rule would look, so a
4086    /// `cross_file_check` that started resolving paths would report it and fail
4087    /// this test. The paired `check()` call is the positive control proving the
4088    /// link really is broken, which is what keeps the empty result meaningful.
4089    #[test]
4090    fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4091        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4092
4093        let temp_dir = tempdir().unwrap();
4094        let base_path = temp_dir.path();
4095
4096        let file_path = base_path.join("README.md");
4097        let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4098        std::fs::write(&file_path, content).unwrap();
4099
4100        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4101
4102        let ctx = crate::lint_context::LintContext::new(
4103            content,
4104            crate::config::MarkdownFlavor::Standard,
4105            Some(file_path.clone()),
4106        );
4107        let per_file = rule.check(&ctx).unwrap();
4108        assert_eq!(
4109            per_file.len(),
4110            1,
4111            "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4112        );
4113
4114        let mut file_index = FileIndex::default();
4115        file_index.cross_file_links.push(CrossFileLinkIndex {
4116            target_path: "missing-guide.md".to_string(),
4117            fragment: String::new(),
4118            line: 3,
4119            column: 1,
4120            origin: LinkOrigin::Body,
4121        });
4122
4123        let result = rule
4124            .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4125            .unwrap();
4126
4127        assert!(
4128            result.is_empty(),
4129            "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4130        );
4131    }
4132
4133    #[test]
4134    fn test_check_clears_stale_cache() {
4135        // Verify that check() resets the file existence cache so stale entries from
4136        // a previous lint cycle do not suppress valid warnings.
4137        let temp_dir = tempdir().unwrap();
4138        let base_path = temp_dir.path();
4139
4140        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4141
4142        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
4143        let phantom_path = base_path.join("phantom.md");
4144        {
4145            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4146            cache.insert(phantom_path.clone(), true);
4147        }
4148
4149        let content = "[phantom](phantom.md)\n";
4150        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4151        let warnings = rule.check(&ctx).unwrap();
4152
4153        // check() must reset the cache; stale "exists=true" must not suppress the warning.
4154        assert_eq!(
4155            warnings.len(),
4156            1,
4157            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4158        );
4159        assert!(warnings[0].message.contains("phantom.md"));
4160    }
4161
4162    #[test]
4163    fn test_check_does_not_carry_over_cache_between_runs() {
4164        // Two consecutive check() calls should each start with a fresh cache.
4165        let temp_dir = tempdir().unwrap();
4166        let base_path = temp_dir.path();
4167
4168        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4169
4170        let content = "[missing](nonexistent.md)\n";
4171        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4172
4173        // First run: file doesn't exist — warning expected.
4174        let warnings_1 = rule.check(&ctx).unwrap();
4175        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4176
4177        // Inject a stale "exists = true" entry for the resolved path.
4178        let nonexistent_path = base_path.join("nonexistent.md");
4179        {
4180            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4181            cache.insert(nonexistent_path.clone(), true);
4182        }
4183
4184        // Second run: cache says file exists, but check() should reset it first.
4185        let warnings_2 = rule.check(&ctx).unwrap();
4186        assert_eq!(
4187            warnings_2.len(),
4188            1,
4189            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4190        );
4191    }
4192
4193    // --- Bug #631: duplicate warnings for broken relative links ---
4194
4195    /// Regression test: a single broken relative link must produce exactly one
4196    /// warning across both check() and cross_file_check(). Previously, each
4197    /// code path emitted an identical warning independently, causing duplicates.
4198    #[test]
4199    fn test_no_duplicate_warnings_for_broken_relative_link() {
4200        use crate::workspace_index::WorkspaceIndex;
4201
4202        let temp_dir = tempdir().unwrap();
4203        let base_path = temp_dir.path();
4204
4205        // The broken link target does NOT exist on disk.
4206        let source_file = base_path.join("index.md");
4207        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4208
4209        let content = "[broken](does/not/exist.md)\n";
4210
4211        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4212
4213        // Collect warnings from check() (per-file path)
4214        let ctx = crate::lint_context::LintContext::new(
4215            content,
4216            crate::config::MarkdownFlavor::Standard,
4217            Some(source_file.clone()),
4218        );
4219        let check_warnings = rule.check(&ctx).unwrap();
4220
4221        // Collect warnings from cross_file_check() (workspace-index path)
4222        let mut file_index = FileIndex::new();
4223        rule.contribute_to_index(&ctx, &mut file_index);
4224        let workspace_index = WorkspaceIndex::new();
4225        let cross_warnings = rule
4226            .cross_file_check(&source_file, &file_index, &workspace_index)
4227            .unwrap();
4228
4229        let total = check_warnings.len() + cross_warnings.len();
4230        assert_eq!(
4231            total, 1,
4232            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4233             check={check_warnings:?}, cross={cross_warnings:?}"
4234        );
4235    }
4236
4237    // --- Bug #632: absolute directory links incorrectly flagged ---
4238
4239    /// With absolute-links = "relative_to_roots", links to existing targets must
4240    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
4241    #[test]
4242    fn test_absolute_dir_link_accepted_relative_to_roots() {
4243        let temp_dir = tempdir().unwrap();
4244        let root = temp_dir.path();
4245
4246        // Create directory `d` with a file inside (but no index.md)
4247        let dir_d = root.join("d");
4248        std::fs::create_dir_all(&dir_d).unwrap();
4249        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4250
4251        // Content exercises all four matrix cells:
4252        //   relative file, relative dir, absolute file, absolute dir
4253        let content = "\
4254[absolute dir](/d)\n\
4255[relative dir](d)\n\
4256[absolute file](/d/foo.md)\n\
4257[relative file](d/foo.md)\n";
4258
4259        let config = MD057Config {
4260            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4261            roots: vec![],
4262            ..Default::default()
4263        };
4264        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4265
4266        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4267        let result = rule.check(&ctx).unwrap();
4268
4269        assert!(
4270            result.is_empty(),
4271            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4272        );
4273    }
4274
4275    /// A directory link with a trailing slash and no index.md should be reported
4276    /// as invalid under relative_to_roots (docs-convention: trailing slash implies index.md).
4277    #[test]
4278    fn test_absolute_trailing_slash_dir_link_requires_index() {
4279        let temp_dir = tempdir().unwrap();
4280        let root = temp_dir.path();
4281
4282        // Create directory `d` WITHOUT index.md
4283        let dir_d = root.join("d");
4284        std::fs::create_dir_all(&dir_d).unwrap();
4285        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4286
4287        // Trailing slash signals "this is a directory index" — index.md must exist.
4288        let content = "[dir with slash](/d/)\n";
4289
4290        let config = MD057Config {
4291            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4292            roots: vec![],
4293            ..Default::default()
4294        };
4295        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4296
4297        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4298        let result = rule.check(&ctx).unwrap();
4299
4300        assert_eq!(
4301            result.len(),
4302            1,
4303            "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
4304        );
4305    }
4306
4307    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
4308    /// is absent. This is tested via the full check() path with RelativeToDocs config
4309    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
4310    #[test]
4311    fn test_docs_dir_variant_still_enforces_index_md() {
4312        let temp_dir = tempdir().unwrap();
4313        let root = temp_dir.path();
4314
4315        // Create a minimal mkdocs.yml pointing at a "docs" directory
4316        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4317
4318        // Create docs/section/ WITHOUT index.md
4319        let docs_dir = root.join("docs");
4320        std::fs::create_dir_all(&docs_dir).unwrap();
4321        let section_dir = docs_dir.join("section");
4322        std::fs::create_dir_all(&section_dir).unwrap();
4323        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4324
4325        // Create the source markdown file inside docs/
4326        let source_file = docs_dir.join("index.md");
4327        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4328
4329        let config = MD057Config {
4330            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4331            ..Default::default()
4332        };
4333        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4334
4335        let content = "[sec](/section)\n";
4336        let ctx = crate::lint_context::LintContext::new(
4337            content,
4338            crate::config::MarkdownFlavor::Standard,
4339            Some(source_file.clone()),
4340        );
4341        let result = rule.check(&ctx).unwrap();
4342
4343        // MkDocs enforces index.md for directory links, so this should be flagged.
4344        assert_eq!(
4345            result.len(),
4346            1,
4347            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4348        );
4349        assert!(
4350            result[0].message.contains("index.md") || result[0].message.contains("section"),
4351            "Message should mention the directory or missing index.md: {}",
4352            result[0].message
4353        );
4354    }
4355
4356    /// Regression test for the edge case where a trailing-slash directory URL has a
4357    /// fragment suffix (e.g. `/guide/#intro`). After stripping the fragment, the
4358    /// decoded path is `guide/` (ends with `/`), but `is_directory_link` was computed
4359    /// from `url.ends_with('/')` which is false when the URL ends with `#intro`.
4360    /// The fix must still treat such links as directory links and require index.md.
4361    #[test]
4362    fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4363        let temp_dir = tempdir().unwrap();
4364        let root = temp_dir.path();
4365
4366        // Create directory `guide` WITHOUT index.md
4367        let guide_dir = root.join("guide");
4368        std::fs::create_dir_all(&guide_dir).unwrap();
4369        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4370
4371        // /guide/#intro has a trailing slash before the fragment — must require index.md
4372        let content = "[guide with fragment](/guide/#intro)\n";
4373
4374        let config = MD057Config {
4375            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4376            roots: vec![],
4377            ..Default::default()
4378        };
4379        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4380        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4381        let result = rule.check(&ctx).unwrap();
4382
4383        assert_eq!(
4384            result.len(),
4385            1,
4386            "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4387        );
4388    }
4389}
4390
4391#[cfg(test)]
4392mod self_referential_links_tests {
4393    use super::*;
4394    use tempfile::tempdir;
4395
4396    /// A document written to `dir/<name>`, checked as itself.
4397    fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4398        let source_file = dir.join(name);
4399        std::fs::write(&source_file, content).unwrap();
4400        let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4401        let ctx =
4402            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4403        rule.check(&ctx).unwrap()
4404    }
4405
4406    fn enabled() -> MD057Config {
4407        MD057Config {
4408            self_referential_links: true,
4409            ..Default::default()
4410        }
4411    }
4412
4413    #[test]
4414    fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4415        let temp_dir = tempdir().unwrap();
4416        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4417        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4418
4419        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4420        assert_eq!(
4421            result[0].message,
4422            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4423        );
4424        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4425        assert_eq!(fix.replacement, "#level-2-heading");
4426        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4427    }
4428
4429    #[test]
4430    fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4431        let temp_dir = tempdir().unwrap();
4432        let content = "# Title\n\nSee [this file](test.md).\n";
4433        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4434
4435        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4436        assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4437        assert!(
4438            result[0].fix.is_none(),
4439            "Dropping the link would change the document, so there is no fix"
4440        );
4441    }
4442
4443    #[test]
4444    fn test_the_check_is_off_by_default() {
4445        let temp_dir = tempdir().unwrap();
4446        let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4447        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4448
4449        assert!(result.is_empty(), "Off by default. Got: {result:?}");
4450    }
4451
4452    #[test]
4453    fn test_a_link_to_another_file_is_left_alone() {
4454        let temp_dir = tempdir().unwrap();
4455        std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4456        let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4457        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4458
4459        assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4460    }
4461
4462    #[test]
4463    fn test_a_self_link_written_with_traversal_reports_once() {
4464        let temp_dir = tempdir().unwrap();
4465        let sub_dir = temp_dir.path().join("sub");
4466        std::fs::create_dir_all(&sub_dir).unwrap();
4467
4468        let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4469        let config = MD057Config {
4470            self_referential_links: true,
4471            compact_paths: true,
4472            ..Default::default()
4473        };
4474        let result = check_as_file(&sub_dir, "test.md", content, config);
4475
4476        assert_eq!(
4477            result.len(),
4478            1,
4479            "A compacted path would still be a link back to this file. Got: {result:?}"
4480        );
4481        assert_eq!(
4482            result[0].message,
4483            "Relative link '../sub/test.md' points to the file it is in"
4484        );
4485    }
4486
4487    #[test]
4488    fn test_compact_paths_still_reports_a_link_to_another_file() {
4489        let temp_dir = tempdir().unwrap();
4490        let sub_dir = temp_dir.path().join("sub");
4491        std::fs::create_dir_all(&sub_dir).unwrap();
4492        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4493
4494        let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4495        let config = MD057Config {
4496            self_referential_links: true,
4497            compact_paths: true,
4498            ..Default::default()
4499        };
4500        let result = check_as_file(&sub_dir, "test.md", content, config);
4501
4502        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4503        assert_eq!(
4504            result[0].message,
4505            "Relative link '../sub/other.md' can be simplified to 'other.md'"
4506        );
4507    }
4508
4509    #[test]
4510    fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4511        let temp_dir = tempdir().unwrap();
4512        let content = "# Title\n\nSee [this file](test#title).\n";
4513        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4514
4515        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4516        assert_eq!(
4517            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4518            Some("#title"),
4519            "Got: {result:?}"
4520        );
4521    }
4522
4523    #[test]
4524    fn test_a_reference_definition_pointing_at_its_own_file() {
4525        let temp_dir = tempdir().unwrap();
4526        let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4527        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4528
4529        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4530        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4531        assert_eq!(fix.replacement, "#level-2-heading");
4532        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4533    }
4534
4535    #[test]
4536    fn test_a_reference_definition_whose_label_repeats_the_destination() {
4537        let temp_dir = tempdir().unwrap();
4538        let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4539        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4540
4541        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4542        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4543        // The label reads the same as the destination, so an unanchored search
4544        // would rewrite the label and orphan the usage above.
4545        assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4546        let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4547            .fix(&crate::lint_context::LintContext::new(
4548                content,
4549                crate::config::MarkdownFlavor::Standard,
4550                Some(temp_dir.path().join("test.md")),
4551            ))
4552            .unwrap();
4553        assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4554        assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4555    }
4556
4557    #[test]
4558    fn test_a_self_link_resolved_through_a_search_path() {
4559        let temp_dir = tempdir().unwrap();
4560        let guide_dir = temp_dir.path().join("docs/guide");
4561        std::fs::create_dir_all(&guide_dir).unwrap();
4562        let config = MD057Config {
4563            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4564            ..enabled()
4565        };
4566        let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4567        let result = check_as_file(&guide_dir, "test.md", content, config);
4568
4569        assert_eq!(
4570            result.len(),
4571            1,
4572            "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4573        );
4574        assert_eq!(
4575            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4576            Some("#title"),
4577            "Got: {result:?}"
4578        );
4579    }
4580
4581    #[test]
4582    fn test_a_target_next_to_the_document_outranks_a_search_path() {
4583        let temp_dir = tempdir().unwrap();
4584        let guide_dir = temp_dir.path().join("docs/guide");
4585        std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4586        std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4587        let config = MD057Config {
4588            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4589            ..enabled()
4590        };
4591        let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4592        let result = check_as_file(&guide_dir, "test.md", content, config);
4593
4594        assert!(
4595            result.is_empty(),
4596            "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4597        );
4598    }
4599
4600    #[test]
4601    fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4602        let temp_dir = tempdir().unwrap();
4603        let content = "# Title\n\n![not a navigation link](test.md)\n";
4604        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4605
4606        assert!(
4607            result.is_empty(),
4608            "An image is not a link the reader follows. Got: {result:?}"
4609        );
4610    }
4611
4612    #[test]
4613    fn test_a_query_string_is_reported_without_a_suggestion() {
4614        let temp_dir = tempdir().unwrap();
4615        let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4616        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4617
4618        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4619        assert!(
4620            result[0].fix.is_none(),
4621            "A query does not survive losing its path. Got: {result:?}"
4622        );
4623    }
4624
4625    #[test]
4626    fn test_fix_rewrites_the_document_and_settles() {
4627        let temp_dir = tempdir().unwrap();
4628        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4629        let source_file = temp_dir.path().join("test.md");
4630        std::fs::write(&source_file, content).unwrap();
4631
4632        let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4633        let ctx = crate::lint_context::LintContext::new(
4634            content,
4635            crate::config::MarkdownFlavor::Standard,
4636            Some(source_file.clone()),
4637        );
4638        let fixed = rule.fix(&ctx).unwrap();
4639        assert_eq!(
4640            fixed,
4641            "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4642        );
4643
4644        let refixed =
4645            crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4646        assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4647    }
4648
4649    #[test]
4650    fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4651        let unfixable = MD057ExistingRelativeLinks::default();
4652        assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4653
4654        let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4655        assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4656    }
4657
4658    #[test]
4659    fn test_the_option_is_read_from_kebab_and_snake_case() {
4660        let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4661        assert!(kebab.self_referential_links);
4662
4663        let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4664        assert!(snake.self_referential_links);
4665    }
4666
4667    fn front_matter_checked() -> MD057Config {
4668        MD057Config {
4669            check_frontmatter: true,
4670            ..Default::default()
4671        }
4672    }
4673
4674    #[test]
4675    fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4676        let temp_dir = tempdir().unwrap();
4677        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4678        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4679
4680        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4681        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4682        assert_eq!(result[0].line, 2);
4683        assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4684        assert_eq!(result[0].end_column, 23);
4685    }
4686
4687    #[test]
4688    fn test_frontmatter_paths_are_not_checked_by_default() {
4689        let temp_dir = tempdir().unwrap();
4690        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4691        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4692
4693        assert!(
4694            result.is_empty(),
4695            "Frontmatter is only checked on request. Got: {result:?}"
4696        );
4697    }
4698
4699    #[test]
4700    fn test_an_existing_frontmatter_path_is_not_reported() {
4701        let temp_dir = tempdir().unwrap();
4702        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4703        let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4704        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4705
4706        assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4707        assert_eq!(result[0].line, 3);
4708    }
4709
4710    #[test]
4711    fn test_an_ignored_frontmatter_field_is_not_checked() {
4712        let temp_dir = tempdir().unwrap();
4713        let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4714        let config = MD057Config {
4715            check_frontmatter: true,
4716            ignore_frontmatter_fields: vec!["Image".to_string()],
4717            ..Default::default()
4718        };
4719        let result = check_as_file(temp_dir.path(), "test.md", content, config);
4720
4721        assert_eq!(
4722            result.len(),
4723            1,
4724            "The ignored field is skipped and the other is not. Got: {result:?}"
4725        );
4726        assert_eq!(result[0].line, 3);
4727    }
4728
4729    #[test]
4730    fn test_an_external_frontmatter_url_is_not_reported() {
4731        let temp_dir = tempdir().unwrap();
4732        let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4733        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4734
4735        assert!(
4736            result.is_empty(),
4737            "An external URL has no local target. Got: {result:?}"
4738        );
4739    }
4740
4741    #[test]
4742    fn test_a_frontmatter_fragment_is_left_to_md051() {
4743        let temp_dir = tempdir().unwrap();
4744        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4745        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4746
4747        assert!(
4748            result.is_empty(),
4749            "A fragment names a heading, not a file. Got: {result:?}"
4750        );
4751    }
4752
4753    #[test]
4754    fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4755        let temp_dir = tempdir().unwrap();
4756        let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4757
4758        let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4759        assert!(
4760            ignored.is_empty(),
4761            "Absolute paths are ignored by default. Got: {ignored:?}"
4762        );
4763
4764        let warning_config = MD057Config {
4765            check_frontmatter: true,
4766            absolute_links: AbsoluteLinksOption::Warn,
4767            ..Default::default()
4768        };
4769        let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4770        assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4771        assert_eq!(
4772            warned[0].message,
4773            "Absolute link '/docs/guide.md' cannot be validated locally"
4774        );
4775    }
4776
4777    #[test]
4778    fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4779        let temp_dir = tempdir().unwrap();
4780        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4781        let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4782        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4783
4784        assert_eq!(
4785            result.len(),
4786            1,
4787            "A query names no file, so only the missing target is reported. Got: {result:?}"
4788        );
4789        assert_eq!(result[0].line, 2);
4790        assert_eq!(
4791            result[0].message,
4792            "Relative link 'docs/missing.md?raw=true' does not exist"
4793        );
4794    }
4795
4796    #[test]
4797    fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4798        let temp_dir = tempdir().unwrap();
4799        let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4800        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4801
4802        assert!(
4803            result.is_empty(),
4804            "Only path-shaped values are destinations. Got: {result:?}"
4805        );
4806    }
4807}