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
7pub static FOOTNOTE_DEF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[ ]{0,3}\[\^([^\]]+)\]:").unwrap());
11
12pub static FOOTNOTE_REF_PATTERN: LazyLock<FancyRegex> =
16 LazyLock::new(|| FancyRegex::new(r"\[\^([^\]]+)\](?!:)").unwrap());
17
18pub 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 if chars.peek() == Some(&' ') {
32 chars.next();
33 pos += 1;
34 }
35 last_content_start = pos;
36 }
37 ' ' => {
38 chars.next();
40 pos += 1;
41 }
42 _ => break,
43 }
44 }
45
46 &line[last_content_start..]
47}
48
49#[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 if ctx.footnote_refs.is_empty() && !ctx.content.contains("[^") {
100 return Ok(warnings);
101 }
102
103 let mut references: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
108
109 for footnote_ref in &ctx.footnote_refs {
111 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 let code_spans = ctx.code_spans();
125 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
126 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; 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 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 references.entry(id).or_default().push((line_num, byte_offset));
153 }
154 }
155 }
156 }
157
158 for occurrences in references.values_mut() {
160 occurrences.sort();
161 occurrences.dedup();
162 }
163
164 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
168 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
169 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 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; definitions
188 .entry(id)
189 .or_default()
190 .push((line_num, line_info.byte_offset));
191 }
192 }
193
194 for (def_id, occurrences) in &definitions {
196 if occurrences.len() > 1 {
197 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 let defined_ids: HashSet<&String> = definitions.keys().collect();
218 for (ref_id, occurrences) in &references {
219 if !defined_ids.contains(ref_id) {
220 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 let referenced_ids: HashSet<&String> = references.keys().collect();
237 for (def_id, occurrences) in &definitions {
238 if !referenced_ids.contains(def_id) {
239 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 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 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 #[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 #[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 assert_eq!(warnings.len(), 1, "Should report each orphaned ID once");
373 assert!(warnings[0].message.contains("missing"));
374 }
375
376 #[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 #[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 #[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 let content = r#"Use `[^1]` syntax for footnotes.
440
441[^1]: This definition exists but the reference in backticks shouldn't count."#;
442 let warnings = check_md066(content);
445 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 #[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 #[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 #[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 #[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 #[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 #[test]
685 fn test_footnote_in_html_block_ignored() {
686 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 #[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 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}