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