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 mut warnings = Vec::new();
210
211 let target_style = match self.config.style {
212 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
213 _ => self.config.style,
214 };
215
216 let lines = ctx.raw_lines();
217 let mut in_code_block = false;
218 let mut code_block_fence_char = '`';
219 let mut code_block_fence_len = 0usize;
220 let mut converted_fence_len = 0usize;
223 let mut needs_lengthening = false;
226
227 for filtered_line in ctx.filtered_lines().skip_front_matter() {
228 let line_num = filtered_line.line_num - 1;
229 let line = filtered_line.content;
230 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
232 continue;
233 }
234
235 if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
237 continue;
238 }
239
240 let Some(marker) = parse_fence_marker(line) else {
241 continue;
242 };
243
244 if ctx.flavor.supports_myst_directives()
246 && !in_code_block
247 && marker.fence_char == '`'
248 && marker.rest.trim_start().starts_with('{')
249 {
250 continue;
251 }
252 let fence_char = marker.fence_char;
253 let fence_len = marker.fence_len;
254
255 if !in_code_block {
256 in_code_block = true;
257 code_block_fence_char = fence_char;
258 code_block_fence_len = fence_len;
259
260 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
261 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
262
263 if needs_conversion {
264 let target_char = if target_style == CodeFenceStyle::Backtick {
265 '`'
266 } else {
267 '~'
268 };
269
270 let prefix = &line[..marker.fence_start];
273 let info = marker.rest;
274 let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, target_char);
275 converted_fence_len = fence_len.max(max_inner + 1);
276 needs_lengthening = false;
277
278 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
279
280 let fence_start = marker.fence_start;
281 let fence_end = fence_start + fence_len;
282 let (start_line, start_col, end_line, end_col) =
283 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
284
285 warnings.push(LintWarning {
286 rule_name: Some(self.name().to_string()),
287 message: format!(
288 "Code fence style: use {} instead of {}",
289 if target_style == CodeFenceStyle::Backtick {
290 "```"
291 } else {
292 "~~~"
293 },
294 if fence_char == '`' { "```" } else { "~~~" }
295 ),
296 line: start_line,
297 column: start_col,
298 end_line,
299 end_column: end_col,
300 severity: Severity::Warning,
301 fix: Some(Fix::new(
302 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
303 replacement,
304 )),
305 });
306 } else {
307 let prefix = &line[..marker.fence_start];
312 let info = marker.rest;
313 let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, fence_char);
314 if max_inner >= fence_len {
315 converted_fence_len = max_inner + 1;
316 needs_lengthening = true;
317
318 let replacement =
319 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
320
321 let fence_start = marker.fence_start;
322 let fence_end = fence_start + fence_len;
323 let (start_line, start_col, end_line, end_col) =
324 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
325
326 warnings.push(LintWarning {
327 rule_name: Some(self.name().to_string()),
328 message: format!(
329 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
330 contains interior fence sequences of equal length; \
331 use {converted_fence_len}",
332 if fence_char == '`' { "backticks" } else { "tildes" },
333 ),
334 line: start_line,
335 column: start_col,
336 end_line,
337 end_column: end_col,
338 severity: Severity::Warning,
339 fix: Some(Fix::new(
340 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
341 replacement,
342 )),
343 });
344 } else {
345 converted_fence_len = fence_len;
346 needs_lengthening = false;
347 }
348 }
349 } else {
350 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
352
353 if is_closing {
354 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
355 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
356
357 if needs_conversion || needs_lengthening {
358 let target_char = if needs_conversion {
359 if target_style == CodeFenceStyle::Backtick {
360 '`'
361 } else {
362 '~'
363 }
364 } else {
365 fence_char
366 };
367
368 let prefix = &line[..marker.fence_start];
369 let replacement = format!(
370 "{prefix}{}{}",
371 target_char.to_string().repeat(converted_fence_len),
372 marker.rest
373 );
374
375 let fence_start = marker.fence_start;
376 let fence_end = fence_start + fence_len;
377 let (start_line, start_col, end_line, end_col) =
378 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
379
380 let message = if needs_conversion {
381 format!(
382 "Code fence style: use {} instead of {}",
383 if target_style == CodeFenceStyle::Backtick {
384 "```"
385 } else {
386 "~~~"
387 },
388 if fence_char == '`' { "```" } else { "~~~" }
389 )
390 } else {
391 format!(
392 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
393 must match the lengthened outer fence; use {converted_fence_len}",
394 if fence_char == '`' { "backticks" } else { "tildes" },
395 )
396 };
397
398 warnings.push(LintWarning {
399 rule_name: Some(self.name().to_string()),
400 message,
401 line: start_line,
402 column: start_col,
403 end_line,
404 end_column: end_col,
405 severity: Severity::Warning,
406 fix: Some(Fix::new(
407 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
408 replacement,
409 )),
410 });
411 }
412
413 in_code_block = false;
414 code_block_fence_len = 0;
415 converted_fence_len = 0;
416 needs_lengthening = false;
417 }
418 }
420 }
421
422 Ok(warnings)
423 }
424
425 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
427 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
429 }
430
431 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
432 if self.should_skip(ctx) {
433 return Ok(ctx.content.to_string());
434 }
435 let warnings = self.check(ctx)?;
436 if warnings.is_empty() {
437 return Ok(ctx.content.to_string());
438 }
439 let warnings =
440 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
441 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
442 .map_err(crate::rule::LintError::InvalidInput)
443 }
444
445 fn as_any(&self) -> &dyn std::any::Any {
446 self
447 }
448
449 crate::impl_rule_config_methods!(MD048Config);
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use crate::lint_context::LintContext;
456
457 #[test]
458 fn test_backtick_style_with_backticks() {
459 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
460 let content = "```\ncode\n```";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463
464 assert_eq!(result.len(), 0);
465 }
466
467 #[test]
468 fn test_backtick_style_with_tildes() {
469 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
470 let content = "~~~\ncode\n~~~";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473
474 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
476 assert_eq!(result[0].line, 1);
477 assert_eq!(result[1].line, 3);
478 }
479
480 #[test]
481 fn test_tilde_style_with_tildes() {
482 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
483 let content = "~~~\ncode\n~~~";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486
487 assert_eq!(result.len(), 0);
488 }
489
490 #[test]
491 fn test_tilde_style_with_backticks() {
492 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
493 let content = "```\ncode\n```";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496
497 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
499 }
500
501 #[test]
502 fn test_consistent_style_tie_prefers_backtick() {
503 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
504 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508
509 assert_eq!(result.len(), 2);
511 assert_eq!(result[0].line, 5);
512 assert_eq!(result[1].line, 7);
513 }
514
515 #[test]
516 fn test_consistent_style_tilde_most_prevalent() {
517 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
518 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522
523 assert_eq!(result.len(), 2);
525 assert_eq!(result[0].line, 5);
526 assert_eq!(result[1].line, 7);
527 }
528
529 #[test]
530 fn test_detect_style_backtick() {
531 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
532 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
533 let style = rule.detect_style(&ctx);
534
535 assert_eq!(style, Some(CodeFenceStyle::Backtick));
536 }
537
538 #[test]
539 fn test_detect_style_tilde() {
540 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
541 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
542 let style = rule.detect_style(&ctx);
543
544 assert_eq!(style, Some(CodeFenceStyle::Tilde));
545 }
546
547 #[test]
548 fn test_detect_style_none() {
549 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
550 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
551 let style = rule.detect_style(&ctx);
552
553 assert_eq!(style, None);
554 }
555
556 #[test]
557 fn test_fix_backticks_to_tildes() {
558 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
559 let content = "```\ncode\n```";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let fixed = rule.fix(&ctx).unwrap();
562
563 assert_eq!(fixed, "~~~\ncode\n~~~");
564 }
565
566 #[test]
567 fn test_fix_tildes_to_backticks() {
568 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
569 let content = "~~~\ncode\n~~~";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let fixed = rule.fix(&ctx).unwrap();
572
573 assert_eq!(fixed, "```\ncode\n```");
574 }
575
576 #[test]
577 fn test_fix_preserves_fence_length() {
578 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
579 let content = "````\ncode with backtick\n```\ncode\n````";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let fixed = rule.fix(&ctx).unwrap();
582
583 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
584 }
585
586 #[test]
587 fn test_fix_preserves_language_info() {
588 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
589 let content = "~~~rust\nfn main() {}\n~~~";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let fixed = rule.fix(&ctx).unwrap();
592
593 assert_eq!(fixed, "```rust\nfn main() {}\n```");
594 }
595
596 #[test]
597 fn test_indented_code_fences() {
598 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
599 let content = " ```\n code\n ```";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602
603 assert_eq!(result.len(), 2);
604 }
605
606 #[test]
607 fn test_fix_indented_fences() {
608 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
609 let content = " ```\n code\n ```";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let fixed = rule.fix(&ctx).unwrap();
612
613 assert_eq!(fixed, " ~~~\n code\n ~~~");
614 }
615
616 #[test]
617 fn test_nested_fences_not_changed() {
618 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
619 let content = "```\ncode with ``` inside\n```";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let fixed = rule.fix(&ctx).unwrap();
622
623 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
624 }
625
626 #[test]
627 fn test_multiple_code_blocks() {
628 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
629 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632
633 assert_eq!(result.len(), 4); }
635
636 #[test]
637 fn test_empty_content() {
638 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
639 let content = "";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let result = rule.check(&ctx).unwrap();
642
643 assert_eq!(result.len(), 0);
644 }
645
646 #[test]
647 fn test_preserve_trailing_newline() {
648 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
649 let content = "~~~\ncode\n~~~\n";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let fixed = rule.fix(&ctx).unwrap();
652
653 assert_eq!(fixed, "```\ncode\n```\n");
654 }
655
656 #[test]
657 fn test_no_trailing_newline() {
658 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
659 let content = "~~~\ncode\n~~~";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let fixed = rule.fix(&ctx).unwrap();
662
663 assert_eq!(fixed, "```\ncode\n```");
664 }
665
666 #[test]
667 fn test_default_config() {
668 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
669 let (name, _config) = rule.default_config_section().unwrap();
670 assert_eq!(name, "MD048");
671 }
672
673 #[test]
676 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
677 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
678 let content = "~~~text\n```rust\ncode\n```\n~~~";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let fixed = rule.fix(&ctx).unwrap();
681
682 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
684 }
685
686 #[test]
689 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
690 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
691 let content = "~~~text\n```rust\ncode\n```\n~~~";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let warnings = rule.check(&ctx).unwrap();
694
695 assert_eq!(warnings.len(), 2);
697 let open_fix = warnings[0].fix.as_ref().unwrap();
698 let close_fix = warnings[1].fix.as_ref().unwrap();
699 assert_eq!(open_fix.replacement, "````text");
700 assert_eq!(close_fix.replacement, "````");
701 }
702
703 #[test]
706 fn test_tilde_outer_with_longer_backtick_inner() {
707 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
708 let content = "~~~text\n````rust\ncode\n````\n~~~";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let fixed = rule.fix(&ctx).unwrap();
711
712 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
713 }
714
715 #[test]
718 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
719 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
720 let content = "```text\n~~~rust\ncode\n~~~\n```";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let fixed = rule.fix(&ctx).unwrap();
723
724 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
725 }
726
727 #[test]
735 fn test_info_string_interior_not_ambiguous() {
736 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
737 let content = "```text\n```rust\ncode\n```\n```";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let warnings = rule.check(&ctx).unwrap();
745
746 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
749 }
750
751 #[test]
753 fn test_info_string_interior_fix_unchanged() {
754 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
755 let content = "```text\n```rust\ncode\n```\n```";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let fixed = rule.fix(&ctx).unwrap();
758
759 assert_eq!(fixed, content);
761 }
762
763 #[test]
765 fn test_tilde_info_string_interior_not_ambiguous() {
766 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
767 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let fixed = rule.fix(&ctx).unwrap();
770
771 assert_eq!(fixed, content);
773 }
774
775 #[test]
777 fn test_no_ambiguity_when_outer_is_longer() {
778 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
779 let content = "````text\n```rust\ncode\n```\n````";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let warnings = rule.check(&ctx).unwrap();
782
783 assert_eq!(
784 warnings.len(),
785 0,
786 "should have no warnings when outer is already longer"
787 );
788 }
789
790 #[test]
794 fn test_longer_info_string_interior_not_ambiguous() {
795 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
796 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, content);
807 }
808
809 #[test]
811 fn test_info_string_interior_consistent_style_no_warning() {
812 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
813 let content = "```text\n```rust\ncode\n```\n```";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let warnings = rule.check(&ctx).unwrap();
816
817 assert_eq!(warnings.len(), 0);
818 }
819
820 #[test]
827 fn test_cross_style_bare_inner_requires_lengthening() {
828 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
829 let content = "~~~\n`````rust\ncode\n```\n~~~";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let fixed = rule.fix(&ctx).unwrap();
835
836 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
839 }
840
841 #[test]
845 fn test_cross_style_info_only_interior_no_lengthening() {
846 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
847 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let fixed = rule.fix(&ctx).unwrap();
853
854 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
855 }
856
857 #[test]
860 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
861 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
862 let content = "````text\n```\nshowing raw fence\n```\n````";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let warnings = rule.check(&ctx).unwrap();
868
869 assert_eq!(
870 warnings.len(),
871 0,
872 "shorter bare interior sequences cannot close a 4-backtick outer"
873 );
874 }
875
876 #[test]
879 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
880 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
881 let content = "````\n```\nsome code\n```\n````";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let warnings = rule.check(&ctx).unwrap();
886
887 assert_eq!(
888 warnings.len(),
889 0,
890 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
891 );
892 }
893
894 #[test]
897 fn test_overindented_inner_sequence_not_ambiguous() {
898 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
899 let content = "```text\n ```\ncode\n```";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 let warnings = rule.check(&ctx).unwrap();
902 let fixed = rule.fix(&ctx).unwrap();
903
904 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
905 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
906 }
907
908 #[test]
911 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
912 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
913 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let fixed = rule.fix(&ctx).unwrap();
916
917 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
918 }
919
920 #[test]
923 fn test_top_level_four_space_fence_marker_is_ignored() {
924 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
925 let content = " ```\n code\n ```";
926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 let warnings = rule.check(&ctx).unwrap();
928 let fixed = rule.fix(&ctx).unwrap();
929
930 assert_eq!(warnings.len(), 0);
931 assert_eq!(fixed, content);
932 }
933
934 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941 let fixed = rule.fix(&ctx).unwrap();
942 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
943 let remaining = rule.check(&ctx2).unwrap();
944 assert!(
945 remaining.is_empty(),
946 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
947 remaining.len(),
948 );
949 }
950
951 #[test]
952 fn test_roundtrip_backticks_to_tildes() {
953 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
954 assert_fix_roundtrip(&rule, "```\ncode\n```");
955 }
956
957 #[test]
958 fn test_roundtrip_tildes_to_backticks() {
959 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
960 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
961 }
962
963 #[test]
964 fn test_roundtrip_mixed_fences_consistent() {
965 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
966 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
967 }
968
969 #[test]
970 fn test_roundtrip_with_info_string() {
971 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
972 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
973 }
974
975 #[test]
976 fn test_roundtrip_longer_fences() {
977 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
978 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
979 }
980
981 #[test]
982 fn test_roundtrip_nested_inner_fences() {
983 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
984 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
985 }
986
987 #[test]
988 fn test_roundtrip_indented_fences() {
989 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
990 assert_fix_roundtrip(&rule, " ```\n code\n ```");
991 }
992
993 #[test]
994 fn test_roundtrip_multiple_blocks() {
995 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
996 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
997 }
998
999 #[test]
1000 fn test_roundtrip_fence_length_ambiguity() {
1001 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1002 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1003 }
1004
1005 #[test]
1006 fn test_roundtrip_trailing_newline() {
1007 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1008 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1009 }
1010
1011 #[test]
1012 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1013 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1014 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1015 }
1016
1017 #[test]
1018 fn test_roundtrip_backtick_outer_tilde_inner() {
1019 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1020 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1021 }
1022
1023 #[test]
1024 fn test_roundtrip_consistent_tilde_prevalent() {
1025 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1026 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1027 }
1028
1029 #[test]
1033 fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1034 use crate::fix_coordinator::FixCoordinator;
1035 use crate::rules::Rule;
1036 use crate::rules::md013_line_length::MD013LineLength;
1037
1038 let content = "\
1042- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1043Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1044or `edition2024` annotations, such as:
1045
1046 ~~~text
1047 ```rust,edition2015
1048 // This only works in 2015.
1049 let try = true;
1050 ```
1051 ~~~
1052
1053### Build options
1054";
1055 let rules: Vec<Box<dyn Rule>> = vec![
1056 Box::new(MD013LineLength::new(80, false, false, false, true)),
1057 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1058 ];
1059
1060 let mut first_pass = content.to_string();
1061 let coordinator = FixCoordinator::new();
1062 coordinator
1063 .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1064 .expect("fix should not fail");
1065
1066 let lines: Vec<&str> = first_pass.lines().collect();
1068 for i in 0..lines.len().saturating_sub(1) {
1069 assert!(
1070 !(lines[i].is_empty() && lines[i + 1].is_empty()),
1071 "Double blank at lines {},{} after first pass:\n{first_pass}",
1072 i + 1,
1073 i + 2
1074 );
1075 }
1076
1077 let mut second_pass = first_pass.clone();
1079 let rules2: Vec<Box<dyn Rule>> = vec![
1080 Box::new(MD013LineLength::new(80, false, false, false, true)),
1081 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1082 ];
1083 let coordinator2 = FixCoordinator::new();
1084 coordinator2
1085 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1086 .expect("fix should not fail");
1087
1088 assert_eq!(
1089 first_pass, second_pass,
1090 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_front_matter_fence_does_not_drive_style_detection() {
1096 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1101 let content = "---\ndescription: |\n ```\n code\n ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let result = rule.check(&ctx).unwrap();
1104 assert!(
1105 result.is_empty(),
1106 "front-matter fence must not drive style detection, got: {result:?}"
1107 );
1108 }
1109}