Skip to main content

rumdl_lib/rules/
md048_code_fence_style.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::code_fence_utils::CodeFenceStyle;
4use crate::utils::range_utils::calculate_match_range;
5use toml;
6
7mod md048_config;
8use md048_config::MD048Config;
9
10/// Parsed fence marker candidate on a single line.
11#[derive(Debug, Clone, Copy)]
12struct FenceMarker<'a> {
13    /// Fence character (` or ~).
14    fence_char: char,
15    /// Length of the contiguous fence run.
16    fence_len: usize,
17    /// Byte index where the fence run starts.
18    fence_start: usize,
19    /// Remaining text after the fence run.
20    rest: &'a str,
21}
22
23/// Parse a candidate fence marker line.
24///
25/// CommonMark only recognizes fenced code block markers when indented by at most
26/// three spaces (outside container contexts). This parser enforces that bound and
27/// returns the marker run and trailing text for further opening/closing checks.
28#[inline]
29fn parse_fence_marker(line: &str) -> Option<FenceMarker<'_>> {
30    let bytes = line.as_bytes();
31    let mut pos = 0usize;
32    while pos < bytes.len() && bytes[pos] == b' ' {
33        pos += 1;
34    }
35    if pos > 3 {
36        return None;
37    }
38
39    let fence_char = match bytes.get(pos).copied() {
40        Some(b'`') => '`',
41        Some(b'~') => '~',
42        _ => return None,
43    };
44
45    let marker = if fence_char == '`' { b'`' } else { b'~' };
46    let mut end = pos;
47    while end < bytes.len() && bytes[end] == marker {
48        end += 1;
49    }
50    let fence_len = end - pos;
51    if fence_len < 3 {
52        return None;
53    }
54
55    Some(FenceMarker {
56        fence_char,
57        fence_len,
58        fence_start: pos,
59        rest: &line[end..],
60    })
61}
62
63#[inline]
64fn is_closing_fence(marker: FenceMarker<'_>, opening_fence_char: char, opening_fence_len: usize) -> bool {
65    marker.fence_char == opening_fence_char && marker.fence_len >= opening_fence_len && marker.rest.trim().is_empty()
66}
67
68/// Rule MD048: Code fence style
69///
70/// See [docs/md048.md](../../docs/md048.md) for full documentation, configuration, and examples.
71#[derive(Clone)]
72pub struct MD048CodeFenceStyle {
73    config: MD048Config,
74}
75
76impl MD048CodeFenceStyle {
77    pub fn new(style: CodeFenceStyle) -> Self {
78        Self {
79            config: MD048Config { style },
80        }
81    }
82
83    pub fn from_config_struct(config: MD048Config) -> Self {
84        Self { config }
85    }
86
87    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
88        // Count occurrences of each fence style (prevalence-based approach)
89        let mut backtick_count = 0;
90        let mut tilde_count = 0;
91        let mut in_code_block = false;
92        let mut opening_fence_char = '`';
93        let mut opening_fence_len = 0usize;
94
95        for filtered_line in ctx.filtered_lines().skip_front_matter() {
96            let i = filtered_line.line_num - 1;
97            let line = filtered_line.content;
98            // Skip lines inside Azure DevOps colon code fences — they are
99            // opaque content and must not influence backtick/tilde style detection.
100            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
101                continue;
102            }
103
104            // Skip lines inside MyST colon directives — they are structural
105            // containers, not code fences.
106            if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
107                continue;
108            }
109
110            let Some(marker) = parse_fence_marker(line) else {
111                continue;
112            };
113
114            // Skip MyST backtick directives (info string starts with {name})
115            if ctx.flavor.supports_myst_directives()
116                && marker.fence_char == '`'
117                && marker.rest.trim_start().starts_with('{')
118            {
119                continue;
120            }
121
122            if !in_code_block {
123                // Opening fence - count it
124                if marker.fence_char == '`' {
125                    backtick_count += 1;
126                } else {
127                    tilde_count += 1;
128                }
129                in_code_block = true;
130                opening_fence_char = marker.fence_char;
131                opening_fence_len = marker.fence_len;
132            } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
133                in_code_block = false;
134            }
135        }
136
137        // Use the most prevalent style
138        // In case of a tie, prefer backticks (more common, widely supported)
139        if backtick_count >= tilde_count && backtick_count > 0 {
140            Some(CodeFenceStyle::Backtick)
141        } else if tilde_count > 0 {
142            Some(CodeFenceStyle::Tilde)
143        } else {
144            None
145        }
146    }
147}
148
149/// Find the maximum fence length using `target_char` within the body of a fenced block.
150///
151/// Scans from the line after `opening_line` until the matching closing fence
152/// (same `opening_char`, length >= `opening_fence_len`, no trailing content).
153/// Returns the maximum number of consecutive `target_char` characters found at
154/// the start of any interior bare fence line (after stripping leading whitespace).
155///
156/// This is used to compute the minimum fence length needed when converting a
157/// fence from one style to another so that nesting remains unambiguous.
158/// For example, converting a `~~~` outer fence that contains ```` ``` ```` inner
159/// fences to backtick style requires using ```` ```` ```` (4 backticks) so that
160/// the inner 3-backtick bare fences cannot inadvertently close the outer block.
161///
162/// Only bare interior sequences (no trailing content) are counted. Per CommonMark
163/// spec section 4.5, a closing fence must be followed only by optional whitespace —
164/// lines with info strings (e.g. `` ```rust ``) can never be closing fences, so
165/// they never create ambiguity regardless of the outer fence's style.
166fn max_inner_fence_length_of_char(
167    lines: &[&str],
168    opening_line: usize,
169    opening_fence_len: usize,
170    opening_char: char,
171    target_char: char,
172) -> usize {
173    let mut max_len = 0usize;
174
175    for line in lines.iter().skip(opening_line + 1) {
176        let Some(marker) = parse_fence_marker(line) else {
177            continue;
178        };
179
180        // Stop at the closing fence of the outer block.
181        if is_closing_fence(marker, opening_char, opening_fence_len) {
182            break;
183        }
184
185        // Count only bare sequences (no info string). Lines with info strings
186        // can never be closing fences per CommonMark and pose no ambiguity risk.
187        if marker.fence_char == target_char && marker.rest.trim().is_empty() {
188            max_len = max_len.max(marker.fence_len);
189        }
190    }
191
192    max_len
193}
194
195impl Rule for MD048CodeFenceStyle {
196    fn name(&self) -> &'static str {
197        "MD048"
198    }
199
200    fn description(&self) -> &'static str {
201        "Code fence style should be consistent"
202    }
203
204    fn category(&self) -> RuleCategory {
205        RuleCategory::CodeBlock
206    }
207
208    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
209        let mut warnings = Vec::new();
210
211        let target_style = match self.config.style {
212            CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
213            _ => self.config.style,
214        };
215
216        let lines = ctx.raw_lines();
217        let mut in_code_block = false;
218        let mut code_block_fence_char = '`';
219        let mut code_block_fence_len = 0usize;
220        // The fence length to use when writing the converted/lengthened closing fence.
221        // May be longer than the original when inner fences require disambiguation by length.
222        let mut converted_fence_len = 0usize;
223        // True when the opening fence was already the correct style but its length is
224        // ambiguous (interior has same-style fences of equal or greater length).
225        let mut needs_lengthening = false;
226
227        for filtered_line in ctx.filtered_lines().skip_front_matter() {
228            let line_num = filtered_line.line_num - 1;
229            let line = filtered_line.content;
230            // Skip lines inside Azure DevOps colon code fences.
231            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
232                continue;
233            }
234
235            // Skip lines inside MyST colon directives.
236            if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
237                continue;
238            }
239
240            let Some(marker) = parse_fence_marker(line) else {
241                continue;
242            };
243
244            // Skip MyST backtick directives (info string starts with {name})
245            if ctx.flavor.supports_myst_directives()
246                && !in_code_block
247                && marker.fence_char == '`'
248                && marker.rest.trim_start().starts_with('{')
249            {
250                continue;
251            }
252            let fence_char = marker.fence_char;
253            let fence_len = marker.fence_len;
254
255            if !in_code_block {
256                in_code_block = true;
257                code_block_fence_char = fence_char;
258                code_block_fence_len = fence_len;
259
260                let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
261                    || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
262
263                if needs_conversion {
264                    let target_char = if target_style == CodeFenceStyle::Backtick {
265                        '`'
266                    } else {
267                        '~'
268                    };
269
270                    // Compute how many target_char characters the converted fence needs.
271                    // Must be strictly greater than any inner bare fence of the target style.
272                    let prefix = &line[..marker.fence_start];
273                    let info = marker.rest;
274                    let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, target_char);
275                    converted_fence_len = fence_len.max(max_inner + 1);
276                    needs_lengthening = false;
277
278                    let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
279
280                    let fence_start = marker.fence_start;
281                    let fence_end = fence_start + fence_len;
282                    let (start_line, start_col, end_line, end_col) =
283                        calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
284
285                    warnings.push(LintWarning {
286                        rule_name: Some(self.name().to_string()),
287                        message: format!(
288                            "Code fence style: use {} instead of {}",
289                            if target_style == CodeFenceStyle::Backtick {
290                                "```"
291                            } else {
292                                "~~~"
293                            },
294                            if fence_char == '`' { "```" } else { "~~~" }
295                        ),
296                        line: start_line,
297                        column: start_col,
298                        end_line,
299                        end_column: end_col,
300                        severity: Severity::Warning,
301                        fix: Some(Fix::new(
302                            ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
303                            replacement,
304                        )),
305                    });
306                } else {
307                    // Already the correct style. Check for fence-length ambiguity:
308                    // if the interior contains same-style bare fences of equal or greater
309                    // length, the outer fence cannot be distinguished from an inner
310                    // closing fence and must be made longer.
311                    let prefix = &line[..marker.fence_start];
312                    let info = marker.rest;
313                    let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, fence_char);
314                    if max_inner >= fence_len {
315                        converted_fence_len = max_inner + 1;
316                        needs_lengthening = true;
317
318                        let replacement =
319                            format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
320
321                        let fence_start = marker.fence_start;
322                        let fence_end = fence_start + fence_len;
323                        let (start_line, start_col, end_line, end_col) =
324                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
325
326                        warnings.push(LintWarning {
327                            rule_name: Some(self.name().to_string()),
328                            message: format!(
329                                "Code fence length is ambiguous: outer fence ({fence_len} {}) \
330                                 contains interior fence sequences of equal length; \
331                                 use {converted_fence_len}",
332                                if fence_char == '`' { "backticks" } else { "tildes" },
333                            ),
334                            line: start_line,
335                            column: start_col,
336                            end_line,
337                            end_column: end_col,
338                            severity: Severity::Warning,
339                            fix: Some(Fix::new(
340                                ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
341                                replacement,
342                            )),
343                        });
344                    } else {
345                        converted_fence_len = fence_len;
346                        needs_lengthening = false;
347                    }
348                }
349            } else {
350                // Inside a code block — check if this is the closing fence.
351                let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
352
353                if is_closing {
354                    let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
355                        || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
356
357                    if needs_conversion || needs_lengthening {
358                        let target_char = if needs_conversion {
359                            if target_style == CodeFenceStyle::Backtick {
360                                '`'
361                            } else {
362                                '~'
363                            }
364                        } else {
365                            fence_char
366                        };
367
368                        let prefix = &line[..marker.fence_start];
369                        let replacement = format!(
370                            "{prefix}{}{}",
371                            target_char.to_string().repeat(converted_fence_len),
372                            marker.rest
373                        );
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                        let message = if needs_conversion {
381                            format!(
382                                "Code fence style: use {} instead of {}",
383                                if target_style == CodeFenceStyle::Backtick {
384                                    "```"
385                                } else {
386                                    "~~~"
387                                },
388                                if fence_char == '`' { "```" } else { "~~~" }
389                            )
390                        } else {
391                            format!(
392                                "Code fence length is ambiguous: closing fence ({fence_len} {}) \
393                                 must match the lengthened outer fence; use {converted_fence_len}",
394                                if fence_char == '`' { "backticks" } else { "tildes" },
395                            )
396                        };
397
398                        warnings.push(LintWarning {
399                            rule_name: Some(self.name().to_string()),
400                            message,
401                            line: start_line,
402                            column: start_col,
403                            end_line,
404                            end_column: end_col,
405                            severity: Severity::Warning,
406                            fix: Some(Fix::new(
407                                ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
408                                replacement,
409                            )),
410                        });
411                    }
412
413                    in_code_block = false;
414                    code_block_fence_len = 0;
415                    converted_fence_len = 0;
416                    needs_lengthening = false;
417                }
418                // Lines inside the block that are not the closing fence are left alone.
419            }
420        }
421
422        Ok(warnings)
423    }
424
425    /// Check if this rule should be skipped for performance
426    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
427        // Skip if content is empty or has no code fence markers
428        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
429    }
430
431    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
432        if self.should_skip(ctx) {
433            return Ok(ctx.content.to_string());
434        }
435        let warnings = self.check(ctx)?;
436        if warnings.is_empty() {
437            return Ok(ctx.content.to_string());
438        }
439        let warnings =
440            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
441        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
442            .map_err(crate::rule::LintError::InvalidInput)
443    }
444
445    fn as_any(&self) -> &dyn std::any::Any {
446        self
447    }
448
449    crate::impl_rule_config_methods!(MD048Config);
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::lint_context::LintContext;
456
457    #[test]
458    fn test_backtick_style_with_backticks() {
459        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
460        let content = "```\ncode\n```";
461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462        let result = rule.check(&ctx).unwrap();
463
464        assert_eq!(result.len(), 0);
465    }
466
467    #[test]
468    fn test_backtick_style_with_tildes() {
469        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
470        let content = "~~~\ncode\n~~~";
471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472        let result = rule.check(&ctx).unwrap();
473
474        assert_eq!(result.len(), 2); // Opening and closing fence
475        assert!(result[0].message.contains("use ``` instead of ~~~"));
476        assert_eq!(result[0].line, 1);
477        assert_eq!(result[1].line, 3);
478    }
479
480    #[test]
481    fn test_tilde_style_with_tildes() {
482        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
483        let content = "~~~\ncode\n~~~";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let result = rule.check(&ctx).unwrap();
486
487        assert_eq!(result.len(), 0);
488    }
489
490    #[test]
491    fn test_tilde_style_with_backticks() {
492        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
493        let content = "```\ncode\n```";
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495        let result = rule.check(&ctx).unwrap();
496
497        assert_eq!(result.len(), 2); // Opening and closing fence
498        assert!(result[0].message.contains("use ~~~ instead of ```"));
499    }
500
501    #[test]
502    fn test_consistent_style_tie_prefers_backtick() {
503        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
504        // One backtick fence and one tilde fence - tie should prefer backticks
505        let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let result = rule.check(&ctx).unwrap();
508
509        // Backticks win due to tie-breaker, so tildes should be flagged
510        assert_eq!(result.len(), 2);
511        assert_eq!(result[0].line, 5);
512        assert_eq!(result[1].line, 7);
513    }
514
515    #[test]
516    fn test_consistent_style_tilde_most_prevalent() {
517        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
518        // Two tilde fences and one backtick fence - tildes are most prevalent
519        let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521        let result = rule.check(&ctx).unwrap();
522
523        // Tildes are most prevalent, so backticks should be flagged
524        assert_eq!(result.len(), 2);
525        assert_eq!(result[0].line, 5);
526        assert_eq!(result[1].line, 7);
527    }
528
529    #[test]
530    fn test_detect_style_backtick() {
531        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
532        let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
533        let style = rule.detect_style(&ctx);
534
535        assert_eq!(style, Some(CodeFenceStyle::Backtick));
536    }
537
538    #[test]
539    fn test_detect_style_tilde() {
540        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
541        let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
542        let style = rule.detect_style(&ctx);
543
544        assert_eq!(style, Some(CodeFenceStyle::Tilde));
545    }
546
547    #[test]
548    fn test_detect_style_none() {
549        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
550        let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
551        let style = rule.detect_style(&ctx);
552
553        assert_eq!(style, None);
554    }
555
556    #[test]
557    fn test_fix_backticks_to_tildes() {
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 fixed = rule.fix(&ctx).unwrap();
562
563        assert_eq!(fixed, "~~~\ncode\n~~~");
564    }
565
566    #[test]
567    fn test_fix_tildes_to_backticks() {
568        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
569        let content = "~~~\ncode\n~~~";
570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571        let fixed = rule.fix(&ctx).unwrap();
572
573        assert_eq!(fixed, "```\ncode\n```");
574    }
575
576    #[test]
577    fn test_fix_preserves_fence_length() {
578        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
579        let content = "````\ncode with backtick\n```\ncode\n````";
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let fixed = rule.fix(&ctx).unwrap();
582
583        assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
584    }
585
586    #[test]
587    fn test_fix_preserves_language_info() {
588        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
589        let content = "~~~rust\nfn main() {}\n~~~";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591        let fixed = rule.fix(&ctx).unwrap();
592
593        assert_eq!(fixed, "```rust\nfn main() {}\n```");
594    }
595
596    #[test]
597    fn test_indented_code_fences() {
598        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
599        let content = "  ```\n  code\n  ```";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601        let result = rule.check(&ctx).unwrap();
602
603        assert_eq!(result.len(), 2);
604    }
605
606    #[test]
607    fn test_fix_indented_fences() {
608        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
609        let content = "  ```\n  code\n  ```";
610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611        let fixed = rule.fix(&ctx).unwrap();
612
613        assert_eq!(fixed, "  ~~~\n  code\n  ~~~");
614    }
615
616    #[test]
617    fn test_nested_fences_not_changed() {
618        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
619        let content = "```\ncode with ``` inside\n```";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621        let fixed = rule.fix(&ctx).unwrap();
622
623        assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
624    }
625
626    #[test]
627    fn test_multiple_code_blocks() {
628        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
629        let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631        let result = rule.check(&ctx).unwrap();
632
633        assert_eq!(result.len(), 4); // 2 opening + 2 closing fences
634    }
635
636    #[test]
637    fn test_empty_content() {
638        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
639        let content = "";
640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641        let result = rule.check(&ctx).unwrap();
642
643        assert_eq!(result.len(), 0);
644    }
645
646    #[test]
647    fn test_preserve_trailing_newline() {
648        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
649        let content = "~~~\ncode\n~~~\n";
650        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651        let fixed = rule.fix(&ctx).unwrap();
652
653        assert_eq!(fixed, "```\ncode\n```\n");
654    }
655
656    #[test]
657    fn test_no_trailing_newline() {
658        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
659        let content = "~~~\ncode\n~~~";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let fixed = rule.fix(&ctx).unwrap();
662
663        assert_eq!(fixed, "```\ncode\n```");
664    }
665
666    #[test]
667    fn test_default_config() {
668        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
669        let (name, _config) = rule.default_config_section().unwrap();
670        assert_eq!(name, "MD048");
671    }
672
673    /// Tilde outer fence containing backtick inner fence: converting to backtick
674    /// style must use a longer fence (4 backticks) to preserve valid nesting.
675    #[test]
676    fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
677        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
678        let content = "~~~text\n```rust\ncode\n```\n~~~";
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let fixed = rule.fix(&ctx).unwrap();
681
682        // The outer fence must be 4 backticks to disambiguate from the inner 3-backtick fences.
683        assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
684    }
685
686    /// check() warns about the outer tilde fences and the fix replacements use the
687    /// correct (longer) fence length.
688    #[test]
689    fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
690        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
691        let content = "~~~text\n```rust\ncode\n```\n~~~";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693        let warnings = rule.check(&ctx).unwrap();
694
695        // Only the outer tilde fences are warned about; inner backtick fences are untouched.
696        assert_eq!(warnings.len(), 2);
697        let open_fix = warnings[0].fix.as_ref().unwrap();
698        let close_fix = warnings[1].fix.as_ref().unwrap();
699        assert_eq!(open_fix.replacement, "````text");
700        assert_eq!(close_fix.replacement, "````");
701    }
702
703    /// When the inner backtick fences use 4 backticks, the outer converted fence
704    /// must use at least 5.
705    #[test]
706    fn test_tilde_outer_with_longer_backtick_inner() {
707        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
708        let content = "~~~text\n````rust\ncode\n````\n~~~";
709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710        let fixed = rule.fix(&ctx).unwrap();
711
712        assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
713    }
714
715    /// Backtick outer fence containing tilde inner fence: converting to tilde
716    /// style must use a longer tilde fence.
717    #[test]
718    fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
719        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
720        let content = "```text\n~~~rust\ncode\n~~~\n```";
721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722        let fixed = rule.fix(&ctx).unwrap();
723
724        assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
725    }
726
727    // -----------------------------------------------------------------------
728    // Fence-length ambiguity detection
729    // -----------------------------------------------------------------------
730
731    /// A backtick block containing only an info-string interior sequence (not bare)
732    /// is NOT ambiguous: info-string sequences cannot be closing fences per CommonMark,
733    /// so the bare ``` at line 3 is simply the closing fence — no lengthening needed.
734    #[test]
735    fn test_info_string_interior_not_ambiguous() {
736        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
737        // line 0: ```text   ← opens block (len=3, info="text")
738        // line 1: ```rust   ← interior content, has info "rust" → cannot close outer
739        // line 2: code
740        // line 3: ```       ← bare, len=3 >= 3 → closes block 1 (per CommonMark)
741        // line 4: ```       ← orphaned second block
742        let content = "```text\n```rust\ncode\n```\n```";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let warnings = rule.check(&ctx).unwrap();
745
746        // No ambiguity: ```rust cannot close the outer, and the bare ``` IS the
747        // unambiguous closing fence. No lengthening needed.
748        assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
749    }
750
751    /// fix() leaves a block with only info-string interior sequences unchanged.
752    #[test]
753    fn test_info_string_interior_fix_unchanged() {
754        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
755        let content = "```text\n```rust\ncode\n```\n```";
756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757        let fixed = rule.fix(&ctx).unwrap();
758
759        // No conversion needed (already backtick), no lengthening needed → unchanged.
760        assert_eq!(fixed, content);
761    }
762
763    /// Same for tilde style: an info-string tilde interior is not ambiguous.
764    #[test]
765    fn test_tilde_info_string_interior_not_ambiguous() {
766        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
767        let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769        let fixed = rule.fix(&ctx).unwrap();
770
771        // ~~~rust cannot close outer (has info); ~~~ IS the closing fence → unchanged.
772        assert_eq!(fixed, content);
773    }
774
775    /// No warning when the outer fence is already longer than any interior fence.
776    #[test]
777    fn test_no_ambiguity_when_outer_is_longer() {
778        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
779        let content = "````text\n```rust\ncode\n```\n````";
780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781        let warnings = rule.check(&ctx).unwrap();
782
783        assert_eq!(
784            warnings.len(),
785            0,
786            "should have no warnings when outer is already longer"
787        );
788    }
789
790    /// An outer block containing a longer info-string sequence and a bare closing
791    /// fence is not ambiguous: the bare closing fence closes the outer normally,
792    /// and the info-string sequence is just content.
793    #[test]
794    fn test_longer_info_string_interior_not_ambiguous() {
795        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
796        // line 0: ```text    ← opens block (len=3, info="text")
797        // line 1: `````rust  ← interior, 5 backticks with info → cannot close outer
798        // line 2: code
799        // line 3: `````      ← bare, len=5 >= 3, no info → closes block 1
800        // line 4: ```        ← orphaned second block
801        let content = "```text\n`````rust\ncode\n`````\n```";
802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803        let fixed = rule.fix(&ctx).unwrap();
804
805        // `````rust cannot close the outer. ````` IS the closing fence. No lengthening.
806        assert_eq!(fixed, content);
807    }
808
809    /// Consistent style: info-string interior sequences are not ambiguous.
810    #[test]
811    fn test_info_string_interior_consistent_style_no_warning() {
812        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
813        let content = "```text\n```rust\ncode\n```\n```";
814        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815        let warnings = rule.check(&ctx).unwrap();
816
817        assert_eq!(warnings.len(), 0);
818    }
819
820    // -----------------------------------------------------------------------
821    // Cross-style conversion: bare-only inner sequence counting
822    // -----------------------------------------------------------------------
823
824    /// Cross-style conversion where outer has NO info string: interior info-string
825    /// sequences are not counted, only bare sequences are.
826    #[test]
827    fn test_cross_style_bare_inner_requires_lengthening() {
828        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
829        // Outer tilde fence (no info). Interior has a 5-backtick info-string sequence
830        // AND a 3-backtick bare sequence. Only the bare sequence (len=3) is counted
831        // → outer becomes 4, not 6.
832        let content = "~~~\n`````rust\ncode\n```\n~~~";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834        let fixed = rule.fix(&ctx).unwrap();
835
836        // 4 backticks (bare seq len=3 → 3+1=4). The 5-backtick info-string seq is
837        // not counted since it cannot be a closing fence.
838        assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
839    }
840
841    /// Cross-style conversion where outer HAS an info string but interior has only
842    /// info-string sequences: no bare inner sequences means no lengthening needed.
843    /// The outer converts at its natural length.
844    #[test]
845    fn test_cross_style_info_only_interior_no_lengthening() {
846        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
847        // Outer tilde fence (info "text"). Interior has only info-string backtick
848        // sequences — no bare closing sequence. Info-string sequences cannot be
849        // closing fences, so no lengthening is needed → outer converts at len=3.
850        let content = "~~~text\n```rust\nexample\n```rust\n~~~";
851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852        let fixed = rule.fix(&ctx).unwrap();
853
854        assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
855    }
856
857    /// Same-style block where outer has an info string but interior contains only
858    /// bare sequences SHORTER than the outer fence: no ambiguity, no warning.
859    #[test]
860    fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
861        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
862        // Outer is 4 backticks with info "text". Interior shows raw fence syntax
863        // (3-backtick bare lines). These are shorter than outer (3 < 4) so they
864        // cannot close the outer block → no ambiguity.
865        let content = "````text\n```\nshowing raw fence\n```\n````";
866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867        let warnings = rule.check(&ctx).unwrap();
868
869        assert_eq!(
870            warnings.len(),
871            0,
872            "shorter bare interior sequences cannot close a 4-backtick outer"
873        );
874    }
875
876    /// Same-style block where outer has NO info string and interior has shorter
877    /// bare sequences: no ambiguity, no warning.
878    #[test]
879    fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
880        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
881        // Outer is 4 backticks (no info). Interior has 3-backtick bare sequences.
882        // 3 < 4 → they cannot close the outer block → no ambiguity.
883        let content = "````\n```\nsome code\n```\n````";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885        let warnings = rule.check(&ctx).unwrap();
886
887        assert_eq!(
888            warnings.len(),
889            0,
890            "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
891        );
892    }
893
894    /// Regression: over-indented inner same-style sequence (4 spaces) is content,
895    /// not a closing fence, and must not trigger ambiguity warnings.
896    #[test]
897    fn test_overindented_inner_sequence_not_ambiguous() {
898        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
899        let content = "```text\n    ```\ncode\n```";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901        let warnings = rule.check(&ctx).unwrap();
902        let fixed = rule.fix(&ctx).unwrap();
903
904        assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
905        assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
906    }
907
908    /// Regression: when converting outer style, over-indented same-style content
909    /// lines must not be mistaken for an outer closing fence.
910    #[test]
911    fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
912        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
913        let content = "~~~text\n    ~~~\n```rust\ncode\n```\n~~~";
914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915        let fixed = rule.fix(&ctx).unwrap();
916
917        assert_eq!(fixed, "````text\n    ~~~\n```rust\ncode\n```\n````");
918    }
919
920    /// CommonMark: a top-level fence marker indented 4 spaces is an indented code
921    /// block line, not a fenced code block marker, so MD048 should ignore it.
922    #[test]
923    fn test_top_level_four_space_fence_marker_is_ignored() {
924        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
925        let content = "    ```\n    code\n    ```";
926        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927        let warnings = rule.check(&ctx).unwrap();
928        let fixed = rule.fix(&ctx).unwrap();
929
930        assert_eq!(warnings.len(), 0);
931        assert_eq!(fixed, content);
932    }
933
934    // -----------------------------------------------------------------------
935    // Roundtrip safety tests: fix() output must produce 0 violations
936    // -----------------------------------------------------------------------
937
938    /// Helper: apply fix, then re-check and assert zero violations remain.
939    fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941        let fixed = rule.fix(&ctx).unwrap();
942        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
943        let remaining = rule.check(&ctx2).unwrap();
944        assert!(
945            remaining.is_empty(),
946            "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
947            remaining.len(),
948        );
949    }
950
951    #[test]
952    fn test_roundtrip_backticks_to_tildes() {
953        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
954        assert_fix_roundtrip(&rule, "```\ncode\n```");
955    }
956
957    #[test]
958    fn test_roundtrip_tildes_to_backticks() {
959        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
960        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
961    }
962
963    #[test]
964    fn test_roundtrip_mixed_fences_consistent() {
965        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
966        assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
967    }
968
969    #[test]
970    fn test_roundtrip_with_info_string() {
971        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
972        assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
973    }
974
975    #[test]
976    fn test_roundtrip_longer_fences() {
977        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
978        assert_fix_roundtrip(&rule, "`````\ncode\n`````");
979    }
980
981    #[test]
982    fn test_roundtrip_nested_inner_fences() {
983        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
984        assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
985    }
986
987    #[test]
988    fn test_roundtrip_indented_fences() {
989        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
990        assert_fix_roundtrip(&rule, "  ```\n  code\n  ```");
991    }
992
993    #[test]
994    fn test_roundtrip_multiple_blocks() {
995        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
996        assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
997    }
998
999    #[test]
1000    fn test_roundtrip_fence_length_ambiguity() {
1001        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1002        assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1003    }
1004
1005    #[test]
1006    fn test_roundtrip_trailing_newline() {
1007        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1008        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1009    }
1010
1011    #[test]
1012    fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1013        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1014        assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1015    }
1016
1017    #[test]
1018    fn test_roundtrip_backtick_outer_tilde_inner() {
1019        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1020        assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1021    }
1022
1023    #[test]
1024    fn test_roundtrip_consistent_tilde_prevalent() {
1025        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1026        assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1027    }
1028
1029    /// The combined MD013+MD048 fix must be idempotent: applying the fix twice
1030    /// must produce the same result as applying it once, and must not introduce
1031    /// double blank lines (MD012).
1032    #[test]
1033    fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1034        use crate::fix_coordinator::FixCoordinator;
1035        use crate::rules::Rule;
1036        use crate::rules::md013_line_length::MD013LineLength;
1037
1038        // This is the exact pattern that caused double blank lines when MD048 and
1039        // MD013 were applied together: a tilde outer fence with an inner backtick
1040        // fence inside a list item that is too long.
1041        let content = "\
1042- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1043Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1044or `edition2024` annotations, such as:
1045
1046  ~~~text
1047  ```rust,edition2015
1048  // This only works in 2015.
1049  let try = true;
1050  ```
1051  ~~~
1052
1053### Build options
1054";
1055        let rules: Vec<Box<dyn Rule>> = vec![
1056            Box::new(MD013LineLength::new(80, false, false, false, true)),
1057            Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1058        ];
1059
1060        let mut first_pass = content.to_string();
1061        let coordinator = FixCoordinator::new();
1062        coordinator
1063            .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1064            .expect("fix should not fail");
1065
1066        // No double blank lines after first pass.
1067        let lines: Vec<&str> = first_pass.lines().collect();
1068        for i in 0..lines.len().saturating_sub(1) {
1069            assert!(
1070                !(lines[i].is_empty() && lines[i + 1].is_empty()),
1071                "Double blank at lines {},{} after first pass:\n{first_pass}",
1072                i + 1,
1073                i + 2
1074            );
1075        }
1076
1077        // Second pass must produce identical output (idempotent).
1078        let mut second_pass = first_pass.clone();
1079        let rules2: Vec<Box<dyn Rule>> = vec![
1080            Box::new(MD013LineLength::new(80, false, false, false, true)),
1081            Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1082        ];
1083        let coordinator2 = FixCoordinator::new();
1084        coordinator2
1085            .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1086            .expect("fix should not fail");
1087
1088        assert_eq!(
1089            first_pass, second_pass,
1090            "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1091        );
1092    }
1093
1094    #[test]
1095    fn test_front_matter_fence_does_not_drive_style_detection() {
1096        // A complete fence pair inside front matter must not influence consistent
1097        // style detection. The only real (body) fence is tilde, so the document is
1098        // self-consistent; counting the front-matter backtick pair would flip the
1099        // detected style to backtick and wrongly flag the body.
1100        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1101        let content = "---\ndescription: |\n  ```\n  code\n  ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1102        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103        let result = rule.check(&ctx).unwrap();
1104        assert!(
1105            result.is_empty(),
1106            "front-matter fence must not drive style detection, got: {result:?}"
1107        );
1108    }
1109}