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