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