Skip to main content

rumdl_lib/rules/
md052_reference_links_images.rs

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