Skip to main content

rumdl_lib/rules/
md048_code_fence_style.rs

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