Skip to main content

rumdl_lib/rules/
md051_link_fragments.rs

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