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.source_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        // Definitions come from the shared parse. Without any, there can be
491        // neither unused nor duplicate definitions, regardless of the usages.
492        if ctx.reference_definitions().is_empty() {
493            return Ok(Vec::new());
494        }
495        // Find definitions and usages using LintContext
496        let definitions = self.find_definitions(ctx);
497        if definitions.is_empty() {
498            return Ok(Vec::new());
499        }
500        let usages = self.find_usages(ctx);
501
502        // Get unused references by comparing definitions and usages
503        let unused_refs = self.get_unused_references(&definitions, &usages);
504
505        let mut warnings = Vec::new();
506
507        // Check for duplicate definitions (case-insensitive per CommonMark spec)
508        let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); // lowercase -> (original, first_line)
509
510        for (definition_id, ranges) in &definitions {
511            // Skip ignored definitions for duplicate checking
512            if self.is_ignored_definition(definition_id) {
513                continue;
514            }
515
516            if ranges.len() > 1 {
517                // Multiple definitions with exact same ID (already lowercase)
518                for (i, &(start_line, _)) in ranges.iter().enumerate() {
519                    if i > 0 {
520                        // Skip the first occurrence, report all others
521                        let line_num = start_line + 1;
522                        let line_content = ctx.lines.get(start_line).map_or("", |l| l.content(ctx.content));
523                        let (start_line_1idx, start_col, end_line, end_col) =
524                            calculate_line_range(line_num, line_content);
525
526                        warnings.push(LintWarning {
527                            rule_name: Some(self.name().to_string()),
528                            line: start_line_1idx,
529                            column: start_col,
530                            end_line,
531                            end_column: end_col,
532                            message: format!("Duplicate link or image reference definition: [{definition_id}]"),
533                            severity: Severity::Warning,
534                            fix: None,
535                        });
536                    }
537                }
538            }
539
540            // Track for case-variant duplicates
541            if let Some(&(start_line, _)) = ranges.first() {
542                // Find the original case version from the line
543                if let Some(line_info) = ctx.lines.get(start_line)
544                    && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
545                {
546                    let original_id = caps.get(1).unwrap().as_str().trim();
547                    let lower_id = Self::normalize_reference(original_id);
548
549                    if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
550                        // Found a case-variant duplicate
551                        if first_original != original_id {
552                            let line_num = start_line + 1;
553                            let line_content = line_info.content(ctx.content);
554                            let (start_line_1idx, start_col, end_line, end_col) =
555                                calculate_line_range(line_num, line_content);
556
557                            warnings.push(LintWarning {
558                                    rule_name: Some(self.name().to_string()),
559                                    line: start_line_1idx,
560                                    column: start_col,
561                                    end_line,
562                                    end_column: end_col,
563                                    message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
564                                                   original_id, first_original, first_line + 1),
565                                    severity: Severity::Warning,
566                                    fix: None,
567                                });
568                        }
569                    } else {
570                        seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
571                    }
572                }
573            }
574        }
575
576        // Create warnings for unused references
577        for (definition, start, _end) in unused_refs {
578            let line_num = start + 1; // 1-indexed line numbers
579            let line_content = ctx.lines.get(start).map_or("", |l| l.content(ctx.content));
580
581            // Calculate precise character range for the entire reference definition line
582            let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
583
584            warnings.push(LintWarning {
585                rule_name: Some(self.name().to_string()),
586                line: start_line,
587                column: start_col,
588                end_line,
589                end_column: end_col,
590                message: format!("Unused link/image reference: [{definition}]"),
591                severity: Severity::Warning,
592                fix: None, // MD053 is warning-only, no automatic fixes
593            });
594        }
595
596        Ok(warnings)
597    }
598
599    /// MD053 does not provide automatic fixes
600    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
601        // This rule is warning-only, no automatic fixes provided
602        Ok(ctx.content.to_string())
603    }
604
605    /// Check if this rule should be skipped for performance
606    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
607        ctx.reference_definitions().is_empty()
608    }
609
610    fn as_any(&self) -> &dyn std::any::Any {
611        self
612    }
613
614    crate::impl_rule_config_methods!(MD053Config);
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::lint_context::LintContext;
621
622    #[test]
623    fn test_used_references_follow_unicode_case_fold_and_label_whitespace() {
624        let rule = MD053LinkImageReferenceDefinitions::new();
625        for (usage, definition) in [
626            ("STRASSE", "straße"),
627            ("σ", "ς"),
628            ("SS", "ẞ"),
629            ("office", "office"),
630            ("foo bar", "foo  bar"),
631            ("foo bar", "foo\tbar"),
632            ("foo\nbar", "foo bar"),
633        ] {
634            for reference in [
635                format!("[{usage}]"),
636                format!("[visible][{usage}]"),
637                format!("[{usage}][]"),
638                format!("![alt][{usage}]"),
639            ] {
640                let content = format!("{reference}\n\n[{definition}]: https://example.com\n");
641                assert!(
642                    pulldown_cmark::Parser::new(&content).any(|event| matches!(
643                        event,
644                        pulldown_cmark::Event::Start(
645                            pulldown_cmark::Tag::Link { .. } | pulldown_cmark::Tag::Image { .. }
646                        )
647                    )),
648                    "the native parser resolves the reference: {content}"
649                );
650                let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
651                assert!(
652                    rule.check(&ctx).unwrap().is_empty(),
653                    "the definition is used: {content}"
654                );
655            }
656        }
657    }
658
659    #[test]
660    fn test_normalized_references_still_report_unused_and_duplicate_definitions() {
661        let rule = MD053LinkImageReferenceDefinitions::new();
662        let content = "[SS]\n\n[ß]: https://example.com\n[unused]: https://other.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("Unused"));
668
669        let content = "[STRASSE]\n\n[straße]: https://first.example.com\n[STRASSE]: https://second.example.com\n";
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let warnings = rule.check(&ctx).unwrap();
672        assert_eq!(warnings.len(), 1);
673        assert_eq!(warnings[0].line, 4);
674        assert!(warnings[0].message.contains("Duplicate"));
675        assert!(warnings[0].fix.is_none());
676    }
677
678    #[test]
679    fn test_ignored_reference_labels_use_the_same_normalization() {
680        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(MD053Config {
681            ignored_definitions: vec!["straße".to_string(), "foo  bar".to_string()],
682        });
683        let content = "[STRASSE]: https://example.com\n[foo bar]: https://example.com\n[other]: https://example.com\n";
684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let warnings = rule.check(&ctx).unwrap();
686        assert_eq!(warnings.len(), 1);
687        assert_eq!(warnings[0].line, 3);
688    }
689
690    #[test]
691    fn test_used_reference_link() {
692        let rule = MD053LinkImageReferenceDefinitions::new();
693        let content = "[text][ref]\n\n[ref]: https://example.com";
694        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695        let result = rule.check(&ctx).unwrap();
696
697        assert_eq!(result.len(), 0);
698    }
699
700    #[test]
701    fn test_unused_reference_definition() {
702        let rule = MD053LinkImageReferenceDefinitions::new();
703        let content = "[unused]: https://example.com";
704        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705        let result = rule.check(&ctx).unwrap();
706
707        assert_eq!(result.len(), 1);
708        assert!(result[0].message.contains("Unused link/image reference: [unused]"));
709    }
710
711    #[test]
712    fn test_unused_reference_definition_with_escaped_bracket_label() {
713        // Issue #814: a label ends at the first unescaped `]`, so `[unused\[\]]`
714        // is one definition. While the label scan stopped at any `]` the
715        // definition was invisible here and an unused reference went unreported.
716        let rule = MD053LinkImageReferenceDefinitions::new();
717        let content = "[unused\\[\\]]: https://example.com";
718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719        let result = rule.check(&ctx).unwrap();
720
721        assert_eq!(result.len(), 1, "unused escaped-bracket definition must be reported");
722    }
723
724    #[test]
725    fn test_used_reference_definition_with_escaped_bracket_label() {
726        // The counterpart bound: once the definition is visible, a real usage of
727        // the same label must still match it, or making the definition visible
728        // would simply convert a false negative into a false positive.
729        //
730        // Silence alone would also hold if the definition were invisible again,
731        // so pin visibility first, or this passes for the wrong reason.
732        let rule = MD053LinkImageReferenceDefinitions::new();
733        let content = "[text][used\\[\\]]\n\n[used\\[\\]]: https://example.com";
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735
736        assert_eq!(
737            ctx.reference_definitions()
738                .iter()
739                .map(|d| d.id.as_str())
740                .collect::<Vec<_>>(),
741            vec!["used\\[\\]"],
742            "precondition: the definition must be visible to the rule"
743        );
744
745        let result = rule.check(&ctx).unwrap();
746        assert!(
747            result.is_empty(),
748            "a used escaped-bracket definition must not be reported: {result:?}"
749        );
750    }
751
752    #[test]
753    fn test_escaped_bracket_definition_is_not_read_as_a_shortcut_usage() {
754        // The shortcut-reference scan skips lines it recognizes as definitions.
755        // While that check stopped at the first `]`, the second line below was
756        // not recognized and was scanned as prose, yielding a shortcut usage of
757        // `a\[`, which normalizes to the first definition's id and silently
758        // marked it used. Both definitions are unused and must be reported.
759        let rule = MD053LinkImageReferenceDefinitions::new();
760        let content = "[a\\[]: https://example.com/1\n[a\\[\\]]: https://example.com/2\n";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let result = rule.check(&ctx).unwrap();
763
764        assert_eq!(
765            result.len(),
766            2,
767            "a definition line must never register as a usage of another definition: {result:?}"
768        );
769    }
770
771    #[test]
772    fn test_used_reference_image() {
773        let rule = MD053LinkImageReferenceDefinitions::new();
774        let content = "![alt][img]\n\n[img]: image.jpg";
775        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776        let result = rule.check(&ctx).unwrap();
777
778        assert_eq!(result.len(), 0);
779    }
780
781    #[test]
782    fn test_case_insensitive_matching() {
783        let rule = MD053LinkImageReferenceDefinitions::new();
784        let content = "[Text][REF]\n\n[ref]: https://example.com";
785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786        let result = rule.check(&ctx).unwrap();
787
788        assert_eq!(result.len(), 0);
789    }
790
791    #[test]
792    fn test_shortcut_reference() {
793        let rule = MD053LinkImageReferenceDefinitions::new();
794        let content = "[ref]\n\n[ref]: https://example.com";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796        let result = rule.check(&ctx).unwrap();
797
798        assert_eq!(result.len(), 0);
799    }
800
801    #[test]
802    fn test_collapsed_reference() {
803        let rule = MD053LinkImageReferenceDefinitions::new();
804        let content = "[ref][]\n\n[ref]: https://example.com";
805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806        let result = rule.check(&ctx).unwrap();
807
808        assert_eq!(result.len(), 0);
809    }
810
811    #[test]
812    fn test_multiple_unused_definitions() {
813        let rule = MD053LinkImageReferenceDefinitions::new();
814        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let result = rule.check(&ctx).unwrap();
817
818        assert_eq!(result.len(), 3);
819
820        // The warnings might not be in the same order, so collect all messages
821        let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
822        assert!(messages.iter().any(|m| m.contains("unused1")));
823        assert!(messages.iter().any(|m| m.contains("unused2")));
824        assert!(messages.iter().any(|m| m.contains("unused3")));
825    }
826
827    #[test]
828    fn test_mixed_used_and_unused() {
829        let rule = MD053LinkImageReferenceDefinitions::new();
830        let content = "[used]\n\n[used]: url1\n[unused]: url2";
831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832        let result = rule.check(&ctx).unwrap();
833
834        assert_eq!(result.len(), 1);
835        assert!(result[0].message.contains("unused"));
836    }
837
838    #[test]
839    fn test_multiline_definition() {
840        let rule = MD053LinkImageReferenceDefinitions::new();
841        let content = "[ref]: https://example.com\n  \"Title on next line\"";
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843        let result = rule.check(&ctx).unwrap();
844
845        assert_eq!(result.len(), 1); // Still unused
846    }
847
848    #[test]
849    fn test_reference_in_code_block() {
850        let rule = MD053LinkImageReferenceDefinitions::new();
851        let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule.check(&ctx).unwrap();
854
855        // Reference used only in code block is still considered unused
856        assert_eq!(result.len(), 1);
857    }
858
859    #[test]
860    fn test_reference_in_inline_code() {
861        let rule = MD053LinkImageReferenceDefinitions::new();
862        let content = "`[ref]`\n\n[ref]: https://example.com";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865
866        // Reference in inline code is not a usage
867        assert_eq!(result.len(), 1);
868    }
869
870    #[test]
871    fn test_escaped_reference() {
872        let rule = MD053LinkImageReferenceDefinitions::new();
873        let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        let result = rule.check(&ctx).unwrap();
876
877        // Should match despite escaping
878        assert_eq!(result.len(), 0);
879    }
880
881    #[test]
882    fn test_duplicate_definitions() {
883        let rule = MD053LinkImageReferenceDefinitions::new();
884        let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886        let result = rule.check(&ctx).unwrap();
887
888        // Should flag the duplicate definition even though it's used (matches markdownlint)
889        assert_eq!(result.len(), 1);
890    }
891
892    #[test]
893    fn test_fix_returns_original() {
894        // MD053 is warning-only, fix should return original content
895        let rule = MD053LinkImageReferenceDefinitions::new();
896        let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let fixed = rule.fix(&ctx).unwrap();
899
900        assert_eq!(fixed, content);
901    }
902
903    #[test]
904    fn test_fix_preserves_content() {
905        // MD053 is warning-only, fix should preserve all content
906        let rule = MD053LinkImageReferenceDefinitions::new();
907        let content = "Content\n\n[unused]: url\n\nMore content";
908        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909        let fixed = rule.fix(&ctx).unwrap();
910
911        assert_eq!(fixed, content);
912    }
913
914    #[test]
915    fn test_fix_does_not_remove() {
916        // MD053 is warning-only, fix should not remove anything
917        let rule = MD053LinkImageReferenceDefinitions::new();
918        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920        let fixed = rule.fix(&ctx).unwrap();
921
922        assert_eq!(fixed, content);
923    }
924
925    #[test]
926    fn test_special_characters_in_reference() {
927        let rule = MD053LinkImageReferenceDefinitions::new();
928        let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931
932        assert_eq!(result.len(), 0);
933    }
934
935    #[test]
936    fn test_find_definitions() {
937        let rule = MD053LinkImageReferenceDefinitions::new();
938        let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940        let defs = rule.find_definitions(&ctx);
941
942        assert_eq!(defs.len(), 3);
943        assert!(defs.contains_key("ref1"));
944        assert!(defs.contains_key("ref2"));
945        assert!(defs.contains_key("ref3"));
946    }
947
948    #[test]
949    fn test_find_usages() {
950        let rule = MD053LinkImageReferenceDefinitions::new();
951        let content = "[text][ref1] and [ref2] and ![img][ref3]";
952        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953        let usages = rule.find_usages(&ctx);
954
955        assert!(usages.contains("ref1"));
956        assert!(usages.contains("ref2"));
957        assert!(usages.contains("ref3"));
958    }
959
960    #[test]
961    fn test_ignored_definitions_config() {
962        // Test with ignored definitions
963        let config = MD053Config {
964            ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
965        };
966        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
967
968        let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        let result = rule.check(&ctx).unwrap();
971
972        // Should only flag "unused", not "todo" or "draft"
973        assert_eq!(result.len(), 1);
974        assert!(result[0].message.contains("unused"));
975        assert!(!result[0].message.contains("todo"));
976        assert!(!result[0].message.contains("draft"));
977    }
978
979    #[test]
980    fn test_ignored_definitions_case_insensitive() {
981        // Test case-insensitive matching of ignored definitions
982        let config = MD053Config {
983            ignored_definitions: vec!["TODO".to_string()],
984        };
985        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
986
987        let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989        let result = rule.check(&ctx).unwrap();
990
991        // Should only flag "unused", not "todo" (matches "TODO" case-insensitively)
992        assert_eq!(result.len(), 1);
993        assert!(result[0].message.contains("unused"));
994        assert!(!result[0].message.contains("todo"));
995    }
996
997    #[test]
998    fn test_default_config_section() {
999        let rule = MD053LinkImageReferenceDefinitions::default();
1000        let config_section = rule.default_config_section();
1001
1002        assert!(config_section.is_some());
1003        let (name, value) = config_section.unwrap();
1004        assert_eq!(name, "MD053");
1005
1006        // Should contain the ignored_definitions option with default empty array
1007        if let toml::Value::Table(table) = value {
1008            assert!(table.contains_key("ignored-definitions"));
1009            assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
1010        } else {
1011            panic!("Expected TOML table");
1012        }
1013    }
1014
1015    #[test]
1016    fn test_fix_with_ignored_definitions() {
1017        // MD053 is warning-only, fix should not remove anything even with ignored definitions
1018        let config = MD053Config {
1019            ignored_definitions: vec!["template".to_string()],
1020        };
1021        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
1022
1023        let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
1024        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025        let fixed = rule.fix(&ctx).unwrap();
1026
1027        // Should keep everything since MD053 doesn't fix
1028        assert_eq!(fixed, content);
1029    }
1030
1031    #[test]
1032    fn test_duplicate_definitions_exact_case() {
1033        let rule = MD053LinkImageReferenceDefinitions::new();
1034        let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
1035        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036        let result = rule.check(&ctx).unwrap();
1037
1038        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
1039        // Plus 1 unused warning
1040        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1041        assert_eq!(duplicate_warnings.len(), 2);
1042        assert_eq!(duplicate_warnings[0].line, 2);
1043        assert_eq!(duplicate_warnings[1].line, 3);
1044    }
1045
1046    #[test]
1047    fn test_duplicate_definitions_case_variants() {
1048        let rule = MD053LinkImageReferenceDefinitions::new();
1049        let content =
1050            "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
1051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let result = rule.check(&ctx).unwrap();
1053
1054        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
1055        // Note: These are treated as exact duplicates since they normalize to the same ID
1056        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1057        assert_eq!(duplicate_warnings.len(), 2);
1058
1059        // The exact duplicate messages don't include "conflicts with"
1060        // Only case-variant duplicates with different normalized forms would
1061        assert_eq!(duplicate_warnings[0].line, 2);
1062        assert_eq!(duplicate_warnings[1].line, 3);
1063    }
1064
1065    #[test]
1066    fn test_duplicate_and_unused() {
1067        let rule = MD053LinkImageReferenceDefinitions::new();
1068        let content = "[used]\n\n[used]: http://url1\n\n[used]: http://url2\n\n[unused]: http://url3";
1069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070        let result = rule.check(&ctx).unwrap();
1071
1072        // Should have 1 duplicate warning and 1 unused warning
1073        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1074        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1075
1076        assert_eq!(duplicate_warnings.len(), 1);
1077        assert_eq!(unused_warnings.len(), 1);
1078        assert_eq!(duplicate_warnings[0].line, 5); // Second [used] definition
1079        assert_eq!(unused_warnings[0].line, 7); // [unused] definition
1080    }
1081
1082    #[test]
1083    fn test_duplicate_with_usage() {
1084        let rule = MD053LinkImageReferenceDefinitions::new();
1085        // Even if used, duplicates should still be reported
1086        let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
1087        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088        let result = rule.check(&ctx).unwrap();
1089
1090        // Should have 1 duplicate warning (no unused since it's referenced)
1091        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1092        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1093
1094        assert_eq!(duplicate_warnings.len(), 1);
1095        assert_eq!(unused_warnings.len(), 0);
1096        assert_eq!(duplicate_warnings[0].line, 4);
1097    }
1098
1099    #[test]
1100    fn test_no_duplicate_different_ids() {
1101        let rule = MD053LinkImageReferenceDefinitions::new();
1102        let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104        let result = rule.check(&ctx).unwrap();
1105
1106        // Should have no duplicate warnings, only unused warnings
1107        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1108        assert_eq!(duplicate_warnings.len(), 0);
1109    }
1110
1111    #[test]
1112    fn test_comment_style_reference_double_slash() {
1113        let rule = MD053LinkImageReferenceDefinitions::new();
1114        // Most popular comment pattern: [//]: # (comment)
1115        let content = "[//]: # (This is a comment)\n\nSome regular text.";
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117        let result = rule.check(&ctx).unwrap();
1118
1119        // Should not report as unused - it's recognized as a comment
1120        assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
1121    }
1122
1123    #[test]
1124    fn test_comment_style_reference_comment_label() {
1125        let rule = MD053LinkImageReferenceDefinitions::new();
1126        // Semantic comment pattern: [comment]: # (text)
1127        let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129        let result = rule.check(&ctx).unwrap();
1130
1131        // Should not report either as unused
1132        assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
1133    }
1134
1135    #[test]
1136    fn test_comment_style_reference_todo_fixme() {
1137        let rule = MD053LinkImageReferenceDefinitions::new();
1138        // Task tracking patterns: [todo]: # and [fixme]: #
1139        let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1140        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141        let result = rule.check(&ctx).unwrap();
1142
1143        // Should not report any as unused
1144        assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1145    }
1146
1147    #[test]
1148    fn test_comment_style_reference_fragment_only() {
1149        let rule = MD053LinkImageReferenceDefinitions::new();
1150        // Any reference with just "#" as URL should be treated as a comment
1151        let content = "[anything]: #\n[ref]: #\n\nSome text.";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154
1155        // Should not report as unused - fragment-only URLs are often comments
1156        assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1157    }
1158
1159    #[test]
1160    fn test_comment_vs_real_reference() {
1161        let rule = MD053LinkImageReferenceDefinitions::new();
1162        // Mix of comment and real reference - only real one should be flagged if unused
1163        let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1164        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165        let result = rule.check(&ctx).unwrap();
1166
1167        // Should only report the real reference as unused
1168        assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1169        assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1170    }
1171
1172    #[test]
1173    fn test_comment_with_fragment_section() {
1174        let rule = MD053LinkImageReferenceDefinitions::new();
1175        // Comment pattern with a fragment section (still a comment)
1176        let content = "[//]: #section (Comment about section)\n\nSome text.";
1177        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178        let result = rule.check(&ctx).unwrap();
1179
1180        // Should not report as unused - it's still a comment pattern
1181        assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1182    }
1183
1184    #[test]
1185    fn test_is_likely_comment_reference_helper() {
1186        // Test the helper function directly
1187        assert!(
1188            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1189            "[//]: # should be recognized as comment"
1190        );
1191        assert!(
1192            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1193            "[comment]: #section should be recognized as comment"
1194        );
1195        assert!(
1196            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1197            "[note]: # should be recognized as comment"
1198        );
1199        assert!(
1200            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1201            "[todo]: # should be recognized as comment"
1202        );
1203        assert!(
1204            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1205            "Any label with just '#' should be recognized as comment"
1206        );
1207        assert!(
1208            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1209            "Real URL should not be recognized as comment"
1210        );
1211        assert!(
1212            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1213            "Real URL should not be recognized as comment"
1214        );
1215    }
1216
1217    #[test]
1218    fn test_reference_with_colon_in_name() {
1219        // References containing colons and spaces should be recognized as valid references
1220        let rule = MD053LinkImageReferenceDefinitions::new();
1221        let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223        let result = rule.check(&ctx).unwrap();
1224
1225        assert!(
1226            result.is_empty(),
1227            "Reference with colon should be recognized as used, got warnings: {result:?}"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_reference_with_colon_various_styles() {
1233        // Test various RFC-style and similar references with colons
1234        let rule = MD053LinkImageReferenceDefinitions::new();
1235        let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1236
1237[RFC: 1234]: https://example.com/rfc1234
1238[Issue: 42]: https://example.com/issue42
1239[PR: 100]: https://example.com/pr100
1240"#;
1241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242        let result = rule.check(&ctx).unwrap();
1243
1244        assert!(
1245            result.is_empty(),
1246            "All colon-style references should be recognized as used, got warnings: {result:?}"
1247        );
1248    }
1249
1250    #[test]
1251    fn test_should_skip_pattern_allows_rfc_style() {
1252        // Verify that should_skip_pattern does NOT skip RFC-style references with colons
1253        // This tests the fix for the bug where references with ": " were incorrectly skipped
1254        assert!(
1255            !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1256            "RFC-style references should NOT be skipped"
1257        );
1258        assert!(
1259            !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1260            "Issue-style references should NOT be skipped"
1261        );
1262        assert!(
1263            !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1264            "PR-style references should NOT be skipped"
1265        );
1266        assert!(
1267            !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1268            "References with 'See:' should NOT be skipped"
1269        );
1270        assert!(
1271            !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1272            "References without space after colon should NOT be skipped"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_should_skip_pattern_skips_prose() {
1278        // Verify that prose-like patterns (3+ words before colon) are still skipped
1279        assert!(
1280            MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1281            "Prose with 3+ words before colon SHOULD be skipped"
1282        );
1283        assert!(
1284            MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1285            "Prose with 4 words before colon SHOULD be skipped"
1286        );
1287        assert!(
1288            MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1289            "Prose-like descriptions SHOULD be skipped"
1290        );
1291    }
1292
1293    #[test]
1294    fn test_many_code_spans_with_shortcut_references() {
1295        // Exercises the binary search path for code span containment.
1296        // With many code spans, linear search would be slow; binary search stays O(log n).
1297        let rule = MD053LinkImageReferenceDefinitions::new();
1298
1299        let mut lines = Vec::new();
1300        // Generate many code spans interleaved with shortcut references
1301        for i in 0..100 {
1302            lines.push(format!("Some `code{i}` text and [used_ref] here"));
1303        }
1304        lines.push(String::new());
1305        lines.push("[used_ref]: https://example.com".to_string());
1306        lines.push("[unused_ref]: https://unused.com".to_string());
1307
1308        let content = lines.join("\n");
1309        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1310        let result = rule.check(&ctx).unwrap();
1311
1312        // used_ref is referenced 100 times, so only unused_ref should be reported
1313        assert_eq!(result.len(), 1);
1314        assert!(result[0].message.contains("unused_ref"));
1315    }
1316
1317    #[test]
1318    fn test_multiline_definition_continuation_tracking() {
1319        // Exercises the forward-tracking for multi-line definitions.
1320        // Definitions with title on the next line should be treated as a single unit.
1321        let rule = MD053LinkImageReferenceDefinitions::new();
1322        let content = "\
1323[ref1]: https://example.com
1324   \"Title on next line\"
1325
1326[ref2]: https://example2.com
1327   \"Another title\"
1328
1329Some text using [ref1] here.
1330";
1331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332        let result = rule.check(&ctx).unwrap();
1333
1334        // ref1 is used, ref2 is not
1335        assert_eq!(result.len(), 1);
1336        assert!(result[0].message.contains("ref2"));
1337    }
1338
1339    #[test]
1340    fn test_code_span_at_boundary_does_not_hide_reference() {
1341        // A reference immediately after a code span should still be detected
1342        let rule = MD053LinkImageReferenceDefinitions::new();
1343        let content = "`code`[ref]\n\n[ref]: https://example.com";
1344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345        let result = rule.check(&ctx).unwrap();
1346
1347        // [ref] is outside the code span, so it counts as a usage
1348        assert_eq!(result.len(), 0);
1349    }
1350
1351    #[test]
1352    fn test_reference_inside_code_span_not_counted() {
1353        // A reference inside a code span should NOT be counted as usage
1354        let rule = MD053LinkImageReferenceDefinitions::new();
1355        let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1356        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357        let result = rule.check(&ctx).unwrap();
1358
1359        // [ref] is inside a code span, so the definition is unused
1360        assert_eq!(result.len(), 1);
1361    }
1362
1363    #[test]
1364    fn test_shortcut_ref_at_byte_zero() {
1365        let rule = MD053LinkImageReferenceDefinitions::default();
1366        let content = "[example]\n\n[example]: https://example.com\n";
1367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368        let result = rule.check(&ctx).unwrap();
1369        assert!(
1370            result.is_empty(),
1371            "[ref] at byte 0 should be recognized as usage: {result:?}"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_shortcut_ref_at_end_of_line() {
1377        let rule = MD053LinkImageReferenceDefinitions::default();
1378        let content = "Text [example]\n\n[example]: https://example.com\n";
1379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380        let result = rule.check(&ctx).unwrap();
1381        assert!(
1382            result.is_empty(),
1383            "[ref] at end of line should be recognized as usage: {result:?}"
1384        );
1385    }
1386
1387    #[test]
1388    fn test_reference_in_multiline_footnote_not_false_positive() {
1389        // Issue #540: reference link inside multi-line footnote body
1390        // was falsely reported as unused because the 4-space-indented
1391        // footnote continuation was misidentified as an indented code block
1392        let rule = MD053LinkImageReferenceDefinitions::new();
1393        let content = "\
1394# Greetings
1395
1396This is a paragraph that has a footnote.[^footnote]
1397
1398[^footnote]:
1399    This footnote is long enough that it doesn't fit on just one line.
1400    Here is my [website][web].
1401
1402[web]: https://web.evanchen.cc
1403";
1404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405        let result = rule.check(&ctx).unwrap();
1406        assert!(
1407            result.is_empty(),
1408            "Reference used inside multi-line footnote should not be flagged: {result:?}"
1409        );
1410    }
1411
1412    #[test]
1413    fn test_reference_in_single_line_footnote() {
1414        let rule = MD053LinkImageReferenceDefinitions::new();
1415        let content = "\
1416# Greetings
1417
1418This is a paragraph that has a footnote.[^footnote]
1419
1420[^footnote]: Here is my [website][web].
1421
1422[web]: https://web.evanchen.cc
1423";
1424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425        let result = rule.check(&ctx).unwrap();
1426        assert!(
1427            result.is_empty(),
1428            "Reference used inside single-line footnote should not be flagged: {result:?}"
1429        );
1430    }
1431
1432    #[test]
1433    fn test_shortcut_reference_in_multiline_footnote() {
1434        // Shortcut reference [web] (not full [text][web]) inside footnote
1435        let rule = MD053LinkImageReferenceDefinitions::new();
1436        let content = "\
1437Text with footnote.[^note]
1438
1439[^note]:
1440    See [web] for details.
1441
1442[web]: https://example.com
1443";
1444        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445        let result = rule.check(&ctx).unwrap();
1446        assert!(
1447            result.is_empty(),
1448            "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1449        );
1450    }
1451
1452    #[test]
1453    fn test_unused_reference_not_in_footnote_still_flagged() {
1454        // Ensure we don't accidentally suppress real unused references
1455        let rule = MD053LinkImageReferenceDefinitions::new();
1456        let content = "\
1457# Greetings
1458
1459This is a paragraph that has a footnote.[^footnote]
1460
1461[^footnote]:
1462    This footnote is long enough.
1463
1464[unused]: https://example.com
1465";
1466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467        let result = rule.check(&ctx).unwrap();
1468        assert_eq!(result.len(), 1);
1469        assert!(result[0].message.contains("unused"));
1470    }
1471
1472    #[test]
1473    fn test_image_reference_in_multiline_footnote() {
1474        let rule = MD053LinkImageReferenceDefinitions::new();
1475        let content = "\
1476Text with footnote.[^note]
1477
1478[^note]:
1479    Here is a diagram:
1480    ![diagram][img]
1481
1482[img]: https://example.com/diagram.png
1483";
1484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485        let result = rule.check(&ctx).unwrap();
1486        assert!(
1487            result.is_empty(),
1488            "Image reference inside multi-line footnote should not be flagged: {result:?}"
1489        );
1490    }
1491
1492    #[test]
1493    fn test_multiple_references_in_one_footnote() {
1494        let rule = MD053LinkImageReferenceDefinitions::new();
1495        let content = "\
1496Text.[^note]
1497
1498[^note]:
1499    See [link1][ref1] and [link2][ref2] for details.
1500
1501[ref1]: https://example.com
1502[ref2]: https://example.org
1503";
1504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505        let result = rule.check(&ctx).unwrap();
1506        assert!(
1507            result.is_empty(),
1508            "Multiple references inside one footnote should all be recognized: {result:?}"
1509        );
1510    }
1511
1512    #[test]
1513    fn test_reference_in_code_block_inside_footnote_not_counted() {
1514        // Fenced code blocks within footnotes should still be treated as code.
1515        // A [ref] pattern inside a fenced code block is not a usage.
1516        let rule = MD053LinkImageReferenceDefinitions::new();
1517        let content = "\
1518Text.[^code]
1519
1520[^code]:
1521    ```python
1522    x = [ref_like_syntax]
1523    ```
1524
1525[ref_like_syntax]: https://example.com
1526";
1527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528        let result = rule.check(&ctx).unwrap();
1529        assert_eq!(
1530            result.len(),
1531            1,
1532            "Reference inside fenced code block within footnote should still be unused: {result:?}"
1533        );
1534        assert!(result[0].message.contains("ref_like_syntax"));
1535    }
1536
1537    #[test]
1538    fn test_nested_list_in_footnote_with_reference() {
1539        let rule = MD053LinkImageReferenceDefinitions::new();
1540        let content = "\
1541Text.[^deep]
1542
1543[^deep]:
1544    - List item
1545        - Nested with [link text][deep-ref]
1546
1547[deep-ref]: https://example.com
1548";
1549        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1550        let result = rule.check(&ctx).unwrap();
1551        assert!(
1552            result.is_empty(),
1553            "Reference in nested list inside footnote should not be flagged: {result:?}"
1554        );
1555    }
1556}