Skip to main content

rumdl_lib/rules/
md051_link_fragments.rs

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