rumdl_lib/rules/
md066_footnote_validation.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use fancy_regex::Regex as FancyRegex;
3use regex::Regex;
4use std::collections::{HashMap, HashSet};
5use std::sync::LazyLock;
6
7/// Pattern to match footnote definitions: [^id]: content
8/// Matches at start of line, with 0-3 leading spaces, caret in brackets
9/// Also handles definitions inside blockquotes (after stripping > prefixes)
10pub static FOOTNOTE_DEF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[ ]{0,3}\[\^([^\]]+)\]:").unwrap());
11
12/// Pattern to match footnote references in text: [^id]
13/// Must NOT be followed by : (which would make it a definition)
14/// Uses fancy_regex for negative lookahead support
15pub static FOOTNOTE_REF_PATTERN: LazyLock<FancyRegex> =
16    LazyLock::new(|| FancyRegex::new(r"\[\^([^\]]+)\](?!:)").unwrap());
17
18/// Strip blockquote prefixes from a line to check for footnote definitions
19/// Handles nested blockquotes like `> > > ` and variations with/without spaces
20pub fn strip_blockquote_prefix(line: &str) -> &str {
21    let mut chars = line.chars().peekable();
22    let mut last_content_start = 0;
23    let mut pos = 0;
24
25    while let Some(&c) = chars.peek() {
26        match c {
27            '>' => {
28                chars.next();
29                pos += 1;
30                // Optionally consume one space after >
31                if chars.peek() == Some(&' ') {
32                    chars.next();
33                    pos += 1;
34                }
35                last_content_start = pos;
36            }
37            ' ' => {
38                // Allow leading spaces before >
39                chars.next();
40                pos += 1;
41            }
42            _ => break,
43        }
44    }
45
46    &line[last_content_start..]
47}
48
49/// Rule MD066: Footnote validation - ensure all footnote references have definitions and vice versa
50///
51/// This rule validates footnote usage in markdown documents:
52/// - Detects orphaned footnote references (`[^1]`) without corresponding definitions
53/// - Detects orphaned footnote definitions (`[^1]: text`) that are never referenced
54///
55/// Footnote syntax (common markdown extension, not part of CommonMark):
56/// - Reference: `[^identifier]` in text
57/// - Definition: `[^identifier]: definition text` (can span multiple lines with indentation)
58///
59/// ## Examples
60///
61/// **Valid:**
62/// ```markdown
63/// This has a footnote[^1] that is properly defined.
64///
65/// [^1]: This is the footnote content.
66/// ```
67///
68/// **Invalid - orphaned reference:**
69/// ```markdown
70/// This references[^missing] a footnote that doesn't exist.
71/// ```
72///
73/// **Invalid - orphaned definition:**
74/// ```markdown
75/// [^unused]: This footnote is defined but never referenced.
76/// ```
77#[derive(Debug, Clone, Default)]
78pub struct MD066FootnoteValidation;
79
80impl MD066FootnoteValidation {
81    pub fn new() -> Self {
82        Self
83    }
84}
85
86impl Rule for MD066FootnoteValidation {
87    fn name(&self) -> &'static str {
88        "MD066"
89    }
90
91    fn description(&self) -> &'static str {
92        "Footnote validation"
93    }
94
95    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
96        let mut warnings = Vec::new();
97
98        // Early exit if no footnotes at all
99        if ctx.footnote_refs.is_empty() && !ctx.content.contains("[^") {
100            return Ok(warnings);
101        }
102
103        // Collect all footnote references (id is WITHOUT the ^ prefix)
104        // Map from id -> list of (line, byte_offset) for each reference
105        // Note: pulldown-cmark only finds references when definitions exist,
106        // so we need to parse references directly to find orphaned ones
107        let mut references: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
108
109        // First, use pulldown-cmark's detected references (when definitions exist)
110        for footnote_ref in &ctx.footnote_refs {
111            // Skip if in code block, frontmatter, HTML comment, or HTML block
112            if ctx.line_info(footnote_ref.line).is_some_and(|info| {
113                info.in_code_block || info.in_front_matter || info.in_html_comment || info.in_html_block
114            }) {
115                continue;
116            }
117            references
118                .entry(footnote_ref.id.to_lowercase())
119                .or_default()
120                .push((footnote_ref.line, footnote_ref.byte_offset));
121        }
122
123        // Also parse references directly to find orphaned ones (without definitions)
124        let code_spans = ctx.code_spans();
125        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
126            // Skip if in code block, frontmatter, HTML comment, or HTML block
127            if line_info.in_code_block
128                || line_info.in_front_matter
129                || line_info.in_html_comment
130                || line_info.in_html_block
131            {
132                continue;
133            }
134
135            let line = line_info.content(ctx.content);
136            let line_num = line_idx + 1; // 1-indexed
137
138            for caps in FOOTNOTE_REF_PATTERN.captures_iter(line).flatten() {
139                if let Some(id_match) = caps.get(1) {
140                    let id = id_match.as_str().to_lowercase();
141
142                    // Check if this match is inside a code span
143                    let match_start = caps.get(0).unwrap().start();
144                    let byte_offset = line_info.byte_offset + match_start;
145
146                    let in_code_span = code_spans
147                        .iter()
148                        .any(|span| byte_offset >= span.byte_offset && byte_offset < span.byte_end);
149
150                    if !in_code_span {
151                        // Only add if not already found (avoid duplicates with pulldown-cmark)
152                        references.entry(id).or_default().push((line_num, byte_offset));
153                    }
154                }
155            }
156        }
157
158        // Deduplicate references (pulldown-cmark and regex might find the same ones)
159        for occurrences in references.values_mut() {
160            occurrences.sort();
161            occurrences.dedup();
162        }
163
164        // Collect footnote definitions by parsing directly from content
165        // Footnote definitions: [^id]: content (NOT in reference_defs which expects URLs)
166        // Map from id (lowercase) -> list of (line, byte_offset) for duplicate detection
167        let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
168        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
169            // Skip if in code block, frontmatter, HTML comment, or HTML block
170            if line_info.in_code_block
171                || line_info.in_front_matter
172                || line_info.in_html_comment
173                || line_info.in_html_block
174            {
175                continue;
176            }
177
178            let line = line_info.content(ctx.content);
179            // Strip blockquote prefixes to handle definitions inside blockquotes
180            let line_stripped = strip_blockquote_prefix(line);
181
182            if let Some(caps) = FOOTNOTE_DEF_PATTERN.captures(line_stripped)
183                && let Some(id_match) = caps.get(1)
184            {
185                let id = id_match.as_str().to_lowercase();
186                let line_num = line_idx + 1; // 1-indexed
187                definitions
188                    .entry(id)
189                    .or_default()
190                    .push((line_num, line_info.byte_offset));
191            }
192        }
193
194        // Check for duplicate definitions
195        for (def_id, occurrences) in &definitions {
196            if occurrences.len() > 1 {
197                // Report all duplicate definitions after the first one
198                for (line, _byte_offset) in &occurrences[1..] {
199                    warnings.push(LintWarning {
200                        rule_name: Some(self.name().to_string()),
201                        line: *line,
202                        column: 1,
203                        end_line: *line,
204                        end_column: 1,
205                        message: format!(
206                            "Duplicate footnote definition '[^{def_id}]' (first defined on line {})",
207                            occurrences[0].0
208                        ),
209                        severity: Severity::Error,
210                        fix: None,
211                    });
212                }
213            }
214        }
215
216        // Check for orphaned references (references without definitions)
217        let defined_ids: HashSet<&String> = definitions.keys().collect();
218        for (ref_id, occurrences) in &references {
219            if !defined_ids.contains(ref_id) {
220                // Report the first occurrence of each undefined reference
221                let (line, _byte_offset) = occurrences[0];
222                warnings.push(LintWarning {
223                    rule_name: Some(self.name().to_string()),
224                    line,
225                    column: 1,
226                    end_line: line,
227                    end_column: 1,
228                    message: format!("Footnote reference '[^{ref_id}]' has no corresponding definition"),
229                    severity: Severity::Error,
230                    fix: None,
231                });
232            }
233        }
234
235        // Check for orphaned definitions (definitions without references)
236        let referenced_ids: HashSet<&String> = references.keys().collect();
237        for (def_id, occurrences) in &definitions {
238            if !referenced_ids.contains(def_id) {
239                // Report the first definition location
240                let (line, _byte_offset) = occurrences[0];
241                warnings.push(LintWarning {
242                    rule_name: Some(self.name().to_string()),
243                    line,
244                    column: 1,
245                    end_line: line,
246                    end_column: 1,
247                    message: format!("Footnote definition '[^{def_id}]' is never referenced"),
248                    severity: Severity::Error,
249                    fix: None,
250                });
251            }
252        }
253
254        // Sort warnings by line number for consistent output
255        warnings.sort_by_key(|w| w.line);
256
257        Ok(warnings)
258    }
259
260    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
261        // No automatic fix - user must decide what to do with orphaned footnotes
262        Ok(ctx.content.to_string())
263    }
264
265    fn as_any(&self) -> &dyn std::any::Any {
266        self
267    }
268
269    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
270    where
271        Self: Sized,
272    {
273        Box::new(MD066FootnoteValidation)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::lint_context::LintContext;
281
282    fn check_md066(content: &str) -> Vec<LintWarning> {
283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
284        MD066FootnoteValidation::new().check(&ctx).unwrap()
285    }
286
287    // ==================== Valid cases ====================
288
289    #[test]
290    fn test_valid_single_footnote() {
291        let content = "This has a footnote[^1].\n\n[^1]: The footnote content.";
292        let warnings = check_md066(content);
293        assert!(warnings.is_empty(), "Valid footnote should not warn: {warnings:?}");
294    }
295
296    #[test]
297    fn test_valid_multiple_footnotes() {
298        let content = r#"First footnote[^1] and second[^2].
299
300[^1]: First definition.
301[^2]: Second definition."#;
302        let warnings = check_md066(content);
303        assert!(warnings.is_empty(), "Valid footnotes should not warn: {warnings:?}");
304    }
305
306    #[test]
307    fn test_valid_named_footnotes() {
308        let content = r#"See the note[^note] and warning[^warning].
309
310[^note]: This is a note.
311[^warning]: This is a warning."#;
312        let warnings = check_md066(content);
313        assert!(warnings.is_empty(), "Named footnotes should not warn: {warnings:?}");
314    }
315
316    #[test]
317    fn test_valid_footnote_used_multiple_times() {
318        let content = r#"First[^1] and again[^1] and third[^1].
319
320[^1]: Used multiple times."#;
321        let warnings = check_md066(content);
322        assert!(warnings.is_empty(), "Reused footnote should not warn: {warnings:?}");
323    }
324
325    #[test]
326    fn test_valid_case_insensitive_matching() {
327        let content = r#"Reference[^NOTE].
328
329[^note]: Definition with different case."#;
330        let warnings = check_md066(content);
331        assert!(
332            warnings.is_empty(),
333            "Case-insensitive matching should work: {warnings:?}"
334        );
335    }
336
337    #[test]
338    fn test_no_footnotes_at_all() {
339        let content = "Just regular markdown without any footnotes.";
340        let warnings = check_md066(content);
341        assert!(warnings.is_empty(), "No footnotes should not warn");
342    }
343
344    // ==================== Orphaned references ====================
345
346    #[test]
347    fn test_orphaned_reference_single() {
348        let content = "This references[^missing] a non-existent footnote.";
349        let warnings = check_md066(content);
350        assert_eq!(warnings.len(), 1, "Should detect orphaned reference");
351        assert!(warnings[0].message.contains("missing"));
352        assert!(warnings[0].message.contains("no corresponding definition"));
353    }
354
355    #[test]
356    fn test_orphaned_reference_multiple() {
357        let content = r#"First[^a], second[^b], third[^c].
358
359[^b]: Only b is defined."#;
360        let warnings = check_md066(content);
361        assert_eq!(warnings.len(), 2, "Should detect 2 orphaned references: {warnings:?}");
362        let messages: Vec<&str> = warnings.iter().map(|w| w.message.as_str()).collect();
363        assert!(messages.iter().any(|m| m.contains("[^a]")));
364        assert!(messages.iter().any(|m| m.contains("[^c]")));
365    }
366
367    #[test]
368    fn test_orphaned_reference_reports_first_occurrence() {
369        let content = "First[^missing] and again[^missing] and third[^missing].";
370        let warnings = check_md066(content);
371        // Should only report once per unique ID
372        assert_eq!(warnings.len(), 1, "Should report each orphaned ID once");
373        assert!(warnings[0].message.contains("missing"));
374    }
375
376    // ==================== Orphaned definitions ====================
377
378    #[test]
379    fn test_orphaned_definition_single() {
380        let content = "Regular text.\n\n[^unused]: This is never referenced.";
381        let warnings = check_md066(content);
382        assert_eq!(warnings.len(), 1, "Should detect orphaned definition");
383        assert!(warnings[0].message.contains("unused"));
384        assert!(warnings[0].message.contains("never referenced"));
385    }
386
387    #[test]
388    fn test_orphaned_definition_multiple() {
389        let content = r#"Using one[^used].
390
391[^used]: This is used.
392[^orphan1]: Never used.
393[^orphan2]: Also never used."#;
394        let warnings = check_md066(content);
395        assert_eq!(warnings.len(), 2, "Should detect 2 orphaned definitions: {warnings:?}");
396        let messages: Vec<&str> = warnings.iter().map(|w| w.message.as_str()).collect();
397        assert!(messages.iter().any(|m| m.contains("orphan1")));
398        assert!(messages.iter().any(|m| m.contains("orphan2")));
399    }
400
401    // ==================== Mixed cases ====================
402
403    #[test]
404    fn test_both_orphaned_reference_and_definition() {
405        let content = r#"Reference[^missing].
406
407[^unused]: Never referenced."#;
408        let warnings = check_md066(content);
409        assert_eq!(
410            warnings.len(),
411            2,
412            "Should detect both orphaned ref and def: {warnings:?}"
413        );
414        let messages: Vec<&str> = warnings.iter().map(|w| w.message.as_str()).collect();
415        assert!(
416            messages.iter().any(|m| m.contains("missing")),
417            "Should find missing ref"
418        );
419        assert!(messages.iter().any(|m| m.contains("unused")), "Should find unused def");
420    }
421
422    // ==================== Code block handling ====================
423
424    #[test]
425    fn test_footnote_in_code_block_ignored() {
426        let content = r#"```
427[^1]: This is in a code block
428```
429
430Regular text without footnotes."#;
431        let warnings = check_md066(content);
432        assert!(warnings.is_empty(), "Footnotes in code blocks should be ignored");
433    }
434
435    #[test]
436    fn test_footnote_reference_in_code_span_ignored() {
437        // Note: This depends on whether pulldown-cmark parses footnotes inside code spans
438        // If it does, we should skip them
439        let content = r#"Use `[^1]` syntax for footnotes.
440
441[^1]: This definition exists but the reference in backticks shouldn't count."#;
442        // This is tricky - if pulldown-cmark doesn't parse [^1] in backticks as a footnote ref,
443        // then the definition is orphaned
444        let warnings = check_md066(content);
445        // Expectation depends on parser behavior - test the actual behavior
446        assert_eq!(
447            warnings.len(),
448            1,
449            "Code span reference shouldn't count, definition is orphaned"
450        );
451        assert!(warnings[0].message.contains("never referenced"));
452    }
453
454    // ==================== Frontmatter handling ====================
455
456    #[test]
457    fn test_footnote_in_frontmatter_ignored() {
458        let content = r#"---
459note: "[^1]: yaml value"
460---
461
462Regular content."#;
463        let warnings = check_md066(content);
464        assert!(
465            warnings.is_empty(),
466            "Footnotes in frontmatter should be ignored: {warnings:?}"
467        );
468    }
469
470    // ==================== Edge cases ====================
471
472    #[test]
473    fn test_empty_document() {
474        let warnings = check_md066("");
475        assert!(warnings.is_empty());
476    }
477
478    #[test]
479    fn test_footnote_with_special_characters() {
480        let content = r#"Reference[^my-note_1].
481
482[^my-note_1]: Definition with special chars in ID."#;
483        let warnings = check_md066(content);
484        assert!(
485            warnings.is_empty(),
486            "Special characters in footnote ID should work: {warnings:?}"
487        );
488    }
489
490    #[test]
491    fn test_multiline_footnote_definition() {
492        let content = r#"Reference[^long].
493
494[^long]: This is a long footnote
495    that spans multiple lines
496    with proper indentation."#;
497        let warnings = check_md066(content);
498        assert!(
499            warnings.is_empty(),
500            "Multiline footnote definitions should work: {warnings:?}"
501        );
502    }
503
504    #[test]
505    fn test_footnote_at_end_of_sentence() {
506        let content = r#"This ends with a footnote[^1].
507
508[^1]: End of sentence footnote."#;
509        let warnings = check_md066(content);
510        assert!(warnings.is_empty());
511    }
512
513    #[test]
514    fn test_footnote_mid_sentence() {
515        let content = r#"Some text[^1] continues here.
516
517[^1]: Mid-sentence footnote."#;
518        let warnings = check_md066(content);
519        assert!(warnings.is_empty());
520    }
521
522    #[test]
523    fn test_adjacent_footnotes() {
524        let content = r#"Text[^1][^2] with adjacent footnotes.
525
526[^1]: First.
527[^2]: Second."#;
528        let warnings = check_md066(content);
529        assert!(warnings.is_empty(), "Adjacent footnotes should work: {warnings:?}");
530    }
531
532    #[test]
533    fn test_footnote_only_definitions_no_references() {
534        let content = r#"[^1]: First orphan.
535[^2]: Second orphan.
536[^3]: Third orphan."#;
537        let warnings = check_md066(content);
538        assert_eq!(warnings.len(), 3, "All definitions should be flagged: {warnings:?}");
539    }
540
541    #[test]
542    fn test_footnote_only_references_no_definitions() {
543        let content = "Text[^1] and[^2] and[^3].";
544        let warnings = check_md066(content);
545        assert_eq!(warnings.len(), 3, "All references should be flagged: {warnings:?}");
546    }
547
548    // ==================== Blockquote handling ====================
549
550    #[test]
551    fn test_footnote_in_blockquote_valid() {
552        let content = r#"> This has a footnote[^1].
553>
554> [^1]: Definition inside blockquote."#;
555        let warnings = check_md066(content);
556        assert!(
557            warnings.is_empty(),
558            "Footnotes inside blockquotes should be validated: {warnings:?}"
559        );
560    }
561
562    #[test]
563    fn test_footnote_in_nested_blockquote() {
564        let content = r#"> > Nested blockquote with footnote[^nested].
565> >
566> > [^nested]: Definition in nested blockquote."#;
567        let warnings = check_md066(content);
568        assert!(
569            warnings.is_empty(),
570            "Footnotes in nested blockquotes should work: {warnings:?}"
571        );
572    }
573
574    #[test]
575    fn test_footnote_blockquote_orphaned_reference() {
576        let content = r#"> This has an orphaned footnote[^missing].
577>
578> No definition here."#;
579        let warnings = check_md066(content);
580        assert_eq!(warnings.len(), 1, "Should detect orphaned ref in blockquote");
581        assert!(warnings[0].message.contains("missing"));
582    }
583
584    #[test]
585    fn test_footnote_blockquote_orphaned_definition() {
586        let content = r#"> Some text.
587>
588> [^unused]: Never referenced in blockquote."#;
589        let warnings = check_md066(content);
590        assert_eq!(warnings.len(), 1, "Should detect orphaned def in blockquote");
591        assert!(warnings[0].message.contains("unused"));
592    }
593
594    // ==================== Duplicate definitions ====================
595
596    #[test]
597    fn test_duplicate_definition_detected() {
598        let content = r#"Reference[^1].
599
600[^1]: First definition.
601[^1]: Second definition (duplicate)."#;
602        let warnings = check_md066(content);
603        assert_eq!(warnings.len(), 1, "Should detect duplicate definition: {warnings:?}");
604        assert!(warnings[0].message.contains("Duplicate"));
605        assert!(warnings[0].message.contains("[^1]"));
606    }
607
608    #[test]
609    fn test_multiple_duplicate_definitions() {
610        let content = r#"Reference[^dup].
611
612[^dup]: First.
613[^dup]: Second.
614[^dup]: Third."#;
615        let warnings = check_md066(content);
616        assert_eq!(warnings.len(), 2, "Should detect 2 duplicate definitions: {warnings:?}");
617        assert!(warnings.iter().all(|w| w.message.contains("Duplicate")));
618    }
619
620    #[test]
621    fn test_duplicate_definition_case_insensitive() {
622        let content = r#"Reference[^Note].
623
624[^note]: Lowercase definition.
625[^NOTE]: Uppercase definition (duplicate)."#;
626        let warnings = check_md066(content);
627        assert_eq!(warnings.len(), 1, "Case-insensitive duplicate detection: {warnings:?}");
628        assert!(warnings[0].message.contains("Duplicate"));
629    }
630
631    // ==================== HTML comment handling ====================
632
633    #[test]
634    fn test_footnote_reference_in_html_comment_ignored() {
635        let content = r#"<!-- This has [^1] in a comment -->
636
637Regular text without footnotes."#;
638        let warnings = check_md066(content);
639        assert!(
640            warnings.is_empty(),
641            "Footnote refs in HTML comments should be ignored: {warnings:?}"
642        );
643    }
644
645    #[test]
646    fn test_footnote_definition_in_html_comment_ignored() {
647        let content = r#"<!--
648[^1]: Definition in HTML comment
649-->
650
651Regular text."#;
652        let warnings = check_md066(content);
653        assert!(
654            warnings.is_empty(),
655            "Footnote defs in HTML comments should be ignored: {warnings:?}"
656        );
657    }
658
659    #[test]
660    fn test_footnote_outside_html_comment_still_validated() {
661        let content = r#"<!-- Just a comment -->
662
663Text with footnote[^1].
664
665[^1]: Valid definition outside comment."#;
666        let warnings = check_md066(content);
667        assert!(warnings.is_empty(), "Valid footnote outside comment: {warnings:?}");
668    }
669
670    #[test]
671    fn test_orphaned_ref_not_saved_by_def_in_comment() {
672        let content = r#"Text with orphaned[^missing].
673
674<!--
675[^missing]: This definition is in a comment, shouldn't count
676-->"#;
677        let warnings = check_md066(content);
678        assert_eq!(warnings.len(), 1, "Def in comment shouldn't satisfy ref: {warnings:?}");
679        assert!(warnings[0].message.contains("no corresponding definition"));
680    }
681
682    // ==================== HTML block handling ====================
683
684    #[test]
685    fn test_footnote_in_html_block_ignored() {
686        // Regex character classes like [^abc] should be ignored in HTML blocks
687        let content = r#"<table>
688<tr>
689<td><code>[^abc]</code></td>
690<td>Negated character class</td>
691</tr>
692</table>
693
694Regular markdown text."#;
695        let warnings = check_md066(content);
696        assert!(
697            warnings.is_empty(),
698            "Footnote-like patterns in HTML blocks should be ignored: {warnings:?}"
699        );
700    }
701
702    #[test]
703    fn test_footnote_in_html_table_ignored() {
704        let content = r#"| Header |
705|--------|
706| Cell   |
707
708<div>
709<p>This has <code>[^0-9]</code> regex pattern</p>
710</div>
711
712Normal text."#;
713        let warnings = check_md066(content);
714        assert!(
715            warnings.is_empty(),
716            "Regex patterns in HTML div should be ignored: {warnings:?}"
717        );
718    }
719
720    #[test]
721    fn test_real_footnote_outside_html_block() {
722        let content = r#"<div>
723Some HTML content
724</div>
725
726Text with real footnote[^1].
727
728[^1]: This is a real footnote definition."#;
729        let warnings = check_md066(content);
730        assert!(
731            warnings.is_empty(),
732            "Real footnote outside HTML block should work: {warnings:?}"
733        );
734    }
735
736    // ==================== Combined edge cases ====================
737
738    #[test]
739    fn test_blockquote_with_duplicate_definitions() {
740        let content = r#"> Text[^1].
741>
742> [^1]: First.
743> [^1]: Duplicate in blockquote."#;
744        let warnings = check_md066(content);
745        assert_eq!(warnings.len(), 1, "Should detect duplicate in blockquote: {warnings:?}");
746        assert!(warnings[0].message.contains("Duplicate"));
747    }
748
749    #[test]
750    fn test_all_enhancement_features_together() {
751        let content = r#"<!-- Comment with [^comment] -->
752
753Regular text[^valid] and[^missing].
754
755> Blockquote text[^bq].
756>
757> [^bq]: Blockquote definition.
758
759[^valid]: Valid definition.
760[^valid]: Duplicate definition.
761[^unused]: Never referenced."#;
762        let warnings = check_md066(content);
763        // Should find:
764        // 1. [^missing] - orphaned reference
765        // 2. [^valid] duplicate definition
766        // 3. [^unused] - orphaned definition
767        assert_eq!(warnings.len(), 3, "Should find all issues: {warnings:?}");
768
769        let messages: Vec<&str> = warnings.iter().map(|w| w.message.as_str()).collect();
770        assert!(
771            messages.iter().any(|m| m.contains("missing")),
772            "Should find orphaned ref"
773        );
774        assert!(
775            messages.iter().any(|m| m.contains("Duplicate")),
776            "Should find duplicate"
777        );
778        assert!(
779            messages.iter().any(|m| m.contains("unused")),
780            "Should find orphaned def"
781        );
782    }
783}