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\n\n[ref4]: url4";
939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940        let defs = rule.find_definitions(&ctx);
941
942        // `[ref3]: url3` continues the paragraph `Some text`, so it is text.
943        assert_eq!(defs.len(), 3);
944        assert!(defs.contains_key("ref1"));
945        assert!(defs.contains_key("ref2"));
946        assert!(!defs.contains_key("ref3"));
947        assert!(defs.contains_key("ref4"));
948    }
949
950    #[test]
951    fn test_find_usages() {
952        let rule = MD053LinkImageReferenceDefinitions::new();
953        let content = "[text][ref1] and [ref2] and ![img][ref3]";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let usages = rule.find_usages(&ctx);
956
957        assert!(usages.contains("ref1"));
958        assert!(usages.contains("ref2"));
959        assert!(usages.contains("ref3"));
960    }
961
962    #[test]
963    fn test_ignored_definitions_config() {
964        // Test with ignored definitions
965        let config = MD053Config {
966            ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
967        };
968        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
969
970        let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972        let result = rule.check(&ctx).unwrap();
973
974        // Should only flag "unused", not "todo" or "draft"
975        assert_eq!(result.len(), 1);
976        assert!(result[0].message.contains("unused"));
977        assert!(!result[0].message.contains("todo"));
978        assert!(!result[0].message.contains("draft"));
979    }
980
981    #[test]
982    fn test_ignored_definitions_case_insensitive() {
983        // Test case-insensitive matching of ignored definitions
984        let config = MD053Config {
985            ignored_definitions: vec!["TODO".to_string()],
986        };
987        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
988
989        let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
990        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991        let result = rule.check(&ctx).unwrap();
992
993        // Should only flag "unused", not "todo" (matches "TODO" case-insensitively)
994        assert_eq!(result.len(), 1);
995        assert!(result[0].message.contains("unused"));
996        assert!(!result[0].message.contains("todo"));
997    }
998
999    #[test]
1000    fn test_default_config_section() {
1001        let rule = MD053LinkImageReferenceDefinitions::default();
1002        let config_section = rule.default_config_section();
1003
1004        assert!(config_section.is_some());
1005        let (name, value) = config_section.unwrap();
1006        assert_eq!(name, "MD053");
1007
1008        // Should contain the ignored_definitions option with default empty array
1009        if let toml::Value::Table(table) = value {
1010            assert!(table.contains_key("ignored-definitions"));
1011            assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
1012        } else {
1013            panic!("Expected TOML table");
1014        }
1015    }
1016
1017    #[test]
1018    fn test_fix_with_ignored_definitions() {
1019        // MD053 is warning-only, fix should not remove anything even with ignored definitions
1020        let config = MD053Config {
1021            ignored_definitions: vec!["template".to_string()],
1022        };
1023        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
1024
1025        let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
1026        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027        let fixed = rule.fix(&ctx).unwrap();
1028
1029        // Should keep everything since MD053 doesn't fix
1030        assert_eq!(fixed, content);
1031    }
1032
1033    #[test]
1034    fn test_duplicate_definitions_exact_case() {
1035        let rule = MD053LinkImageReferenceDefinitions::new();
1036        let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
1037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038        let result = rule.check(&ctx).unwrap();
1039
1040        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
1041        // Plus 1 unused warning
1042        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1043        assert_eq!(duplicate_warnings.len(), 2);
1044        assert_eq!(duplicate_warnings[0].line, 2);
1045        assert_eq!(duplicate_warnings[1].line, 3);
1046    }
1047
1048    #[test]
1049    fn test_duplicate_definitions_case_variants() {
1050        let rule = MD053LinkImageReferenceDefinitions::new();
1051        let content =
1052            "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
1053        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054        let result = rule.check(&ctx).unwrap();
1055
1056        // Should have 2 duplicate warnings (for the 2nd and 3rd definitions)
1057        // Note: These are treated as exact duplicates since they normalize to the same ID
1058        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1059        assert_eq!(duplicate_warnings.len(), 2);
1060
1061        // The exact duplicate messages don't include "conflicts with"
1062        // Only case-variant duplicates with different normalized forms would
1063        assert_eq!(duplicate_warnings[0].line, 2);
1064        assert_eq!(duplicate_warnings[1].line, 3);
1065    }
1066
1067    #[test]
1068    fn test_duplicate_and_unused() {
1069        let rule = MD053LinkImageReferenceDefinitions::new();
1070        let content = "[used]\n\n[used]: http://url1\n\n[used]: http://url2\n\n[unused]: http://url3";
1071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072        let result = rule.check(&ctx).unwrap();
1073
1074        // Should have 1 duplicate warning and 1 unused warning
1075        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1076        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1077
1078        assert_eq!(duplicate_warnings.len(), 1);
1079        assert_eq!(unused_warnings.len(), 1);
1080        assert_eq!(duplicate_warnings[0].line, 5); // Second [used] definition
1081        assert_eq!(unused_warnings[0].line, 7); // [unused] definition
1082    }
1083
1084    #[test]
1085    fn test_duplicate_with_usage() {
1086        let rule = MD053LinkImageReferenceDefinitions::new();
1087        // Even if used, duplicates should still be reported
1088        let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
1089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090        let result = rule.check(&ctx).unwrap();
1091
1092        // Should have 1 duplicate warning (no unused since it's referenced)
1093        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1094        let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1095
1096        assert_eq!(duplicate_warnings.len(), 1);
1097        assert_eq!(unused_warnings.len(), 0);
1098        assert_eq!(duplicate_warnings[0].line, 4);
1099    }
1100
1101    #[test]
1102    fn test_no_duplicate_different_ids() {
1103        let rule = MD053LinkImageReferenceDefinitions::new();
1104        let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
1105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106        let result = rule.check(&ctx).unwrap();
1107
1108        // Should have no duplicate warnings, only unused warnings
1109        let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1110        assert_eq!(duplicate_warnings.len(), 0);
1111    }
1112
1113    #[test]
1114    fn test_comment_style_reference_double_slash() {
1115        let rule = MD053LinkImageReferenceDefinitions::new();
1116        // Most popular comment pattern: [//]: # (comment)
1117        let content = "[//]: # (This is a comment)\n\nSome regular text.";
1118        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119        let result = rule.check(&ctx).unwrap();
1120
1121        // Should not report as unused - it's recognized as a comment
1122        assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
1123    }
1124
1125    #[test]
1126    fn test_comment_style_reference_comment_label() {
1127        let rule = MD053LinkImageReferenceDefinitions::new();
1128        // Semantic comment pattern: [comment]: # (text)
1129        let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
1130        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131        let result = rule.check(&ctx).unwrap();
1132
1133        // Should not report either as unused
1134        assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
1135    }
1136
1137    #[test]
1138    fn test_comment_style_reference_todo_fixme() {
1139        let rule = MD053LinkImageReferenceDefinitions::new();
1140        // Task tracking patterns: [todo]: # and [fixme]: #
1141        let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143        let result = rule.check(&ctx).unwrap();
1144
1145        // Should not report any as unused
1146        assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1147    }
1148
1149    #[test]
1150    fn test_comment_style_reference_fragment_only() {
1151        let rule = MD053LinkImageReferenceDefinitions::new();
1152        // Any reference with just "#" as URL should be treated as a comment
1153        let content = "[anything]: #\n[ref]: #\n\nSome text.";
1154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155        let result = rule.check(&ctx).unwrap();
1156
1157        // Should not report as unused - fragment-only URLs are often comments
1158        assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1159    }
1160
1161    #[test]
1162    fn test_comment_vs_real_reference() {
1163        let rule = MD053LinkImageReferenceDefinitions::new();
1164        // Mix of comment and real reference - only real one should be flagged if unused
1165        let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167        let result = rule.check(&ctx).unwrap();
1168
1169        // Should only report the real reference as unused
1170        assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1171        assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1172    }
1173
1174    #[test]
1175    fn test_comment_with_fragment_section() {
1176        let rule = MD053LinkImageReferenceDefinitions::new();
1177        // Comment pattern with a fragment section (still a comment)
1178        let content = "[//]: #section (Comment about section)\n\nSome text.";
1179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180        let result = rule.check(&ctx).unwrap();
1181
1182        // Should not report as unused - it's still a comment pattern
1183        assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1184    }
1185
1186    #[test]
1187    fn test_is_likely_comment_reference_helper() {
1188        // Test the helper function directly
1189        assert!(
1190            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1191            "[//]: # should be recognized as comment"
1192        );
1193        assert!(
1194            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1195            "[comment]: #section should be recognized as comment"
1196        );
1197        assert!(
1198            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1199            "[note]: # should be recognized as comment"
1200        );
1201        assert!(
1202            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1203            "[todo]: # should be recognized as comment"
1204        );
1205        assert!(
1206            MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1207            "Any label with just '#' should be recognized as comment"
1208        );
1209        assert!(
1210            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1211            "Real URL should not be recognized as comment"
1212        );
1213        assert!(
1214            !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1215            "Real URL should not be recognized as comment"
1216        );
1217    }
1218
1219    #[test]
1220    fn test_reference_with_colon_in_name() {
1221        // References containing colons and spaces should be recognized as valid references
1222        let rule = MD053LinkImageReferenceDefinitions::new();
1223        let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1224        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225        let result = rule.check(&ctx).unwrap();
1226
1227        assert!(
1228            result.is_empty(),
1229            "Reference with colon should be recognized as used, got warnings: {result:?}"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_reference_with_colon_various_styles() {
1235        // Test various RFC-style and similar references with colons
1236        let rule = MD053LinkImageReferenceDefinitions::new();
1237        let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1238
1239[RFC: 1234]: https://example.com/rfc1234
1240[Issue: 42]: https://example.com/issue42
1241[PR: 100]: https://example.com/pr100
1242"#;
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244        let result = rule.check(&ctx).unwrap();
1245
1246        assert!(
1247            result.is_empty(),
1248            "All colon-style references should be recognized as used, got warnings: {result:?}"
1249        );
1250    }
1251
1252    #[test]
1253    fn test_should_skip_pattern_allows_rfc_style() {
1254        // Verify that should_skip_pattern does NOT skip RFC-style references with colons
1255        // This tests the fix for the bug where references with ": " were incorrectly skipped
1256        assert!(
1257            !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1258            "RFC-style references should NOT be skipped"
1259        );
1260        assert!(
1261            !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1262            "Issue-style references should NOT be skipped"
1263        );
1264        assert!(
1265            !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1266            "PR-style references should NOT be skipped"
1267        );
1268        assert!(
1269            !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1270            "References with 'See:' should NOT be skipped"
1271        );
1272        assert!(
1273            !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1274            "References without space after colon should NOT be skipped"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_should_skip_pattern_skips_prose() {
1280        // Verify that prose-like patterns (3+ words before colon) are still skipped
1281        assert!(
1282            MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1283            "Prose with 3+ words before colon SHOULD be skipped"
1284        );
1285        assert!(
1286            MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1287            "Prose with 4 words before colon SHOULD be skipped"
1288        );
1289        assert!(
1290            MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1291            "Prose-like descriptions SHOULD be skipped"
1292        );
1293    }
1294
1295    #[test]
1296    fn test_many_code_spans_with_shortcut_references() {
1297        // Exercises the binary search path for code span containment.
1298        // With many code spans, linear search would be slow; binary search stays O(log n).
1299        let rule = MD053LinkImageReferenceDefinitions::new();
1300
1301        let mut lines = Vec::new();
1302        // Generate many code spans interleaved with shortcut references
1303        for i in 0..100 {
1304            lines.push(format!("Some `code{i}` text and [used_ref] here"));
1305        }
1306        lines.push(String::new());
1307        lines.push("[used_ref]: https://example.com".to_string());
1308        lines.push("[unused_ref]: https://unused.com".to_string());
1309
1310        let content = lines.join("\n");
1311        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1312        let result = rule.check(&ctx).unwrap();
1313
1314        // used_ref is referenced 100 times, so only unused_ref should be reported
1315        assert_eq!(result.len(), 1);
1316        assert!(result[0].message.contains("unused_ref"));
1317    }
1318
1319    #[test]
1320    fn test_multiline_definition_continuation_tracking() {
1321        // Exercises the forward-tracking for multi-line definitions.
1322        // Definitions with title on the next line should be treated as a single unit.
1323        let rule = MD053LinkImageReferenceDefinitions::new();
1324        let content = "\
1325[ref1]: https://example.com
1326   \"Title on next line\"
1327
1328[ref2]: https://example2.com
1329   \"Another title\"
1330
1331Some text using [ref1] here.
1332";
1333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334        let result = rule.check(&ctx).unwrap();
1335
1336        // ref1 is used, ref2 is not
1337        assert_eq!(result.len(), 1);
1338        assert!(result[0].message.contains("ref2"));
1339    }
1340
1341    #[test]
1342    fn test_code_span_at_boundary_does_not_hide_reference() {
1343        // A reference immediately after a code span should still be detected
1344        let rule = MD053LinkImageReferenceDefinitions::new();
1345        let content = "`code`[ref]\n\n[ref]: https://example.com";
1346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347        let result = rule.check(&ctx).unwrap();
1348
1349        // [ref] is outside the code span, so it counts as a usage
1350        assert_eq!(result.len(), 0);
1351    }
1352
1353    #[test]
1354    fn test_reference_inside_code_span_not_counted() {
1355        // A reference inside a code span should NOT be counted as usage
1356        let rule = MD053LinkImageReferenceDefinitions::new();
1357        let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1358        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359        let result = rule.check(&ctx).unwrap();
1360
1361        // [ref] is inside a code span, so the definition is unused
1362        assert_eq!(result.len(), 1);
1363    }
1364
1365    #[test]
1366    fn test_shortcut_ref_at_byte_zero() {
1367        let rule = MD053LinkImageReferenceDefinitions::default();
1368        let content = "[example]\n\n[example]: https://example.com\n";
1369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370        let result = rule.check(&ctx).unwrap();
1371        assert!(
1372            result.is_empty(),
1373            "[ref] at byte 0 should be recognized as usage: {result:?}"
1374        );
1375    }
1376
1377    #[test]
1378    fn test_shortcut_ref_at_end_of_line() {
1379        let rule = MD053LinkImageReferenceDefinitions::default();
1380        let content = "Text [example]\n\n[example]: https://example.com\n";
1381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382        let result = rule.check(&ctx).unwrap();
1383        assert!(
1384            result.is_empty(),
1385            "[ref] at end of line should be recognized as usage: {result:?}"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_reference_in_multiline_footnote_not_false_positive() {
1391        // Issue #540: reference link inside multi-line footnote body
1392        // was falsely reported as unused because the 4-space-indented
1393        // footnote continuation was misidentified as an indented code block
1394        let rule = MD053LinkImageReferenceDefinitions::new();
1395        let content = "\
1396# Greetings
1397
1398This is a paragraph that has a footnote.[^footnote]
1399
1400[^footnote]:
1401    This footnote is long enough that it doesn't fit on just one line.
1402    Here is my [website][web].
1403
1404[web]: https://web.evanchen.cc
1405";
1406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407        let result = rule.check(&ctx).unwrap();
1408        assert!(
1409            result.is_empty(),
1410            "Reference used inside multi-line footnote should not be flagged: {result:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_reference_in_single_line_footnote() {
1416        let rule = MD053LinkImageReferenceDefinitions::new();
1417        let content = "\
1418# Greetings
1419
1420This is a paragraph that has a footnote.[^footnote]
1421
1422[^footnote]: Here is my [website][web].
1423
1424[web]: https://web.evanchen.cc
1425";
1426        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427        let result = rule.check(&ctx).unwrap();
1428        assert!(
1429            result.is_empty(),
1430            "Reference used inside single-line footnote should not be flagged: {result:?}"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_shortcut_reference_in_multiline_footnote() {
1436        // Shortcut reference [web] (not full [text][web]) inside footnote
1437        let rule = MD053LinkImageReferenceDefinitions::new();
1438        let content = "\
1439Text with footnote.[^note]
1440
1441[^note]:
1442    See [web] for details.
1443
1444[web]: https://example.com
1445";
1446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1447        let result = rule.check(&ctx).unwrap();
1448        assert!(
1449            result.is_empty(),
1450            "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_unused_reference_not_in_footnote_still_flagged() {
1456        // Ensure we don't accidentally suppress real unused references
1457        let rule = MD053LinkImageReferenceDefinitions::new();
1458        let content = "\
1459# Greetings
1460
1461This is a paragraph that has a footnote.[^footnote]
1462
1463[^footnote]:
1464    This footnote is long enough.
1465
1466[unused]: https://example.com
1467";
1468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469        let result = rule.check(&ctx).unwrap();
1470        assert_eq!(result.len(), 1);
1471        assert!(result[0].message.contains("unused"));
1472    }
1473
1474    #[test]
1475    fn test_image_reference_in_multiline_footnote() {
1476        let rule = MD053LinkImageReferenceDefinitions::new();
1477        let content = "\
1478Text with footnote.[^note]
1479
1480[^note]:
1481    Here is a diagram:
1482    ![diagram][img]
1483
1484[img]: https://example.com/diagram.png
1485";
1486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487        let result = rule.check(&ctx).unwrap();
1488        assert!(
1489            result.is_empty(),
1490            "Image reference inside multi-line footnote should not be flagged: {result:?}"
1491        );
1492    }
1493
1494    #[test]
1495    fn test_multiple_references_in_one_footnote() {
1496        let rule = MD053LinkImageReferenceDefinitions::new();
1497        let content = "\
1498Text.[^note]
1499
1500[^note]:
1501    See [link1][ref1] and [link2][ref2] for details.
1502
1503[ref1]: https://example.com
1504[ref2]: https://example.org
1505";
1506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1507        let result = rule.check(&ctx).unwrap();
1508        assert!(
1509            result.is_empty(),
1510            "Multiple references inside one footnote should all be recognized: {result:?}"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_reference_in_code_block_inside_footnote_not_counted() {
1516        // Fenced code blocks within footnotes should still be treated as code.
1517        // A [ref] pattern inside a fenced code block is not a usage.
1518        let rule = MD053LinkImageReferenceDefinitions::new();
1519        let content = "\
1520Text.[^code]
1521
1522[^code]:
1523    ```python
1524    x = [ref_like_syntax]
1525    ```
1526
1527[ref_like_syntax]: https://example.com
1528";
1529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530        let result = rule.check(&ctx).unwrap();
1531        assert_eq!(
1532            result.len(),
1533            1,
1534            "Reference inside fenced code block within footnote should still be unused: {result:?}"
1535        );
1536        assert!(result[0].message.contains("ref_like_syntax"));
1537    }
1538
1539    #[test]
1540    fn test_nested_list_in_footnote_with_reference() {
1541        let rule = MD053LinkImageReferenceDefinitions::new();
1542        let content = "\
1543Text.[^deep]
1544
1545[^deep]:
1546    - List item
1547        - Nested with [link text][deep-ref]
1548
1549[deep-ref]: https://example.com
1550";
1551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552        let result = rule.check(&ctx).unwrap();
1553        assert!(
1554            result.is_empty(),
1555            "Reference in nested list inside footnote should not be flagged: {result:?}"
1556        );
1557    }
1558}