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