Skip to main content

rumdl_lib/rules/
md051_link_fragments.rs

1use crate::rule::{CrossFileScope, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::anchor_styles::AnchorStyle;
4use crate::utils::frontmatter_values;
5use crate::utils::range_utils::byte_to_char_count;
6use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
7use pulldown_cmark::LinkType;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::{Component, Path, PathBuf};
12use std::sync::LazyLock;
13
14/// Configuration for MD051 (Link fragments)
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub struct MD051Config {
18    /// Anchor generation style to match the target platform
19    #[serde(default, alias = "anchor_style")]
20    pub anchor_style: AnchorStyle,
21
22    /// Match link fragments against headings case-insensitively.
23    ///
24    /// rumdl defaults to `true` (permissive matching), which deviates from
25    /// markdownlint's default of `false`. Set this to `false` for strict
26    /// markdownlint parity.
27    #[serde(default = "default_ignore_case", alias = "ignore_case")]
28    pub ignore_case: bool,
29
30    /// Optional regex applied to the fragment text (without the leading `#`).
31    /// Fragments that match are skipped — useful for runtime-generated anchors
32    /// (e.g., footnote IDs) that aren't visible to the linter.
33    #[serde(default, alias = "ignored_pattern")]
34    pub ignored_pattern: Option<String>,
35
36    /// Also check fragments in path-shaped frontmatter values.
37    ///
38    /// Off by default, because frontmatter has no syntax marking a value as a
39    /// link: a path-shaped value is only a guess at one. Static-site generators
40    /// also resolve frontmatter paths from the site root rather than the
41    /// document's own directory, so checking them like body links reports
42    /// working paths as broken.
43    ///
44    /// Enable it for projects whose frontmatter links really are relative to
45    /// the document, and use `ignore-frontmatter-fields` for the keys that are
46    /// not.
47    ///
48    /// Example:
49    /// ```toml
50    /// [MD051]
51    /// check-frontmatter = true
52    /// ignore-frontmatter-fields = ["image", "cover"]
53    /// ```
54    #[serde(default)]
55    pub check_frontmatter: bool,
56
57    /// Top-level frontmatter keys whose values are not checked. Matched
58    /// case-insensitively. A parent key excludes its whole subtree. Applies
59    /// only when `check-frontmatter` is enabled.
60    #[serde(default)]
61    pub ignore_frontmatter_fields: Vec<String>,
62}
63
64fn default_ignore_case() -> bool {
65    true
66}
67
68impl Default for MD051Config {
69    fn default() -> Self {
70        Self {
71            anchor_style: AnchorStyle::default(),
72            ignore_case: true,
73            ignored_pattern: None,
74            check_frontmatter: false,
75            ignore_frontmatter_fields: Vec::new(),
76        }
77    }
78}
79
80impl RuleConfig for MD051Config {
81    const RULE_NAME: &'static str = "MD051";
82}
83// HTML tags with id or name attributes (supports any HTML element, not just <a>)
84// This pattern only captures the first id/name attribute in a tag
85static HTML_ANCHOR_PATTERN: LazyLock<Regex> =
86    LazyLock::new(|| Regex::new(r#"\b(?:id|name)\s*=\s*["']([^"']+)["']"#).unwrap());
87
88// Attribute anchor pattern for kramdown/MkDocs { #id } syntax
89// Matches {#id} or { #id } with optional spaces, supports multiple anchors
90// Also supports classes and attributes: { #id .class key=value }
91static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
92    LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z0-9_][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
93
94// Material for MkDocs setting anchor pattern: <!-- md:setting NAME -->
95// Used in headings to generate anchors for configuration option references
96static MD_SETTING_PATTERN: LazyLock<Regex> =
97    LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
98
99/// Normalize a path by resolving . and .. components
100fn normalize_path(path: &Path) -> PathBuf {
101    let mut result = PathBuf::new();
102    for component in path.components() {
103        match component {
104            Component::CurDir => {} // Skip .
105            Component::ParentDir => {
106                result.pop(); // Go up one level for ..
107            }
108            c => result.push(c.as_os_str()),
109        }
110    }
111    result
112}
113
114/// Rule MD051: Link fragments
115///
116/// See [docs/md051.md](../../docs/md051.md) for full documentation, configuration, and examples.
117///
118/// This rule validates that link anchors (the part after #) point to existing headings.
119/// Supports both same-document anchors and cross-file fragment links when linting a workspace.
120#[derive(Clone)]
121pub struct MD051LinkFragments {
122    config: MD051Config,
123    /// Pre-compiled `ignored_pattern` regex. `None` if the user did not set the
124    /// option, or if the pattern failed to compile (a `log::warn!` is emitted
125    /// once at construction time so the user can fix the config).
126    ignored_pattern_regex: Option<Regex>,
127    /// `ignore_frontmatter_fields` lowercased for case-insensitive matching.
128    ignored_front_matter_fields: HashSet<String>,
129}
130
131/// Anchor sets extracted from a single document, with parallel lowercase and
132/// case-preserving storage. The `*_exact` sets are empty unless
133/// `ignore_case = false` so the default permissive path costs no extra
134/// allocations.
135struct AnchorSets {
136    markdown_headings: HashSet<String>,
137    markdown_headings_exact: HashSet<String>,
138    html_anchors: HashSet<String>,
139    html_anchors_exact: HashSet<String>,
140}
141
142impl Default for MD051LinkFragments {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl MD051LinkFragments {
149    pub fn new() -> Self {
150        Self::from_config_struct(MD051Config::default())
151    }
152
153    /// Create with specific anchor style (other options use defaults)
154    pub fn with_anchor_style(style: AnchorStyle) -> Self {
155        Self::from_config_struct(MD051Config {
156            anchor_style: style,
157            ..MD051Config::default()
158        })
159    }
160
161    /// Create from a fully-populated config struct.
162    ///
163    /// Compiles `ignored_pattern` once. An invalid regex is logged via
164    /// `log::warn!` and the rule falls back to "no filter" so linting still
165    /// works rather than silently swallowing every fragment.
166    pub fn from_config_struct(config: MD051Config) -> Self {
167        let ignored_pattern_regex = config
168            .ignored_pattern
169            .as_deref()
170            .and_then(|pattern| match Regex::new(pattern) {
171                Ok(re) => Some(re),
172                Err(err) => {
173                    log::warn!(
174                        "Invalid ignored_pattern regex for MD051 ('{pattern}'): {err}. Falling back to no filter."
175                    );
176                    None
177                }
178            });
179        let ignored_front_matter_fields = config
180            .ignore_frontmatter_fields
181            .iter()
182            .map(|field| field.to_lowercase())
183            .collect();
184        Self {
185            config,
186            ignored_pattern_regex,
187            ignored_front_matter_fields,
188        }
189    }
190
191    /// Parse ATX heading content from blockquote inner text.
192    /// Strips the leading `# ` marker, optional closing hash sequence, and extracts custom IDs.
193    /// Returns `(clean_text, custom_id)` or None if not a heading.
194    fn parse_blockquote_heading(bq_content: &str) -> Option<(String, Option<String>)> {
195        crate::utils::header_id_utils::parse_blockquote_atx_heading(bq_content)
196    }
197
198    /// Insert a heading fragment with deduplication.
199    /// When `use_underscore_dedup` is true (Python-Markdown/MkDocs), the primary suffix
200    /// uses `_N` and `-N` is registered as a fallback. Otherwise, only `-N` is used.
201    ///
202    /// Empty fragments (from CJK-only headings) are handled specially for Python-Markdown:
203    /// the first empty slug gets `_1`, the second `_2`, etc. (matching Python-Markdown's
204    /// `unique()` function which always enters the dedup loop for falsy IDs).
205    fn insert_deduplicated_fragment(
206        fragment: String,
207        fragment_counts: &mut HashMap<String, usize>,
208        markdown_headings: &mut HashSet<String>,
209        mut markdown_headings_exact: Option<&mut HashSet<String>>,
210        use_underscore_dedup: bool,
211    ) {
212        // Slugs from generate_fragment are already lowercase, so the exact set
213        // ends up identical to the lowercased set for slugs. The exact set is
214        // only meaningfully different for case-preserving custom IDs (handled
215        // by the caller). Skipping the parallel inserts when the caller passes
216        // None avoids unnecessary allocations on the default ignore_case=true path.
217        let mut also_insert_exact = |form: &str| {
218            if let Some(set) = markdown_headings_exact.as_deref_mut() {
219                set.insert(form.to_string());
220            }
221        };
222
223        if fragment.is_empty() {
224            if !use_underscore_dedup {
225                return;
226            }
227            // Python-Markdown: empty slug → _1, _2, _3, ...
228            let count = fragment_counts.entry(fragment).or_insert(0);
229            *count += 1;
230            let formed = format!("_{count}");
231            also_insert_exact(&formed);
232            markdown_headings.insert(formed);
233            return;
234        }
235        if let Some(count) = fragment_counts.get_mut(&fragment) {
236            let suffix = *count;
237            *count += 1;
238            if use_underscore_dedup {
239                // Python-Markdown primary: heading_1, heading_2
240                let underscore_form = format!("{fragment}_{suffix}");
241                also_insert_exact(&underscore_form);
242                markdown_headings.insert(underscore_form);
243                // Also accept GitHub-style for compatibility
244                let dash_form = format!("{fragment}-{suffix}");
245                also_insert_exact(&dash_form);
246                markdown_headings.insert(dash_form);
247            } else {
248                // GitHub-style: heading-1, heading-2
249                let form = format!("{fragment}-{suffix}");
250                also_insert_exact(&form);
251                markdown_headings.insert(form);
252            }
253        } else {
254            fragment_counts.insert(fragment.clone(), 1);
255            also_insert_exact(&fragment);
256            markdown_headings.insert(fragment);
257        }
258    }
259
260    /// Add a heading to the cross-file index with proper deduplication.
261    /// When `use_underscore_dedup` is true (Python-Markdown/MkDocs), the primary anchor
262    /// uses `_N` and `-N` is registered as a fallback alias.
263    ///
264    /// Empty fragments (from CJK-only headings) get `_1`, `_2`, etc. in Python-Markdown mode.
265    fn add_heading_to_index(
266        fragment: &str,
267        text: &str,
268        custom_anchor: Option<String>,
269        line: usize,
270        fragment_counts: &mut HashMap<String, usize>,
271        file_index: &mut FileIndex,
272        use_underscore_dedup: bool,
273    ) {
274        if fragment.is_empty() {
275            if !use_underscore_dedup {
276                return;
277            }
278            // Python-Markdown: empty slug → _1, _2, _3, ...
279            let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
280            *count += 1;
281            file_index.add_heading(HeadingIndex {
282                text: text.to_string(),
283                auto_anchor: format!("_{count}"),
284                custom_anchor,
285                line,
286                is_setext: false,
287            });
288            return;
289        }
290        if let Some(count) = fragment_counts.get_mut(fragment) {
291            let suffix = *count;
292            *count += 1;
293            let (primary, alias) = if use_underscore_dedup {
294                // Python-Markdown primary: heading_1; GitHub fallback: heading-1
295                (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
296            } else {
297                // GitHub-style primary: heading-1
298                (format!("{fragment}-{suffix}"), None)
299            };
300            file_index.add_heading(HeadingIndex {
301                text: text.to_string(),
302                auto_anchor: primary,
303                custom_anchor,
304                line,
305                is_setext: false,
306            });
307            if let Some(alias_anchor) = alias {
308                let heading_idx = file_index.headings.len() - 1;
309                file_index.add_anchor_alias(&alias_anchor, heading_idx);
310            }
311        } else {
312            fragment_counts.insert(fragment.to_string(), 1);
313            file_index.add_heading(HeadingIndex {
314                text: text.to_string(),
315                auto_anchor: fragment.to_string(),
316                custom_anchor,
317                line,
318                is_setext: false,
319            });
320        }
321    }
322
323    /// Extract all valid heading anchors from the document.
324    ///
325    /// Returns parallel lowercase + case-preserving sets so the same-document
326    /// check can honor `ignore_case` consistently with cross-file lookups.
327    /// The `*_exact` sets are only populated when `ignore_case = false` to
328    /// avoid unnecessary allocations on the default permissive path.
329    fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
330        let track_exact = !self.config.ignore_case;
331        let mut markdown_headings = HashSet::with_capacity(32);
332        let mut markdown_headings_exact = if track_exact {
333            HashSet::with_capacity(32)
334        } else {
335            HashSet::new()
336        };
337        let mut html_anchors = HashSet::with_capacity(16);
338        let mut html_anchors_exact = if track_exact {
339            HashSet::with_capacity(16)
340        } else {
341            HashSet::new()
342        };
343        let mut fragment_counts = std::collections::HashMap::new();
344        let use_underscore_dedup = self.config.anchor_style == AnchorStyle::PythonMarkdown;
345
346        for line_info in &ctx.lines {
347            if line_info.in_front_matter {
348                continue;
349            }
350
351            // Skip code blocks for anchor extraction
352            if line_info.in_code_block {
353                continue;
354            }
355
356            let content = line_info.content(ctx.content);
357            let bytes = content.as_bytes();
358
359            // Extract HTML anchor tags with id/name attributes
360            if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
361                // HTML spec: only the first id attribute per element is valid
362                // Process element by element to handle multiple id attributes correctly
363                let mut pos = 0;
364                while pos < content.len() {
365                    if let Some(start) = content[pos..].find('<') {
366                        let tag_start = pos + start;
367                        if let Some(end) = content[tag_start..].find('>') {
368                            let tag_end = tag_start + end + 1;
369                            let tag = &content[tag_start..tag_end];
370
371                            // Extract first id or name attribute from this tag
372                            if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
373                                let matched_text = caps.as_str();
374                                if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
375                                    && let Some(id_match) = caps.get(1)
376                                {
377                                    let id = id_match.as_str();
378                                    if !id.is_empty() {
379                                        html_anchors.insert(id.to_lowercase());
380                                        if track_exact {
381                                            html_anchors_exact.insert(id.to_string());
382                                        }
383                                    }
384                                }
385                            }
386                            pos = tag_end;
387                        } else {
388                            break;
389                        }
390                    } else {
391                        break;
392                    }
393                }
394            }
395
396            // Extract attribute anchors { #id } from non-heading lines
397            // Headings already have custom_id extracted below
398            if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
399                for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
400                    if let Some(id_match) = caps.get(1) {
401                        let id = id_match.as_str();
402                        markdown_headings.insert(id.to_lowercase());
403                        if track_exact {
404                            markdown_headings_exact.insert(id.to_string());
405                        }
406                    }
407                }
408            }
409
410            // Extract heading anchors from blockquote content
411            // Blockquote headings (e.g., "> ## Heading") are not detected by the main heading parser
412            // because the regex operates on the full line, but they still generate valid anchors
413            if line_info.heading.is_none()
414                && let Some(bq) = &line_info.blockquote
415                && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
416            {
417                if let Some(id) = custom_id {
418                    markdown_headings.insert(id.to_lowercase());
419                    if track_exact {
420                        markdown_headings_exact.insert(id);
421                    }
422                }
423                let fragment = self.config.anchor_style.generate_fragment(&clean_text);
424                Self::insert_deduplicated_fragment(
425                    fragment,
426                    &mut fragment_counts,
427                    &mut markdown_headings,
428                    track_exact.then_some(&mut markdown_headings_exact),
429                    use_underscore_dedup,
430                );
431            }
432
433            // Extract markdown heading anchors
434            if let Some(heading) = &line_info.heading {
435                // Custom ID from {#custom-id} syntax
436                if let Some(custom_id) = &heading.custom_id {
437                    markdown_headings.insert(custom_id.to_lowercase());
438                    if track_exact {
439                        markdown_headings_exact.insert(custom_id.clone());
440                    }
441                }
442
443                // Generate fragment directly from heading text
444                // Note: HTML stripping was removed because it interfered with arrow patterns
445                // like <-> and placeholders like <FILE>. The anchor styles handle these correctly.
446                let fragment = self.config.anchor_style.generate_fragment(&heading.text);
447
448                Self::insert_deduplicated_fragment(
449                    fragment,
450                    &mut fragment_counts,
451                    &mut markdown_headings,
452                    track_exact.then_some(&mut markdown_headings_exact),
453                    use_underscore_dedup,
454                );
455            }
456        }
457
458        AnchorSets {
459            markdown_headings,
460            markdown_headings_exact,
461            html_anchors,
462            html_anchors_exact,
463        }
464    }
465
466    /// Fast check if URL is external (doesn't need to be validated)
467    #[inline]
468    fn is_external_url_fast(url: &str) -> bool {
469        // Quick prefix checks for common protocols
470        url.starts_with("http://")
471            || url.starts_with("https://")
472            || url.starts_with("ftp://")
473            || url.starts_with("mailto:")
474            || url.starts_with("tel:")
475            || url.starts_with("//")
476    }
477
478    /// Resolve a path by trying markdown extensions if it has no extension
479    ///
480    /// For extension-less paths (e.g., `page`), returns a list of paths to try:
481    /// 1. The original path (in case it's already in the index)
482    /// 2. The path with each markdown extension (e.g., `page.md`, `page.markdown`, etc.)
483    ///
484    /// For paths with extensions, returns just the original path.
485    #[inline]
486    fn resolve_path_with_extensions(path: &Path, extensions: &[&str]) -> Vec<PathBuf> {
487        if path.extension().is_none() {
488            // Extension-less path - try with markdown extensions
489            let mut paths = Vec::with_capacity(extensions.len() + 1);
490            // First try the exact path (in case it's already in the index)
491            paths.push(path.to_path_buf());
492            // Then try with each markdown extension
493            for ext in extensions {
494                let path_with_ext = path.with_extension(&ext[1..]); // Remove leading dot
495                paths.push(path_with_ext);
496            }
497            paths
498        } else {
499            // Path has extension - use as-is
500            vec![path.to_path_buf()]
501        }
502    }
503
504    /// Check if a path part (without fragment or query) is an extension-less path
505    ///
506    /// Extension-less paths are potential cross-file links that need resolution
507    /// with markdown extensions (e.g., `page#section` -> `page.md#section`).
508    ///
509    /// We recognize them as extension-less if:
510    /// 1. Path has no extension (no dot)
511    /// 2. Path is not empty
512    /// 3. Path doesn't look like query syntax
513    /// 4. Path contains at least one alphanumeric character (valid filename)
514    /// 5. Path contains only valid path characters (alphanumeric, slashes, hyphens, underscores)
515    ///
516    /// Optimized: single pass through characters to check both conditions.
517    #[inline]
518    fn is_extensionless_path(path_part: &str) -> bool {
519        // Quick rejections for common non-extension-less cases
520        if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
521            return false;
522        }
523
524        // Single pass: check for alphanumeric and validate all characters
525        let mut has_alphanumeric = false;
526        for c in path_part.chars() {
527            if c.is_alphanumeric() {
528                has_alphanumeric = true;
529            } else if !matches!(c, '/' | '\\' | '-' | '_') {
530                // Invalid character found - early exit
531                return false;
532            }
533        }
534
535        // Must have at least one alphanumeric character to be a valid filename
536        has_alphanumeric
537    }
538
539    /// Check if URL is a cross-file link (contains a file path before #)
540    #[inline]
541    fn is_cross_file_link(url: &str) -> bool {
542        if let Some(fragment_pos) = url.find('#') {
543            let path_part = &url[..fragment_pos];
544
545            // If there's no path part, it's just a fragment (#heading)
546            if path_part.is_empty() {
547                return false;
548            }
549
550            // Check for Liquid syntax used by Jekyll and other static site generators
551            // Liquid tags: {% ... %} for control flow and includes
552            // Liquid variables: {{ ... }} for outputting values
553            // These are template directives that reference external content and should be skipped
554            // We check for proper bracket order to avoid false positives
555            if let Some(tag_start) = path_part.find("{%")
556                && path_part[tag_start + 2..].contains("%}")
557            {
558                return true;
559            }
560            if let Some(var_start) = path_part.find("{{")
561                && path_part[var_start + 2..].contains("}}")
562            {
563                return true;
564            }
565
566            // Check if it's an absolute path (starts with /)
567            // These are links to other pages on the same site
568            if path_part.starts_with('/') {
569                return true;
570            }
571
572            // A query string belongs to the destination, not to the path it names,
573            // so `page.md?raw=true` and `page?raw=true` name `page.md` and `page`.
574            let path_part = path_part.split('?').next().unwrap_or(path_part);
575
576            // A destination that is only a query and a fragment stays on this page
577            if path_part.is_empty() {
578                return false;
579            }
580
581            // Check if it looks like a file path:
582            // - Contains a file extension (dot followed by letters)
583            // - Contains path separators
584            // - Contains relative path indicators
585            // - OR is an extension-less path with a fragment (GitHub-style: page#section)
586            let has_extension = path_part.contains('.')
587                && (
588                    // Has file extension pattern
589                    {
590                    // Handle files starting with dot
591                    if let Some(after_dot) = path_part.strip_prefix('.') {
592                        let dots_count = path_part.matches('.').count();
593                        if dots_count == 1 {
594                            // Could be ".ext" (file extension) or ".hidden" (hidden file)
595                            // Treat short alphanumeric suffixes as file extensions
596                            !after_dot.is_empty() && after_dot.len() <= 10 &&
597                            after_dot.chars().all(|c| c.is_ascii_alphanumeric())
598                        } else {
599                            // Hidden file with extension like ".hidden.txt"
600                            path_part.split('.').next_back().is_some_and(|ext| {
601                                !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
602                            })
603                        }
604                    } else {
605                        // Regular file path
606                        path_part.split('.').next_back().is_some_and(|ext| {
607                            !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
608                        })
609                    }
610                } ||
611                // Or contains path separators
612                path_part.contains('/') || path_part.contains('\\') ||
613                // Or starts with relative path indicators
614                path_part.starts_with("./") || path_part.starts_with("../")
615                );
616
617            // Extension-less paths with fragments are potential cross-file links
618            // This supports GitHub-style links like [link](page#section) that resolve to page.md#section
619            let is_extensionless = Self::is_extensionless_path(path_part);
620
621            has_extension || is_extensionless
622        } else {
623            false
624        }
625    }
626
627    /// Whether this document has frontmatter the rule is configured to check.
628    ///
629    /// Gates the body-link early exits, which would otherwise skip a document
630    /// whose only links sit in its frontmatter.
631    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
632        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
633    }
634
635    /// Frontmatter values that read as link destinations, or nothing when the
636    /// rule is not configured to check frontmatter.
637    fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
638        if !self.checks_front_matter_of(ctx) {
639            return Vec::new();
640        }
641        frontmatter_values::link_destinations(ctx, &self.ignored_front_matter_fields)
642    }
643
644    /// Whether a fragment is one the flavor or the configuration resolves
645    /// outside the document's own anchors.
646    fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
647        // MkDocs runtime-generated anchors:
648        // - #fn:NAME, #fnref:NAME from the footnotes extension
649        // - #+key.path or #+key:value from Material for MkDocs option references
650        //   (e.g., #+type:abstract, #+toc.slugify, #+pymdownx.highlight.anchor_linenums)
651        if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
652            && (fragment.starts_with("fn:")
653                || fragment.starts_with("fnref:")
654                || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
655        {
656            return true;
657        }
658
659        // Fragments matching the user-configured ignored_pattern
660        self.ignored_pattern_regex
661            .as_ref()
662            .is_some_and(|re| re.is_match(fragment))
663    }
664
665    /// Whether a fragment names an anchor this document defines. Both HTML and
666    /// markdown anchors honor the `ignore_case` option, mirroring markdownlint
667    /// and the cross-file path.
668    fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
669        if self.config.ignore_case {
670            let lower = fragment.to_lowercase();
671            anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
672        } else {
673            anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
674        }
675    }
676
677    /// Report frontmatter fragments that name no anchor in this document.
678    ///
679    /// Only a fragment-only value is resolved here. A value that carries a path
680    /// (`page.md#section`) is a cross-file link: `contribute_to_index` hands it
681    /// to the workspace index and `cross_file_check` resolves it against the
682    /// target file's anchors.
683    fn check_front_matter(
684        &self,
685        ctx: &crate::lint_context::LintContext,
686        links: &[frontmatter_values::FrontMatterLink],
687        anchors: &AnchorSets,
688        warnings: &mut Vec<LintWarning>,
689    ) {
690        for link in links {
691            let line = ctx.lines[link.line - 1].content(ctx.content);
692            let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
693                continue;
694            };
695            if fragment.is_empty() {
696                continue;
697            }
698
699            // Pandoc and Quarto slugs diverge from GitHub style for headings
700            // that contain punctuation.
701            if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
702                continue;
703            }
704
705            if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
706                continue;
707            }
708
709            let column = byte_to_char_count(line, link.range.start);
710            warnings.push(LintWarning {
711                rule_name: Some(self.name().to_string()),
712                message: format!("Link anchor '#{fragment}' does not exist in document headings"),
713                line: link.line,
714                column,
715                end_line: link.line,
716                end_column: column + 1 + fragment.chars().count(),
717                severity: Severity::Error,
718                fix: None,
719            });
720        }
721    }
722}
723
724impl Rule for MD051LinkFragments {
725    fn name(&self) -> &'static str {
726        "MD051"
727    }
728
729    fn description(&self) -> &'static str {
730        "Link fragments should reference valid headings"
731    }
732
733    fn fix_capability(&self) -> FixCapability {
734        FixCapability::Unfixable
735    }
736
737    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
738        // Skip if no link fragments present. A document whose only links sit in
739        // its frontmatter has no link syntax to find, so that shortcut only
740        // applies when frontmatter is not being checked.
741        if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
742            return true;
743        }
744        // Check for # character (fragments)
745        !ctx.has_char('#')
746    }
747
748    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
749        let mut warnings = Vec::new();
750
751        if ctx.content.is_empty() || self.should_skip(ctx) {
752            return Ok(warnings);
753        }
754
755        let front_matter_links = self.front_matter_links(ctx);
756        if ctx.links.is_empty() && front_matter_links.is_empty() {
757            return Ok(warnings);
758        }
759
760        let anchors = self.extract_headings_from_context(ctx);
761
762        for link in &ctx.links {
763            if link.is_reference {
764                continue;
765            }
766
767            // Skip links inside PyMdown blocks (MkDocs flavor)
768            if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
769                continue;
770            }
771
772            // Skip wiki-links - they reference other files and may have their own fragment validation
773            if matches!(link.link_type, LinkType::WikiLink { .. }) {
774                continue;
775            }
776
777            // Skip links inside Jinja templates
778            if ctx.is_in_jinja_range(link.byte_offset) {
779                continue;
780            }
781
782            // Skip Pandoc/Quarto citations ([@citation], @citation)
783            // Citations are bibliography references, not link fragments
784            if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
785                continue;
786            }
787
788            // Skip links inside shortcodes ({{< ... >}} or {{% ... %}})
789            // Shortcodes may contain template syntax that looks like fragment links
790            if ctx.is_in_shortcode(link.byte_offset) {
791                continue;
792            }
793
794            let url = &link.url;
795
796            // Skip links without fragments or external URLs
797            if !url.contains('#') || Self::is_external_url_fast(url) {
798                continue;
799            }
800
801            // Skip mdbook template placeholders ({{#VARIABLE}})
802            // mdbook uses {{#VARIABLE}} syntax where # is part of the template, not a fragment
803            if url.contains("{{#") && url.contains("}}") {
804                continue;
805            }
806
807            // Resolve link fragments against Pandoc heading slugs. Pandoc/Quarto
808            // auto-generate slugs that diverge from GitHub style for headings that
809            // contain punctuation (e.g. `# 5. Five Things` becomes `5.-five-things`
810            // under Pandoc but `5-five-things` under GitHub). Treat such fragments
811            // as resolved when running under a Pandoc-compatible flavor.
812            if ctx.flavor.is_pandoc_compatible()
813                && let Some(frag) = url.strip_prefix('#')
814                && ctx.has_pandoc_slug(frag)
815            {
816                continue;
817            }
818
819            // Skip Quarto/RMarkdown cross-references (@fig-, @tbl-, @sec-, @eq-, etc.)
820            // These are special cross-reference syntax, not HTML anchors
821            // Format: @prefix-identifier or just @identifier
822            if url.starts_with('@') {
823                continue;
824            }
825
826            // Cross-file links are valid if the file exists (not checked here)
827            if Self::is_cross_file_link(url) {
828                continue;
829            }
830
831            let Some(fragment_pos) = url.find('#') else {
832                continue;
833            };
834
835            let fragment = &url[fragment_pos + 1..];
836
837            // Skip Liquid template variables and filters
838            if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
839                continue;
840            }
841
842            if fragment.is_empty() {
843                continue;
844            }
845
846            if self.fragment_is_exempt(ctx, fragment) {
847                continue;
848            }
849
850            if !self.fragment_resolves(fragment, &anchors) {
851                warnings.push(LintWarning {
852                    rule_name: Some(self.name().to_string()),
853                    message: format!("Link anchor '#{fragment}' does not exist in document headings"),
854                    line: link.line,
855                    column: link.start_col + 1,
856                    end_line: link.line,
857                    end_column: link.end_col + 1,
858                    severity: Severity::Error,
859                    fix: None,
860                });
861            }
862        }
863
864        self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
865
866        Ok(warnings)
867    }
868
869    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
870        // MD051 does not provide auto-fix
871        // Link fragment corrections require human judgment to avoid incorrect fixes
872        Ok(ctx.content.to_string())
873    }
874
875    fn as_any(&self) -> &dyn std::any::Any {
876        self
877    }
878
879    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
880    where
881        Self: Sized,
882    {
883        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
884
885        // When no explicit anchor style is configured (the user didn't override the default),
886        // and a flavor is active, fall back to the flavor's native anchor generation.
887        let explicit_style_present = config
888            .rules
889            .get("MD051")
890            .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
891        if !explicit_style_present {
892            rule_config.anchor_style = match config.global.flavor {
893                crate::config::MarkdownFlavor::MkDocs => AnchorStyle::PythonMarkdown,
894                crate::config::MarkdownFlavor::Kramdown => AnchorStyle::KramdownGfm,
895                _ => AnchorStyle::GitHub,
896            };
897        }
898
899        Box::new(MD051LinkFragments::from_config_struct(rule_config))
900    }
901
902    fn category(&self) -> RuleCategory {
903        RuleCategory::Link
904    }
905
906    fn skippable_by_category(&self) -> bool {
907        // A frontmatter fragment is a link this rule resolves, and the document
908        // holding it needs no link syntax anywhere else.
909        !self.config.check_frontmatter
910    }
911
912    fn cross_file_scope(&self) -> CrossFileScope {
913        CrossFileScope::Workspace
914    }
915
916    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
917        let mut fragment_counts = HashMap::new();
918        let use_underscore_dedup = self.config.anchor_style == AnchorStyle::PythonMarkdown;
919
920        // Extract headings, HTML anchors, and attribute anchors (for other files to reference)
921        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
922            if line_info.in_front_matter {
923                continue;
924            }
925
926            // Skip code blocks for anchor extraction
927            if line_info.in_code_block {
928                continue;
929            }
930
931            let content = line_info.content(ctx.content);
932
933            // Extract HTML anchors (id or name attributes on any element)
934            if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
935                let mut pos = 0;
936                while pos < content.len() {
937                    if let Some(start) = content[pos..].find('<') {
938                        let tag_start = pos + start;
939                        if let Some(end) = content[tag_start..].find('>') {
940                            let tag_end = tag_start + end + 1;
941                            let tag = &content[tag_start..tag_end];
942
943                            if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
944                                && let Some(id_match) = caps.get(1)
945                            {
946                                file_index.add_html_anchor(id_match.as_str());
947                            }
948                            pos = tag_end;
949                        } else {
950                            break;
951                        }
952                    } else {
953                        break;
954                    }
955                }
956            }
957
958            // Extract attribute anchors { #id } on non-heading lines
959            // Headings already have custom_id extracted via heading.custom_id
960            if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
961                for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
962                    if let Some(id_match) = caps.get(1) {
963                        file_index.add_attribute_anchor(id_match.as_str());
964                    }
965                }
966            }
967
968            // Extract heading anchors from blockquote content
969            if line_info.heading.is_none()
970                && let Some(bq) = &line_info.blockquote
971                && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
972            {
973                let fragment = self.config.anchor_style.generate_fragment(&clean_text);
974                Self::add_heading_to_index(
975                    &fragment,
976                    &clean_text,
977                    custom_id,
978                    line_idx + 1,
979                    &mut fragment_counts,
980                    file_index,
981                    use_underscore_dedup,
982                );
983            }
984
985            // Extract heading anchors
986            if let Some(heading) = &line_info.heading {
987                let fragment = self.config.anchor_style.generate_fragment(&heading.text);
988
989                Self::add_heading_to_index(
990                    &fragment,
991                    &heading.text,
992                    heading.custom_id.clone(),
993                    line_idx + 1,
994                    &mut fragment_counts,
995                    file_index,
996                    use_underscore_dedup,
997                );
998
999                // Extract Material for MkDocs setting anchors from headings.
1000                // These are rendered as anchors at build time by Material's JS.
1001                // Most references use #+key.path format (handled by the skip logic in check()),
1002                // but this extraction enables cross-file validation for direct #key.path references.
1003                if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
1004                    && let Some(caps) = MD_SETTING_PATTERN.captures(content)
1005                    && let Some(name) = caps.get(1)
1006                {
1007                    file_index.add_html_anchor(name.as_str());
1008                }
1009            }
1010        }
1011
1012        // Extract cross-file links (for validation against other files)
1013        for link in &ctx.links {
1014            if link.is_reference {
1015                continue;
1016            }
1017
1018            // Skip links inside PyMdown blocks (MkDocs flavor)
1019            if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1020                continue;
1021            }
1022
1023            // Skip wiki-links - they use a different linking system and are not validated
1024            // as relative file paths
1025            if matches!(link.link_type, LinkType::WikiLink { .. }) {
1026                continue;
1027            }
1028
1029            let url = &link.url;
1030
1031            // Skip external URLs
1032            if Self::is_external_url_fast(url) {
1033                continue;
1034            }
1035
1036            // Only process cross-file links with fragments
1037            if Self::is_cross_file_link(url)
1038                && let Some(fragment_pos) = url.find('#')
1039            {
1040                let path_part = &url[..fragment_pos];
1041                let fragment = &url[fragment_pos + 1..];
1042
1043                // Skip empty fragments or template syntax
1044                if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1045                    continue;
1046                }
1047
1048                file_index.add_cross_file_link(CrossFileLinkIndex {
1049                    target_path: path_part.to_string(),
1050                    fragment: fragment.to_string(),
1051                    line: link.line,
1052                    column: link.start_col + 1,
1053                });
1054            }
1055        }
1056
1057        // Extract cross-file links from frontmatter values that carry a fragment
1058        for link in self.front_matter_links(ctx) {
1059            let line = ctx.lines[link.line - 1].content(ctx.content);
1060            let value = &line[link.range.clone()];
1061
1062            if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1063                continue;
1064            }
1065
1066            let Some(fragment_pos) = value.find('#') else {
1067                continue;
1068            };
1069            let path_part = &value[..fragment_pos];
1070            let fragment = &value[fragment_pos + 1..];
1071
1072            // Skip empty fragments or template syntax
1073            if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1074                continue;
1075            }
1076
1077            file_index.add_cross_file_link(CrossFileLinkIndex {
1078                target_path: path_part.to_string(),
1079                fragment: fragment.to_string(),
1080                line: link.line,
1081                column: byte_to_char_count(line, link.range.start),
1082            });
1083        }
1084    }
1085
1086    fn cross_file_check(
1087        &self,
1088        file_path: &Path,
1089        file_index: &FileIndex,
1090        workspace_index: &crate::workspace_index::WorkspaceIndex,
1091    ) -> LintResult {
1092        let mut warnings = Vec::new();
1093
1094        // Supported markdown file extensions (with leading dot, matching MD057)
1095        const MARKDOWN_EXTENSIONS: &[&str] = &[
1096            ".md",
1097            ".markdown",
1098            ".mdx",
1099            ".mkd",
1100            ".mkdn",
1101            ".mdown",
1102            ".mdwn",
1103            ".qmd",
1104            ".rmd",
1105        ];
1106
1107        let ignored_pattern = self.ignored_pattern_regex.as_ref();
1108        let ignore_case = self.config.ignore_case;
1109
1110        // Check each cross-file link in this file
1111        for cross_link in &file_index.cross_file_links {
1112            // Skip cross-file links without fragments - nothing to validate
1113            if cross_link.fragment.is_empty() {
1114                continue;
1115            }
1116
1117            // Honor `ignored-pattern`: skip fragments matching the configured regex.
1118            if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1119                continue;
1120            }
1121
1122            // A query string is not part of the file name: `other.md?raw=true`
1123            // names `other.md`. The message keeps the destination as written.
1124            let target_path = cross_link
1125                .target_path
1126                .split('?')
1127                .next()
1128                .unwrap_or(&cross_link.target_path);
1129
1130            // Resolve the target file path relative to the current file
1131            let base_target_path = if let Some(parent) = file_path.parent() {
1132                parent.join(target_path)
1133            } else {
1134                Path::new(target_path).to_path_buf()
1135            };
1136
1137            // Normalize the path (remove . and ..)
1138            let base_target_path = normalize_path(&base_target_path);
1139
1140            // For extension-less paths, try resolving with markdown extensions
1141            // This handles GitHub-style links like [link](page#section) -> page.md#section
1142            let target_paths_to_try = Self::resolve_path_with_extensions(&base_target_path, MARKDOWN_EXTENSIONS);
1143
1144            // Try to find the target file in the workspace index
1145            let mut target_file_index = None;
1146
1147            for target_path in &target_paths_to_try {
1148                if let Some(index) = workspace_index.get_file(target_path) {
1149                    target_file_index = Some(index);
1150                    break;
1151                }
1152            }
1153
1154            if let Some(target_file_index) = target_file_index {
1155                // Check if the fragment matches any heading in the target file (O(1) lookup)
1156                if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1157                    warnings.push(LintWarning {
1158                        rule_name: Some(self.name().to_string()),
1159                        line: cross_link.line,
1160                        column: cross_link.column,
1161                        end_line: cross_link.line,
1162                        end_column: cross_link.column
1163                            + cross_link.target_path.chars().count()
1164                            + 1
1165                            + cross_link.fragment.chars().count(),
1166                        message: format!(
1167                            "Link fragment '{}' not found in '{}'",
1168                            cross_link.fragment, cross_link.target_path
1169                        ),
1170                        severity: Severity::Error,
1171                        fix: None,
1172                    });
1173                }
1174            }
1175            // If target file not in index, skip (could be external file or not in workspace)
1176        }
1177
1178        Ok(warnings)
1179    }
1180
1181    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1182        let table = crate::rule_config_serde::config_schema_table(&MD051Config::default())?;
1183        if table.is_empty() {
1184            None
1185        } else {
1186            Some((MD051Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1187        }
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use crate::lint_context::LintContext;
1195
1196    #[test]
1197    fn test_quarto_cross_references() {
1198        let rule = MD051LinkFragments::new();
1199
1200        // Test that Quarto cross-references are skipped
1201        let content = r#"# Test Document
1202
1203## Figures
1204
1205See [@fig-plot] for the visualization.
1206
1207More details in [@tbl-results] and [@sec-methods].
1208
1209The equation [@eq-regression] shows the relationship.
1210
1211Reference to [@lst-code] for implementation."#;
1212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1213        let result = rule.check(&ctx).unwrap();
1214        assert!(
1215            result.is_empty(),
1216            "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1217            result.len()
1218        );
1219
1220        // Test that normal anchors still work
1221        let content_with_anchor = r#"# Test
1222
1223See [link](#test) for details."#;
1224        let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1225        let result_anchor = rule.check(&ctx_anchor).unwrap();
1226        assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1227
1228        // Test that invalid anchors are still flagged
1229        let content_invalid = r#"# Test
1230
1231See [link](#nonexistent) for details."#;
1232        let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1233        let result_invalid = rule.check(&ctx_invalid).unwrap();
1234        assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1235    }
1236
1237    #[test]
1238    fn test_jsx_in_heading_anchor() {
1239        // Issue #510: JSX/HTML tags in headings should be stripped for anchor generation
1240        let rule = MD051LinkFragments::new();
1241
1242        // Self-closing JSX tag
1243        let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245        let result = rule.check(&ctx).unwrap();
1246        assert!(
1247            result.is_empty(),
1248            "JSX self-closing tag should be stripped from anchor: got {result:?}"
1249        );
1250
1251        // JSX with attributes
1252        let content2 =
1253            "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1254        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1255        let result2 = rule.check(&ctx2).unwrap();
1256        assert!(
1257            result2.is_empty(),
1258            "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1259        );
1260
1261        // HTML tags with inner text preserved
1262        let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1263        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1264        let result3 = rule.check(&ctx3).unwrap();
1265        assert!(
1266            result3.is_empty(),
1267            "HTML tag content should be preserved in anchor: got {result3:?}"
1268        );
1269    }
1270
1271    // Cross-file validation tests
1272    #[test]
1273    fn test_cross_file_scope() {
1274        let rule = MD051LinkFragments::new();
1275        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1276    }
1277
1278    #[test]
1279    fn test_contribute_to_index_extracts_headings() {
1280        let rule = MD051LinkFragments::new();
1281        let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1282        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283
1284        let mut file_index = FileIndex::new();
1285        rule.contribute_to_index(&ctx, &mut file_index);
1286
1287        assert_eq!(file_index.headings.len(), 3);
1288        assert_eq!(file_index.headings[0].text, "First Heading");
1289        assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1290        assert!(file_index.headings[0].custom_anchor.is_none());
1291
1292        assert_eq!(file_index.headings[1].text, "Second");
1293        assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1294
1295        assert_eq!(file_index.headings[2].text, "Third");
1296    }
1297
1298    #[test]
1299    fn test_contribute_to_index_extracts_cross_file_links() {
1300        let rule = MD051LinkFragments::new();
1301        let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1302        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303
1304        let mut file_index = FileIndex::new();
1305        rule.contribute_to_index(&ctx, &mut file_index);
1306
1307        assert_eq!(file_index.cross_file_links.len(), 2);
1308        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1309        assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1310        assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1311        assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1312    }
1313
1314    #[test]
1315    fn test_cross_file_check_valid_fragment() {
1316        use crate::workspace_index::WorkspaceIndex;
1317
1318        let rule = MD051LinkFragments::new();
1319
1320        // Build workspace index with target file
1321        let mut workspace_index = WorkspaceIndex::new();
1322        let mut target_file_index = FileIndex::new();
1323        target_file_index.add_heading(HeadingIndex {
1324            text: "Installation Guide".to_string(),
1325            auto_anchor: "installation-guide".to_string(),
1326            custom_anchor: None,
1327            line: 1,
1328            is_setext: false,
1329        });
1330        workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1331
1332        // Create a FileIndex for the file being checked
1333        let mut current_file_index = FileIndex::new();
1334        current_file_index.add_cross_file_link(CrossFileLinkIndex {
1335            target_path: "install.md".to_string(),
1336            fragment: "installation-guide".to_string(),
1337            line: 3,
1338            column: 5,
1339        });
1340
1341        let warnings = rule
1342            .cross_file_check(Path::new("docs/readme.md"), &current_file_index, &workspace_index)
1343            .unwrap();
1344
1345        // Should find no warnings since fragment exists
1346        assert!(warnings.is_empty());
1347    }
1348
1349    #[test]
1350    fn test_cross_file_check_invalid_fragment() {
1351        use crate::workspace_index::WorkspaceIndex;
1352
1353        let rule = MD051LinkFragments::new();
1354
1355        // Build workspace index with target file
1356        let mut workspace_index = WorkspaceIndex::new();
1357        let mut target_file_index = FileIndex::new();
1358        target_file_index.add_heading(HeadingIndex {
1359            text: "Installation Guide".to_string(),
1360            auto_anchor: "installation-guide".to_string(),
1361            custom_anchor: None,
1362            line: 1,
1363            is_setext: false,
1364        });
1365        workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1366
1367        // Create a FileIndex with a cross-file link pointing to non-existent fragment
1368        let mut current_file_index = FileIndex::new();
1369        current_file_index.add_cross_file_link(CrossFileLinkIndex {
1370            target_path: "install.md".to_string(),
1371            fragment: "nonexistent".to_string(),
1372            line: 3,
1373            column: 5,
1374        });
1375
1376        let warnings = rule
1377            .cross_file_check(Path::new("docs/readme.md"), &current_file_index, &workspace_index)
1378            .unwrap();
1379
1380        // Should find one warning since fragment doesn't exist
1381        assert_eq!(warnings.len(), 1);
1382        assert!(warnings[0].message.contains("nonexistent"));
1383        assert!(warnings[0].message.contains("install.md"));
1384    }
1385
1386    #[test]
1387    fn test_cross_file_check_custom_anchor_match() {
1388        use crate::workspace_index::WorkspaceIndex;
1389
1390        let rule = MD051LinkFragments::new();
1391
1392        // Build workspace index with target file that has custom anchor
1393        let mut workspace_index = WorkspaceIndex::new();
1394        let mut target_file_index = FileIndex::new();
1395        target_file_index.add_heading(HeadingIndex {
1396            text: "Installation Guide".to_string(),
1397            auto_anchor: "installation-guide".to_string(),
1398            custom_anchor: Some("install".to_string()),
1399            line: 1,
1400            is_setext: false,
1401        });
1402        workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1403
1404        // Link uses custom anchor
1405        let mut current_file_index = FileIndex::new();
1406        current_file_index.add_cross_file_link(CrossFileLinkIndex {
1407            target_path: "install.md".to_string(),
1408            fragment: "install".to_string(),
1409            line: 3,
1410            column: 5,
1411        });
1412
1413        let warnings = rule
1414            .cross_file_check(Path::new("docs/readme.md"), &current_file_index, &workspace_index)
1415            .unwrap();
1416
1417        // Should find no warnings since custom anchor matches
1418        assert!(warnings.is_empty());
1419    }
1420
1421    #[test]
1422    fn test_cross_file_check_target_not_in_workspace() {
1423        use crate::workspace_index::WorkspaceIndex;
1424
1425        let rule = MD051LinkFragments::new();
1426
1427        // Empty workspace index
1428        let workspace_index = WorkspaceIndex::new();
1429
1430        // Link to file not in workspace
1431        let mut current_file_index = FileIndex::new();
1432        current_file_index.add_cross_file_link(CrossFileLinkIndex {
1433            target_path: "external.md".to_string(),
1434            fragment: "heading".to_string(),
1435            line: 3,
1436            column: 5,
1437        });
1438
1439        let warnings = rule
1440            .cross_file_check(Path::new("docs/readme.md"), &current_file_index, &workspace_index)
1441            .unwrap();
1442
1443        // Should not warn about files not in workspace
1444        assert!(warnings.is_empty());
1445    }
1446
1447    #[test]
1448    fn test_wikilinks_skipped_in_check() {
1449        // Wikilinks should not trigger MD051 warnings for missing fragments
1450        let rule = MD051LinkFragments::new();
1451
1452        let content = r#"# Test Document
1453
1454## Valid Heading
1455
1456[[Microsoft#Windows OS]]
1457[[SomePage#section]]
1458[[page|Display Text]]
1459[[path/to/page#section]]
1460"#;
1461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462        let result = rule.check(&ctx).unwrap();
1463
1464        assert!(
1465            result.is_empty(),
1466            "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_wikilinks_not_added_to_cross_file_index() {
1472        // Wikilinks should not be added to the cross-file link index
1473        let rule = MD051LinkFragments::new();
1474
1475        let content = r#"# Test Document
1476
1477[[Microsoft#Windows OS]]
1478[[SomePage#section]]
1479[Regular Link](other.md#section)
1480"#;
1481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482
1483        let mut file_index = FileIndex::new();
1484        rule.contribute_to_index(&ctx, &mut file_index);
1485
1486        // Should only have one cross-file link (the regular markdown link)
1487        // Wikilinks should not be added
1488        let cross_file_links = &file_index.cross_file_links;
1489        assert_eq!(
1490            cross_file_links.len(),
1491            1,
1492            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1493        );
1494        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1495        assert_eq!(file_index.cross_file_links[0].fragment, "section");
1496    }
1497
1498    #[test]
1499    fn test_pandoc_flavor_skips_citations() {
1500        // Pandoc citations ([@key]) are bibliography references, not link fragments.
1501        // MD051 should skip them under Pandoc flavor, mirroring the Quarto skip behavior
1502        // tested in test_quarto_cross_references.
1503        let rule = MD051LinkFragments::new();
1504        let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1505        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1506        let result = rule.check(&ctx).unwrap();
1507        assert!(
1508            result.is_empty(),
1509            "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1510        );
1511    }
1512
1513    #[test]
1514    fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1515        // The Pandoc heading slug for `# 5. Five Things` is `5.-five-things` (the
1516        // dot is preserved per Pandoc's rule of keeping `.`/`_`/`-`), whereas the
1517        // GitHub anchor for the same heading is `5-five-things` (the dot is
1518        // stripped). A link to `#5.-five-things` would be flagged under the
1519        // GitHub default but must be accepted under Pandoc-compatible flavors via
1520        // the `has_pandoc_slug` short-circuit.
1521        use crate::config::MarkdownFlavor;
1522        let rule = MD051LinkFragments::new();
1523        let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1524
1525        // Sanity check: under Standard flavor (GitHub anchor style), the
1526        // divergent fragment is reported as an unknown anchor.
1527        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1528        let std_result = rule.check(&ctx_std).unwrap();
1529        assert_eq!(
1530            std_result.len(),
1531            1,
1532            "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1533        );
1534
1535        // Under Pandoc flavor, the Pandoc slug guard should resolve it.
1536        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1537        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1538        assert!(
1539            pandoc_result.is_empty(),
1540            "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1541        );
1542    }
1543
1544    /// A link whose text contains an email address must still be checked under
1545    /// Pandoc — the `@` embedded in a word is not a citation marker, so the
1546    /// citation guard must not silence MD051 on a missing fragment.
1547    #[test]
1548    fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1549        use crate::config::MarkdownFlavor;
1550        let rule = MD051LinkFragments::new();
1551        let content = "# Title\n\n[contact user@example.com](#missing)\n";
1552
1553        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1554        let std_result = rule.check(&ctx_std).unwrap();
1555        assert_eq!(
1556            std_result.len(),
1557            1,
1558            "Standard flavor must flag the missing fragment: {std_result:?}"
1559        );
1560
1561        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1562        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1563        assert_eq!(
1564            pandoc_result.len(),
1565            1,
1566            "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1567        );
1568    }
1569
1570    /// `[see @smith2020](#missing)` is a Markdown link, not a citation —
1571    /// Pandoc prefers the link interpretation when `[...]` is immediately
1572    /// followed by `(...)`. MD051 must still flag the missing fragment.
1573    #[test]
1574    fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1575        use crate::config::MarkdownFlavor;
1576        let rule = MD051LinkFragments::new();
1577        let content = "# Title\n\n[see @smith2020](#missing)\n";
1578
1579        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1580        let std_result = rule.check(&ctx_std).unwrap();
1581        assert_eq!(
1582            std_result.len(),
1583            1,
1584            "Standard flavor must flag the missing fragment: {std_result:?}"
1585        );
1586
1587        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1588        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1589        assert_eq!(
1590            pandoc_result.len(),
1591            1,
1592            "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1593        );
1594    }
1595
1596    /// Pandoc's auto_identifiers extension disambiguates duplicate headings by
1597    /// appending `-1`, `-2`, etc. A link to `#a.-1` must resolve against the
1598    /// second `# A.` heading.
1599    #[test]
1600    fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1601        use crate::config::MarkdownFlavor;
1602        let rule = MD051LinkFragments::new();
1603        let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1604
1605        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1606        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1607        assert!(
1608            pandoc_result.is_empty(),
1609            "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1610        );
1611
1612        let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1613        let quarto_result = rule.check(&ctx_quarto).unwrap();
1614        assert!(
1615            quarto_result.is_empty(),
1616            "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1617        );
1618    }
1619
1620    /// A link to `#a.-2` with only two `# A.` headings must still be flagged —
1621    /// only `-1` exists when there are two duplicates.
1622    #[test]
1623    fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1624        use crate::config::MarkdownFlavor;
1625        let rule = MD051LinkFragments::new();
1626        let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1627
1628        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1629        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1630        assert_eq!(
1631            pandoc_result.len(),
1632            1,
1633            "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1634        );
1635    }
1636
1637    fn front_matter_checked() -> MD051Config {
1638        MD051Config {
1639            check_frontmatter: true,
1640            ..MD051Config::default()
1641        }
1642    }
1643
1644    fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646        MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1647    }
1648
1649    #[test]
1650    fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1651        let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1652        let result = check_front_matter(content, front_matter_checked());
1653
1654        assert_eq!(
1655            result.len(),
1656            1,
1657            "Only the unresolved fragment is reported. Got: {result:?}"
1658        );
1659        assert_eq!(
1660            result[0].message,
1661            "Link anchor '#missing' does not exist in document headings"
1662        );
1663        assert_eq!(result[0].line, 2);
1664        assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1665        assert_eq!(result[0].end_column, 18);
1666    }
1667
1668    #[test]
1669    fn frontmatter_fragments_are_not_checked_by_default() {
1670        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1671        let result = check_front_matter(content, MD051Config::default());
1672
1673        assert!(
1674            result.is_empty(),
1675            "Frontmatter is only checked on request. Got: {result:?}"
1676        );
1677    }
1678
1679    #[test]
1680    fn an_ignored_frontmatter_field_is_not_checked() {
1681        let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1682        let config = MD051Config {
1683            check_frontmatter: true,
1684            ignore_frontmatter_fields: vec!["Hero".to_string()],
1685            ..MD051Config::default()
1686        };
1687        let result = check_front_matter(content, config);
1688
1689        assert_eq!(
1690            result.len(),
1691            1,
1692            "The ignored field is skipped and the other is not. Got: {result:?}"
1693        );
1694        assert_eq!(result[0].line, 3);
1695    }
1696
1697    #[test]
1698    fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1699        let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1700        let config = MD051Config {
1701            check_frontmatter: true,
1702            ignored_pattern: Some("^fn:".to_string()),
1703            ..MD051Config::default()
1704        };
1705        let result = check_front_matter(content, config);
1706
1707        assert_eq!(
1708            result.len(),
1709            1,
1710            "The matching fragment is skipped and the other is not. Got: {result:?}"
1711        );
1712        assert_eq!(result[0].line, 3);
1713    }
1714
1715    #[test]
1716    fn a_frontmatter_fragment_honors_ignore_case() {
1717        let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1718
1719        let permissive = check_front_matter(content, front_matter_checked());
1720        assert!(
1721            permissive.is_empty(),
1722            "The default resolves a case mismatch. Got: {permissive:?}"
1723        );
1724
1725        let strict = check_front_matter(
1726            content,
1727            MD051Config {
1728                check_frontmatter: true,
1729                ignore_case: false,
1730                ..MD051Config::default()
1731            },
1732        );
1733        assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1734    }
1735
1736    #[test]
1737    fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1738        let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1739        let result = check_front_matter(content, front_matter_checked());
1740
1741        assert!(
1742            result.is_empty(),
1743            "Only path-shaped values are destinations. Got: {result:?}"
1744        );
1745    }
1746
1747    #[test]
1748    fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1749        let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1750        let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1751
1752        let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1753        let mut source_index = FileIndex::default();
1754        rule.contribute_to_index(&source_ctx, &mut source_index);
1755
1756        let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1757        let mut target_index = FileIndex::default();
1758        rule.contribute_to_index(&target_ctx, &mut target_index);
1759
1760        let source_path = PathBuf::from("docs/source.md");
1761        let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1762        workspace.insert_file(source_path.clone(), source_index.clone());
1763        workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1764
1765        let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1766
1767        assert_eq!(
1768            warnings.len(),
1769            1,
1770            "Only the unresolved fragment is reported. Got: {warnings:?}"
1771        );
1772        assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1773        assert_eq!(warnings[0].line, 2);
1774        assert_eq!(warnings[0].column, 11);
1775    }
1776
1777    #[test]
1778    fn a_query_string_does_not_hide_the_target_file() {
1779        let rule = MD051LinkFragments::new();
1780        let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1781
1782        let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1783        let mut source_index = FileIndex::default();
1784        rule.contribute_to_index(&source_ctx, &mut source_index);
1785
1786        let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1787        let mut target_index = FileIndex::default();
1788        rule.contribute_to_index(&target_ctx, &mut target_index);
1789
1790        let source_path = PathBuf::from("docs/source.md");
1791        let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1792        workspace.insert_file(source_path.clone(), source_index.clone());
1793        workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1794
1795        let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1796
1797        assert_eq!(
1798            warnings.len(),
1799            1,
1800            "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1801        );
1802        assert_eq!(
1803            warnings[0].message,
1804            "Link fragment 'missing' not found in 'other.md?raw=true'"
1805        );
1806        assert_eq!(warnings[0].line, 3);
1807    }
1808
1809    #[test]
1810    fn a_query_string_does_not_hide_an_extensionless_target_file() {
1811        let rule = MD051LinkFragments::new();
1812        let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1813
1814        let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1815        let same_document = rule.check(&source_ctx).unwrap();
1816        assert!(
1817            same_document.is_empty(),
1818            "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1819        );
1820
1821        let mut source_index = FileIndex::default();
1822        rule.contribute_to_index(&source_ctx, &mut source_index);
1823
1824        let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
1825        let mut target_index = FileIndex::default();
1826        rule.contribute_to_index(&target_ctx, &mut target_index);
1827
1828        let source_path = PathBuf::from("docs/source.md");
1829        let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1830        workspace.insert_file(source_path.clone(), source_index.clone());
1831        workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1832
1833        let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1834
1835        assert_eq!(
1836            warnings.len(),
1837            1,
1838            "The query is stripped before the markdown extension is added. Got: {warnings:?}"
1839        );
1840        assert_eq!(
1841            warnings[0].message,
1842            "Link fragment 'absent' not found in 'other?raw=true'"
1843        );
1844        assert_eq!(warnings[0].line, 5);
1845    }
1846
1847    #[test]
1848    fn a_destination_that_is_only_a_query_stays_on_this_page() {
1849        let rule = MD051LinkFragments::new();
1850        let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
1851
1852        let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1853        let warnings = rule.check(&source_ctx).unwrap();
1854
1855        assert_eq!(
1856            warnings.len(),
1857            1,
1858            "Only the absent anchor is reported. Got: {warnings:?}"
1859        );
1860        assert_eq!(
1861            warnings[0].message,
1862            "Link anchor '#nowhere' does not exist in document headings"
1863        );
1864        assert_eq!(warnings[0].line, 6);
1865
1866        let mut source_index = FileIndex::default();
1867        rule.contribute_to_index(&source_ctx, &mut source_index);
1868        assert!(
1869            source_index.cross_file_links.is_empty(),
1870            "A query with no path names no other file. Got: {:?}",
1871            source_index.cross_file_links
1872        );
1873    }
1874
1875    #[test]
1876    fn a_frontmatter_path_carrying_a_query_is_indexed() {
1877        let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1878        let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
1879
1880        let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1881        let mut source_index = FileIndex::default();
1882        rule.contribute_to_index(&source_ctx, &mut source_index);
1883
1884        assert_eq!(source_index.cross_file_links.len(), 1);
1885        assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
1886        assert_eq!(source_index.cross_file_links[0].fragment, "missing");
1887    }
1888
1889    #[test]
1890    fn frontmatter_cross_file_paths_are_not_indexed_by_default() {
1891        let rule = MD051LinkFragments::new();
1892        let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
1893
1894        let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1895        let mut source_index = FileIndex::default();
1896        rule.contribute_to_index(&source_ctx, &mut source_index);
1897
1898        assert!(
1899            source_index.cross_file_links.is_empty(),
1900            "Frontmatter is only checked on request. Got: {:?}",
1901            source_index.cross_file_links
1902        );
1903    }
1904}