Skip to main content

rumdl_lib/rules/
md052_reference_links_images.rs

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