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