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/// Rule MD048: Code fence style
80///
81/// See [docs/md048.md](../../docs/md048.md) for full documentation, configuration, and examples.
82#[derive(Clone)]
83pub struct MD048CodeFenceStyle {
84    config: MD048Config,
85    /// Whether `style` came from the configuration rather than from the
86    /// default. MDG enforces backtick either way; this only decides whether the
87    /// user is told that the style they asked for was not adopted.
88    style_explicit: bool,
89}
90
91impl MD048CodeFenceStyle {
92    pub fn new(style: CodeFenceStyle) -> Self {
93        Self {
94            config: MD048Config { style },
95            style_explicit: true,
96        }
97    }
98
99    pub fn from_config_struct(config: MD048Config) -> Self {
100        Self {
101            config,
102            style_explicit: false,
103        }
104    }
105
106    /// Resolve the fence style MD048 should converge on.
107    ///
108    /// A Gherkin Doc String is only ever a backtick fence, so a tilde fence can
109    /// never be one, and a configuration demanding tilde fences cannot be
110    /// satisfied in this flavor. MDG therefore always converges on backtick:
111    /// `consistent` resolves to backtick rather than to whichever marker
112    /// happens to be more common, and an explicit `tilde` is not adopted.
113    fn effective_target_style(&self, ctx: &crate::lint_context::LintContext) -> CodeFenceStyle {
114        if ctx.flavor == MarkdownFlavor::MDG {
115            self.warn_once_about_overridden_style();
116            return CodeFenceStyle::Backtick;
117        }
118
119        match self.config.style {
120            CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
121            style => style,
122        }
123    }
124
125    /// Tell the user once that MDG did not adopt the style they configured.
126    ///
127    /// Only `tilde` is worth reporting: it is the one setting MDG cannot
128    /// satisfy. `consistent` asks for no particular marker, and backtick is
129    /// what MDG picks for it anyway.
130    fn warn_once_about_overridden_style(&self) {
131        if !self.style_explicit || self.config.style != CodeFenceStyle::Tilde {
132            return;
133        }
134
135        MDG_STYLE_OVERRIDE.report(
136            "MD048",
137            "style",
138            "tilde",
139            "backtick",
140            "a Gherkin Doc String is only ever a backtick fence",
141        );
142    }
143
144    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
145        // Count occurrences of each fence style (prevalence-based approach)
146        let mut backtick_count = 0;
147        let mut tilde_count = 0;
148        let mut in_code_block = false;
149        let mut opening_fence_char = '`';
150        let mut opening_fence_len = 0usize;
151
152        for filtered_line in ctx.filtered_lines().skip_front_matter() {
153            let i = filtered_line.line_num - 1;
154            let line = filtered_line.content;
155            // Skip lines inside Azure DevOps colon code fences — they are
156            // opaque content and must not influence backtick/tilde style detection.
157            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
158                continue;
159            }
160
161            // Skip lines inside MyST colon directives — they are structural
162            // containers, not code fences.
163            if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
164                continue;
165            }
166
167            let Some(marker) = parse_fence_marker(line) else {
168                continue;
169            };
170
171            // Skip MyST backtick directives (info string starts with {name})
172            if ctx.flavor.supports_myst_directives()
173                && marker.fence_char == '`'
174                && marker.rest.trim_start().starts_with('{')
175            {
176                continue;
177            }
178
179            if !in_code_block {
180                // Opening fence - count it
181                if marker.fence_char == '`' {
182                    backtick_count += 1;
183                } else {
184                    tilde_count += 1;
185                }
186                in_code_block = true;
187                opening_fence_char = marker.fence_char;
188                opening_fence_len = marker.fence_len;
189            } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
190                in_code_block = false;
191            }
192        }
193
194        // Use the most prevalent style
195        // In case of a tie, prefer backticks (more common, widely supported)
196        if backtick_count >= tilde_count && backtick_count > 0 {
197            Some(CodeFenceStyle::Backtick)
198        } else if tilde_count > 0 {
199            Some(CodeFenceStyle::Tilde)
200        } else {
201            None
202        }
203    }
204}
205
206/// Find the maximum fence length using `target_char` within the body of a fenced block.
207///
208/// Scans from the line after `opening_line` until the matching closing fence
209/// (same `opening_char`, length >= `opening_fence_len`, no trailing content).
210/// Returns the maximum number of consecutive `target_char` characters found at
211/// the start of any interior bare fence line (after stripping leading whitespace).
212///
213/// This is used to compute the minimum fence length needed when converting a
214/// fence from one style to another so that nesting remains unambiguous.
215/// For example, converting a `~~~` outer fence that contains ```` ``` ```` inner
216/// fences to backtick style requires using ```` ```` ```` (4 backticks) so that
217/// the inner 3-backtick bare fences cannot inadvertently close the outer block.
218///
219/// Only bare interior sequences (no trailing content) are counted. Per CommonMark
220/// spec section 4.5, a closing fence must be followed only by optional whitespace —
221/// lines with info strings (e.g. `` ```rust ``) can never be closing fences, so
222/// they never create ambiguity regardless of the outer fence's style.
223fn max_inner_fence_length_of_char(
224    lines: &[&str],
225    opening_line: usize,
226    opening_fence_len: usize,
227    opening_char: char,
228    target_char: char,
229) -> usize {
230    let mut max_len = 0usize;
231
232    for line in lines.iter().skip(opening_line + 1) {
233        let Some(marker) = parse_fence_marker(line) else {
234            continue;
235        };
236
237        // Stop at the closing fence of the outer block.
238        if is_closing_fence(marker, opening_char, opening_fence_len) {
239            break;
240        }
241
242        // Count only bare sequences (no info string). Lines with info strings
243        // can never be closing fences per CommonMark and pose no ambiguity risk.
244        if marker.fence_char == target_char && marker.rest.trim().is_empty() {
245            max_len = max_len.max(marker.fence_len);
246        }
247    }
248
249    max_len
250}
251
252impl Rule for MD048CodeFenceStyle {
253    fn name(&self) -> &'static str {
254        "MD048"
255    }
256
257    fn description(&self) -> &'static str {
258        "Code fence style should be consistent"
259    }
260
261    fn category(&self) -> RuleCategory {
262        RuleCategory::CodeBlock
263    }
264
265    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
266        let mut warnings = Vec::new();
267
268        let target_style = self.effective_target_style(ctx);
269
270        let lines = ctx.raw_lines();
271        let mut in_code_block = false;
272        let mut code_block_fence_char = '`';
273        let mut code_block_fence_len = 0usize;
274        // The fence length to use when writing the converted/lengthened closing fence.
275        // May be longer than the original when inner fences require disambiguation by length.
276        let mut converted_fence_len = 0usize;
277        // True when the opening fence was already the correct style but its length is
278        // ambiguous (interior has same-style fences of equal or greater length).
279        let mut needs_lengthening = false;
280
281        for filtered_line in ctx.filtered_lines().skip_front_matter() {
282            let line_num = filtered_line.line_num - 1;
283            let line = filtered_line.content;
284            // Skip lines inside Azure DevOps colon code fences.
285            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
286                continue;
287            }
288
289            // Skip lines inside MyST colon directives.
290            if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
291                continue;
292            }
293
294            let Some(marker) = parse_fence_marker(line) else {
295                continue;
296            };
297
298            // Skip MyST backtick directives (info string starts with {name})
299            if ctx.flavor.supports_myst_directives()
300                && !in_code_block
301                && marker.fence_char == '`'
302                && marker.rest.trim_start().starts_with('{')
303            {
304                continue;
305            }
306            let fence_char = marker.fence_char;
307            let fence_len = marker.fence_len;
308
309            if !in_code_block {
310                in_code_block = true;
311                code_block_fence_char = fence_char;
312                code_block_fence_len = fence_len;
313
314                let needs_conversion = needs_fence_conversion(fence_char, target_style);
315
316                if needs_conversion {
317                    let target_char = if target_style == CodeFenceStyle::Backtick {
318                        '`'
319                    } else {
320                        '~'
321                    };
322
323                    // Compute how many target_char characters the converted fence needs.
324                    // Must be strictly greater than any inner bare fence of the target style.
325                    let prefix = &line[..marker.fence_start];
326                    let info = marker.rest;
327                    let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, target_char);
328                    converted_fence_len = fence_len.max(max_inner + 1);
329                    needs_lengthening = false;
330
331                    let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
332
333                    let fence_start = marker.fence_start;
334                    let fence_end = fence_start + fence_len;
335                    let (start_line, start_col, end_line, end_col) =
336                        calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
337
338                    warnings.push(LintWarning {
339                        rule_name: Some(self.name().to_string()),
340                        message: format!(
341                            "Code fence style: use {} instead of {}",
342                            if target_style == CodeFenceStyle::Backtick {
343                                "```"
344                            } else {
345                                "~~~"
346                            },
347                            if fence_char == '`' { "```" } else { "~~~" }
348                        ),
349                        line: start_line,
350                        column: start_col,
351                        end_line,
352                        end_column: end_col,
353                        severity: Severity::Warning,
354                        fix: Some(Fix::new(
355                            ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
356                            replacement,
357                        )),
358                    });
359                } else {
360                    // No character conversion is required.
361                    // Check for fence-length ambiguity:
362                    // if the interior contains same-style bare fences of equal or greater
363                    // length, the outer fence cannot be distinguished from an inner
364                    // closing fence and must be made longer.
365                    let prefix = &line[..marker.fence_start];
366                    let info = marker.rest;
367                    let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, fence_char);
368                    if max_inner >= fence_len {
369                        converted_fence_len = max_inner + 1;
370                        needs_lengthening = true;
371
372                        let replacement =
373                            format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
374
375                        let fence_start = marker.fence_start;
376                        let fence_end = fence_start + fence_len;
377                        let (start_line, start_col, end_line, end_col) =
378                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
379
380                        warnings.push(LintWarning {
381                            rule_name: Some(self.name().to_string()),
382                            message: format!(
383                                "Code fence length is ambiguous: outer fence ({fence_len} {}) \
384                                 contains interior fence sequences of equal length; \
385                                 use {converted_fence_len}",
386                                if fence_char == '`' { "backticks" } else { "tildes" },
387                            ),
388                            line: start_line,
389                            column: start_col,
390                            end_line,
391                            end_column: end_col,
392                            severity: Severity::Warning,
393                            fix: Some(Fix::new(
394                                ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
395                                replacement,
396                            )),
397                        });
398                    } else {
399                        converted_fence_len = fence_len;
400                        needs_lengthening = false;
401                    }
402                }
403            } else {
404                // Inside a code block — check if this is the closing fence.
405                let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
406
407                if is_closing {
408                    let needs_conversion = needs_fence_conversion(fence_char, target_style);
409
410                    if needs_conversion || needs_lengthening {
411                        let target_char = if needs_conversion {
412                            if target_style == CodeFenceStyle::Backtick {
413                                '`'
414                            } else {
415                                '~'
416                            }
417                        } else {
418                            fence_char
419                        };
420
421                        let prefix = &line[..marker.fence_start];
422                        let replacement = format!(
423                            "{prefix}{}{}",
424                            target_char.to_string().repeat(converted_fence_len),
425                            marker.rest
426                        );
427
428                        let fence_start = marker.fence_start;
429                        let fence_end = fence_start + fence_len;
430                        let (start_line, start_col, end_line, end_col) =
431                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
432
433                        let message = if needs_conversion {
434                            format!(
435                                "Code fence style: use {} instead of {}",
436                                if target_style == CodeFenceStyle::Backtick {
437                                    "```"
438                                } else {
439                                    "~~~"
440                                },
441                                if fence_char == '`' { "```" } else { "~~~" }
442                            )
443                        } else {
444                            format!(
445                                "Code fence length is ambiguous: closing fence ({fence_len} {}) \
446                                 must match the lengthened outer fence; use {converted_fence_len}",
447                                if fence_char == '`' { "backticks" } else { "tildes" },
448                            )
449                        };
450
451                        warnings.push(LintWarning {
452                            rule_name: Some(self.name().to_string()),
453                            message,
454                            line: start_line,
455                            column: start_col,
456                            end_line,
457                            end_column: end_col,
458                            severity: Severity::Warning,
459                            fix: Some(Fix::new(
460                                ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
461                                replacement,
462                            )),
463                        });
464                    }
465
466                    in_code_block = false;
467                    code_block_fence_len = 0;
468                    converted_fence_len = 0;
469                    needs_lengthening = false;
470                }
471                // Lines inside the block that are not the closing fence are left alone.
472            }
473        }
474
475        Ok(warnings)
476    }
477
478    /// Check if this rule should be skipped for performance
479    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
480        // Skip if content is empty or has no code fence markers
481        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
482    }
483
484    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
485        if self.should_skip(ctx) {
486            return Ok(ctx.content.to_string());
487        }
488        let warnings = self.check(ctx)?;
489        if warnings.is_empty() {
490            return Ok(ctx.content.to_string());
491        }
492        let warnings =
493            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
494        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
495            .map_err(crate::rule::LintError::InvalidInput)
496    }
497
498    fn as_any(&self) -> &dyn std::any::Any {
499        self
500    }
501
502    crate::impl_rule_config_sections!(MD048Config);
503
504    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
505    where
506        Self: Sized,
507    {
508        let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
509        let style_explicit = option_is_explicit(config, "MD048", "style");
510
511        Box::new(Self {
512            config: rule_config,
513            style_explicit,
514        })
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::lint_context::LintContext;
522
523    #[test]
524    fn test_backtick_style_with_backticks() {
525        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
526        let content = "```\ncode\n```";
527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let result = rule.check(&ctx).unwrap();
529
530        assert_eq!(result.len(), 0);
531    }
532
533    #[test]
534    fn test_backtick_style_with_tildes() {
535        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
536        let content = "~~~\ncode\n~~~";
537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538        let result = rule.check(&ctx).unwrap();
539
540        assert_eq!(result.len(), 2); // Opening and closing fence
541        assert!(result[0].message.contains("use ``` instead of ~~~"));
542        assert_eq!(result[0].line, 1);
543        assert_eq!(result[1].line, 3);
544    }
545
546    #[test]
547    fn test_tilde_style_with_tildes() {
548        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
549        let content = "~~~\ncode\n~~~";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        let result = rule.check(&ctx).unwrap();
552
553        assert_eq!(result.len(), 0);
554    }
555
556    #[test]
557    fn test_tilde_style_with_backticks() {
558        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
559        let content = "```\ncode\n```";
560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561        let result = rule.check(&ctx).unwrap();
562
563        assert_eq!(result.len(), 2); // Opening and closing fence
564        assert!(result[0].message.contains("use ~~~ instead of ```"));
565    }
566
567    #[test]
568    fn test_mdg_overrides_tilde_style_to_backtick() {
569        // A Gherkin Doc String is only ever a backtick fence, so a
570        // configuration demanding tilde fences cannot be satisfied in this
571        // flavor. MDG does not adopt it: a Doc String keeps its backticks and a
572        // tilde fence is still corrected into one.
573        let backticks = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n  ```json\n  {\"ok\": true}\n  ```";
574        let tildes =
575            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ~~~text\n  example\n  ~~~";
576
577        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
578        let ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::MDG, None);
579        assert!(rule.check(&ctx).unwrap().is_empty());
580        assert_eq!(rule.fix(&ctx).unwrap(), backticks);
581
582        let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
583        assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
584        let fixed = rule.fix(&ctx).unwrap();
585        assert_eq!(
586            fixed,
587            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ```text\n  example\n  ```"
588        );
589
590        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
591        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
592        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
593
594        // Standard honours `tilde` exactly as before, in both directions.
595        let standard_ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::Standard, None);
596        assert_eq!(rule.check(&standard_ctx).unwrap().len(), 2);
597        assert!(rule.fix(&standard_ctx).unwrap().contains("~~~json"));
598
599        let standard_ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::Standard, None);
600        assert!(rule.check(&standard_ctx).unwrap().is_empty());
601        assert_eq!(rule.fix(&standard_ctx).unwrap(), tildes);
602    }
603
604    #[test]
605    fn test_mdg_applies_backtick_style() {
606        // The explicit backtick style is the one MDG can satisfy, so it is
607        // applied unchanged.
608        let tildes =
609            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ~~~text\n  example\n  ~~~";
610
611        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
612        let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
613        assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
614        assert_eq!(
615            rule.fix(&ctx).unwrap(),
616            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n  ```text\n  example\n  ```"
617        );
618    }
619
620    #[test]
621    fn test_mdg_tilde_style_still_disambiguates_fence_length() {
622        // The override decides which marker MD048 converges on; it does not
623        // touch the length arithmetic that keeps a converted outer fence from
624        // being closed by an interior one.
625        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
626        let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n~~~\n```rust\ncode\n```\n~~~";
627
628        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
629        let fixed = rule.fix(&mdg_ctx).unwrap();
630        assert_eq!(
631            fixed,
632            "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n````\n```rust\ncode\n```\n````"
633        );
634
635        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
636        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
637
638        // Standard honours `tilde`: the outer fence is already a tilde fence,
639        // so nothing is converted or lengthened.
640        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641        assert!(rule.check(&standard_ctx).unwrap().is_empty());
642        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
643    }
644
645    #[test]
646    fn test_from_config_records_whether_style_was_configured() {
647        // The MDG override applies either way, but the warning is only for a
648        // style the user actually asked for, so a configured style has to be
649        // told apart from a defaulted one.
650        use crate::config::Config;
651        use std::collections::BTreeMap;
652
653        let mut values = BTreeMap::new();
654        values.insert("style".to_string(), toml::Value::String("tilde".to_string()));
655        let mut config = Config::default();
656        config.rules.insert(
657            "MD048".to_string(),
658            crate::config::RuleConfig { severity: None, values },
659        );
660
661        let configured = MD048CodeFenceStyle::from_config(&config);
662        let configured = configured.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
663        assert_eq!(configured.config.style, CodeFenceStyle::Tilde);
664        assert!(configured.style_explicit);
665
666        let defaulted = MD048CodeFenceStyle::from_config(&Config::default());
667        let defaulted = defaulted.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
668        assert!(!defaulted.style_explicit);
669
670        // The override does not depend on the warning: a defaulted `tilde` is
671        // enforced as backtick just the same.
672        let tilde = MD048CodeFenceStyle::from_config_struct(MD048Config {
673            style: CodeFenceStyle::Tilde,
674        });
675        let content = "# Feature: F\n\n~~~text\nexample\n~~~";
676        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
677        assert_eq!(tilde.fix(&mdg_ctx).unwrap(), "# Feature: F\n\n```text\nexample\n```");
678    }
679
680    #[test]
681    fn test_mdg_consistent_fence_style_ignores_tilde_prevalence() {
682        // Standard resolves `consistent` by prevalence (tilde wins here); MDG
683        // always resolves it to backtick.
684        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
685        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  ```";
686
687        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688        assert_eq!(rule.detect_style(&standard_ctx), Some(CodeFenceStyle::Tilde));
689
690        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
691        let warnings = rule.check(&mdg_ctx).unwrap();
692        assert_eq!(warnings.len(), 4, "two tilde blocks, each with two fence lines");
693        assert!(
694            warnings
695                .iter()
696                .all(|warning| warning.message.contains("use ``` instead of ~~~"))
697        );
698
699        let fixed = rule.fix(&mdg_ctx).unwrap();
700        assert!(!fixed.contains("~~~"), "MDG must convert every fence: {fixed:?}");
701
702        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
703        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
704        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
705    }
706
707    #[test]
708    fn test_consistent_style_tie_prefers_backtick() {
709        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
710        // One backtick fence and one tilde fence - tie should prefer backticks
711        let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713        let result = rule.check(&ctx).unwrap();
714
715        // Backticks win due to tie-breaker, so tildes should be flagged
716        assert_eq!(result.len(), 2);
717        assert_eq!(result[0].line, 5);
718        assert_eq!(result[1].line, 7);
719    }
720
721    #[test]
722    fn test_consistent_style_tilde_most_prevalent() {
723        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
724        // Two tilde fences and one backtick fence - tildes are most prevalent
725        let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
726        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727        let result = rule.check(&ctx).unwrap();
728
729        // Tildes are most prevalent, so backticks should be flagged
730        assert_eq!(result.len(), 2);
731        assert_eq!(result[0].line, 5);
732        assert_eq!(result[1].line, 7);
733    }
734
735    #[test]
736    fn test_detect_style_backtick() {
737        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
738        let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
739        let style = rule.detect_style(&ctx);
740
741        assert_eq!(style, Some(CodeFenceStyle::Backtick));
742    }
743
744    #[test]
745    fn test_detect_style_tilde() {
746        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
747        let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
748        let style = rule.detect_style(&ctx);
749
750        assert_eq!(style, Some(CodeFenceStyle::Tilde));
751    }
752
753    #[test]
754    fn test_detect_style_none() {
755        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
756        let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
757        let style = rule.detect_style(&ctx);
758
759        assert_eq!(style, None);
760    }
761
762    #[test]
763    fn test_fix_backticks_to_tildes() {
764        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
765        let content = "```\ncode\n```";
766        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767        let fixed = rule.fix(&ctx).unwrap();
768
769        assert_eq!(fixed, "~~~\ncode\n~~~");
770    }
771
772    #[test]
773    fn test_fix_tildes_to_backticks() {
774        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
775        let content = "~~~\ncode\n~~~";
776        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777        let fixed = rule.fix(&ctx).unwrap();
778
779        assert_eq!(fixed, "```\ncode\n```");
780    }
781
782    #[test]
783    fn test_fix_preserves_fence_length() {
784        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
785        let content = "````\ncode with backtick\n```\ncode\n````";
786        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787        let fixed = rule.fix(&ctx).unwrap();
788
789        assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
790    }
791
792    #[test]
793    fn test_fix_preserves_language_info() {
794        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
795        let content = "~~~rust\nfn main() {}\n~~~";
796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797        let fixed = rule.fix(&ctx).unwrap();
798
799        assert_eq!(fixed, "```rust\nfn main() {}\n```");
800    }
801
802    #[test]
803    fn test_indented_code_fences() {
804        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
805        let content = "  ```\n  code\n  ```";
806        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807        let result = rule.check(&ctx).unwrap();
808
809        assert_eq!(result.len(), 2);
810    }
811
812    #[test]
813    fn test_fix_indented_fences() {
814        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
815        let content = "  ```\n  code\n  ```";
816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817        let fixed = rule.fix(&ctx).unwrap();
818
819        assert_eq!(fixed, "  ~~~\n  code\n  ~~~");
820    }
821
822    #[test]
823    fn test_nested_fences_not_changed() {
824        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
825        let content = "```\ncode with ``` inside\n```";
826        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827        let fixed = rule.fix(&ctx).unwrap();
828
829        assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
830    }
831
832    #[test]
833    fn test_multiple_code_blocks() {
834        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
835        let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838
839        assert_eq!(result.len(), 4); // 2 opening + 2 closing fences
840    }
841
842    #[test]
843    fn test_empty_content() {
844        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
845        let content = "";
846        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847        let result = rule.check(&ctx).unwrap();
848
849        assert_eq!(result.len(), 0);
850    }
851
852    #[test]
853    fn test_preserve_trailing_newline() {
854        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
855        let content = "~~~\ncode\n~~~\n";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857        let fixed = rule.fix(&ctx).unwrap();
858
859        assert_eq!(fixed, "```\ncode\n```\n");
860    }
861
862    #[test]
863    fn test_no_trailing_newline() {
864        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
865        let content = "~~~\ncode\n~~~";
866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867        let fixed = rule.fix(&ctx).unwrap();
868
869        assert_eq!(fixed, "```\ncode\n```");
870    }
871
872    #[test]
873    fn test_default_config() {
874        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
875        let (name, _config) = rule.default_config_section().unwrap();
876        assert_eq!(name, "MD048");
877    }
878
879    /// Tilde outer fence containing backtick inner fence: converting to backtick
880    /// style must use a longer fence (4 backticks) to preserve valid nesting.
881    #[test]
882    fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
883        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
884        let content = "~~~text\n```rust\ncode\n```\n~~~";
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886        let fixed = rule.fix(&ctx).unwrap();
887
888        // The outer fence must be 4 backticks to disambiguate from the inner 3-backtick fences.
889        assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
890    }
891
892    /// check() warns about the outer tilde fences and the fix replacements use the
893    /// correct (longer) fence length.
894    #[test]
895    fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
896        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
897        let content = "~~~text\n```rust\ncode\n```\n~~~";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let warnings = rule.check(&ctx).unwrap();
900
901        // Only the outer tilde fences are warned about; inner backtick fences are untouched.
902        assert_eq!(warnings.len(), 2);
903        let open_fix = warnings[0].fix.as_ref().unwrap();
904        let close_fix = warnings[1].fix.as_ref().unwrap();
905        assert_eq!(open_fix.replacement, "````text");
906        assert_eq!(close_fix.replacement, "````");
907    }
908
909    /// When the inner backtick fences use 4 backticks, the outer converted fence
910    /// must use at least 5.
911    #[test]
912    fn test_tilde_outer_with_longer_backtick_inner() {
913        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
914        let content = "~~~text\n````rust\ncode\n````\n~~~";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let fixed = rule.fix(&ctx).unwrap();
917
918        assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
919    }
920
921    /// Backtick outer fence containing tilde inner fence: converting to tilde
922    /// style must use a longer tilde fence.
923    #[test]
924    fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
925        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
926        let content = "```text\n~~~rust\ncode\n~~~\n```";
927        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928        let fixed = rule.fix(&ctx).unwrap();
929
930        assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
931    }
932
933    // -----------------------------------------------------------------------
934    // Fence-length ambiguity detection
935    // -----------------------------------------------------------------------
936
937    /// A backtick block containing only an info-string interior sequence (not bare)
938    /// is NOT ambiguous: info-string sequences cannot be closing fences per CommonMark,
939    /// so the bare ``` at line 3 is simply the closing fence — no lengthening needed.
940    #[test]
941    fn test_info_string_interior_not_ambiguous() {
942        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
943        // line 0: ```text   ← opens block (len=3, info="text")
944        // line 1: ```rust   ← interior content, has info "rust" → cannot close outer
945        // line 2: code
946        // line 3: ```       ← bare, len=3 >= 3 → closes block 1 (per CommonMark)
947        // line 4: ```       ← orphaned second block
948        let content = "```text\n```rust\ncode\n```\n```";
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950        let warnings = rule.check(&ctx).unwrap();
951
952        // No ambiguity: ```rust cannot close the outer, and the bare ``` IS the
953        // unambiguous closing fence. No lengthening needed.
954        assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
955    }
956
957    /// fix() leaves a block with only info-string interior sequences unchanged.
958    #[test]
959    fn test_info_string_interior_fix_unchanged() {
960        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
961        let content = "```text\n```rust\ncode\n```\n```";
962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963        let fixed = rule.fix(&ctx).unwrap();
964
965        // No conversion needed (already backtick), no lengthening needed → unchanged.
966        assert_eq!(fixed, content);
967    }
968
969    /// Same for tilde style: an info-string tilde interior is not ambiguous.
970    #[test]
971    fn test_tilde_info_string_interior_not_ambiguous() {
972        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
973        let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let fixed = rule.fix(&ctx).unwrap();
976
977        // ~~~rust cannot close outer (has info); ~~~ IS the closing fence → unchanged.
978        assert_eq!(fixed, content);
979    }
980
981    /// No warning when the outer fence is already longer than any interior fence.
982    #[test]
983    fn test_no_ambiguity_when_outer_is_longer() {
984        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
985        let content = "````text\n```rust\ncode\n```\n````";
986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987        let warnings = rule.check(&ctx).unwrap();
988
989        assert_eq!(
990            warnings.len(),
991            0,
992            "should have no warnings when outer is already longer"
993        );
994    }
995
996    /// An outer block containing a longer info-string sequence and a bare closing
997    /// fence is not ambiguous: the bare closing fence closes the outer normally,
998    /// and the info-string sequence is just content.
999    #[test]
1000    fn test_longer_info_string_interior_not_ambiguous() {
1001        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1002        // line 0: ```text    ← opens block (len=3, info="text")
1003        // line 1: `````rust  ← interior, 5 backticks with info → cannot close outer
1004        // line 2: code
1005        // line 3: `````      ← bare, len=5 >= 3, no info → closes block 1
1006        // line 4: ```        ← orphaned second block
1007        let content = "```text\n`````rust\ncode\n`````\n```";
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009        let fixed = rule.fix(&ctx).unwrap();
1010
1011        // `````rust cannot close the outer. ````` IS the closing fence. No lengthening.
1012        assert_eq!(fixed, content);
1013    }
1014
1015    /// Consistent style: info-string interior sequences are not ambiguous.
1016    #[test]
1017    fn test_info_string_interior_consistent_style_no_warning() {
1018        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1019        let content = "```text\n```rust\ncode\n```\n```";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021        let warnings = rule.check(&ctx).unwrap();
1022
1023        assert_eq!(warnings.len(), 0);
1024    }
1025
1026    // -----------------------------------------------------------------------
1027    // Cross-style conversion: bare-only inner sequence counting
1028    // -----------------------------------------------------------------------
1029
1030    /// Cross-style conversion where outer has NO info string: interior info-string
1031    /// sequences are not counted, only bare sequences are.
1032    #[test]
1033    fn test_cross_style_bare_inner_requires_lengthening() {
1034        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1035        // Outer tilde fence (no info). Interior has a 5-backtick info-string sequence
1036        // AND a 3-backtick bare sequence. Only the bare sequence (len=3) is counted
1037        // → outer becomes 4, not 6.
1038        let content = "~~~\n`````rust\ncode\n```\n~~~";
1039        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040        let fixed = rule.fix(&ctx).unwrap();
1041
1042        // 4 backticks (bare seq len=3 → 3+1=4). The 5-backtick info-string seq is
1043        // not counted since it cannot be a closing fence.
1044        assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
1045    }
1046
1047    /// Cross-style conversion where outer HAS an info string but interior has only
1048    /// info-string sequences: no bare inner sequences means no lengthening needed.
1049    /// The outer converts at its natural length.
1050    #[test]
1051    fn test_cross_style_info_only_interior_no_lengthening() {
1052        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1053        // Outer tilde fence (info "text"). Interior has only info-string backtick
1054        // sequences — no bare closing sequence. Info-string sequences cannot be
1055        // closing fences, so no lengthening is needed → outer converts at len=3.
1056        let content = "~~~text\n```rust\nexample\n```rust\n~~~";
1057        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058        let fixed = rule.fix(&ctx).unwrap();
1059
1060        assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
1061    }
1062
1063    /// Same-style block where outer has an info string but interior contains only
1064    /// bare sequences SHORTER than the outer fence: no ambiguity, no warning.
1065    #[test]
1066    fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
1067        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1068        // Outer is 4 backticks with info "text". Interior shows raw fence syntax
1069        // (3-backtick bare lines). These are shorter than outer (3 < 4) so they
1070        // cannot close the outer block → no ambiguity.
1071        let content = "````text\n```\nshowing raw fence\n```\n````";
1072        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073        let warnings = rule.check(&ctx).unwrap();
1074
1075        assert_eq!(
1076            warnings.len(),
1077            0,
1078            "shorter bare interior sequences cannot close a 4-backtick outer"
1079        );
1080    }
1081
1082    /// Same-style block where outer has NO info string and interior has shorter
1083    /// bare sequences: no ambiguity, no warning.
1084    #[test]
1085    fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
1086        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1087        // Outer is 4 backticks (no info). Interior has 3-backtick bare sequences.
1088        // 3 < 4 → they cannot close the outer block → no ambiguity.
1089        let content = "````\n```\nsome code\n```\n````";
1090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091        let warnings = rule.check(&ctx).unwrap();
1092
1093        assert_eq!(
1094            warnings.len(),
1095            0,
1096            "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
1097        );
1098    }
1099
1100    /// Regression: over-indented inner same-style sequence (4 spaces) is content,
1101    /// not a closing fence, and must not trigger ambiguity warnings.
1102    #[test]
1103    fn test_overindented_inner_sequence_not_ambiguous() {
1104        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1105        let content = "```text\n    ```\ncode\n```";
1106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107        let warnings = rule.check(&ctx).unwrap();
1108        let fixed = rule.fix(&ctx).unwrap();
1109
1110        assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
1111        assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
1112    }
1113
1114    /// Regression: when converting outer style, over-indented same-style content
1115    /// lines must not be mistaken for an outer closing fence.
1116    #[test]
1117    fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
1118        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1119        let content = "~~~text\n    ~~~\n```rust\ncode\n```\n~~~";
1120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121        let fixed = rule.fix(&ctx).unwrap();
1122
1123        assert_eq!(fixed, "````text\n    ~~~\n```rust\ncode\n```\n````");
1124    }
1125
1126    /// CommonMark: a top-level fence marker indented 4 spaces is an indented code
1127    /// block line, not a fenced code block marker, so MD048 should ignore it.
1128    #[test]
1129    fn test_top_level_four_space_fence_marker_is_ignored() {
1130        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1131        let content = "    ```\n    code\n    ```";
1132        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133        let warnings = rule.check(&ctx).unwrap();
1134        let fixed = rule.fix(&ctx).unwrap();
1135
1136        assert_eq!(warnings.len(), 0);
1137        assert_eq!(fixed, content);
1138    }
1139
1140    // -----------------------------------------------------------------------
1141    // Roundtrip safety tests: fix() output must produce 0 violations
1142    // -----------------------------------------------------------------------
1143
1144    /// Helper: apply fix, then re-check and assert zero violations remain.
1145    fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
1146        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147        let fixed = rule.fix(&ctx).unwrap();
1148        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1149        let remaining = rule.check(&ctx2).unwrap();
1150        assert!(
1151            remaining.is_empty(),
1152            "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
1153            remaining.len(),
1154        );
1155    }
1156
1157    #[test]
1158    fn test_roundtrip_backticks_to_tildes() {
1159        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1160        assert_fix_roundtrip(&rule, "```\ncode\n```");
1161    }
1162
1163    #[test]
1164    fn test_roundtrip_tildes_to_backticks() {
1165        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1166        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
1167    }
1168
1169    #[test]
1170    fn test_roundtrip_mixed_fences_consistent() {
1171        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1172        assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
1173    }
1174
1175    #[test]
1176    fn test_roundtrip_with_info_string() {
1177        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1178        assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
1179    }
1180
1181    #[test]
1182    fn test_roundtrip_longer_fences() {
1183        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1184        assert_fix_roundtrip(&rule, "`````\ncode\n`````");
1185    }
1186
1187    #[test]
1188    fn test_roundtrip_nested_inner_fences() {
1189        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1190        assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
1191    }
1192
1193    #[test]
1194    fn test_roundtrip_indented_fences() {
1195        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1196        assert_fix_roundtrip(&rule, "  ```\n  code\n  ```");
1197    }
1198
1199    #[test]
1200    fn test_roundtrip_multiple_blocks() {
1201        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1202        assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
1203    }
1204
1205    #[test]
1206    fn test_roundtrip_fence_length_ambiguity() {
1207        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1208        assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1209    }
1210
1211    #[test]
1212    fn test_roundtrip_trailing_newline() {
1213        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1214        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1215    }
1216
1217    #[test]
1218    fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1219        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1220        assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1221    }
1222
1223    #[test]
1224    fn test_roundtrip_backtick_outer_tilde_inner() {
1225        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1226        assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1227    }
1228
1229    #[test]
1230    fn test_roundtrip_consistent_tilde_prevalent() {
1231        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1232        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1233    }
1234
1235    /// The combined MD013+MD048 fix must be idempotent: applying the fix twice
1236    /// must produce the same result as applying it once, and must not introduce
1237    /// double blank lines (MD012).
1238    #[test]
1239    fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1240        use crate::fix_coordinator::FixCoordinator;
1241        use crate::rules::Rule;
1242        use crate::rules::md013_line_length::MD013LineLength;
1243
1244        // This is the exact pattern that caused double blank lines when MD048 and
1245        // MD013 were applied together: a tilde outer fence with an inner backtick
1246        // fence inside a list item that is too long.
1247        let content = "\
1248- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1249Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1250or `edition2024` annotations, such as:
1251
1252  ~~~text
1253  ```rust,edition2015
1254  // This only works in 2015.
1255  let try = true;
1256  ```
1257  ~~~
1258
1259### Build options
1260";
1261        let rules: Vec<Box<dyn Rule>> = vec![
1262            Box::new(MD013LineLength::new(80, false, false, false, true)),
1263            Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1264        ];
1265
1266        let mut first_pass = content.to_string();
1267        let coordinator = FixCoordinator::new();
1268        coordinator
1269            .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1270            .expect("fix should not fail");
1271
1272        // No double blank lines after first pass.
1273        let lines: Vec<&str> = first_pass.lines().collect();
1274        for i in 0..lines.len().saturating_sub(1) {
1275            assert!(
1276                !(lines[i].is_empty() && lines[i + 1].is_empty()),
1277                "Double blank at lines {},{} after first pass:\n{first_pass}",
1278                i + 1,
1279                i + 2
1280            );
1281        }
1282
1283        // Second pass must produce identical output (idempotent).
1284        let mut second_pass = first_pass.clone();
1285        let rules2: Vec<Box<dyn Rule>> = vec![
1286            Box::new(MD013LineLength::new(80, false, false, false, true)),
1287            Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1288        ];
1289        let coordinator2 = FixCoordinator::new();
1290        coordinator2
1291            .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1292            .expect("fix should not fail");
1293
1294        assert_eq!(
1295            first_pass, second_pass,
1296            "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1297        );
1298    }
1299
1300    #[test]
1301    fn test_front_matter_fence_does_not_drive_style_detection() {
1302        // A complete fence pair inside front matter must not influence consistent
1303        // style detection. The only real (body) fence is tilde, so the document is
1304        // self-consistent; counting the front-matter backtick pair would flip the
1305        // detected style to backtick and wrongly flag the body.
1306        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1307        let content = "---\ndescription: |\n  ```\n  code\n  ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let result = rule.check(&ctx).unwrap();
1310        assert!(
1311            result.is_empty(),
1312            "front-matter fence must not drive style detection, got: {result:?}"
1313        );
1314    }
1315}