Skip to main content

rumdl_lib/rules/
md053_link_image_reference_definitions.rs

1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_line_range;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7use std::sync::LazyLock;
8
9// Shortcut reference links: [reference] - must not be followed by another bracket
10// Allow references followed by punctuation like colon, period, comma (e.g., "[reference]:", "[reference].")
11// Don't exclude references followed by ": " in the middle of a line (only at start of line)
12static SHORTCUT_REFERENCE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
13
14// Link/image reference definition format: [reference]: URL
15static REFERENCE_DEFINITION_REGEX: LazyLock<Regex> =
16    LazyLock::new(|| Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap());
17
18// Multi-line reference definition continuation pattern
19static CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+(.+)$").unwrap());
20
21/// Configuration for MD053 rule
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(rename_all = "kebab-case")]
24pub struct MD053Config {
25    /// List of reference names to keep even if unused
26    #[serde(default = "default_ignored_definitions")]
27    pub ignored_definitions: Vec<String>,
28}
29
30impl Default for MD053Config {
31    fn default() -> Self {
32        Self {
33            ignored_definitions: default_ignored_definitions(),
34        }
35    }
36}
37
38fn default_ignored_definitions() -> Vec<String> {
39    Vec::new()
40}
41
42impl RuleConfig for MD053Config {
43    const RULE_NAME: &'static str = "MD053";
44}
45
46/// Rule MD053: Link and image reference definitions should be used
47///
48/// See [docs/md053.md](../../docs/md053.md) for full documentation, configuration, and examples.
49///
50/// This rule is triggered when a link or image reference definition is declared but not used
51/// anywhere in the document. Unused reference definitions can create confusion and clutter.
52///
53/// ## Supported Reference Formats
54///
55/// This rule handles the following reference formats:
56///
57/// - **Full reference links/images**: `[text][reference]` or `![text][reference]`
58/// - **Collapsed reference links/images**: `[text][]` or `![text][]`
59/// - **Shortcut reference links**: `[reference]` (must be defined elsewhere)
60/// - **Reference definitions**: `[reference]: URL "Optional Title"`
61/// - **Multi-line reference definitions**:
62///   ```markdown
63///   [reference]: URL
64///      "Optional title continued on next line"
65///   ```
66///
67/// ## Configuration Options
68///
69/// The rule supports the following configuration options:
70///
71/// ```yaml
72/// MD053:
73///   ignored_definitions: []  # List of reference definitions to ignore (never report as unused)
74/// ```
75///
76/// ## Performance Optimizations
77///
78/// This rule implements various performance optimizations for handling large documents:
79///
80/// 1. **Caching**: The rule caches parsed definitions and references based on content hashing
81/// 2. **Efficient Reference Matching**: Uses HashMaps for O(1) lookups of definitions
82/// 3. **Smart Code Block Handling**: Efficiently skips references inside code blocks/spans
83/// 4. **Lazy Evaluation**: Only processes necessary portions of the document
84///
85/// ## Edge Cases Handled
86///
87/// - **Case insensitivity**: References are matched case-insensitively
88/// - **Escaped characters**: Properly processes escaped characters in references
89/// - **Unicode support**: Handles non-ASCII characters in references and URLs
90/// - **Code blocks**: Ignores references inside code blocks and spans
91/// - **Special characters**: Properly handles references with special characters
92///
93/// ## Fix Behavior
94///
95/// This rule does not provide automatic fixes. Unused references must be manually reviewed
96/// and removed, as they may be intentionally kept for future use or as templates.
97#[derive(Clone)]
98pub struct MD053LinkImageReferenceDefinitions {
99    config: MD053Config,
100}
101
102impl MD053LinkImageReferenceDefinitions {
103    /// Create a new instance of the MD053 rule
104    pub fn new() -> Self {
105        Self {
106            config: MD053Config::default(),
107        }
108    }
109
110    /// Create a new instance with the given configuration
111    pub fn from_config_struct(config: MD053Config) -> Self {
112        Self { config }
113    }
114
115    /// Returns true if this pattern should be skipped during reference detection
116    fn should_skip_pattern(text: &str) -> bool {
117        // Don't skip pure numeric patterns - they could be footnote references like [1]
118        // Only skip numeric ranges like [1:3], [0:10], etc.
119        if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
120            return true;
121        }
122
123        // Skip glob/wildcard patterns like [*], [...], [**]
124        if text == "*" || text == "..." || text == "**" {
125            return true;
126        }
127
128        // Skip patterns that are just punctuation or operators
129        if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
130            return true;
131        }
132
133        // Skip very short non-word patterns (likely operators or syntax)
134        // But allow single digits (could be footnotes) and single letters
135        if text.len() <= 2 && !text.chars().all(char::is_alphanumeric) {
136            return true;
137        }
138
139        // Skip descriptive prose patterns with colon like [default: the project root]
140        // But allow reference-style patterns like [RFC: 1234], [Issue: 42], [See: Section 2]
141        // These are distinguished by having a short prefix (typically 1-2 words) before the colon
142        if text.contains(':') && text.contains(' ') && !text.contains('`') {
143            // Check if this looks like a reference pattern (short prefix before colon)
144            // vs a prose description (longer text before colon)
145            if let Some((before_colon, _)) = text.split_once(':') {
146                let before_trimmed = before_colon.trim();
147                // Count words before colon - references typically have 1-2 words
148                let word_count = before_trimmed.split_whitespace().count();
149                // If there are 3+ words before the colon, it's likely prose
150                if word_count >= 3 {
151                    return true;
152                }
153            }
154        }
155
156        // Skip alert/admonition patterns like [!WARN], [!NOTE], etc.
157        if text.starts_with('!') {
158            return true;
159        }
160
161        // Note: We don't filter out patterns with backticks because backticks in reference names
162        // are valid markdown syntax, e.g., [`dataclasses.InitVar`] is a valid reference name
163
164        // Also don't filter out references with dots - these are legitimate reference names
165        // like [tool.ruff] or [os.path] which are valid markdown references
166
167        // Note: We don't filter based on word count anymore because legitimate references
168        // can have many words, like "python language reference for import statements"
169        // Word count filtering was causing false positives where valid references were
170        // being incorrectly flagged as unused
171
172        false
173    }
174
175    /// Unescape a reference string by removing backslashes before special characters.
176    ///
177    /// This allows matching references like `[example\-reference]` with definitions like
178    /// `[example-reference]: http://example.com`
179    ///
180    /// Returns the unescaped reference string.
181    fn unescape_reference(reference: &str) -> String {
182        // Remove backslashes before special characters
183        reference.replace('\\', "")
184    }
185
186    /// Check if a reference definition is likely a comment-style reference.
187    ///
188    /// This recognizes common community patterns for comments in markdown:
189    /// - `[//]: # (comment)` - Most popular pattern
190    /// - `[comment]: # (text)` - Semantic pattern
191    /// - `[note]: # (text)` - Documentation pattern
192    /// - `[todo]: # (text)` - Task tracking pattern
193    /// - Any reference with just `#` as the URL (fragment-only, often unused)
194    ///
195    /// While not part of any official markdown spec (CommonMark, GFM), these patterns
196    /// are widely used across 23+ markdown implementations as documented in the community.
197    ///
198    /// # Arguments
199    /// * `ref_id` - The reference ID (already normalized to lowercase)
200    /// * `url` - The URL from the reference definition
201    ///
202    /// # Returns
203    /// `true` if this looks like a comment-style reference that should be ignored
204    fn is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
205        // Common comment reference labels used in the community
206        const COMMENT_LABELS: &[&str] = &[
207            "//",      // [//]: # (comment) - most popular
208            "comment", // [comment]: # (text)
209            "note",    // [note]: # (text)
210            "todo",    // [todo]: # (text)
211            "fixme",   // [fixme]: # (text)
212            "hack",    // [hack]: # (text)
213        ];
214
215        let normalized_id = ref_id.trim().to_lowercase();
216        let normalized_url = url.trim();
217
218        // Pattern 1: Known comment labels with fragment URLs
219        // e.g., [//]: # (comment), [comment]: #section
220        if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
221            return true;
222        }
223
224        // Pattern 2: Any reference with just "#" as the URL
225        // This is often used as a comment placeholder or unused anchor
226        if normalized_url == "#" {
227            return true;
228        }
229
230        false
231    }
232
233    /// Find all link and image reference definitions in the content.
234    ///
235    /// This method returns a HashMap where the key is the normalized reference ID and the value is a vector of (start_line, end_line) tuples.
236    fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
237        let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
238
239        // First, add all reference definitions from context
240        for ref_def in &ctx.reference_defs {
241            // Skip comment-style references (e.g., [//]: # (comment))
242            if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
243                continue;
244            }
245
246            // Apply unescape to handle escaped characters in definitions
247            let normalized_id = Self::unescape_reference(&ref_def.id); // Already lowercase from context
248            definitions
249                .entry(normalized_id)
250                .or_default()
251                .push((ref_def.line - 1, ref_def.line - 1)); // Convert to 0-indexed
252        }
253
254        // Handle multi-line definitions by tracking the last definition seen
255        let lines = &ctx.lines;
256        let mut last_def_line: Option<usize> = None;
257        let mut last_def_id: Option<String> = None;
258
259        for (i, line_info) in lines.iter().enumerate() {
260            if line_info.in_code_block || line_info.in_front_matter {
261                last_def_line = None;
262                last_def_id = None;
263                continue;
264            }
265
266            let line = line_info.content(ctx.content);
267
268            if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line) {
269                // Track this definition for potential continuation
270                let ref_id = caps.get(1).unwrap().as_str().trim();
271                let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
272                last_def_line = Some(i);
273                last_def_id = Some(normalized_id);
274            } else if let Some(def_start) = last_def_line
275                && let Some(ref def_id) = last_def_id
276                && CONTINUATION_REGEX.is_match(line)
277            {
278                // Extend the definition's end line
279                if let Some(ranges) = definitions.get_mut(def_id.as_str())
280                    && let Some(last_range) = ranges.last_mut()
281                    && last_range.0 == def_start
282                {
283                    last_range.1 = i;
284                }
285            } else {
286                // Non-continuation, non-definition line resets tracking
287                last_def_line = None;
288                last_def_id = None;
289            }
290        }
291        definitions
292    }
293
294    /// Find all link and image reference reference usages in the content.
295    ///
296    /// This method returns a HashSet of all normalized reference IDs found in usage.
297    /// It leverages cached data from LintContext for efficiency.
298    fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
299        let mut usages: HashSet<String> = HashSet::new();
300
301        // 1. Add usages from cached reference links in LintContext
302        for link in &ctx.links {
303            if link.is_reference
304                && let Some(ref_id) = &link.reference_id
305                && !ctx.line_info(link.line).is_some_and(|info| info.in_code_block)
306            {
307                usages.insert(Self::unescape_reference(ref_id).to_lowercase());
308            }
309        }
310
311        // 2. Add usages from cached reference images in LintContext
312        for image in &ctx.images {
313            if image.is_reference
314                && let Some(ref_id) = &image.reference_id
315                && !ctx.line_info(image.line).is_some_and(|info| info.in_code_block)
316            {
317                usages.insert(Self::unescape_reference(ref_id).to_lowercase());
318            }
319        }
320
321        // 3. Add usages from footnote references (e.g., [^1], [^note])
322        for footnote_ref in &ctx.footnote_refs {
323            if !ctx.line_info(footnote_ref.line).is_some_and(|info| info.in_code_block) {
324                let ref_id = format!("^{}", footnote_ref.id);
325                usages.insert(ref_id.to_lowercase());
326            }
327        }
328
329        // 4. Find shortcut references [ref] not already handled by DocumentStructure.links
330        //    and ensure they are not within code spans or code blocks.
331        let code_spans = ctx.code_spans();
332
333        // Build sorted array of code span byte ranges for binary search
334        let mut span_ranges: Vec<(usize, usize)> = code_spans
335            .iter()
336            .map(|span| (span.byte_offset, span.byte_end))
337            .collect();
338        span_ranges.sort_unstable_by_key(|&(start, _)| start);
339
340        for line_info in &ctx.lines {
341            if line_info.in_code_block || line_info.in_front_matter {
342                continue;
343            }
344
345            let line_content = line_info.content(ctx.content);
346
347            // Quick check: skip lines without '[' (no possible references)
348            if !line_content.contains('[') {
349                continue;
350            }
351
352            // Skip lines that are reference definitions
353            if REFERENCE_DEFINITION_REGEX.is_match(line_content) {
354                continue;
355            }
356
357            for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(line_content) {
358                if let Some(full_match) = caps.get(0)
359                    && let Some(ref_id_match) = caps.get(1)
360                {
361                    let match_start = full_match.start();
362
363                    // Negative lookbehind: skip if preceded by ! (image syntax)
364                    if match_start > 0 && line_content.as_bytes()[match_start - 1] == b'!' {
365                        continue;
366                    }
367
368                    // Negative lookahead: skip if followed by [ (full reference link)
369                    let match_end = full_match.end();
370                    if match_end < line_content.len() && line_content.as_bytes()[match_end] == b'[' {
371                        continue;
372                    }
373
374                    let match_byte_offset = line_info.byte_offset + match_start;
375
376                    // Binary search for code span containment
377                    let in_code_span = span_ranges
378                        .binary_search_by(|&(start, end)| {
379                            if match_byte_offset < start {
380                                std::cmp::Ordering::Greater
381                            } else if match_byte_offset >= end {
382                                std::cmp::Ordering::Less
383                            } else {
384                                std::cmp::Ordering::Equal
385                            }
386                        })
387                        .is_ok();
388
389                    if !in_code_span {
390                        let ref_id = ref_id_match.as_str().trim();
391
392                        if !Self::should_skip_pattern(ref_id) {
393                            let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
394                            usages.insert(normalized_id);
395                        }
396                    }
397                }
398            }
399        }
400
401        usages
402    }
403
404    /// Get unused references with their line ranges.
405    ///
406    /// This method uses the cached definitions to improve performance.
407    ///
408    /// Note: References that are only used inside code blocks are still considered unused,
409    /// as code blocks are treated as examples or documentation rather than actual content.
410    fn get_unused_references(
411        &self,
412        definitions: &HashMap<String, Vec<(usize, usize)>>,
413        usages: &HashSet<String>,
414    ) -> Vec<(String, usize, usize)> {
415        let mut unused = Vec::new();
416        for (id, ranges) in definitions {
417            // If this id is not used anywhere and is not in the ignored list
418            if !usages.contains(id) && !self.is_ignored_definition(id) {
419                // Only report as unused if there's exactly one definition
420                // Multiple definitions are already reported as duplicates
421                if ranges.len() == 1 {
422                    let (start, end) = ranges[0];
423                    unused.push((id.clone(), start, end));
424                }
425                // If there are multiple definitions (duplicates), don't report them as unused
426                // They're already being reported as duplicate definitions
427            }
428        }
429        unused
430    }
431
432    /// Check if a definition should be ignored (kept even if unused)
433    fn is_ignored_definition(&self, definition_id: &str) -> bool {
434        self.config
435            .ignored_definitions
436            .iter()
437            .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
438    }
439}
440
441impl Default for MD053LinkImageReferenceDefinitions {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447impl Rule for MD053LinkImageReferenceDefinitions {
448    fn name(&self) -> &'static str {
449        "MD053"
450    }
451
452    fn description(&self) -> &'static str {
453        "Link and image reference definitions should be needed"
454    }
455
456    fn category(&self) -> RuleCategory {
457        RuleCategory::Link
458    }
459
460    fn fix_capability(&self) -> FixCapability {
461        FixCapability::Unfixable
462    }
463
464    /// Check the content for unused and duplicate link/image reference definitions.
465    ///
466    /// This implementation uses caching for improved performance on large documents.
467    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
468        // Find definitions and usages using LintContext
469        let definitions = self.find_definitions(ctx);
470        let usages = self.find_usages(ctx);
471
472        // Get unused references by comparing definitions and usages
473        let unused_refs = self.get_unused_references(&definitions, &usages);
474
475        let mut warnings = Vec::new();
476
477        // Check for duplicate definitions (case-insensitive per CommonMark spec)
478        let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); // lowercase -> (original, first_line)
479
480        for (definition_id, ranges) in &definitions {
481            // Skip ignored definitions for duplicate checking
482            if self.is_ignored_definition(definition_id) {
483                continue;
484            }
485
486            if ranges.len() > 1 {
487                // Multiple definitions with exact same ID (already lowercase)
488                for (i, &(start_line, _)) in ranges.iter().enumerate() {
489                    if i > 0 {
490                        // Skip the first occurrence, report all others
491                        let line_num = start_line + 1;
492                        let line_content = ctx.lines.get(start_line).map_or("", |l| l.content(ctx.content));
493                        let (start_line_1idx, start_col, end_line, end_col) =
494                            calculate_line_range(line_num, line_content);
495
496                        warnings.push(LintWarning {
497                            rule_name: Some(self.name().to_string()),
498                            line: start_line_1idx,
499                            column: start_col,
500                            end_line,
501                            end_column: end_col,
502                            message: format!("Duplicate link or image reference definition: [{definition_id}]"),
503                            severity: Severity::Warning,
504                            fix: None,
505                        });
506                    }
507                }
508            }
509
510            // Track for case-variant duplicates
511            if let Some(&(start_line, _)) = ranges.first() {
512                // Find the original case version from the line
513                if let Some(line_info) = ctx.lines.get(start_line)
514                    && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
515                {
516                    let original_id = caps.get(1).unwrap().as_str().trim();
517                    let lower_id = original_id.to_lowercase();
518
519                    if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
520                        // Found a case-variant duplicate
521                        if first_original != original_id {
522                            let line_num = start_line + 1;
523                            let line_content = line_info.content(ctx.content);
524                            let (start_line_1idx, start_col, end_line, end_col) =
525                                calculate_line_range(line_num, line_content);
526
527                            warnings.push(LintWarning {
528                                    rule_name: Some(self.name().to_string()),
529                                    line: start_line_1idx,
530                                    column: start_col,
531                                    end_line,
532                                    end_column: end_col,
533                                    message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
534                                                   original_id, first_original, first_line + 1),
535                                    severity: Severity::Warning,
536                                    fix: None,
537                                });
538                        }
539                    } else {
540                        seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
541                    }
542                }
543            }
544        }
545
546        // Create warnings for unused references
547        for (definition, start, _end) in unused_refs {
548            let line_num = start + 1; // 1-indexed line numbers
549            let line_content = ctx.lines.get(start).map_or("", |l| l.content(ctx.content));
550
551            // Calculate precise character range for the entire reference definition line
552            let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
553
554            warnings.push(LintWarning {
555                rule_name: Some(self.name().to_string()),
556                line: start_line,
557                column: start_col,
558                end_line,
559                end_column: end_col,
560                message: format!("Unused link/image reference: [{definition}]"),
561                severity: Severity::Warning,
562                fix: None, // MD053 is warning-only, no automatic fixes
563            });
564        }
565
566        Ok(warnings)
567    }
568
569    /// MD053 does not provide automatic fixes
570    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
571        // This rule is warning-only, no automatic fixes provided
572        Ok(ctx.content.to_string())
573    }
574
575    /// Check if this rule should be skipped for performance
576    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
577        // Skip if content is empty or has no links/images
578        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
579    }
580
581    fn as_any(&self) -> &dyn std::any::Any {
582        self
583    }
584
585    crate::impl_rule_config_methods!(MD053Config);
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::lint_context::LintContext;
592
593    #[test]
594    fn test_used_reference_link() {
595        let rule = MD053LinkImageReferenceDefinitions::new();
596        let content = "[text][ref]\n\n[ref]: https://example.com";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598        let result = rule.check(&ctx).unwrap();
599
600        assert_eq!(result.len(), 0);
601    }
602
603    #[test]
604    fn test_unused_reference_definition() {
605        let rule = MD053LinkImageReferenceDefinitions::new();
606        let content = "[unused]: https://example.com";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609
610        assert_eq!(result.len(), 1);
611        assert!(result[0].message.contains("Unused link/image reference: [unused]"));
612    }
613
614    #[test]
615    fn test_used_reference_image() {
616        let rule = MD053LinkImageReferenceDefinitions::new();
617        let content = "![alt][img]\n\n[img]: image.jpg";
618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619        let result = rule.check(&ctx).unwrap();
620
621        assert_eq!(result.len(), 0);
622    }
623
624    #[test]
625    fn test_case_insensitive_matching() {
626        let rule = MD053LinkImageReferenceDefinitions::new();
627        let content = "[Text][REF]\n\n[ref]: https://example.com";
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629        let result = rule.check(&ctx).unwrap();
630
631        assert_eq!(result.len(), 0);
632    }
633
634    #[test]
635    fn test_shortcut_reference() {
636        let rule = MD053LinkImageReferenceDefinitions::new();
637        let content = "[ref]\n\n[ref]: https://example.com";
638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639        let result = rule.check(&ctx).unwrap();
640
641        assert_eq!(result.len(), 0);
642    }
643
644    #[test]
645    fn test_collapsed_reference() {
646        let rule = MD053LinkImageReferenceDefinitions::new();
647        let content = "[ref][]\n\n[ref]: https://example.com";
648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649        let result = rule.check(&ctx).unwrap();
650
651        assert_eq!(result.len(), 0);
652    }
653
654    #[test]
655    fn test_multiple_unused_definitions() {
656        let rule = MD053LinkImageReferenceDefinitions::new();
657        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let result = rule.check(&ctx).unwrap();
660
661        assert_eq!(result.len(), 3);
662
663        // The warnings might not be in the same order, so collect all messages
664        let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
665        assert!(messages.iter().any(|m| m.contains("unused1")));
666        assert!(messages.iter().any(|m| m.contains("unused2")));
667        assert!(messages.iter().any(|m| m.contains("unused3")));
668    }
669
670    #[test]
671    fn test_mixed_used_and_unused() {
672        let rule = MD053LinkImageReferenceDefinitions::new();
673        let content = "[used]\n\n[used]: url1\n[unused]: url2";
674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675        let result = rule.check(&ctx).unwrap();
676
677        assert_eq!(result.len(), 1);
678        assert!(result[0].message.contains("unused"));
679    }
680
681    #[test]
682    fn test_multiline_definition() {
683        let rule = MD053LinkImageReferenceDefinitions::new();
684        let content = "[ref]: https://example.com\n  \"Title on next line\"";
685        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686        let result = rule.check(&ctx).unwrap();
687
688        assert_eq!(result.len(), 1); // Still unused
689    }
690
691    #[test]
692    fn test_reference_in_code_block() {
693        let rule = MD053LinkImageReferenceDefinitions::new();
694        let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696        let result = rule.check(&ctx).unwrap();
697
698        // Reference used only in code block is still considered unused
699        assert_eq!(result.len(), 1);
700    }
701
702    #[test]
703    fn test_reference_in_inline_code() {
704        let rule = MD053LinkImageReferenceDefinitions::new();
705        let content = "`[ref]`\n\n[ref]: https://example.com";
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let result = rule.check(&ctx).unwrap();
708
709        // Reference in inline code is not a usage
710        assert_eq!(result.len(), 1);
711    }
712
713    #[test]
714    fn test_escaped_reference() {
715        let rule = MD053LinkImageReferenceDefinitions::new();
716        let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718        let result = rule.check(&ctx).unwrap();
719
720        // Should match despite escaping
721        assert_eq!(result.len(), 0);
722    }
723
724    #[test]
725    fn test_duplicate_definitions() {
726        let rule = MD053LinkImageReferenceDefinitions::new();
727        let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729        let result = rule.check(&ctx).unwrap();
730
731        // Should flag the duplicate definition even though it's used (matches markdownlint)
732        assert_eq!(result.len(), 1);
733    }
734
735    #[test]
736    fn test_fix_returns_original() {
737        // MD053 is warning-only, fix should return original content
738        let rule = MD053LinkImageReferenceDefinitions::new();
739        let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let fixed = rule.fix(&ctx).unwrap();
742
743        assert_eq!(fixed, content);
744    }
745
746    #[test]
747    fn test_fix_preserves_content() {
748        // MD053 is warning-only, fix should preserve all content
749        let rule = MD053LinkImageReferenceDefinitions::new();
750        let content = "Content\n\n[unused]: url\n\nMore content";
751        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752        let fixed = rule.fix(&ctx).unwrap();
753
754        assert_eq!(fixed, content);
755    }
756
757    #[test]
758    fn test_fix_does_not_remove() {
759        // MD053 is warning-only, fix should not remove anything
760        let rule = MD053LinkImageReferenceDefinitions::new();
761        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let fixed = rule.fix(&ctx).unwrap();
764
765        assert_eq!(fixed, content);
766    }
767
768    #[test]
769    fn test_special_characters_in_reference() {
770        let rule = MD053LinkImageReferenceDefinitions::new();
771        let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774
775        assert_eq!(result.len(), 0);
776    }
777
778    #[test]
779    fn test_find_definitions() {
780        let rule = MD053LinkImageReferenceDefinitions::new();
781        let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let defs = rule.find_definitions(&ctx);
784
785        assert_eq!(defs.len(), 3);
786        assert!(defs.contains_key("ref1"));
787        assert!(defs.contains_key("ref2"));
788        assert!(defs.contains_key("ref3"));
789    }
790
791    #[test]
792    fn test_find_usages() {
793        let rule = MD053LinkImageReferenceDefinitions::new();
794        let content = "[text][ref1] and [ref2] and ![img][ref3]";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796        let usages = rule.find_usages(&ctx);
797
798        assert!(usages.contains("ref1"));
799        assert!(usages.contains("ref2"));
800        assert!(usages.contains("ref3"));
801    }
802
803    #[test]
804    fn test_ignored_definitions_config() {
805        // Test with ignored definitions
806        let config = MD053Config {
807            ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
808        };
809        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
810
811        let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813        let result = rule.check(&ctx).unwrap();
814
815        // Should only flag "unused", not "todo" or "draft"
816        assert_eq!(result.len(), 1);
817        assert!(result[0].message.contains("unused"));
818        assert!(!result[0].message.contains("todo"));
819        assert!(!result[0].message.contains("draft"));
820    }
821
822    #[test]
823    fn test_ignored_definitions_case_insensitive() {
824        // Test case-insensitive matching of ignored definitions
825        let config = MD053Config {
826            ignored_definitions: vec!["TODO".to_string()],
827        };
828        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
829
830        let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832        let result = rule.check(&ctx).unwrap();
833
834        // Should only flag "unused", not "todo" (matches "TODO" case-insensitively)
835        assert_eq!(result.len(), 1);
836        assert!(result[0].message.contains("unused"));
837        assert!(!result[0].message.contains("todo"));
838    }
839
840    #[test]
841    fn test_default_config_section() {
842        let rule = MD053LinkImageReferenceDefinitions::default();
843        let config_section = rule.default_config_section();
844
845        assert!(config_section.is_some());
846        let (name, value) = config_section.unwrap();
847        assert_eq!(name, "MD053");
848
849        // Should contain the ignored_definitions option with default empty array
850        if let toml::Value::Table(table) = value {
851            assert!(table.contains_key("ignored-definitions"));
852            assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
853        } else {
854            panic!("Expected TOML table");
855        }
856    }
857
858    #[test]
859    fn test_fix_with_ignored_definitions() {
860        // MD053 is warning-only, fix should not remove anything even with ignored definitions
861        let config = MD053Config {
862            ignored_definitions: vec!["template".to_string()],
863        };
864        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
865
866        let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868        let fixed = rule.fix(&ctx).unwrap();
869
870        // Should keep everything since MD053 doesn't fix
871        assert_eq!(fixed, content);
872    }
873
874    #[test]
875    fn test_duplicate_definitions_exact_case() {
876        let rule = MD053LinkImageReferenceDefinitions::new();
877        let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
878        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879        let result = rule.check(&ctx).unwrap();
880
881        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
882        // Plus 1 unused warning
883        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
884        assert_eq!(duplicate_warnings.len(), 2);
885        assert_eq!(duplicate_warnings[0].line, 2);
886        assert_eq!(duplicate_warnings[1].line, 3);
887    }
888
889    #[test]
890    fn test_duplicate_definitions_case_variants() {
891        let rule = MD053LinkImageReferenceDefinitions::new();
892        let content =
893            "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895        let result = rule.check(&ctx).unwrap();
896
897        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
898        // Note: These are treated as exact duplicates since they normalize to the same ID
899        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
900        assert_eq!(duplicate_warnings.len(), 2);
901
902        // The exact duplicate messages don't include "conflicts with"
903        // Only case-variant duplicates with different normalized forms would
904        assert_eq!(duplicate_warnings[0].line, 2);
905        assert_eq!(duplicate_warnings[1].line, 3);
906    }
907
908    #[test]
909    fn test_duplicate_and_unused() {
910        let rule = MD053LinkImageReferenceDefinitions::new();
911        let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913        let result = rule.check(&ctx).unwrap();
914
915        // Should have 1 duplicate warning and 1 unused warning
916        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
917        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
918
919        assert_eq!(duplicate_warnings.len(), 1);
920        assert_eq!(unused_warnings.len(), 1);
921        assert_eq!(duplicate_warnings[0].line, 3); // Second [used] definition
922        assert_eq!(unused_warnings[0].line, 4); // [unused] definition
923    }
924
925    #[test]
926    fn test_duplicate_with_usage() {
927        let rule = MD053LinkImageReferenceDefinitions::new();
928        // Even if used, duplicates should still be reported
929        let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
930        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931        let result = rule.check(&ctx).unwrap();
932
933        // Should have 1 duplicate warning (no unused since it's referenced)
934        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
935        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
936
937        assert_eq!(duplicate_warnings.len(), 1);
938        assert_eq!(unused_warnings.len(), 0);
939        assert_eq!(duplicate_warnings[0].line, 4);
940    }
941
942    #[test]
943    fn test_no_duplicate_different_ids() {
944        let rule = MD053LinkImageReferenceDefinitions::new();
945        let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let result = rule.check(&ctx).unwrap();
948
949        // Should have no duplicate warnings, only unused warnings
950        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
951        assert_eq!(duplicate_warnings.len(), 0);
952    }
953
954    #[test]
955    fn test_comment_style_reference_double_slash() {
956        let rule = MD053LinkImageReferenceDefinitions::new();
957        // Most popular comment pattern: [//]: # (comment)
958        let content = "[//]: # (This is a comment)\n\nSome regular text.";
959        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960        let result = rule.check(&ctx).unwrap();
961
962        // Should not report as unused - it's recognized as a comment
963        assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
964    }
965
966    #[test]
967    fn test_comment_style_reference_comment_label() {
968        let rule = MD053LinkImageReferenceDefinitions::new();
969        // Semantic comment pattern: [comment]: # (text)
970        let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972        let result = rule.check(&ctx).unwrap();
973
974        // Should not report either as unused
975        assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
976    }
977
978    #[test]
979    fn test_comment_style_reference_todo_fixme() {
980        let rule = MD053LinkImageReferenceDefinitions::new();
981        // Task tracking patterns: [todo]: # and [fixme]: #
982        let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985
986        // Should not report any as unused
987        assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
988    }
989
990    #[test]
991    fn test_comment_style_reference_fragment_only() {
992        let rule = MD053LinkImageReferenceDefinitions::new();
993        // Any reference with just "#" as URL should be treated as a comment
994        let content = "[anything]: #\n[ref]: #\n\nSome text.";
995        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996        let result = rule.check(&ctx).unwrap();
997
998        // Should not report as unused - fragment-only URLs are often comments
999        assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1000    }
1001
1002    #[test]
1003    fn test_comment_vs_real_reference() {
1004        let rule = MD053LinkImageReferenceDefinitions::new();
1005        // Mix of comment and real reference - only real one should be flagged if unused
1006        let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008        let result = rule.check(&ctx).unwrap();
1009
1010        // Should only report the real reference as unused
1011        assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1012        assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1013    }
1014
1015    #[test]
1016    fn test_comment_with_fragment_section() {
1017        let rule = MD053LinkImageReferenceDefinitions::new();
1018        // Comment pattern with a fragment section (still a comment)
1019        let content = "[//]: #section (Comment about section)\n\nSome text.";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021        let result = rule.check(&ctx).unwrap();
1022
1023        // Should not report as unused - it's still a comment pattern
1024        assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1025    }
1026
1027    #[test]
1028    fn test_is_likely_comment_reference_helper() {
1029        // Test the helper function directly
1030        assert!(
1031            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1032            "[//]: # should be recognized as comment"
1033        );
1034        assert!(
1035            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1036            "[comment]: #section should be recognized as comment"
1037        );
1038        assert!(
1039            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1040            "[note]: # should be recognized as comment"
1041        );
1042        assert!(
1043            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1044            "[todo]: # should be recognized as comment"
1045        );
1046        assert!(
1047            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1048            "Any label with just '#' should be recognized as comment"
1049        );
1050        assert!(
1051            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1052            "Real URL should not be recognized as comment"
1053        );
1054        assert!(
1055            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1056            "Real URL should not be recognized as comment"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_reference_with_colon_in_name() {
1062        // References containing colons and spaces should be recognized as valid references
1063        let rule = MD053LinkImageReferenceDefinitions::new();
1064        let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1065        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066        let result = rule.check(&ctx).unwrap();
1067
1068        assert!(
1069            result.is_empty(),
1070            "Reference with colon should be recognized as used, got warnings: {result:?}"
1071        );
1072    }
1073
1074    #[test]
1075    fn test_reference_with_colon_various_styles() {
1076        // Test various RFC-style and similar references with colons
1077        let rule = MD053LinkImageReferenceDefinitions::new();
1078        let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1079
1080[RFC: 1234]: https://example.com/rfc1234
1081[Issue: 42]: https://example.com/issue42
1082[PR: 100]: https://example.com/pr100
1083"#;
1084        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085        let result = rule.check(&ctx).unwrap();
1086
1087        assert!(
1088            result.is_empty(),
1089            "All colon-style references should be recognized as used, got warnings: {result:?}"
1090        );
1091    }
1092
1093    #[test]
1094    fn test_should_skip_pattern_allows_rfc_style() {
1095        // Verify that should_skip_pattern does NOT skip RFC-style references with colons
1096        // This tests the fix for the bug where references with ": " were incorrectly skipped
1097        assert!(
1098            !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1099            "RFC-style references should NOT be skipped"
1100        );
1101        assert!(
1102            !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1103            "Issue-style references should NOT be skipped"
1104        );
1105        assert!(
1106            !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1107            "PR-style references should NOT be skipped"
1108        );
1109        assert!(
1110            !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1111            "References with 'See:' should NOT be skipped"
1112        );
1113        assert!(
1114            !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1115            "References without space after colon should NOT be skipped"
1116        );
1117    }
1118
1119    #[test]
1120    fn test_should_skip_pattern_skips_prose() {
1121        // Verify that prose-like patterns (3+ words before colon) are still skipped
1122        assert!(
1123            MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1124            "Prose with 3+ words before colon SHOULD be skipped"
1125        );
1126        assert!(
1127            MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1128            "Prose with 4 words before colon SHOULD be skipped"
1129        );
1130        assert!(
1131            MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1132            "Prose-like descriptions SHOULD be skipped"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_many_code_spans_with_shortcut_references() {
1138        // Exercises the binary search path for code span containment.
1139        // With many code spans, linear search would be slow; binary search stays O(log n).
1140        let rule = MD053LinkImageReferenceDefinitions::new();
1141
1142        let mut lines = Vec::new();
1143        // Generate many code spans interleaved with shortcut references
1144        for i in 0..100 {
1145            lines.push(format!("Some `code{i}` text and [used_ref] here"));
1146        }
1147        lines.push(String::new());
1148        lines.push("[used_ref]: https://example.com".to_string());
1149        lines.push("[unused_ref]: https://unused.com".to_string());
1150
1151        let content = lines.join("\n");
1152        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154
1155        // used_ref is referenced 100 times, so only unused_ref should be reported
1156        assert_eq!(result.len(), 1);
1157        assert!(result[0].message.contains("unused_ref"));
1158    }
1159
1160    #[test]
1161    fn test_multiline_definition_continuation_tracking() {
1162        // Exercises the forward-tracking for multi-line definitions.
1163        // Definitions with title on the next line should be treated as a single unit.
1164        let rule = MD053LinkImageReferenceDefinitions::new();
1165        let content = "\
1166[ref1]: https://example.com
1167   \"Title on next line\"
1168
1169[ref2]: https://example2.com
1170   \"Another title\"
1171
1172Some text using [ref1] here.
1173";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175        let result = rule.check(&ctx).unwrap();
1176
1177        // ref1 is used, ref2 is not
1178        assert_eq!(result.len(), 1);
1179        assert!(result[0].message.contains("ref2"));
1180    }
1181
1182    #[test]
1183    fn test_code_span_at_boundary_does_not_hide_reference() {
1184        // A reference immediately after a code span should still be detected
1185        let rule = MD053LinkImageReferenceDefinitions::new();
1186        let content = "`code`[ref]\n\n[ref]: https://example.com";
1187        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188        let result = rule.check(&ctx).unwrap();
1189
1190        // [ref] is outside the code span, so it counts as a usage
1191        assert_eq!(result.len(), 0);
1192    }
1193
1194    #[test]
1195    fn test_reference_inside_code_span_not_counted() {
1196        // A reference inside a code span should NOT be counted as usage
1197        let rule = MD053LinkImageReferenceDefinitions::new();
1198        let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let result = rule.check(&ctx).unwrap();
1201
1202        // [ref] is inside a code span, so the definition is unused
1203        assert_eq!(result.len(), 1);
1204    }
1205
1206    #[test]
1207    fn test_shortcut_ref_at_byte_zero() {
1208        let rule = MD053LinkImageReferenceDefinitions::default();
1209        let content = "[example]\n\n[example]: https://example.com\n";
1210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211        let result = rule.check(&ctx).unwrap();
1212        assert!(
1213            result.is_empty(),
1214            "[ref] at byte 0 should be recognized as usage: {result:?}"
1215        );
1216    }
1217
1218    #[test]
1219    fn test_shortcut_ref_at_end_of_line() {
1220        let rule = MD053LinkImageReferenceDefinitions::default();
1221        let content = "Text [example]\n\n[example]: https://example.com\n";
1222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223        let result = rule.check(&ctx).unwrap();
1224        assert!(
1225            result.is_empty(),
1226            "[ref] at end of line should be recognized as usage: {result:?}"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_reference_in_multiline_footnote_not_false_positive() {
1232        // Issue #540: reference link inside multi-line footnote body
1233        // was falsely reported as unused because the 4-space-indented
1234        // footnote continuation was misidentified as an indented code block
1235        let rule = MD053LinkImageReferenceDefinitions::new();
1236        let content = "\
1237# Greetings
1238
1239This is a paragraph that has a footnote.[^footnote]
1240
1241[^footnote]:
1242    This footnote is long enough that it doesn't fit on just one line.
1243    Here is my [website][web].
1244
1245[web]: https://web.evanchen.cc
1246";
1247        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248        let result = rule.check(&ctx).unwrap();
1249        assert!(
1250            result.is_empty(),
1251            "Reference used inside multi-line footnote should not be flagged: {result:?}"
1252        );
1253    }
1254
1255    #[test]
1256    fn test_reference_in_single_line_footnote() {
1257        let rule = MD053LinkImageReferenceDefinitions::new();
1258        let content = "\
1259# Greetings
1260
1261This is a paragraph that has a footnote.[^footnote]
1262
1263[^footnote]: Here is my [website][web].
1264
1265[web]: https://web.evanchen.cc
1266";
1267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268        let result = rule.check(&ctx).unwrap();
1269        assert!(
1270            result.is_empty(),
1271            "Reference used inside single-line footnote should not be flagged: {result:?}"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_shortcut_reference_in_multiline_footnote() {
1277        // Shortcut reference [web] (not full [text][web]) inside footnote
1278        let rule = MD053LinkImageReferenceDefinitions::new();
1279        let content = "\
1280Text with footnote.[^note]
1281
1282[^note]:
1283    See [web] for details.
1284
1285[web]: https://example.com
1286";
1287        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288        let result = rule.check(&ctx).unwrap();
1289        assert!(
1290            result.is_empty(),
1291            "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1292        );
1293    }
1294
1295    #[test]
1296    fn test_unused_reference_not_in_footnote_still_flagged() {
1297        // Ensure we don't accidentally suppress real unused references
1298        let rule = MD053LinkImageReferenceDefinitions::new();
1299        let content = "\
1300# Greetings
1301
1302This is a paragraph that has a footnote.[^footnote]
1303
1304[^footnote]:
1305    This footnote is long enough.
1306
1307[unused]: https://example.com
1308";
1309        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310        let result = rule.check(&ctx).unwrap();
1311        assert_eq!(result.len(), 1);
1312        assert!(result[0].message.contains("unused"));
1313    }
1314
1315    #[test]
1316    fn test_image_reference_in_multiline_footnote() {
1317        let rule = MD053LinkImageReferenceDefinitions::new();
1318        let content = "\
1319Text with footnote.[^note]
1320
1321[^note]:
1322    Here is a diagram:
1323    ![diagram][img]
1324
1325[img]: https://example.com/diagram.png
1326";
1327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328        let result = rule.check(&ctx).unwrap();
1329        assert!(
1330            result.is_empty(),
1331            "Image reference inside multi-line footnote should not be flagged: {result:?}"
1332        );
1333    }
1334
1335    #[test]
1336    fn test_multiple_references_in_one_footnote() {
1337        let rule = MD053LinkImageReferenceDefinitions::new();
1338        let content = "\
1339Text.[^note]
1340
1341[^note]:
1342    See [link1][ref1] and [link2][ref2] for details.
1343
1344[ref1]: https://example.com
1345[ref2]: https://example.org
1346";
1347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348        let result = rule.check(&ctx).unwrap();
1349        assert!(
1350            result.is_empty(),
1351            "Multiple references inside one footnote should all be recognized: {result:?}"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_reference_in_code_block_inside_footnote_not_counted() {
1357        // Fenced code blocks within footnotes should still be treated as code.
1358        // A [ref] pattern inside a fenced code block is not a usage.
1359        let rule = MD053LinkImageReferenceDefinitions::new();
1360        let content = "\
1361Text.[^code]
1362
1363[^code]:
1364    ```python
1365    x = [ref_like_syntax]
1366    ```
1367
1368[ref_like_syntax]: https://example.com
1369";
1370        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371        let result = rule.check(&ctx).unwrap();
1372        assert_eq!(
1373            result.len(),
1374            1,
1375            "Reference inside fenced code block within footnote should still be unused: {result:?}"
1376        );
1377        assert!(result[0].message.contains("ref_like_syntax"));
1378    }
1379
1380    #[test]
1381    fn test_nested_list_in_footnote_with_reference() {
1382        let rule = MD053LinkImageReferenceDefinitions::new();
1383        let content = "\
1384Text.[^deep]
1385
1386[^deep]:
1387    - List item
1388        - Nested with [link text][deep-ref]
1389
1390[deep-ref]: https://example.com
1391";
1392        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393        let result = rule.check(&ctx).unwrap();
1394        assert!(
1395            result.is_empty(),
1396            "Reference in nested list inside footnote should not be flagged: {result:?}"
1397        );
1398    }
1399}