1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::code_fence_utils::CodeFenceStyle;
4use crate::utils::range_utils::calculate_match_range;
5use toml;
6
7mod md048_config;
8use md048_config::MD048Config;
9
10#[derive(Debug, Clone, Copy)]
12struct FenceMarker<'a> {
13 fence_char: char,
15 fence_len: usize,
17 fence_start: usize,
19 rest: &'a str,
21}
22
23#[inline]
29fn parse_fence_marker(line: &str) -> Option<FenceMarker<'_>> {
30 let bytes = line.as_bytes();
31 let mut pos = 0usize;
32 while pos < bytes.len() && bytes[pos] == b' ' {
33 pos += 1;
34 }
35 if pos > 3 {
36 return None;
37 }
38
39 let fence_char = match bytes.get(pos).copied() {
40 Some(b'`') => '`',
41 Some(b'~') => '~',
42 _ => return None,
43 };
44
45 let marker = if fence_char == '`' { b'`' } else { b'~' };
46 let mut end = pos;
47 while end < bytes.len() && bytes[end] == marker {
48 end += 1;
49 }
50 let fence_len = end - pos;
51 if fence_len < 3 {
52 return None;
53 }
54
55 Some(FenceMarker {
56 fence_char,
57 fence_len,
58 fence_start: pos,
59 rest: &line[end..],
60 })
61}
62
63#[inline]
64fn is_closing_fence(marker: FenceMarker<'_>, opening_fence_char: char, opening_fence_len: usize) -> bool {
65 marker.fence_char == opening_fence_char && marker.fence_len >= opening_fence_len && marker.rest.trim().is_empty()
66}
67
68#[derive(Clone)]
72pub struct MD048CodeFenceStyle {
73 config: MD048Config,
74}
75
76impl MD048CodeFenceStyle {
77 pub fn new(style: CodeFenceStyle) -> Self {
78 Self {
79 config: MD048Config { style },
80 }
81 }
82
83 pub fn from_config_struct(config: MD048Config) -> Self {
84 Self { config }
85 }
86
87 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
88 let mut backtick_count = 0;
90 let mut tilde_count = 0;
91 let mut in_code_block = false;
92 let mut opening_fence_char = '`';
93 let mut opening_fence_len = 0usize;
94
95 for filtered_line in ctx.filtered_lines().skip_front_matter() {
96 let i = filtered_line.line_num - 1;
97 let line = filtered_line.content;
98 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
101 continue;
102 }
103
104 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
107 continue;
108 }
109
110 let Some(marker) = parse_fence_marker(line) else {
111 continue;
112 };
113
114 if ctx.flavor.supports_myst_directives()
116 && marker.fence_char == '`'
117 && marker.rest.trim_start().starts_with('{')
118 {
119 continue;
120 }
121
122 if !in_code_block {
123 if marker.fence_char == '`' {
125 backtick_count += 1;
126 } else {
127 tilde_count += 1;
128 }
129 in_code_block = true;
130 opening_fence_char = marker.fence_char;
131 opening_fence_len = marker.fence_len;
132 } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
133 in_code_block = false;
134 }
135 }
136
137 if backtick_count >= tilde_count && backtick_count > 0 {
140 Some(CodeFenceStyle::Backtick)
141 } else if tilde_count > 0 {
142 Some(CodeFenceStyle::Tilde)
143 } else {
144 None
145 }
146 }
147}
148
149fn max_inner_fence_length_of_char(
167 lines: &[&str],
168 opening_line: usize,
169 opening_fence_len: usize,
170 opening_char: char,
171 target_char: char,
172) -> usize {
173 let mut max_len = 0usize;
174
175 for line in lines.iter().skip(opening_line + 1) {
176 let Some(marker) = parse_fence_marker(line) else {
177 continue;
178 };
179
180 if is_closing_fence(marker, opening_char, opening_fence_len) {
182 break;
183 }
184
185 if marker.fence_char == target_char && marker.rest.trim().is_empty() {
188 max_len = max_len.max(marker.fence_len);
189 }
190 }
191
192 max_len
193}
194
195impl Rule for MD048CodeFenceStyle {
196 fn name(&self) -> &'static str {
197 "MD048"
198 }
199
200 fn description(&self) -> &'static str {
201 "Code fence style should be consistent"
202 }
203
204 fn category(&self) -> RuleCategory {
205 RuleCategory::CodeBlock
206 }
207
208 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
209 let line_index = &ctx.line_index;
210
211 let mut warnings = Vec::new();
212
213 let target_style = match self.config.style {
214 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
215 _ => self.config.style,
216 };
217
218 let lines = ctx.raw_lines();
219 let mut in_code_block = false;
220 let mut code_block_fence_char = '`';
221 let mut code_block_fence_len = 0usize;
222 let mut converted_fence_len = 0usize;
225 let mut needs_lengthening = false;
228
229 for filtered_line in ctx.filtered_lines().skip_front_matter() {
230 let line_num = filtered_line.line_num - 1;
231 let line = filtered_line.content;
232 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
234 continue;
235 }
236
237 if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
239 continue;
240 }
241
242 let Some(marker) = parse_fence_marker(line) else {
243 continue;
244 };
245
246 if ctx.flavor.supports_myst_directives()
248 && !in_code_block
249 && marker.fence_char == '`'
250 && marker.rest.trim_start().starts_with('{')
251 {
252 continue;
253 }
254 let fence_char = marker.fence_char;
255 let fence_len = marker.fence_len;
256
257 if !in_code_block {
258 in_code_block = true;
259 code_block_fence_char = fence_char;
260 code_block_fence_len = fence_len;
261
262 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
263 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
264
265 if needs_conversion {
266 let target_char = if target_style == CodeFenceStyle::Backtick {
267 '`'
268 } else {
269 '~'
270 };
271
272 let prefix = &line[..marker.fence_start];
275 let info = marker.rest;
276 let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, target_char);
277 converted_fence_len = fence_len.max(max_inner + 1);
278 needs_lengthening = false;
279
280 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
281
282 let fence_start = marker.fence_start;
283 let fence_end = fence_start + fence_len;
284 let (start_line, start_col, end_line, end_col) =
285 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
286
287 warnings.push(LintWarning {
288 rule_name: Some(self.name().to_string()),
289 message: format!(
290 "Code fence style: use {} instead of {}",
291 if target_style == CodeFenceStyle::Backtick {
292 "```"
293 } else {
294 "~~~"
295 },
296 if fence_char == '`' { "```" } else { "~~~" }
297 ),
298 line: start_line,
299 column: start_col,
300 end_line,
301 end_column: end_col,
302 severity: Severity::Warning,
303 fix: Some(Fix::new(
304 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
305 replacement,
306 )),
307 });
308 } else {
309 let prefix = &line[..marker.fence_start];
314 let info = marker.rest;
315 let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, fence_char);
316 if max_inner >= fence_len {
317 converted_fence_len = max_inner + 1;
318 needs_lengthening = true;
319
320 let replacement =
321 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
322
323 let fence_start = marker.fence_start;
324 let fence_end = fence_start + fence_len;
325 let (start_line, start_col, end_line, end_col) =
326 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
327
328 warnings.push(LintWarning {
329 rule_name: Some(self.name().to_string()),
330 message: format!(
331 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
332 contains interior fence sequences of equal length; \
333 use {converted_fence_len}",
334 if fence_char == '`' { "backticks" } else { "tildes" },
335 ),
336 line: start_line,
337 column: start_col,
338 end_line,
339 end_column: end_col,
340 severity: Severity::Warning,
341 fix: Some(Fix::new(
342 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
343 replacement,
344 )),
345 });
346 } else {
347 converted_fence_len = fence_len;
348 needs_lengthening = false;
349 }
350 }
351 } else {
352 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
354
355 if is_closing {
356 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
357 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
358
359 if needs_conversion || needs_lengthening {
360 let target_char = if needs_conversion {
361 if target_style == CodeFenceStyle::Backtick {
362 '`'
363 } else {
364 '~'
365 }
366 } else {
367 fence_char
368 };
369
370 let prefix = &line[..marker.fence_start];
371 let replacement = format!(
372 "{prefix}{}{}",
373 target_char.to_string().repeat(converted_fence_len),
374 marker.rest
375 );
376
377 let fence_start = marker.fence_start;
378 let fence_end = fence_start + fence_len;
379 let (start_line, start_col, end_line, end_col) =
380 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
381
382 let message = if needs_conversion {
383 format!(
384 "Code fence style: use {} instead of {}",
385 if target_style == CodeFenceStyle::Backtick {
386 "```"
387 } else {
388 "~~~"
389 },
390 if fence_char == '`' { "```" } else { "~~~" }
391 )
392 } else {
393 format!(
394 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
395 must match the lengthened outer fence; use {converted_fence_len}",
396 if fence_char == '`' { "backticks" } else { "tildes" },
397 )
398 };
399
400 warnings.push(LintWarning {
401 rule_name: Some(self.name().to_string()),
402 message,
403 line: start_line,
404 column: start_col,
405 end_line,
406 end_column: end_col,
407 severity: Severity::Warning,
408 fix: Some(Fix::new(
409 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
410 replacement,
411 )),
412 });
413 }
414
415 in_code_block = false;
416 code_block_fence_len = 0;
417 converted_fence_len = 0;
418 needs_lengthening = false;
419 }
420 }
422 }
423
424 Ok(warnings)
425 }
426
427 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
429 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
431 }
432
433 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
434 if self.should_skip(ctx) {
435 return Ok(ctx.content.to_string());
436 }
437 let warnings = self.check(ctx)?;
438 if warnings.is_empty() {
439 return Ok(ctx.content.to_string());
440 }
441 let warnings =
442 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
443 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
444 .map_err(crate::rule::LintError::InvalidInput)
445 }
446
447 fn as_any(&self) -> &dyn std::any::Any {
448 self
449 }
450
451 crate::impl_rule_config_methods!(MD048Config);
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::lint_context::LintContext;
458
459 #[test]
460 fn test_backtick_style_with_backticks() {
461 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
462 let content = "```\ncode\n```";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465
466 assert_eq!(result.len(), 0);
467 }
468
469 #[test]
470 fn test_backtick_style_with_tildes() {
471 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
472 let content = "~~~\ncode\n~~~";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475
476 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
478 assert_eq!(result[0].line, 1);
479 assert_eq!(result[1].line, 3);
480 }
481
482 #[test]
483 fn test_tilde_style_with_tildes() {
484 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
485 let content = "~~~\ncode\n~~~";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let result = rule.check(&ctx).unwrap();
488
489 assert_eq!(result.len(), 0);
490 }
491
492 #[test]
493 fn test_tilde_style_with_backticks() {
494 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
495 let content = "```\ncode\n```";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498
499 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
501 }
502
503 #[test]
504 fn test_consistent_style_tie_prefers_backtick() {
505 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
506 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510
511 assert_eq!(result.len(), 2);
513 assert_eq!(result[0].line, 5);
514 assert_eq!(result[1].line, 7);
515 }
516
517 #[test]
518 fn test_consistent_style_tilde_most_prevalent() {
519 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
520 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524
525 assert_eq!(result.len(), 2);
527 assert_eq!(result[0].line, 5);
528 assert_eq!(result[1].line, 7);
529 }
530
531 #[test]
532 fn test_detect_style_backtick() {
533 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
534 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
535 let style = rule.detect_style(&ctx);
536
537 assert_eq!(style, Some(CodeFenceStyle::Backtick));
538 }
539
540 #[test]
541 fn test_detect_style_tilde() {
542 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
543 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
544 let style = rule.detect_style(&ctx);
545
546 assert_eq!(style, Some(CodeFenceStyle::Tilde));
547 }
548
549 #[test]
550 fn test_detect_style_none() {
551 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
552 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
553 let style = rule.detect_style(&ctx);
554
555 assert_eq!(style, None);
556 }
557
558 #[test]
559 fn test_fix_backticks_to_tildes() {
560 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
561 let content = "```\ncode\n```";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let fixed = rule.fix(&ctx).unwrap();
564
565 assert_eq!(fixed, "~~~\ncode\n~~~");
566 }
567
568 #[test]
569 fn test_fix_tildes_to_backticks() {
570 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
571 let content = "~~~\ncode\n~~~";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let fixed = rule.fix(&ctx).unwrap();
574
575 assert_eq!(fixed, "```\ncode\n```");
576 }
577
578 #[test]
579 fn test_fix_preserves_fence_length() {
580 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
581 let content = "````\ncode with backtick\n```\ncode\n````";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let fixed = rule.fix(&ctx).unwrap();
584
585 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
586 }
587
588 #[test]
589 fn test_fix_preserves_language_info() {
590 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
591 let content = "~~~rust\nfn main() {}\n~~~";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let fixed = rule.fix(&ctx).unwrap();
594
595 assert_eq!(fixed, "```rust\nfn main() {}\n```");
596 }
597
598 #[test]
599 fn test_indented_code_fences() {
600 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
601 let content = " ```\n code\n ```";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604
605 assert_eq!(result.len(), 2);
606 }
607
608 #[test]
609 fn test_fix_indented_fences() {
610 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
611 let content = " ```\n code\n ```";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let fixed = rule.fix(&ctx).unwrap();
614
615 assert_eq!(fixed, " ~~~\n code\n ~~~");
616 }
617
618 #[test]
619 fn test_nested_fences_not_changed() {
620 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
621 let content = "```\ncode with ``` inside\n```";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let fixed = rule.fix(&ctx).unwrap();
624
625 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
626 }
627
628 #[test]
629 fn test_multiple_code_blocks() {
630 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
631 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634
635 assert_eq!(result.len(), 4); }
637
638 #[test]
639 fn test_empty_content() {
640 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
641 let content = "";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644
645 assert_eq!(result.len(), 0);
646 }
647
648 #[test]
649 fn test_preserve_trailing_newline() {
650 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
651 let content = "~~~\ncode\n~~~\n";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let fixed = rule.fix(&ctx).unwrap();
654
655 assert_eq!(fixed, "```\ncode\n```\n");
656 }
657
658 #[test]
659 fn test_no_trailing_newline() {
660 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
661 let content = "~~~\ncode\n~~~";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let fixed = rule.fix(&ctx).unwrap();
664
665 assert_eq!(fixed, "```\ncode\n```");
666 }
667
668 #[test]
669 fn test_default_config() {
670 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
671 let (name, _config) = rule.default_config_section().unwrap();
672 assert_eq!(name, "MD048");
673 }
674
675 #[test]
678 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
679 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
680 let content = "~~~text\n```rust\ncode\n```\n~~~";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682 let fixed = rule.fix(&ctx).unwrap();
683
684 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
686 }
687
688 #[test]
691 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
692 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
693 let content = "~~~text\n```rust\ncode\n```\n~~~";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let warnings = rule.check(&ctx).unwrap();
696
697 assert_eq!(warnings.len(), 2);
699 let open_fix = warnings[0].fix.as_ref().unwrap();
700 let close_fix = warnings[1].fix.as_ref().unwrap();
701 assert_eq!(open_fix.replacement, "````text");
702 assert_eq!(close_fix.replacement, "````");
703 }
704
705 #[test]
708 fn test_tilde_outer_with_longer_backtick_inner() {
709 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
710 let content = "~~~text\n````rust\ncode\n````\n~~~";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let fixed = rule.fix(&ctx).unwrap();
713
714 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
715 }
716
717 #[test]
720 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
721 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
722 let content = "```text\n~~~rust\ncode\n~~~\n```";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let fixed = rule.fix(&ctx).unwrap();
725
726 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
727 }
728
729 #[test]
737 fn test_info_string_interior_not_ambiguous() {
738 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
739 let content = "```text\n```rust\ncode\n```\n```";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let warnings = rule.check(&ctx).unwrap();
747
748 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
751 }
752
753 #[test]
755 fn test_info_string_interior_fix_unchanged() {
756 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
757 let content = "```text\n```rust\ncode\n```\n```";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let fixed = rule.fix(&ctx).unwrap();
760
761 assert_eq!(fixed, content);
763 }
764
765 #[test]
767 fn test_tilde_info_string_interior_not_ambiguous() {
768 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
769 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let fixed = rule.fix(&ctx).unwrap();
772
773 assert_eq!(fixed, content);
775 }
776
777 #[test]
779 fn test_no_ambiguity_when_outer_is_longer() {
780 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
781 let content = "````text\n```rust\ncode\n```\n````";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let warnings = rule.check(&ctx).unwrap();
784
785 assert_eq!(
786 warnings.len(),
787 0,
788 "should have no warnings when outer is already longer"
789 );
790 }
791
792 #[test]
796 fn test_longer_info_string_interior_not_ambiguous() {
797 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
798 let content = "```text\n`````rust\ncode\n`````\n```";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let fixed = rule.fix(&ctx).unwrap();
806
807 assert_eq!(fixed, content);
809 }
810
811 #[test]
813 fn test_info_string_interior_consistent_style_no_warning() {
814 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
815 let content = "```text\n```rust\ncode\n```\n```";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let warnings = rule.check(&ctx).unwrap();
818
819 assert_eq!(warnings.len(), 0);
820 }
821
822 #[test]
829 fn test_cross_style_bare_inner_requires_lengthening() {
830 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
831 let content = "~~~\n`````rust\ncode\n```\n~~~";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let fixed = rule.fix(&ctx).unwrap();
837
838 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
841 }
842
843 #[test]
847 fn test_cross_style_info_only_interior_no_lengthening() {
848 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
849 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let fixed = rule.fix(&ctx).unwrap();
855
856 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
857 }
858
859 #[test]
862 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
863 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
864 let content = "````text\n```\nshowing raw fence\n```\n````";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let warnings = rule.check(&ctx).unwrap();
870
871 assert_eq!(
872 warnings.len(),
873 0,
874 "shorter bare interior sequences cannot close a 4-backtick outer"
875 );
876 }
877
878 #[test]
881 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
882 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
883 let content = "````\n```\nsome code\n```\n````";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let warnings = rule.check(&ctx).unwrap();
888
889 assert_eq!(
890 warnings.len(),
891 0,
892 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
893 );
894 }
895
896 #[test]
899 fn test_overindented_inner_sequence_not_ambiguous() {
900 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
901 let content = "```text\n ```\ncode\n```";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let warnings = rule.check(&ctx).unwrap();
904 let fixed = rule.fix(&ctx).unwrap();
905
906 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
907 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
908 }
909
910 #[test]
913 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
914 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
915 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let fixed = rule.fix(&ctx).unwrap();
918
919 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
920 }
921
922 #[test]
925 fn test_top_level_four_space_fence_marker_is_ignored() {
926 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
927 let content = " ```\n code\n ```";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let warnings = rule.check(&ctx).unwrap();
930 let fixed = rule.fix(&ctx).unwrap();
931
932 assert_eq!(warnings.len(), 0);
933 assert_eq!(fixed, content);
934 }
935
936 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let fixed = rule.fix(&ctx).unwrap();
944 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
945 let remaining = rule.check(&ctx2).unwrap();
946 assert!(
947 remaining.is_empty(),
948 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
949 remaining.len(),
950 );
951 }
952
953 #[test]
954 fn test_roundtrip_backticks_to_tildes() {
955 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
956 assert_fix_roundtrip(&rule, "```\ncode\n```");
957 }
958
959 #[test]
960 fn test_roundtrip_tildes_to_backticks() {
961 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
962 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
963 }
964
965 #[test]
966 fn test_roundtrip_mixed_fences_consistent() {
967 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
968 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
969 }
970
971 #[test]
972 fn test_roundtrip_with_info_string() {
973 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
974 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
975 }
976
977 #[test]
978 fn test_roundtrip_longer_fences() {
979 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
980 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
981 }
982
983 #[test]
984 fn test_roundtrip_nested_inner_fences() {
985 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
986 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
987 }
988
989 #[test]
990 fn test_roundtrip_indented_fences() {
991 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
992 assert_fix_roundtrip(&rule, " ```\n code\n ```");
993 }
994
995 #[test]
996 fn test_roundtrip_multiple_blocks() {
997 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
998 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
999 }
1000
1001 #[test]
1002 fn test_roundtrip_fence_length_ambiguity() {
1003 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1004 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1005 }
1006
1007 #[test]
1008 fn test_roundtrip_trailing_newline() {
1009 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1010 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1011 }
1012
1013 #[test]
1014 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1015 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1016 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1017 }
1018
1019 #[test]
1020 fn test_roundtrip_backtick_outer_tilde_inner() {
1021 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1022 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1023 }
1024
1025 #[test]
1026 fn test_roundtrip_consistent_tilde_prevalent() {
1027 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1028 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1029 }
1030
1031 #[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 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 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 let mut second_pass = first_pass.clone();
1081 let rules2: Vec<Box<dyn Rule>> = vec![
1082 Box::new(MD013LineLength::new(80, false, false, false, true)),
1083 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1084 ];
1085 let coordinator2 = FixCoordinator::new();
1086 coordinator2
1087 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1088 .expect("fix should not fail");
1089
1090 assert_eq!(
1091 first_pass, second_pass,
1092 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_front_matter_fence_does_not_drive_style_detection() {
1098 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1103 let content = "---\ndescription: |\n ```\n code\n ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert!(
1107 result.is_empty(),
1108 "front-matter fence must not drive style detection, got: {result:?}"
1109 );
1110 }
1111}