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
79#[derive(Clone)]
83pub struct MD048CodeFenceStyle {
84 config: MD048Config,
85 style_explicit: bool,
89}
90
91impl MD048CodeFenceStyle {
92 pub fn new(style: CodeFenceStyle) -> Self {
93 Self {
94 config: MD048Config { style },
95 style_explicit: true,
96 }
97 }
98
99 pub fn from_config_struct(config: MD048Config) -> Self {
100 Self {
101 config,
102 style_explicit: false,
103 }
104 }
105
106 fn effective_target_style(&self, ctx: &crate::lint_context::LintContext) -> CodeFenceStyle {
114 if ctx.flavor == MarkdownFlavor::MDG {
115 self.warn_once_about_overridden_style();
116 return CodeFenceStyle::Backtick;
117 }
118
119 match self.config.style {
120 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
121 style => style,
122 }
123 }
124
125 fn warn_once_about_overridden_style(&self) {
131 if !self.style_explicit || self.config.style != CodeFenceStyle::Tilde {
132 return;
133 }
134
135 MDG_STYLE_OVERRIDE.report(
136 "MD048",
137 "style",
138 "tilde",
139 "backtick",
140 "a Gherkin Doc String is only ever a backtick fence",
141 );
142 }
143
144 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
145 let mut backtick_count = 0;
147 let mut tilde_count = 0;
148 let mut in_code_block = false;
149 let mut opening_fence_char = '`';
150 let mut opening_fence_len = 0usize;
151
152 for filtered_line in ctx.filtered_lines().skip_front_matter() {
153 let i = filtered_line.line_num - 1;
154 let line = filtered_line.content;
155 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|li| li.in_code_block) {
158 continue;
159 }
160
161 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|li| li.in_myst_directive) {
164 continue;
165 }
166
167 let Some(marker) = parse_fence_marker(line) else {
168 continue;
169 };
170
171 if ctx.flavor.supports_myst_directives()
173 && marker.fence_char == '`'
174 && marker.rest.trim_start().starts_with('{')
175 {
176 continue;
177 }
178
179 if !in_code_block {
180 if marker.fence_char == '`' {
182 backtick_count += 1;
183 } else {
184 tilde_count += 1;
185 }
186 in_code_block = true;
187 opening_fence_char = marker.fence_char;
188 opening_fence_len = marker.fence_len;
189 } else if is_closing_fence(marker, opening_fence_char, opening_fence_len) {
190 in_code_block = false;
191 }
192 }
193
194 if backtick_count >= tilde_count && backtick_count > 0 {
197 Some(CodeFenceStyle::Backtick)
198 } else if tilde_count > 0 {
199 Some(CodeFenceStyle::Tilde)
200 } else {
201 None
202 }
203 }
204}
205
206fn max_inner_fence_length_of_char(
224 lines: &[&str],
225 opening_line: usize,
226 opening_fence_len: usize,
227 opening_char: char,
228 target_char: char,
229) -> usize {
230 let mut max_len = 0usize;
231
232 for line in lines.iter().skip(opening_line + 1) {
233 let Some(marker) = parse_fence_marker(line) else {
234 continue;
235 };
236
237 if is_closing_fence(marker, opening_char, opening_fence_len) {
239 break;
240 }
241
242 if marker.fence_char == target_char && marker.rest.trim().is_empty() {
245 max_len = max_len.max(marker.fence_len);
246 }
247 }
248
249 max_len
250}
251
252impl Rule for MD048CodeFenceStyle {
253 fn name(&self) -> &'static str {
254 "MD048"
255 }
256
257 fn description(&self) -> &'static str {
258 "Code fence style should be consistent"
259 }
260
261 fn category(&self) -> RuleCategory {
262 RuleCategory::CodeBlock
263 }
264
265 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
266 let mut warnings = Vec::new();
267
268 let target_style = self.effective_target_style(ctx);
269
270 let lines = ctx.raw_lines();
271 let mut in_code_block = false;
272 let mut code_block_fence_char = '`';
273 let mut code_block_fence_len = 0usize;
274 let mut converted_fence_len = 0usize;
277 let mut needs_lengthening = false;
280
281 for filtered_line in ctx.filtered_lines().skip_front_matter() {
282 let line_num = filtered_line.line_num - 1;
283 let line = filtered_line.content;
284 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(line_num).is_some_and(|li| li.in_code_block) {
286 continue;
287 }
288
289 if ctx.flavor.supports_myst_directives() && ctx.lines.get(line_num).is_some_and(|li| li.in_myst_directive) {
291 continue;
292 }
293
294 let Some(marker) = parse_fence_marker(line) else {
295 continue;
296 };
297
298 if ctx.flavor.supports_myst_directives()
300 && !in_code_block
301 && marker.fence_char == '`'
302 && marker.rest.trim_start().starts_with('{')
303 {
304 continue;
305 }
306 let fence_char = marker.fence_char;
307 let fence_len = marker.fence_len;
308
309 if !in_code_block {
310 in_code_block = true;
311 code_block_fence_char = fence_char;
312 code_block_fence_len = fence_len;
313
314 let needs_conversion = needs_fence_conversion(fence_char, target_style);
315
316 if needs_conversion {
317 let target_char = if target_style == CodeFenceStyle::Backtick {
318 '`'
319 } else {
320 '~'
321 };
322
323 let prefix = &line[..marker.fence_start];
326 let info = marker.rest;
327 let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, target_char);
328 converted_fence_len = fence_len.max(max_inner + 1);
329 needs_lengthening = false;
330
331 let replacement = format!("{prefix}{}{info}", target_char.to_string().repeat(converted_fence_len));
332
333 let fence_start = marker.fence_start;
334 let fence_end = fence_start + fence_len;
335 let (start_line, start_col, end_line, end_col) =
336 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
337
338 warnings.push(LintWarning {
339 rule_name: Some(self.name().to_string()),
340 message: format!(
341 "Code fence style: use {} instead of {}",
342 if target_style == CodeFenceStyle::Backtick {
343 "```"
344 } else {
345 "~~~"
346 },
347 if fence_char == '`' { "```" } else { "~~~" }
348 ),
349 line: start_line,
350 column: start_col,
351 end_line,
352 end_column: end_col,
353 severity: Severity::Warning,
354 fix: Some(Fix::new(
355 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
356 replacement,
357 )),
358 });
359 } else {
360 let prefix = &line[..marker.fence_start];
366 let info = marker.rest;
367 let max_inner = max_inner_fence_length_of_char(lines, line_num, fence_len, fence_char, fence_char);
368 if max_inner >= fence_len {
369 converted_fence_len = max_inner + 1;
370 needs_lengthening = true;
371
372 let replacement =
373 format!("{prefix}{}{info}", fence_char.to_string().repeat(converted_fence_len));
374
375 let fence_start = marker.fence_start;
376 let fence_end = fence_start + fence_len;
377 let (start_line, start_col, end_line, end_col) =
378 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
379
380 warnings.push(LintWarning {
381 rule_name: Some(self.name().to_string()),
382 message: format!(
383 "Code fence length is ambiguous: outer fence ({fence_len} {}) \
384 contains interior fence sequences of equal length; \
385 use {converted_fence_len}",
386 if fence_char == '`' { "backticks" } else { "tildes" },
387 ),
388 line: start_line,
389 column: start_col,
390 end_line,
391 end_column: end_col,
392 severity: Severity::Warning,
393 fix: Some(Fix::new(
394 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
395 replacement,
396 )),
397 });
398 } else {
399 converted_fence_len = fence_len;
400 needs_lengthening = false;
401 }
402 }
403 } else {
404 let is_closing = is_closing_fence(marker, code_block_fence_char, code_block_fence_len);
406
407 if is_closing {
408 let needs_conversion = needs_fence_conversion(fence_char, target_style);
409
410 if needs_conversion || needs_lengthening {
411 let target_char = if needs_conversion {
412 if target_style == CodeFenceStyle::Backtick {
413 '`'
414 } else {
415 '~'
416 }
417 } else {
418 fence_char
419 };
420
421 let prefix = &line[..marker.fence_start];
422 let replacement = format!(
423 "{prefix}{}{}",
424 target_char.to_string().repeat(converted_fence_len),
425 marker.rest
426 );
427
428 let fence_start = marker.fence_start;
429 let fence_end = fence_start + fence_len;
430 let (start_line, start_col, end_line, end_col) =
431 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
432
433 let message = if needs_conversion {
434 format!(
435 "Code fence style: use {} instead of {}",
436 if target_style == CodeFenceStyle::Backtick {
437 "```"
438 } else {
439 "~~~"
440 },
441 if fence_char == '`' { "```" } else { "~~~" }
442 )
443 } else {
444 format!(
445 "Code fence length is ambiguous: closing fence ({fence_len} {}) \
446 must match the lengthened outer fence; use {converted_fence_len}",
447 if fence_char == '`' { "backticks" } else { "tildes" },
448 )
449 };
450
451 warnings.push(LintWarning {
452 rule_name: Some(self.name().to_string()),
453 message,
454 line: start_line,
455 column: start_col,
456 end_line,
457 end_column: end_col,
458 severity: Severity::Warning,
459 fix: Some(Fix::new(
460 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.len()),
461 replacement,
462 )),
463 });
464 }
465
466 in_code_block = false;
467 code_block_fence_len = 0;
468 converted_fence_len = 0;
469 needs_lengthening = false;
470 }
471 }
473 }
474
475 Ok(warnings)
476 }
477
478 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
480 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
482 }
483
484 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
485 if self.should_skip(ctx) {
486 return Ok(ctx.content.to_string());
487 }
488 let warnings = self.check(ctx)?;
489 if warnings.is_empty() {
490 return Ok(ctx.content.to_string());
491 }
492 let warnings =
493 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
494 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
495 .map_err(crate::rule::LintError::InvalidInput)
496 }
497
498 fn as_any(&self) -> &dyn std::any::Any {
499 self
500 }
501
502 crate::impl_rule_config_sections!(MD048Config);
503
504 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
505 where
506 Self: Sized,
507 {
508 let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
509 let style_explicit = option_is_explicit(config, "MD048", "style");
510
511 Box::new(Self {
512 config: rule_config,
513 style_explicit,
514 })
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::lint_context::LintContext;
522
523 #[test]
524 fn test_backtick_style_with_backticks() {
525 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
526 let content = "```\ncode\n```";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx).unwrap();
529
530 assert_eq!(result.len(), 0);
531 }
532
533 #[test]
534 fn test_backtick_style_with_tildes() {
535 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
536 let content = "~~~\ncode\n~~~";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538 let result = rule.check(&ctx).unwrap();
539
540 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
542 assert_eq!(result[0].line, 1);
543 assert_eq!(result[1].line, 3);
544 }
545
546 #[test]
547 fn test_tilde_style_with_tildes() {
548 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
549 let content = "~~~\ncode\n~~~";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552
553 assert_eq!(result.len(), 0);
554 }
555
556 #[test]
557 fn test_tilde_style_with_backticks() {
558 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
559 let content = "```\ncode\n```";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562
563 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
565 }
566
567 #[test]
568 fn test_mdg_overrides_tilde_style_to_backtick() {
569 let backticks = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a JSON payload\n\n ```json\n {\"ok\": true}\n ```";
574 let tildes =
575 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ~~~text\n example\n ~~~";
576
577 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
578 let ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::MDG, None);
579 assert!(rule.check(&ctx).unwrap().is_empty());
580 assert_eq!(rule.fix(&ctx).unwrap(), backticks);
581
582 let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
583 assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
584 let fixed = rule.fix(&ctx).unwrap();
585 assert_eq!(
586 fixed,
587 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ```text\n example\n ```"
588 );
589
590 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
591 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
592 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
593
594 let standard_ctx = LintContext::new(backticks, crate::config::MarkdownFlavor::Standard, None);
596 assert_eq!(rule.check(&standard_ctx).unwrap().len(), 2);
597 assert!(rule.fix(&standard_ctx).unwrap().contains("~~~json"));
598
599 let standard_ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::Standard, None);
600 assert!(rule.check(&standard_ctx).unwrap().is_empty());
601 assert_eq!(rule.fix(&standard_ctx).unwrap(), tildes);
602 }
603
604 #[test]
605 fn test_mdg_applies_backtick_style() {
606 let tildes =
609 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ~~~text\n example\n ~~~";
610
611 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
612 let ctx = LintContext::new(tildes, crate::config::MarkdownFlavor::MDG, None);
613 assert_eq!(rule.check(&ctx).unwrap().len(), 2, "the opening and closing fence");
614 assert_eq!(
615 rule.fix(&ctx).unwrap(),
616 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n ```text\n example\n ```"
617 );
618 }
619
620 #[test]
621 fn test_mdg_tilde_style_still_disambiguates_fence_length() {
622 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
626 let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n~~~\n```rust\ncode\n```\n~~~";
627
628 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
629 let fixed = rule.fix(&mdg_ctx).unwrap();
630 assert_eq!(
631 fixed,
632 "# Feature: Checkout\n\n## Scenario: Purchase\n\n* Given a rendered example\n\n````\n```rust\ncode\n```\n````"
633 );
634
635 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
636 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
637
638 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 assert!(rule.check(&standard_ctx).unwrap().is_empty());
642 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
643 }
644
645 #[test]
646 fn test_from_config_records_whether_style_was_configured() {
647 use crate::config::Config;
651 use std::collections::BTreeMap;
652
653 let mut values = BTreeMap::new();
654 values.insert("style".to_string(), toml::Value::String("tilde".to_string()));
655 let mut config = Config::default();
656 config.rules.insert(
657 "MD048".to_string(),
658 crate::config::RuleConfig { severity: None, values },
659 );
660
661 let configured = MD048CodeFenceStyle::from_config(&config);
662 let configured = configured.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
663 assert_eq!(configured.config.style, CodeFenceStyle::Tilde);
664 assert!(configured.style_explicit);
665
666 let defaulted = MD048CodeFenceStyle::from_config(&Config::default());
667 let defaulted = defaulted.as_any().downcast_ref::<MD048CodeFenceStyle>().unwrap();
668 assert!(!defaulted.style_explicit);
669
670 let tilde = MD048CodeFenceStyle::from_config_struct(MD048Config {
673 style: CodeFenceStyle::Tilde,
674 });
675 let content = "# Feature: F\n\n~~~text\nexample\n~~~";
676 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
677 assert_eq!(tilde.fix(&mdg_ctx).unwrap(), "# Feature: F\n\n```text\nexample\n```");
678 }
679
680 #[test]
681 fn test_mdg_consistent_fence_style_ignores_tilde_prevalence() {
682 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
685 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 ```";
686
687 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 assert_eq!(rule.detect_style(&standard_ctx), Some(CodeFenceStyle::Tilde));
689
690 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
691 let warnings = rule.check(&mdg_ctx).unwrap();
692 assert_eq!(warnings.len(), 4, "two tilde blocks, each with two fence lines");
693 assert!(
694 warnings
695 .iter()
696 .all(|warning| warning.message.contains("use ``` instead of ~~~"))
697 );
698
699 let fixed = rule.fix(&mdg_ctx).unwrap();
700 assert!(!fixed.contains("~~~"), "MDG must convert every fence: {fixed:?}");
701
702 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
703 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
704 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
705 }
706
707 #[test]
708 fn test_consistent_style_tie_prefers_backtick() {
709 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
710 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714
715 assert_eq!(result.len(), 2);
717 assert_eq!(result[0].line, 5);
718 assert_eq!(result[1].line, 7);
719 }
720
721 #[test]
722 fn test_consistent_style_tilde_most_prevalent() {
723 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
724 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728
729 assert_eq!(result.len(), 2);
731 assert_eq!(result[0].line, 5);
732 assert_eq!(result[1].line, 7);
733 }
734
735 #[test]
736 fn test_detect_style_backtick() {
737 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
738 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard, None);
739 let style = rule.detect_style(&ctx);
740
741 assert_eq!(style, Some(CodeFenceStyle::Backtick));
742 }
743
744 #[test]
745 fn test_detect_style_tilde() {
746 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
747 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard, None);
748 let style = rule.detect_style(&ctx);
749
750 assert_eq!(style, Some(CodeFenceStyle::Tilde));
751 }
752
753 #[test]
754 fn test_detect_style_none() {
755 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
756 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard, None);
757 let style = rule.detect_style(&ctx);
758
759 assert_eq!(style, None);
760 }
761
762 #[test]
763 fn test_fix_backticks_to_tildes() {
764 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
765 let content = "```\ncode\n```";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let fixed = rule.fix(&ctx).unwrap();
768
769 assert_eq!(fixed, "~~~\ncode\n~~~");
770 }
771
772 #[test]
773 fn test_fix_tildes_to_backticks() {
774 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
775 let content = "~~~\ncode\n~~~";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let fixed = rule.fix(&ctx).unwrap();
778
779 assert_eq!(fixed, "```\ncode\n```");
780 }
781
782 #[test]
783 fn test_fix_preserves_fence_length() {
784 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
785 let content = "````\ncode with backtick\n```\ncode\n````";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 let fixed = rule.fix(&ctx).unwrap();
788
789 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
790 }
791
792 #[test]
793 fn test_fix_preserves_language_info() {
794 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
795 let content = "~~~rust\nfn main() {}\n~~~";
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let fixed = rule.fix(&ctx).unwrap();
798
799 assert_eq!(fixed, "```rust\nfn main() {}\n```");
800 }
801
802 #[test]
803 fn test_indented_code_fences() {
804 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
805 let content = " ```\n code\n ```";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807 let result = rule.check(&ctx).unwrap();
808
809 assert_eq!(result.len(), 2);
810 }
811
812 #[test]
813 fn test_fix_indented_fences() {
814 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
815 let content = " ```\n code\n ```";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let fixed = rule.fix(&ctx).unwrap();
818
819 assert_eq!(fixed, " ~~~\n code\n ~~~");
820 }
821
822 #[test]
823 fn test_nested_fences_not_changed() {
824 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
825 let content = "```\ncode with ``` inside\n```";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let fixed = rule.fix(&ctx).unwrap();
828
829 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
830 }
831
832 #[test]
833 fn test_multiple_code_blocks() {
834 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
835 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838
839 assert_eq!(result.len(), 4); }
841
842 #[test]
843 fn test_empty_content() {
844 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
845 let content = "";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848
849 assert_eq!(result.len(), 0);
850 }
851
852 #[test]
853 fn test_preserve_trailing_newline() {
854 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
855 let content = "~~~\ncode\n~~~\n";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let fixed = rule.fix(&ctx).unwrap();
858
859 assert_eq!(fixed, "```\ncode\n```\n");
860 }
861
862 #[test]
863 fn test_no_trailing_newline() {
864 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
865 let content = "~~~\ncode\n~~~";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let fixed = rule.fix(&ctx).unwrap();
868
869 assert_eq!(fixed, "```\ncode\n```");
870 }
871
872 #[test]
873 fn test_default_config() {
874 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
875 let (name, _config) = rule.default_config_section().unwrap();
876 assert_eq!(name, "MD048");
877 }
878
879 #[test]
882 fn test_tilde_outer_with_backtick_inner_uses_longer_fence() {
883 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
884 let content = "~~~text\n```rust\ncode\n```\n~~~";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let fixed = rule.fix(&ctx).unwrap();
887
888 assert_eq!(fixed, "````text\n```rust\ncode\n```\n````");
890 }
891
892 #[test]
895 fn test_check_tilde_outer_with_backtick_inner_warns_with_correct_replacement() {
896 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
897 let content = "~~~text\n```rust\ncode\n```\n~~~";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let warnings = rule.check(&ctx).unwrap();
900
901 assert_eq!(warnings.len(), 2);
903 let open_fix = warnings[0].fix.as_ref().unwrap();
904 let close_fix = warnings[1].fix.as_ref().unwrap();
905 assert_eq!(open_fix.replacement, "````text");
906 assert_eq!(close_fix.replacement, "````");
907 }
908
909 #[test]
912 fn test_tilde_outer_with_longer_backtick_inner() {
913 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
914 let content = "~~~text\n````rust\ncode\n````\n~~~";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let fixed = rule.fix(&ctx).unwrap();
917
918 assert_eq!(fixed, "`````text\n````rust\ncode\n````\n`````");
919 }
920
921 #[test]
924 fn test_backtick_outer_with_tilde_inner_uses_longer_fence() {
925 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
926 let content = "```text\n~~~rust\ncode\n~~~\n```";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let fixed = rule.fix(&ctx).unwrap();
929
930 assert_eq!(fixed, "~~~~text\n~~~rust\ncode\n~~~\n~~~~");
931 }
932
933 #[test]
941 fn test_info_string_interior_not_ambiguous() {
942 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
943 let content = "```text\n```rust\ncode\n```\n```";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let warnings = rule.check(&ctx).unwrap();
951
952 assert_eq!(warnings.len(), 0, "expected 0 warnings, got {warnings:?}");
955 }
956
957 #[test]
959 fn test_info_string_interior_fix_unchanged() {
960 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
961 let content = "```text\n```rust\ncode\n```\n```";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let fixed = rule.fix(&ctx).unwrap();
964
965 assert_eq!(fixed, content);
967 }
968
969 #[test]
971 fn test_tilde_info_string_interior_not_ambiguous() {
972 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
973 let content = "~~~text\n~~~rust\ncode\n~~~\n~~~";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let fixed = rule.fix(&ctx).unwrap();
976
977 assert_eq!(fixed, content);
979 }
980
981 #[test]
983 fn test_no_ambiguity_when_outer_is_longer() {
984 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
985 let content = "````text\n```rust\ncode\n```\n````";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let warnings = rule.check(&ctx).unwrap();
988
989 assert_eq!(
990 warnings.len(),
991 0,
992 "should have no warnings when outer is already longer"
993 );
994 }
995
996 #[test]
1000 fn test_longer_info_string_interior_not_ambiguous() {
1001 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1002 let content = "```text\n`````rust\ncode\n`````\n```";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let fixed = rule.fix(&ctx).unwrap();
1010
1011 assert_eq!(fixed, content);
1013 }
1014
1015 #[test]
1017 fn test_info_string_interior_consistent_style_no_warning() {
1018 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1019 let content = "```text\n```rust\ncode\n```\n```";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let warnings = rule.check(&ctx).unwrap();
1022
1023 assert_eq!(warnings.len(), 0);
1024 }
1025
1026 #[test]
1033 fn test_cross_style_bare_inner_requires_lengthening() {
1034 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1035 let content = "~~~\n`````rust\ncode\n```\n~~~";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let fixed = rule.fix(&ctx).unwrap();
1041
1042 assert_eq!(fixed, "````\n`````rust\ncode\n```\n````");
1045 }
1046
1047 #[test]
1051 fn test_cross_style_info_only_interior_no_lengthening() {
1052 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1053 let content = "~~~text\n```rust\nexample\n```rust\n~~~";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let fixed = rule.fix(&ctx).unwrap();
1059
1060 assert_eq!(fixed, "```text\n```rust\nexample\n```rust\n```");
1061 }
1062
1063 #[test]
1066 fn test_same_style_info_outer_shorter_bare_interior_no_warning() {
1067 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1068 let content = "````text\n```\nshowing raw fence\n```\n````";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let warnings = rule.check(&ctx).unwrap();
1074
1075 assert_eq!(
1076 warnings.len(),
1077 0,
1078 "shorter bare interior sequences cannot close a 4-backtick outer"
1079 );
1080 }
1081
1082 #[test]
1085 fn test_same_style_no_info_outer_shorter_bare_interior_no_warning() {
1086 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1087 let content = "````\n```\nsome code\n```\n````";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let warnings = rule.check(&ctx).unwrap();
1092
1093 assert_eq!(
1094 warnings.len(),
1095 0,
1096 "shorter bare interior sequences cannot close a 4-backtick outer (no info)"
1097 );
1098 }
1099
1100 #[test]
1103 fn test_overindented_inner_sequence_not_ambiguous() {
1104 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1105 let content = "```text\n ```\ncode\n```";
1106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let warnings = rule.check(&ctx).unwrap();
1108 let fixed = rule.fix(&ctx).unwrap();
1109
1110 assert_eq!(warnings.len(), 0, "over-indented inner fence should not warn");
1111 assert_eq!(fixed, content, "over-indented inner fence should remain unchanged");
1112 }
1113
1114 #[test]
1117 fn test_conversion_ignores_overindented_inner_sequence_for_closing_detection() {
1118 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1119 let content = "~~~text\n ~~~\n```rust\ncode\n```\n~~~";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let fixed = rule.fix(&ctx).unwrap();
1122
1123 assert_eq!(fixed, "````text\n ~~~\n```rust\ncode\n```\n````");
1124 }
1125
1126 #[test]
1129 fn test_top_level_four_space_fence_marker_is_ignored() {
1130 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1131 let content = " ```\n code\n ```";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let warnings = rule.check(&ctx).unwrap();
1134 let fixed = rule.fix(&ctx).unwrap();
1135
1136 assert_eq!(warnings.len(), 0);
1137 assert_eq!(fixed, content);
1138 }
1139
1140 fn assert_fix_roundtrip(rule: &MD048CodeFenceStyle, content: &str) {
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let fixed = rule.fix(&ctx).unwrap();
1148 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1149 let remaining = rule.check(&ctx2).unwrap();
1150 assert!(
1151 remaining.is_empty(),
1152 "After fix, expected 0 violations but got {}.\nOriginal:\n{content}\nFixed:\n{fixed}\nRemaining: {remaining:?}",
1153 remaining.len(),
1154 );
1155 }
1156
1157 #[test]
1158 fn test_roundtrip_backticks_to_tildes() {
1159 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1160 assert_fix_roundtrip(&rule, "```\ncode\n```");
1161 }
1162
1163 #[test]
1164 fn test_roundtrip_tildes_to_backticks() {
1165 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1166 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~");
1167 }
1168
1169 #[test]
1170 fn test_roundtrip_mixed_fences_consistent() {
1171 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1172 assert_fix_roundtrip(&rule, "```\ncode\n```\n\n~~~\nmore code\n~~~");
1173 }
1174
1175 #[test]
1176 fn test_roundtrip_with_info_string() {
1177 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1178 assert_fix_roundtrip(&rule, "~~~rust\nfn main() {}\n~~~");
1179 }
1180
1181 #[test]
1182 fn test_roundtrip_longer_fences() {
1183 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1184 assert_fix_roundtrip(&rule, "`````\ncode\n`````");
1185 }
1186
1187 #[test]
1188 fn test_roundtrip_nested_inner_fences() {
1189 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1190 assert_fix_roundtrip(&rule, "~~~text\n```rust\ncode\n```\n~~~");
1191 }
1192
1193 #[test]
1194 fn test_roundtrip_indented_fences() {
1195 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1196 assert_fix_roundtrip(&rule, " ```\n code\n ```");
1197 }
1198
1199 #[test]
1200 fn test_roundtrip_multiple_blocks() {
1201 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1202 assert_fix_roundtrip(&rule, "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~");
1203 }
1204
1205 #[test]
1206 fn test_roundtrip_fence_length_ambiguity() {
1207 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1208 assert_fix_roundtrip(&rule, "~~~\n`````rust\ncode\n```\n~~~");
1209 }
1210
1211 #[test]
1212 fn test_roundtrip_trailing_newline() {
1213 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1214 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n");
1215 }
1216
1217 #[test]
1218 fn test_roundtrip_tilde_outer_longer_backtick_inner() {
1219 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
1220 assert_fix_roundtrip(&rule, "~~~text\n````rust\ncode\n````\n~~~");
1221 }
1222
1223 #[test]
1224 fn test_roundtrip_backtick_outer_tilde_inner() {
1225 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
1226 assert_fix_roundtrip(&rule, "```text\n~~~rust\ncode\n~~~\n```");
1227 }
1228
1229 #[test]
1230 fn test_roundtrip_consistent_tilde_prevalent() {
1231 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1232 assert_fix_roundtrip(&rule, "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~");
1233 }
1234
1235 #[test]
1239 fn test_fix_idempotent_no_double_blanks_with_nested_fences() {
1240 use crate::fix_coordinator::FixCoordinator;
1241 use crate::rules::Rule;
1242 use crate::rules::md013_line_length::MD013LineLength;
1243
1244 let content = "\
1248- **edition**: Rust edition to use by default for the code snippets. Default is `\"2015\"`. \
1249Individual code blocks can be controlled with the `edition2015`, `edition2018`, `edition2021` \
1250or `edition2024` annotations, such as:
1251
1252 ~~~text
1253 ```rust,edition2015
1254 // This only works in 2015.
1255 let try = true;
1256 ```
1257 ~~~
1258
1259### Build options
1260";
1261 let rules: Vec<Box<dyn Rule>> = vec![
1262 Box::new(MD013LineLength::new(80, false, false, false, true)),
1263 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1264 ];
1265
1266 let mut first_pass = content.to_string();
1267 let coordinator = FixCoordinator::new();
1268 coordinator
1269 .apply_fixes_iterative(&rules, &[], &mut first_pass, &Default::default(), 10, None)
1270 .expect("fix should not fail");
1271
1272 let lines: Vec<&str> = first_pass.lines().collect();
1274 for i in 0..lines.len().saturating_sub(1) {
1275 assert!(
1276 !(lines[i].is_empty() && lines[i + 1].is_empty()),
1277 "Double blank at lines {},{} after first pass:\n{first_pass}",
1278 i + 1,
1279 i + 2
1280 );
1281 }
1282
1283 let mut second_pass = first_pass.clone();
1285 let rules2: Vec<Box<dyn Rule>> = vec![
1286 Box::new(MD013LineLength::new(80, false, false, false, true)),
1287 Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Backtick)),
1288 ];
1289 let coordinator2 = FixCoordinator::new();
1290 coordinator2
1291 .apply_fixes_iterative(&rules2, &[], &mut second_pass, &Default::default(), 10, None)
1292 .expect("fix should not fail");
1293
1294 assert_eq!(
1295 first_pass, second_pass,
1296 "Fix is not idempotent:\nFirst pass:\n{first_pass}\nSecond pass:\n{second_pass}"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_front_matter_fence_does_not_drive_style_detection() {
1302 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
1307 let content = "---\ndescription: |\n ```\n code\n ```\n---\n\n~~~python\nprint(\"hi\")\n~~~\n";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let result = rule.check(&ctx).unwrap();
1310 assert!(
1311 result.is_empty(),
1312 "front-matter fence must not drive style detection, got: {result:?}"
1313 );
1314 }
1315}