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