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 mut last = definition_order
158                .iter()
159                .rev()
160                .find(|(id, _, _)| seen_refs.contains_key(id))
161                .unwrap()
162                .1
163                - 1;
164            while let Some(line) = ctx.lines.get(last + 1) {
165                if !line.in_footnote_definition
166                    || FOOTNOTE_DEF_PATTERN.is_match(strip_blockquote_prefix(line.content(ctx.content)))
167                {
168                    break;
169                }
170                last += 1;
171            }
172            warning.end_line = last + 1;
173            warning.end_column = ctx.lines[warning.end_line - 1].content(ctx.content).chars().count() + 1;
174            warning.fix = reordering_fix(ctx, &definition_order, &seen_refs);
175        }
176        Ok(warnings)
177    }
178
179    fn fixes_from_diagnostics(&self) -> bool {
180        true
181    }
182
183    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
184        let warnings = filter_warnings_by_inline_config(self.check(ctx)?, ctx.inline_config(), self.name());
185        apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
186    }
187
188    fn as_any(&self) -> &dyn std::any::Any {
189        self
190    }
191
192    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
193    where
194        Self: Sized,
195    {
196        Box::new(MD067FootnoteDefinitionOrder)
197    }
198}
199
200/// Reorder complete parser-owned definitions in their existing source slots.
201/// Separators and unrelated blocks stay in place; every replacement is atomic.
202fn reordering_fix(
203    ctx: &crate::lint_context::LintContext,
204    definitions: &[(String, usize, usize)],
205    reference_order: &HashMap<String, usize>,
206) -> Option<Fix> {
207    let mut unique = HashSet::new();
208    if definitions.iter().any(|(id, _, _)| !unique.insert(id)) {
209        return None;
210    }
211
212    let mut parents = Vec::new();
213    let mut parsed = HashMap::new();
214    let options = crate::utils::parser_options::rumdl_parser_options_for(ctx.content);
215    for (event, range) in Parser::new_ext(ctx.content, options).into_offset_iter() {
216        match event {
217            Event::Start(tag) => {
218                if let Tag::FootnoteDefinition(id) = &tag {
219                    let first = ctx.lines.partition_point(|line| line.byte_offset <= range.start) - 1;
220                    let mut last = ctx.lines.partition_point(|line| line.byte_offset < range.end) - 1;
221                    // Blank separators belong to their slots, not the definition
222                    // that happened to precede them (including quoted blanks).
223                    while last > first
224                        && strip_blockquote_prefix(ctx.lines[last].content(ctx.content))
225                            .trim_matches([' ', '\t', '\r'])
226                            .is_empty()
227                    {
228                        last -= 1;
229                    }
230                    let span = ctx.lines[first].byte_offset
231                        ..ctx.lines[last].byte_offset + ctx.lines[last].content(ctx.content).len();
232                    parsed.insert((id.to_lowercase(), first + 1), (span, parents.clone()));
233                }
234                parents.push(range.start);
235            }
236            Event::End(_) => {
237                parents.pop();
238            }
239            _ => {}
240        }
241    }
242
243    let mut slots = Vec::new();
244    let mut container = None;
245    for (id, line, _) in definitions {
246        let Some(&rank) = reference_order.get(id) else { continue };
247        let (range, parents) = parsed.get(&(id.clone(), *line))?;
248        let last_line = ctx.lines.partition_point(|line| line.byte_offset < range.end);
249        if let Some(following) = ctx.lines.get(last_line) {
250            let text = strip_blockquote_prefix(following.content(ctx.content));
251            if !text.trim_matches([' ', '\t', '\r']).is_empty() && !FOOTNOTE_DEF_PATTERN.is_match(text) {
252                // A paragraph moved into a slot ending in code could absorb
253                // the following prose as a lazy footnote continuation.
254                return None;
255            }
256        }
257        let first_line = ctx.lines[*line - 1].content(ctx.content);
258        let prefix = &first_line[..first_line.find("[^")?];
259        let scope = (parents, prefix);
260        if container.is_some_and(|existing| existing != scope) {
261            // Different containers or indentation can change whether a
262            // definition belongs to an adjacent list after it moves.
263            return None;
264        }
265        container = Some(scope);
266        let next = ctx
267            .footnote_references()
268            .partition_point(|reference| reference.byte_offset < range.start);
269        if ctx
270            .footnote_references()
271            .get(next)
272            .is_some_and(|reference| reference.byte_offset < range.end)
273        {
274            // Moving references could change the order again on the next pass.
275            return None;
276        }
277        slots.push((range.clone(), rank));
278    }
279    let first_start = slots.first()?.0.start;
280    let first_line = ctx.lines.partition_point(|line| line.byte_offset < first_start);
281    // A preceding next-line directive must stay attached to the same content,
282    // including directives belonging to other tools or other rumdl rules.
283    let span = ctx.lines[first_line.saturating_sub(1)].byte_offset..slots.last()?.0.end;
284    if ctx
285        .html_comment_ranges()
286        .iter()
287        .any(|comment| comment.start < span.end && comment.end > span.start)
288        || ctx
289            .mdx_comment_ranges()
290            .iter()
291            .any(|&(start, end)| start < span.end && end > span.start)
292        || ctx.overlaps_mdx_code(span.start, span.end)
293    {
294        // Comments may carry directives whose scope must not change. Native
295        // MDX syntax can also end a definition that pulldown considers open.
296        return None;
297    }
298    let mut sorted: Vec<_> = slots.iter().collect();
299    sorted.sort_unstable_by_key(|(_, rank)| *rank);
300    let changed: Vec<_> = slots
301        .iter()
302        .zip(sorted)
303        .filter(|((destination, _), (source, _))| destination != source)
304        .collect();
305    let start = changed.first()?.0.0.start;
306    let end = changed.last()?.0.0.end;
307    let mut replacement = String::with_capacity(end - start);
308    let mut cursor = start;
309    for ((destination, _), (source, _)) in changed {
310        replacement.push_str(&ctx.content[cursor..destination.start]);
311        replacement.push_str(&ctx.content[source.clone()]);
312        cursor = destination.end;
313    }
314    // A reorder must survive or be skipped as a whole when another rule edits
315    // one of these definitions. Separate edits could lose or duplicate content.
316    Some(Fix::new(start..end, replacement))
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::LintContext;
323
324    fn check(content: &str) -> Vec<LintWarning> {
325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
326        MD067FootnoteDefinitionOrder::new().check(&ctx).unwrap()
327    }
328
329    #[test]
330    fn test_fix_reorders_complete_footnotes_atomically() {
331        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";
332        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";
333        let rule = MD067FootnoteDefinitionOrder::new();
334        let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
335        let warnings = rule.check(&ctx).unwrap();
336        assert_eq!(rule.fix(&ctx).unwrap(), expected);
337        assert_eq!(
338            crate::utils::fix_utils::apply_warning_fixes(input, &warnings).unwrap(),
339            expected
340        );
341        let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
342        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
343        assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
344    }
345
346    #[test]
347    fn test_correct_order() {
348        let content = r#"Text with [^1] and [^2].
349
350[^1]: First definition
351[^2]: Second definition
352"#;
353        let warnings = check(content);
354        assert!(warnings.is_empty(), "Expected no warnings for correct order");
355    }
356
357    #[test]
358    fn test_incorrect_order() {
359        let content = r#"Text with [^1] and [^2].
360
361[^2]: Second definition
362[^1]: First definition
363"#;
364        let warnings = check(content);
365        assert_eq!(warnings.len(), 1);
366        assert!(warnings[0].message.contains("out of order"));
367        assert!(warnings[0].message.contains("[^2]"));
368    }
369
370    #[test]
371    fn test_named_footnotes_order() {
372        let content = r#"Text with [^alpha] and [^beta].
373
374[^beta]: Beta definition
375[^alpha]: Alpha definition
376"#;
377        let warnings = check(content);
378        assert_eq!(warnings.len(), 1);
379        assert!(warnings[0].message.contains("[^beta]"));
380    }
381
382    #[test]
383    fn test_multiple_refs_same_footnote() {
384        let content = r#"Text with [^1] and [^2] and [^1] again.
385
386[^1]: First footnote
387[^2]: Second footnote
388"#;
389        let warnings = check(content);
390        assert!(
391            warnings.is_empty(),
392            "Multiple refs to same footnote should use first occurrence"
393        );
394    }
395
396    #[test]
397    fn test_skip_code_blocks() {
398        let content = r#"Text with [^1].
399
400```
401[^2]: In code block
402```
403
404[^1]: Real definition
405"#;
406        let warnings = check(content);
407        assert!(warnings.is_empty());
408    }
409
410    #[test]
411    fn test_skip_code_spans() {
412        let content = r#"Text with `[^2]` in code and [^1].
413
414[^1]: Only real reference
415"#;
416        let warnings = check(content);
417        assert!(warnings.is_empty());
418    }
419
420    #[test]
421    fn test_case_insensitive() {
422        let content = r#"Text with [^Note] and [^OTHER].
423
424[^note]: First (case-insensitive match)
425[^other]: Second
426"#;
427        let warnings = check(content);
428        assert!(warnings.is_empty());
429    }
430
431    #[test]
432    fn test_definitions_without_references() {
433        // Orphaned definitions are handled by MD066, not this rule
434        let content = r#"Text with [^1].
435
436[^1]: Referenced
437[^2]: Orphaned
438"#;
439        let warnings = check(content);
440        assert!(warnings.is_empty(), "Orphaned definitions handled by MD066");
441    }
442
443    #[test]
444    fn test_three_footnotes_wrong_order() {
445        let content = r#"Ref [^a], then [^b], then [^c].
446
447[^c]: Third ref, first def
448[^a]: First ref, second def
449[^b]: Second ref, third def
450"#;
451        let warnings = check(content);
452        assert!(!warnings.is_empty());
453    }
454
455    #[test]
456    fn test_blockquote_definitions() {
457        let content = r#"Text with [^1] and [^2].
458
459> [^1]: First in blockquote
460> [^2]: Second in blockquote
461"#;
462        let warnings = check(content);
463        assert!(warnings.is_empty());
464    }
465
466    #[test]
467    fn test_midline_footnote_ref_with_colon_counted_for_ordering() {
468        // Mid-line [^a]: should count as a reference for ordering purposes
469        let content = "# Test\n\nSecond ref [^b] here.\n\nFirst ref [^a]: and text.\n\n[^a]: First definition.\n[^b]: Second definition.\n";
470        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471        let rule = MD067FootnoteDefinitionOrder;
472        let result = rule.check(&ctx).unwrap();
473        // Reference order is [^b] then [^a], but definitions are [^a] then [^b]
474        assert!(!result.is_empty(), "Should detect ordering mismatch: {result:?}");
475    }
476
477    #[test]
478    fn test_linestart_footnote_def_not_counted_as_reference_for_ordering() {
479        // [^a]: at line start is a definition, not a reference
480        let content = "# Test\n\n[^a] first ref.\n[^b] second ref.\n\n[^a]: First.\n[^b]: Second.\n";
481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482        let rule = MD067FootnoteDefinitionOrder;
483        let result = rule.check(&ctx).unwrap();
484        assert!(result.is_empty(), "Correct order should pass: {result:?}");
485    }
486
487    // ==================== Warning position tests ====================
488
489    #[test]
490    fn test_out_of_order_column_position() {
491        let content = "Text with [^1] and [^2].\n\n[^2]: Second definition\n[^1]: First definition\n";
492        let warnings = check(content);
493        assert_eq!(warnings.len(), 1);
494        assert_eq!(warnings[0].line, 3);
495        assert_eq!(warnings[0].column, 1, "Definition at start of line");
496        // The atomic reorder spans both definitions, so a disable on either
497        // definition suppresses the complete operation.
498        assert_eq!(warnings[0].end_line, 4);
499        assert_eq!(warnings[0].end_column, 23);
500    }
501
502    #[test]
503    fn test_out_of_order_blockquote_column_position() {
504        let content = "Text with [^1] and [^2].\n\n> [^2]: Second in blockquote\n> [^1]: First in blockquote\n";
505        let warnings = check(content);
506        assert_eq!(warnings.len(), 1);
507        assert_eq!(warnings[0].line, 3);
508        // After "> " prefix (2 chars), definition starts at column 3
509        assert_eq!(warnings[0].column, 3, "Should point past blockquote prefix");
510    }
511
512    #[test]
513    fn test_fix_preserves_source_slots_and_reference_semantics() {
514        for (input, expected) in [
515            (
516                "Refs[^a][^b][^c].\n\n[^c]: C\n[^a]: A\n[^b]: B\n",
517                "Refs[^a][^b][^c].\n\n[^a]: A\n[^b]: B\n[^c]: C\n",
518            ),
519            (
520                "Refs[^a][^b].\n\n[^b]: B\n\nKeep this paragraph.\n\n[^unused]: U\n\n[^a]: A\n",
521                "Refs[^a][^b].\n\n[^a]: A\n\nKeep this paragraph.\n\n[^unused]: U\n\n[^b]: B\n",
522            ),
523            (
524                "Refs[^é][^β].\r\n\r\n[^β]: 第二\r\n[^é]: café",
525                "Refs[^é][^β].\r\n\r\n[^é]: café\r\n[^β]: 第二",
526            ),
527            (
528                "Refs[^a][^b].\n\n> [^b]: B\n>\n>     Continued B.\n>\n> [^a]: A\n",
529                "Refs[^a][^b].\n\n> [^a]: A\n>\n> [^b]: B\n>\n>     Continued B.\n",
530            ),
531            (
532                "Refs[^a][^b].\n\n  [^b]: B\n  [^a]: A\n",
533                "Refs[^a][^b].\n\n  [^a]: A\n  [^b]: B\n",
534            ),
535            (
536                "Escaped \\[^b], missing[^missing], then real[^a][^b].\n\n[^b]: B\n[^a]: A\n",
537                "Escaped \\[^b], missing[^missing], then real[^a][^b].\n\n[^a]: A\n[^b]: B\n",
538            ),
539        ] {
540            let rule = MD067FootnoteDefinitionOrder::new();
541            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
542            let fixed = rule.fix(&ctx).unwrap();
543            assert_eq!(fixed, expected, "{input:?}");
544            assert_eq!(
545                crate::utils::fix_utils::apply_warning_fixes(input, &rule.check(&ctx).unwrap()).unwrap(),
546                expected
547            );
548            let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
549            assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed:?}");
550            assert_eq!(rule.fix(&ctx).unwrap(), expected);
551        }
552    }
553
554    #[test]
555    fn test_fix_leaves_ambiguous_and_disabled_definitions_untouched() {
556        for input in [
557            "Refs[^a][^b].\n\n- item\n[^b]: B\n  [^a]: A\n",
558            "Refs[^a][^b].\n\n[^b]: B\n\n    ```\n    code\n    ```\nParagraph after b.\n\n[^a]: A\n",
559            "Refs[^a][^b].\n\n<!-- rumdl-disable-next-line MD013 -->\n[^b]: Long definition.\n[^a]: A\n",
560            "Refs[^a][^b].\n\n[^b]: B\n[^a]: A\n[^a]: Duplicate A\n",
561            "Refs[^a][^b].\n\n> [^b]: B\n\n> [^a]: A\n",
562            "Refs[^a][^b].\n\n[^b]: B\n<!-- keep -->\n[^a]: A\n",
563            "Refs[^a][^b].\n\n[^b]: B refers to [^a].\n[^a]: A\n",
564            "<!-- rumdl-disable MD067 -->\nRefs[^a][^b].\n\n[^b]: B\n[^a]: A\n",
565            "Refs[^a][^b].\n\n[^b]: B\n<!-- rumdl-disable-next-line MD067 -->\n[^a]: A\n",
566        ] {
567            let rule = MD067FootnoteDefinitionOrder::new();
568            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
569            assert!(
570                !rule.check(&ctx).unwrap().is_empty(),
571                "retain suppression evidence: {input:?}"
572            );
573            assert_eq!(rule.fix(&ctx).unwrap(), input);
574        }
575    }
576
577    #[test]
578    fn test_disable_on_later_definition_is_accounted_for() {
579        let input = "Refs[^a][^b].\n\n[^b]: B\n<!-- rumdl-disable-next-line MD067 -->\n[^a]: A\n";
580        let rules: Vec<Box<dyn Rule>> = vec![
581            Box::new(MD067FootnoteDefinitionOrder::new()),
582            Box::new(crate::rules::MD087UnusedDisableComment),
583        ];
584        let warnings = crate::lint(
585            input,
586            &rules,
587            false,
588            crate::config::MarkdownFlavor::Standard,
589            None,
590            None,
591        )
592        .unwrap();
593        assert!(
594            warnings.is_empty(),
595            "the ordering operation spans both definitions: {warnings:?}"
596        );
597    }
598
599    #[test]
600    fn test_fix_preserves_nonbreaking_space_inside_a_definition() {
601        let input = "Refs[^a][^b].\n\n[^b]: B\n\n    \u{a0}\n\n[^a]: A\n";
602        let expected = "Refs[^a][^b].\n\n[^a]: A\n\n[^b]: B\n\n    \u{a0}\n";
603        let rule = MD067FootnoteDefinitionOrder;
604        let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
605        assert_eq!(rule.fix(&ctx).unwrap(), expected);
606    }
607
608    #[test]
609    fn test_real_reference_before_html_comment_controls_order() {
610        let input = "Text [^b] <!--\nnote\n-->\nMore [^a] and [^b].\n\n[^b]: B\n[^a]: A\n";
611        let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
612        let rule = MD067FootnoteDefinitionOrder;
613        assert!(rule.check(&ctx).unwrap().is_empty());
614        assert_eq!(rule.fix(&ctx).unwrap(), input);
615    }
616
617    #[test]
618    fn test_fix_preserves_mdx_flow_syntax_next_to_definitions() {
619        // ESM needs a blank separator: unlike expressions/JSX, it cannot
620        // interrupt the preceding footnote paragraph.
621        for syntax in ["{expr}", "<Widget />", "\nexport const value = 1;"] {
622            let input = format!("Refs[^a][^b].\n\n[^b]: B\n{syntax}\n\n[^a]: A\n");
623            let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::MDX, None);
624            let rule = MD067FootnoteDefinitionOrder;
625            let warnings = rule.check(&ctx).unwrap();
626            assert!(!warnings.is_empty(), "{syntax}");
627            assert_eq!(rule.fix(&ctx).unwrap(), input, "{syntax}");
628            assert!(warnings.iter().all(|warning| warning.fix.is_none()), "{syntax}");
629        }
630    }
631
632    #[test]
633    fn test_fix_preserves_mdx_directive_targets() {
634        let input = "Refs[^a][^b].\n\n{/* rumdl-disable-next-line MD013 */}\n[^b]: Long definition.\n[^a]: A\n";
635        let ctx = LintContext::new(input, crate::config::MarkdownFlavor::MDX, None);
636        let rule = MD067FootnoteDefinitionOrder;
637        assert!(!rule.check(&ctx).unwrap().is_empty());
638        assert_eq!(rule.fix(&ctx).unwrap(), input);
639    }
640}