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(|c| c.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.iter() {
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(|l| l.content(ctx.content)).unwrap_or("");
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(|l| l.content(ctx.content)).unwrap_or("");
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    fn default_config_section(&self) -> Option<(String, toml::Value)> {
586        let default_config = MD053Config::default();
587        let json_value = serde_json::to_value(&default_config).ok()?;
588        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
589        if let toml::Value::Table(table) = toml_value {
590            if !table.is_empty() {
591                Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
592            } else {
593                None
594            }
595        } else {
596            None
597        }
598    }
599
600    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
601    where
602        Self: Sized,
603    {
604        let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
605        Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::lint_context::LintContext;
613
614    #[test]
615    fn test_used_reference_link() {
616        let rule = MD053LinkImageReferenceDefinitions::new();
617        let content = "[text][ref]\n\n[ref]: https://example.com";
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_unused_reference_definition() {
626        let rule = MD053LinkImageReferenceDefinitions::new();
627        let content = "[unused]: 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(), 1);
632        assert!(result[0].message.contains("Unused link/image reference: [unused]"));
633    }
634
635    #[test]
636    fn test_used_reference_image() {
637        let rule = MD053LinkImageReferenceDefinitions::new();
638        let content = "![alt][img]\n\n[img]: image.jpg";
639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640        let result = rule.check(&ctx).unwrap();
641
642        assert_eq!(result.len(), 0);
643    }
644
645    #[test]
646    fn test_case_insensitive_matching() {
647        let rule = MD053LinkImageReferenceDefinitions::new();
648        let content = "[Text][REF]\n\n[ref]: https://example.com";
649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650        let result = rule.check(&ctx).unwrap();
651
652        assert_eq!(result.len(), 0);
653    }
654
655    #[test]
656    fn test_shortcut_reference() {
657        let rule = MD053LinkImageReferenceDefinitions::new();
658        let content = "[ref]\n\n[ref]: https://example.com";
659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660        let result = rule.check(&ctx).unwrap();
661
662        assert_eq!(result.len(), 0);
663    }
664
665    #[test]
666    fn test_collapsed_reference() {
667        let rule = MD053LinkImageReferenceDefinitions::new();
668        let content = "[ref][]\n\n[ref]: https://example.com";
669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670        let result = rule.check(&ctx).unwrap();
671
672        assert_eq!(result.len(), 0);
673    }
674
675    #[test]
676    fn test_multiple_unused_definitions() {
677        let rule = MD053LinkImageReferenceDefinitions::new();
678        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = rule.check(&ctx).unwrap();
681
682        assert_eq!(result.len(), 3);
683
684        // The warnings might not be in the same order, so collect all messages
685        let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
686        assert!(messages.iter().any(|m| m.contains("unused1")));
687        assert!(messages.iter().any(|m| m.contains("unused2")));
688        assert!(messages.iter().any(|m| m.contains("unused3")));
689    }
690
691    #[test]
692    fn test_mixed_used_and_unused() {
693        let rule = MD053LinkImageReferenceDefinitions::new();
694        let content = "[used]\n\n[used]: url1\n[unused]: url2";
695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696        let result = rule.check(&ctx).unwrap();
697
698        assert_eq!(result.len(), 1);
699        assert!(result[0].message.contains("unused"));
700    }
701
702    #[test]
703    fn test_multiline_definition() {
704        let rule = MD053LinkImageReferenceDefinitions::new();
705        let content = "[ref]: https://example.com\n  \"Title on next line\"";
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let result = rule.check(&ctx).unwrap();
708
709        assert_eq!(result.len(), 1); // Still unused
710    }
711
712    #[test]
713    fn test_reference_in_code_block() {
714        let rule = MD053LinkImageReferenceDefinitions::new();
715        let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result = rule.check(&ctx).unwrap();
718
719        // Reference used only in code block is still considered unused
720        assert_eq!(result.len(), 1);
721    }
722
723    #[test]
724    fn test_reference_in_inline_code() {
725        let rule = MD053LinkImageReferenceDefinitions::new();
726        let content = "`[ref]`\n\n[ref]: https://example.com";
727        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728        let result = rule.check(&ctx).unwrap();
729
730        // Reference in inline code is not a usage
731        assert_eq!(result.len(), 1);
732    }
733
734    #[test]
735    fn test_escaped_reference() {
736        let rule = MD053LinkImageReferenceDefinitions::new();
737        let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740
741        // Should match despite escaping
742        assert_eq!(result.len(), 0);
743    }
744
745    #[test]
746    fn test_duplicate_definitions() {
747        let rule = MD053LinkImageReferenceDefinitions::new();
748        let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let result = rule.check(&ctx).unwrap();
751
752        // Should flag the duplicate definition even though it's used (matches markdownlint)
753        assert_eq!(result.len(), 1);
754    }
755
756    #[test]
757    fn test_fix_returns_original() {
758        // MD053 is warning-only, fix should return original content
759        let rule = MD053LinkImageReferenceDefinitions::new();
760        let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let fixed = rule.fix(&ctx).unwrap();
763
764        assert_eq!(fixed, content);
765    }
766
767    #[test]
768    fn test_fix_preserves_content() {
769        // MD053 is warning-only, fix should preserve all content
770        let rule = MD053LinkImageReferenceDefinitions::new();
771        let content = "Content\n\n[unused]: url\n\nMore content";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let fixed = rule.fix(&ctx).unwrap();
774
775        assert_eq!(fixed, content);
776    }
777
778    #[test]
779    fn test_fix_does_not_remove() {
780        // MD053 is warning-only, fix should not remove anything
781        let rule = MD053LinkImageReferenceDefinitions::new();
782        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784        let fixed = rule.fix(&ctx).unwrap();
785
786        assert_eq!(fixed, content);
787    }
788
789    #[test]
790    fn test_special_characters_in_reference() {
791        let rule = MD053LinkImageReferenceDefinitions::new();
792        let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
793        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794        let result = rule.check(&ctx).unwrap();
795
796        assert_eq!(result.len(), 0);
797    }
798
799    #[test]
800    fn test_find_definitions() {
801        let rule = MD053LinkImageReferenceDefinitions::new();
802        let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
803        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804        let defs = rule.find_definitions(&ctx);
805
806        assert_eq!(defs.len(), 3);
807        assert!(defs.contains_key("ref1"));
808        assert!(defs.contains_key("ref2"));
809        assert!(defs.contains_key("ref3"));
810    }
811
812    #[test]
813    fn test_find_usages() {
814        let rule = MD053LinkImageReferenceDefinitions::new();
815        let content = "[text][ref1] and [ref2] and ![img][ref3]";
816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817        let usages = rule.find_usages(&ctx);
818
819        assert!(usages.contains("ref1"));
820        assert!(usages.contains("ref2"));
821        assert!(usages.contains("ref3"));
822    }
823
824    #[test]
825    fn test_ignored_definitions_config() {
826        // Test with ignored definitions
827        let config = MD053Config {
828            ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
829        };
830        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
831
832        let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834        let result = rule.check(&ctx).unwrap();
835
836        // Should only flag "unused", not "todo" or "draft"
837        assert_eq!(result.len(), 1);
838        assert!(result[0].message.contains("unused"));
839        assert!(!result[0].message.contains("todo"));
840        assert!(!result[0].message.contains("draft"));
841    }
842
843    #[test]
844    fn test_ignored_definitions_case_insensitive() {
845        // Test case-insensitive matching of ignored definitions
846        let config = MD053Config {
847            ignored_definitions: vec!["TODO".to_string()],
848        };
849        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
850
851        let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule.check(&ctx).unwrap();
854
855        // Should only flag "unused", not "todo" (matches "TODO" case-insensitively)
856        assert_eq!(result.len(), 1);
857        assert!(result[0].message.contains("unused"));
858        assert!(!result[0].message.contains("todo"));
859    }
860
861    #[test]
862    fn test_default_config_section() {
863        let rule = MD053LinkImageReferenceDefinitions::default();
864        let config_section = rule.default_config_section();
865
866        assert!(config_section.is_some());
867        let (name, value) = config_section.unwrap();
868        assert_eq!(name, "MD053");
869
870        // Should contain the ignored_definitions option with default empty array
871        if let toml::Value::Table(table) = value {
872            assert!(table.contains_key("ignored-definitions"));
873            assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
874        } else {
875            panic!("Expected TOML table");
876        }
877    }
878
879    #[test]
880    fn test_fix_with_ignored_definitions() {
881        // MD053 is warning-only, fix should not remove anything even with ignored definitions
882        let config = MD053Config {
883            ignored_definitions: vec!["template".to_string()],
884        };
885        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
886
887        let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let fixed = rule.fix(&ctx).unwrap();
890
891        // Should keep everything since MD053 doesn't fix
892        assert_eq!(fixed, content);
893    }
894
895    #[test]
896    fn test_duplicate_definitions_exact_case() {
897        let rule = MD053LinkImageReferenceDefinitions::new();
898        let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900        let result = rule.check(&ctx).unwrap();
901
902        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
903        // Plus 1 unused warning
904        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
905        assert_eq!(duplicate_warnings.len(), 2);
906        assert_eq!(duplicate_warnings[0].line, 2);
907        assert_eq!(duplicate_warnings[1].line, 3);
908    }
909
910    #[test]
911    fn test_duplicate_definitions_case_variants() {
912        let rule = MD053LinkImageReferenceDefinitions::new();
913        let content =
914            "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let result = rule.check(&ctx).unwrap();
917
918        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
919        // Note: These are treated as exact duplicates since they normalize to the same ID
920        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
921        assert_eq!(duplicate_warnings.len(), 2);
922
923        // The exact duplicate messages don't include "conflicts with"
924        // Only case-variant duplicates with different normalized forms would
925        assert_eq!(duplicate_warnings[0].line, 2);
926        assert_eq!(duplicate_warnings[1].line, 3);
927    }
928
929    #[test]
930    fn test_duplicate_and_unused() {
931        let rule = MD053LinkImageReferenceDefinitions::new();
932        let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
933        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934        let result = rule.check(&ctx).unwrap();
935
936        // Should have 1 duplicate warning and 1 unused warning
937        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
938        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
939
940        assert_eq!(duplicate_warnings.len(), 1);
941        assert_eq!(unused_warnings.len(), 1);
942        assert_eq!(duplicate_warnings[0].line, 3); // Second [used] definition
943        assert_eq!(unused_warnings[0].line, 4); // [unused] definition
944    }
945
946    #[test]
947    fn test_duplicate_with_usage() {
948        let rule = MD053LinkImageReferenceDefinitions::new();
949        // Even if used, duplicates should still be reported
950        let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
951        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952        let result = rule.check(&ctx).unwrap();
953
954        // Should have 1 duplicate warning (no unused since it's referenced)
955        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
956        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
957
958        assert_eq!(duplicate_warnings.len(), 1);
959        assert_eq!(unused_warnings.len(), 0);
960        assert_eq!(duplicate_warnings[0].line, 4);
961    }
962
963    #[test]
964    fn test_no_duplicate_different_ids() {
965        let rule = MD053LinkImageReferenceDefinitions::new();
966        let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968        let result = rule.check(&ctx).unwrap();
969
970        // Should have no duplicate warnings, only unused warnings
971        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
972        assert_eq!(duplicate_warnings.len(), 0);
973    }
974
975    #[test]
976    fn test_comment_style_reference_double_slash() {
977        let rule = MD053LinkImageReferenceDefinitions::new();
978        // Most popular comment pattern: [//]: # (comment)
979        let content = "[//]: # (This is a comment)\n\nSome regular text.";
980        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981        let result = rule.check(&ctx).unwrap();
982
983        // Should not report as unused - it's recognized as a comment
984        assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
985    }
986
987    #[test]
988    fn test_comment_style_reference_comment_label() {
989        let rule = MD053LinkImageReferenceDefinitions::new();
990        // Semantic comment pattern: [comment]: # (text)
991        let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993        let result = rule.check(&ctx).unwrap();
994
995        // Should not report either as unused
996        assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
997    }
998
999    #[test]
1000    fn test_comment_style_reference_todo_fixme() {
1001        let rule = MD053LinkImageReferenceDefinitions::new();
1002        // Task tracking patterns: [todo]: # and [fixme]: #
1003        let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1004        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005        let result = rule.check(&ctx).unwrap();
1006
1007        // Should not report any as unused
1008        assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1009    }
1010
1011    #[test]
1012    fn test_comment_style_reference_fragment_only() {
1013        let rule = MD053LinkImageReferenceDefinitions::new();
1014        // Any reference with just "#" as URL should be treated as a comment
1015        let content = "[anything]: #\n[ref]: #\n\nSome text.";
1016        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017        let result = rule.check(&ctx).unwrap();
1018
1019        // Should not report as unused - fragment-only URLs are often comments
1020        assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1021    }
1022
1023    #[test]
1024    fn test_comment_vs_real_reference() {
1025        let rule = MD053LinkImageReferenceDefinitions::new();
1026        // Mix of comment and real reference - only real one should be flagged if unused
1027        let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029        let result = rule.check(&ctx).unwrap();
1030
1031        // Should only report the real reference as unused
1032        assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1033        assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1034    }
1035
1036    #[test]
1037    fn test_comment_with_fragment_section() {
1038        let rule = MD053LinkImageReferenceDefinitions::new();
1039        // Comment pattern with a fragment section (still a comment)
1040        let content = "[//]: #section (Comment about section)\n\nSome text.";
1041        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042        let result = rule.check(&ctx).unwrap();
1043
1044        // Should not report as unused - it's still a comment pattern
1045        assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1046    }
1047
1048    #[test]
1049    fn test_is_likely_comment_reference_helper() {
1050        // Test the helper function directly
1051        assert!(
1052            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1053            "[//]: # should be recognized as comment"
1054        );
1055        assert!(
1056            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1057            "[comment]: #section should be recognized as comment"
1058        );
1059        assert!(
1060            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1061            "[note]: # should be recognized as comment"
1062        );
1063        assert!(
1064            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1065            "[todo]: # should be recognized as comment"
1066        );
1067        assert!(
1068            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1069            "Any label with just '#' should be recognized as comment"
1070        );
1071        assert!(
1072            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1073            "Real URL should not be recognized as comment"
1074        );
1075        assert!(
1076            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1077            "Real URL should not be recognized as comment"
1078        );
1079    }
1080
1081    #[test]
1082    fn test_reference_with_colon_in_name() {
1083        // References containing colons and spaces should be recognized as valid references
1084        let rule = MD053LinkImageReferenceDefinitions::new();
1085        let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087        let result = rule.check(&ctx).unwrap();
1088
1089        assert!(
1090            result.is_empty(),
1091            "Reference with colon should be recognized as used, got warnings: {result:?}"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_reference_with_colon_various_styles() {
1097        // Test various RFC-style and similar references with colons
1098        let rule = MD053LinkImageReferenceDefinitions::new();
1099        let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1100
1101[RFC: 1234]: https://example.com/rfc1234
1102[Issue: 42]: https://example.com/issue42
1103[PR: 100]: https://example.com/pr100
1104"#;
1105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106        let result = rule.check(&ctx).unwrap();
1107
1108        assert!(
1109            result.is_empty(),
1110            "All colon-style references should be recognized as used, got warnings: {result:?}"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_should_skip_pattern_allows_rfc_style() {
1116        // Verify that should_skip_pattern does NOT skip RFC-style references with colons
1117        // This tests the fix for the bug where references with ": " were incorrectly skipped
1118        assert!(
1119            !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1120            "RFC-style references should NOT be skipped"
1121        );
1122        assert!(
1123            !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1124            "Issue-style references should NOT be skipped"
1125        );
1126        assert!(
1127            !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1128            "PR-style references should NOT be skipped"
1129        );
1130        assert!(
1131            !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1132            "References with 'See:' should NOT be skipped"
1133        );
1134        assert!(
1135            !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1136            "References without space after colon should NOT be skipped"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_should_skip_pattern_skips_prose() {
1142        // Verify that prose-like patterns (3+ words before colon) are still skipped
1143        assert!(
1144            MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1145            "Prose with 3+ words before colon SHOULD be skipped"
1146        );
1147        assert!(
1148            MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1149            "Prose with 4 words before colon SHOULD be skipped"
1150        );
1151        assert!(
1152            MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1153            "Prose-like descriptions SHOULD be skipped"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_many_code_spans_with_shortcut_references() {
1159        // Exercises the binary search path for code span containment.
1160        // With many code spans, linear search would be slow; binary search stays O(log n).
1161        let rule = MD053LinkImageReferenceDefinitions::new();
1162
1163        let mut lines = Vec::new();
1164        // Generate many code spans interleaved with shortcut references
1165        for i in 0..100 {
1166            lines.push(format!("Some `code{i}` text and [used_ref] here"));
1167        }
1168        lines.push(String::new());
1169        lines.push("[used_ref]: https://example.com".to_string());
1170        lines.push("[unused_ref]: https://unused.com".to_string());
1171
1172        let content = lines.join("\n");
1173        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1174        let result = rule.check(&ctx).unwrap();
1175
1176        // used_ref is referenced 100 times, so only unused_ref should be reported
1177        assert_eq!(result.len(), 1);
1178        assert!(result[0].message.contains("unused_ref"));
1179    }
1180
1181    #[test]
1182    fn test_multiline_definition_continuation_tracking() {
1183        // Exercises the forward-tracking for multi-line definitions.
1184        // Definitions with title on the next line should be treated as a single unit.
1185        let rule = MD053LinkImageReferenceDefinitions::new();
1186        let content = "\
1187[ref1]: https://example.com
1188   \"Title on next line\"
1189
1190[ref2]: https://example2.com
1191   \"Another title\"
1192
1193Some text using [ref1] here.
1194";
1195        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196        let result = rule.check(&ctx).unwrap();
1197
1198        // ref1 is used, ref2 is not
1199        assert_eq!(result.len(), 1);
1200        assert!(result[0].message.contains("ref2"));
1201    }
1202
1203    #[test]
1204    fn test_code_span_at_boundary_does_not_hide_reference() {
1205        // A reference immediately after a code span should still be detected
1206        let rule = MD053LinkImageReferenceDefinitions::new();
1207        let content = "`code`[ref]\n\n[ref]: https://example.com";
1208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209        let result = rule.check(&ctx).unwrap();
1210
1211        // [ref] is outside the code span, so it counts as a usage
1212        assert_eq!(result.len(), 0);
1213    }
1214
1215    #[test]
1216    fn test_reference_inside_code_span_not_counted() {
1217        // A reference inside a code span should NOT be counted as usage
1218        let rule = MD053LinkImageReferenceDefinitions::new();
1219        let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        let result = rule.check(&ctx).unwrap();
1222
1223        // [ref] is inside a code span, so the definition is unused
1224        assert_eq!(result.len(), 1);
1225    }
1226
1227    #[test]
1228    fn test_shortcut_ref_at_byte_zero() {
1229        let rule = MD053LinkImageReferenceDefinitions::default();
1230        let content = "[example]\n\n[example]: https://example.com\n";
1231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232        let result = rule.check(&ctx).unwrap();
1233        assert!(
1234            result.is_empty(),
1235            "[ref] at byte 0 should be recognized as usage: {result:?}"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_shortcut_ref_at_end_of_line() {
1241        let rule = MD053LinkImageReferenceDefinitions::default();
1242        let content = "Text [example]\n\n[example]: https://example.com\n";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244        let result = rule.check(&ctx).unwrap();
1245        assert!(
1246            result.is_empty(),
1247            "[ref] at end of line should be recognized as usage: {result:?}"
1248        );
1249    }
1250
1251    #[test]
1252    fn test_reference_in_multiline_footnote_not_false_positive() {
1253        // Issue #540: reference link inside multi-line footnote body
1254        // was falsely reported as unused because the 4-space-indented
1255        // footnote continuation was misidentified as an indented code block
1256        let rule = MD053LinkImageReferenceDefinitions::new();
1257        let content = "\
1258# Greetings
1259
1260This is a paragraph that has a footnote.[^footnote]
1261
1262[^footnote]:
1263    This footnote is long enough that it doesn't fit on just one line.
1264    Here is my [website][web].
1265
1266[web]: https://web.evanchen.cc
1267";
1268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269        let result = rule.check(&ctx).unwrap();
1270        assert!(
1271            result.is_empty(),
1272            "Reference used inside multi-line footnote should not be flagged: {result:?}"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_reference_in_single_line_footnote() {
1278        let rule = MD053LinkImageReferenceDefinitions::new();
1279        let content = "\
1280# Greetings
1281
1282This is a paragraph that has a footnote.[^footnote]
1283
1284[^footnote]: Here is my [website][web].
1285
1286[web]: https://web.evanchen.cc
1287";
1288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289        let result = rule.check(&ctx).unwrap();
1290        assert!(
1291            result.is_empty(),
1292            "Reference used inside single-line footnote should not be flagged: {result:?}"
1293        );
1294    }
1295
1296    #[test]
1297    fn test_shortcut_reference_in_multiline_footnote() {
1298        // Shortcut reference [web] (not full [text][web]) inside footnote
1299        let rule = MD053LinkImageReferenceDefinitions::new();
1300        let content = "\
1301Text with footnote.[^note]
1302
1303[^note]:
1304    See [web] for details.
1305
1306[web]: https://example.com
1307";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let result = rule.check(&ctx).unwrap();
1310        assert!(
1311            result.is_empty(),
1312            "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_unused_reference_not_in_footnote_still_flagged() {
1318        // Ensure we don't accidentally suppress real unused references
1319        let rule = MD053LinkImageReferenceDefinitions::new();
1320        let content = "\
1321# Greetings
1322
1323This is a paragraph that has a footnote.[^footnote]
1324
1325[^footnote]:
1326    This footnote is long enough.
1327
1328[unused]: https://example.com
1329";
1330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331        let result = rule.check(&ctx).unwrap();
1332        assert_eq!(result.len(), 1);
1333        assert!(result[0].message.contains("unused"));
1334    }
1335
1336    #[test]
1337    fn test_image_reference_in_multiline_footnote() {
1338        let rule = MD053LinkImageReferenceDefinitions::new();
1339        let content = "\
1340Text with footnote.[^note]
1341
1342[^note]:
1343    Here is a diagram:
1344    ![diagram][img]
1345
1346[img]: https://example.com/diagram.png
1347";
1348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349        let result = rule.check(&ctx).unwrap();
1350        assert!(
1351            result.is_empty(),
1352            "Image reference inside multi-line footnote should not be flagged: {result:?}"
1353        );
1354    }
1355
1356    #[test]
1357    fn test_multiple_references_in_one_footnote() {
1358        let rule = MD053LinkImageReferenceDefinitions::new();
1359        let content = "\
1360Text.[^note]
1361
1362[^note]:
1363    See [link1][ref1] and [link2][ref2] for details.
1364
1365[ref1]: https://example.com
1366[ref2]: https://example.org
1367";
1368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369        let result = rule.check(&ctx).unwrap();
1370        assert!(
1371            result.is_empty(),
1372            "Multiple references inside one footnote should all be recognized: {result:?}"
1373        );
1374    }
1375
1376    #[test]
1377    fn test_reference_in_code_block_inside_footnote_not_counted() {
1378        // Fenced code blocks within footnotes should still be treated as code.
1379        // A [ref] pattern inside a fenced code block is not a usage.
1380        let rule = MD053LinkImageReferenceDefinitions::new();
1381        let content = "\
1382Text.[^code]
1383
1384[^code]:
1385    ```python
1386    x = [ref_like_syntax]
1387    ```
1388
1389[ref_like_syntax]: https://example.com
1390";
1391        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392        let result = rule.check(&ctx).unwrap();
1393        assert_eq!(
1394            result.len(),
1395            1,
1396            "Reference inside fenced code block within footnote should still be unused: {result:?}"
1397        );
1398        assert!(result[0].message.contains("ref_like_syntax"));
1399    }
1400
1401    #[test]
1402    fn test_nested_list_in_footnote_with_reference() {
1403        let rule = MD053LinkImageReferenceDefinitions::new();
1404        let content = "\
1405Text.[^deep]
1406
1407[^deep]:
1408    - List item
1409        - Nested with [link text][deep-ref]
1410
1411[deep-ref]: https://example.com
1412";
1413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414        let result = rule.check(&ctx).unwrap();
1415        assert!(
1416            result.is_empty(),
1417            "Reference in nested list inside footnote should not be flagged: {result:?}"
1418        );
1419    }
1420}