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