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_or((1, 1), |li| footnote_def_position(li.content(ctx.content)));
151 warnings.push(LintWarning {
152 rule_name: Some(self.name().to_string()),
153 line: *def_line,
154 column: col,
155 end_line: *def_line,
156 end_column: end_col,
157 message: format!(
158 "Footnote definition '[^{def_id}]' is out of order; expected '[^{expected_id}]' next (based on reference order)"
159 ),
160 severity: Severity::Warning,
161 fix: None,
162 });
163 }
164 }
165 expected_idx = ref_idx + 1;
166 }
167 }
169
170 Ok(warnings)
171 }
172
173 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
174 Ok(ctx.content.to_string())
177 }
178
179 fn as_any(&self) -> &dyn std::any::Any {
180 self
181 }
182
183 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
184 where
185 Self: Sized,
186 {
187 Box::new(MD067FootnoteDefinitionOrder)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::LintContext;
195
196 fn check(content: &str) -> Vec<LintWarning> {
197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
198 MD067FootnoteDefinitionOrder::new().check(&ctx).unwrap()
199 }
200
201 #[test]
202 fn test_correct_order() {
203 let content = r#"Text with [^1] and [^2].
204
205[^1]: First definition
206[^2]: Second definition
207"#;
208 let warnings = check(content);
209 assert!(warnings.is_empty(), "Expected no warnings for correct order");
210 }
211
212 #[test]
213 fn test_incorrect_order() {
214 let content = r#"Text with [^1] and [^2].
215
216[^2]: Second definition
217[^1]: First definition
218"#;
219 let warnings = check(content);
220 assert_eq!(warnings.len(), 1);
221 assert!(warnings[0].message.contains("out of order"));
222 assert!(warnings[0].message.contains("[^2]"));
223 }
224
225 #[test]
226 fn test_named_footnotes_order() {
227 let content = r#"Text with [^alpha] and [^beta].
228
229[^beta]: Beta definition
230[^alpha]: Alpha definition
231"#;
232 let warnings = check(content);
233 assert_eq!(warnings.len(), 1);
234 assert!(warnings[0].message.contains("[^beta]"));
235 }
236
237 #[test]
238 fn test_multiple_refs_same_footnote() {
239 let content = r#"Text with [^1] and [^2] and [^1] again.
240
241[^1]: First footnote
242[^2]: Second footnote
243"#;
244 let warnings = check(content);
245 assert!(
246 warnings.is_empty(),
247 "Multiple refs to same footnote should use first occurrence"
248 );
249 }
250
251 #[test]
252 fn test_skip_code_blocks() {
253 let content = r#"Text with [^1].
254
255```
256[^2]: In code block
257```
258
259[^1]: Real definition
260"#;
261 let warnings = check(content);
262 assert!(warnings.is_empty());
263 }
264
265 #[test]
266 fn test_skip_code_spans() {
267 let content = r#"Text with `[^2]` in code and [^1].
268
269[^1]: Only real reference
270"#;
271 let warnings = check(content);
272 assert!(warnings.is_empty());
273 }
274
275 #[test]
276 fn test_case_insensitive() {
277 let content = r#"Text with [^Note] and [^OTHER].
278
279[^note]: First (case-insensitive match)
280[^other]: Second
281"#;
282 let warnings = check(content);
283 assert!(warnings.is_empty());
284 }
285
286 #[test]
287 fn test_definitions_without_references() {
288 let content = r#"Text with [^1].
290
291[^1]: Referenced
292[^2]: Orphaned
293"#;
294 let warnings = check(content);
295 assert!(warnings.is_empty(), "Orphaned definitions handled by MD066");
296 }
297
298 #[test]
299 fn test_three_footnotes_wrong_order() {
300 let content = r#"Ref [^a], then [^b], then [^c].
301
302[^c]: Third ref, first def
303[^a]: First ref, second def
304[^b]: Second ref, third def
305"#;
306 let warnings = check(content);
307 assert!(!warnings.is_empty());
308 }
309
310 #[test]
311 fn test_blockquote_definitions() {
312 let content = r#"Text with [^1] and [^2].
313
314> [^1]: First in blockquote
315> [^2]: Second in blockquote
316"#;
317 let warnings = check(content);
318 assert!(warnings.is_empty());
319 }
320
321 #[test]
322 fn test_midline_footnote_ref_with_colon_counted_for_ordering() {
323 let content = "# Test\n\nSecond ref [^b] here.\n\nFirst ref [^a]: and text.\n\n[^a]: First definition.\n[^b]: Second definition.\n";
325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
326 let rule = MD067FootnoteDefinitionOrder;
327 let result = rule.check(&ctx).unwrap();
328 assert!(!result.is_empty(), "Should detect ordering mismatch: {result:?}");
330 }
331
332 #[test]
333 fn test_linestart_footnote_def_not_counted_as_reference_for_ordering() {
334 let content = "# Test\n\n[^a] first ref.\n[^b] second ref.\n\n[^a]: First.\n[^b]: Second.\n";
336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337 let rule = MD067FootnoteDefinitionOrder;
338 let result = rule.check(&ctx).unwrap();
339 assert!(result.is_empty(), "Correct order should pass: {result:?}");
340 }
341
342 #[test]
345 fn test_out_of_order_column_position() {
346 let content = "Text with [^1] and [^2].\n\n[^2]: Second definition\n[^1]: First definition\n";
347 let warnings = check(content);
348 assert_eq!(warnings.len(), 1);
349 assert_eq!(warnings[0].line, 3);
350 assert_eq!(warnings[0].column, 1, "Definition at start of line");
351 assert_eq!(warnings[0].end_column, 6);
353 }
354
355 #[test]
356 fn test_out_of_order_blockquote_column_position() {
357 let content = "Text with [^1] and [^2].\n\n> [^2]: Second in blockquote\n> [^1]: First in blockquote\n";
358 let warnings = check(content);
359 assert_eq!(warnings.len(), 1);
360 assert_eq!(warnings[0].line, 3);
361 assert_eq!(warnings[0].column, 3, "Should point past blockquote prefix");
363 }
364}