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