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