Skip to main content

rumdl_lib/rules/
md048_code_fence_style.rs

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