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