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        references
310    }
311
312    fn find_undefined_references(
313        &self,
314        references: &HashSet<String>,
315        ctx: &crate::lint_context::LintContext,
316        mkdocs_mode: bool,
317    ) -> Vec<(usize, usize, usize, String)> {
318        let mut undefined = Vec::new();
319        let mut reported_refs = HashMap::new();
320        let mut in_example_section = false;
321
322        // Get code spans and HTML tags once for the entire function
323        let code_spans = ctx.code_spans();
324        let html_tags = ctx.html_tags();
325
326        // Use cached data for reference links and images
327        for link in &ctx.links {
328            if !link.is_reference {
329                continue; // Skip inline links
330            }
331
332            // Skip links inside Jinja templates
333            if ctx.is_in_jinja_range(link.byte_offset) {
334                continue;
335            }
336
337            // Skip links inside code spans
338            if Self::is_in_code_span(link.byte_offset, &code_spans) {
339                continue;
340            }
341
342            // Skip links inside HTML comments (uses pre-computed ranges)
343            if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
344                continue;
345            }
346
347            // Skip links inside HTML tags
348            if Self::is_in_html_tag(&html_tags, link.byte_offset) {
349                continue;
350            }
351
352            // Skip links inside math contexts
353            if is_in_math_context(ctx, link.byte_offset) {
354                continue;
355            }
356
357            // Skip links inside table cells
358            if is_in_table_cell(ctx, link.line, link.start_col) {
359                continue;
360            }
361
362            // Skip links inside frontmatter
363            if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
364                continue;
365            }
366
367            // Skip Pandoc/Quarto citations ([@citation], @citation)
368            // Citations look like reference links but are bibliography references
369            if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
370                continue;
371            }
372
373            // Skip links inside shortcodes ({{< ... >}} or {{% ... %}})
374            // Shortcodes may contain template syntax that looks like reference links
375            if ctx.is_in_shortcode(link.byte_offset) {
376                continue;
377            }
378
379            if let Some(ref_id) = &link.reference_id {
380                let reference_lower = ref_id.to_lowercase();
381
382                // Skip known non-reference patterns (markdown extensions, code examples)
383                if self.is_known_non_reference_pattern(ref_id) {
384                    continue;
385                }
386
387                // Skip MkDocs auto-references if in MkDocs mode
388                // Check both the reference_id and the link text for shorthand references
389                // Strip backticks since MkDocs resolves `module.Class` as module.Class
390                let stripped_ref = Self::strip_backticks(ref_id);
391                let stripped_text = Self::strip_backticks(&link.text);
392                if mkdocs_mode
393                    && (is_mkdocs_auto_reference(stripped_ref)
394                        || is_mkdocs_auto_reference(stripped_text)
395                        || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
396                        || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
397                {
398                    continue;
399                }
400
401                // Check if reference is defined
402                if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
403                    // Check if the line is in an example section or list item
404                    if let Some(line_info) = ctx.line_info(link.line) {
405                        if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
406                            in_example_section = true;
407                            continue;
408                        }
409
410                        if in_example_section {
411                            continue;
412                        }
413
414                        // Skip list items
415                        if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
416                            continue;
417                        }
418
419                        // Skip lines that are HTML content
420                        let trimmed = line_info.content(ctx.content).trim_start();
421                        if trimmed.starts_with('<') {
422                            continue;
423                        }
424                    }
425
426                    let match_len = link.byte_end - link.byte_offset;
427                    // calculate_match_range expects a byte offset within the line;
428                    // start_col is a character column, so derive the byte offset.
429                    let line_start = ctx.line_index.get_line_start_byte(link.line).unwrap_or(0);
430                    undefined.push((
431                        link.line - 1,
432                        link.byte_offset - line_start,
433                        match_len,
434                        ref_id.to_string(),
435                    ));
436                    reported_refs.insert(reference_lower, true);
437                }
438            }
439        }
440
441        // Use cached data for reference images
442        for image in &ctx.images {
443            if !image.is_reference {
444                continue; // Skip inline images
445            }
446
447            // Skip images inside Jinja templates
448            if ctx.is_in_jinja_range(image.byte_offset) {
449                continue;
450            }
451
452            // Skip images inside code spans
453            if Self::is_in_code_span(image.byte_offset, &code_spans) {
454                continue;
455            }
456
457            // Skip images inside HTML comments (uses pre-computed ranges)
458            if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
459                continue;
460            }
461
462            // Skip images inside HTML tags
463            if Self::is_in_html_tag(&html_tags, image.byte_offset) {
464                continue;
465            }
466
467            // Skip images inside math contexts
468            if is_in_math_context(ctx, image.byte_offset) {
469                continue;
470            }
471
472            // Skip images inside table cells
473            if is_in_table_cell(ctx, image.line, image.start_col) {
474                continue;
475            }
476
477            // Skip images inside frontmatter
478            if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
479                continue;
480            }
481
482            if let Some(ref_id) = &image.reference_id {
483                let reference_lower = ref_id.to_lowercase();
484
485                // Skip known non-reference patterns (markdown extensions, code examples)
486                if self.is_known_non_reference_pattern(ref_id) {
487                    continue;
488                }
489
490                // Skip MkDocs auto-references if in MkDocs mode
491                // Check both the reference_id and the alt text for shorthand references
492                // Strip backticks since MkDocs resolves `module.Class` as module.Class
493                let stripped_ref = Self::strip_backticks(ref_id);
494                let stripped_alt = Self::strip_backticks(&image.alt_text);
495                if mkdocs_mode
496                    && (is_mkdocs_auto_reference(stripped_ref)
497                        || is_mkdocs_auto_reference(stripped_alt)
498                        || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
499                        || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
500                {
501                    continue;
502                }
503
504                // Check if reference is defined
505                if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
506                    // Check if the line is in an example section or list item
507                    if let Some(line_info) = ctx.line_info(image.line) {
508                        if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
509                            in_example_section = true;
510                            continue;
511                        }
512
513                        if in_example_section {
514                            continue;
515                        }
516
517                        // Skip list items
518                        if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
519                            continue;
520                        }
521
522                        // Skip lines that are HTML content
523                        let trimmed = line_info.content(ctx.content).trim_start();
524                        if trimmed.starts_with('<') {
525                            continue;
526                        }
527                    }
528
529                    let match_len = image.byte_end - image.byte_offset;
530                    // calculate_match_range expects a byte offset within the line;
531                    // start_col is a character column, so derive the byte offset.
532                    let line_start = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
533                    undefined.push((
534                        image.line - 1,
535                        image.byte_offset - line_start,
536                        match_len,
537                        ref_id.to_string(),
538                    ));
539                    reported_refs.insert(reference_lower, true);
540                }
541            }
542        }
543
544        // Build a set of byte ranges that are already covered by parsed links/images
545        let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
546
547        // Add ranges from parsed links
548        for link in &ctx.links {
549            covered_ranges.push((link.byte_offset, link.byte_end));
550        }
551
552        // Add ranges from parsed images
553        for image in &ctx.images {
554            covered_ranges.push((image.byte_offset, image.byte_end));
555        }
556
557        // Sort ranges by start position
558        covered_ranges.sort_by_key(|&(start, _)| start);
559
560        // Handle shortcut references [text] which aren't captured in ctx.links
561        // Only check these if shortcut_syntax is enabled (default: false)
562        // Shortcut syntax is ambiguous because [text] could be a reference link
563        // OR just text in brackets (like spec notation in quotes)
564        if !self.config.shortcut_syntax {
565            return undefined;
566        }
567
568        // Need to use regex for shortcut references
569        let lines = ctx.raw_lines();
570        in_example_section = false; // Reset for line-by-line processing
571
572        for (line_num, line) in lines.iter().enumerate() {
573            // Skip lines in frontmatter or code blocks using LintContext's pre-computed info
574            if let Some(line_info) = ctx.line_info(line_num + 1)
575                && (line_info.in_front_matter || line_info.in_code_block)
576            {
577                continue;
578            }
579
580            // Check for example sections
581            if OUTPUT_EXAMPLE_START.is_match(line) {
582                in_example_section = true;
583                continue;
584            }
585
586            if in_example_section {
587                // Check if we're exiting the example section (another heading)
588                if line.starts_with('#') && !OUTPUT_EXAMPLE_START.is_match(line) {
589                    in_example_section = false;
590                } else {
591                    continue;
592                }
593            }
594
595            // Skip list items
596            if LIST_ITEM_REGEX.is_match(line) {
597                continue;
598            }
599
600            // Skip lines that are HTML content
601            let trimmed_line = line.trim_start();
602            if trimmed_line.starts_with('<') {
603                continue;
604            }
605
606            // Skip GitHub alerts/callouts (e.g., > [!TIP])
607            if GITHUB_ALERT_REGEX.is_match(line) {
608                continue;
609            }
610
611            // Skip abbreviation definitions (*[ABBR]: Definition)
612            // These are not reference links and should not be checked
613            if trimmed_line.starts_with("*[") {
614                continue;
615            }
616
617            // Collect positions of brackets that are part of URLs (IPv6, etc.)
618            // so we can exclude them from reference checking
619            let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
620            for mat in URL_WITH_BRACKETS.find_iter(line) {
621                // Find all bracket pairs within this URL match
622                let url_str = mat.as_str();
623                let url_start = mat.start();
624
625                // Find brackets within the URL (e.g., in https://[::1]:8080)
626                let mut idx = 0;
627                while idx < url_str.len() {
628                    if let Some(bracket_start) = url_str[idx..].find('[') {
629                        let bracket_start_abs = url_start + idx + bracket_start;
630                        if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
631                            let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
632                            url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
633                            idx += bracket_start + bracket_end + 2;
634                        } else {
635                            break;
636                        }
637                    } else {
638                        break;
639                    }
640                }
641            }
642
643            // Check shortcut references: [reference]
644            if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
645                for cap in captures {
646                    if let Some(ref_match) = cap.get(1) {
647                        // Check if this bracket is part of a URL (IPv6, etc.)
648                        let bracket_start = cap.get(0).unwrap().start();
649                        let bracket_end = cap.get(0).unwrap().end();
650
651                        // Skip if this bracket pair is within any URL bracket range
652                        let is_in_url = url_bracket_ranges
653                            .iter()
654                            .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
655
656                        if is_in_url {
657                            continue;
658                        }
659
660                        // Skip Pandoc/RMarkdown inline footnotes: ^[text]
661                        // Check if there's a ^ immediately before the opening bracket
662                        if bracket_start > 0 {
663                            // bracket_start is a byte offset, so we need to check the byte before
664                            if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
665                                && *byte == b'^'
666                            {
667                                continue; // This is an inline footnote, skip it
668                            }
669                        }
670
671                        let reference = ref_match.as_str();
672                        let reference_lower = reference.to_lowercase();
673
674                        // Skip known non-reference patterns (markdown extensions, code examples)
675                        if self.is_known_non_reference_pattern(reference) {
676                            continue;
677                        }
678
679                        // Skip GitHub alerts (including extended types)
680                        if let Some(alert_type) = reference.strip_prefix('!')
681                            && matches!(
682                                alert_type,
683                                "NOTE"
684                                    | "TIP"
685                                    | "WARNING"
686                                    | "IMPORTANT"
687                                    | "CAUTION"
688                                    | "INFO"
689                                    | "SUCCESS"
690                                    | "FAILURE"
691                                    | "DANGER"
692                                    | "BUG"
693                                    | "EXAMPLE"
694                                    | "QUOTE"
695                            )
696                        {
697                            continue;
698                        }
699
700                        // Skip MkDocs snippet section markers like [start:section] or [end:section]
701                        // when they appear as part of snippet syntax (e.g., # -8<- [start:section])
702                        if mkdocs_mode
703                            && (reference.starts_with("start:") || reference.starts_with("end:"))
704                            && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
705                                || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
706                        {
707                            continue;
708                        }
709
710                        // Skip MkDocs auto-references if in MkDocs mode
711                        // Strip backticks since MkDocs resolves `module.Class` as module.Class
712                        let stripped_ref = Self::strip_backticks(reference);
713                        if mkdocs_mode
714                            && (is_mkdocs_auto_reference(stripped_ref)
715                                || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
716                        {
717                            continue;
718                        }
719
720                        // Pandoc-flavor implicit header references: `[Section name]` resolves
721                        // to a heading whose Pandoc slug matches the bracketed text. These are
722                        // not undefined references — Pandoc renders them as anchor links.
723                        if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
724                            continue;
725                        }
726
727                        if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
728                            let full_match = cap.get(0).unwrap();
729                            let col = full_match.start();
730                            let line_start_byte = ctx.line_offsets[line_num];
731                            let byte_pos = line_start_byte + col;
732
733                            // Skip if inside code span
734                            let code_spans = ctx.code_spans();
735                            if Self::is_in_code_span(byte_pos, &code_spans) {
736                                continue;
737                            }
738
739                            // Skip if inside Jinja template
740                            if ctx.is_in_jinja_range(byte_pos) {
741                                continue;
742                            }
743
744                            // Skip if inside code block
745                            if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
746                                &ctx.code_blocks,
747                                byte_pos,
748                            ) {
749                                continue;
750                            }
751
752                            // Skip if inside HTML comment (uses pre-computed ranges)
753                            if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
754                                continue;
755                            }
756
757                            // Skip if inside HTML tag
758                            if Self::is_in_html_tag(&html_tags, byte_pos) {
759                                continue;
760                            }
761
762                            // Skip if inside math context
763                            if is_in_math_context(ctx, byte_pos) {
764                                continue;
765                            }
766
767                            // Skip if inside table cell
768                            if is_in_table_cell(ctx, line_num + 1, col) {
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        undefined
854    }
855}
856
857impl Rule for MD052ReferenceLinkImages {
858    fn name(&self) -> &'static str {
859        "MD052"
860    }
861
862    fn description(&self) -> &'static str {
863        "Reference links and images should use a reference that exists"
864    }
865
866    fn category(&self) -> RuleCategory {
867        RuleCategory::Link
868    }
869
870    fn fix_capability(&self) -> FixCapability {
871        FixCapability::Unfixable
872    }
873
874    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
875        let content = ctx.content;
876        let mut warnings = Vec::new();
877
878        // OPTIMIZATION: Early exit if no brackets at all
879        if !content.contains('[') {
880            return Ok(warnings);
881        }
882
883        // Check if we're in MkDocs mode from the context
884        let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
885
886        let references = self.extract_references(ctx);
887
888        // Use optimized detection method with cached link/image data
889        let lines = ctx.raw_lines();
890        for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
891            let line_content = lines.get(line_num).unwrap_or(&"");
892
893            // Calculate precise character range for the entire undefined reference
894            let (start_line, start_col, end_line, end_col) =
895                calculate_match_range(line_num + 1, line_content, col, match_len);
896
897            warnings.push(LintWarning {
898                rule_name: Some(self.name().to_string()),
899                line: start_line,
900                column: start_col,
901                end_line,
902                end_column: end_col,
903                message: format!("Reference '{reference}' not found"),
904                severity: Severity::Warning,
905                fix: None,
906            });
907        }
908
909        Ok(warnings)
910    }
911
912    /// Check if this rule should be skipped for performance
913    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
914        // Skip if content is empty or has no links/images
915        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
916    }
917
918    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
919        let content = ctx.content;
920        // No automatic fix available for undefined references
921        Ok(content.to_string())
922    }
923
924    fn as_any(&self) -> &dyn std::any::Any {
925        self
926    }
927
928    crate::impl_rule_config_methods!(MD052Config);
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::lint_context::LintContext;
935
936    #[test]
937    fn test_valid_reference_link() {
938        let rule = MD052ReferenceLinkImages::new();
939        let content = "[text][ref]\n\n[ref]: https://example.com";
940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941        let result = rule.check(&ctx).unwrap();
942
943        assert_eq!(result.len(), 0);
944    }
945
946    #[test]
947    fn test_undefined_reference_link() {
948        let rule = MD052ReferenceLinkImages::new();
949        let content = "[text][undefined]";
950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951        let result = rule.check(&ctx).unwrap();
952
953        assert_eq!(result.len(), 1);
954        assert!(result[0].message.contains("Reference 'undefined' not found"));
955    }
956
957    #[test]
958    fn test_undefined_reference_column_non_ascii_prefix() {
959        // Issue #670: columns are character offsets. A multi-byte prefix must not
960        // shift the reported column (the reference span is located via a char-based
961        // start_col fed into a byte-expecting range helper).
962        let rule = MD052ReferenceLinkImages::new();
963        // Character columns: 1:你 2:好 3:[ ...  The reference link starts at column 3.
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_eq!(
970            result[0].column, 3,
971            "Column must be a character offset, not a byte offset"
972        );
973    }
974
975    #[test]
976    fn test_valid_reference_image() {
977        let rule = MD052ReferenceLinkImages::new();
978        let content = "![alt][img]\n\n[img]: image.jpg";
979        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980        let result = rule.check(&ctx).unwrap();
981
982        assert_eq!(result.len(), 0);
983    }
984
985    #[test]
986    fn test_undefined_reference_image() {
987        let rule = MD052ReferenceLinkImages::new();
988        let content = "![alt][missing]";
989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990        let result = rule.check(&ctx).unwrap();
991
992        assert_eq!(result.len(), 1);
993        assert!(result[0].message.contains("Reference 'missing' not found"));
994    }
995
996    #[test]
997    fn test_case_insensitive_references() {
998        let rule = MD052ReferenceLinkImages::new();
999        let content = "[Text][REF]\n\n[ref]: https://example.com";
1000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001        let result = rule.check(&ctx).unwrap();
1002
1003        assert_eq!(result.len(), 0);
1004    }
1005
1006    #[test]
1007    fn test_shortcut_reference_valid() {
1008        let rule = MD052ReferenceLinkImages::new();
1009        let content = "[ref]\n\n[ref]: https://example.com";
1010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011        let result = rule.check(&ctx).unwrap();
1012
1013        assert_eq!(result.len(), 0);
1014    }
1015
1016    #[test]
1017    fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1018        // Shortcut syntax checking is disabled by default
1019        // Enable it to test undefined shortcut references
1020        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1021            shortcut_syntax: true,
1022            ..Default::default()
1023        });
1024        let content = "[undefined]";
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027
1028        assert_eq!(result.len(), 1);
1029        assert!(result[0].message.contains("Reference 'undefined' not found"));
1030    }
1031
1032    #[test]
1033    fn test_shortcut_reference_not_checked_by_default() {
1034        // By default, shortcut references are NOT checked (matches markdownlint behavior)
1035        let rule = MD052ReferenceLinkImages::new();
1036        let content = "[undefined]";
1037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038        let result = rule.check(&ctx).unwrap();
1039
1040        // Should be 0 because shortcut_syntax is false by default
1041        assert_eq!(result.len(), 0);
1042    }
1043
1044    #[test]
1045    fn test_inline_links_ignored() {
1046        let rule = MD052ReferenceLinkImages::new();
1047        let content = "[text](https://example.com)";
1048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049        let result = rule.check(&ctx).unwrap();
1050
1051        assert_eq!(result.len(), 0);
1052    }
1053
1054    #[test]
1055    fn test_inline_images_ignored() {
1056        let rule = MD052ReferenceLinkImages::new();
1057        let content = "![alt](image.jpg)";
1058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059        let result = rule.check(&ctx).unwrap();
1060
1061        assert_eq!(result.len(), 0);
1062    }
1063
1064    #[test]
1065    fn test_references_in_code_blocks_ignored() {
1066        let rule = MD052ReferenceLinkImages::new();
1067        let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1068        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069        let result = rule.check(&ctx).unwrap();
1070
1071        assert_eq!(result.len(), 0);
1072    }
1073
1074    #[test]
1075    fn test_references_in_inline_code_ignored() {
1076        let rule = MD052ReferenceLinkImages::new();
1077        let content = "`[undefined]`";
1078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079        let result = rule.check(&ctx).unwrap();
1080
1081        // References inside inline code spans should be ignored
1082        assert_eq!(result.len(), 0);
1083    }
1084
1085    #[test]
1086    fn test_comprehensive_inline_code_detection() {
1087        // Enable shortcut_syntax to test comprehensive detection
1088        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1089            shortcut_syntax: true,
1090            ..Default::default()
1091        });
1092        let content = r#"# Test
1093
1094This `[inside]` should be ignored.
1095This [outside] should be flagged.
1096Reference links `[text][ref]` in code are ignored.
1097Regular reference [text][missing] should be flagged.
1098Images `![alt][img]` in code are ignored.
1099Regular image ![alt][badimg] should be flagged.
1100
1101Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1102
1103```
1104[code block content] should be ignored
1105```
1106
1107`Multiple [refs] in [same] code span` ignored."#;
1108
1109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110        let result = rule.check(&ctx).unwrap();
1111
1112        // Should only flag: outside, missing, badimg, three (4 total)
1113        assert_eq!(result.len(), 4);
1114
1115        let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1116        assert!(messages.iter().any(|m| m.contains("outside")));
1117        assert!(messages.iter().any(|m| m.contains("missing")));
1118        assert!(messages.iter().any(|m| m.contains("badimg")));
1119        assert!(messages.iter().any(|m| m.contains("three")));
1120
1121        // Should NOT flag any references inside code spans
1122        assert!(!messages.iter().any(|m| m.contains("inside")));
1123        assert!(!messages.iter().any(|m| m.contains("one")));
1124        assert!(!messages.iter().any(|m| m.contains("two")));
1125        assert!(!messages.iter().any(|m| m.contains("refs")));
1126        assert!(!messages.iter().any(|m| m.contains("same")));
1127    }
1128
1129    #[test]
1130    fn test_multiple_undefined_references() {
1131        let rule = MD052ReferenceLinkImages::new();
1132        let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1133        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134        let result = rule.check(&ctx).unwrap();
1135
1136        assert_eq!(result.len(), 3);
1137        assert!(result[0].message.contains("ref1"));
1138        assert!(result[1].message.contains("ref2"));
1139        assert!(result[2].message.contains("ref3"));
1140    }
1141
1142    #[test]
1143    fn test_mixed_valid_and_undefined() {
1144        let rule = MD052ReferenceLinkImages::new();
1145        let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1146        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147        let result = rule.check(&ctx).unwrap();
1148
1149        assert_eq!(result.len(), 1);
1150        assert!(result[0].message.contains("missing"));
1151    }
1152
1153    #[test]
1154    fn test_empty_reference() {
1155        let rule = MD052ReferenceLinkImages::new();
1156        let content = "[text][]\n\n[ref]: https://example.com";
1157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158        let result = rule.check(&ctx).unwrap();
1159
1160        // Empty reference should use the link text as reference
1161        assert_eq!(result.len(), 1);
1162    }
1163
1164    #[test]
1165    fn test_escaped_brackets_ignored() {
1166        let rule = MD052ReferenceLinkImages::new();
1167        let content = "\\[not a link\\]";
1168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169        let result = rule.check(&ctx).unwrap();
1170
1171        assert_eq!(result.len(), 0);
1172    }
1173
1174    #[test]
1175    fn test_list_items_ignored() {
1176        let rule = MD052ReferenceLinkImages::new();
1177        let content = "- [undefined]\n* [another]\n+ [third]";
1178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179        let result = rule.check(&ctx).unwrap();
1180
1181        // List items that look like shortcut references should be ignored
1182        assert_eq!(result.len(), 0);
1183    }
1184
1185    #[test]
1186    fn test_output_example_section_ignored() {
1187        // Enable shortcut_syntax to test example section handling
1188        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1189            shortcut_syntax: true,
1190            ..Default::default()
1191        });
1192        let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1193        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194        let result = rule.check(&ctx).unwrap();
1195
1196        // Only the reference outside the Output section should be flagged
1197        assert_eq!(result.len(), 1);
1198        assert!(result[0].message.contains("missing"));
1199    }
1200
1201    #[test]
1202    fn test_reference_definitions_in_code_blocks_ignored() {
1203        let rule = MD052ReferenceLinkImages::new();
1204        let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1205        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206        let result = rule.check(&ctx).unwrap();
1207
1208        // Reference defined in code block should not count
1209        assert_eq!(result.len(), 1);
1210        assert!(result[0].message.contains("ref"));
1211    }
1212
1213    #[test]
1214    fn test_multiple_references_to_same_undefined() {
1215        let rule = MD052ReferenceLinkImages::new();
1216        let content = "[first][missing] [second][missing] [third][missing]";
1217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218        let result = rule.check(&ctx).unwrap();
1219
1220        // Should only report once per unique reference
1221        assert_eq!(result.len(), 1);
1222        assert!(result[0].message.contains("missing"));
1223    }
1224
1225    #[test]
1226    fn test_reference_with_special_characters() {
1227        let rule = MD052ReferenceLinkImages::new();
1228        let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1229        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230        let result = rule.check(&ctx).unwrap();
1231
1232        assert_eq!(result.len(), 0);
1233    }
1234
1235    #[test]
1236    fn test_issue_51_html_attribute_not_reference() {
1237        // Test for issue #51 - HTML attributes with square brackets shouldn't be treated as references
1238        let rule = MD052ReferenceLinkImages::new();
1239        let content = r#"# Example
1240
1241## Test
1242
1243Want to fill out this form?
1244
1245<form method="post">
1246    <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1247</form>"#;
1248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249        let result = rule.check(&ctx).unwrap();
1250
1251        assert_eq!(
1252            result.len(),
1253            0,
1254            "HTML attributes with square brackets should not be flagged as undefined references"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_extract_references() {
1260        let rule = MD052ReferenceLinkImages::new();
1261        let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263        let refs = rule.extract_references(&ctx);
1264
1265        assert_eq!(refs.len(), 3);
1266        assert!(refs.contains("ref1"));
1267        assert!(refs.contains("ref2"));
1268        assert!(refs.contains("ref3"));
1269    }
1270
1271    #[test]
1272    fn test_inline_code_not_flagged() {
1273        // Enable shortcut_syntax to test inline code detection
1274        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1275            shortcut_syntax: true,
1276            ..Default::default()
1277        });
1278
1279        // Test that arrays in inline code are not flagged as references
1280        let content = r#"# Test
1281
1282Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1283
1284Also, `[todo]` is not a reference link.
1285
1286But this [reference] should be flagged.
1287
1288And this `[inline code]` should not be flagged.
1289"#;
1290
1291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292        let warnings = rule.check(&ctx).unwrap();
1293
1294        // Should only flag [reference], not the ones in backticks
1295        assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1296        assert!(warnings[0].message.contains("'reference'"));
1297    }
1298
1299    #[test]
1300    fn test_code_block_references_ignored() {
1301        // Enable shortcut_syntax to test code block handling
1302        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1303            shortcut_syntax: true,
1304            ..Default::default()
1305        });
1306
1307        let content = r#"# Test
1308
1309```markdown
1310[undefined] reference in code block
1311![undefined] image in code block
1312```
1313
1314[real-undefined] reference outside
1315"#;
1316
1317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318        let warnings = rule.check(&ctx).unwrap();
1319
1320        // Should only flag [real-undefined], not the ones in code block
1321        assert_eq!(warnings.len(), 1);
1322        assert!(warnings[0].message.contains("'real-undefined'"));
1323    }
1324
1325    #[test]
1326    fn test_html_comments_ignored() {
1327        // Test for issue #20 - MD052 should not flag content inside HTML comments
1328        let rule = MD052ReferenceLinkImages::new();
1329
1330        // Test the exact case from issue #20
1331        let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1332<!--- set_env EDITOR 'python3 fake_editor.py' -->
1333
1334```bash
1335$ python3 vote.py
13363 votes for: 2
13372 votes for: 3, 4
1338```"#;
1339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340        let result = rule.check(&ctx).unwrap();
1341        assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1342
1343        // Test various reference patterns inside HTML comments
1344        let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1345Normal [text][undefined]
1346<!-- Another [comment][with] references -->"#;
1347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348        let result = rule.check(&ctx).unwrap();
1349        assert_eq!(
1350            result.len(),
1351            1,
1352            "Should only flag the undefined reference outside comments"
1353        );
1354        assert!(result[0].message.contains("undefined"));
1355
1356        // Test multi-line HTML comments
1357        let content = r#"<!--
1358[ref1]
1359[ref2][ref3]
1360-->
1361[actual][undefined]"#;
1362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363        let result = rule.check(&ctx).unwrap();
1364        assert_eq!(
1365            result.len(),
1366            1,
1367            "Should not flag references in multi-line HTML comments"
1368        );
1369        assert!(result[0].message.contains("undefined"));
1370
1371        // Test mixed scenarios
1372        let content = r#"<!-- Comment with [1:] pattern -->
1373Valid [link][ref]
1374<!-- More [refs][in][comments] -->
1375![image][missing]
1376
1377[ref]: https://example.com"#;
1378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379        let result = rule.check(&ctx).unwrap();
1380        assert_eq!(result.len(), 1, "Should only flag missing image reference");
1381        assert!(result[0].message.contains("missing"));
1382    }
1383
1384    #[test]
1385    fn test_frontmatter_ignored() {
1386        // Test for issue #24 - MD052 should not flag content inside frontmatter
1387        // Enable shortcut_syntax to test frontmatter handling
1388        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1389            shortcut_syntax: true,
1390            ..Default::default()
1391        });
1392
1393        // Test YAML frontmatter with arrays and references
1394        let content = r#"---
1395layout: post
1396title: "My Jekyll Post"
1397date: 2023-01-01
1398categories: blog
1399tags: ["test", "example"]
1400author: John Doe
1401---
1402
1403# My Blog Post
1404
1405This is the actual markdown content that should be linted.
1406
1407[undefined] reference should be flagged.
1408
1409## Section 1
1410
1411Some content here."#;
1412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413        let result = rule.check(&ctx).unwrap();
1414
1415        // Should only flag [undefined] in the content, not the ["test", "example"] array in frontmatter
1416        assert_eq!(
1417            result.len(),
1418            1,
1419            "Should only flag the undefined reference outside frontmatter"
1420        );
1421        assert!(result[0].message.contains("undefined"));
1422
1423        // Test TOML frontmatter
1424        let content = r#"+++
1425title = "My Post"
1426tags = ["example", "test"]
1427+++
1428
1429# Content
1430
1431[missing] reference should be flagged."#;
1432        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433        let result = rule.check(&ctx).unwrap();
1434        assert_eq!(
1435            result.len(),
1436            1,
1437            "Should only flag the undefined reference outside TOML frontmatter"
1438        );
1439        assert!(result[0].message.contains("missing"));
1440    }
1441
1442    #[test]
1443    fn test_mkdocs_snippet_markers_not_flagged() {
1444        // Test for issue #68 - MkDocs snippet selection markers should not be flagged as undefined references
1445        // Enable shortcut_syntax to test snippet marker handling
1446        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1447            shortcut_syntax: true,
1448            ..Default::default()
1449        });
1450
1451        // Test snippet section markers
1452        let content = r#"# Document with MkDocs Snippets
1453
1454Some content here.
1455
1456# -8<- [start:remote-content]
1457
1458This is the remote content section.
1459
1460# -8<- [end:remote-content]
1461
1462More content here.
1463
1464<!-- --8<-- [start:another-section] -->
1465Content in another section
1466<!-- --8<-- [end:another-section] -->"#;
1467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1468        let result = rule.check(&ctx).unwrap();
1469
1470        // Should not flag any snippet markers as undefined references
1471        assert_eq!(
1472            result.len(),
1473            0,
1474            "Should not flag MkDocs snippet markers as undefined references"
1475        );
1476
1477        // Test that the snippet marker lines are properly skipped
1478        // but regular undefined references on other lines are still caught
1479        let content = r#"# Document
1480
1481# -8<- [start:section]
1482Content with [reference] inside snippet section
1483# -8<- [end:section]
1484
1485Regular [undefined] reference outside snippet markers."#;
1486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1487        let result = rule.check(&ctx).unwrap();
1488
1489        assert_eq!(
1490            result.len(),
1491            2,
1492            "Should flag undefined references but skip snippet marker lines"
1493        );
1494        // The references inside the content should be flagged, but not start: and end:
1495        assert!(result[0].message.contains("reference"));
1496        assert!(result[1].message.contains("undefined"));
1497
1498        // Test in standard mode - should flag the markers as undefined
1499        let content = r#"# Document
1500
1501# -8<- [start:section]
1502# -8<- [end:section]"#;
1503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504        let result = rule.check(&ctx).unwrap();
1505
1506        assert_eq!(
1507            result.len(),
1508            2,
1509            "In standard mode, snippet markers should be flagged as undefined references"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_pandoc_citations_not_flagged() {
1515        // Test that Pandoc/RMarkdown/Quarto citation syntax is not flagged
1516        // Enable shortcut_syntax to test citation handling
1517        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1518            shortcut_syntax: true,
1519            ..Default::default()
1520        });
1521
1522        let content = r#"# Research Paper
1523
1524We are using the **bookdown** package [@R-bookdown] in this sample book.
1525This was built on top of R Markdown and **knitr** [@xie2015].
1526
1527Multiple citations [@citation1; @citation2; @citation3] are also supported.
1528
1529Regular [undefined] reference should still be flagged.
1530"#;
1531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532        let result = rule.check(&ctx).unwrap();
1533
1534        // Should only flag the undefined reference, not the citations
1535        assert_eq!(
1536            result.len(),
1537            1,
1538            "Should only flag the undefined reference, not Pandoc citations"
1539        );
1540        assert!(result[0].message.contains("undefined"));
1541    }
1542
1543    #[test]
1544    fn test_pandoc_inline_footnotes_not_flagged() {
1545        // Test that Pandoc inline footnote syntax is not flagged
1546        // Enable shortcut_syntax to test inline footnote handling
1547        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1548            shortcut_syntax: true,
1549            ..Default::default()
1550        });
1551
1552        let content = r#"# Math Document
1553
1554You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1555
1556Another footnote^[with some text and a [link](https://example.com)].
1557
1558But this [reference] without ^ should be flagged.
1559"#;
1560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561        let result = rule.check(&ctx).unwrap();
1562
1563        // Should only flag the reference without ^
1564        assert_eq!(
1565            result.len(),
1566            1,
1567            "Should only flag the regular reference, not inline footnotes"
1568        );
1569        assert!(result[0].message.contains("reference"));
1570    }
1571
1572    #[test]
1573    fn test_github_alerts_not_flagged() {
1574        // Test for issue #60 - GitHub alerts should not be flagged as undefined references
1575        // Enable shortcut_syntax to test GitHub alert handling
1576        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1577            shortcut_syntax: true,
1578            ..Default::default()
1579        });
1580
1581        // Test various GitHub alert types
1582        let content = r#"# Document with GitHub Alerts
1583
1584> [!NOTE]
1585> This is a note alert.
1586
1587> [!TIP]
1588> This is a tip alert.
1589
1590> [!IMPORTANT]
1591> This is an important alert.
1592
1593> [!WARNING]
1594> This is a warning alert.
1595
1596> [!CAUTION]
1597> This is a caution alert.
1598
1599Regular content with [undefined] reference."#;
1600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601        let result = rule.check(&ctx).unwrap();
1602
1603        // Should only flag the undefined reference, not the GitHub alerts
1604        assert_eq!(
1605            result.len(),
1606            1,
1607            "Should only flag the undefined reference, not GitHub alerts"
1608        );
1609        assert!(result[0].message.contains("undefined"));
1610        assert_eq!(result[0].line, 18); // Line with [undefined]
1611
1612        // Test GitHub alerts with additional content
1613        let content = r#"> [!TIP]
1614> Here's a useful tip about [something].
1615> Multiple lines are allowed.
1616
1617[something] is mentioned but not defined."#;
1618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619        let result = rule.check(&ctx).unwrap();
1620
1621        // Should flag only the [something] outside blockquotes
1622        // The test shows we're only catching one, which might be correct behavior
1623        // matching markdownlint's approach
1624        assert_eq!(result.len(), 1, "Should flag undefined reference");
1625        assert!(result[0].message.contains("something"));
1626
1627        // Test GitHub alerts with proper references
1628        let content = r#"> [!NOTE]
1629> See [reference] for more details.
1630
1631[reference]: https://example.com"#;
1632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633        let result = rule.check(&ctx).unwrap();
1634
1635        // Should not flag anything - [!NOTE] is GitHub alert and [reference] is defined
1636        assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1637    }
1638
1639    #[test]
1640    fn test_ignore_config() {
1641        // Test that user-configured ignore list is respected
1642        let config = MD052Config {
1643            shortcut_syntax: true,
1644            ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1645        };
1646        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1647
1648        let content = r#"# Document with Custom Types
1649
1650Use [Vec] for dynamic arrays.
1651Use [HashMap] for key-value storage.
1652Use [Option] for nullable values.
1653Use [Result] for error handling.
1654"#;
1655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656        let result = rule.check(&ctx).unwrap();
1657
1658        // Should only flag [Result] because it's not in ignore
1659        assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1660        assert!(result[0].message.contains("Result"));
1661    }
1662
1663    #[test]
1664    fn test_ignore_case_insensitive() {
1665        // Test that ignore list is case-insensitive
1666        let config = MD052Config {
1667            shortcut_syntax: true,
1668            ignore: vec!["Vec".to_string()],
1669        };
1670        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1671
1672        let content = r#"# Case Insensitivity Test
1673
1674[Vec] should be ignored.
1675[vec] should also be ignored (different case, same match).
1676[VEC] should also be ignored (different case, same match).
1677[undefined] should be flagged (not in ignore list).
1678"#;
1679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680        let result = rule.check(&ctx).unwrap();
1681
1682        // Should only flag [undefined] because ignore is case-insensitive
1683        assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1684        assert!(result[0].message.contains("undefined"));
1685    }
1686
1687    #[test]
1688    fn test_ignore_empty_by_default() {
1689        // Test that empty ignore list doesn't affect existing behavior
1690        let rule = MD052ReferenceLinkImages::new();
1691
1692        let content = "[text][undefined]";
1693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694        let result = rule.check(&ctx).unwrap();
1695
1696        // Should still flag undefined references
1697        assert_eq!(result.len(), 1);
1698        assert!(result[0].message.contains("undefined"));
1699    }
1700
1701    #[test]
1702    fn test_ignore_with_reference_links() {
1703        // Test ignore list with full reference link syntax [text][ref]
1704        let config = MD052Config {
1705            shortcut_syntax: false,
1706            ignore: vec!["CustomType".to_string()],
1707        };
1708        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1709
1710        let content = r#"# Test
1711
1712See [documentation][CustomType] for details.
1713See [other docs][MissingRef] for more.
1714"#;
1715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1716        let result = rule.check(&ctx).unwrap();
1717
1718        // Debug: print warnings if test fails
1719        for (i, w) in result.iter().enumerate() {
1720            eprintln!("Warning {}: {}", i, w.message);
1721        }
1722
1723        // Should flag [MissingRef] but not [CustomType]
1724        // Note: reference IDs are lowercased in the message
1725        assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1726        assert!(
1727            result[0].message.contains("missingref"),
1728            "Expected 'missingref' in message: {}",
1729            result[0].message
1730        );
1731    }
1732
1733    #[test]
1734    fn test_ignore_multiple() {
1735        // Test multiple ignored names work correctly
1736        let config = MD052Config {
1737            shortcut_syntax: true,
1738            ignore: vec![
1739                "i32".to_string(),
1740                "u64".to_string(),
1741                "String".to_string(),
1742                "Arc".to_string(),
1743                "Mutex".to_string(),
1744            ],
1745        };
1746        let rule = MD052ReferenceLinkImages::from_config_struct(config);
1747
1748        let content = r#"# Types
1749
1750[i32] [u64] [String] [Arc] [Mutex] [Box]
1751"#;
1752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753        let result = rule.check(&ctx).unwrap();
1754
1755        // Note: i32 and u64 are already in the hardcoded list, so they'd be skipped anyway
1756        // String is NOT in the hardcoded list, so we test that the user config works
1757        // [Box] should be flagged (not in ignore)
1758        assert_eq!(result.len(), 1);
1759        assert!(result[0].message.contains("Box"));
1760    }
1761
1762    #[test]
1763    fn test_nested_code_fences_reference_extraction() {
1764        // Verify that extract_references uses LintContext's pre-computed in_code_block
1765        // so nested fences are handled correctly.
1766        // A 4-backtick fence wrapping a 3-backtick fence should treat the inner
1767        // ``` as content, not a code block boundary.
1768        let rule = MD052ReferenceLinkImages::new();
1769
1770        let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1771        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772        let result = rule.check(&ctx).unwrap();
1773
1774        // The reference definition is inside a code block (the outer ````),
1775        // so it should NOT be recognized as a definition.
1776        // Therefore [ref-inside] should be flagged as undefined.
1777        assert_eq!(
1778            result.len(),
1779            1,
1780            "Reference defined inside nested code fence should not count as a definition"
1781        );
1782        assert!(result[0].message.contains("ref-inside"));
1783    }
1784
1785    #[test]
1786    fn test_pandoc_flavor_skips_citations() {
1787        // Pandoc citations ([@key]) are bibliography references, not undefined reference
1788        // links. MD052 should skip them under Pandoc flavor, mirroring the Quarto skip.
1789        let rule = MD052ReferenceLinkImages::new();
1790        let content = "See [@smith2020] for details.\n";
1791        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1792        let result = rule.check(&ctx).unwrap();
1793        assert!(
1794            result.is_empty(),
1795            "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1796        );
1797    }
1798
1799    #[test]
1800    fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1801        // Implicit header references (`[Section name]` resolving to a heading
1802        // whose Pandoc slug matches the bracketed text) only flow through
1803        // MD052's shortcut-syntax regex path — pulldown-cmark drops them as
1804        // broken links before they reach `ctx.links`. Enabling
1805        // `shortcut_syntax = true` exercises the SHORTCUT_REF_REGEX scan where
1806        // the Pandoc implicit-header-ref guard lives.
1807        use crate::config::MarkdownFlavor;
1808        let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1809            shortcut_syntax: true,
1810            ..Default::default()
1811        });
1812        let content = "# My Section\n\nSee [My Section] for details.\n";
1813
1814        // Under Standard flavor (no implicit-header-ref resolution), shortcut
1815        // checking flags the bracketed text as undefined.
1816        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1817        let std_result = rule.check(&ctx_std).unwrap();
1818        assert_eq!(
1819            std_result.len(),
1820            1,
1821            "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1822        );
1823
1824        // Under Pandoc flavor, the implicit-header-ref guard resolves it.
1825        let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1826        let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1827        assert!(
1828            pandoc_result.is_empty(),
1829            "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1830        );
1831    }
1832}