1use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
26use crate::rules::md066_footnote_validation::{FOOTNOTE_DEF_PATTERN, footnote_def_position, strip_blockquote_prefix};
27use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
28use pulldown_cmark::{Event, Parser, Tag};
29use std::collections::{HashMap, HashSet};
30
31#[derive(Debug, Default, Clone)]
32pub struct MD067FootnoteDefinitionOrder;
33
34impl MD067FootnoteDefinitionOrder {
35 pub fn new() -> Self {
36 Self
37 }
38}
39
40impl Rule for MD067FootnoteDefinitionOrder {
41 fn name(&self) -> &'static str {
42 "MD067"
43 }
44
45 fn description(&self) -> &'static str {
46 "Footnote definitions should appear in order of first reference"
47 }
48
49 fn category(&self) -> RuleCategory {
50 RuleCategory::Other
51 }
52
53 fn fix_capability(&self) -> FixCapability {
54 FixCapability::ConditionallyFixable
55 }
56
57 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
58 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(67))
59 }
60
61 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
62 ctx.content.is_empty() || !ctx.content.contains("[^")
63 }
64
65 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
66 let mut warnings = Vec::new();
67
68 let mut reference_order: Vec<String> = Vec::new();
70 let mut seen_refs: HashMap<String, usize> = HashMap::new();
71
72 let mut definition_order: Vec<(String, usize, usize)> = Vec::new(); for reference in ctx.footnote_references() {
78 let line = &ctx.lines[reference.line - 1];
81 if line.in_code_block
82 || line.in_front_matter
83 || line.in_html_comment
84 || line.in_mdx_comment
85 || line.in_html_block
86 {
87 continue;
88 }
89 let id = reference.id.to_lowercase();
90 if !seen_refs.contains_key(&id) {
91 seen_refs.insert(id.clone(), reference_order.len());
92 reference_order.push(id);
93 }
94 }
95
96 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
98 if line_info.in_code_block
100 || line_info.in_front_matter
101 || line_info.in_html_comment
102 || line_info.in_mdx_comment
103 || line_info.in_html_block
104 {
105 continue;
106 }
107
108 let line = line_info.content(ctx.content);
109 let line_stripped = strip_blockquote_prefix(line);
111
112 if let Some(caps) = FOOTNOTE_DEF_PATTERN.captures(line_stripped)
113 && let Some(id_match) = caps.get(1)
114 {
115 let id = id_match.as_str().to_lowercase();
116 let line_num = line_idx + 1;
117 definition_order.push((id, line_num, line_info.byte_offset));
118 }
119 }
120
121 let mut expected_idx = 0;
123 for (def_id, def_line, _byte_offset) in &definition_order {
124 if let Some(&ref_idx) = seen_refs.get(def_id) {
126 if ref_idx != expected_idx {
127 if expected_idx < reference_order.len() {
129 let expected_id = &reference_order[expected_idx];
130 let (col, end_col) = ctx
131 .lines
132 .get(*def_line - 1)
133 .map_or((1, 1), |li| footnote_def_position(li.content(ctx.content)));
134 warnings.push(LintWarning {
135 rule_name: Some(self.name().to_string()),
136 line: *def_line,
137 column: col,
138 end_line: *def_line,
139 end_column: end_col,
140 message: format!(
141 "Footnote definition '[^{def_id}]' is out of order; expected '[^{expected_id}]' next (based on reference order)"
142 ),
143 severity: Severity::Warning,
144 fix: None,
145 });
146 }
147 }
148 expected_idx = ref_idx + 1;
149 }
150 }
152
153 warnings.truncate(1);
156 if let Some(warning) = warnings.first_mut() {
157 let def_start = definition_order
158 .iter()
159 .rev()
160 .find(|(id, _, _)| seen_refs.contains_key(id))
161 .unwrap()
162 .1
163 - 1;
164 let mut last = def_start;
165 while let Some(line) = ctx.lines.get(last + 1) {
166 if !line.in_footnote_definition
167 || FOOTNOTE_DEF_PATTERN.is_match(strip_blockquote_prefix(line.content(ctx.content)))
168 {
169 break;
170 }
171 last += 1;
172 }
173 while last > def_start && ctx.lines[last].is_blank {
176 last -= 1;
177 }
178 warning.end_line = last + 1;
179 warning.end_column = ctx.lines[warning.end_line - 1].content(ctx.content).chars().count() + 1;
180 warning.fix = reordering_fix(ctx, &definition_order, &seen_refs);
181 }
182 Ok(warnings)
183 }
184
185 fn fixes_from_diagnostics(&self) -> bool {
186 true
187 }
188
189 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
190 let warnings = filter_warnings_by_inline_config(self.check(ctx)?, ctx.inline_config(), self.name());
191 apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
192 }
193
194 fn as_any(&self) -> &dyn std::any::Any {
195 self
196 }
197
198 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
199 where
200 Self: Sized,
201 {
202 Box::new(MD067FootnoteDefinitionOrder)
203 }
204}
205
206fn reordering_fix(
209 ctx: &crate::lint_context::LintContext,
210 definitions: &[(String, usize, usize)],
211 reference_order: &HashMap<String, usize>,
212) -> Option<Fix> {
213 let mut unique = HashSet::new();
214 if definitions.iter().any(|(id, _, _)| !unique.insert(id)) {
215 return None;
216 }
217
218 let mut parents = Vec::new();
219 let mut parsed = HashMap::new();
220 let options = crate::utils::parser_options::rumdl_parser_options_for(ctx.content);
221 for (event, range) in Parser::new_ext(ctx.content, options).into_offset_iter() {
222 match event {
223 Event::Start(tag) => {
224 if let Tag::FootnoteDefinition(id) = &tag {
225 let first = ctx.lines.partition_point(|line| line.byte_offset <= range.start) - 1;
226 let mut last = ctx.lines.partition_point(|line| line.byte_offset < range.end) - 1;
227 while last > first
230 && strip_blockquote_prefix(ctx.lines[last].content(ctx.content))
231 .trim_matches([' ', '\t', '\r'])
232 .is_empty()
233 {
234 last -= 1;
235 }
236 let span = ctx.lines[first].byte_offset
237 ..ctx.lines[last].byte_offset + ctx.lines[last].content(ctx.content).len();
238 parsed.insert((id.to_lowercase(), first + 1), (span, parents.clone()));
239 }
240 parents.push(range.start);
241 }
242 Event::End(_) => {
243 parents.pop();
244 }
245 _ => {}
246 }
247 }
248
249 let mut slots = Vec::new();
250 let mut container = None;
251 for (id, line, _) in definitions {
252 let Some(&rank) = reference_order.get(id) else { continue };
253 let (range, parents) = parsed.get(&(id.clone(), *line))?;
254 let last_line = ctx.lines.partition_point(|line| line.byte_offset < range.end);
255 if let Some(following) = ctx.lines.get(last_line) {
256 let text = strip_blockquote_prefix(following.content(ctx.content));
257 if !text.trim_matches([' ', '\t', '\r']).is_empty() && !FOOTNOTE_DEF_PATTERN.is_match(text) {
258 return None;
261 }
262 }
263 let first_line = ctx.lines[*line - 1].content(ctx.content);
264 let prefix = &first_line[..first_line.find("[^")?];
265 let scope = (parents, prefix);
266 if container.is_some_and(|existing| existing != scope) {
267 return None;
270 }
271 container = Some(scope);
272 let next = ctx
273 .footnote_references()
274 .partition_point(|reference| reference.byte_offset < range.start);
275 if ctx
276 .footnote_references()
277 .get(next)
278 .is_some_and(|reference| reference.byte_offset < range.end)
279 {
280 return None;
282 }
283 slots.push((range.clone(), rank));
284 }
285 let first_start = slots.first()?.0.start;
286 let first_line = ctx.lines.partition_point(|line| line.byte_offset < first_start);
287 let span = ctx.lines[first_line.saturating_sub(1)].byte_offset..slots.last()?.0.end;
290 if ctx
291 .html_comment_ranges()
292 .iter()
293 .any(|comment| comment.start < span.end && comment.end > span.start)
294 || ctx
295 .mdx_comment_ranges()
296 .iter()
297 .any(|&(start, end)| start < span.end && end > span.start)
298 || ctx.overlaps_mdx_code(span.start, span.end)
299 {
300 return None;
303 }
304 let mut sorted: Vec<_> = slots.iter().collect();
305 sorted.sort_unstable_by_key(|(_, rank)| *rank);
306 let changed: Vec<_> = slots
307 .iter()
308 .zip(sorted)
309 .filter(|((destination, _), (source, _))| destination != source)
310 .collect();
311 let start = changed.first()?.0.0.start;
312 let end = changed.last()?.0.0.end;
313 let mut replacement = String::with_capacity(end - start);
314 let mut cursor = start;
315 for ((destination, _), (source, _)) in changed {
316 replacement.push_str(&ctx.content[cursor..destination.start]);
317 replacement.push_str(&ctx.content[source.clone()]);
318 cursor = destination.end;
319 }
320 Some(Fix::new(start..end, replacement))
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::LintContext;
329
330 fn check(content: &str) -> Vec<LintWarning> {
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 MD067FootnoteDefinitionOrder::new().check(&ctx).unwrap()
333 }
334
335 #[test]
336 fn test_fix_reorders_complete_footnotes_atomically() {
337 let input = "Hello [^1].\nWorld [^2].\n\n[^2]: Second footnote.\n\n Another paragraph.\n\n ```rust\n code();\n ```\n\n[^1]: First footnote.\n";
338 let expected = "Hello [^1].\nWorld [^2].\n\n[^1]: First footnote.\n\n[^2]: Second footnote.\n\n Another paragraph.\n\n ```rust\n code();\n ```\n";
339 let rule = MD067FootnoteDefinitionOrder::new();
340 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
341 let warnings = rule.check(&ctx).unwrap();
342 assert_eq!(rule.fix(&ctx).unwrap(), expected);
343 assert_eq!(
344 crate::utils::fix_utils::apply_warning_fixes(input, &warnings).unwrap(),
345 expected
346 );
347 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
348 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
349 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
350 }
351
352 #[test]
353 fn test_correct_order() {
354 let content = r#"Text with [^1] and [^2].
355
356[^1]: First definition
357[^2]: Second definition
358"#;
359 let warnings = check(content);
360 assert!(warnings.is_empty(), "Expected no warnings for correct order");
361 }
362
363 #[test]
364 fn test_incorrect_order() {
365 let content = r#"Text with [^1] and [^2].
366
367[^2]: Second definition
368[^1]: First definition
369"#;
370 let warnings = check(content);
371 assert_eq!(warnings.len(), 1);
372 assert!(warnings[0].message.contains("out of order"));
373 assert!(warnings[0].message.contains("[^2]"));
374 }
375
376 #[test]
377 fn test_named_footnotes_order() {
378 let content = r#"Text with [^alpha] and [^beta].
379
380[^beta]: Beta definition
381[^alpha]: Alpha definition
382"#;
383 let warnings = check(content);
384 assert_eq!(warnings.len(), 1);
385 assert!(warnings[0].message.contains("[^beta]"));
386 }
387
388 #[test]
389 fn test_multiple_refs_same_footnote() {
390 let content = r#"Text with [^1] and [^2] and [^1] again.
391
392[^1]: First footnote
393[^2]: Second footnote
394"#;
395 let warnings = check(content);
396 assert!(
397 warnings.is_empty(),
398 "Multiple refs to same footnote should use first occurrence"
399 );
400 }
401
402 #[test]
403 fn test_skip_code_blocks() {
404 let content = r#"Text with [^1].
405
406```
407[^2]: In code block
408```
409
410[^1]: Real definition
411"#;
412 let warnings = check(content);
413 assert!(warnings.is_empty());
414 }
415
416 #[test]
417 fn test_skip_code_spans() {
418 let content = r#"Text with `[^2]` in code and [^1].
419
420[^1]: Only real reference
421"#;
422 let warnings = check(content);
423 assert!(warnings.is_empty());
424 }
425
426 #[test]
427 fn test_case_insensitive() {
428 let content = r#"Text with [^Note] and [^OTHER].
429
430[^note]: First (case-insensitive match)
431[^other]: Second
432"#;
433 let warnings = check(content);
434 assert!(warnings.is_empty());
435 }
436
437 #[test]
438 fn test_definitions_without_references() {
439 let content = r#"Text with [^1].
441
442[^1]: Referenced
443[^2]: Orphaned
444"#;
445 let warnings = check(content);
446 assert!(warnings.is_empty(), "Orphaned definitions handled by MD066");
447 }
448
449 #[test]
450 fn test_three_footnotes_wrong_order() {
451 let content = r#"Ref [^a], then [^b], then [^c].
452
453[^c]: Third ref, first def
454[^a]: First ref, second def
455[^b]: Second ref, third def
456"#;
457 let warnings = check(content);
458 assert!(!warnings.is_empty());
459 }
460
461 #[test]
462 fn test_blockquote_definitions() {
463 let content = r#"Text with [^1] and [^2].
464
465> [^1]: First in blockquote
466> [^2]: Second in blockquote
467"#;
468 let warnings = check(content);
469 assert!(warnings.is_empty());
470 }
471
472 #[test]
473 fn test_midline_footnote_ref_with_colon_counted_for_ordering() {
474 let content = "# Test\n\nSecond ref [^b] here.\n\nFirst ref [^a]: and text.\n\n[^a]: First definition.\n[^b]: Second definition.\n";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let rule = MD067FootnoteDefinitionOrder;
478 let result = rule.check(&ctx).unwrap();
479 assert!(!result.is_empty(), "Should detect ordering mismatch: {result:?}");
481 }
482
483 #[test]
484 fn test_linestart_footnote_def_not_counted_as_reference_for_ordering() {
485 let content = "# Test\n\n[^a] first ref.\n[^b] second ref.\n\n[^a]: First.\n[^b]: Second.\n";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let rule = MD067FootnoteDefinitionOrder;
489 let result = rule.check(&ctx).unwrap();
490 assert!(result.is_empty(), "Correct order should pass: {result:?}");
491 }
492
493 #[test]
496 fn test_out_of_order_column_position() {
497 let content = "Text with [^1] and [^2].\n\n[^2]: Second definition\n[^1]: First definition\n";
498 let warnings = check(content);
499 assert_eq!(warnings.len(), 1);
500 assert_eq!(warnings[0].line, 3);
501 assert_eq!(warnings[0].column, 1, "Definition at start of line");
502 assert_eq!(warnings[0].end_line, 4);
505 assert_eq!(warnings[0].end_column, 23);
506 }
507
508 #[test]
511 fn test_out_of_order_range_excludes_trailing_blank_lines() {
512 let content = "Text with [^1] and [^2].\n\n[^2]: Second definition\n[^1]: First definition\n\n## Next\n";
513 let warnings = check(content);
514 assert_eq!(warnings.len(), 1);
515 assert_eq!((warnings[0].line, warnings[0].end_line), (3, 4));
516 assert_eq!(warnings[0].end_column, 23);
517 }
518
519 #[test]
522 fn test_out_of_order_range_keeps_continuation_paragraphs() {
523 let content = "Text with [^1] and [^2].\n\n[^2]: Second definition\n[^1]: First definition\n\n More about one.\n\n## Next\n";
524 let warnings = check(content);
525 assert_eq!(warnings.len(), 1);
526 assert_eq!((warnings[0].line, warnings[0].end_line), (3, 6));
527 assert_eq!(warnings[0].end_column, 20);
528 }
529
530 #[test]
531 fn test_out_of_order_blockquote_column_position() {
532 let content = "Text with [^1] and [^2].\n\n> [^2]: Second in blockquote\n> [^1]: First in blockquote\n";
533 let warnings = check(content);
534 assert_eq!(warnings.len(), 1);
535 assert_eq!(warnings[0].line, 3);
536 assert_eq!(warnings[0].column, 3, "Should point past blockquote prefix");
538 }
539
540 #[test]
541 fn test_fix_preserves_source_slots_and_reference_semantics() {
542 for (input, expected) in [
543 (
544 "Refs[^a][^b][^c].\n\n[^c]: C\n[^a]: A\n[^b]: B\n",
545 "Refs[^a][^b][^c].\n\n[^a]: A\n[^b]: B\n[^c]: C\n",
546 ),
547 (
548 "Refs[^a][^b].\n\n[^b]: B\n\nKeep this paragraph.\n\n[^unused]: U\n\n[^a]: A\n",
549 "Refs[^a][^b].\n\n[^a]: A\n\nKeep this paragraph.\n\n[^unused]: U\n\n[^b]: B\n",
550 ),
551 (
552 "Refs[^é][^β].\r\n\r\n[^β]: 第二\r\n[^é]: café",
553 "Refs[^é][^β].\r\n\r\n[^é]: café\r\n[^β]: 第二",
554 ),
555 (
556 "Refs[^a][^b].\n\n> [^b]: B\n>\n> Continued B.\n>\n> [^a]: A\n",
557 "Refs[^a][^b].\n\n> [^a]: A\n>\n> [^b]: B\n>\n> Continued B.\n",
558 ),
559 (
560 "Refs[^a][^b].\n\n [^b]: B\n [^a]: A\n",
561 "Refs[^a][^b].\n\n [^a]: A\n [^b]: B\n",
562 ),
563 (
564 "Escaped \\[^b], missing[^missing], then real[^a][^b].\n\n[^b]: B\n[^a]: A\n",
565 "Escaped \\[^b], missing[^missing], then real[^a][^b].\n\n[^a]: A\n[^b]: B\n",
566 ),
567 ] {
568 let rule = MD067FootnoteDefinitionOrder::new();
569 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
570 let fixed = rule.fix(&ctx).unwrap();
571 assert_eq!(fixed, expected, "{input:?}");
572 assert_eq!(
573 crate::utils::fix_utils::apply_warning_fixes(input, &rule.check(&ctx).unwrap()).unwrap(),
574 expected
575 );
576 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
577 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed:?}");
578 assert_eq!(rule.fix(&ctx).unwrap(), expected);
579 }
580 }
581
582 #[test]
583 fn test_fix_leaves_ambiguous_and_disabled_definitions_untouched() {
584 for input in [
585 "Refs[^a][^b].\n\n- item\n[^b]: B\n [^a]: A\n",
586 "Refs[^a][^b].\n\n[^b]: B\n\n ```\n code\n ```\nParagraph after b.\n\n[^a]: A\n",
587 "Refs[^a][^b].\n\n<!-- rumdl-disable-next-line MD013 -->\n[^b]: Long definition.\n[^a]: A\n",
588 "Refs[^a][^b].\n\n[^b]: B\n[^a]: A\n[^a]: Duplicate A\n",
589 "Refs[^a][^b].\n\n> [^b]: B\n\n> [^a]: A\n",
590 "Refs[^a][^b].\n\n[^b]: B\n<!-- keep -->\n[^a]: A\n",
591 "Refs[^a][^b].\n\n[^b]: B refers to [^a].\n[^a]: A\n",
592 "<!-- rumdl-disable MD067 -->\nRefs[^a][^b].\n\n[^b]: B\n[^a]: A\n",
593 "Refs[^a][^b].\n\n[^b]: B\n<!-- rumdl-disable-next-line MD067 -->\n[^a]: A\n",
594 ] {
595 let rule = MD067FootnoteDefinitionOrder::new();
596 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
597 assert!(
598 !rule.check(&ctx).unwrap().is_empty(),
599 "retain suppression evidence: {input:?}"
600 );
601 assert_eq!(rule.fix(&ctx).unwrap(), input);
602 }
603 }
604
605 #[test]
606 fn test_disable_on_later_definition_is_accounted_for() {
607 let input = "Refs[^a][^b].\n\n[^b]: B\n<!-- rumdl-disable-next-line MD067 -->\n[^a]: A\n";
608 let rules: Vec<Box<dyn Rule>> = vec![
609 Box::new(MD067FootnoteDefinitionOrder::new()),
610 Box::new(crate::rules::MD087UnusedDisableComment),
611 ];
612 let warnings = crate::lint(
613 input,
614 &rules,
615 false,
616 crate::config::MarkdownFlavor::Standard,
617 None,
618 None,
619 )
620 .unwrap();
621 assert!(
622 warnings.is_empty(),
623 "the ordering operation spans both definitions: {warnings:?}"
624 );
625 }
626
627 #[test]
628 fn test_fix_preserves_nonbreaking_space_inside_a_definition() {
629 let input = "Refs[^a][^b].\n\n[^b]: B\n\n \u{a0}\n\n[^a]: A\n";
630 let expected = "Refs[^a][^b].\n\n[^a]: A\n\n[^b]: B\n\n \u{a0}\n";
631 let rule = MD067FootnoteDefinitionOrder;
632 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
633 assert_eq!(rule.fix(&ctx).unwrap(), expected);
634 }
635
636 #[test]
637 fn test_real_reference_before_html_comment_controls_order() {
638 let input = "Text [^b] <!--\nnote\n-->\nMore [^a] and [^b].\n\n[^b]: B\n[^a]: A\n";
639 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
640 let rule = MD067FootnoteDefinitionOrder;
641 assert!(rule.check(&ctx).unwrap().is_empty());
642 assert_eq!(rule.fix(&ctx).unwrap(), input);
643 }
644
645 #[test]
646 fn test_fix_preserves_mdx_flow_syntax_next_to_definitions() {
647 for syntax in ["{expr}", "<Widget />", "\nexport const value = 1;"] {
650 let input = format!("Refs[^a][^b].\n\n[^b]: B\n{syntax}\n\n[^a]: A\n");
651 let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::MDX, None);
652 let rule = MD067FootnoteDefinitionOrder;
653 let warnings = rule.check(&ctx).unwrap();
654 assert!(!warnings.is_empty(), "{syntax}");
655 assert_eq!(rule.fix(&ctx).unwrap(), input, "{syntax}");
656 assert!(warnings.iter().all(|warning| warning.fix.is_none()), "{syntax}");
657 }
658 }
659
660 #[test]
661 fn test_fix_preserves_mdx_directive_targets() {
662 let input = "Refs[^a][^b].\n\n{/* rumdl-disable-next-line MD013 */}\n[^b]: Long definition.\n[^a]: A\n";
663 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::MDX, None);
664 let rule = MD067FootnoteDefinitionOrder;
665 assert!(!rule.check(&ctx).unwrap().is_empty());
666 assert_eq!(rule.fix(&ctx).unwrap(), input);
667 }
668}