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 can_convert_block = true;
343 let mut list_fence_end = None;
344
345 for filtered_line in ctx.filtered_lines().skip_front_matter() {
346 let line_num = filtered_line.line_num - 1;
347 if ambiguity_limit.is_some_and(|limit| ctx.lines[line_num].byte_offset >= limit) {
348 break;
349 }
350 let line = filtered_line.content;
351 if list_fence_end.is_some_and(|end| line_num > end) {
352 in_code_block = false;
353 list_fence_end = None;
354 }
355 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
357 continue;
358 }
359
360 if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
362 continue;
363 }
364
365 let marker = if in_code_block && list_fence_end == Some(line_num) {
366 parse_container_fence_marker(line)
367 } else {
368 parse_fence_marker(line)
369 };
370 let Some(marker) = marker else {
371 continue;
372 };
373
374 let marker_offset = ctx.lines[line_num].byte_offset + marker.fence_start;
375 if !in_code_block && (ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)) {
376 continue;
377 }
378
379 if ctx.flavor.supports_myst_directives()
381 && !in_code_block
382 && marker.fence_char == '`'
383 && marker.rest.trim_start().starts_with('{')
384 {
385 continue;
386 }
387 let fence_char = marker.fence_char;
388 let fence_len = marker.fence_len;
389
390 if !in_code_block {
391 in_code_block = true;
392 code_block_fence_char = fence_char;
393 code_block_fence_len = fence_len;
394 list_fence_end = native_list_fence_end(ctx, line_num, marker.fence_start);
395 can_convert_block =
396 !(fence_char == '~' && target_style == CodeFenceStyle::Backtick && marker.rest.contains('`'));
397
398 let needs_conversion = needs_fence_conversion(fence_char, target_style);
399
400 if needs_conversion {
401 let target_char = if target_style == CodeFenceStyle::Backtick {
402 '`'
403 } else {
404 '~'
405 };
406
407 let prefix = &line[..marker.fence_start];
410 let info = marker.rest;
411 let max_inner = max_inner_fence_length_of_char(
412 lines,
413 line_num,
414 fence_len,
415 fence_char,
416 target_char,
417 list_fence_end,
418 );
419 converted_fence_len = fence_len.max(max_inner + 1);
420 needs_lengthening = false;
421
422 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
423
424 let fence_start = marker.fence_start;
425 let fence_end = fence_start + fence_len;
426 let (start_line, start_col, end_line, end_col) =
427 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
428
429 warnings.push(LintWarning {
430 rule_name: Some(self.name().to_string()),
431 message: format!(
432 "Code fence style: use {} instead of {}",
433 if target_style == CodeFenceStyle::Backtick {
434 "```"
435 } else {
436 "~~~"
437 },
438 if fence_char == '`' { "```" } else { "~~~" }
439 ),
440 line: start_line,
441 column: start_col,
442 end_line,
443 end_column: end_col,
444 severity: Severity::Warning,
445 fix: can_convert_block.then(|| {
446 Fix::new(
447 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
448 replacement,
449 )
450 }),
451 });
452 } else {
453 let prefix = &line[..marker.fence_start];
459 let info = marker.rest;
460 let max_inner = max_inner_fence_length_of_char(
461 lines,
462 line_num,
463 fence_len,
464 fence_char,
465 fence_char,
466 list_fence_end,
467 );
468 if max_inner >= fence_len {
469 converted_fence_len = max_inner + 1;
470 needs_lengthening = true;
471
472 let replacement =
473 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
474
475 let fence_start = marker.fence_start;
476 let fence_end = fence_start + fence_len;
477 let (start_line, start_col, end_line, end_col) =
478 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
479
480 warnings.push(LintWarning {
481 rule_name: Some(self.name().to_string()),
482 message: format!(
483 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
484 contains interior fence sequences of equal length; \
485 use {converted_fence_len}",
486 if fence_char == '`' { "backticks" } else { "tildes" },
487 ),
488 line: start_line,
489 column: start_col,
490 end_line,
491 end_column: end_col,
492 severity: Severity::Warning,
493 fix: Some(Fix::new(
494 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
495 replacement,
496 )),
497 });
498 } else {
499 converted_fence_len = fence_len;
500 needs_lengthening = false;
501 }
502 }
503 } else {
504 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
506
507 if is_closing {
508 let needs_conversion = needs_fence_conversion(fence_char, target_style);
509
510 if needs_conversion || needs_lengthening {
511 let target_char = if needs_conversion {
512 if target_style == CodeFenceStyle::Backtick {
513 '`'
514 } else {
515 '~'
516 }
517 } else {
518 fence_char
519 };
520
521 let prefix = &line[..marker.fence_start];
522 let replacement = format!(
523 "{prefix}{}{}",
524 target_char.to_string().repeat(converted_fence_len),
525 marker.rest
526 );
527
528 let fence_start = marker.fence_start;
529 let fence_end = fence_start + fence_len;
530 let (start_line, start_col, end_line, end_col) =
531 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
532
533 let message = if needs_conversion {
534 format!(
535 "Code fence style: use {} instead of {}",
536 if target_style == CodeFenceStyle::Backtick {
537 "```"
538 } else {
539 "~~~"
540 },
541 if fence_char == '`' { "```" } else { "~~~" }
542 )
543 } else {
544 format!(
545 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
546 must match the lengthened outer fence; use {converted_fence_len}",
547 if fence_char == '`' { "backticks" } else { "tildes" },
548 )
549 };
550
551 warnings.push(LintWarning {
552 rule_name: Some(self.name().to_string()),
553 message,
554 line: start_line,
555 column: start_col,
556 end_line,
557 end_column: end_col,
558 severity: Severity::Warning,
559 fix: can_convert_block.then(|| {
560 Fix::new(
561 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
562 replacement,
563 )
564 }),
565 });
566 }
567
568 in_code_block = false;
569 list_fence_end = None;
570 code_block_fence_len = 0;
571 converted_fence_len = 0;
572 needs_lengthening = false;
573 }
574 }
576 }
577
578 Ok(warnings)
579 }
580
581 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
583 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
585 }
586
587 fn fixes_from_diagnostics(&self) -> bool {
588 true
589 }
590
591 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
592 if self.should_skip(ctx) {
593 return Ok(ctx.content.to_string());
594 }
595 let warnings = self.check(ctx)?;
596 if warnings.is_empty() {
597 return Ok(ctx.content.to_string());
598 }
599 let warnings =
600 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
601 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
602 .map_err(crate::rule::LintError::InvalidInput)
603 }
604
605 fn as_any(&self) -> &dyn std::any::Any {
606 self
607 }
608
609 crate::impl_rule_config_sections!(MD048Config);
610
611 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
612 where
613 Self: Sized,
614 {
615 let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
616 let style_explicit = option_is_explicit(config, "MD048", "style");
617
618 Box::new(Self {
619 config: rule_config,
620 style_explicit,
621 })
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use crate::lint_context::LintContext;
629
630 #[test]
631 fn test_backtick_style_with_backticks() {
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 result = rule.check(&ctx).unwrap();
636
637 assert_eq!(result.len(), 0);
638 }
639
640 #[test]
641 fn test_backtick_style_with_tildes() {
642 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
643 let content = "~~~\ncode\n~~~";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646
647 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
649 assert_eq!(result[0].line, 1);
650 assert_eq!(result[1].line, 3);
651 }
652
653 #[test]
654 fn test_tilde_style_with_tildes() {
655 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
656 let content = "~~~\ncode\n~~~";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659
660 assert_eq!(result.len(), 0);
661 }
662
663 #[test]
664 fn test_tilde_style_with_backticks() {
665 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
666 let content = "```\ncode\n```";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669
670 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
672 }
673
674 #[test]
675 fn test_mdg_overrides_tilde_style_to_backtick() {
676 let backticks = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n ```json\n {\"ok\": true}\n ```";
681 let tildes =
682 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ~~~text\n example\n ~~~";
683
684 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
685 let ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::MDG, None);
686 assert!(rule.check(&ctx).unwrap().is_empty());
687 assert_eq!(rule.fix(&ctx).unwrap(), backticks);
688
689 let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
690 assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
691 let fixed = rule.fix(&ctx).unwrap();
692 assert_eq!(
693 fixed,
694 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ```text\n example\n ```"
695 );
696
697 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
698 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
699 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
700
701 let standard_ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::Standard, None);
703 assert_eq!(rule.check(&standard_ctx).unwrap().len(), 2);
704 assert!(rule.fix(&standard_ctx).unwrap().contains("~~~json"));
705
706 let standard_ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::Standard, None);
707 assert!(rule.check(&standard_ctx).unwrap().is_empty());
708 assert_eq!(rule.fix(&standard_ctx).unwrap(), tildes);
709 }
710
711 #[test]
712 fn test_mdg_applies_backtick_style() {
713 let tildes =
716 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ~~~text\n example\n ~~~";
717
718 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
719 let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
720 assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
721 assert_eq!(
722 rule.fix(&ctx).unwrap(),
723 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ```text\n example\n ```"
724 );
725 }
726
727 #[test]
728 fn test_mdg_tilde_style_still_disambiguates_fence_length() {
729 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
733 let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n~~~\n```rust\ncode\n```\n~~~";
734
735 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
736 let fixed = rule.fix(&mdg_ctx).unwrap();
737 assert_eq!(
738 fixed,
739 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n````\n```rust\ncode\n```\n````"
740 );
741
742 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
743 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
744
745 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 assert!(rule.check(&standard_ctx).unwrap().is_empty());
749 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
750 }
751
752 #[test]
753 fn test_from_config_records_whether_style_was_configured() {
754 use crate::config::Config;
758 use std::collections::BTreeMap;
759
760 let mut values = BTreeMap::new();
761 values.insert("style".to_string(), toml::Value::String("tilde".to_string()));
762 let mut config = Config::default();
763 config.rules.insert(
764 "MD048".to_string(),
765 crate::config::RuleConfig { severity: None, values },
766 );
767
768 let configured = MD048CodeFenceStyle::from_config(&config);
769 let configured = configured.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
770 assert_eq!(configured.config.style, CodeFenceStyle::Tilde);
771 assert!(configured.style_explicit);
772
773 let defaulted = MD048CodeFenceStyle::from_config(&Config::default());
774 let defaulted = defaulted.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
775 assert!(!defaulted.style_explicit);
776
777 let tilde = MD048CodeFenceStyle::from_config_struct(MD048Config {
780 style: CodeFenceStyle::Tilde,
781 });
782 let content = "# Feature: F\n\n~~~text\nexample\n~~~";
783 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
784 assert_eq!(tilde.fix(&mdg_ctx).unwrap(), "# Feature: F\n\n```text\nexample\n```");
785 }
786
787 #[test]
788 fn test_mdg_consistent_fence_style_ignores_tilde_prevalence() {
789 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
792 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 ```";
793
794 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 assert_eq!(rule.detect_style(&standard_ctx), Some(CodeFenceStyle::Tilde));
796
797 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
798 let warnings = rule.check(&mdg_ctx).unwrap();
799 assert_eq!(warnings.len(), 4, "two tilde blocks, each with two fence lines");
800 assert!(
801 warnings
802 .iter()
803 .all(|warning| warning.message.contains("use ``` instead of ~~~"))
804 );
805
806 let fixed = rule.fix(&mdg_ctx).unwrap();
807 assert!(!fixed.contains("~~~"), "MDG must convert every fence: {fixed:?}");
808
809 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
810 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
811 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
812 }
813
814 #[test]
815 fn test_consistent_style_tie_prefers_backtick() {
816 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
817 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821
822 assert_eq!(result.len(), 2);
824 assert_eq!(result[0].line, 5);
825 assert_eq!(result[1].line, 7);
826 }
827
828 #[test]
829 fn test_consistent_style_tilde_most_prevalent() {
830 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
831 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835
836 assert_eq!(result.len(), 2);
838 assert_eq!(result[0].line, 5);
839 assert_eq!(result[1].line, 7);
840 }
841
842 #[test]
843 fn test_detect_style_backtick() {
844 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
845 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
846 let style = rule.detect_style(&ctx);
847
848 assert_eq!(style, Some(CodeFenceStyle::Backtick));
849 }
850
851 #[test]
852 fn test_detect_style_tilde() {
853 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
854 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
855 let style = rule.detect_style(&ctx);
856
857 assert_eq!(style, Some(CodeFenceStyle::Tilde));
858 }
859
860 #[test]
861 fn test_detect_style_none() {
862 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
863 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
864 let style = rule.detect_style(&ctx);
865
866 assert_eq!(style, None);
867 }
868
869 #[test]
870 fn test_fix_backticks_to_tildes() {
871 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
872 let content = "```\ncode\n```";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let fixed = rule.fix(&ctx).unwrap();
875
876 assert_eq!(fixed, "~~~\ncode\n~~~");
877 }
878
879 #[test]
880 fn test_fix_tildes_to_backticks() {
881 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
882 let content = "~~~\ncode\n~~~";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let fixed = rule.fix(&ctx).unwrap();
885
886 assert_eq!(fixed, "```\ncode\n```");
887 }
888
889 #[test]
890 fn test_fix_preserves_fence_length() {
891 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
892 let content = "````\ncode with backtick\n```\ncode\n````";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let fixed = rule.fix(&ctx).unwrap();
895
896 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
897 }
898
899 #[test]
900 fn test_fix_preserves_language_info() {
901 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
902 let content = "~~~rust\nfn main() {}\n~~~";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let fixed = rule.fix(&ctx).unwrap();
905
906 assert_eq!(fixed, "```rust\nfn main() {}\n```");
907 }
908
909 #[test]
910 fn test_indented_code_fences() {
911 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
912 let content = " ```\n code\n ```";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915
916 assert_eq!(result.len(), 2);
917 }
918
919 #[test]
920 fn test_fix_indented_fences() {
921 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
922 let content = " ```\n code\n ```";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let fixed = rule.fix(&ctx).unwrap();
925
926 assert_eq!(fixed, " ~~~\n code\n ~~~");
927 }
928
929 #[test]
930 fn test_nested_fences_not_changed() {
931 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
932 let content = "```\ncode with ``` inside\n```";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let fixed = rule.fix(&ctx).unwrap();
935
936 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
937 }
938
939 #[test]
940 fn test_multiple_code_blocks() {
941 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
942 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let result = rule.check(&ctx).unwrap();
945
946 assert_eq!(result.len(), 4); }
948
949 #[test]
950 fn test_empty_content() {
951 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
952 let content = "";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.check(&ctx).unwrap();
955
956 assert_eq!(result.len(), 0);
957 }
958
959 #[test]
960 fn test_preserve_trailing_newline() {
961 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
962 let content = "~~~\ncode\n~~~\n";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let fixed = rule.fix(&ctx).unwrap();
965
966 assert_eq!(fixed, "```\ncode\n```\n");
967 }
968
969 #[test]
970 fn test_no_trailing_newline() {
971 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
972 let content = "~~~\ncode\n~~~";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let fixed = rule.fix(&ctx).unwrap();
975
976 assert_eq!(fixed, "```\ncode\n```");
977 }
978
979 #[test]
980 fn test_default_config() {
981 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
982 let (name, _config) = rule.default_config_section().unwrap();
983 assert_eq!(name, "MD048");
984 }
985
986 #[test]
989 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
990 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
991 let content = "~~~text\n```rust\ncode\n```\n~~~";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let fixed = rule.fix(&ctx).unwrap();
994
995 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
997 }
998
999 #[test]
1002 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
1003 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1004 let content = "~~~text\n```rust\ncode\n```\n~~~";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006 let warnings = rule.check(&ctx).unwrap();
1007
1008 assert_eq!(warnings.len(), 2);
1010 let open_fix = warnings[0].fix.as_ref().unwrap();
1011 let close_fix = warnings[1].fix.as_ref().unwrap();
1012 assert_eq!(open_fix.replacement, "````text");
1013 assert_eq!(close_fix.replacement, "````");
1014 }
1015
1016 #[test]
1019 fn test_tilde_outer_with_longer_backtick_inner() {
1020 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1021 let content = "~~~text\n````rust\ncode\n````\n~~~";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023 let fixed = rule.fix(&ctx).unwrap();
1024
1025 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
1026 }
1027
1028 #[test]
1031 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
1032 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1033 let content = "```text\n~~~rust\ncode\n~~~\n```";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035 let fixed = rule.fix(&ctx).unwrap();
1036
1037 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
1038 }
1039
1040 #[test]
1048 fn test_info_string_interior_not_ambiguous() {
1049 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1050 let content = "```text\n```rust\ncode\n```\n```";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let warnings = rule.check(&ctx).unwrap();
1058
1059 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
1062 }
1063
1064 #[test]
1066 fn test_info_string_interior_fix_unchanged() {
1067 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1068 let content = "```text\n```rust\ncode\n```\n```";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let fixed = rule.fix(&ctx).unwrap();
1071
1072 assert_eq!(fixed, content);
1074 }
1075
1076 #[test]
1078 fn test_tilde_info_string_interior_not_ambiguous() {
1079 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1080 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let fixed = rule.fix(&ctx).unwrap();
1083
1084 assert_eq!(fixed, content);
1086 }
1087
1088 #[test]
1090 fn test_no_ambiguity_when_outer_is_longer() {
1091 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1092 let content = "````text\n```rust\ncode\n```\n````";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let warnings = rule.check(&ctx).unwrap();
1095
1096 assert_eq!(
1097 warnings.len(),
1098 0,
1099 "should have no warnings when outer is already longer"
1100 );
1101 }
1102
1103 #[test]
1107 fn test_longer_info_string_interior_not_ambiguous() {
1108 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1109 let content = "```text\n`````rust\ncode\n`````\n```";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let fixed = rule.fix(&ctx).unwrap();
1117
1118 assert_eq!(fixed, content);
1120 }
1121
1122 #[test]
1124 fn test_info_string_interior_consistent_style_no_warning() {
1125 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1126 let content = "```text\n```rust\ncode\n```\n```";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let warnings = rule.check(&ctx).unwrap();
1129
1130 assert_eq!(warnings.len(), 0);
1131 }
1132
1133 #[test]
1140 fn test_cross_style_bare_inner_requires_lengthening() {
1141 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1142 let content = "~~~\n`````rust\ncode\n```\n~~~";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let fixed = rule.fix(&ctx).unwrap();
1148
1149 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
1152 }
1153
1154 #[test]
1158 fn test_cross_style_info_only_interior_no_lengthening() {
1159 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1160 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let fixed = rule.fix(&ctx).unwrap();
1166
1167 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
1168 }
1169
1170 #[test]
1173 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
1174 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1175 let content = "````text\n```\nshowing raw fence\n```\n````";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let warnings = rule.check(&ctx).unwrap();
1181
1182 assert_eq!(
1183 warnings.len(),
1184 0,
1185 "shorter bare interior sequences cannot close a 4-backtick outer"
1186 );
1187 }
1188
1189 #[test]
1192 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
1193 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1194 let content = "````\n```\nsome code\n```\n````";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let warnings = rule.check(&ctx).unwrap();
1199
1200 assert_eq!(
1201 warnings.len(),
1202 0,
1203 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
1204 );
1205 }
1206
1207 #[test]
1210 fn test_overindented_inner_sequence_not_ambiguous() {
1211 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1212 let content = "```text\n ```\ncode\n```";
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214 let warnings = rule.check(&ctx).unwrap();
1215 let fixed = rule.fix(&ctx).unwrap();
1216
1217 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
1218 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
1219 }
1220
1221 #[test]
1224 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
1225 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1226 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let fixed = rule.fix(&ctx).unwrap();
1229
1230 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
1231 }
1232
1233 #[test]
1236 fn test_top_level_four_space_fence_marker_is_ignored() {
1237 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1238 let content = " ```\n code\n ```";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let warnings = rule.check(&ctx).unwrap();
1241 let fixed = rule.fix(&ctx).unwrap();
1242
1243 assert_eq!(warnings.len(), 0);
1244 assert_eq!(fixed, content);
1245 }
1246
1247 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let fixed = rule.fix(&ctx).unwrap();
1255 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1256 let remaining = rule.check(&ctx2).unwrap();
1257 assert!(
1258 remaining.is_empty(),
1259 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
1260 remaining.len(),
1261 );
1262 }
1263
1264 #[test]
1265 fn test_roundtrip_backticks_to_tildes() {
1266 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1267 assert_fix_roundtrip(&rule, "```\ncode\n```");
1268 }
1269
1270 #[test]
1271 fn test_roundtrip_tildes_to_backticks() {
1272 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1273 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
1274 }
1275
1276 #[test]
1277 fn test_roundtrip_mixed_fences_consistent() {
1278 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1279 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
1280 }
1281
1282 #[test]
1283 fn test_roundtrip_with_info_string() {
1284 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1285 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
1286 }
1287
1288 #[test]
1289 fn test_roundtrip_longer_fences() {
1290 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1291 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
1292 }
1293
1294 #[test]
1295 fn test_roundtrip_nested_inner_fences() {
1296 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1297 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
1298 }
1299
1300 #[test]
1301 fn test_roundtrip_indented_fences() {
1302 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1303 assert_fix_roundtrip(&rule, " ```\n code\n ```");
1304 }
1305
1306 #[test]
1307 fn test_roundtrip_multiple_blocks() {
1308 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1309 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
1310 }
1311
1312 #[test]
1313 fn test_roundtrip_fence_length_ambiguity() {
1314 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1315 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1316 }
1317
1318 #[test]
1319 fn test_roundtrip_trailing_newline() {
1320 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1321 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1322 }
1323
1324 #[test]
1325 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1326 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1327 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1328 }
1329
1330 #[test]
1331 fn test_roundtrip_backtick_outer_tilde_inner() {
1332 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1333 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1334 }
1335
1336 #[test]
1337 fn test_roundtrip_consistent_tilde_prevalent() {
1338 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1339 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1340 }
1341
1342 #[test]
1346 fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1347 use crate::fix_coordinator::FixCoordinator;
1348 use crate::rules::Rule;
1349 use crate::rules::md013_line_length::MD013LineLength;
1350
1351 let content = "\
1355- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1356Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1357or `edition2024` annotations, such as:
1358
1359 ~~~text
1360 ```rust,edition2015
1361 // This only works in 2015.
1362 let try = true;
1363 ```
1364 ~~~
1365
1366### Build options
1367";
1368 let rules: Vec<Box<dyn Rule>> = vec![
1369 Box::new(MD013LineLength::new(80, false, false, false, true)),
1370 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1371 ];
1372
1373 let mut first_pass = content.to_string();
1374 let coordinator = FixCoordinator::new();
1375 coordinator
1376 .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1377 .expect("fix should not fail");
1378
1379 let lines: Vec<&str> = first_pass.lines().collect();
1381 for i in 0..lines.len().saturating_sub(1) {
1382 assert!(
1383 !(lines[i].is_empty() && lines[i + 1].is_empty()),
1384 "Double blank at lines {},{} after first pass:\n{first_pass}",
1385 i + 1,
1386 i + 2
1387 );
1388 }
1389
1390 let mut second_pass = first_pass.clone();
1392 let rules2: Vec<Box<dyn Rule>> = vec![
1393 Box::new(MD013LineLength::new(80, false, false, false, true)),
1394 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1395 ];
1396 let coordinator2 = FixCoordinator::new();
1397 coordinator2
1398 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1399 .expect("fix should not fail");
1400
1401 assert_eq!(
1402 first_pass, second_pass,
1403 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_front_matter_fence_does_not_drive_style_detection() {
1409 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1414 let content = "---\ndescription: |\n ```\n code\n ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 let result = rule.check(&ctx).unwrap();
1417 assert!(
1418 result.is_empty(),
1419 "front-matter fence must not drive style detection, got: {result:?}"
1420 );
1421 }
1422}
1423
1424#[cfg(test)]
1425mod incompatible_info_tests {
1426 use super::*;
1427 use crate::lint_context::LintContext;
1428
1429 #[test]
1430 fn md048_backtick_info_keeps_tilde_fences_without_unsafe_fixes() {
1431 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1432 for info in ["foo `bar`", r"foo \`bar\`", "`foo", "foo`", "``foo", "内容 `code`"] {
1433 for fence in ["~~~", "~~~~~"] {
1434 for ending in ["\n", "\r\n"] {
1435 let source = format!("{fence}{info}{ending}literal code{ending}{fence}{ending}");
1436 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1437 let warnings = rule.check(&ctx).unwrap();
1438 assert_eq!(warnings.len(), 2, "{source}");
1439 assert!(warnings.iter().all(|warning| warning.fix.is_none()), "{warnings:?}");
1440 assert_eq!(rule.fix(&ctx).unwrap(), source);
1441 }
1442 }
1443 }
1444 }
1445
1446 #[test]
1447 fn md048_safe_fences_after_incompatible_info_still_convert() {
1448 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1449 let source = "~~~foo `bar`\nfirst\n~~~\n\n~~~text\nsecond\n~~~\n";
1450 let expected = "~~~foo `bar`\nfirst\n~~~\n\n```text\nsecond\n```\n";
1451 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1452 let warnings = rule.check(&ctx).unwrap();
1453 assert_eq!(warnings.len(), 4);
1454 assert!(warnings[..2].iter().all(|warning| warning.fix.is_none()));
1455 assert!(warnings[2..].iter().all(|warning| warning.fix.is_some()));
1456 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1457 let ctx = LintContext::new(expected, MarkdownFlavor::Standard, None);
1458 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1459 }
1460
1461 #[test]
1462 fn md048_unclosed_incompatible_tilde_fence_is_preserved() {
1463 let source = "~~~foo `bar`\nfirst\n```\nrest\n";
1464 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1465 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1466 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1467 assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
1468 assert_eq!(rule.fix(&ctx).unwrap(), source);
1469 }
1470}
1471
1472#[cfg(test)]
1473mod list_fence_content_tests {
1474 use super::*;
1475 use crate::lint_context::LintContext;
1476
1477 #[test]
1478 fn md048_list_fences_account_for_container_indented_inner_markers() {
1479 for (opening, target, style) in [('~', '`', CodeFenceStyle::Backtick), ('`', '~', CodeFenceStyle::Tilde)] {
1480 let rule = MD048CodeFenceStyle::new(style);
1481 for opener_indent in [2, 3] {
1482 for inner_indent in [4, 5, 6, 10] {
1483 for inner_length in [3, 5] {
1484 let prefix = " ".repeat(opener_indent);
1485 let inner_prefix = " ".repeat(inner_indent);
1486 let fence = opening.to_string().repeat(3);
1487 let inner = target.to_string().repeat(inner_length);
1488 let source = format!(
1489 "- item\n\n{prefix}{fence}text\n{inner_prefix}{inner}\n{prefix}literal code\n{inner_prefix}{inner}\n{prefix}{fence}\n"
1490 );
1491 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1492 let fixed = rule.fix(&ctx).unwrap();
1493 let blocks = |content: &str| {
1494 pulldown_cmark::Parser::new(content)
1495 .filter_map(|event| match event {
1496 pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
1497 Some(format!("{kind:?}"))
1498 }
1499 pulldown_cmark::Event::Text(text) => Some(text.to_string()),
1500 _ => None,
1501 })
1502 .collect::<Vec<_>>()
1503 };
1504 assert_eq!(
1505 blocks(&source),
1506 blocks(&fixed),
1507 "{source} -> {fixed}; cached: {:?}",
1508 ctx.code_block_details
1509 );
1510 assert!(
1511 !fixed.contains(&format!("{prefix}{fence}text")),
1512 "must still convert safe container fences: {fixed}"
1513 );
1514 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1515 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
1516 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
1517 }
1518 }
1519 }
1520 }
1521 }
1522
1523 #[test]
1524 fn md048_native_list_closing_boundary_keeps_following_blocks_separate() {
1525 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1526 let source = "- item\n\n ~~~text\n ```\n literal code\n ```\n ~~~\n\n~~~text\nafter\n~~~\n";
1527 let expected = "- item\n\n ````text\n ```\n literal code\n ```\n ````\n\n```text\nafter\n```\n";
1528 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1529 assert_eq!(
1530 rule.fix(&ctx).unwrap(),
1531 expected,
1532 "cached: {:?}",
1533 ctx.code_block_details
1534 );
1535 }
1536}