Skip to main content

rumdl_lib/rules/
md052_reference_links_images.rs

1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3use crate::utils::range_utils::calculate_match_range;
4use crate::utils::regex_cache::SHORTCUT_REF_REGEX;
5use crate::utils::skip_context::{is_in_math_context, is_in_table_cell};
6use regex::Regex;
7use std::collections::{HashMap, HashSet};
8use std::sync::LazyLock;
9
10mod md052_config;
11use md052_config::MD052Config;
12
13// Pattern to match reference definitions [ref]: url
14// Note: \S* instead of \S+ to allow empty definitions like [ref]:
15// The capturing group handles nested brackets to support cases like [`union[t, none]`]:
16static REF_REGEX: LazyLock<Regex> =
17    LazyLock::new(|| Regex::new(r"^\s*\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]:\s*.*").unwrap());
18
19// Pattern for list items to exclude from reference checks (standard regex is fine)
20static LIST_ITEM_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[-*+]\s+(?:\[[xX\s]\]\s+)?").unwrap());
21
22// Pattern for output example sections (standard regex is fine)
23static OUTPUT_EXAMPLE_START: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"^#+\s*(?:Output|Example|Output Style|Output Format)\s*$").unwrap());
25
26// Pattern for GitHub alerts/callouts in blockquotes (e.g., > [!NOTE], > [!TIP], etc.)
27// Extended to include additional common alert types
28static GITHUB_ALERT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r"^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|INFO|SUCCESS|FAILURE|DANGER|BUG|EXAMPLE|QUOTE)\]")
30        .unwrap()
31});
32
33// Pattern to detect URLs that may contain brackets (IPv6, API endpoints, etc.)
34// This pattern specifically looks for:
35// - IPv6 addresses: https://[::1] or https://[2001:db8::1]
36// - IPv6 with zone IDs: https://[fe80::1%eth0]
37// - IPv6 mixed notation: https://[::ffff:192.0.2.1]
38// - API paths with array notation: https://api.example.com/users[0]
39// But NOT markdown reference links that happen to follow URLs
40static URL_WITH_BRACKETS: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"https?://(?:\[[0-9a-fA-F:.%]+\]|[^\s\[\]]+/[^\s]*\[\d+\])").unwrap());
42
43/// Rule MD052: Reference links and images should use reference style
44///
45/// See [docs/md052.md](../../docs/md052.md) for full documentation, configuration, and examples.
46///
47/// This rule is triggered when a reference link or image uses a reference that isn't defined.
48///
49/// ## Configuration
50///
51/// - `shortcut-syntax`: Whether to check shortcut reference syntax `[text]` (default: false)
52///
53/// By default, only full (`[text][ref]`) and collapsed (`[text][]`) reference syntax is checked.
54/// Shortcut syntax is ambiguous because `[text]` could be a reference link OR just text in brackets.
55#[derive(Clone, Default)]
56pub struct MD052ReferenceLinkImages {
57    config: MD052Config,
58}
59
60impl MD052ReferenceLinkImages {
61    pub fn new() -> Self {
62        Self {
63            config: MD052Config::default(),
64        }
65    }
66
67    pub fn from_config_struct(config: MD052Config) -> Self {
68        Self { config }
69    }
70
71    /// Strip surrounding backticks from a string
72    /// Used for MkDocs auto-reference detection where `module.Class` should be treated as module.Class
73    fn strip_backticks(s: &str) -> &str {
74        s.trim_start_matches('`').trim_end_matches('`')
75    }
76
77    /// Check if a string is a valid Python identifier
78    /// Used for MkDocs auto-reference detection where single-word backtick-wrapped identifiers
79    /// like `str`, `int`, etc. should be accepted as valid auto-references
80    fn is_valid_python_identifier(s: &str) -> bool {
81        if s.is_empty() {
82            return false;
83        }
84        let first_char = s.chars().next().unwrap();
85        if !first_char.is_ascii_alphabetic() && first_char != '_' {
86            return false;
87        }
88        s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
89    }
90
91    /// Check if text matches a known non-reference pattern that should be skipped.
92    ///
93    /// These are deterministic patterns from markdown extensions or code examples,
94    /// not heuristics. Returns true for:
95    /// - User-configured names via `ignore` config option
96    /// - Markdown extensions: [^footnote], [@citation], [!alert], [TOC]
97    /// - Programming syntax: [T], [null], [i32], ["string"]
98    /// - Descriptive text: [default: value], [0-9]
99    fn is_known_non_reference_pattern(&self, text: &str) -> bool {
100        // Check user-configured ignore list first (case-insensitive match)
101        // Reference IDs are normalized to lowercase during parsing,
102        // so we use case-insensitive comparison for user convenience
103        if self.config.ignore.iter().any(|p| p.eq_ignore_ascii_case(text)) {
104            return true;
105        }
106        // Skip numeric patterns (array indices, ranges)
107        if text.chars().all(|c| c.is_ascii_digit()) {
108            return true;
109        }
110
111        // Skip numeric ranges like [1:3], [0:10], etc.
112        if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
113            return true;
114        }
115
116        // Skip patterns that look like config sections [tool.something], [section.subsection]
117        // But not if they contain other non-alphanumeric chars like hyphens, underscores, or backticks
118        // Backticks indicate intentional code formatting in a reference name (e.g., [`module.Class`])
119        if text.contains('.')
120            && !text.contains(' ')
121            && !text.contains('-')
122            && !text.contains('_')
123            && !text.contains('`')
124        {
125            // Config sections typically have dots, no spaces, and only alphanumeric + dots
126            return true;
127        }
128
129        // Skip glob/wildcard patterns like [*], [...], [**]
130        if text == "*" || text == "..." || text == "**" {
131            return true;
132        }
133
134        // Skip patterns that look like file paths [dir/file], [src/utils]
135        if text.contains('/') && !text.contains(' ') && !text.starts_with("http") {
136            return true;
137        }
138
139        // Skip programming type annotations like [int, str], [Dict[str, Any]]
140        // These typically have commas and/or nested brackets
141        if text.contains(',') || text.contains('[') || text.contains(']') {
142            // Check if it looks like a type annotation pattern
143            return true;
144        }
145
146        // Note: We don't filter out patterns with backticks because backticks in reference names
147        // are valid markdown syntax, e.g., [`dataclasses.InitVar`] is a valid reference name
148
149        // Skip patterns that look like module/class paths ONLY if they don't have backticks
150        // Backticks indicate intentional code formatting in a reference name
151        // e.g., skip [dataclasses.initvar] but allow [`typing.ClassVar`]
152        if !text.contains('`')
153            && text.contains('.')
154            && !text.contains(' ')
155            && !text.contains('-')
156            && !text.contains('_')
157        {
158            return true;
159        }
160
161        // Note: We don't filter based on word count anymore because legitimate references
162        // can have many words, like "python language reference for import statements"
163        // Word count filtering was causing false positives where valid references were
164        // being incorrectly flagged as unused
165
166        // Skip patterns that are just punctuation or operators
167        if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
168            return true;
169        }
170
171        // Skip very short non-word patterns (likely operators or syntax)
172        if text.len() <= 2 && !text.chars().all(char::is_alphabetic) {
173            return true;
174        }
175
176        // Skip quoted patterns like ["E501"], ["ALL"], ["E", "F"]
177        if (text.starts_with('"') && text.ends_with('"'))
178            || (text.starts_with('\'') && text.ends_with('\''))
179            || text.contains('"')
180            || text.contains('\'')
181        {
182            return true;
183        }
184
185        // Skip descriptive patterns with colon like [default: the project root]
186        // But allow simple numeric ranges which are handled above
187        if text.contains(':') && text.contains(' ') {
188            return true;
189        }
190
191        // Skip alert/admonition patterns like [!WARN], [!NOTE], etc.
192        if text.starts_with('!') {
193            return true;
194        }
195
196        // Skip footnote syntax like [^1], [^note], etc.
197        // Footnotes start with ^ and are a common markdown extension
198        if text.starts_with('^') {
199            return true;
200        }
201
202        // Skip Pandoc/RMarkdown/Quarto citation syntax like [@citation-key]
203        // Citations in these formats start with @ inside brackets
204        if text.starts_with('@') {
205            return true;
206        }
207
208        // Skip table of contents markers like [TOC]
209        // Used by Python-Markdown and other processors
210        if text == "TOC" {
211            return true;
212        }
213
214        // Skip single uppercase letters (likely type parameters) like [T], [U], [K], [V]
215        if text.len() == 1 && text.chars().all(|c| c.is_ascii_uppercase()) {
216            return true;
217        }
218
219        // Skip common programming type names, literals, and short identifiers
220        // that are likely not markdown references
221        let common_non_refs = [
222            // Programming types
223            "object",
224            "Object",
225            "any",
226            "Any",
227            "inv",
228            "void",
229            "bool",
230            "int",
231            "float",
232            "str",
233            "char",
234            "i8",
235            "i16",
236            "i32",
237            "i64",
238            "i128",
239            "isize",
240            "u8",
241            "u16",
242            "u32",
243            "u64",
244            "u128",
245            "usize",
246            "f32",
247            "f64",
248            // JavaScript/JSON literals (excluding "undefined" which is too ambiguous)
249            "null",
250            "true",
251            "false",
252            "NaN",
253            "Infinity",
254            // Common JavaScript output patterns
255            "object Object",
256        ];
257
258        if common_non_refs.contains(&text) {
259            return true;
260        }
261
262        false
263    }
264
265    /// Check if a byte position is inside any code span. O(log n) via binary search.
266    fn is_in_code_span(byte_pos: usize, code_spans: &[crate::lint_context::CodeSpan]) -> bool {
267        let idx = code_spans.partition_point(|span| span.byte_offset <= byte_pos);
268        idx > 0 && byte_pos < code_spans[idx - 1].byte_end
269    }
270
271    /// Check if a byte position is within an HTML tag. O(log n) via binary search.
272    fn is_in_html_tag(html_tags: &[crate::lint_context::HtmlTag], byte_pos: usize) -> bool {
273        let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
274        idx > 0 && byte_pos < html_tags[idx - 1].byte_end
275    }
276
277    fn extract_references(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
278        use crate::utils::skip_context::is_mkdocs_snippet_line;
279
280        let mut references = HashSet::new();
281
282        for (line_num, line) in ctx.content.lines().enumerate() {
283            // Use LintContext's pre-computed code block info (1-indexed)
284            if let Some(line_info) = ctx.line_info(line_num + 1)
285                && line_info.in_code_block
286            {
287                continue;
288            }
289
290            // Skip lines that look like MkDocs snippet markers (only in MkDocs mode)
291            if is_mkdocs_snippet_line(line, ctx.flavor) {
292                continue;
293            }
294
295            // Check for abbreviation syntax (*[ABBR]: Definition) and skip it
296            // Abbreviations are not reference links and should not be tracked
297            if line.trim_start().starts_with("*[") {
298                continue;
299            }
300
301            if let Some(cap) = REF_REGEX.captures(line) {
302                // Store references in lowercase for case-insensitive comparison
303                if let Some(reference) = cap.get(1) {
304                    references.insert(reference.as_str().to_lowercase());
305                }
306            }
307        }
308
309        // Include definitions found by rumdl's shared parser, which recognizes
310        // blockquote-prefixed definitions (`> [id]: url`) that the line scan above
311        // does not. IDs are already lowercased.
312        for def in &ctx.reference_defs {
313            references.insert(def.id.clone());
314        }
315
316        references
317    }
318
319    fn find_undefined_references(
320        &self,
321        references: &HashSet<String>,
322        ctx: &crate::lint_context::LintContext,
323        mkdocs_mode: bool,
324    ) -> Vec<(usize, usize, usize, String)> {
325        let mut undefined = Vec::new();
326        let mut reported_refs = HashMap::new();
327        let mut in_example_section = false;
328
329        // Get code spans and HTML tags once for the entire function
330        let code_spans = ctx.code_spans();
331        let html_tags = ctx.html_tags();
332
333        // Use cached data for reference links and images
334        for link in &ctx.links {
335            if !link.is_reference {
336                continue; // Skip inline links
337            }
338
339            // Skip links inside Jinja templates
340            if ctx.is_in_jinja_range(link.byte_offset) {
341                continue;
342            }
343
344            // Skip links inside code spans
345            if Self::is_in_code_span(link.byte_offset, &code_spans) {
346                continue;
347            }
348
349            // Skip links inside HTML comments (uses pre-computed ranges)
350            if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
351                continue;
352            }
353
354            // Skip links inside HTML tags
355            if Self::is_in_html_tag(&html_tags, link.byte_offset) {
356                continue;
357            }
358
359            // Skip links inside math contexts
360            if is_in_math_context(ctx, link.byte_offset) {
361                continue;
362            }
363
364            // Skip links inside table cells
365            if is_in_table_cell(ctx, link.line, link.start_col) {
366                continue;
367            }
368
369            // Skip links inside frontmatter
370            if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
371                continue;
372            }
373
374            // Skip Pandoc/Quarto citations ([@citation], @citation)
375            // Citations look like reference links but are bibliography references
376            if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
377                continue;
378            }
379
380            // Skip links inside shortcodes ({{< ... >}} or {{% ... %}})
381            // Shortcodes may contain template syntax that looks like reference links
382            if ctx.is_in_shortcode(link.byte_offset) {
383                continue;
384            }
385
386            if let Some(ref_id) = &link.reference_id {
387                let reference_lower = ref_id.to_lowercase();
388
389                // Skip known non-reference patterns (markdown extensions, code examples)
390                if self.is_known_non_reference_pattern(ref_id) {
391                    continue;
392                }
393
394                // Skip MkDocs auto-references if in MkDocs mode
395                // Check both the reference_id and the link text for shorthand references
396                // Strip backticks since MkDocs resolves `module.Class` as module.Class
397                let stripped_ref = Self::strip_backticks(ref_id);
398                let stripped_text = Self::strip_backticks(&link.text);
399                if mkdocs_mode
400                    && (is_mkdocs_auto_reference(stripped_ref)
401                        || is_mkdocs_auto_reference(stripped_text)
402                        || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
403                        || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
404                {
405                    continue;
406                }
407
408                // Check if reference is defined
409                if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
410                    // Check if the line is in an example section or list item
411                    if let Some(line_info) = ctx.line_info(link.line) {
412                        if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
413                            in_example_section = true;
414                            continue;
415                        }
416
417                        if in_example_section {
418                            continue;
419                        }
420
421                        // Skip list items
422                        if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
423                            continue;
424                        }
425
426                        // Skip lines that are HTML content
427                        let trimmed = line_info.content(ctx.content).trim_start();
428                        if trimmed.starts_with('<') {
429                            continue;
430                        }
431                    }
432
433                    let match_len = link.byte_end - link.byte_offset;
434                    // calculate_match_range expects a byte offset within the line;
435                    // start_col is a character column, so derive the byte offset.
436                    let line_start = ctx.line_index.get_line_start_byte(link.line).unwrap_or(0);
437                    undefined.push((
438                        link.line - 1,
439                        link.byte_offset - line_start,
440                        match_len,
441                        ref_id.to_string(),
442                    ));
443                    reported_refs.insert(reference_lower, true);
444                }
445            }
446        }
447
448        // Use cached data for reference images
449        for image in &ctx.images {
450            if !image.is_reference {
451                continue; // Skip inline images
452            }
453
454            // Skip images inside Jinja templates
455            if ctx.is_in_jinja_range(image.byte_offset) {
456                continue;
457            }
458
459            // Skip images inside code spans
460            if Self::is_in_code_span(image.byte_offset, &code_spans) {
461                continue;
462            }
463
464            // Skip images inside HTML comments (uses pre-computed ranges)
465            if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
466                continue;
467            }
468
469            // Skip images inside HTML tags
470            if Self::is_in_html_tag(&html_tags, image.byte_offset) {
471                continue;
472            }
473
474            // Skip images inside math contexts
475            if is_in_math_context(ctx, image.byte_offset) {
476                continue;
477            }
478
479            // Skip images inside table cells
480            if is_in_table_cell(ctx, image.line, image.start_col) {
481                continue;
482            }
483
484            // Skip images inside frontmatter
485            if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
486                continue;
487            }
488
489            if let Some(ref_id) = &image.reference_id {
490                let reference_lower = ref_id.to_lowercase();
491
492                // Skip known non-reference patterns (markdown extensions, code examples)
493                if self.is_known_non_reference_pattern(ref_id) {
494                    continue;
495                }
496
497                // Skip MkDocs auto-references if in MkDocs mode
498                // Check both the reference_id and the alt text for shorthand references
499                // Strip backticks since MkDocs resolves `module.Class` as module.Class
500                let stripped_ref = Self::strip_backticks(ref_id);
501                let stripped_alt = Self::strip_backticks(&image.alt_text);
502                if mkdocs_mode
503                    && (is_mkdocs_auto_reference(stripped_ref)
504                        || is_mkdocs_auto_reference(stripped_alt)
505                        || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
506                        || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
507                {
508                    continue;
509                }
510
511                // Check if reference is defined
512                if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
513                    // Check if the line is in an example section or list item
514                    if let Some(line_info) = ctx.line_info(image.line) {
515                        if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
516                            in_example_section = true;
517                            continue;
518                        }
519
520                        if in_example_section {
521                            continue;
522                        }
523
524                        // Skip list items
525                        if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
526                            continue;
527                        }
528
529                        // Skip lines that are HTML content
530                        let trimmed = line_info.content(ctx.content).trim_start();
531                        if trimmed.starts_with('<') {
532                            continue;
533                        }
534                    }
535
536                    let match_len = image.byte_end - image.byte_offset;
537                    // calculate_match_range expects a byte offset within the line;
538                    // start_col is a character column, so derive the byte offset.
539                    let line_start = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
540                    undefined.push((
541                        image.line - 1,
542                        image.byte_offset - line_start,
543                        match_len,
544                        ref_id.to_string(),
545                    ));
546                    reported_refs.insert(reference_lower, true);
547                }
548            }
549        }
550
551        // Build a set of byte ranges that are already covered by parsed links/images
552        let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
553
554        // Add ranges from parsed links
555        for link in &ctx.links {
556            covered_ranges.push((link.byte_offset, link.byte_end));
557        }
558
559        // Add ranges from parsed images
560        for image in &ctx.images {
561            covered_ranges.push((image.byte_offset, image.byte_end));
562        }
563
564        // Sort ranges by start position
565        covered_ranges.sort_by_key(|&(start, _)| start);
566
567        // Handle shortcut references [text] which aren't captured in ctx.links
568        // Only check these if shortcut_syntax is enabled (default: false)
569        // Shortcut syntax is ambiguous because [text] could be a reference link
570        // OR just text in brackets (like spec notation in quotes)
571        if !self.config.shortcut_syntax {
572            return undefined;
573        }
574
575        // Need to use regex for shortcut references
576        let lines = ctx.raw_lines();
577        in_example_section = false; // Reset for line-by-line processing
578
579        for (line_num, line) in lines.iter().enumerate() {
580            // Skip lines in frontmatter or code blocks using LintContext's pre-computed info
581            if let Some(line_info) = ctx.line_info(line_num + 1)
582                && (line_info.in_front_matter || line_info.in_code_block)
583            {
584                continue;
585            }
586
587            // Check for example sections
588            if OUTPUT_EXAMPLE_START.is_match(line) {
589                in_example_section = true;
590                continue;
591            }
592
593            if in_example_section {
594                // Check if we're exiting the example section (another heading)
595                if line.starts_with('#') && !OUTPUT_EXAMPLE_START.is_match(line) {
596                    in_example_section = false;
597                } else {
598                    continue;
599                }
600            }
601
602            // Skip list items
603            if LIST_ITEM_REGEX.is_match(line) {
604                continue;
605            }
606
607            // Skip lines that are HTML content
608            let trimmed_line = line.trim_start();
609            if trimmed_line.starts_with('<') {
610                continue;
611            }
612
613            // Skip GitHub alerts/callouts (e.g., > [!TIP])
614            if GITHUB_ALERT_REGEX.is_match(line) {
615                continue;
616            }
617
618            // Skip abbreviation definitions (*[ABBR]: Definition)
619            // These are not reference links and should not be checked
620            if trimmed_line.starts_with("*[") {
621                continue;
622            }
623
624            // Collect positions of brackets that are part of URLs (IPv6, etc.)
625            // so we can exclude them from reference checking
626            let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
627            for mat in URL_WITH_BRACKETS.find_iter(line) {
628                // Find all bracket pairs within this URL match
629                let url_str = mat.as_str();
630                let url_start = mat.start();
631
632                // Find brackets within the URL (e.g., in https://[::1]:8080)
633                let mut idx = 0;
634                while idx < url_str.len() {
635                    if let Some(bracket_start) = url_str[idx..].find('[') {
636                        let bracket_start_abs = url_start + idx + bracket_start;
637                        if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
638                            let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
639                            url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
640                            idx += bracket_start + bracket_end + 2;
641                        } else {
642                            break;
643                        }
644                    } else {
645                        break;
646                    }
647                }
648            }
649
650            // Check shortcut references: [reference]
651            if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
652                for cap in captures {
653                    if let Some(ref_match) = cap.get(1) {
654                        // Check if this bracket is part of a URL (IPv6, etc.)
655                        let bracket_start = cap.get(0).unwrap().start();
656                        let bracket_end = cap.get(0).unwrap().end();
657
658                        // Skip if this bracket pair is within any URL bracket range
659                        let is_in_url = url_bracket_ranges
660                            .iter()
661                            .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
662
663                        if is_in_url {
664                            continue;
665                        }
666
667                        // Skip Pandoc/RMarkdown inline footnotes: ^[text]
668                        // Check if there's a ^ immediately before the opening bracket
669                        if bracket_start > 0 {
670                            // bracket_start is a byte offset, so we need to check the byte before
671                            if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
672                                && *byte == b'^'
673                            {
674                                continue; // This is an inline footnote, skip it
675                            }
676                        }
677
678                        let reference = ref_match.as_str();
679                        let reference_lower = reference.to_lowercase();
680
681                        // Skip known non-reference patterns (markdown extensions, code examples)
682                        if self.is_known_non_reference_pattern(reference) {
683                            continue;
684                        }
685
686                        // Skip GitHub alerts (including extended types)
687                        if let Some(alert_type) = reference.strip_prefix('!')
688                            && matches!(
689                                alert_type,
690                                "NOTE"
691                                    | "TIP"
692                                    | "WARNING"
693                                    | "IMPORTANT"
694                                    | "CAUTION"
695                                    | "INFO"
696                                    | "SUCCESS"
697                                    | "FAILURE"
698                                    | "DANGER"
699                                    | "BUG"
700                                    | "EXAMPLE"
701                                    | "QUOTE"
702                            )
703                        {
704                            continue;
705                        }
706
707                        // Skip MkDocs snippet section markers like [start:section] or [end:section]
708                        // when they appear as part of snippet syntax (e.g., # -8<- [start:section])
709                        if mkdocs_mode
710                            && (reference.starts_with("start:") || reference.starts_with("end:"))
711                            && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
712                                || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
713                        {
714                            continue;
715                        }
716
717                        // Skip MkDocs auto-references if in MkDocs mode
718                        // Strip backticks since MkDocs resolves `module.Class` as module.Class
719                        let stripped_ref = Self::strip_backticks(reference);
720                        if mkdocs_mode
721                            && (is_mkdocs_auto_reference(stripped_ref)
722                                || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
723                        {
724                            continue;
725                        }
726
727                        // Pandoc-flavor implicit header references: `[Section name]` resolves
728                        // to a heading whose Pandoc slug matches the bracketed text. These are
729                        // not undefined references — Pandoc renders them as anchor links.
730                        if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
731                            continue;
732                        }
733
734                        if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
735                            let full_match = cap.get(0).unwrap();
736                            let col = full_match.start();
737                            let line_start_byte = ctx.line_offsets[line_num];
738                            let byte_pos = line_start_byte + col;
739
740                            // Skip if inside code span
741                            let code_spans = ctx.code_spans();
742                            if Self::is_in_code_span(byte_pos, &code_spans) {
743                                continue;
744                            }
745
746                            // Skip if inside Jinja template
747                            if ctx.is_in_jinja_range(byte_pos) {
748                                continue;
749                            }
750
751                            // Skip if inside code block
752                            if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
753                                &ctx.code_blocks,
754                                byte_pos,
755                            ) {
756                                continue;
757                            }
758
759                            // Skip if inside HTML comment (uses pre-computed ranges)
760                            if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
761                                continue;
762                            }
763
764                            // Skip if inside HTML tag
765                            if Self::is_in_html_tag(&html_tags, byte_pos) {
766                                continue;
767                            }
768
769                            // Skip if inside math context
770                            if is_in_math_context(ctx, byte_pos) {
771                                continue;
772                            }
773
774                            // Skip if inside table cell
775                            if is_in_table_cell(ctx, line_num + 1, col) {
776                                continue;
777                            }
778
779                            let byte_end = byte_pos + (full_match.end() - full_match.start());
780
781                            // Check if this shortcut ref overlaps with any parsed link/image
782                            let mut is_covered = false;
783                            for &(range_start, range_end) in &covered_ranges {
784                                if range_start <= byte_pos && byte_end <= range_end {
785                                    // This shortcut ref is completely within a parsed link/image
786                                    is_covered = true;
787                                    break;
788                                }
789                                if range_start > byte_end {
790                                    // No need to check further (ranges are sorted)
791                                    break;
792                                }
793                            }
794
795                            if is_covered {
796                                continue;
797                            }
798
799                            // More sophisticated checks to avoid false positives
800
801                            // Check 1: If preceded by ], this might be part of [text][ref]
802                            // Look for the pattern ...][ref] and check if there's a matching [ before
803                            let line_chars: Vec<char> = line.chars().collect();
804                            if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
805                                // Look backwards for a [ that would make this [text][ref]
806                                let mut bracket_count = 1; // We already saw one ]
807                                let mut check_pos = col.saturating_sub(2);
808                                let mut found_opening = false;
809
810                                while check_pos > 0 && check_pos < line_chars.len() {
811                                    match line_chars.get(check_pos) {
812                                        Some(&']') => bracket_count += 1,
813                                        Some(&'[') => {
814                                            bracket_count -= 1;
815                                            if bracket_count == 0 {
816                                                // Check if this [ is escaped
817                                                if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
818                                                    found_opening = true;
819                                                }
820                                                break;
821                                            }
822                                        }
823                                        _ => {}
824                                    }
825                                    if check_pos == 0 {
826                                        break;
827                                    }
828                                    check_pos = check_pos.saturating_sub(1);
829                                }
830
831                                if found_opening {
832                                    // This is part of [text][ref], skip it
833                                    continue;
834                                }
835                            }
836
837                            // Check 2: If there's an escaped bracket pattern before this
838                            // e.g., \[text\][ref], the [ref] shouldn't be treated as a shortcut
839                            let before_text = &line[..col];
840                            if before_text.contains("\\]") {
841                                // Check if there's a \[ before the \]
842                                if let Some(escaped_close_pos) = before_text.rfind("\\]") {
843                                    let search_text = &before_text[..escaped_close_pos];
844                                    if search_text.contains("\\[") {
845                                        // This looks like \[...\][ref], skip it
846                                        continue;
847                                    }
848                                }
849                            }
850
851                            let match_len = full_match.end() - full_match.start();
852                            undefined.push((line_num, col, match_len, reference.to_string()));
853                            reported_refs.insert(reference_lower, true);
854                        }
855                    }
856                }
857            }
858        }
859
860        undefined
861    }
862}
863
864impl Rule for MD052ReferenceLinkImages {
865    fn name(&self) -> &'static str {
866        "MD052"
867    }
868
869    fn description(&self) -> &'static str {
870        "Reference links and images should use a reference that exists"
871    }
872
873    fn category(&self) -> RuleCategory {
874        RuleCategory::Link
875    }
876
877    fn fix_capability(&self) -> FixCapability {
878        FixCapability::Unfixable
879    }
880
881    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
882        let content = ctx.content;
883        let mut warnings = Vec::new();
884
885        // OPTIMIZATION: Early exit if no brackets at all
886        if !content.contains('[') {
887            return Ok(warnings);
888        }
889
890        // Check if we're in MkDocs mode from the context
891        let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
892
893        let references = self.extract_references(ctx);
894
895        // Use optimized detection method with cached link/image data
896        let lines = ctx.raw_lines();
897        for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
898            let line_content = lines.get(line_num).unwrap_or(&"");
899
900            // Calculate precise character range for the entire undefined reference
901            let (start_line, start_col, end_line, end_col) =
902                calculate_match_range(line_num + 1, line_content, col, match_len);
903
904            warnings.push(LintWarning {
905                rule_name: Some(self.name().to_string()),
906                line: start_line,
907                column: start_col,
908                end_line,
909                end_column: end_col,
910                message: format!("Reference '{reference}' not found"),
911                severity: Severity::Warning,
912                fix: None,
913            });
914        }
915
916        Ok(warnings)
917    }
918
919    /// Check if this rule should be skipped for performance
920    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
921        // Skip if content is empty or has no links/images
922        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
923    }
924
925    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
926        let content = ctx.content;
927        // No automatic fix available for undefined references
928        Ok(content.to_string())
929    }
930
931    fn as_any(&self) -> &dyn std::any::Any {
932        self
933    }
934
935    crate::impl_rule_config_methods!(MD052Config);
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941    use crate::lint_context::LintContext;
942
943    #[test]
944    fn test_valid_reference_link() {
945        let rule = MD052ReferenceLinkImages::new();
946        let content = "[text][ref]\n\n[ref]: https://example.com";
947        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948        let result = rule.check(&ctx).unwrap();
949
950        assert_eq!(result.len(), 0);
951    }
952
953    #[test]
954    fn test_undefined_reference_link() {
955        let rule = MD052ReferenceLinkImages::new();
956        let content = "[text][undefined]";
957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958        let result = rule.check(&ctx).unwrap();
959
960        assert_eq!(result.len(), 1);
961        assert!(result[0].message.contains("Reference 'undefined' not found"));
962    }
963
964    #[test]
965    fn test_undefined_reference_column_non_ascii_prefix() {
966        // Issue #670: columns are character offsets. A multi-byte prefix must not
967        // shift the reported column (the reference span is located via a char-based
968        // start_col fed into a byte-expecting range helper).
969        let rule = MD052ReferenceLinkImages::new();
970        // Character columns: 1:你 2:好 3:[ ...  The reference link starts at column 3.
971        let content = "你好[text][undefined]";
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973        let result = rule.check(&ctx).unwrap();
974
975        assert_eq!(result.len(), 1);
976        assert_eq!(
977            result[0].column, 3,
978            "Column must be a character offset, not a byte offset"
979        );
980    }
981
982    #[test]
983    fn test_valid_reference_image() {
984        let rule = MD052ReferenceLinkImages::new();
985        let content = "![alt][img]\n\n[img]: image.jpg";
986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987        let result = rule.check(&ctx).unwrap();
988
989        assert_eq!(result.len(), 0);
990    }
991
992    #[test]
993    fn test_undefined_reference_image() {
994        let rule = MD052ReferenceLinkImages::new();
995        let content = "![alt][missing]";
996        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997        let result = rule.check(&ctx).unwrap();
998
999        assert_eq!(result.len(), 1);
1000        assert!(result[0].message.contains("Reference 'missing' not found"));
1001    }
1002
1003    #[test]
1004    fn test_case_insensitive_references() {
1005        let rule = MD052ReferenceLinkImages::new();
1006        let content = "[Text][REF]\n\n[ref]: https://example.com";
1007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008        let result = rule.check(&ctx).unwrap();
1009
1010        assert_eq!(result.len(), 0);
1011    }
1012
1013    #[test]
1014    fn test_shortcut_reference_valid() {
1015        let rule = MD052ReferenceLinkImages::new();
1016        let content = "[ref]\n\n[ref]: https://example.com";
1017        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018        let result = rule.check(&ctx).unwrap();
1019
1020        assert_eq!(result.len(), 0);
1021    }
1022
1023    #[test]
1024    fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1025        // Shortcut syntax checking is disabled by default
1026        // Enable it to test undefined shortcut references
1027        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1028            shortcut_syntax: true,
1029            ..Default::default()
1030        });
1031        let content = "[undefined]";
1032        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033        let result = rule.check(&ctx).unwrap();
1034
1035        assert_eq!(result.len(), 1);
1036        assert!(result[0].message.contains("Reference 'undefined' not found"));
1037    }
1038
1039    #[test]
1040    fn test_shortcut_reference_not_checked_by_default() {
1041        // By default, shortcut references are NOT checked (matches markdownlint behavior)
1042        let rule = MD052ReferenceLinkImages::new();
1043        let content = "[undefined]";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let result = rule.check(&ctx).unwrap();
1046
1047        // Should be 0 because shortcut_syntax is false by default
1048        assert_eq!(result.len(), 0);
1049    }
1050
1051    #[test]
1052    fn test_inline_links_ignored() {
1053        let rule = MD052ReferenceLinkImages::new();
1054        let content = "[text](https://example.com)";
1055        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056        let result = rule.check(&ctx).unwrap();
1057
1058        assert_eq!(result.len(), 0);
1059    }
1060
1061    #[test]
1062    fn test_inline_images_ignored() {
1063        let rule = MD052ReferenceLinkImages::new();
1064        let content = "![alt](image.jpg)";
1065        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066        let result = rule.check(&ctx).unwrap();
1067
1068        assert_eq!(result.len(), 0);
1069    }
1070
1071    #[test]
1072    fn test_references_in_code_blocks_ignored() {
1073        let rule = MD052ReferenceLinkImages::new();
1074        let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1075        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076        let result = rule.check(&ctx).unwrap();
1077
1078        assert_eq!(result.len(), 0);
1079    }
1080
1081    #[test]
1082    fn test_references_in_inline_code_ignored() {
1083        let rule = MD052ReferenceLinkImages::new();
1084        let content = "`[undefined]`";
1085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086        let result = rule.check(&ctx).unwrap();
1087
1088        // References inside inline code spans should be ignored
1089        assert_eq!(result.len(), 0);
1090    }
1091
1092    #[test]
1093    fn test_comprehensive_inline_code_detection() {
1094        // Enable shortcut_syntax to test comprehensive detection
1095        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1096            shortcut_syntax: true,
1097            ..Default::default()
1098        });
1099        let content = r#"# Test
1100
1101This `[inside]` should be ignored.
1102This [outside] should be flagged.
1103Reference links `[text][ref]` in code are ignored.
1104Regular reference [text][missing] should be flagged.
1105Images `![alt][img]` in code are ignored.
1106Regular image ![alt][badimg] should be flagged.
1107
1108Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1109
1110```
1111[code block content] should be ignored
1112```
1113
1114`Multiple [refs] in [same] code span` ignored."#;
1115
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117        let result = rule.check(&ctx).unwrap();
1118
1119        // Should only flag: outside, missing, badimg, three (4 total)
1120        assert_eq!(result.len(), 4);
1121
1122        let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1123        assert!(messages.iter().any(|m| m.contains("outside")));
1124        assert!(messages.iter().any(|m| m.contains("missing")));
1125        assert!(messages.iter().any(|m| m.contains("badimg")));
1126        assert!(messages.iter().any(|m| m.contains("three")));
1127
1128        // Should NOT flag any references inside code spans
1129        assert!(!messages.iter().any(|m| m.contains("inside")));
1130        assert!(!messages.iter().any(|m| m.contains("one")));
1131        assert!(!messages.iter().any(|m| m.contains("two")));
1132        assert!(!messages.iter().any(|m| m.contains("refs")));
1133        assert!(!messages.iter().any(|m| m.contains("same")));
1134    }
1135
1136    #[test]
1137    fn test_multiple_undefined_references() {
1138        let rule = MD052ReferenceLinkImages::new();
1139        let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1140        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141        let result = rule.check(&ctx).unwrap();
1142
1143        assert_eq!(result.len(), 3);
1144        assert!(result[0].message.contains("ref1"));
1145        assert!(result[1].message.contains("ref2"));
1146        assert!(result[2].message.contains("ref3"));
1147    }
1148
1149    #[test]
1150    fn test_mixed_valid_and_undefined() {
1151        let rule = MD052ReferenceLinkImages::new();
1152        let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1153        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154        let result = rule.check(&ctx).unwrap();
1155
1156        assert_eq!(result.len(), 1);
1157        assert!(result[0].message.contains("missing"));
1158    }
1159
1160    #[test]
1161    fn test_empty_reference() {
1162        let rule = MD052ReferenceLinkImages::new();
1163        let content = "[text][]\n\n[ref]: https://example.com";
1164        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165        let result = rule.check(&ctx).unwrap();
1166
1167        // Empty reference should use the link text as reference
1168        assert_eq!(result.len(), 1);
1169    }
1170
1171    #[test]
1172    fn test_escaped_brackets_ignored() {
1173        let rule = MD052ReferenceLinkImages::new();
1174        let content = "\\[not a link\\]";
1175        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176        let result = rule.check(&ctx).unwrap();
1177
1178        assert_eq!(result.len(), 0);
1179    }
1180
1181    #[test]
1182    fn test_list_items_ignored() {
1183        let rule = MD052ReferenceLinkImages::new();
1184        let content = "- [undefined]\n* [another]\n+ [third]";
1185        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186        let result = rule.check(&ctx).unwrap();
1187
1188        // List items that look like shortcut references should be ignored
1189        assert_eq!(result.len(), 0);
1190    }
1191
1192    #[test]
1193    fn test_output_example_section_ignored() {
1194        // Enable shortcut_syntax to test example section handling
1195        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1196            shortcut_syntax: true,
1197            ..Default::default()
1198        });
1199        let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1200        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201        let result = rule.check(&ctx).unwrap();
1202
1203        // Only the reference outside the Output section should be flagged
1204        assert_eq!(result.len(), 1);
1205        assert!(result[0].message.contains("missing"));
1206    }
1207
1208    #[test]
1209    fn test_reference_definitions_in_code_blocks_ignored() {
1210        let rule = MD052ReferenceLinkImages::new();
1211        let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213        let result = rule.check(&ctx).unwrap();
1214
1215        // Reference defined in code block should not count
1216        assert_eq!(result.len(), 1);
1217        assert!(result[0].message.contains("ref"));
1218    }
1219
1220    #[test]
1221    fn test_multiple_references_to_same_undefined() {
1222        let rule = MD052ReferenceLinkImages::new();
1223        let content = "[first][missing] [second][missing] [third][missing]";
1224        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225        let result = rule.check(&ctx).unwrap();
1226
1227        // Should only report once per unique reference
1228        assert_eq!(result.len(), 1);
1229        assert!(result[0].message.contains("missing"));
1230    }
1231
1232    #[test]
1233    fn test_reference_with_special_characters() {
1234        let rule = MD052ReferenceLinkImages::new();
1235        let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237        let result = rule.check(&ctx).unwrap();
1238
1239        assert_eq!(result.len(), 0);
1240    }
1241
1242    #[test]
1243    fn test_issue_51_html_attribute_not_reference() {
1244        // Test for issue #51 - HTML attributes with square brackets shouldn't be treated as references
1245        let rule = MD052ReferenceLinkImages::new();
1246        let content = r#"# Example
1247
1248## Test
1249
1250Want to fill out this form?
1251
1252<form method="post">
1253    <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1254</form>"#;
1255        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256        let result = rule.check(&ctx).unwrap();
1257
1258        assert_eq!(
1259            result.len(),
1260            0,
1261            "HTML attributes with square brackets should not be flagged as undefined references"
1262        );
1263    }
1264
1265    #[test]
1266    fn test_extract_references() {
1267        let rule = MD052ReferenceLinkImages::new();
1268        let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1269        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270        let refs = rule.extract_references(&ctx);
1271
1272        assert_eq!(refs.len(), 3);
1273        assert!(refs.contains("ref1"));
1274        assert!(refs.contains("ref2"));
1275        assert!(refs.contains("ref3"));
1276    }
1277
1278    #[test]
1279    fn test_inline_code_not_flagged() {
1280        // Enable shortcut_syntax to test inline code detection
1281        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1282            shortcut_syntax: true,
1283            ..Default::default()
1284        });
1285
1286        // Test that arrays in inline code are not flagged as references
1287        let content = r#"# Test
1288
1289Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1290
1291Also, `[todo]` is not a reference link.
1292
1293But this [reference] should be flagged.
1294
1295And this `[inline code]` should not be flagged.
1296"#;
1297
1298        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299        let warnings = rule.check(&ctx).unwrap();
1300
1301        // Should only flag [reference], not the ones in backticks
1302        assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1303        assert!(warnings[0].message.contains("'reference'"));
1304    }
1305
1306    #[test]
1307    fn test_code_block_references_ignored() {
1308        // Enable shortcut_syntax to test code block handling
1309        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1310            shortcut_syntax: true,
1311            ..Default::default()
1312        });
1313
1314        let content = r#"# Test
1315
1316```markdown
1317[undefined] reference in code block
1318![undefined] image in code block
1319```
1320
1321[real-undefined] reference outside
1322"#;
1323
1324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325        let warnings = rule.check(&ctx).unwrap();
1326
1327        // Should only flag [real-undefined], not the ones in code block
1328        assert_eq!(warnings.len(), 1);
1329        assert!(warnings[0].message.contains("'real-undefined'"));
1330    }
1331
1332    #[test]
1333    fn test_html_comments_ignored() {
1334        // Test for issue #20 - MD052 should not flag content inside HTML comments
1335        let rule = MD052ReferenceLinkImages::new();
1336
1337        // Test the exact case from issue #20
1338        let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1339<!--- set_env EDITOR 'python3 fake_editor.py' -->
1340
1341```bash
1342$ python3 vote.py
13433 votes for: 2
13442 votes for: 3, 4
1345```"#;
1346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347        let result = rule.check(&ctx).unwrap();
1348        assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1349
1350        // Test various reference patterns inside HTML comments
1351        let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1352Normal [text][undefined]
1353<!-- Another [comment][with] references -->"#;
1354        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355        let result = rule.check(&ctx).unwrap();
1356        assert_eq!(
1357            result.len(),
1358            1,
1359            "Should only flag the undefined reference outside comments"
1360        );
1361        assert!(result[0].message.contains("undefined"));
1362
1363        // Test multi-line HTML comments
1364        let content = r#"<!--
1365[ref1]
1366[ref2][ref3]
1367-->
1368[actual][undefined]"#;
1369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370        let result = rule.check(&ctx).unwrap();
1371        assert_eq!(
1372            result.len(),
1373            1,
1374            "Should not flag references in multi-line HTML comments"
1375        );
1376        assert!(result[0].message.contains("undefined"));
1377
1378        // Test mixed scenarios
1379        let content = r#"<!-- Comment with [1:] pattern -->
1380Valid [link][ref]
1381<!-- More [refs][in][comments] -->
1382![image][missing]
1383
1384[ref]: https://example.com"#;
1385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386        let result = rule.check(&ctx).unwrap();
1387        assert_eq!(result.len(), 1, "Should only flag missing image reference");
1388        assert!(result[0].message.contains("missing"));
1389    }
1390
1391    #[test]
1392    fn test_frontmatter_ignored() {
1393        // Test for issue #24 - MD052 should not flag content inside frontmatter
1394        // Enable shortcut_syntax to test frontmatter handling
1395        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1396            shortcut_syntax: true,
1397            ..Default::default()
1398        });
1399
1400        // Test YAML frontmatter with arrays and references
1401        let content = r#"---
1402layout: post
1403title: "My Jekyll Post"
1404date: 2023-01-01
1405categories: blog
1406tags: ["test", "example"]
1407author: John Doe
1408---
1409
1410# My Blog Post
1411
1412This is the actual markdown content that should be linted.
1413
1414[undefined] reference should be flagged.
1415
1416## Section 1
1417
1418Some content here."#;
1419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420        let result = rule.check(&ctx).unwrap();
1421
1422        // Should only flag [undefined] in the content, not the ["test", "example"] array in frontmatter
1423        assert_eq!(
1424            result.len(),
1425            1,
1426            "Should only flag the undefined reference outside frontmatter"
1427        );
1428        assert!(result[0].message.contains("undefined"));
1429
1430        // Test TOML frontmatter
1431        let content = r#"+++
1432title = "My Post"
1433tags = ["example", "test"]
1434+++
1435
1436# Content
1437
1438[missing] reference should be flagged."#;
1439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440        let result = rule.check(&ctx).unwrap();
1441        assert_eq!(
1442            result.len(),
1443            1,
1444            "Should only flag the undefined reference outside TOML frontmatter"
1445        );
1446        assert!(result[0].message.contains("missing"));
1447    }
1448
1449    #[test]
1450    fn test_mkdocs_snippet_markers_not_flagged() {
1451        // Test for issue #68 - MkDocs snippet selection markers should not be flagged as undefined references
1452        // Enable shortcut_syntax to test snippet marker handling
1453        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1454            shortcut_syntax: true,
1455            ..Default::default()
1456        });
1457
1458        // Test snippet section markers
1459        let content = r#"# Document with MkDocs Snippets
1460
1461Some content here.
1462
1463# -8<- [start:remote-content]
1464
1465This is the remote content section.
1466
1467# -8<- [end:remote-content]
1468
1469More content here.
1470
1471<!-- --8<-- [start:another-section] -->
1472Content in another section
1473<!-- --8<-- [end:another-section] -->"#;
1474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1475        let result = rule.check(&ctx).unwrap();
1476
1477        // Should not flag any snippet markers as undefined references
1478        assert_eq!(
1479            result.len(),
1480            0,
1481            "Should not flag MkDocs snippet markers as undefined references"
1482        );
1483
1484        // Test that the snippet marker lines are properly skipped
1485        // but regular undefined references on other lines are still caught
1486        let content = r#"# Document
1487
1488# -8<- [start:section]
1489Content with [reference] inside snippet section
1490# -8<- [end:section]
1491
1492Regular [undefined] reference outside snippet markers."#;
1493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1494        let result = rule.check(&ctx).unwrap();
1495
1496        assert_eq!(
1497            result.len(),
1498            2,
1499            "Should flag undefined references but skip snippet marker lines"
1500        );
1501        // The references inside the content should be flagged, but not start: and end:
1502        assert!(result[0].message.contains("reference"));
1503        assert!(result[1].message.contains("undefined"));
1504
1505        // Test in standard mode - should flag the markers as undefined
1506        let content = r#"# Document
1507
1508# -8<- [start:section]
1509# -8<- [end:section]"#;
1510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511        let result = rule.check(&ctx).unwrap();
1512
1513        assert_eq!(
1514            result.len(),
1515            2,
1516            "In standard mode, snippet markers should be flagged as undefined references"
1517        );
1518    }
1519
1520    #[test]
1521    fn test_pandoc_citations_not_flagged() {
1522        // Test that Pandoc/RMarkdown/Quarto citation syntax is not flagged
1523        // Enable shortcut_syntax to test citation handling
1524        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1525            shortcut_syntax: true,
1526            ..Default::default()
1527        });
1528
1529        let content = r#"# Research Paper
1530
1531We are using the **bookdown** package [@R-bookdown] in this sample book.
1532This was built on top of R Markdown and **knitr** [@xie2015].
1533
1534Multiple citations [@citation1; @citation2; @citation3] are also supported.
1535
1536Regular [undefined] reference should still be flagged.
1537"#;
1538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539        let result = rule.check(&ctx).unwrap();
1540
1541        // Should only flag the undefined reference, not the citations
1542        assert_eq!(
1543            result.len(),
1544            1,
1545            "Should only flag the undefined reference, not Pandoc citations"
1546        );
1547        assert!(result[0].message.contains("undefined"));
1548    }
1549
1550    #[test]
1551    fn test_pandoc_inline_footnotes_not_flagged() {
1552        // Test that Pandoc inline footnote syntax is not flagged
1553        // Enable shortcut_syntax to test inline footnote handling
1554        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1555            shortcut_syntax: true,
1556            ..Default::default()
1557        });
1558
1559        let content = r#"# Math Document
1560
1561You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1562
1563Another footnote^[with some text and a [link](https://example.com)].
1564
1565But this [reference] without ^ should be flagged.
1566"#;
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        let result = rule.check(&ctx).unwrap();
1569
1570        // Should only flag the reference without ^
1571        assert_eq!(
1572            result.len(),
1573            1,
1574            "Should only flag the regular reference, not inline footnotes"
1575        );
1576        assert!(result[0].message.contains("reference"));
1577    }
1578
1579    #[test]
1580    fn test_github_alerts_not_flagged() {
1581        // Test for issue #60 - GitHub alerts should not be flagged as undefined references
1582        // Enable shortcut_syntax to test GitHub alert handling
1583        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1584            shortcut_syntax: true,
1585            ..Default::default()
1586        });
1587
1588        // Test various GitHub alert types
1589        let content = r#"# Document with GitHub Alerts
1590
1591> [!NOTE]
1592> This is a note alert.
1593
1594> [!TIP]
1595> This is a tip alert.
1596
1597> [!IMPORTANT]
1598> This is an important alert.
1599
1600> [!WARNING]
1601> This is a warning alert.
1602
1603> [!CAUTION]
1604> This is a caution alert.
1605
1606Regular content with [undefined] reference."#;
1607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608        let result = rule.check(&ctx).unwrap();
1609
1610        // Should only flag the undefined reference, not the GitHub alerts
1611        assert_eq!(
1612            result.len(),
1613            1,
1614            "Should only flag the undefined reference, not GitHub alerts"
1615        );
1616        assert!(result[0].message.contains("undefined"));
1617        assert_eq!(result[0].line, 18); // Line with [undefined]
1618
1619        // Test GitHub alerts with additional content
1620        let content = r#"> [!TIP]
1621> Here's a useful tip about [something].
1622> Multiple lines are allowed.
1623
1624[something] is mentioned but not defined."#;
1625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626        let result = rule.check(&ctx).unwrap();
1627
1628        // Should flag only the [something] outside blockquotes
1629        // The test shows we're only catching one, which might be correct behavior
1630        // matching markdownlint's approach
1631        assert_eq!(result.len(), 1, "Should flag undefined reference");
1632        assert!(result[0].message.contains("something"));
1633
1634        // Test GitHub alerts with proper references
1635        let content = r#"> [!NOTE]
1636> See [reference] for more details.
1637
1638[reference]: https://example.com"#;
1639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640        let result = rule.check(&ctx).unwrap();
1641
1642        // Should not flag anything - [!NOTE] is GitHub alert and [reference] is defined
1643        assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1644    }
1645
1646    #[test]
1647    fn test_ignore_config() {
1648        // Test that user-configured ignore list is respected
1649        let config = MD052Config {
1650            shortcut_syntax: true,
1651            ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1652        };
1653        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1654
1655        let content = r#"# Document with Custom Types
1656
1657Use [Vec] for dynamic arrays.
1658Use [HashMap] for key-value storage.
1659Use [Option] for nullable values.
1660Use [Result] for error handling.
1661"#;
1662        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663        let result = rule.check(&ctx).unwrap();
1664
1665        // Should only flag [Result] because it's not in ignore
1666        assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1667        assert!(result[0].message.contains("Result"));
1668    }
1669
1670    #[test]
1671    fn test_ignore_case_insensitive() {
1672        // Test that ignore list is case-insensitive
1673        let config = MD052Config {
1674            shortcut_syntax: true,
1675            ignore: vec!["Vec".to_string()],
1676        };
1677        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1678
1679        let content = r#"# Case Insensitivity Test
1680
1681[Vec] should be ignored.
1682[vec] should also be ignored (different case, same match).
1683[VEC] should also be ignored (different case, same match).
1684[undefined] should be flagged (not in ignore list).
1685"#;
1686        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687        let result = rule.check(&ctx).unwrap();
1688
1689        // Should only flag [undefined] because ignore is case-insensitive
1690        assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1691        assert!(result[0].message.contains("undefined"));
1692    }
1693
1694    #[test]
1695    fn test_ignore_empty_by_default() {
1696        // Test that empty ignore list doesn't affect existing behavior
1697        let rule = MD052ReferenceLinkImages::new();
1698
1699        let content = "[text][undefined]";
1700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701        let result = rule.check(&ctx).unwrap();
1702
1703        // Should still flag undefined references
1704        assert_eq!(result.len(), 1);
1705        assert!(result[0].message.contains("undefined"));
1706    }
1707
1708    #[test]
1709    fn test_ignore_with_reference_links() {
1710        // Test ignore list with full reference link syntax [text][ref]
1711        let config = MD052Config {
1712            shortcut_syntax: false,
1713            ignore: vec!["CustomType".to_string()],
1714        };
1715        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1716
1717        let content = r#"# Test
1718
1719See [documentation][CustomType] for details.
1720See [other docs][MissingRef] for more.
1721"#;
1722        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1723        let result = rule.check(&ctx).unwrap();
1724
1725        // Debug: print warnings if test fails
1726        for (i, w) in result.iter().enumerate() {
1727            eprintln!("Warning {}: {}", i, w.message);
1728        }
1729
1730        // Should flag [MissingRef] but not [CustomType]
1731        // Note: reference IDs are lowercased in the message
1732        assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1733        assert!(
1734            result[0].message.contains("missingref"),
1735            "Expected 'missingref' in message: {}",
1736            result[0].message
1737        );
1738    }
1739
1740    #[test]
1741    fn test_ignore_multiple() {
1742        // Test multiple ignored names work correctly
1743        let config = MD052Config {
1744            shortcut_syntax: true,
1745            ignore: vec![
1746                "i32".to_string(),
1747                "u64".to_string(),
1748                "String".to_string(),
1749                "Arc".to_string(),
1750                "Mutex".to_string(),
1751            ],
1752        };
1753        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1754
1755        let content = r#"# Types
1756
1757[i32] [u64] [String] [Arc] [Mutex] [Box]
1758"#;
1759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760        let result = rule.check(&ctx).unwrap();
1761
1762        // Note: i32 and u64 are already in the hardcoded list, so they'd be skipped anyway
1763        // String is NOT in the hardcoded list, so we test that the user config works
1764        // [Box] should be flagged (not in ignore)
1765        assert_eq!(result.len(), 1);
1766        assert!(result[0].message.contains("Box"));
1767    }
1768
1769    #[test]
1770    fn test_nested_code_fences_reference_extraction() {
1771        // Verify that extract_references uses LintContext's pre-computed in_code_block
1772        // so nested fences are handled correctly.
1773        // A 4-backtick fence wrapping a 3-backtick fence should treat the inner
1774        // ``` as content, not a code block boundary.
1775        let rule = MD052ReferenceLinkImages::new();
1776
1777        let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1779        let result = rule.check(&ctx).unwrap();
1780
1781        // The reference definition is inside a code block (the outer ````),
1782        // so it should NOT be recognized as a definition.
1783        // Therefore [ref-inside] should be flagged as undefined.
1784        assert_eq!(
1785            result.len(),
1786            1,
1787            "Reference defined inside nested code fence should not count as a definition"
1788        );
1789        assert!(result[0].message.contains("ref-inside"));
1790    }
1791
1792    #[test]
1793    fn test_pandoc_flavor_skips_citations() {
1794        // Pandoc citations ([@key]) are bibliography references, not undefined reference
1795        // links. MD052 should skip them under Pandoc flavor, mirroring the Quarto skip.
1796        let rule = MD052ReferenceLinkImages::new();
1797        let content = "See [@smith2020] for details.\n";
1798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1799        let result = rule.check(&ctx).unwrap();
1800        assert!(
1801            result.is_empty(),
1802            "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1803        );
1804    }
1805
1806    #[test]
1807    fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1808        // Implicit header references (`[Section name]` resolving to a heading
1809        // whose Pandoc slug matches the bracketed text) only flow through
1810        // MD052's shortcut-syntax regex path — pulldown-cmark drops them as
1811        // broken links before they reach `ctx.links`. Enabling
1812        // `shortcut_syntax = true` exercises the SHORTCUT_REF_REGEX scan where
1813        // the Pandoc implicit-header-ref guard lives.
1814        use crate::config::MarkdownFlavor;
1815        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1816            shortcut_syntax: true,
1817            ..Default::default()
1818        });
1819        let content = "# My Section\n\nSee [My Section] for details.\n";
1820
1821        // Under Standard flavor (no implicit-header-ref resolution), shortcut
1822        // checking flags the bracketed text as undefined.
1823        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1824        let std_result = rule.check(&ctx_std).unwrap();
1825        assert_eq!(
1826            std_result.len(),
1827            1,
1828            "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1829        );
1830
1831        // Under Pandoc flavor, the implicit-header-ref guard resolves it.
1832        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1833        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1834        assert!(
1835            pandoc_result.is_empty(),
1836            "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1837        );
1838    }
1839}