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::{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        // Track first reference position for each footnote ID
69        let mut reference_order: Vec<String> = Vec::new();
70        let mut seen_refs: HashMap<String, usize> = HashMap::new();
71
72        // Track definition positions
73        let mut definition_order: Vec<(String, usize, usize)> = Vec::new(); // (id, line, byte_offset)
74
75        // Parser references exclude escapes, code, and missing definitions. Missing
76        // definitions belong to MD066 and must not shift the expected order here.
77        for reference in ctx.footnote_references() {
78            // The shared parser can still expose references in front matter
79            // or flavor-specific non-Markdown regions. Preserve those exclusions.
80            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        // Second pass: collect definitions in document order
97        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
98            // Skip special contexts
99            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            // Strip blockquote prefixes
110            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        // Compare definition order against reference order
122        let mut expected_idx = 0;
123        for (def_id, def_line, _byte_offset) in &definition_order {
124            // Find this definition's expected position based on reference order
125            if let Some(&ref_idx) = seen_refs.get(def_id) {
126                if ref_idx != expected_idx {
127                    // Find what was expected
128                    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            // Definitions without references are handled by MD066, skip them here
151        }
152
153        // Ordering is one operation over the definitions, even when the fix
154        // is unsafe. Retain that full diagnostic span for suppression/MD087.
155        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            // Blank lines after the definition's last content line are not part
174            // of the definition, so the range ends on that content line.
175            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
206/// Reorder complete parser-owned definitions in their existing source slots.
207/// Separators and unrelated blocks stay in place; every replacement is atomic.
208fn 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                    // Blank separators belong to their slots, not the definition
228                    // that happened to precede them (including quoted blanks).
229                    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                // A paragraph moved into a slot ending in code could absorb
259                // the following prose as a lazy footnote continuation.
260                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            // Different containers or indentation can change whether a
268            // definition belongs to an adjacent list after it moves.
269            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            // Moving references could change the order again on the next pass.
281            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    // A preceding next-line directive must stay attached to the same content,
288    // including directives belonging to other tools or other rumdl rules.
289    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        // Comments may carry directives whose scope must not change. Native
301        // MDX syntax can also end a definition that pulldown considers open.
302        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    // A reorder must survive or be skipped as a whole when another rule edits
321    // one of these definitions. Separate edits could lose or duplicate content.
322    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        // Orphaned definitions are handled by MD066, not this rule
440        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        // Mid-line [^a]: should count as a reference for ordering purposes
475        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        // Reference order is [^b] then [^a], but definitions are [^a] then [^b]
480        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        // [^a]: at line start is a definition, not a reference
486        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    // ==================== Warning position tests ====================
494
495    #[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        // The atomic reorder spans both definitions, so a disable on either
503        // definition suppresses the complete operation.
504        assert_eq!(warnings[0].end_line, 4);
505        assert_eq!(warnings[0].end_column, 23);
506    }
507
508    /// Blank lines after the last definition are not part of it, so the range
509    /// ends at the end of that definition's last content line.
510    #[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    /// A multi-paragraph definition keeps its continuation lines in the range,
520    /// but not the blank lines that follow it.
521    #[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        // After "> " prefix (2 chars), definition starts at column 3
537        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        // ESM needs a blank separator: unlike expressions/JSX, it cannot
648        // interrupt the preceding footnote paragraph.
649        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}