rumdl_lib/rules/
md053_link_image_reference_definitions.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::document_structure::DocumentStructure;
4use crate::utils::range_utils::calculate_line_range;
5use fancy_regex::Regex as FancyRegex;
6use lazy_static::lazy_static;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11lazy_static! {
12    // Link reference format: [text][reference]
13    // REMOVED: static ref LINK_REFERENCE_REGEX: FancyRegex = FancyRegex::new(r"\[([^\]]*)\]\s*\[([^\]]*)\]").unwrap();
14
15    // Image reference format: ![text][reference]
16    // REMOVED: static ref IMAGE_REFERENCE_REGEX: FancyRegex = FancyRegex::new(r"!\[([^\]]*)\]\s*\[([^\]]*)\]").unwrap();
17
18    // Shortcut reference links: [reference] - must not be followed by a colon or another bracket to avoid matching definitions
19    // Allow references followed by parentheses (like "[reference] (text)")
20    static ref SHORTCUT_REFERENCE_REGEX: FancyRegex =
21        FancyRegex::new(r"(?<!\!)\[([^\]]+)\](?!\s*[\[:])").unwrap();
22
23    // REMOVED: Empty reference links: [text][] or ![text][]
24    // static ref EMPTY_LINK_REFERENCE_REGEX: Regex = Regex::new(r"\[([^\]]+)\]\s*\[\s*\]").unwrap();
25    // static ref EMPTY_IMAGE_REFERENCE_REGEX: Regex = Regex::new(r"!\[([^\]]+)\]\s*\[\s*\]").unwrap();
26
27    // Link/image reference definition format: [reference]: URL
28    static ref REFERENCE_DEFINITION_REGEX: Regex =
29        Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap();
30
31    // Multi-line reference definition continuation pattern
32    static ref CONTINUATION_REGEX: Regex = Regex::new(r"^\s+(.+)$").unwrap();
33
34    // Code block regex
35    static ref CODE_BLOCK_START_REGEX: Regex = Regex::new(r"^```").unwrap();
36    static ref CODE_BLOCK_END_REGEX: Regex = Regex::new(r"^```\s*$").unwrap();
37}
38
39/// Configuration for MD053 rule
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41#[serde(rename_all = "kebab-case")]
42pub struct MD053Config {
43    /// List of reference names to keep even if unused
44    #[serde(default = "default_ignored_definitions")]
45    pub ignored_definitions: Vec<String>,
46}
47
48impl Default for MD053Config {
49    fn default() -> Self {
50        Self {
51            ignored_definitions: default_ignored_definitions(),
52        }
53    }
54}
55
56fn default_ignored_definitions() -> Vec<String> {
57    Vec::new()
58}
59
60impl RuleConfig for MD053Config {
61    const RULE_NAME: &'static str = "MD053";
62}
63
64/// Rule MD053: Link and image reference definitions should be used
65///
66/// See [docs/md053.md](../../docs/md053.md) for full documentation, configuration, and examples.
67///
68/// This rule is triggered when a link or image reference definition is declared but not used
69/// anywhere in the document. Unused reference definitions can create confusion and clutter.
70///
71/// ## Supported Reference Formats
72///
73/// This rule handles the following reference formats:
74///
75/// - **Full reference links/images**: `[text][reference]` or `![text][reference]`
76/// - **Collapsed reference links/images**: `[text][]` or `![text][]`
77/// - **Shortcut reference links**: `[reference]` (must be defined elsewhere)
78/// - **Reference definitions**: `[reference]: URL "Optional Title"`
79/// - **Multi-line reference definitions**:
80///   ```markdown
81///   [reference]: URL
82///      "Optional title continued on next line"
83///   ```
84///
85/// ## Configuration Options
86///
87/// The rule supports the following configuration options:
88///
89/// ```yaml
90/// MD053:
91///   ignored_definitions: []  # List of reference definitions to ignore (never report as unused)
92/// ```
93///
94/// ## Performance Optimizations
95///
96/// This rule implements various performance optimizations for handling large documents:
97///
98/// 1. **Caching**: The rule caches parsed definitions and references based on content hashing
99/// 2. **Efficient Reference Matching**: Uses HashMaps for O(1) lookups of definitions
100/// 3. **Smart Code Block Handling**: Efficiently skips references inside code blocks/spans
101/// 4. **Lazy Evaluation**: Only processes necessary portions of the document
102///
103/// ## Edge Cases Handled
104///
105/// - **Case insensitivity**: References are matched case-insensitively
106/// - **Escaped characters**: Properly processes escaped characters in references
107/// - **Unicode support**: Handles non-ASCII characters in references and URLs
108/// - **Code blocks**: Ignores references inside code blocks and spans
109/// - **Special characters**: Properly handles references with special characters
110///
111/// ## Fix Behavior
112///
113/// This rule does not provide automatic fixes. Unused references must be manually reviewed
114/// and removed, as they may be intentionally kept for future use or as templates.
115#[derive(Clone)]
116pub struct MD053LinkImageReferenceDefinitions {
117    config: MD053Config,
118}
119
120impl MD053LinkImageReferenceDefinitions {
121    /// Create a new instance of the MD053 rule
122    pub fn new() -> Self {
123        Self {
124            config: MD053Config::default(),
125        }
126    }
127
128    /// Create a new instance with the given configuration
129    pub fn from_config_struct(config: MD053Config) -> Self {
130        Self { config }
131    }
132
133    /// Unescape a reference string by removing backslashes before special characters.
134    ///
135    /// This allows matching references like `[example\-reference]` with definitions like
136    /// `[example-reference]: http://example.com`
137    ///
138    /// Returns the unescaped reference string.
139    fn unescape_reference(reference: &str) -> String {
140        // Remove backslashes before special characters
141        reference.replace("\\", "")
142    }
143
144    /// Find all link and image reference definitions in the content.
145    ///
146    /// 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.
147    fn find_definitions(
148        &self,
149        ctx: &crate::lint_context::LintContext,
150        doc_structure: &DocumentStructure,
151    ) -> HashMap<String, Vec<(usize, usize)>> {
152        let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
153
154        // First, add all reference definitions from context
155        for ref_def in &ctx.reference_defs {
156            // Apply unescape to handle escaped characters in definitions
157            let normalized_id = Self::unescape_reference(&ref_def.id); // Already lowercase from context
158            definitions
159                .entry(normalized_id)
160                .or_default()
161                .push((ref_def.line - 1, ref_def.line - 1)); // Convert to 0-indexed
162        }
163
164        // Handle multi-line definitions that might not be fully captured by ctx.reference_defs
165        let lines = &ctx.lines;
166        let mut i = 0;
167        while i < lines.len() {
168            let line_info = &lines[i];
169            let line = &line_info.content;
170
171            // Skip code blocks and front matter using line info
172            if line_info.in_code_block || doc_structure.is_in_front_matter(i + 1) {
173                i += 1;
174                continue;
175            }
176
177            // Check for multi-line continuation of existing definitions
178            if i > 0 && CONTINUATION_REGEX.is_match(line) {
179                // Find the reference definition this continues
180                let mut def_start = i - 1;
181                while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(&lines[def_start].content) {
182                    def_start -= 1;
183                }
184
185                if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].content) {
186                    let ref_id = caps.get(1).unwrap().as_str().trim();
187                    let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
188
189                    // Update the end line for this definition
190                    if let Some(ranges) = definitions.get_mut(&normalized_id)
191                        && let Some(last_range) = ranges.last_mut()
192                        && last_range.0 == def_start
193                    {
194                        last_range.1 = i;
195                    }
196                }
197            }
198            i += 1;
199        }
200        definitions
201    }
202
203    /// Find all link and image reference reference usages in the content.
204    ///
205    /// This method returns a HashSet of all normalized reference IDs found in usage.
206    /// It leverages cached data from LintContext for efficiency.
207    fn find_usages(
208        &self,
209        doc_structure: &DocumentStructure,
210        ctx: &crate::lint_context::LintContext,
211    ) -> HashSet<String> {
212        let mut usages: HashSet<String> = HashSet::new();
213
214        // 1. Add usages from cached reference links in LintContext
215        for link in &ctx.links {
216            if link.is_reference
217                && let Some(ref_id) = &link.reference_id
218            {
219                // Ensure the link itself is not inside a code block line
220                if !doc_structure.is_in_code_block(link.line) {
221                    usages.insert(Self::unescape_reference(ref_id).to_lowercase());
222                }
223            }
224        }
225
226        // 2. Add usages from cached reference images in LintContext
227        for image in &ctx.images {
228            if image.is_reference
229                && let Some(ref_id) = &image.reference_id
230            {
231                // Ensure the image itself is not inside a code block line
232                if !doc_structure.is_in_code_block(image.line) {
233                    usages.insert(Self::unescape_reference(ref_id).to_lowercase());
234                }
235            }
236        }
237
238        // 3. Find shortcut references [ref] not already handled by DocumentStructure.links
239        //    and ensure they are not within code spans or code blocks.
240        // Cache code spans once before the loop
241        let code_spans = ctx.code_spans();
242
243        for (i, line_info) in ctx.lines.iter().enumerate() {
244            let line_num = i + 1; // 1-indexed
245
246            // Skip lines in code blocks or front matter
247            if line_info.in_code_block || doc_structure.is_in_front_matter(line_num) {
248                continue;
249            }
250
251            // Find potential shortcut references
252            for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(&line_info.content).flatten() {
253                if let Some(full_match) = caps.get(0)
254                    && let Some(ref_id_match) = caps.get(1)
255                {
256                    // Check if the match is within a code span
257                    let match_byte_offset = line_info.byte_offset + full_match.start();
258                    let in_code_span = code_spans
259                        .iter()
260                        .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
261
262                    if !in_code_span {
263                        let ref_id = ref_id_match.as_str().trim();
264                        let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
265                        usages.insert(normalized_id);
266                    }
267                }
268            }
269        }
270
271        // NOTE: The complex recursive loop trying to find references within definitions
272        // has been removed as it's not standard Markdown behavior for finding *usages*.
273        // Usages refer to `[text][ref]`, `![alt][ref]`, `[ref]`, etc., in the main content,
274        // not references potentially embedded within the URL or title of another definition.
275
276        usages
277    }
278
279    /// Get unused references with their line ranges.
280    ///
281    /// This method uses the cached definitions to improve performance.
282    ///
283    /// Note: References that are only used inside code blocks are still considered unused,
284    /// as code blocks are treated as examples or documentation rather than actual content.
285    fn get_unused_references(
286        &self,
287        definitions: &HashMap<String, Vec<(usize, usize)>>,
288        usages: &HashSet<String>,
289    ) -> Vec<(String, usize, usize)> {
290        let mut unused = Vec::new();
291        for (id, ranges) in definitions {
292            // If this id is not used anywhere and is not in the ignored list, all its ranges are unused
293            if !usages.contains(id) && !self.is_ignored_definition(id) {
294                for (start, end) in ranges {
295                    unused.push((id.clone(), *start, *end));
296                }
297            }
298        }
299        unused
300    }
301
302    /// Check if a definition should be ignored (kept even if unused)
303    fn is_ignored_definition(&self, definition_id: &str) -> bool {
304        self.config
305            .ignored_definitions
306            .iter()
307            .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
308    }
309}
310
311impl Default for MD053LinkImageReferenceDefinitions {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317impl Rule for MD053LinkImageReferenceDefinitions {
318    fn name(&self) -> &'static str {
319        "MD053"
320    }
321
322    fn description(&self) -> &'static str {
323        "Link and image reference definitions should be needed"
324    }
325
326    /// Check the content for unused link/image reference definitions.
327    ///
328    /// This implementation uses caching for improved performance on large documents.
329    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
330        let content = ctx.content;
331        // Compute DocumentStructure once
332        let doc_structure = DocumentStructure::new(content);
333
334        // Find definitions and usages using DocumentStructure
335        let definitions = self.find_definitions(ctx, &doc_structure);
336        let usages = self.find_usages(&doc_structure, ctx);
337
338        // Get unused references by comparing definitions and usages
339        let unused_refs = self.get_unused_references(&definitions, &usages);
340
341        let mut warnings = Vec::new();
342
343        // Create warnings for unused references
344        for (definition, start, _end) in unused_refs {
345            let line_num = start + 1; // 1-indexed line numbers
346            let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
347
348            // Calculate precise character range for the entire reference definition line
349            let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
350
351            warnings.push(LintWarning {
352                rule_name: Some(self.name()),
353                line: start_line,
354                column: start_col,
355                end_line,
356                end_column: end_col,
357                message: format!("Unused link/image reference: [{definition}]"),
358                severity: Severity::Warning,
359                fix: None, // MD053 is warning-only, no automatic fixes
360            });
361        }
362
363        Ok(warnings)
364    }
365
366    /// MD053 does not provide automatic fixes
367    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
368        // This rule is warning-only, no automatic fixes provided
369        Ok(ctx.content.to_string())
370    }
371
372    /// Check if this rule should be skipped for performance
373    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
374        // Skip if content is empty or has no reference definitions
375        ctx.content.is_empty() || !ctx.content.contains("]:")
376    }
377
378    fn as_any(&self) -> &dyn std::any::Any {
379        self
380    }
381
382    fn default_config_section(&self) -> Option<(String, toml::Value)> {
383        let default_config = MD053Config::default();
384        let json_value = serde_json::to_value(&default_config).ok()?;
385        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
386        if let toml::Value::Table(table) = toml_value {
387            if !table.is_empty() {
388                Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
389            } else {
390                None
391            }
392        } else {
393            None
394        }
395    }
396
397    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
398    where
399        Self: Sized,
400    {
401        let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
402        Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::lint_context::LintContext;
410
411    #[test]
412    fn test_used_reference_link() {
413        let rule = MD053LinkImageReferenceDefinitions::new();
414        let content = "[text][ref]\n\n[ref]: https://example.com";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
416        let result = rule.check(&ctx).unwrap();
417
418        assert_eq!(result.len(), 0);
419    }
420
421    #[test]
422    fn test_unused_reference_definition() {
423        let rule = MD053LinkImageReferenceDefinitions::new();
424        let content = "[unused]: https://example.com";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
426        let result = rule.check(&ctx).unwrap();
427
428        assert_eq!(result.len(), 1);
429        assert!(result[0].message.contains("Unused link/image reference: [unused]"));
430    }
431
432    #[test]
433    fn test_used_reference_image() {
434        let rule = MD053LinkImageReferenceDefinitions::new();
435        let content = "![alt][img]\n\n[img]: image.jpg";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437        let result = rule.check(&ctx).unwrap();
438
439        assert_eq!(result.len(), 0);
440    }
441
442    #[test]
443    fn test_case_insensitive_matching() {
444        let rule = MD053LinkImageReferenceDefinitions::new();
445        let content = "[Text][REF]\n\n[ref]: https://example.com";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447        let result = rule.check(&ctx).unwrap();
448
449        assert_eq!(result.len(), 0);
450    }
451
452    #[test]
453    fn test_shortcut_reference() {
454        let rule = MD053LinkImageReferenceDefinitions::new();
455        let content = "[ref]\n\n[ref]: https://example.com";
456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
457        let result = rule.check(&ctx).unwrap();
458
459        assert_eq!(result.len(), 0);
460    }
461
462    #[test]
463    fn test_collapsed_reference() {
464        let rule = MD053LinkImageReferenceDefinitions::new();
465        let content = "[ref][]\n\n[ref]: https://example.com";
466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
467        let result = rule.check(&ctx).unwrap();
468
469        assert_eq!(result.len(), 0);
470    }
471
472    #[test]
473    fn test_multiple_unused_definitions() {
474        let rule = MD053LinkImageReferenceDefinitions::new();
475        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477        let result = rule.check(&ctx).unwrap();
478
479        assert_eq!(result.len(), 3);
480
481        // The warnings might not be in the same order, so collect all messages
482        let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
483        assert!(messages.iter().any(|m| m.contains("unused1")));
484        assert!(messages.iter().any(|m| m.contains("unused2")));
485        assert!(messages.iter().any(|m| m.contains("unused3")));
486    }
487
488    #[test]
489    fn test_mixed_used_and_unused() {
490        let rule = MD053LinkImageReferenceDefinitions::new();
491        let content = "[used]\n\n[used]: url1\n[unused]: url2";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493        let result = rule.check(&ctx).unwrap();
494
495        assert_eq!(result.len(), 1);
496        assert!(result[0].message.contains("unused"));
497    }
498
499    #[test]
500    fn test_multiline_definition() {
501        let rule = MD053LinkImageReferenceDefinitions::new();
502        let content = "[ref]: https://example.com\n  \"Title on next line\"";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504        let result = rule.check(&ctx).unwrap();
505
506        assert_eq!(result.len(), 1); // Still unused
507    }
508
509    #[test]
510    fn test_reference_in_code_block() {
511        let rule = MD053LinkImageReferenceDefinitions::new();
512        let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514        let result = rule.check(&ctx).unwrap();
515
516        // Reference used only in code block is still considered unused
517        assert_eq!(result.len(), 1);
518    }
519
520    #[test]
521    fn test_reference_in_inline_code() {
522        let rule = MD053LinkImageReferenceDefinitions::new();
523        let content = "`[ref]`\n\n[ref]: https://example.com";
524        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
525        let result = rule.check(&ctx).unwrap();
526
527        // Reference in inline code is not a usage
528        assert_eq!(result.len(), 1);
529    }
530
531    #[test]
532    fn test_escaped_reference() {
533        let rule = MD053LinkImageReferenceDefinitions::new();
534        let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
536        let result = rule.check(&ctx).unwrap();
537
538        // Should match despite escaping
539        assert_eq!(result.len(), 0);
540    }
541
542    #[test]
543    fn test_duplicate_definitions() {
544        let rule = MD053LinkImageReferenceDefinitions::new();
545        let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547        let result = rule.check(&ctx).unwrap();
548
549        // Both definitions are used (Markdown uses the first one)
550        assert_eq!(result.len(), 0);
551    }
552
553    #[test]
554    fn test_fix_returns_original() {
555        // MD053 is warning-only, fix should return original content
556        let rule = MD053LinkImageReferenceDefinitions::new();
557        let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
559        let fixed = rule.fix(&ctx).unwrap();
560
561        assert_eq!(fixed, content);
562    }
563
564    #[test]
565    fn test_fix_preserves_content() {
566        // MD053 is warning-only, fix should preserve all content
567        let rule = MD053LinkImageReferenceDefinitions::new();
568        let content = "Content\n\n[unused]: url\n\nMore content";
569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
570        let fixed = rule.fix(&ctx).unwrap();
571
572        assert_eq!(fixed, content);
573    }
574
575    #[test]
576    fn test_fix_does_not_remove() {
577        // MD053 is warning-only, fix should not remove anything
578        let rule = MD053LinkImageReferenceDefinitions::new();
579        let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
581        let fixed = rule.fix(&ctx).unwrap();
582
583        assert_eq!(fixed, content);
584    }
585
586    #[test]
587    fn test_special_characters_in_reference() {
588        let rule = MD053LinkImageReferenceDefinitions::new();
589        let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591        let result = rule.check(&ctx).unwrap();
592
593        assert_eq!(result.len(), 0);
594    }
595
596    #[test]
597    fn test_find_definitions() {
598        let rule = MD053LinkImageReferenceDefinitions::new();
599        let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601        let doc = DocumentStructure::new(content);
602        let defs = rule.find_definitions(&ctx, &doc);
603
604        assert_eq!(defs.len(), 3);
605        assert!(defs.contains_key("ref1"));
606        assert!(defs.contains_key("ref2"));
607        assert!(defs.contains_key("ref3"));
608    }
609
610    #[test]
611    fn test_find_usages() {
612        let rule = MD053LinkImageReferenceDefinitions::new();
613        let content = "[text][ref1] and [ref2] and ![img][ref3]";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
615        let doc = DocumentStructure::new(content);
616        let usages = rule.find_usages(&doc, &ctx);
617
618        assert!(usages.contains("ref1"));
619        assert!(usages.contains("ref2"));
620        assert!(usages.contains("ref3"));
621    }
622
623    #[test]
624    fn test_ignored_definitions_config() {
625        // Test with ignored definitions
626        let config = MD053Config {
627            ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
628        };
629        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
630
631        let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633        let result = rule.check(&ctx).unwrap();
634
635        // Should only flag "unused", not "todo" or "draft"
636        assert_eq!(result.len(), 1);
637        assert!(result[0].message.contains("unused"));
638        assert!(!result[0].message.contains("todo"));
639        assert!(!result[0].message.contains("draft"));
640    }
641
642    #[test]
643    fn test_ignored_definitions_case_insensitive() {
644        // Test case-insensitive matching of ignored definitions
645        let config = MD053Config {
646            ignored_definitions: vec!["TODO".to_string()],
647        };
648        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
649
650        let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
652        let result = rule.check(&ctx).unwrap();
653
654        // Should only flag "unused", not "todo" (matches "TODO" case-insensitively)
655        assert_eq!(result.len(), 1);
656        assert!(result[0].message.contains("unused"));
657        assert!(!result[0].message.contains("todo"));
658    }
659
660    #[test]
661    fn test_default_config_section() {
662        let rule = MD053LinkImageReferenceDefinitions::default();
663        let config_section = rule.default_config_section();
664
665        assert!(config_section.is_some());
666        let (name, value) = config_section.unwrap();
667        assert_eq!(name, "MD053");
668
669        // Should contain the ignored_definitions option with default empty array
670        if let toml::Value::Table(table) = value {
671            assert!(table.contains_key("ignored-definitions"));
672            assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
673        } else {
674            panic!("Expected TOML table");
675        }
676    }
677
678    #[test]
679    fn test_fix_with_ignored_definitions() {
680        // MD053 is warning-only, fix should not remove anything even with ignored definitions
681        let config = MD053Config {
682            ignored_definitions: vec!["template".to_string()],
683        };
684        let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
685
686        let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
688        let fixed = rule.fix(&ctx).unwrap();
689
690        // Should keep everything since MD053 doesn't fix
691        assert_eq!(fixed, content);
692    }
693}