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