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