1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rules::code_fence_utils::CodeFenceStyle;
3use crate::utils::range_utils::calculate_match_range;
4use toml;
5
6mod md048_config;
7use md048_config::MD048Config;
8
9#[derive(Debug, Clone, Copy)]
11struct FenceMarker<'a> {
12 fence_char: char,
14 fence_len: usize,
16 fence_start: usize,
18 rest: &'a str,
20}
21
22#[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#[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 let mut backtick_count = 0;
89 let mut tilde_count = 0;
90 let mut in_code_block = false;
91 let mut opening_fence_char = '`';
92 let mut opening_fence_len = 0usize;
93
94 for (i, line) in ctx.content.lines().enumerate() {
95 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
98 continue;
99 }
100
101 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
104 continue;
105 }
106
107 let Some(marker) = parse_fence_marker(line) else {
108 continue;
109 };
110
111 if ctx.flavor.supports_myst_directives()
113 && marker.fence_char == '`'
114 && marker.rest.trim_start().starts_with('{')
115 {
116 continue;
117 }
118
119 if !in_code_block {
120 if marker.fence_char == '`' {
122 backtick_count += 1;
123 } else {
124 tilde_count += 1;
125 }
126 in_code_block = true;
127 opening_fence_char = marker.fence_char;
128 opening_fence_len = marker.fence_len;
129 } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
130 in_code_block = false;
131 }
132 }
133
134 if backtick_count >= tilde_count && backtick_count > 0 {
137 Some(CodeFenceStyle::Backtick)
138 } else if tilde_count > 0 {
139 Some(CodeFenceStyle::Tilde)
140 } else {
141 None
142 }
143 }
144}
145
146fn max_inner_fence_length_of_char(
164 lines: &[&str],
165 opening_line: usize,
166 opening_fence_len: usize,
167 opening_char: char,
168 target_char: char,
169) -> usize {
170 let mut max_len = 0usize;
171
172 for line in lines.iter().skip(opening_line + 1) {
173 let Some(marker) = parse_fence_marker(line) else {
174 continue;
175 };
176
177 if is_closing_fence(marker, opening_char, opening_fence_len) {
179 break;
180 }
181
182 if marker.fence_char == target_char && marker.rest.trim().is_empty() {
185 max_len = max_len.max(marker.fence_len);
186 }
187 }
188
189 max_len
190}
191
192impl Rule for MD048CodeFenceStyle {
193 fn name(&self) -> &'static str {
194 "MD048"
195 }
196
197 fn description(&self) -> &'static str {
198 "Code fence style should be consistent"
199 }
200
201 fn category(&self) -> RuleCategory {
202 RuleCategory::CodeBlock
203 }
204
205 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
206 let content = ctx.content;
207 let line_index = &ctx.line_index;
208
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: Vec<&str> = content.lines().collect();
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 (line_num, &line) in lines.iter().enumerate() {
228 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
230 continue;
231 }
232
233 if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
235 continue;
236 }
237
238 let Some(marker) = parse_fence_marker(line) else {
239 continue;
240 };
241
242 if ctx.flavor.supports_myst_directives()
244 && !in_code_block
245 && marker.fence_char == '`'
246 && marker.rest.trim_start().starts_with('{')
247 {
248 continue;
249 }
250 let fence_char = marker.fence_char;
251 let fence_len = marker.fence_len;
252
253 if !in_code_block {
254 in_code_block = true;
255 code_block_fence_char = fence_char;
256 code_block_fence_len = fence_len;
257
258 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
259 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
260
261 if needs_conversion {
262 let target_char = if target_style == CodeFenceStyle::Backtick {
263 '`'
264 } else {
265 '~'
266 };
267
268 let prefix = &line[..marker.fence_start];
271 let info = marker.rest;
272 let max_inner =
273 max_inner_fence_length_of_char(&lines, line_num, fence_len, fence_char, target_char);
274 converted_fence_len = fence_len.max(max_inner + 1);
275 needs_lengthening = false;
276
277 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
278
279 let fence_start = marker.fence_start;
280 let fence_end = fence_start + fence_len;
281 let (start_line, start_col, end_line, end_col) =
282 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
283
284 warnings.push(LintWarning {
285 rule_name: Some(self.name().to_string()),
286 message: format!(
287 "Code fence style: use {} instead of {}",
288 if target_style == CodeFenceStyle::Backtick {
289 "```"
290 } else {
291 "~~~"
292 },
293 if fence_char == '`' { "```" } else { "~~~" }
294 ),
295 line: start_line,
296 column: start_col,
297 end_line,
298 end_column: end_col,
299 severity: Severity::Warning,
300 fix: Some(Fix::new(
301 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
302 replacement,
303 )),
304 });
305 } else {
306 let prefix = &line[..marker.fence_start];
311 let info = marker.rest;
312 let max_inner = max_inner_fence_length_of_char(&lines, line_num, fence_len, fence_char, fence_char);
313 if max_inner >= fence_len {
314 converted_fence_len = max_inner + 1;
315 needs_lengthening = true;
316
317 let replacement =
318 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
319
320 let fence_start = marker.fence_start;
321 let fence_end = fence_start + fence_len;
322 let (start_line, start_col, end_line, end_col) =
323 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
324
325 warnings.push(LintWarning {
326 rule_name: Some(self.name().to_string()),
327 message: format!(
328 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
329 contains interior fence sequences of equal length; \
330 use {converted_fence_len}",
331 if fence_char == '`' { "backticks" } else { "tildes" },
332 ),
333 line: start_line,
334 column: start_col,
335 end_line,
336 end_column: end_col,
337 severity: Severity::Warning,
338 fix: Some(Fix::new(
339 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
340 replacement,
341 )),
342 });
343 } else {
344 converted_fence_len = fence_len;
345 needs_lengthening = false;
346 }
347 }
348 } else {
349 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
351
352 if is_closing {
353 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
354 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
355
356 if needs_conversion || needs_lengthening {
357 let target_char = if needs_conversion {
358 if target_style == CodeFenceStyle::Backtick {
359 '`'
360 } else {
361 '~'
362 }
363 } else {
364 fence_char
365 };
366
367 let prefix = &line[..marker.fence_start];
368 let replacement = format!(
369 "{prefix}{}{}",
370 target_char.to_string().repeat(converted_fence_len),
371 marker.rest
372 );
373
374 let fence_start = marker.fence_start;
375 let fence_end = fence_start + fence_len;
376 let (start_line, start_col, end_line, end_col) =
377 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
378
379 let message = if needs_conversion {
380 format!(
381 "Code fence style: use {} instead of {}",
382 if target_style == CodeFenceStyle::Backtick {
383 "```"
384 } else {
385 "~~~"
386 },
387 if fence_char == '`' { "```" } else { "~~~" }
388 )
389 } else {
390 format!(
391 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
392 must match the lengthened outer fence; use {converted_fence_len}",
393 if fence_char == '`' { "backticks" } else { "tildes" },
394 )
395 };
396
397 warnings.push(LintWarning {
398 rule_name: Some(self.name().to_string()),
399 message,
400 line: start_line,
401 column: start_col,
402 end_line,
403 end_column: end_col,
404 severity: Severity::Warning,
405 fix: Some(Fix::new(
406 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
407 replacement,
408 )),
409 });
410 }
411
412 in_code_block = false;
413 code_block_fence_len = 0;
414 converted_fence_len = 0;
415 needs_lengthening = false;
416 }
417 }
419 }
420
421 Ok(warnings)
422 }
423
424 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
426 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
428 }
429
430 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
431 if self.should_skip(ctx) {
432 return Ok(ctx.content.to_string());
433 }
434 let warnings = self.check(ctx)?;
435 if warnings.is_empty() {
436 return Ok(ctx.content.to_string());
437 }
438 let warnings =
439 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
440 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
441 .map_err(crate::rule::LintError::InvalidInput)
442 }
443
444 fn as_any(&self) -> &dyn std::any::Any {
445 self
446 }
447
448 fn default_config_section(&self) -> Option<(String, toml::Value)> {
449 let json_value = serde_json::to_value(&self.config).ok()?;
450 Some((
451 self.name().to_string(),
452 crate::rule_config_serde::json_to_toml_value(&json_value)?,
453 ))
454 }
455
456 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
457 where
458 Self: Sized,
459 {
460 let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
461 Box::new(Self::from_config_struct(rule_config))
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::lint_context::LintContext;
469
470 #[test]
471 fn test_backtick_style_with_backticks() {
472 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
473 let content = "```\ncode\n```";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476
477 assert_eq!(result.len(), 0);
478 }
479
480 #[test]
481 fn test_backtick_style_with_tildes() {
482 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
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(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
489 assert_eq!(result[0].line, 1);
490 assert_eq!(result[1].line, 3);
491 }
492
493 #[test]
494 fn test_tilde_style_with_tildes() {
495 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
496 let content = "~~~\ncode\n~~~";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let result = rule.check(&ctx).unwrap();
499
500 assert_eq!(result.len(), 0);
501 }
502
503 #[test]
504 fn test_tilde_style_with_backticks() {
505 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
506 let content = "```\ncode\n```";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509
510 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
512 }
513
514 #[test]
515 fn test_consistent_style_tie_prefers_backtick() {
516 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
517 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520 let result = rule.check(&ctx).unwrap();
521
522 assert_eq!(result.len(), 2);
524 assert_eq!(result[0].line, 5);
525 assert_eq!(result[1].line, 7);
526 }
527
528 #[test]
529 fn test_consistent_style_tilde_most_prevalent() {
530 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
531 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx).unwrap();
535
536 assert_eq!(result.len(), 2);
538 assert_eq!(result[0].line, 5);
539 assert_eq!(result[1].line, 7);
540 }
541
542 #[test]
543 fn test_detect_style_backtick() {
544 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
545 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
546 let style = rule.detect_style(&ctx);
547
548 assert_eq!(style, Some(CodeFenceStyle::Backtick));
549 }
550
551 #[test]
552 fn test_detect_style_tilde() {
553 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
554 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
555 let style = rule.detect_style(&ctx);
556
557 assert_eq!(style, Some(CodeFenceStyle::Tilde));
558 }
559
560 #[test]
561 fn test_detect_style_none() {
562 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
563 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
564 let style = rule.detect_style(&ctx);
565
566 assert_eq!(style, None);
567 }
568
569 #[test]
570 fn test_fix_backticks_to_tildes() {
571 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
572 let content = "```\ncode\n```";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let fixed = rule.fix(&ctx).unwrap();
575
576 assert_eq!(fixed, "~~~\ncode\n~~~");
577 }
578
579 #[test]
580 fn test_fix_tildes_to_backticks() {
581 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
582 let content = "~~~\ncode\n~~~";
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let fixed = rule.fix(&ctx).unwrap();
585
586 assert_eq!(fixed, "```\ncode\n```");
587 }
588
589 #[test]
590 fn test_fix_preserves_fence_length() {
591 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
592 let content = "````\ncode with backtick\n```\ncode\n````";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let fixed = rule.fix(&ctx).unwrap();
595
596 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
597 }
598
599 #[test]
600 fn test_fix_preserves_language_info() {
601 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
602 let content = "~~~rust\nfn main() {}\n~~~";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let fixed = rule.fix(&ctx).unwrap();
605
606 assert_eq!(fixed, "```rust\nfn main() {}\n```");
607 }
608
609 #[test]
610 fn test_indented_code_fences() {
611 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
612 let content = " ```\n code\n ```";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615
616 assert_eq!(result.len(), 2);
617 }
618
619 #[test]
620 fn test_fix_indented_fences() {
621 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
622 let content = " ```\n code\n ```";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let fixed = rule.fix(&ctx).unwrap();
625
626 assert_eq!(fixed, " ~~~\n code\n ~~~");
627 }
628
629 #[test]
630 fn test_nested_fences_not_changed() {
631 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
632 let content = "```\ncode with ``` inside\n```";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let fixed = rule.fix(&ctx).unwrap();
635
636 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
637 }
638
639 #[test]
640 fn test_multiple_code_blocks() {
641 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
642 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645
646 assert_eq!(result.len(), 4); }
648
649 #[test]
650 fn test_empty_content() {
651 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
652 let content = "";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655
656 assert_eq!(result.len(), 0);
657 }
658
659 #[test]
660 fn test_preserve_trailing_newline() {
661 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
662 let content = "~~~\ncode\n~~~\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```\n");
667 }
668
669 #[test]
670 fn test_no_trailing_newline() {
671 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
672 let content = "~~~\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\n```");
677 }
678
679 #[test]
680 fn test_default_config() {
681 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
682 let (name, _config) = rule.default_config_section().unwrap();
683 assert_eq!(name, "MD048");
684 }
685
686 #[test]
689 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
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 fixed = rule.fix(&ctx).unwrap();
694
695 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
697 }
698
699 #[test]
702 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
703 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
704 let content = "~~~text\n```rust\ncode\n```\n~~~";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let warnings = rule.check(&ctx).unwrap();
707
708 assert_eq!(warnings.len(), 2);
710 let open_fix = warnings[0].fix.as_ref().unwrap();
711 let close_fix = warnings[1].fix.as_ref().unwrap();
712 assert_eq!(open_fix.replacement, "````text");
713 assert_eq!(close_fix.replacement, "````");
714 }
715
716 #[test]
719 fn test_tilde_outer_with_longer_backtick_inner() {
720 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
721 let content = "~~~text\n````rust\ncode\n````\n~~~";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let fixed = rule.fix(&ctx).unwrap();
724
725 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
726 }
727
728 #[test]
731 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
732 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
733 let content = "```text\n~~~rust\ncode\n~~~\n```";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let fixed = rule.fix(&ctx).unwrap();
736
737 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
738 }
739
740 #[test]
748 fn test_info_string_interior_not_ambiguous() {
749 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
750 let content = "```text\n```rust\ncode\n```\n```";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let warnings = rule.check(&ctx).unwrap();
758
759 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
762 }
763
764 #[test]
766 fn test_info_string_interior_fix_unchanged() {
767 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
768 let content = "```text\n```rust\ncode\n```\n```";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let fixed = rule.fix(&ctx).unwrap();
771
772 assert_eq!(fixed, content);
774 }
775
776 #[test]
778 fn test_tilde_info_string_interior_not_ambiguous() {
779 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
780 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let fixed = rule.fix(&ctx).unwrap();
783
784 assert_eq!(fixed, content);
786 }
787
788 #[test]
790 fn test_no_ambiguity_when_outer_is_longer() {
791 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
792 let content = "````text\n```rust\ncode\n```\n````";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let warnings = rule.check(&ctx).unwrap();
795
796 assert_eq!(
797 warnings.len(),
798 0,
799 "should have no warnings when outer is already longer"
800 );
801 }
802
803 #[test]
807 fn test_longer_info_string_interior_not_ambiguous() {
808 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
809 let content = "```text\n`````rust\ncode\n`````\n```";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let fixed = rule.fix(&ctx).unwrap();
817
818 assert_eq!(fixed, content);
820 }
821
822 #[test]
824 fn test_info_string_interior_consistent_style_no_warning() {
825 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
826 let content = "```text\n```rust\ncode\n```\n```";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let warnings = rule.check(&ctx).unwrap();
829
830 assert_eq!(warnings.len(), 0);
831 }
832
833 #[test]
840 fn test_cross_style_bare_inner_requires_lengthening() {
841 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
842 let content = "~~~\n`````rust\ncode\n```\n~~~";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let fixed = rule.fix(&ctx).unwrap();
848
849 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
852 }
853
854 #[test]
858 fn test_cross_style_info_only_interior_no_lengthening() {
859 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
860 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let fixed = rule.fix(&ctx).unwrap();
866
867 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
868 }
869
870 #[test]
873 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
874 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
875 let content = "````text\n```\nshowing raw fence\n```\n````";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let warnings = rule.check(&ctx).unwrap();
881
882 assert_eq!(
883 warnings.len(),
884 0,
885 "shorter bare interior sequences cannot close a 4-backtick outer"
886 );
887 }
888
889 #[test]
892 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
893 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
894 let content = "````\n```\nsome code\n```\n````";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let warnings = rule.check(&ctx).unwrap();
899
900 assert_eq!(
901 warnings.len(),
902 0,
903 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
904 );
905 }
906
907 #[test]
910 fn test_overindented_inner_sequence_not_ambiguous() {
911 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
912 let content = "```text\n ```\ncode\n```";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let warnings = rule.check(&ctx).unwrap();
915 let fixed = rule.fix(&ctx).unwrap();
916
917 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
918 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
919 }
920
921 #[test]
924 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
925 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
926 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let fixed = rule.fix(&ctx).unwrap();
929
930 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
931 }
932
933 #[test]
936 fn test_top_level_four_space_fence_marker_is_ignored() {
937 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
938 let content = " ```\n code\n ```";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let warnings = rule.check(&ctx).unwrap();
941 let fixed = rule.fix(&ctx).unwrap();
942
943 assert_eq!(warnings.len(), 0);
944 assert_eq!(fixed, content);
945 }
946
947 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let fixed = rule.fix(&ctx).unwrap();
955 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
956 let remaining = rule.check(&ctx2).unwrap();
957 assert!(
958 remaining.is_empty(),
959 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
960 remaining.len(),
961 );
962 }
963
964 #[test]
965 fn test_roundtrip_backticks_to_tildes() {
966 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
967 assert_fix_roundtrip(&rule, "```\ncode\n```");
968 }
969
970 #[test]
971 fn test_roundtrip_tildes_to_backticks() {
972 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
973 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
974 }
975
976 #[test]
977 fn test_roundtrip_mixed_fences_consistent() {
978 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
979 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
980 }
981
982 #[test]
983 fn test_roundtrip_with_info_string() {
984 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
985 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
986 }
987
988 #[test]
989 fn test_roundtrip_longer_fences() {
990 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
991 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
992 }
993
994 #[test]
995 fn test_roundtrip_nested_inner_fences() {
996 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
997 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
998 }
999
1000 #[test]
1001 fn test_roundtrip_indented_fences() {
1002 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1003 assert_fix_roundtrip(&rule, " ```\n code\n ```");
1004 }
1005
1006 #[test]
1007 fn test_roundtrip_multiple_blocks() {
1008 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1009 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
1010 }
1011
1012 #[test]
1013 fn test_roundtrip_fence_length_ambiguity() {
1014 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1015 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1016 }
1017
1018 #[test]
1019 fn test_roundtrip_trailing_newline() {
1020 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1021 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1022 }
1023
1024 #[test]
1025 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1026 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1027 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1028 }
1029
1030 #[test]
1031 fn test_roundtrip_backtick_outer_tilde_inner() {
1032 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1033 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1034 }
1035
1036 #[test]
1037 fn test_roundtrip_consistent_tilde_prevalent() {
1038 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1039 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1040 }
1041
1042 #[test]
1046 fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1047 use crate::fix_coordinator::FixCoordinator;
1048 use crate::rules::Rule;
1049 use crate::rules::md013_line_length::MD013LineLength;
1050
1051 let content = "\
1055- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1056Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1057or `edition2024` annotations, such as:
1058
1059 ~~~text
1060 ```rust,edition2015
1061 // This only works in 2015.
1062 let try = true;
1063 ```
1064 ~~~
1065
1066### Build options
1067";
1068 let rules: Vec<Box<dyn Rule>> = vec![
1069 Box::new(MD013LineLength::new(80, false, false, false, true)),
1070 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1071 ];
1072
1073 let mut first_pass = content.to_string();
1074 let coordinator = FixCoordinator::new();
1075 coordinator
1076 .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1077 .expect("fix should not fail");
1078
1079 let lines: Vec<&str> = first_pass.lines().collect();
1081 for i in 0..lines.len().saturating_sub(1) {
1082 assert!(
1083 !(lines[i].is_empty() && lines[i + 1].is_empty()),
1084 "Double blank at lines {},{} after first pass:\n{first_pass}",
1085 i + 1,
1086 i + 2
1087 );
1088 }
1089
1090 let mut second_pass = first_pass.clone();
1092 let rules2: Vec<Box<dyn Rule>> = vec![
1093 Box::new(MD013LineLength::new(80, false, false, false, true)),
1094 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1095 ];
1096 let coordinator2 = FixCoordinator::new();
1097 coordinator2
1098 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1099 .expect("fix should not fail");
1100
1101 assert_eq!(
1102 first_pass, second_pass,
1103 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1104 );
1105 }
1106}