Skip to main content

rumdl_lib/rules/
md051_link_fragments.rs

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