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