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