Skip to main content

rumdl_lib/rules/
md067_footnote_definition_order.rs

1//! MD067: Footnote definitions should appear in order of first reference
2//!
3//! This rule enforces that footnote definitions appear in the same order
4//! as their first references in the document. Out-of-order footnotes
5//! can confuse readers.
6//!
7//! ## Example
8//!
9//! ### Incorrect
10//! ```markdown
11//! Text with [^2] and then [^1].
12//!
13//! [^1]: First definition
14//! [^2]: Second definition
15//! ```
16//!
17//! ### Correct
18//! ```markdown
19//! Text with [^2] and then [^1].
20//!
21//! [^2]: Referenced first
22//! [^1]: Referenced second
23//! ```
24
25use 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        // Track first reference position for each footnote ID
65        let mut reference_order: Vec<String> = Vec::new();
66        let mut seen_refs: HashMap<String, usize> = HashMap::new();
67
68        // Track definition positions
69        let mut definition_order: Vec<(String, usize, usize)> = Vec::new(); // (id, line, byte_offset)
70
71        // First pass: collect references in order of first occurrence
72        for line_info in &ctx.lines {
73            // Skip special contexts
74            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                    // Skip if this is a footnote definition (at line start with 0-3 spaces indent)
88                    // Also handle blockquote prefixes (e.g., "> [^id]:")
89                    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                    // Check if this match is inside a code span
100                    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        // Second pass: collect definitions in document order
114        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
115            // Skip special contexts
116            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            // Strip blockquote prefixes
127            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        // Compare definition order against reference order
139        let mut expected_idx = 0;
140        for (def_id, def_line, _byte_offset) in &definition_order {
141            // Find this definition's expected position based on reference order
142            if let Some(&ref_idx) = seen_refs.get(def_id) {
143                if ref_idx != expected_idx {
144                    // Find what was expected
145                    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            // Definitions without references are handled by MD066, skip them here
169        }
170
171        Ok(warnings)
172    }
173
174    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
175        // Auto-fix would require reordering definitions which is complex
176        // and could break multi-paragraph footnotes
177        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        // Orphaned definitions are handled by MD066, not this rule
290        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        // Mid-line [^a]: should count as a reference for ordering purposes
325        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        // Reference order is [^b] then [^a], but definitions are [^a] then [^b]
330        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        // [^a]: at line start is a definition, not a reference
336        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    // ==================== Warning position tests ====================
344
345    #[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        // "[^2]:" is 5 chars
353        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        // After "> " prefix (2 chars), definition starts at column 3
363        assert_eq!(warnings[0].column, 3, "Should point past blockquote prefix");
364    }
365}