Skip to main content

rumdl_lib/rules/
md048_code_fence_style.rs

1use crate::config::MarkdownFlavor;
2use crate::filtered_lines::FilteredLinesExt;
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
5use crate::rules::code_fence_utils::CodeFenceStyle;
6use crate::utils::range_utils::calculate_match_range;
7use toml;
8
9mod md048_config;
10use md048_config::MD048Config;
11
12/// Reports the MDG style override once per process; see [`FlavorOverrideNotice`].
13static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
14
15/// Parsed fence marker candidate on a single line.
16#[derive(Debug, Clone, Copy)]
17struct FenceMarker<'a> {
18    /// Fence character (` or ~).
19    fence_char: char,
20    /// Length of the contiguous fence run.
21    fence_len: usize,
22    /// Byte index where the fence run starts.
23    fence_start: usize,
24    /// Remaining text after the fence run.
25    rest: &'a str,
26}
27
28/// Parse a candidate fence marker line.
29///
30/// CommonMark only recognizes fenced code block markers when indented by at most
31/// three spaces (outside container contexts). This parser enforces that bound and
32/// returns the marker run and trailing text for further opening/closing checks.
33#[inline]
34fn parse_fence_marker(line: &str) -> Option<FenceMarker<'_>> {
35    let bytes = line.as_bytes();
36    let mut pos = 0usize;
37    while pos < bytes.len() && bytes[pos] == b' ' {
38        pos += 1;
39    }
40    if pos > 3 {
41        return None;
42    }
43
44    let fence_char = match bytes.get(pos).copied() {
45        Some(b'`') => '`',
46        Some(b'~') => '~',
47        _ => return None,
48    };
49
50    let marker = if fence_char == '`' { b'`' } else { b'~' };
51    let mut end = pos;
52    while end < bytes.len() && bytes[end] == marker {
53        end += 1;
54    }
55    let fence_len = end - pos;
56    if fence_len < 3 {
57        return None;
58    }
59
60    Some(FenceMarker {
61        fence_char,
62        fence_len,
63        fence_start: pos,
64        rest: &line[end..],
65    })
66}
67
68#[inline]
69fn is_closing_fence(marker: FenceMarker<'_>, opening_fence_char: char, opening_fence_len: usize) -> bool {
70    marker.fence_char == opening_fence_char && marker.fence_len >= opening_fence_len && marker.rest.trim().is_empty()
71}
72
73#[inline]
74fn needs_fence_conversion(fence_char: char, target_style: CodeFenceStyle) -> bool {
75    (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
76        || (fence_char == '~' && target_style == CodeFenceStyle::Backtick)
77}
78
79/// The native parser already knows where a list-contained fenced block ends.
80/// Source indentation can exceed three spaces after a list prefix is restored.
81fn native_list_fence_end(ctx: &crate::lint_context::LintContext, line: usize, marker_start: usize) -> Option<usize> {
82    let info = ctx.lines.get(line)?;
83    if !info.in_list_block {
84        return None;
85    }
86    let start = info.byte_offset + marker_start;
87    let index = ctx.code_block_details.partition_point(|block| block.start < start);
88    let block = ctx.code_block_details.get(index)?;
89    if block.start != start || !block.is_fenced {
90        return None;
91    }
92    ctx.line_offsets
93        .partition_point(|&offset| offset < block.end)
94        .checked_sub(1)
95}
96
97/// Only use this for known container content, never for standalone indented code.
98fn parse_container_fence_marker(line: &str) -> Option<FenceMarker<'_>> {
99    let content = line.trim_start_matches([' ', '\t']);
100    let mut marker = parse_fence_marker(content)?;
101    marker.fence_start += line.len() - content.len();
102    Some(marker)
103}
104
105/// Rule MD048: Code fence style
106///
107/// See [docs/md048.md](../../docs/md048.md) for full documentation, configuration, and examples.
108#[derive(Clone)]
109pub struct MD048CodeFenceStyle {
110    config: MD048Config,
111    /// Whether `style` came from the configuration rather than from the
112    /// default. MDG enforces backtick either way; this only decides whether the
113    /// user is told that the style they asked for was not adopted.
114    style_explicit: bool,
115}
116
117impl MD048CodeFenceStyle {
118    pub fn new(style: CodeFenceStyle) -> Self {
119        Self {
120            config: MD048Config { style },
121            style_explicit: true,
122        }
123    }
124
125    pub fn from_config_struct(config: MD048Config) -> Self {
126        Self {
127            config,
128            style_explicit: false,
129        }
130    }
131
132    /// Resolve the fence style MD048 should converge on.
133    ///
134    /// A Gherkin Doc String is only ever a backtick fence, so a tilde fence can
135    /// never be one, and a configuration demanding tilde fences cannot be
136    /// satisfied in this flavor. MDG therefore always converges on backtick:
137    /// `consistent` resolves to backtick rather than to whichever marker
138    /// happens to be more common, and an explicit `tilde` is not adopted.
139    fn effective_target_style(&self, ctx: &crate::lint_context::LintContext) -> CodeFenceStyle {
140        if ctx.flavor == MarkdownFlavor::MDG {
141            self.warn_once_about_overridden_style();
142            return CodeFenceStyle::Backtick;
143        }
144
145        match self.config.style {
146            CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
147            style => style,
148        }
149    }
150
151    /// Tell the user once that MDG did not adopt the style they configured.
152    ///
153    /// Only `tilde` is worth reporting: it is the one setting MDG cannot
154    /// satisfy. `consistent` asks for no particular marker, and backtick is
155    /// what MDG picks for it anyway.
156    fn warn_once_about_overridden_style(&self) {
157        if !self.style_explicit || self.config.style != CodeFenceStyle::Tilde {
158            return;
159        }
160
161        MDG_STYLE_OVERRIDE.report(
162            "MD048",
163            "style",
164            "tilde",
165            "backtick",
166            "a Gherkin Doc String is only ever a backtick fence",
167        );
168    }
169
170    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
171        let ambiguity_limit = super::code_fence_utils::fence_ambiguity_limit(ctx);
172        // Count occurrences of each fence style (prevalence-based approach)
173        let mut backtick_count = 0;
174        let mut tilde_count = 0;
175        let mut in_code_block = false;
176        let mut opening_fence_char = '`';
177        let mut opening_fence_len = 0usize;
178        let mut list_fence_end = None;
179
180        for filtered_line in ctx.filtered_lines().skip_front_matter() {
181            let i = filtered_line.line_num - 1;
182            if ambiguity_limit.is_some_and(|limit| ctx.lines[i].byte_offset >= limit) {
183                break;
184            }
185            let line = filtered_line.content;
186            if list_fence_end.is_some_and(|end| i > end) {
187                in_code_block = false;
188                list_fence_end = None;
189            }
190            // Skip lines inside Azure DevOps colon code fences — they are
191            // opaque content and must not influence backtick/tilde style detection.
192            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
193                continue;
194            }
195
196            // Skip lines inside MyST colon directives — they are structural
197            // containers, not code fences.
198            if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
199                continue;
200            }
201
202            let marker = if in_code_block && list_fence_end == Some(i) {
203                parse_container_fence_marker(line)
204            } else {
205                parse_fence_marker(line)
206            };
207            let Some(marker) = marker else {
208                continue;
209            };
210
211            let marker_offset = ctx.lines[i].byte_offset + marker.fence_start;
212            if !in_code_block && (ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)) {
213                continue;
214            }
215
216            // Skip MyST backtick directives (info string starts with {name})
217            if ctx.flavor.supports_myst_directives()
218                && marker.fence_char == '`'
219                && marker.rest.trim_start().starts_with('{')
220            {
221                continue;
222            }
223
224            if !in_code_block {
225                // Opening fence - count it
226                if marker.fence_char == '`' {
227                    backtick_count += 1;
228                } else {
229                    tilde_count += 1;
230                }
231                in_code_block = true;
232                opening_fence_char = marker.fence_char;
233                opening_fence_len = marker.fence_len;
234                list_fence_end = native_list_fence_end(ctx, i, marker.fence_start);
235            } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
236                in_code_block = false;
237                list_fence_end = None;
238            }
239        }
240
241        // Use the most prevalent style
242        // In case of a tie, prefer backticks (more common, widely supported)
243        if backtick_count >= tilde_count && backtick_count > 0 {
244            Some(CodeFenceStyle::Backtick)
245        } else if tilde_count > 0 {
246            Some(CodeFenceStyle::Tilde)
247        } else {
248            None
249        }
250    }
251}
252
253/// Find the maximum fence length using `target_char` within the body of a fenced block.
254///
255/// Scans from the line after `opening_line` until the matching closing fence
256/// (same `opening_char`, length >= `opening_fence_len`, no trailing content).
257/// Returns the maximum number of consecutive `target_char` characters found at
258/// the start of any interior bare fence line (after stripping leading whitespace).
259///
260/// This is used to compute the minimum fence length needed when converting a
261/// fence from one style to another so that nesting remains unambiguous.
262/// For example, converting a `~~~` outer fence that contains ```` ``` ```` inner
263/// fences to backtick style requires using ```` ```` ```` (4 backticks) so that
264/// the inner 3-backtick bare fences cannot inadvertently close the outer block.
265///
266/// Only bare interior sequences (no trailing content) are counted. Per CommonMark
267/// spec section 4.5, a closing fence must be followed only by optional whitespace —
268/// lines with info strings (e.g. `` ```rust ``) can never be closing fences, so
269/// they never create ambiguity regardless of the outer fence's style.
270fn max_inner_fence_length_of_char(
271    lines: &[&str],
272    opening_line: usize,
273    opening_fence_len: usize,
274    opening_char: char,
275    target_char: char,
276    list_fence_end: Option<usize>,
277) -> usize {
278    let mut max_len = 0usize;
279
280    for (index, line) in lines.iter().enumerate().skip(opening_line + 1) {
281        if list_fence_end.is_some_and(|end| index > end) {
282            break;
283        }
284        let marker = if list_fence_end.is_some() && target_char != opening_char {
285            parse_container_fence_marker(line)
286        } else {
287            parse_fence_marker(line)
288        };
289        let Some(marker) = marker else {
290            continue;
291        };
292
293        // Stop at the closing fence of the outer block.
294        if list_fence_end.is_none_or(|end| index == end) && is_closing_fence(marker, opening_char, opening_fence_len) {
295            break;
296        }
297
298        // Count only bare sequences (no info string). Lines with info strings
299        // can never be closing fences per CommonMark and pose no ambiguity risk.
300        if marker.fence_char == target_char && marker.rest.trim().is_empty() {
301            max_len = max_len.max(marker.fence_len);
302        }
303    }
304
305    max_len
306}
307
308impl Rule for MD048CodeFenceStyle {
309    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
310        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(48))
311    }
312
313    fn name(&self) -> &'static str {
314        "MD048"
315    }
316
317    fn description(&self) -> &'static str {
318        "Code fence style should be consistent"
319    }
320
321    fn category(&self) -> RuleCategory {
322        RuleCategory::CodeBlock
323    }
324
325    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
326        let mut warnings = Vec::new();
327        let ambiguity_limit = super::code_fence_utils::fence_ambiguity_limit(ctx);
328
329        let target_style = self.effective_target_style(ctx);
330
331        let lines = ctx.raw_lines();
332        let mut in_code_block = false;
333        let mut code_block_fence_char = '`';
334        let mut code_block_fence_len = 0usize;
335        // The fence length to use when writing the converted/lengthened closing fence.
336        // May be longer than the original when inner fences require disambiguation by length.
337        let mut converted_fence_len = 0usize;
338        // True when the opening fence was already the correct style but its length is
339        // ambiguous (interior has same-style fences of equal or greater length).
340        let mut needs_lengthening = false;
341        // A block's two fences are rewritten together or not at all: a closing
342        // fence converted on its own no longer closes its opener, and the block
343        // then runs to the end of the document. Each fence is reported on its
344        // own line, though, so a directive naming only the opener filters out
345        // half of the pair. Nothing between the fences can be a directive, so
346        // the opener's line speaks for the whole block, as it does for MD046.
347        let mut opening_fence_disabled = false;
348        // Where the open block's opening fence was reported, if it was. The
349        // fences are still reported one by one, and a consumer may apply the
350        // fix of either warning alone, as an editor quick fix does. Each fix
351        // therefore also carries the edit of the other fence.
352        let mut opening_warning = None;
353        let mut list_fence_end = None;
354
355        for filtered_line in ctx.filtered_lines().skip_front_matter() {
356            let line_num = filtered_line.line_num - 1;
357            if ambiguity_limit.is_some_and(|limit| ctx.lines[line_num].byte_offset >= limit) {
358                break;
359            }
360            let line = filtered_line.content;
361            if list_fence_end.is_some_and(|end| line_num > end) {
362                in_code_block = false;
363                list_fence_end = None;
364            }
365            // Skip lines inside Azure DevOps colon code fences.
366            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
367                continue;
368            }
369
370            // Skip lines inside MyST colon directives.
371            if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
372                continue;
373            }
374
375            let marker = if in_code_block && list_fence_end == Some(line_num) {
376                parse_container_fence_marker(line)
377            } else {
378                parse_fence_marker(line)
379            };
380            let Some(marker) = marker else {
381                continue;
382            };
383
384            let marker_offset = ctx.lines[line_num].byte_offset + marker.fence_start;
385            if !in_code_block && (ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)) {
386                continue;
387            }
388
389            // Skip MyST backtick directives (info string starts with {name})
390            if ctx.flavor.supports_myst_directives()
391                && !in_code_block
392                && marker.fence_char == '`'
393                && marker.rest.trim_start().starts_with('{')
394            {
395                continue;
396            }
397            let fence_char = marker.fence_char;
398            let fence_len = marker.fence_len;
399
400            if !in_code_block {
401                in_code_block = true;
402                code_block_fence_char = fence_char;
403                code_block_fence_len = fence_len;
404                opening_fence_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num + 1);
405                list_fence_end = native_list_fence_end(ctx, line_num, marker.fence_start);
406                // A backtick fence cannot contain any backtick in its info string.
407                let can_convert_block =
408                    !(fence_char == '~' && target_style == CodeFenceStyle::Backtick && marker.rest.contains('`'));
409                let reported_before = warnings.len();
410
411                let needs_conversion = needs_fence_conversion(fence_char, target_style);
412
413                if needs_conversion {
414                    let target_char = if target_style == CodeFenceStyle::Backtick {
415                        '`'
416                    } else {
417                        '~'
418                    };
419
420                    // Compute how many target_char characters the converted fence needs.
421                    // Must be strictly greater than any inner bare fence of the target style.
422                    let prefix = &line[..marker.fence_start];
423                    let info = marker.rest;
424                    let max_inner = max_inner_fence_length_of_char(
425                        lines,
426                        line_num,
427                        fence_len,
428                        fence_char,
429                        target_char,
430                        list_fence_end,
431                    );
432                    converted_fence_len = fence_len.max(max_inner + 1);
433                    needs_lengthening = false;
434
435                    let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
436
437                    let fence_start = marker.fence_start;
438                    let fence_end = fence_start + fence_len;
439                    let (start_line, start_col, end_line, end_col) =
440                        calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
441
442                    warnings.push(LintWarning {
443                        rule_name: Some(self.name().to_string()),
444                        message: format!(
445                            "Code fence style: use {} instead of {}",
446                            if target_style == CodeFenceStyle::Backtick {
447                                "```"
448                            } else {
449                                "~~~"
450                            },
451                            if fence_char == '`' { "```" } else { "~~~" }
452                        ),
453                        line: start_line,
454                        column: start_col,
455                        end_line,
456                        end_column: end_col,
457                        severity: Severity::Warning,
458                        fix: can_convert_block.then(|| {
459                            Fix::new(
460                                ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
461                                replacement,
462                            )
463                        }),
464                    });
465                } else {
466                    // No character conversion is required.
467                    // Check for fence-length ambiguity:
468                    // if the interior contains same-style bare fences of equal or greater
469                    // length, the outer fence cannot be distinguished from an inner
470                    // closing fence and must be made longer.
471                    let prefix = &line[..marker.fence_start];
472                    let info = marker.rest;
473                    let max_inner = max_inner_fence_length_of_char(
474                        lines,
475                        line_num,
476                        fence_len,
477                        fence_char,
478                        fence_char,
479                        list_fence_end,
480                    );
481                    if max_inner >= fence_len {
482                        converted_fence_len = max_inner + 1;
483                        needs_lengthening = true;
484
485                        let replacement =
486                            format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
487
488                        let fence_start = marker.fence_start;
489                        let fence_end = fence_start + fence_len;
490                        let (start_line, start_col, end_line, end_col) =
491                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
492
493                        warnings.push(LintWarning {
494                            rule_name: Some(self.name().to_string()),
495                            message: format!(
496                                "Code fence length is ambiguous: outer fence ({fence_len} {}) \
497                                 contains interior fence sequences of equal length; \
498                                 use {converted_fence_len}",
499                                if fence_char == '`' { "backticks" } else { "tildes" },
500                            ),
501                            line: start_line,
502                            column: start_col,
503                            end_line,
504                            end_column: end_col,
505                            severity: Severity::Warning,
506                            fix: Some(Fix::new(
507                                ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
508                                replacement,
509                            )),
510                        });
511                    } else {
512                        converted_fence_len = fence_len;
513                        needs_lengthening = false;
514                    }
515                }
516                opening_warning = (warnings.len() > reported_before).then_some(reported_before);
517            } else {
518                // Inside a code block — check if this is the closing fence.
519                let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
520
521                if is_closing {
522                    let needs_conversion = needs_fence_conversion(fence_char, target_style);
523
524                    if (needs_conversion || needs_lengthening) && !opening_fence_disabled {
525                        let target_char = if needs_conversion {
526                            if target_style == CodeFenceStyle::Backtick {
527                                '`'
528                            } else {
529                                '~'
530                            }
531                        } else {
532                            fence_char
533                        };
534
535                        let prefix = &line[..marker.fence_start];
536                        let replacement = format!(
537                            "{prefix}{}{}",
538                            target_char.to_string().repeat(converted_fence_len),
539                            marker.rest
540                        );
541
542                        let fence_start = marker.fence_start;
543                        let fence_end = fence_start + fence_len;
544                        let (start_line, start_col, end_line, end_col) =
545                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
546
547                        let message = if needs_conversion {
548                            format!(
549                                "Code fence style: use {} instead of {}",
550                                if target_style == CodeFenceStyle::Backtick {
551                                    "```"
552                                } else {
553                                    "~~~"
554                                },
555                                if fence_char == '`' { "```" } else { "~~~" }
556                            )
557                        } else {
558                            format!(
559                                "Code fence length is ambiguous: closing fence ({fence_len} {}) \
560                                 must match the lengthened outer fence; use {converted_fence_len}",
561                                if fence_char == '`' { "backticks" } else { "tildes" },
562                            )
563                        };
564
565                        // An opening fence that cannot be converted has no fix,
566                        // and then neither has its closing fence.
567                        let closing_range = ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len());
568                        let fix = opening_warning
569                            .and_then(|index| warnings[index].fix.as_mut())
570                            .map(|opening| {
571                                let opening_edit = Fix::new(opening.range.clone(), opening.replacement.clone());
572                                opening
573                                    .additional_edits
574                                    .push(Fix::new(closing_range.clone(), replacement.clone()));
575                                Fix::with_additional_edits(closing_range, replacement, vec![opening_edit])
576                            });
577
578                        warnings.push(LintWarning {
579                            rule_name: Some(self.name().to_string()),
580                            message,
581                            line: start_line,
582                            column: start_col,
583                            end_line,
584                            end_column: end_col,
585                            severity: Severity::Warning,
586                            fix,
587                        });
588                    }
589
590                    in_code_block = false;
591                    list_fence_end = None;
592                    code_block_fence_len = 0;
593                    converted_fence_len = 0;
594                    needs_lengthening = false;
595                }
596                // Lines inside the block that are not the closing fence are left alone.
597            }
598        }
599
600        Ok(warnings)
601    }
602
603    /// Check if this rule should be skipped for performance
604    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
605        // Skip if content is empty or has no code fence markers
606        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
607    }
608
609    fn fixes_from_diagnostics(&self) -> bool {
610        true
611    }
612
613    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
614        if self.should_skip(ctx) {
615            return Ok(ctx.content.to_string());
616        }
617        let warnings = self.check(ctx)?;
618        if warnings.is_empty() {
619            return Ok(ctx.content.to_string());
620        }
621        let warnings =
622            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
623        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
624            .map_err(crate::rule::LintError::InvalidInput)
625    }
626
627    fn as_any(&self) -> &dyn std::any::Any {
628        self
629    }
630
631    crate::impl_rule_config_sections!(MD048Config);
632
633    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
634    where
635        Self: Sized,
636    {
637        let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
638        let style_explicit = option_is_explicit(config, "MD048", "style");
639
640        Box::new(Self {
641            config: rule_config,
642            style_explicit,
643        })
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::lint_context::LintContext;
651
652    #[test]
653    fn test_backtick_style_with_backticks() {
654        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
655        let content = "```\ncode\n```";
656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657        let result = rule.check(&ctx).unwrap();
658
659        assert_eq!(result.len(), 0);
660    }
661
662    #[test]
663    fn test_backtick_style_with_tildes() {
664        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
665        let content = "~~~\ncode\n~~~";
666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667        let result = rule.check(&ctx).unwrap();
668
669        assert_eq!(result.len(), 2); // Opening and closing fence
670        assert!(result[0].message.contains("use ``` instead of ~~~"));
671        assert_eq!(result[0].line, 1);
672        assert_eq!(result[1].line, 3);
673    }
674
675    #[test]
676    fn test_tilde_style_with_tildes() {
677        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
678        let content = "~~~\ncode\n~~~";
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = rule.check(&ctx).unwrap();
681
682        assert_eq!(result.len(), 0);
683    }
684
685    #[test]
686    fn test_tilde_style_with_backticks() {
687        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
688        let content = "```\ncode\n```";
689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690        let result = rule.check(&ctx).unwrap();
691
692        assert_eq!(result.len(), 2); // Opening and closing fence
693        assert!(result[0].message.contains("use ~~~ instead of ```"));
694    }
695
696    #[test]
697    fn test_mdg_overrides_tilde_style_to_backtick() {
698        // A Gherkin Doc String is only ever a backtick fence, so a
699        // configuration demanding tilde fences cannot be satisfied in this
700        // flavor. MDG does not adopt it: a Doc String keeps its backticks and a
701        // tilde fence is still corrected into one.
702        let backticks = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n  ```json\n  {\"ok\": true}\n  ```";
703        let tildes =
704            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ~~~text\n  example\n  ~~~";
705
706        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
707        let ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::MDG, None);
708        assert!(rule.check(&ctx).unwrap().is_empty());
709        assert_eq!(rule.fix(&ctx).unwrap(), backticks);
710
711        let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
712        assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
713        let fixed = rule.fix(&ctx).unwrap();
714        assert_eq!(
715            fixed,
716            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ```text\n  example\n  ```"
717        );
718
719        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
720        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
721        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
722
723        // Standard honours `tilde` exactly as before, in both directions.
724        let standard_ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::Standard, None);
725        assert_eq!(rule.check(&standard_ctx).unwrap().len(), 2);
726        assert!(rule.fix(&standard_ctx).unwrap().contains("~~~json"));
727
728        let standard_ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::Standard, None);
729        assert!(rule.check(&standard_ctx).unwrap().is_empty());
730        assert_eq!(rule.fix(&standard_ctx).unwrap(), tildes);
731    }
732
733    #[test]
734    fn test_mdg_applies_backtick_style() {
735        // The explicit backtick style is the one MDG can satisfy, so it is
736        // applied unchanged.
737        let tildes =
738            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ~~~text\n  example\n  ~~~";
739
740        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
741        let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
742        assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
743        assert_eq!(
744            rule.fix(&ctx).unwrap(),
745            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ```text\n  example\n  ```"
746        );
747    }
748
749    #[test]
750    fn test_mdg_tilde_style_still_disambiguates_fence_length() {
751        // The override decides which marker MD048 converges on; it does not
752        // touch the length arithmetic that keeps a converted outer fence from
753        // being closed by an interior one.
754        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
755        let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n~~~\n```rust\ncode\n```\n~~~";
756
757        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
758        let fixed = rule.fix(&mdg_ctx).unwrap();
759        assert_eq!(
760            fixed,
761            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n````\n```rust\ncode\n```\n````"
762        );
763
764        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
765        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
766
767        // Standard honours `tilde`: the outer fence is already a tilde fence,
768        // so nothing is converted or lengthened.
769        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770        assert!(rule.check(&standard_ctx).unwrap().is_empty());
771        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
772    }
773
774    #[test]
775    fn test_from_config_records_whether_style_was_configured() {
776        // The MDG override applies either way, but the warning is only for a
777        // style the user actually asked for, so a configured style has to be
778        // told apart from a defaulted one.
779        use crate::config::Config;
780        use std::collections::BTreeMap;
781
782        let mut values = BTreeMap::new();
783        values.insert("style".to_string(), toml::Value::String("tilde".to_string()));
784        let mut config = Config::default();
785        config.rules.insert(
786            "MD048".to_string(),
787            crate::config::RuleConfig { severity: None, values },
788        );
789
790        let configured = MD048CodeFenceStyle::from_config(&config);
791        let configured = configured.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
792        assert_eq!(configured.config.style, CodeFenceStyle::Tilde);
793        assert!(configured.style_explicit);
794
795        let defaulted = MD048CodeFenceStyle::from_config(&Config::default());
796        let defaulted = defaulted.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
797        assert!(!defaulted.style_explicit);
798
799        // The override does not depend on the warning: a defaulted `tilde` is
800        // enforced as backtick just the same.
801        let tilde = MD048CodeFenceStyle::from_config_struct(MD048Config {
802            style: CodeFenceStyle::Tilde,
803        });
804        let content = "# Feature: F\n\n~~~text\nexample\n~~~";
805        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
806        assert_eq!(tilde.fix(&mdg_ctx).unwrap(), "# Feature: F\n\n```text\nexample\n```");
807    }
808
809    #[test]
810    fn test_mdg_consistent_fence_style_ignores_tilde_prevalence() {
811        // Standard resolves `consistent` by prevalence (tilde wins here); MDG
812        // always resolves it to backtick.
813        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
814        let content = "# Feature: Checkout\n\n~~~text\nfirst example\n~~~\n\n~~~text\nsecond example\n~~~\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n  ```json\n  {\"ok\": true}\n  ```";
815
816        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817        assert_eq!(rule.detect_style(&standard_ctx), Some(CodeFenceStyle::Tilde));
818
819        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
820        let warnings = rule.check(&mdg_ctx).unwrap();
821        assert_eq!(warnings.len(), 4, "two tilde blocks, each with two fence lines");
822        assert!(
823            warnings
824                .iter()
825                .all(|warning| warning.message.contains("use ``` instead of ~~~"))
826        );
827
828        let fixed = rule.fix(&mdg_ctx).unwrap();
829        assert!(!fixed.contains("~~~"), "MDG must convert every fence: {fixed:?}");
830
831        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
832        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
833        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
834    }
835
836    #[test]
837    fn test_consistent_style_tie_prefers_backtick() {
838        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
839        // One backtick fence and one tilde fence - tie should prefer backticks
840        let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842        let result = rule.check(&ctx).unwrap();
843
844        // Backticks win due to tie-breaker, so tildes should be flagged
845        assert_eq!(result.len(), 2);
846        assert_eq!(result[0].line, 5);
847        assert_eq!(result[1].line, 7);
848    }
849
850    #[test]
851    fn test_consistent_style_tilde_most_prevalent() {
852        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
853        // Two tilde fences and one backtick fence - tildes are most prevalent
854        let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856        let result = rule.check(&ctx).unwrap();
857
858        // Tildes are most prevalent, so backticks should be flagged
859        assert_eq!(result.len(), 2);
860        assert_eq!(result[0].line, 5);
861        assert_eq!(result[1].line, 7);
862    }
863
864    #[test]
865    fn test_detect_style_backtick() {
866        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
867        let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
868        let style = rule.detect_style(&ctx);
869
870        assert_eq!(style, Some(CodeFenceStyle::Backtick));
871    }
872
873    #[test]
874    fn test_detect_style_tilde() {
875        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
876        let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
877        let style = rule.detect_style(&ctx);
878
879        assert_eq!(style, Some(CodeFenceStyle::Tilde));
880    }
881
882    #[test]
883    fn test_detect_style_none() {
884        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
885        let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
886        let style = rule.detect_style(&ctx);
887
888        assert_eq!(style, None);
889    }
890
891    #[test]
892    fn test_fix_backticks_to_tildes() {
893        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
894        let content = "```\ncode\n```";
895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896        let fixed = rule.fix(&ctx).unwrap();
897
898        assert_eq!(fixed, "~~~\ncode\n~~~");
899    }
900
901    #[test]
902    fn test_fix_tildes_to_backticks() {
903        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
904        let content = "~~~\ncode\n~~~";
905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906        let fixed = rule.fix(&ctx).unwrap();
907
908        assert_eq!(fixed, "```\ncode\n```");
909    }
910
911    #[test]
912    fn test_fix_preserves_fence_length() {
913        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
914        let content = "````\ncode with backtick\n```\ncode\n````";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let fixed = rule.fix(&ctx).unwrap();
917
918        assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
919    }
920
921    #[test]
922    fn test_fix_preserves_language_info() {
923        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
924        let content = "~~~rust\nfn main() {}\n~~~";
925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926        let fixed = rule.fix(&ctx).unwrap();
927
928        assert_eq!(fixed, "```rust\nfn main() {}\n```");
929    }
930
931    #[test]
932    fn test_indented_code_fences() {
933        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
934        let content = "  ```\n  code\n  ```";
935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936        let result = rule.check(&ctx).unwrap();
937
938        assert_eq!(result.len(), 2);
939    }
940
941    #[test]
942    fn test_fix_indented_fences() {
943        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
944        let content = "  ```\n  code\n  ```";
945        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946        let fixed = rule.fix(&ctx).unwrap();
947
948        assert_eq!(fixed, "  ~~~\n  code\n  ~~~");
949    }
950
951    #[test]
952    fn test_nested_fences_not_changed() {
953        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
954        let content = "```\ncode with ``` inside\n```";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956        let fixed = rule.fix(&ctx).unwrap();
957
958        assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
959    }
960
961    #[test]
962    fn test_multiple_code_blocks() {
963        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
964        let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
965        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966        let result = rule.check(&ctx).unwrap();
967
968        assert_eq!(result.len(), 4); // 2 opening + 2 closing fences
969    }
970
971    #[test]
972    fn test_empty_content() {
973        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
974        let content = "";
975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976        let result = rule.check(&ctx).unwrap();
977
978        assert_eq!(result.len(), 0);
979    }
980
981    #[test]
982    fn test_preserve_trailing_newline() {
983        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
984        let content = "~~~\ncode\n~~~\n";
985        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986        let fixed = rule.fix(&ctx).unwrap();
987
988        assert_eq!(fixed, "```\ncode\n```\n");
989    }
990
991    #[test]
992    fn test_no_trailing_newline() {
993        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
994        let content = "~~~\ncode\n~~~";
995        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996        let fixed = rule.fix(&ctx).unwrap();
997
998        assert_eq!(fixed, "```\ncode\n```");
999    }
1000
1001    #[test]
1002    fn test_default_config() {
1003        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1004        let (name, _config) = rule.default_config_section().unwrap();
1005        assert_eq!(name, "MD048");
1006    }
1007
1008    /// Tilde outer fence containing backtick inner fence: converting to backtick
1009    /// style must use a longer fence (4 backticks) to preserve valid nesting.
1010    #[test]
1011    fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
1012        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1013        let content = "~~~text\n```rust\ncode\n```\n~~~";
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let fixed = rule.fix(&ctx).unwrap();
1016
1017        // The outer fence must be 4 backticks to disambiguate from the inner 3-backtick fences.
1018        assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
1019    }
1020
1021    /// check() warns about the outer tilde fences and the fix replacements use the
1022    /// correct (longer) fence length.
1023    #[test]
1024    fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
1025        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1026        let content = "~~~text\n```rust\ncode\n```\n~~~";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028        let warnings = rule.check(&ctx).unwrap();
1029
1030        // Only the outer tilde fences are warned about; inner backtick fences are untouched.
1031        assert_eq!(warnings.len(), 2);
1032        let open_fix = warnings[0].fix.as_ref().unwrap();
1033        let close_fix = warnings[1].fix.as_ref().unwrap();
1034        assert_eq!(open_fix.replacement, "````text");
1035        assert_eq!(close_fix.replacement, "````");
1036    }
1037
1038    /// When the inner backtick fences use 4 backticks, the outer converted fence
1039    /// must use at least 5.
1040    #[test]
1041    fn test_tilde_outer_with_longer_backtick_inner() {
1042        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1043        let content = "~~~text\n````rust\ncode\n````\n~~~";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let fixed = rule.fix(&ctx).unwrap();
1046
1047        assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
1048    }
1049
1050    /// Backtick outer fence containing tilde inner fence: converting to tilde
1051    /// style must use a longer tilde fence.
1052    #[test]
1053    fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
1054        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1055        let content = "```text\n~~~rust\ncode\n~~~\n```";
1056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057        let fixed = rule.fix(&ctx).unwrap();
1058
1059        assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
1060    }
1061
1062    // -----------------------------------------------------------------------
1063    // Fence-length ambiguity detection
1064    // -----------------------------------------------------------------------
1065
1066    /// A backtick block containing only an info-string interior sequence (not bare)
1067    /// is NOT ambiguous: info-string sequences cannot be closing fences per CommonMark,
1068    /// so the bare ``` at line 3 is simply the closing fence — no lengthening needed.
1069    #[test]
1070    fn test_info_string_interior_not_ambiguous() {
1071        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1072        // line 0: ```text   ← opens block (len=3, info="text")
1073        // line 1: ```rust   ← interior content, has info "rust" → cannot close outer
1074        // line 2: code
1075        // line 3: ```       ← bare, len=3 >= 3 → closes block 1 (per CommonMark)
1076        // line 4: ```       ← orphaned second block
1077        let content = "```text\n```rust\ncode\n```\n```";
1078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079        let warnings = rule.check(&ctx).unwrap();
1080
1081        // No ambiguity: ```rust cannot close the outer, and the bare ``` IS the
1082        // unambiguous closing fence. No lengthening needed.
1083        assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
1084    }
1085
1086    /// fix() leaves a block with only info-string interior sequences unchanged.
1087    #[test]
1088    fn test_info_string_interior_fix_unchanged() {
1089        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1090        let content = "```text\n```rust\ncode\n```\n```";
1091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092        let fixed = rule.fix(&ctx).unwrap();
1093
1094        // No conversion needed (already backtick), no lengthening needed → unchanged.
1095        assert_eq!(fixed, content);
1096    }
1097
1098    /// Same for tilde style: an info-string tilde interior is not ambiguous.
1099    #[test]
1100    fn test_tilde_info_string_interior_not_ambiguous() {
1101        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1102        let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104        let fixed = rule.fix(&ctx).unwrap();
1105
1106        // ~~~rust cannot close outer (has info); ~~~ IS the closing fence → unchanged.
1107        assert_eq!(fixed, content);
1108    }
1109
1110    /// No warning when the outer fence is already longer than any interior fence.
1111    #[test]
1112    fn test_no_ambiguity_when_outer_is_longer() {
1113        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1114        let content = "````text\n```rust\ncode\n```\n````";
1115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116        let warnings = rule.check(&ctx).unwrap();
1117
1118        assert_eq!(
1119            warnings.len(),
1120            0,
1121            "should have no warnings when outer is already longer"
1122        );
1123    }
1124
1125    /// An outer block containing a longer info-string sequence and a bare closing
1126    /// fence is not ambiguous: the bare closing fence closes the outer normally,
1127    /// and the info-string sequence is just content.
1128    #[test]
1129    fn test_longer_info_string_interior_not_ambiguous() {
1130        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1131        // line 0: ```text    ← opens block (len=3, info="text")
1132        // line 1: `````rust  ← interior, 5 backticks with info → cannot close outer
1133        // line 2: code
1134        // line 3: `````      ← bare, len=5 >= 3, no info → closes block 1
1135        // line 4: ```        ← orphaned second block
1136        let content = "```text\n`````rust\ncode\n`````\n```";
1137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138        let fixed = rule.fix(&ctx).unwrap();
1139
1140        // `````rust cannot close the outer. ````` IS the closing fence. No lengthening.
1141        assert_eq!(fixed, content);
1142    }
1143
1144    /// Consistent style: info-string interior sequences are not ambiguous.
1145    #[test]
1146    fn test_info_string_interior_consistent_style_no_warning() {
1147        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1148        let content = "```text\n```rust\ncode\n```\n```";
1149        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150        let warnings = rule.check(&ctx).unwrap();
1151
1152        assert_eq!(warnings.len(), 0);
1153    }
1154
1155    // -----------------------------------------------------------------------
1156    // Cross-style conversion: bare-only inner sequence counting
1157    // -----------------------------------------------------------------------
1158
1159    /// Cross-style conversion where outer has NO info string: interior info-string
1160    /// sequences are not counted, only bare sequences are.
1161    #[test]
1162    fn test_cross_style_bare_inner_requires_lengthening() {
1163        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1164        // Outer tilde fence (no info). Interior has a 5-backtick info-string sequence
1165        // AND a 3-backtick bare sequence. Only the bare sequence (len=3) is counted
1166        // → outer becomes 4, not 6.
1167        let content = "~~~\n`````rust\ncode\n```\n~~~";
1168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169        let fixed = rule.fix(&ctx).unwrap();
1170
1171        // 4 backticks (bare seq len=3 → 3+1=4). The 5-backtick info-string seq is
1172        // not counted since it cannot be a closing fence.
1173        assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
1174    }
1175
1176    /// Cross-style conversion where outer HAS an info string but interior has only
1177    /// info-string sequences: no bare inner sequences means no lengthening needed.
1178    /// The outer converts at its natural length.
1179    #[test]
1180    fn test_cross_style_info_only_interior_no_lengthening() {
1181        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1182        // Outer tilde fence (info "text"). Interior has only info-string backtick
1183        // sequences — no bare closing sequence. Info-string sequences cannot be
1184        // closing fences, so no lengthening is needed → outer converts at len=3.
1185        let content = "~~~text\n```rust\nexample\n```rust\n~~~";
1186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187        let fixed = rule.fix(&ctx).unwrap();
1188
1189        assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
1190    }
1191
1192    /// Same-style block where outer has an info string but interior contains only
1193    /// bare sequences SHORTER than the outer fence: no ambiguity, no warning.
1194    #[test]
1195    fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
1196        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1197        // Outer is 4 backticks with info "text". Interior shows raw fence syntax
1198        // (3-backtick bare lines). These are shorter than outer (3 < 4) so they
1199        // cannot close the outer block → no ambiguity.
1200        let content = "````text\n```\nshowing raw fence\n```\n````";
1201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202        let warnings = rule.check(&ctx).unwrap();
1203
1204        assert_eq!(
1205            warnings.len(),
1206            0,
1207            "shorter bare interior sequences cannot close a 4-backtick outer"
1208        );
1209    }
1210
1211    /// Same-style block where outer has NO info string and interior has shorter
1212    /// bare sequences: no ambiguity, no warning.
1213    #[test]
1214    fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
1215        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1216        // Outer is 4 backticks (no info). Interior has 3-backtick bare sequences.
1217        // 3 < 4 → they cannot close the outer block → no ambiguity.
1218        let content = "````\n```\nsome code\n```\n````";
1219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220        let warnings = rule.check(&ctx).unwrap();
1221
1222        assert_eq!(
1223            warnings.len(),
1224            0,
1225            "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
1226        );
1227    }
1228
1229    /// Regression: over-indented inner same-style sequence (4 spaces) is content,
1230    /// not a closing fence, and must not trigger ambiguity warnings.
1231    #[test]
1232    fn test_overindented_inner_sequence_not_ambiguous() {
1233        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1234        let content = "```text\n    ```\ncode\n```";
1235        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236        let warnings = rule.check(&ctx).unwrap();
1237        let fixed = rule.fix(&ctx).unwrap();
1238
1239        assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
1240        assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
1241    }
1242
1243    /// Regression: when converting outer style, over-indented same-style content
1244    /// lines must not be mistaken for an outer closing fence.
1245    #[test]
1246    fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
1247        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1248        let content = "~~~text\n    ~~~\n```rust\ncode\n```\n~~~";
1249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250        let fixed = rule.fix(&ctx).unwrap();
1251
1252        assert_eq!(fixed, "````text\n    ~~~\n```rust\ncode\n```\n````");
1253    }
1254
1255    /// CommonMark: a top-level fence marker indented 4 spaces is an indented code
1256    /// block line, not a fenced code block marker, so MD048 should ignore it.
1257    #[test]
1258    fn test_top_level_four_space_fence_marker_is_ignored() {
1259        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1260        let content = "    ```\n    code\n    ```";
1261        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262        let warnings = rule.check(&ctx).unwrap();
1263        let fixed = rule.fix(&ctx).unwrap();
1264
1265        assert_eq!(warnings.len(), 0);
1266        assert_eq!(fixed, content);
1267    }
1268
1269    // -----------------------------------------------------------------------
1270    // Roundtrip safety tests: fix() output must produce 0 violations
1271    // -----------------------------------------------------------------------
1272
1273    /// Helper: apply fix, then re-check and assert zero violations remain.
1274    fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
1275        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276        let fixed = rule.fix(&ctx).unwrap();
1277        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1278        let remaining = rule.check(&ctx2).unwrap();
1279        assert!(
1280            remaining.is_empty(),
1281            "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
1282            remaining.len(),
1283        );
1284    }
1285
1286    #[test]
1287    fn test_roundtrip_backticks_to_tildes() {
1288        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1289        assert_fix_roundtrip(&rule, "```\ncode\n```");
1290    }
1291
1292    #[test]
1293    fn test_roundtrip_tildes_to_backticks() {
1294        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1295        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
1296    }
1297
1298    #[test]
1299    fn test_roundtrip_mixed_fences_consistent() {
1300        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1301        assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
1302    }
1303
1304    #[test]
1305    fn test_roundtrip_with_info_string() {
1306        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1307        assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
1308    }
1309
1310    #[test]
1311    fn test_roundtrip_longer_fences() {
1312        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1313        assert_fix_roundtrip(&rule, "`````\ncode\n`````");
1314    }
1315
1316    #[test]
1317    fn test_roundtrip_nested_inner_fences() {
1318        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1319        assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
1320    }
1321
1322    #[test]
1323    fn test_roundtrip_indented_fences() {
1324        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1325        assert_fix_roundtrip(&rule, "  ```\n  code\n  ```");
1326    }
1327
1328    #[test]
1329    fn test_roundtrip_multiple_blocks() {
1330        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1331        assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
1332    }
1333
1334    #[test]
1335    fn test_roundtrip_fence_length_ambiguity() {
1336        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1337        assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1338    }
1339
1340    #[test]
1341    fn test_roundtrip_trailing_newline() {
1342        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1343        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1344    }
1345
1346    #[test]
1347    fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1348        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1349        assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1350    }
1351
1352    #[test]
1353    fn test_roundtrip_backtick_outer_tilde_inner() {
1354        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1355        assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1356    }
1357
1358    #[test]
1359    fn test_roundtrip_consistent_tilde_prevalent() {
1360        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1361        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1362    }
1363
1364    /// The combined MD013+MD048 fix must be idempotent: applying the fix twice
1365    /// must produce the same result as applying it once, and must not introduce
1366    /// double blank lines (MD012).
1367    #[test]
1368    fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1369        use crate::fix_coordinator::FixCoordinator;
1370        use crate::rules::Rule;
1371        use crate::rules::md013_line_length::MD013LineLength;
1372
1373        // This is the exact pattern that caused double blank lines when MD048 and
1374        // MD013 were applied together: a tilde outer fence with an inner backtick
1375        // fence inside a list item that is too long.
1376        let content = "\
1377- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1378Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1379or `edition2024` annotations, such as:
1380
1381  ~~~text
1382  ```rust,edition2015
1383  // This only works in 2015.
1384  let try = true;
1385  ```
1386  ~~~
1387
1388### Build options
1389";
1390        let rules: Vec<Box<dyn Rule>> = vec![
1391            Box::new(MD013LineLength::new(80, false, false, false, true)),
1392            Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1393        ];
1394
1395        let mut first_pass = content.to_string();
1396        let coordinator = FixCoordinator::new();
1397        coordinator
1398            .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1399            .expect("fix should not fail");
1400
1401        // No double blank lines after first pass.
1402        let lines: Vec<&str> = first_pass.lines().collect();
1403        for i in 0..lines.len().saturating_sub(1) {
1404            assert!(
1405                !(lines[i].is_empty() && lines[i + 1].is_empty()),
1406                "Double blank at lines {},{} after first pass:\n{first_pass}",
1407                i + 1,
1408                i + 2
1409            );
1410        }
1411
1412        // Second pass must produce identical output (idempotent).
1413        let mut second_pass = first_pass.clone();
1414        let rules2: Vec<Box<dyn Rule>> = vec![
1415            Box::new(MD013LineLength::new(80, false, false, false, true)),
1416            Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1417        ];
1418        let coordinator2 = FixCoordinator::new();
1419        coordinator2
1420            .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1421            .expect("fix should not fail");
1422
1423        assert_eq!(
1424            first_pass, second_pass,
1425            "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_front_matter_fence_does_not_drive_style_detection() {
1431        // A complete fence pair inside front matter must not influence consistent
1432        // style detection. The only real (body) fence is tilde, so the document is
1433        // self-consistent; counting the front-matter backtick pair would flip the
1434        // detected style to backtick and wrongly flag the body.
1435        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1436        let content = "---\ndescription: |\n  ```\n  code\n  ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1437        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438        let result = rule.check(&ctx).unwrap();
1439        assert!(
1440            result.is_empty(),
1441            "front-matter fence must not drive style detection, got: {result:?}"
1442        );
1443    }
1444}
1445
1446#[cfg(test)]
1447mod incompatible_info_tests {
1448    use super::*;
1449    use crate::lint_context::LintContext;
1450
1451    #[test]
1452    fn md048_backtick_info_keeps_tilde_fences_without_unsafe_fixes() {
1453        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1454        for info in ["foo `bar`", r"foo \`bar\`", "`foo", "foo`", "``foo", "内容 `code`"] {
1455            for fence in ["~~~", "~~~~~"] {
1456                for ending in ["\n", "\r\n"] {
1457                    let source = format!("{fence}{info}{ending}literal code{ending}{fence}{ending}");
1458                    let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1459                    let warnings = rule.check(&ctx).unwrap();
1460                    assert_eq!(warnings.len(), 2, "{source}");
1461                    assert!(warnings.iter().all(|warning| warning.fix.is_none()), "{warnings:?}");
1462                    assert_eq!(rule.fix(&ctx).unwrap(), source);
1463                }
1464            }
1465        }
1466    }
1467
1468    #[test]
1469    fn md048_safe_fences_after_incompatible_info_still_convert() {
1470        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1471        let source = "~~~foo `bar`\nfirst\n~~~\n\n~~~text\nsecond\n~~~\n";
1472        let expected = "~~~foo `bar`\nfirst\n~~~\n\n```text\nsecond\n```\n";
1473        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1474        let warnings = rule.check(&ctx).unwrap();
1475        assert_eq!(warnings.len(), 4);
1476        assert!(warnings[..2].iter().all(|warning| warning.fix.is_none()));
1477        assert!(warnings[2..].iter().all(|warning| warning.fix.is_some()));
1478        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1479        let ctx = LintContext::new(expected, MarkdownFlavor::Standard, None);
1480        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1481    }
1482
1483    #[test]
1484    fn md048_unclosed_incompatible_tilde_fence_is_preserved() {
1485        let source = "~~~foo `bar`\nfirst\n```\nrest\n";
1486        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1487        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1488        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1489        assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
1490        assert_eq!(rule.fix(&ctx).unwrap(), source);
1491    }
1492}
1493
1494#[cfg(test)]
1495mod list_fence_content_tests {
1496    use super::*;
1497    use crate::lint_context::LintContext;
1498
1499    #[test]
1500    fn md048_list_fences_account_for_container_indented_inner_markers() {
1501        for (opening, target, style) in [('~', '`', CodeFenceStyle::Backtick), ('`', '~', CodeFenceStyle::Tilde)] {
1502            let rule = MD048CodeFenceStyle::new(style);
1503            for opener_indent in [2, 3] {
1504                for inner_indent in [4, 5, 6, 10] {
1505                    for inner_length in [3, 5] {
1506                        let prefix = " ".repeat(opener_indent);
1507                        let inner_prefix = " ".repeat(inner_indent);
1508                        let fence = opening.to_string().repeat(3);
1509                        let inner = target.to_string().repeat(inner_length);
1510                        let source = format!(
1511                            "- item\n\n{prefix}{fence}text\n{inner_prefix}{inner}\n{prefix}literal code\n{inner_prefix}{inner}\n{prefix}{fence}\n"
1512                        );
1513                        let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1514                        let fixed = rule.fix(&ctx).unwrap();
1515                        let blocks = |content: &str| {
1516                            pulldown_cmark::Parser::new(content)
1517                                .filter_map(|event| match event {
1518                                    pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
1519                                        Some(format!("{kind:?}"))
1520                                    }
1521                                    pulldown_cmark::Event::Text(text) => Some(text.to_string()),
1522                                    _ => None,
1523                                })
1524                                .collect::<Vec<_>>()
1525                        };
1526                        assert_eq!(
1527                            blocks(&source),
1528                            blocks(&fixed),
1529                            "{source} -> {fixed}; cached: {:?}",
1530                            ctx.code_block_details
1531                        );
1532                        assert!(
1533                            !fixed.contains(&format!("{prefix}{fence}text")),
1534                            "must still convert safe container fences: {fixed}"
1535                        );
1536                        let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1537                        assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
1538                        assert_eq!(rule.fix(&ctx).unwrap(), fixed);
1539                    }
1540                }
1541            }
1542        }
1543    }
1544
1545    #[test]
1546    fn md048_native_list_closing_boundary_keeps_following_blocks_separate() {
1547        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1548        let source = "- item\n\n  ~~~text\n    ```\n  literal code\n    ```\n     ~~~\n\n~~~text\nafter\n~~~\n";
1549        let expected = "- item\n\n  ````text\n    ```\n  literal code\n    ```\n     ````\n\n```text\nafter\n```\n";
1550        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1551        assert_eq!(
1552            rule.fix(&ctx).unwrap(),
1553            expected,
1554            "cached: {:?}",
1555            ctx.code_block_details
1556        );
1557    }
1558}
1559
1560#[cfg(test)]
1561mod paired_fix_tests {
1562    use super::*;
1563    use crate::lint_context::LintContext;
1564    use crate::utils::fix_utils::apply_warning_fixes;
1565
1566    /// The two fences of a block are reported separately, and a consumer may
1567    /// apply either fix alone. Each must therefore rewrite both fences.
1568    #[test]
1569    fn md048_each_fence_fix_rewrites_both_fences_of_its_block() {
1570        for (style, document, converted) in [
1571            (
1572                CodeFenceStyle::Backtick,
1573                "~~~text\ncode\n~~~\n\nAfter.\n",
1574                "```text\ncode\n```\n\nAfter.\n",
1575            ),
1576            (
1577                CodeFenceStyle::Tilde,
1578                "```text\ncode\n```\n\nAfter.\n",
1579                "~~~text\ncode\n~~~\n\nAfter.\n",
1580            ),
1581            (
1582                CodeFenceStyle::Backtick,
1583                "~~~markdown\n```rust\ncode\n```\n~~~\n\nAfter.\n",
1584                "````markdown\n```rust\ncode\n```\n````\n\nAfter.\n",
1585            ),
1586            (
1587                CodeFenceStyle::Backtick,
1588                "- item\n\n  ~~~text\n  code\n   ~~~\n\n  After.\n",
1589                "- item\n\n  ```text\n  code\n   ```\n\n  After.\n",
1590            ),
1591            (
1592                CodeFenceStyle::Backtick,
1593                "~~~内容 🎉\ncode\n~~~",
1594                "```内容 🎉\ncode\n```",
1595            ),
1596        ] {
1597            let rule = MD048CodeFenceStyle::new(style);
1598            for (source, expected) in [
1599                (document.to_string(), converted.to_string()),
1600                (document.replace('\n', "\r\n"), converted.replace('\n', "\r\n")),
1601            ] {
1602                let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1603                let warnings = rule.check(&ctx).unwrap();
1604                assert_eq!(warnings.len(), 2, "{source:?}");
1605                for warning in &warnings {
1606                    assert_eq!(
1607                        apply_warning_fixes(&source, std::slice::from_ref(warning)).unwrap(),
1608                        expected,
1609                        "the fix on line {} of {source:?}",
1610                        warning.line
1611                    );
1612                }
1613                // Each fix leads with its own fence, then names the other one.
1614                let [opening, closing] = [&warnings[0], &warnings[1]].map(|warning| warning.fix.as_ref().unwrap());
1615                for (own, other) in [(opening, closing), (closing, opening)] {
1616                    assert_eq!(own.additional_edits.len(), 1, "{source:?}");
1617                    assert_eq!(own.additional_edits[0].range, other.range);
1618                    assert_eq!(own.additional_edits[0].replacement, other.replacement);
1619                    assert!(own.additional_edits[0].additional_edits.is_empty());
1620                }
1621                // Applied together, the repeated edits collapse.
1622                assert_eq!(rule.fix(&ctx).unwrap(), expected);
1623            }
1624        }
1625    }
1626
1627    /// Each block pairs its own fences, and a fence with no partner to rewrite
1628    /// keeps a fix for its line alone.
1629    #[test]
1630    fn md048_fix_pairs_only_the_fences_of_one_block() {
1631        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1632
1633        let source = "~~~text\none\n~~~\n\n~~~text\ntwo\n~~~\n";
1634        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1635        let warnings = rule.check(&ctx).unwrap();
1636        assert_eq!(
1637            warnings.iter().map(|w| (w.line, w.column)).collect::<Vec<_>>(),
1638            [(1, 1), (3, 1), (5, 1), (7, 1)]
1639        );
1640        for (index, expected) in [
1641            "```text\none\n```\n\n~~~text\ntwo\n~~~\n",
1642            "```text\none\n```\n\n~~~text\ntwo\n~~~\n",
1643            "~~~text\none\n~~~\n\n```text\ntwo\n```\n",
1644            "~~~text\none\n~~~\n\n```text\ntwo\n```\n",
1645        ]
1646        .into_iter()
1647        .enumerate()
1648        {
1649            assert_eq!(
1650                apply_warning_fixes(source, &warnings[index..=index]).unwrap(),
1651                expected,
1652                "warning {index}"
1653            );
1654        }
1655
1656        // Unclosed: the opening fence is the only one there is.
1657        let source = "~~~text\n```\ncode\n";
1658        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1659        let warnings = rule.check(&ctx).unwrap();
1660        assert_eq!(warnings.len(), 1);
1661        assert!(warnings[0].fix.as_ref().unwrap().additional_edits.is_empty());
1662        assert_eq!(rule.fix(&ctx).unwrap(), "````text\n```\ncode\n");
1663
1664        // An unclosed block before a closed one does not lend it an opener.
1665        let source = "- item\n\n  ~~~text\n  code\n\n~~~text\ntwo\n~~~\n";
1666        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1667        let warnings = rule.check(&ctx).unwrap();
1668        assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), [3, 6, 8]);
1669        assert_eq!(
1670            apply_warning_fixes(source, &warnings[..1]).unwrap(),
1671            "- item\n\n  ```text\n  code\n\n~~~text\ntwo\n~~~\n"
1672        );
1673        for warning in &warnings[1..] {
1674            assert_eq!(
1675                apply_warning_fixes(source, std::slice::from_ref(warning)).unwrap(),
1676                "- item\n\n  ~~~text\n  code\n\n```text\ntwo\n```\n"
1677            );
1678        }
1679    }
1680}