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 line in ctx.content.lines() {
95 let Some(marker) = parse_fence_marker(line) else {
96 continue;
97 };
98
99 if !in_code_block {
100 if marker.fence_char == '`' {
102 backtick_count += 1;
103 } else {
104 tilde_count += 1;
105 }
106 in_code_block = true;
107 opening_fence_char = marker.fence_char;
108 opening_fence_len = marker.fence_len;
109 } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
110 in_code_block = false;
111 }
112 }
113
114 if backtick_count >= tilde_count && backtick_count > 0 {
117 Some(CodeFenceStyle::Backtick)
118 } else if tilde_count > 0 {
119 Some(CodeFenceStyle::Tilde)
120 } else {
121 None
122 }
123 }
124}
125
126fn max_inner_fence_length_of_char(
144 lines: &[&str],
145 opening_line: usize,
146 opening_fence_len: usize,
147 opening_char: char,
148 target_char: char,
149) -> usize {
150 let mut max_len = 0usize;
151
152 for line in lines.iter().skip(opening_line + 1) {
153 let Some(marker) = parse_fence_marker(line) else {
154 continue;
155 };
156
157 if is_closing_fence(marker, opening_char, opening_fence_len) {
159 break;
160 }
161
162 if marker.fence_char == target_char && marker.rest.trim().is_empty() {
165 max_len = max_len.max(marker.fence_len);
166 }
167 }
168
169 max_len
170}
171
172impl Rule for MD048CodeFenceStyle {
173 fn name(&self) -> &'static str {
174 "MD048"
175 }
176
177 fn description(&self) -> &'static str {
178 "Code fence style should be consistent"
179 }
180
181 fn category(&self) -> RuleCategory {
182 RuleCategory::CodeBlock
183 }
184
185 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
186 let content = ctx.content;
187 let line_index = &ctx.line_index;
188
189 let mut warnings = Vec::new();
190
191 let target_style = match self.config.style {
192 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
193 _ => self.config.style,
194 };
195
196 let lines: Vec<&str> = content.lines().collect();
197 let mut in_code_block = false;
198 let mut code_block_fence_char = '`';
199 let mut code_block_fence_len = 0usize;
200 let mut converted_fence_len = 0usize;
203 let mut needs_lengthening = false;
206
207 for (line_num, &line) in lines.iter().enumerate() {
208 let Some(marker) = parse_fence_marker(line) else {
209 continue;
210 };
211 let fence_char = marker.fence_char;
212 let fence_len = marker.fence_len;
213
214 if !in_code_block {
215 in_code_block = true;
216 code_block_fence_char = fence_char;
217 code_block_fence_len = fence_len;
218
219 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
220 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
221
222 if needs_conversion {
223 let target_char = if target_style == CodeFenceStyle::Backtick {
224 '`'
225 } else {
226 '~'
227 };
228
229 let prefix = &line[..marker.fence_start];
232 let info = marker.rest;
233 let max_inner =
234 max_inner_fence_length_of_char(&lines, line_num, fence_len, fence_char, target_char);
235 converted_fence_len = fence_len.max(max_inner + 1);
236 needs_lengthening = false;
237
238 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
239
240 let fence_start = marker.fence_start;
241 let fence_end = fence_start + fence_len;
242 let (start_line, start_col, end_line, end_col) =
243 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
244
245 warnings.push(LintWarning {
246 rule_name: Some(self.name().to_string()),
247 message: format!(
248 "Code fence style: use {} instead of {}",
249 if target_style == CodeFenceStyle::Backtick {
250 "```"
251 } else {
252 "~~~"
253 },
254 if fence_char == '`' { "```" } else { "~~~" }
255 ),
256 line: start_line,
257 column: start_col,
258 end_line,
259 end_column: end_col,
260 severity: Severity::Warning,
261 fix: Some(Fix {
262 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
263 replacement,
264 }),
265 });
266 } else {
267 let prefix = &line[..marker.fence_start];
272 let info = marker.rest;
273 let max_inner = max_inner_fence_length_of_char(&lines, line_num, fence_len, fence_char, fence_char);
274 if max_inner >= fence_len {
275 converted_fence_len = max_inner + 1;
276 needs_lengthening = true;
277
278 let replacement =
279 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
280
281 let fence_start = marker.fence_start;
282 let fence_end = fence_start + fence_len;
283 let (start_line, start_col, end_line, end_col) =
284 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
285
286 warnings.push(LintWarning {
287 rule_name: Some(self.name().to_string()),
288 message: format!(
289 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
290 contains interior fence sequences of equal length; \
291 use {converted_fence_len}",
292 if fence_char == '`' { "backticks" } else { "tildes" },
293 ),
294 line: start_line,
295 column: start_col,
296 end_line,
297 end_column: end_col,
298 severity: Severity::Warning,
299 fix: Some(Fix {
300 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
301 replacement,
302 }),
303 });
304 } else {
305 converted_fence_len = fence_len;
306 needs_lengthening = false;
307 }
308 }
309 } else {
310 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
312
313 if is_closing {
314 let needs_conversion = (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
315 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick);
316
317 if needs_conversion || needs_lengthening {
318 let target_char = if needs_conversion {
319 if target_style == CodeFenceStyle::Backtick {
320 '`'
321 } else {
322 '~'
323 }
324 } else {
325 fence_char
326 };
327
328 let prefix = &line[..marker.fence_start];
329 let replacement = format!(
330 "{prefix}{}{}",
331 target_char.to_string().repeat(converted_fence_len),
332 marker.rest
333 );
334
335 let fence_start = marker.fence_start;
336 let fence_end = fence_start + fence_len;
337 let (start_line, start_col, end_line, end_col) =
338 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
339
340 let message = if needs_conversion {
341 format!(
342 "Code fence style: use {} instead of {}",
343 if target_style == CodeFenceStyle::Backtick {
344 "```"
345 } else {
346 "~~~"
347 },
348 if fence_char == '`' { "```" } else { "~~~" }
349 )
350 } else {
351 format!(
352 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
353 must match the lengthened outer fence; use {converted_fence_len}",
354 if fence_char == '`' { "backticks" } else { "tildes" },
355 )
356 };
357
358 warnings.push(LintWarning {
359 rule_name: Some(self.name().to_string()),
360 message,
361 line: start_line,
362 column: start_col,
363 end_line,
364 end_column: end_col,
365 severity: Severity::Warning,
366 fix: Some(Fix {
367 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
368 replacement,
369 }),
370 });
371 }
372
373 in_code_block = false;
374 code_block_fence_len = 0;
375 converted_fence_len = 0;
376 needs_lengthening = false;
377 }
378 }
380 }
381
382 Ok(warnings)
383 }
384
385 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
387 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
389 }
390
391 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
392 if self.should_skip(ctx) {
393 return Ok(ctx.content.to_string());
394 }
395 let warnings = self.check(ctx)?;
396 if warnings.is_empty() {
397 return Ok(ctx.content.to_string());
398 }
399 let warnings =
400 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
401 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
402 .map_err(crate::rule::LintError::InvalidInput)
403 }
404
405 fn as_any(&self) -> &dyn std::any::Any {
406 self
407 }
408
409 fn default_config_section(&self) -> Option<(String, toml::Value)> {
410 let json_value = serde_json::to_value(&self.config).ok()?;
411 Some((
412 self.name().to_string(),
413 crate::rule_config_serde::json_to_toml_value(&json_value)?,
414 ))
415 }
416
417 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
418 where
419 Self: Sized,
420 {
421 let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
422 Box::new(Self::from_config_struct(rule_config))
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::lint_context::LintContext;
430
431 #[test]
432 fn test_backtick_style_with_backticks() {
433 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
434 let content = "```\ncode\n```";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437
438 assert_eq!(result.len(), 0);
439 }
440
441 #[test]
442 fn test_backtick_style_with_tildes() {
443 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
444 let content = "~~~\ncode\n~~~";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447
448 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
450 assert_eq!(result[0].line, 1);
451 assert_eq!(result[1].line, 3);
452 }
453
454 #[test]
455 fn test_tilde_style_with_tildes() {
456 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
457 let content = "~~~\ncode\n~~~";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460
461 assert_eq!(result.len(), 0);
462 }
463
464 #[test]
465 fn test_tilde_style_with_backticks() {
466 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
467 let content = "```\ncode\n```";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469 let result = rule.check(&ctx).unwrap();
470
471 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
473 }
474
475 #[test]
476 fn test_consistent_style_tie_prefers_backtick() {
477 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
478 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482
483 assert_eq!(result.len(), 2);
485 assert_eq!(result[0].line, 5);
486 assert_eq!(result[1].line, 7);
487 }
488
489 #[test]
490 fn test_consistent_style_tilde_most_prevalent() {
491 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
492 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\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);
499 assert_eq!(result[0].line, 5);
500 assert_eq!(result[1].line, 7);
501 }
502
503 #[test]
504 fn test_detect_style_backtick() {
505 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
506 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
507 let style = rule.detect_style(&ctx);
508
509 assert_eq!(style, Some(CodeFenceStyle::Backtick));
510 }
511
512 #[test]
513 fn test_detect_style_tilde() {
514 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
515 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
516 let style = rule.detect_style(&ctx);
517
518 assert_eq!(style, Some(CodeFenceStyle::Tilde));
519 }
520
521 #[test]
522 fn test_detect_style_none() {
523 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
524 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
525 let style = rule.detect_style(&ctx);
526
527 assert_eq!(style, None);
528 }
529
530 #[test]
531 fn test_fix_backticks_to_tildes() {
532 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
533 let content = "```\ncode\n```";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let fixed = rule.fix(&ctx).unwrap();
536
537 assert_eq!(fixed, "~~~\ncode\n~~~");
538 }
539
540 #[test]
541 fn test_fix_tildes_to_backticks() {
542 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
543 let content = "~~~\ncode\n~~~";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let fixed = rule.fix(&ctx).unwrap();
546
547 assert_eq!(fixed, "```\ncode\n```");
548 }
549
550 #[test]
551 fn test_fix_preserves_fence_length() {
552 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
553 let content = "````\ncode with backtick\n```\ncode\n````";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let fixed = rule.fix(&ctx).unwrap();
556
557 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
558 }
559
560 #[test]
561 fn test_fix_preserves_language_info() {
562 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
563 let content = "~~~rust\nfn main() {}\n~~~";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let fixed = rule.fix(&ctx).unwrap();
566
567 assert_eq!(fixed, "```rust\nfn main() {}\n```");
568 }
569
570 #[test]
571 fn test_indented_code_fences() {
572 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
573 let content = " ```\n code\n ```";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576
577 assert_eq!(result.len(), 2);
578 }
579
580 #[test]
581 fn test_fix_indented_fences() {
582 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
583 let content = " ```\n code\n ```";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let fixed = rule.fix(&ctx).unwrap();
586
587 assert_eq!(fixed, " ~~~\n code\n ~~~");
588 }
589
590 #[test]
591 fn test_nested_fences_not_changed() {
592 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
593 let content = "```\ncode with ``` inside\n```";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let fixed = rule.fix(&ctx).unwrap();
596
597 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
598 }
599
600 #[test]
601 fn test_multiple_code_blocks() {
602 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
603 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606
607 assert_eq!(result.len(), 4); }
609
610 #[test]
611 fn test_empty_content() {
612 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
613 let content = "";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616
617 assert_eq!(result.len(), 0);
618 }
619
620 #[test]
621 fn test_preserve_trailing_newline() {
622 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
623 let content = "~~~\ncode\n~~~\n";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let fixed = rule.fix(&ctx).unwrap();
626
627 assert_eq!(fixed, "```\ncode\n```\n");
628 }
629
630 #[test]
631 fn test_no_trailing_newline() {
632 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
633 let content = "~~~\ncode\n~~~";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let fixed = rule.fix(&ctx).unwrap();
636
637 assert_eq!(fixed, "```\ncode\n```");
638 }
639
640 #[test]
641 fn test_default_config() {
642 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
643 let (name, _config) = rule.default_config_section().unwrap();
644 assert_eq!(name, "MD048");
645 }
646
647 #[test]
650 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
651 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
652 let content = "~~~text\n```rust\ncode\n```\n~~~";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let fixed = rule.fix(&ctx).unwrap();
655
656 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
658 }
659
660 #[test]
663 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
664 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
665 let content = "~~~text\n```rust\ncode\n```\n~~~";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let warnings = rule.check(&ctx).unwrap();
668
669 assert_eq!(warnings.len(), 2);
671 let open_fix = warnings[0].fix.as_ref().unwrap();
672 let close_fix = warnings[1].fix.as_ref().unwrap();
673 assert_eq!(open_fix.replacement, "````text");
674 assert_eq!(close_fix.replacement, "````");
675 }
676
677 #[test]
680 fn test_tilde_outer_with_longer_backtick_inner() {
681 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
682 let content = "~~~text\n````rust\ncode\n````\n~~~";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let fixed = rule.fix(&ctx).unwrap();
685
686 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
687 }
688
689 #[test]
692 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
693 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
694 let content = "```text\n~~~rust\ncode\n~~~\n```";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let fixed = rule.fix(&ctx).unwrap();
697
698 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
699 }
700
701 #[test]
709 fn test_info_string_interior_not_ambiguous() {
710 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
711 let content = "```text\n```rust\ncode\n```\n```";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let warnings = rule.check(&ctx).unwrap();
719
720 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
723 }
724
725 #[test]
727 fn test_info_string_interior_fix_unchanged() {
728 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
729 let content = "```text\n```rust\ncode\n```\n```";
730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731 let fixed = rule.fix(&ctx).unwrap();
732
733 assert_eq!(fixed, content);
735 }
736
737 #[test]
739 fn test_tilde_info_string_interior_not_ambiguous() {
740 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
741 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let fixed = rule.fix(&ctx).unwrap();
744
745 assert_eq!(fixed, content);
747 }
748
749 #[test]
751 fn test_no_ambiguity_when_outer_is_longer() {
752 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
753 let content = "````text\n```rust\ncode\n```\n````";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let warnings = rule.check(&ctx).unwrap();
756
757 assert_eq!(
758 warnings.len(),
759 0,
760 "should have no warnings when outer is already longer"
761 );
762 }
763
764 #[test]
768 fn test_longer_info_string_interior_not_ambiguous() {
769 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
770 let content = "```text\n`````rust\ncode\n`````\n```";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let fixed = rule.fix(&ctx).unwrap();
778
779 assert_eq!(fixed, content);
781 }
782
783 #[test]
785 fn test_info_string_interior_consistent_style_no_warning() {
786 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
787 let content = "```text\n```rust\ncode\n```\n```";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let warnings = rule.check(&ctx).unwrap();
790
791 assert_eq!(warnings.len(), 0);
792 }
793
794 #[test]
801 fn test_cross_style_bare_inner_requires_lengthening() {
802 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
803 let content = "~~~\n`````rust\ncode\n```\n~~~";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let fixed = rule.fix(&ctx).unwrap();
809
810 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
813 }
814
815 #[test]
819 fn test_cross_style_info_only_interior_no_lengthening() {
820 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
821 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let fixed = rule.fix(&ctx).unwrap();
827
828 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
829 }
830
831 #[test]
834 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
835 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
836 let content = "````text\n```\nshowing raw fence\n```\n````";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let warnings = rule.check(&ctx).unwrap();
842
843 assert_eq!(
844 warnings.len(),
845 0,
846 "shorter bare interior sequences cannot close a 4-backtick outer"
847 );
848 }
849
850 #[test]
853 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
854 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
855 let content = "````\n```\nsome code\n```\n````";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let warnings = rule.check(&ctx).unwrap();
860
861 assert_eq!(
862 warnings.len(),
863 0,
864 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
865 );
866 }
867
868 #[test]
871 fn test_overindented_inner_sequence_not_ambiguous() {
872 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
873 let content = "```text\n ```\ncode\n```";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let warnings = rule.check(&ctx).unwrap();
876 let fixed = rule.fix(&ctx).unwrap();
877
878 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
879 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
880 }
881
882 #[test]
885 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
886 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
887 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let fixed = rule.fix(&ctx).unwrap();
890
891 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
892 }
893
894 #[test]
897 fn test_top_level_four_space_fence_marker_is_ignored() {
898 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
899 let content = " ```\n code\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);
905 assert_eq!(fixed, content);
906 }
907
908 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let fixed = rule.fix(&ctx).unwrap();
916 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
917 let remaining = rule.check(&ctx2).unwrap();
918 assert!(
919 remaining.is_empty(),
920 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
921 remaining.len(),
922 );
923 }
924
925 #[test]
926 fn test_roundtrip_backticks_to_tildes() {
927 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
928 assert_fix_roundtrip(&rule, "```\ncode\n```");
929 }
930
931 #[test]
932 fn test_roundtrip_tildes_to_backticks() {
933 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
934 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
935 }
936
937 #[test]
938 fn test_roundtrip_mixed_fences_consistent() {
939 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
940 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
941 }
942
943 #[test]
944 fn test_roundtrip_with_info_string() {
945 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
946 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
947 }
948
949 #[test]
950 fn test_roundtrip_longer_fences() {
951 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
952 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
953 }
954
955 #[test]
956 fn test_roundtrip_nested_inner_fences() {
957 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
958 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
959 }
960
961 #[test]
962 fn test_roundtrip_indented_fences() {
963 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
964 assert_fix_roundtrip(&rule, " ```\n code\n ```");
965 }
966
967 #[test]
968 fn test_roundtrip_multiple_blocks() {
969 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
970 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
971 }
972
973 #[test]
974 fn test_roundtrip_fence_length_ambiguity() {
975 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
976 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
977 }
978
979 #[test]
980 fn test_roundtrip_trailing_newline() {
981 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
982 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
983 }
984
985 #[test]
986 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
987 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
988 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
989 }
990
991 #[test]
992 fn test_roundtrip_backtick_outer_tilde_inner() {
993 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
994 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
995 }
996
997 #[test]
998 fn test_roundtrip_consistent_tilde_prevalent() {
999 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1000 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1001 }
1002
1003 #[test]
1007 fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1008 use crate::fix_coordinator::FixCoordinator;
1009 use crate::rules::Rule;
1010 use crate::rules::md013_line_length::MD013LineLength;
1011
1012 let content = "\
1016- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1017Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1018or `edition2024` annotations, such as:
1019
1020 ~~~text
1021 ```rust,edition2015
1022 // This only works in 2015.
1023 let try = true;
1024 ```
1025 ~~~
1026
1027### Build options
1028";
1029 let rules: Vec<Box<dyn Rule>> = vec![
1030 Box::new(MD013LineLength::new(80, false, false, false, true)),
1031 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1032 ];
1033
1034 let mut first_pass = content.to_string();
1035 let coordinator = FixCoordinator::new();
1036 coordinator
1037 .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1038 .expect("fix should not fail");
1039
1040 let lines: Vec<&str> = first_pass.lines().collect();
1042 for i in 0..lines.len().saturating_sub(1) {
1043 assert!(
1044 !(lines[i].is_empty() && lines[i + 1].is_empty()),
1045 "Double blank at lines {},{} after first pass:\n{first_pass}",
1046 i + 1,
1047 i + 2
1048 );
1049 }
1050
1051 let mut second_pass = first_pass.clone();
1053 let rules2: Vec<Box<dyn Rule>> = vec![
1054 Box::new(MD013LineLength::new(80, false, false, false, true)),
1055 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1056 ];
1057 let coordinator2 = FixCoordinator::new();
1058 coordinator2
1059 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1060 .expect("fix should not fail");
1061
1062 assert_eq!(
1063 first_pass, second_pass,
1064 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1065 );
1066 }
1067}