1use crate::config::MarkdownFlavor;
2use crate::filtered_lines::FilteredLinesExt;
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
5use crate::rules::code_fence_utils::CodeFenceStyle;
6use crate::utils::range_utils::calculate_match_range;
7use toml;
8
9mod md048_config;
10use md048_config::MD048Config;
11
12static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
14
15#[derive(Debug, Clone, Copy)]
17struct FenceMarker<'a> {
18 fence_char: char,
20 fence_len: usize,
22 fence_start: usize,
24 rest: &'a str,
26}
27
28#[inline]
34fn parse_fence_marker(line: &str) -> Option<FenceMarker<'_>> {
35 let bytes = line.as_bytes();
36 let mut pos = 0usize;
37 while pos < bytes.len() && bytes[pos] == b' ' {
38 pos += 1;
39 }
40 if pos > 3 {
41 return None;
42 }
43
44 let fence_char = match bytes.get(pos).copied() {
45 Some(b'`') => '`',
46 Some(b'~') => '~',
47 _ => return None,
48 };
49
50 let marker = if fence_char == '`' { b'`' } else { b'~' };
51 let mut end = pos;
52 while end < bytes.len() && bytes[end] == marker {
53 end += 1;
54 }
55 let fence_len = end - pos;
56 if fence_len < 3 {
57 return None;
58 }
59
60 Some(FenceMarker {
61 fence_char,
62 fence_len,
63 fence_start: pos,
64 rest: &line[end..],
65 })
66}
67
68#[inline]
69fn is_closing_fence(marker: FenceMarker<'_>, opening_fence_char: char, opening_fence_len: usize) -> bool {
70 marker.fence_char == opening_fence_char && marker.fence_len >= opening_fence_len && marker.rest.trim().is_empty()
71}
72
73#[inline]
74fn needs_fence_conversion(fence_char: char, target_style: CodeFenceStyle) -> bool {
75 (fence_char == '`' && target_style == CodeFenceStyle::Tilde)
76 || (fence_char == '~' && target_style == CodeFenceStyle::Backtick)
77}
78
79fn native_list_fence_end(ctx: &crate::lint_context::LintContext, line: usize, marker_start: usize) -> Option<usize> {
82 let info = ctx.lines.get(line)?;
83 if !info.in_list_block {
84 return None;
85 }
86 let start = info.byte_offset + marker_start;
87 let index = ctx.code_block_details.partition_point(|block| block.start < start);
88 let block = ctx.code_block_details.get(index)?;
89 if block.start != start || !block.is_fenced {
90 return None;
91 }
92 ctx.line_offsets
93 .partition_point(|&offset| offset < block.end)
94 .checked_sub(1)
95}
96
97fn parse_container_fence_marker(line: &str) -> Option<FenceMarker<'_>> {
99 let content = line.trim_start_matches([' ', '\t']);
100 let mut marker = parse_fence_marker(content)?;
101 marker.fence_start += line.len() - content.len();
102 Some(marker)
103}
104
105#[derive(Clone)]
109pub struct MD048CodeFenceStyle {
110 config: MD048Config,
111 style_explicit: bool,
115}
116
117impl MD048CodeFenceStyle {
118 pub fn new(style: CodeFenceStyle) -> Self {
119 Self {
120 config: MD048Config { style },
121 style_explicit: true,
122 }
123 }
124
125 pub fn from_config_struct(config: MD048Config) -> Self {
126 Self {
127 config,
128 style_explicit: false,
129 }
130 }
131
132 fn effective_target_style(&self, ctx: &crate::lint_context::LintContext) -> CodeFenceStyle {
140 if ctx.flavor == MarkdownFlavor::MDG {
141 self.warn_once_about_overridden_style();
142 return CodeFenceStyle::Backtick;
143 }
144
145 match self.config.style {
146 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
147 style => style,
148 }
149 }
150
151 fn warn_once_about_overridden_style(&self) {
157 if !self.style_explicit || self.config.style != CodeFenceStyle::Tilde {
158 return;
159 }
160
161 MDG_STYLE_OVERRIDE.report(
162 "MD048",
163 "style",
164 "tilde",
165 "backtick",
166 "a Gherkin Doc String is only ever a backtick fence",
167 );
168 }
169
170 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
171 let ambiguity_limit = super::code_fence_utils::fence_ambiguity_limit(ctx);
172 let mut backtick_count = 0;
174 let mut tilde_count = 0;
175 let mut in_code_block = false;
176 let mut opening_fence_char = '`';
177 let mut opening_fence_len = 0usize;
178 let mut list_fence_end = None;
179
180 for filtered_line in ctx.filtered_lines().skip_front_matter() {
181 let i = filtered_line.line_num - 1;
182 if ambiguity_limit.is_some_and(|limit| ctx.lines[i].byte_offset >= limit) {
183 break;
184 }
185 let line = filtered_line.content;
186 if list_fence_end.is_some_and(|end| i > end) {
187 in_code_block = false;
188 list_fence_end = None;
189 }
190 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
193 continue;
194 }
195
196 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
199 continue;
200 }
201
202 let marker = if in_code_block && list_fence_end == Some(i) {
203 parse_container_fence_marker(line)
204 } else {
205 parse_fence_marker(line)
206 };
207 let Some(marker) = marker else {
208 continue;
209 };
210
211 let marker_offset = ctx.lines[i].byte_offset + marker.fence_start;
212 if !in_code_block && (ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)) {
213 continue;
214 }
215
216 if ctx.flavor.supports_myst_directives()
218 && marker.fence_char == '`'
219 && marker.rest.trim_start().starts_with('{')
220 {
221 continue;
222 }
223
224 if !in_code_block {
225 if marker.fence_char == '`' {
227 backtick_count += 1;
228 } else {
229 tilde_count += 1;
230 }
231 in_code_block = true;
232 opening_fence_char = marker.fence_char;
233 opening_fence_len = marker.fence_len;
234 list_fence_end = native_list_fence_end(ctx, i, marker.fence_start);
235 } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
236 in_code_block = false;
237 list_fence_end = None;
238 }
239 }
240
241 if backtick_count >= tilde_count && backtick_count > 0 {
244 Some(CodeFenceStyle::Backtick)
245 } else if tilde_count > 0 {
246 Some(CodeFenceStyle::Tilde)
247 } else {
248 None
249 }
250 }
251}
252
253fn max_inner_fence_length_of_char(
271 lines: &[&str],
272 opening_line: usize,
273 opening_fence_len: usize,
274 opening_char: char,
275 target_char: char,
276 list_fence_end: Option<usize>,
277) -> usize {
278 let mut max_len = 0usize;
279
280 for (index, line) in lines.iter().enumerate().skip(opening_line + 1) {
281 if list_fence_end.is_some_and(|end| index > end) {
282 break;
283 }
284 let marker = if list_fence_end.is_some() && target_char != opening_char {
285 parse_container_fence_marker(line)
286 } else {
287 parse_fence_marker(line)
288 };
289 let Some(marker) = marker else {
290 continue;
291 };
292
293 if list_fence_end.is_none_or(|end| index == end) && is_closing_fence(marker, opening_char, opening_fence_len) {
295 break;
296 }
297
298 if marker.fence_char == target_char && marker.rest.trim().is_empty() {
301 max_len = max_len.max(marker.fence_len);
302 }
303 }
304
305 max_len
306}
307
308impl Rule for MD048CodeFenceStyle {
309 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
310 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(48))
311 }
312
313 fn name(&self) -> &'static str {
314 "MD048"
315 }
316
317 fn description(&self) -> &'static str {
318 "Code fence style should be consistent"
319 }
320
321 fn category(&self) -> RuleCategory {
322 RuleCategory::CodeBlock
323 }
324
325 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
326 let mut warnings = Vec::new();
327 let ambiguity_limit = super::code_fence_utils::fence_ambiguity_limit(ctx);
328
329 let target_style = self.effective_target_style(ctx);
330
331 let lines = ctx.raw_lines();
332 let mut in_code_block = false;
333 let mut code_block_fence_char = '`';
334 let mut code_block_fence_len = 0usize;
335 let mut converted_fence_len = 0usize;
338 let mut needs_lengthening = false;
341 let mut opening_fence_disabled = false;
348 let mut opening_warning = None;
353 let mut list_fence_end = None;
354
355 for filtered_line in ctx.filtered_lines().skip_front_matter() {
356 let line_num = filtered_line.line_num - 1;
357 if ambiguity_limit.is_some_and(|limit| ctx.lines[line_num].byte_offset >= limit) {
358 break;
359 }
360 let line = filtered_line.content;
361 if list_fence_end.is_some_and(|end| line_num > end) {
362 in_code_block = false;
363 list_fence_end = None;
364 }
365 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
367 continue;
368 }
369
370 if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
372 continue;
373 }
374
375 let marker = if in_code_block && list_fence_end == Some(line_num) {
376 parse_container_fence_marker(line)
377 } else {
378 parse_fence_marker(line)
379 };
380 let Some(marker) = marker else {
381 continue;
382 };
383
384 let marker_offset = ctx.lines[line_num].byte_offset + marker.fence_start;
385 if !in_code_block && (ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)) {
386 continue;
387 }
388
389 if ctx.flavor.supports_myst_directives()
391 && !in_code_block
392 && marker.fence_char == '`'
393 && marker.rest.trim_start().starts_with('{')
394 {
395 continue;
396 }
397 let fence_char = marker.fence_char;
398 let fence_len = marker.fence_len;
399
400 if !in_code_block {
401 in_code_block = true;
402 code_block_fence_char = fence_char;
403 code_block_fence_len = fence_len;
404 opening_fence_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num + 1);
405 list_fence_end = native_list_fence_end(ctx, line_num, marker.fence_start);
406 let can_convert_block =
408 !(fence_char == '~' && target_style == CodeFenceStyle::Backtick && marker.rest.contains('`'));
409 let reported_before = warnings.len();
410
411 let needs_conversion = needs_fence_conversion(fence_char, target_style);
412
413 if needs_conversion {
414 let target_char = if target_style == CodeFenceStyle::Backtick {
415 '`'
416 } else {
417 '~'
418 };
419
420 let prefix = &line[..marker.fence_start];
423 let info = marker.rest;
424 let max_inner = max_inner_fence_length_of_char(
425 lines,
426 line_num,
427 fence_len,
428 fence_char,
429 target_char,
430 list_fence_end,
431 );
432 converted_fence_len = fence_len.max(max_inner + 1);
433 needs_lengthening = false;
434
435 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
436
437 let fence_start = marker.fence_start;
438 let fence_end = fence_start + fence_len;
439 let (start_line, start_col, end_line, end_col) =
440 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
441
442 warnings.push(LintWarning {
443 rule_name: Some(self.name().to_string()),
444 message: format!(
445 "Code fence style: use {} instead of {}",
446 if target_style == CodeFenceStyle::Backtick {
447 "```"
448 } else {
449 "~~~"
450 },
451 if fence_char == '`' { "```" } else { "~~~" }
452 ),
453 line: start_line,
454 column: start_col,
455 end_line,
456 end_column: end_col,
457 severity: Severity::Warning,
458 fix: can_convert_block.then(|| {
459 Fix::new(
460 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
461 replacement,
462 )
463 }),
464 });
465 } else {
466 let prefix = &line[..marker.fence_start];
472 let info = marker.rest;
473 let max_inner = max_inner_fence_length_of_char(
474 lines,
475 line_num,
476 fence_len,
477 fence_char,
478 fence_char,
479 list_fence_end,
480 );
481 if max_inner >= fence_len {
482 converted_fence_len = max_inner + 1;
483 needs_lengthening = true;
484
485 let replacement =
486 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
487
488 let fence_start = marker.fence_start;
489 let fence_end = fence_start + fence_len;
490 let (start_line, start_col, end_line, end_col) =
491 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
492
493 warnings.push(LintWarning {
494 rule_name: Some(self.name().to_string()),
495 message: format!(
496 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
497 contains interior fence sequences of equal length; \
498 use {converted_fence_len}",
499 if fence_char == '`' { "backticks" } else { "tildes" },
500 ),
501 line: start_line,
502 column: start_col,
503 end_line,
504 end_column: end_col,
505 severity: Severity::Warning,
506 fix: Some(Fix::new(
507 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
508 replacement,
509 )),
510 });
511 } else {
512 converted_fence_len = fence_len;
513 needs_lengthening = false;
514 }
515 }
516 opening_warning = (warnings.len() > reported_before).then_some(reported_before);
517 } else {
518 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
520
521 if is_closing {
522 let needs_conversion = needs_fence_conversion(fence_char, target_style);
523
524 if (needs_conversion || needs_lengthening) && !opening_fence_disabled {
525 let target_char = if needs_conversion {
526 if target_style == CodeFenceStyle::Backtick {
527 '`'
528 } else {
529 '~'
530 }
531 } else {
532 fence_char
533 };
534
535 let prefix = &line[..marker.fence_start];
536 let replacement = format!(
537 "{prefix}{}{}",
538 target_char.to_string().repeat(converted_fence_len),
539 marker.rest
540 );
541
542 let fence_start = marker.fence_start;
543 let fence_end = fence_start + fence_len;
544 let (start_line, start_col, end_line, end_col) =
545 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
546
547 let message = if needs_conversion {
548 format!(
549 "Code fence style: use {} instead of {}",
550 if target_style == CodeFenceStyle::Backtick {
551 "```"
552 } else {
553 "~~~"
554 },
555 if fence_char == '`' { "```" } else { "~~~" }
556 )
557 } else {
558 format!(
559 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
560 must match the lengthened outer fence; use {converted_fence_len}",
561 if fence_char == '`' { "backticks" } else { "tildes" },
562 )
563 };
564
565 let closing_range = ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len());
568 let fix = opening_warning
569 .and_then(|index| warnings[index].fix.as_mut())
570 .map(|opening| {
571 let opening_edit = Fix::new(opening.range.clone(), opening.replacement.clone());
572 opening
573 .additional_edits
574 .push(Fix::new(closing_range.clone(), replacement.clone()));
575 Fix::with_additional_edits(closing_range, replacement, vec![opening_edit])
576 });
577
578 warnings.push(LintWarning {
579 rule_name: Some(self.name().to_string()),
580 message,
581 line: start_line,
582 column: start_col,
583 end_line,
584 end_column: end_col,
585 severity: Severity::Warning,
586 fix,
587 });
588 }
589
590 in_code_block = false;
591 list_fence_end = None;
592 code_block_fence_len = 0;
593 converted_fence_len = 0;
594 needs_lengthening = false;
595 }
596 }
598 }
599
600 Ok(warnings)
601 }
602
603 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
605 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
607 }
608
609 fn fixes_from_diagnostics(&self) -> bool {
610 true
611 }
612
613 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
614 if self.should_skip(ctx) {
615 return Ok(ctx.content.to_string());
616 }
617 let warnings = self.check(ctx)?;
618 if warnings.is_empty() {
619 return Ok(ctx.content.to_string());
620 }
621 let warnings =
622 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
623 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
624 .map_err(crate::rule::LintError::InvalidInput)
625 }
626
627 fn as_any(&self) -> &dyn std::any::Any {
628 self
629 }
630
631 crate::impl_rule_config_sections!(MD048Config);
632
633 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
634 where
635 Self: Sized,
636 {
637 let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
638 let style_explicit = option_is_explicit(config, "MD048", "style");
639
640 Box::new(Self {
641 config: rule_config,
642 style_explicit,
643 })
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::lint_context::LintContext;
651
652 #[test]
653 fn test_backtick_style_with_backticks() {
654 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
655 let content = "```\ncode\n```";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658
659 assert_eq!(result.len(), 0);
660 }
661
662 #[test]
663 fn test_backtick_style_with_tildes() {
664 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
665 let content = "~~~\ncode\n~~~";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668
669 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
671 assert_eq!(result[0].line, 1);
672 assert_eq!(result[1].line, 3);
673 }
674
675 #[test]
676 fn test_tilde_style_with_tildes() {
677 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
678 let content = "~~~\ncode\n~~~";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681
682 assert_eq!(result.len(), 0);
683 }
684
685 #[test]
686 fn test_tilde_style_with_backticks() {
687 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
688 let content = "```\ncode\n```";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691
692 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
694 }
695
696 #[test]
697 fn test_mdg_overrides_tilde_style_to_backtick() {
698 let backticks = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n ```json\n {\"ok\": true}\n ```";
703 let tildes =
704 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ~~~text\n example\n ~~~";
705
706 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
707 let ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::MDG, None);
708 assert!(rule.check(&ctx).unwrap().is_empty());
709 assert_eq!(rule.fix(&ctx).unwrap(), backticks);
710
711 let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
712 assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
713 let fixed = rule.fix(&ctx).unwrap();
714 assert_eq!(
715 fixed,
716 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ```text\n example\n ```"
717 );
718
719 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
720 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
721 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
722
723 let standard_ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::Standard, None);
725 assert_eq!(rule.check(&standard_ctx).unwrap().len(), 2);
726 assert!(rule.fix(&standard_ctx).unwrap().contains("~~~json"));
727
728 let standard_ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::Standard, None);
729 assert!(rule.check(&standard_ctx).unwrap().is_empty());
730 assert_eq!(rule.fix(&standard_ctx).unwrap(), tildes);
731 }
732
733 #[test]
734 fn test_mdg_applies_backtick_style() {
735 let tildes =
738 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ~~~text\n example\n ~~~";
739
740 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
741 let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
742 assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
743 assert_eq!(
744 rule.fix(&ctx).unwrap(),
745 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ```text\n example\n ```"
746 );
747 }
748
749 #[test]
750 fn test_mdg_tilde_style_still_disambiguates_fence_length() {
751 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
755 let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n~~~\n```rust\ncode\n```\n~~~";
756
757 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
758 let fixed = rule.fix(&mdg_ctx).unwrap();
759 assert_eq!(
760 fixed,
761 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n````\n```rust\ncode\n```\n````"
762 );
763
764 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
765 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
766
767 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 assert!(rule.check(&standard_ctx).unwrap().is_empty());
771 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
772 }
773
774 #[test]
775 fn test_from_config_records_whether_style_was_configured() {
776 use crate::config::Config;
780 use std::collections::BTreeMap;
781
782 let mut values = BTreeMap::new();
783 values.insert("style".to_string(), toml::Value::String("tilde".to_string()));
784 let mut config = Config::default();
785 config.rules.insert(
786 "MD048".to_string(),
787 crate::config::RuleConfig { severity: None, values },
788 );
789
790 let configured = MD048CodeFenceStyle::from_config(&config);
791 let configured = configured.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
792 assert_eq!(configured.config.style, CodeFenceStyle::Tilde);
793 assert!(configured.style_explicit);
794
795 let defaulted = MD048CodeFenceStyle::from_config(&Config::default());
796 let defaulted = defaulted.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
797 assert!(!defaulted.style_explicit);
798
799 let tilde = MD048CodeFenceStyle::from_config_struct(MD048Config {
802 style: CodeFenceStyle::Tilde,
803 });
804 let content = "# Feature: F\n\n~~~text\nexample\n~~~";
805 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
806 assert_eq!(tilde.fix(&mdg_ctx).unwrap(), "# Feature: F\n\n```text\nexample\n```");
807 }
808
809 #[test]
810 fn test_mdg_consistent_fence_style_ignores_tilde_prevalence() {
811 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
814 let content = "# Feature: Checkout\n\n~~~text\nfirst example\n~~~\n\n~~~text\nsecond example\n~~~\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n ```json\n {\"ok\": true}\n ```";
815
816 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 assert_eq!(rule.detect_style(&standard_ctx), Some(CodeFenceStyle::Tilde));
818
819 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
820 let warnings = rule.check(&mdg_ctx).unwrap();
821 assert_eq!(warnings.len(), 4, "two tilde blocks, each with two fence lines");
822 assert!(
823 warnings
824 .iter()
825 .all(|warning| warning.message.contains("use ``` instead of ~~~"))
826 );
827
828 let fixed = rule.fix(&mdg_ctx).unwrap();
829 assert!(!fixed.contains("~~~"), "MDG must convert every fence: {fixed:?}");
830
831 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
832 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
833 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
834 }
835
836 #[test]
837 fn test_consistent_style_tie_prefers_backtick() {
838 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
839 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843
844 assert_eq!(result.len(), 2);
846 assert_eq!(result[0].line, 5);
847 assert_eq!(result[1].line, 7);
848 }
849
850 #[test]
851 fn test_consistent_style_tilde_most_prevalent() {
852 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
853 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857
858 assert_eq!(result.len(), 2);
860 assert_eq!(result[0].line, 5);
861 assert_eq!(result[1].line, 7);
862 }
863
864 #[test]
865 fn test_detect_style_backtick() {
866 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
867 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
868 let style = rule.detect_style(&ctx);
869
870 assert_eq!(style, Some(CodeFenceStyle::Backtick));
871 }
872
873 #[test]
874 fn test_detect_style_tilde() {
875 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
876 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
877 let style = rule.detect_style(&ctx);
878
879 assert_eq!(style, Some(CodeFenceStyle::Tilde));
880 }
881
882 #[test]
883 fn test_detect_style_none() {
884 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
885 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
886 let style = rule.detect_style(&ctx);
887
888 assert_eq!(style, None);
889 }
890
891 #[test]
892 fn test_fix_backticks_to_tildes() {
893 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
894 let content = "```\ncode\n```";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let fixed = rule.fix(&ctx).unwrap();
897
898 assert_eq!(fixed, "~~~\ncode\n~~~");
899 }
900
901 #[test]
902 fn test_fix_tildes_to_backticks() {
903 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
904 let content = "~~~\ncode\n~~~";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let fixed = rule.fix(&ctx).unwrap();
907
908 assert_eq!(fixed, "```\ncode\n```");
909 }
910
911 #[test]
912 fn test_fix_preserves_fence_length() {
913 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
914 let content = "````\ncode with backtick\n```\ncode\n````";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let fixed = rule.fix(&ctx).unwrap();
917
918 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
919 }
920
921 #[test]
922 fn test_fix_preserves_language_info() {
923 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
924 let content = "~~~rust\nfn main() {}\n~~~";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let fixed = rule.fix(&ctx).unwrap();
927
928 assert_eq!(fixed, "```rust\nfn main() {}\n```");
929 }
930
931 #[test]
932 fn test_indented_code_fences() {
933 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
934 let content = " ```\n code\n ```";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937
938 assert_eq!(result.len(), 2);
939 }
940
941 #[test]
942 fn test_fix_indented_fences() {
943 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
944 let content = " ```\n code\n ```";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let fixed = rule.fix(&ctx).unwrap();
947
948 assert_eq!(fixed, " ~~~\n code\n ~~~");
949 }
950
951 #[test]
952 fn test_nested_fences_not_changed() {
953 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
954 let content = "```\ncode with ``` inside\n```";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let fixed = rule.fix(&ctx).unwrap();
957
958 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
959 }
960
961 #[test]
962 fn test_multiple_code_blocks() {
963 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
964 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.check(&ctx).unwrap();
967
968 assert_eq!(result.len(), 4); }
970
971 #[test]
972 fn test_empty_content() {
973 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
974 let content = "";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977
978 assert_eq!(result.len(), 0);
979 }
980
981 #[test]
982 fn test_preserve_trailing_newline() {
983 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
984 let content = "~~~\ncode\n~~~\n";
985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let fixed = rule.fix(&ctx).unwrap();
987
988 assert_eq!(fixed, "```\ncode\n```\n");
989 }
990
991 #[test]
992 fn test_no_trailing_newline() {
993 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
994 let content = "~~~\ncode\n~~~";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let fixed = rule.fix(&ctx).unwrap();
997
998 assert_eq!(fixed, "```\ncode\n```");
999 }
1000
1001 #[test]
1002 fn test_default_config() {
1003 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1004 let (name, _config) = rule.default_config_section().unwrap();
1005 assert_eq!(name, "MD048");
1006 }
1007
1008 #[test]
1011 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
1012 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1013 let content = "~~~text\n```rust\ncode\n```\n~~~";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let fixed = rule.fix(&ctx).unwrap();
1016
1017 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
1019 }
1020
1021 #[test]
1024 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
1025 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1026 let content = "~~~text\n```rust\ncode\n```\n~~~";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let warnings = rule.check(&ctx).unwrap();
1029
1030 assert_eq!(warnings.len(), 2);
1032 let open_fix = warnings[0].fix.as_ref().unwrap();
1033 let close_fix = warnings[1].fix.as_ref().unwrap();
1034 assert_eq!(open_fix.replacement, "````text");
1035 assert_eq!(close_fix.replacement, "````");
1036 }
1037
1038 #[test]
1041 fn test_tilde_outer_with_longer_backtick_inner() {
1042 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1043 let content = "~~~text\n````rust\ncode\n````\n~~~";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let fixed = rule.fix(&ctx).unwrap();
1046
1047 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
1048 }
1049
1050 #[test]
1053 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
1054 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1055 let content = "```text\n~~~rust\ncode\n~~~\n```";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let fixed = rule.fix(&ctx).unwrap();
1058
1059 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
1060 }
1061
1062 #[test]
1070 fn test_info_string_interior_not_ambiguous() {
1071 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1072 let content = "```text\n```rust\ncode\n```\n```";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079 let warnings = rule.check(&ctx).unwrap();
1080
1081 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
1084 }
1085
1086 #[test]
1088 fn test_info_string_interior_fix_unchanged() {
1089 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1090 let content = "```text\n```rust\ncode\n```\n```";
1091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092 let fixed = rule.fix(&ctx).unwrap();
1093
1094 assert_eq!(fixed, content);
1096 }
1097
1098 #[test]
1100 fn test_tilde_info_string_interior_not_ambiguous() {
1101 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1102 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let fixed = rule.fix(&ctx).unwrap();
1105
1106 assert_eq!(fixed, content);
1108 }
1109
1110 #[test]
1112 fn test_no_ambiguity_when_outer_is_longer() {
1113 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1114 let content = "````text\n```rust\ncode\n```\n````";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let warnings = rule.check(&ctx).unwrap();
1117
1118 assert_eq!(
1119 warnings.len(),
1120 0,
1121 "should have no warnings when outer is already longer"
1122 );
1123 }
1124
1125 #[test]
1129 fn test_longer_info_string_interior_not_ambiguous() {
1130 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1131 let content = "```text\n`````rust\ncode\n`````\n```";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138 let fixed = rule.fix(&ctx).unwrap();
1139
1140 assert_eq!(fixed, content);
1142 }
1143
1144 #[test]
1146 fn test_info_string_interior_consistent_style_no_warning() {
1147 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1148 let content = "```text\n```rust\ncode\n```\n```";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let warnings = rule.check(&ctx).unwrap();
1151
1152 assert_eq!(warnings.len(), 0);
1153 }
1154
1155 #[test]
1162 fn test_cross_style_bare_inner_requires_lengthening() {
1163 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1164 let content = "~~~\n`````rust\ncode\n```\n~~~";
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 let fixed = rule.fix(&ctx).unwrap();
1170
1171 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
1174 }
1175
1176 #[test]
1180 fn test_cross_style_info_only_interior_no_lengthening() {
1181 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1182 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let fixed = rule.fix(&ctx).unwrap();
1188
1189 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
1190 }
1191
1192 #[test]
1195 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
1196 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1197 let content = "````text\n```\nshowing raw fence\n```\n````";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let warnings = rule.check(&ctx).unwrap();
1203
1204 assert_eq!(
1205 warnings.len(),
1206 0,
1207 "shorter bare interior sequences cannot close a 4-backtick outer"
1208 );
1209 }
1210
1211 #[test]
1214 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
1215 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1216 let content = "````\n```\nsome code\n```\n````";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let warnings = rule.check(&ctx).unwrap();
1221
1222 assert_eq!(
1223 warnings.len(),
1224 0,
1225 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
1226 );
1227 }
1228
1229 #[test]
1232 fn test_overindented_inner_sequence_not_ambiguous() {
1233 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1234 let content = "```text\n ```\ncode\n```";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let warnings = rule.check(&ctx).unwrap();
1237 let fixed = rule.fix(&ctx).unwrap();
1238
1239 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
1240 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
1241 }
1242
1243 #[test]
1246 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
1247 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1248 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let fixed = rule.fix(&ctx).unwrap();
1251
1252 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
1253 }
1254
1255 #[test]
1258 fn test_top_level_four_space_fence_marker_is_ignored() {
1259 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1260 let content = " ```\n code\n ```";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 let warnings = rule.check(&ctx).unwrap();
1263 let fixed = rule.fix(&ctx).unwrap();
1264
1265 assert_eq!(warnings.len(), 0);
1266 assert_eq!(fixed, content);
1267 }
1268
1269 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276 let fixed = rule.fix(&ctx).unwrap();
1277 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1278 let remaining = rule.check(&ctx2).unwrap();
1279 assert!(
1280 remaining.is_empty(),
1281 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
1282 remaining.len(),
1283 );
1284 }
1285
1286 #[test]
1287 fn test_roundtrip_backticks_to_tildes() {
1288 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1289 assert_fix_roundtrip(&rule, "```\ncode\n```");
1290 }
1291
1292 #[test]
1293 fn test_roundtrip_tildes_to_backticks() {
1294 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1295 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
1296 }
1297
1298 #[test]
1299 fn test_roundtrip_mixed_fences_consistent() {
1300 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1301 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
1302 }
1303
1304 #[test]
1305 fn test_roundtrip_with_info_string() {
1306 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1307 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
1308 }
1309
1310 #[test]
1311 fn test_roundtrip_longer_fences() {
1312 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1313 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
1314 }
1315
1316 #[test]
1317 fn test_roundtrip_nested_inner_fences() {
1318 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1319 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
1320 }
1321
1322 #[test]
1323 fn test_roundtrip_indented_fences() {
1324 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1325 assert_fix_roundtrip(&rule, " ```\n code\n ```");
1326 }
1327
1328 #[test]
1329 fn test_roundtrip_multiple_blocks() {
1330 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1331 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
1332 }
1333
1334 #[test]
1335 fn test_roundtrip_fence_length_ambiguity() {
1336 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1337 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1338 }
1339
1340 #[test]
1341 fn test_roundtrip_trailing_newline() {
1342 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1343 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1344 }
1345
1346 #[test]
1347 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1348 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1349 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1350 }
1351
1352 #[test]
1353 fn test_roundtrip_backtick_outer_tilde_inner() {
1354 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1355 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1356 }
1357
1358 #[test]
1359 fn test_roundtrip_consistent_tilde_prevalent() {
1360 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1361 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1362 }
1363
1364 #[test]
1368 fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1369 use crate::fix_coordinator::FixCoordinator;
1370 use crate::rules::Rule;
1371 use crate::rules::md013_line_length::MD013LineLength;
1372
1373 let content = "\
1377- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1378Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1379or `edition2024` annotations, such as:
1380
1381 ~~~text
1382 ```rust,edition2015
1383 // This only works in 2015.
1384 let try = true;
1385 ```
1386 ~~~
1387
1388### Build options
1389";
1390 let rules: Vec<Box<dyn Rule>> = vec![
1391 Box::new(MD013LineLength::new(80, false, false, false, true)),
1392 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1393 ];
1394
1395 let mut first_pass = content.to_string();
1396 let coordinator = FixCoordinator::new();
1397 coordinator
1398 .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1399 .expect("fix should not fail");
1400
1401 let lines: Vec<&str> = first_pass.lines().collect();
1403 for i in 0..lines.len().saturating_sub(1) {
1404 assert!(
1405 !(lines[i].is_empty() && lines[i + 1].is_empty()),
1406 "Double blank at lines {},{} after first pass:\n{first_pass}",
1407 i + 1,
1408 i + 2
1409 );
1410 }
1411
1412 let mut second_pass = first_pass.clone();
1414 let rules2: Vec<Box<dyn Rule>> = vec![
1415 Box::new(MD013LineLength::new(80, false, false, false, true)),
1416 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1417 ];
1418 let coordinator2 = FixCoordinator::new();
1419 coordinator2
1420 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1421 .expect("fix should not fail");
1422
1423 assert_eq!(
1424 first_pass, second_pass,
1425 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_front_matter_fence_does_not_drive_style_detection() {
1431 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1436 let content = "---\ndescription: |\n ```\n code\n ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438 let result = rule.check(&ctx).unwrap();
1439 assert!(
1440 result.is_empty(),
1441 "front-matter fence must not drive style detection, got: {result:?}"
1442 );
1443 }
1444}
1445
1446#[cfg(test)]
1447mod incompatible_info_tests {
1448 use super::*;
1449 use crate::lint_context::LintContext;
1450
1451 #[test]
1452 fn md048_backtick_info_keeps_tilde_fences_without_unsafe_fixes() {
1453 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1454 for info in ["foo `bar`", r"foo \`bar\`", "`foo", "foo`", "``foo", "内容 `code`"] {
1455 for fence in ["~~~", "~~~~~"] {
1456 for ending in ["\n", "\r\n"] {
1457 let source = format!("{fence}{info}{ending}literal code{ending}{fence}{ending}");
1458 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1459 let warnings = rule.check(&ctx).unwrap();
1460 assert_eq!(warnings.len(), 2, "{source}");
1461 assert!(warnings.iter().all(|warning| warning.fix.is_none()), "{warnings:?}");
1462 assert_eq!(rule.fix(&ctx).unwrap(), source);
1463 }
1464 }
1465 }
1466 }
1467
1468 #[test]
1469 fn md048_safe_fences_after_incompatible_info_still_convert() {
1470 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1471 let source = "~~~foo `bar`\nfirst\n~~~\n\n~~~text\nsecond\n~~~\n";
1472 let expected = "~~~foo `bar`\nfirst\n~~~\n\n```text\nsecond\n```\n";
1473 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1474 let warnings = rule.check(&ctx).unwrap();
1475 assert_eq!(warnings.len(), 4);
1476 assert!(warnings[..2].iter().all(|warning| warning.fix.is_none()));
1477 assert!(warnings[2..].iter().all(|warning| warning.fix.is_some()));
1478 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1479 let ctx = LintContext::new(expected, MarkdownFlavor::Standard, None);
1480 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1481 }
1482
1483 #[test]
1484 fn md048_unclosed_incompatible_tilde_fence_is_preserved() {
1485 let source = "~~~foo `bar`\nfirst\n```\nrest\n";
1486 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1487 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1488 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1489 assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
1490 assert_eq!(rule.fix(&ctx).unwrap(), source);
1491 }
1492}
1493
1494#[cfg(test)]
1495mod list_fence_content_tests {
1496 use super::*;
1497 use crate::lint_context::LintContext;
1498
1499 #[test]
1500 fn md048_list_fences_account_for_container_indented_inner_markers() {
1501 for (opening, target, style) in [('~', '`', CodeFenceStyle::Backtick), ('`', '~', CodeFenceStyle::Tilde)] {
1502 let rule = MD048CodeFenceStyle::new(style);
1503 for opener_indent in [2, 3] {
1504 for inner_indent in [4, 5, 6, 10] {
1505 for inner_length in [3, 5] {
1506 let prefix = " ".repeat(opener_indent);
1507 let inner_prefix = " ".repeat(inner_indent);
1508 let fence = opening.to_string().repeat(3);
1509 let inner = target.to_string().repeat(inner_length);
1510 let source = format!(
1511 "- item\n\n{prefix}{fence}text\n{inner_prefix}{inner}\n{prefix}literal code\n{inner_prefix}{inner}\n{prefix}{fence}\n"
1512 );
1513 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1514 let fixed = rule.fix(&ctx).unwrap();
1515 let blocks = |content: &str| {
1516 pulldown_cmark::Parser::new(content)
1517 .filter_map(|event| match event {
1518 pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
1519 Some(format!("{kind:?}"))
1520 }
1521 pulldown_cmark::Event::Text(text) => Some(text.to_string()),
1522 _ => None,
1523 })
1524 .collect::<Vec<_>>()
1525 };
1526 assert_eq!(
1527 blocks(&source),
1528 blocks(&fixed),
1529 "{source} -> {fixed}; cached: {:?}",
1530 ctx.code_block_details
1531 );
1532 assert!(
1533 !fixed.contains(&format!("{prefix}{fence}text")),
1534 "must still convert safe container fences: {fixed}"
1535 );
1536 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1537 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
1538 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
1539 }
1540 }
1541 }
1542 }
1543 }
1544
1545 #[test]
1546 fn md048_native_list_closing_boundary_keeps_following_blocks_separate() {
1547 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1548 let source = "- item\n\n ~~~text\n ```\n literal code\n ```\n ~~~\n\n~~~text\nafter\n~~~\n";
1549 let expected = "- item\n\n ````text\n ```\n literal code\n ```\n ````\n\n```text\nafter\n```\n";
1550 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1551 assert_eq!(
1552 rule.fix(&ctx).unwrap(),
1553 expected,
1554 "cached: {:?}",
1555 ctx.code_block_details
1556 );
1557 }
1558}
1559
1560#[cfg(test)]
1561mod paired_fix_tests {
1562 use super::*;
1563 use crate::lint_context::LintContext;
1564 use crate::utils::fix_utils::apply_warning_fixes;
1565
1566 #[test]
1569 fn md048_each_fence_fix_rewrites_both_fences_of_its_block() {
1570 for (style, document, converted) in [
1571 (
1572 CodeFenceStyle::Backtick,
1573 "~~~text\ncode\n~~~\n\nAfter.\n",
1574 "```text\ncode\n```\n\nAfter.\n",
1575 ),
1576 (
1577 CodeFenceStyle::Tilde,
1578 "```text\ncode\n```\n\nAfter.\n",
1579 "~~~text\ncode\n~~~\n\nAfter.\n",
1580 ),
1581 (
1582 CodeFenceStyle::Backtick,
1583 "~~~markdown\n```rust\ncode\n```\n~~~\n\nAfter.\n",
1584 "````markdown\n```rust\ncode\n```\n````\n\nAfter.\n",
1585 ),
1586 (
1587 CodeFenceStyle::Backtick,
1588 "- item\n\n ~~~text\n code\n ~~~\n\n After.\n",
1589 "- item\n\n ```text\n code\n ```\n\n After.\n",
1590 ),
1591 (
1592 CodeFenceStyle::Backtick,
1593 "~~~内容 🎉\ncode\n~~~",
1594 "```内容 🎉\ncode\n```",
1595 ),
1596 ] {
1597 let rule = MD048CodeFenceStyle::new(style);
1598 for (source, expected) in [
1599 (document.to_string(), converted.to_string()),
1600 (document.replace('\n', "\r\n"), converted.replace('\n', "\r\n")),
1601 ] {
1602 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1603 let warnings = rule.check(&ctx).unwrap();
1604 assert_eq!(warnings.len(), 2, "{source:?}");
1605 for warning in &warnings {
1606 assert_eq!(
1607 apply_warning_fixes(&source, std::slice::from_ref(warning)).unwrap(),
1608 expected,
1609 "the fix on line {} of {source:?}",
1610 warning.line
1611 );
1612 }
1613 let [opening, closing] = [&warnings[0], &warnings[1]].map(|warning| warning.fix.as_ref().unwrap());
1615 for (own, other) in [(opening, closing), (closing, opening)] {
1616 assert_eq!(own.additional_edits.len(), 1, "{source:?}");
1617 assert_eq!(own.additional_edits[0].range, other.range);
1618 assert_eq!(own.additional_edits[0].replacement, other.replacement);
1619 assert!(own.additional_edits[0].additional_edits.is_empty());
1620 }
1621 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1623 }
1624 }
1625 }
1626
1627 #[test]
1630 fn md048_fix_pairs_only_the_fences_of_one_block() {
1631 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1632
1633 let source = "~~~text\none\n~~~\n\n~~~text\ntwo\n~~~\n";
1634 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1635 let warnings = rule.check(&ctx).unwrap();
1636 assert_eq!(
1637 warnings.iter().map(|w| (w.line, w.column)).collect::<Vec<_>>(),
1638 [(1, 1), (3, 1), (5, 1), (7, 1)]
1639 );
1640 for (index, expected) in [
1641 "```text\none\n```\n\n~~~text\ntwo\n~~~\n",
1642 "```text\none\n```\n\n~~~text\ntwo\n~~~\n",
1643 "~~~text\none\n~~~\n\n```text\ntwo\n```\n",
1644 "~~~text\none\n~~~\n\n```text\ntwo\n```\n",
1645 ]
1646 .into_iter()
1647 .enumerate()
1648 {
1649 assert_eq!(
1650 apply_warning_fixes(source, &warnings[index..=index]).unwrap(),
1651 expected,
1652 "warning {index}"
1653 );
1654 }
1655
1656 let source = "~~~text\n```\ncode\n";
1658 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1659 let warnings = rule.check(&ctx).unwrap();
1660 assert_eq!(warnings.len(), 1);
1661 assert!(warnings[0].fix.as_ref().unwrap().additional_edits.is_empty());
1662 assert_eq!(rule.fix(&ctx).unwrap(), "````text\n```\ncode\n");
1663
1664 let source = "- item\n\n ~~~text\n code\n\n~~~text\ntwo\n~~~\n";
1666 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1667 let warnings = rule.check(&ctx).unwrap();
1668 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), [3, 6, 8]);
1669 assert_eq!(
1670 apply_warning_fixes(source, &warnings[..1]).unwrap(),
1671 "- item\n\n ```text\n code\n\n~~~text\ntwo\n~~~\n"
1672 );
1673 for warning in &warnings[1..] {
1674 assert_eq!(
1675 apply_warning_fixes(source, std::slice::from_ref(warning)).unwrap(),
1676 "- item\n\n ~~~text\n code\n\n```text\ntwo\n```\n"
1677 );
1678 }
1679 }
1680}