rumdl_lib/rules/
md067_footnote_definition_order.rs1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
26use crate::rules::md066_footnote_validation::{
27 FOOTNOTE_DEF_PATTERN, FOOTNOTE_REF_PATTERN, footnote_def_position, strip_blockquote_prefix,
28};
29use std::collections::HashMap;
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::Unfixable
55 }
56
57 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
58 ctx.content.is_empty() || !ctx.content.contains("[^")
59 }
60
61 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
62 let mut warnings = Vec::new();
63
64 let mut reference_order: Vec<String> = Vec::new();
66 let mut seen_refs: HashMap<String, usize> = HashMap::new();
67
68 let mut definition_order: Vec<(String, usize, usize)> = Vec::new(); for line_info in &ctx.lines {
73 if line_info.in_code_block
75 || line_info.in_front_matter
76 || line_info.in_html_comment
77 || line_info.in_mdx_comment
78 || line_info.in_html_block
79 {
80 continue;
81 }
82
83 let line = line_info.content(ctx.content);
84
85 for caps in FOOTNOTE_REF_PATTERN.captures_iter(line) {
86 if let Some(id_match) = caps.get(1) {
87 let full_match = caps.get(0).unwrap();
90 if line.as_bytes().get(full_match.end()) == Some(&b':') {
91 let before_match = &line[..full_match.start()];
92 if before_match.chars().all(|c| c == ' ' || c == '>') {
93 continue;
94 }
95 }
96
97 let id = id_match.as_str().to_lowercase();
98
99 let match_start = full_match.start();
101 let byte_offset = line_info.byte_offset + match_start;
102
103 let in_code_span = ctx.is_in_code_span_byte(byte_offset);
104
105 if !in_code_span && !seen_refs.contains_key(&id) {
106 seen_refs.insert(id.clone(), reference_order.len());
107 reference_order.push(id);
108 }
109 }
110 }
111 }
112
113 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
115 if line_info.in_code_block
117 || line_info.in_front_matter
118 || line_info.in_html_comment
119 || line_info.in_mdx_comment
120 || line_info.in_html_block
121 {
122 continue;
123 }
124
125 let line = line_info.content(ctx.content);
126 let line_stripped = strip_blockquote_prefix(line);
128
129 if let Some(caps) = FOOTNOTE_DEF_PATTERN.captures(line_stripped)
130 && let Some(id_match) = caps.get(1)
131 {
132 let id = id_match.as_str().to_lowercase();
133 let line_num = line_idx + 1;
134 definition_order.push((id, line_num, line_info.byte_offset));
135 }
136 }
137
138 let mut expected_idx = 0;
140 for (def_id, def_line, _byte_offset) in &definition_order {
141 if let Some(&ref_idx) = seen_refs.get(def_id) {
143 if ref_idx != expected_idx {
144 if expected_idx < reference_order.len() {
146 let expected_id = &reference_order[expected_idx];
147 let (col, end_col) = ctx
148 .lines
149 .get(*def_line - 1)
150 .map(|li| footnote_def_position(li.content(ctx.content)))
151 .unwrap_or((1, 1));
152 warnings.push(LintWarning {
153 rule_name: Some(self.name().to_string()),
154 line: *def_line,
155 column: col,
156 end_line: *def_line,
157 end_column: end_col,
158 message: format!(
159 "Footnote definition '[^{def_id}]' is out of order; expected '[^{expected_id}]' next (based on reference order)"
160 ),
161 severity: Severity::Warning,
162 fix: None,
163 });
164 }
165 }
166 expected_idx = ref_idx + 1;
167 }
168 }
170
171 Ok(warnings)
172 }
173
174 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
175 Ok(ctx.content.to_string())
178 }
179
180 fn as_any(&self) -> &dyn std::any::Any {
181 self
182 }
183
184 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
185 where
186 Self: Sized,
187 {
188 Box::new(MD067FootnoteDefinitionOrder)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::LintContext;
196
197 fn check(content: &str) -> Vec<LintWarning> {
198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
199 MD067FootnoteDefinitionOrder::new().check(&ctx).unwrap()
200 }
201
202 #[test]
203 fn test_correct_order() {
204 let content = r#"Text with [^1] and [^2].
205
206[^1]: First definition
207[^2]: Second definition
208"#;
209 let warnings = check(content);
210 assert!(warnings.is_empty(), "Expected no warnings for correct order");
211 }
212
213 #[test]
214 fn test_incorrect_order() {
215 let content = r#"Text with [^1] and [^2].
216
217[^2]: Second definition
218[^1]: First definition
219"#;
220 let warnings = check(content);
221 assert_eq!(warnings.len(), 1);
222 assert!(warnings[0].message.contains("out of order"));
223 assert!(warnings[0].message.contains("[^2]"));
224 }
225
226 #[test]
227 fn test_named_footnotes_order() {
228 let content = r#"Text with [^alpha] and [^beta].
229
230[^beta]: Beta definition
231[^alpha]: Alpha definition
232"#;
233 let warnings = check(content);
234 assert_eq!(warnings.len(), 1);
235 assert!(warnings[0].message.contains("[^beta]"));
236 }
237
238 #[test]
239 fn test_multiple_refs_same_footnote() {
240 let content = r#"Text with [^1] and [^2] and [^1] again.
241
242[^1]: First footnote
243[^2]: Second footnote
244"#;
245 let warnings = check(content);
246 assert!(
247 warnings.is_empty(),
248 "Multiple refs to same footnote should use first occurrence"
249 );
250 }
251
252 #[test]
253 fn test_skip_code_blocks() {
254 let content = r#"Text with [^1].
255
256```
257[^2]: In code block
258```
259
260[^1]: Real definition
261"#;
262 let warnings = check(content);
263 assert!(warnings.is_empty());
264 }
265
266 #[test]
267 fn test_skip_code_spans() {
268 let content = r#"Text with `[^2]` in code and [^1].
269
270[^1]: Only real reference
271"#;
272 let warnings = check(content);
273 assert!(warnings.is_empty());
274 }
275
276 #[test]
277 fn test_case_insensitive() {
278 let content = r#"Text with [^Note] and [^OTHER].
279
280[^note]: First (case-insensitive match)
281[^other]: Second
282"#;
283 let warnings = check(content);
284 assert!(warnings.is_empty());
285 }
286
287 #[test]
288 fn test_definitions_without_references() {
289 let content = r#"Text with [^1].
291
292[^1]: Referenced
293[^2]: Orphaned
294"#;
295 let warnings = check(content);
296 assert!(warnings.is_empty(), "Orphaned definitions handled by MD066");
297 }
298
299 #[test]
300 fn test_three_footnotes_wrong_order() {
301 let content = r#"Ref [^a], then [^b], then [^c].
302
303[^c]: Third ref, first def
304[^a]: First ref, second def
305[^b]: Second ref, third def
306"#;
307 let warnings = check(content);
308 assert!(!warnings.is_empty());
309 }
310
311 #[test]
312 fn test_blockquote_definitions() {
313 let content = r#"Text with [^1] and [^2].
314
315> [^1]: First in blockquote
316> [^2]: Second in blockquote
317"#;
318 let warnings = check(content);
319 assert!(warnings.is_empty());
320 }
321
322 #[test]
323 fn test_midline_footnote_ref_with_colon_counted_for_ordering() {
324 let content = "# Test\n\nSecond ref [^b] here.\n\nFirst ref [^a]: and text.\n\n[^a]: First definition.\n[^b]: Second definition.\n";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let rule = MD067FootnoteDefinitionOrder;
328 let result = rule.check(&ctx).unwrap();
329 assert!(!result.is_empty(), "Should detect ordering mismatch: {result:?}");
331 }
332
333 #[test]
334 fn test_linestart_footnote_def_not_counted_as_reference_for_ordering() {
335 let content = "# Test\n\n[^a] first ref.\n[^b] second ref.\n\n[^a]: First.\n[^b]: Second.\n";
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 let rule = MD067FootnoteDefinitionOrder;
339 let result = rule.check(&ctx).unwrap();
340 assert!(result.is_empty(), "Correct order should pass: {result:?}");
341 }
342
343 #[test]
346 fn test_out_of_order_column_position() {
347 let content = "Text with [^1] and [^2].\n\n[^2]: Second definition\n[^1]: First definition\n";
348 let warnings = check(content);
349 assert_eq!(warnings.len(), 1);
350 assert_eq!(warnings[0].line, 3);
351 assert_eq!(warnings[0].column, 1, "Definition at start of line");
352 assert_eq!(warnings[0].end_column, 6);
354 }
355
356 #[test]
357 fn test_out_of_order_blockquote_column_position() {
358 let content = "Text with [^1] and [^2].\n\n> [^2]: Second in blockquote\n> [^1]: First in blockquote\n";
359 let warnings = check(content);
360 assert_eq!(warnings.len(), 1);
361 assert_eq!(warnings[0].line, 3);
362 assert_eq!(warnings[0].column, 3, "Should point past blockquote prefix");
364 }
365}