Skip to content

Commit c78995e

Browse files
committed
fix(rules): preserve code blocks inside admonitions during MD013 reflow
FencedCodeTracker::process_line returned false for the closing fence line, causing in_code_block to be cleared. This meant closing fences inside MkDocs admonitions were misclassified as plain text and merged with subsequent paragraph content during reflow. Fix at two levels: - FencedCodeTracker now returns true for closing fence lines (the closing fence is still part of the code block). This fixes all MkDocs container types (admonitions, content tabs, markdown HTML). - MD013 admonition classification also checks is_fence_marker as defense-in-depth. Fixes #485
1 parent 98bf311 commit c78995e

File tree

3 files changed

+231
-2
lines changed

3 files changed

+231
-2
lines changed

src/lint_context/flavor_detection.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ impl FencedCodeTracker {
3535
self.fence_marker = Some(fence_char.to_string().repeat(fence_len));
3636
}
3737
}
38+
self.in_fenced_code
3839
} else if let Some(ref marker) = self.fence_marker {
3940
let fence_char = marker.chars().next().unwrap();
4041
if trimmed.starts_with(marker.as_str())
@@ -43,11 +44,17 @@ impl FencedCodeTracker {
4344
.skip(marker.len())
4445
.all(|c| c == fence_char || c.is_whitespace())
4546
{
47+
// The closing fence is still part of the code block for the
48+
// current line, so return true. Subsequent lines will see
49+
// in_fenced_code = false.
4650
self.in_fenced_code = false;
4751
self.fence_marker = None;
52+
return true;
4853
}
54+
true
55+
} else {
56+
self.in_fenced_code
4957
}
50-
self.in_fenced_code
5158
}
5259

5360
/// Reset state when exiting a container.

src/rules/md013_line_length/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1076,14 +1076,21 @@ impl MD013LineLength {
10761076
// Check for MkDocs admonition lines inside list items.
10771077
// The flavor detection marks these with in_admonition, so we
10781078
// can classify them as admonition header or body content.
1079+
// Code fence markers (``` or ~~~) within admonitions must be
1080+
// classified as CodeBlock so the block builder preserves them
1081+
// verbatim instead of merging them into paragraph text.
10791082
if line_info.in_admonition {
10801083
let raw_content = line_info.content(ctx.content);
10811084
if mkdocs_admonitions::is_admonition_start(raw_content) {
10821085
let header_text = raw_content[indent..].trim_end().to_string();
10831086
list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
10841087
} else {
10851088
let body_text = raw_content[indent..].trim_end().to_string();
1086-
list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
1089+
if is_fence_marker(&body_text) {
1090+
list_item_lines.push(LineType::CodeBlock(body_text, indent));
1091+
} else {
1092+
list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
1093+
}
10871094
}
10881095
i += 1;
10891096
continue;

src/rules/md013_line_length/tests.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4737,6 +4737,221 @@ fn test_reflow_admonition_body_indent_preserved() {
47374737
}
47384738
}
47394739

4740+
#[test]
4741+
fn test_reflow_admonition_with_code_block_in_list_item() {
4742+
// Code blocks inside admonitions within list items must be preserved
4743+
// verbatim. The closing fence must not be merged with subsequent text.
4744+
// Regression test for https://github.com/rvben/rumdl/issues/485
4745+
let config = MD013Config {
4746+
line_length: crate::types::LineLength::from_const(88),
4747+
paragraphs: true,
4748+
code_blocks: true,
4749+
tables: true,
4750+
headings: true,
4751+
strict: false,
4752+
reflow: true,
4753+
reflow_mode: ReflowMode::Default,
4754+
length_mode: LengthMode::default(),
4755+
abbreviations: Vec::new(),
4756+
};
4757+
let rule = MD013LineLength::from_config_struct(config);
4758+
4759+
let content = concat!(
4760+
"# Test\n",
4761+
"\n",
4762+
"- Lorem ipsum dolor sit amet.\n",
4763+
"\n",
4764+
" !!! example\n",
4765+
"\n",
4766+
" ```yaml\n",
4767+
" hello: world\n",
4768+
" ```\n",
4769+
"\n",
4770+
" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n",
4771+
);
4772+
4773+
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4774+
let result = rule.check(&ctx).unwrap();
4775+
let fixes: Vec<_> = result.iter().filter(|w| w.fix.is_some()).collect();
4776+
assert!(!fixes.is_empty(), "Should have a reflow fix");
4777+
4778+
let fix = fixes[0].fix.as_ref().unwrap();
4779+
let replacement = &fix.replacement;
4780+
4781+
// The code block must be preserved intact
4782+
assert!(
4783+
replacement.contains(" ```yaml\n hello: world\n ```"),
4784+
"Code block inside admonition must be preserved verbatim; got:\n{replacement}"
4785+
);
4786+
4787+
// The closing fence must NOT be merged with paragraph text
4788+
assert!(
4789+
!replacement.contains("``` Lorem"),
4790+
"Closing fence must not be merged with paragraph text; got:\n{replacement}"
4791+
);
4792+
4793+
// The trailing paragraph must be reflowed
4794+
assert!(
4795+
replacement.contains(" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor\n incididunt ut labore et dolore magna aliqua."),
4796+
"Trailing paragraph should be reflowed; got:\n{replacement}"
4797+
);
4798+
}
4799+
4800+
#[test]
4801+
fn test_reflow_admonition_with_tilde_fence_in_list_item() {
4802+
// Tilde fences (~~~) inside admonitions must be handled the same as backtick fences.
4803+
let config = MD013Config {
4804+
line_length: crate::types::LineLength::from_const(88),
4805+
paragraphs: true,
4806+
code_blocks: true,
4807+
tables: true,
4808+
headings: true,
4809+
strict: false,
4810+
reflow: true,
4811+
reflow_mode: ReflowMode::Default,
4812+
length_mode: LengthMode::default(),
4813+
abbreviations: Vec::new(),
4814+
};
4815+
let rule = MD013LineLength::from_config_struct(config);
4816+
4817+
let content = concat!(
4818+
"# Test\n",
4819+
"\n",
4820+
"- Lorem ipsum dolor sit amet.\n",
4821+
"\n",
4822+
" !!! example\n",
4823+
"\n",
4824+
" ~~~python\n",
4825+
" def hello():\n",
4826+
" pass\n",
4827+
" ~~~\n",
4828+
"\n",
4829+
" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n",
4830+
);
4831+
4832+
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4833+
let result = rule.check(&ctx).unwrap();
4834+
let fixes: Vec<_> = result.iter().filter(|w| w.fix.is_some()).collect();
4835+
assert!(!fixes.is_empty(), "Should have a reflow fix");
4836+
4837+
let fix = fixes[0].fix.as_ref().unwrap();
4838+
let replacement = &fix.replacement;
4839+
4840+
// Tilde-fenced code block must be preserved intact
4841+
assert!(
4842+
replacement.contains(" ~~~python\n def hello():\n pass\n ~~~"),
4843+
"Tilde-fenced code block must be preserved; got:\n{replacement}"
4844+
);
4845+
4846+
assert!(
4847+
!replacement.contains("~~~ Lorem") && !replacement.contains("~~~Lorem"),
4848+
"Closing tilde fence must not be merged with text; got:\n{replacement}"
4849+
);
4850+
}
4851+
4852+
#[test]
4853+
fn test_reflow_admonition_with_multiple_code_blocks_in_list_item() {
4854+
// Multiple code blocks inside an admonition must all be preserved.
4855+
let config = MD013Config {
4856+
line_length: crate::types::LineLength::from_const(88),
4857+
paragraphs: true,
4858+
code_blocks: true,
4859+
tables: true,
4860+
headings: true,
4861+
strict: false,
4862+
reflow: true,
4863+
reflow_mode: ReflowMode::Default,
4864+
length_mode: LengthMode::default(),
4865+
abbreviations: Vec::new(),
4866+
};
4867+
let rule = MD013LineLength::from_config_struct(config);
4868+
4869+
let content = concat!(
4870+
"# Test\n",
4871+
"\n",
4872+
"- Lorem ipsum dolor sit amet.\n",
4873+
"\n",
4874+
" !!! example\n",
4875+
"\n",
4876+
" ```yaml\n",
4877+
" hello: world\n",
4878+
" ```\n",
4879+
"\n",
4880+
" ```python\n",
4881+
" print(\"hello\")\n",
4882+
" ```\n",
4883+
"\n",
4884+
" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n",
4885+
);
4886+
4887+
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4888+
let result = rule.check(&ctx).unwrap();
4889+
let fixes: Vec<_> = result.iter().filter(|w| w.fix.is_some()).collect();
4890+
assert!(!fixes.is_empty(), "Should have a reflow fix");
4891+
4892+
let fix = fixes[0].fix.as_ref().unwrap();
4893+
let replacement = &fix.replacement;
4894+
4895+
// Both code blocks must be preserved
4896+
assert!(
4897+
replacement.contains("```yaml"),
4898+
"First code block opening fence must be preserved; got:\n{replacement}"
4899+
);
4900+
assert!(
4901+
replacement.contains("```python"),
4902+
"Second code block opening fence must be preserved; got:\n{replacement}"
4903+
);
4904+
4905+
// No fence markers merged with text
4906+
assert!(
4907+
!replacement.contains("``` Lorem") && !replacement.contains("``` print"),
4908+
"Fence markers must not be merged with other content; got:\n{replacement}"
4909+
);
4910+
}
4911+
4912+
#[test]
4913+
fn test_reflow_admonition_code_block_idempotent() {
4914+
// After fixing, running again should produce no changes.
4915+
let config = MD013Config {
4916+
line_length: crate::types::LineLength::from_const(88),
4917+
paragraphs: true,
4918+
code_blocks: true,
4919+
tables: true,
4920+
headings: true,
4921+
strict: false,
4922+
reflow: true,
4923+
reflow_mode: ReflowMode::Default,
4924+
length_mode: LengthMode::default(),
4925+
abbreviations: Vec::new(),
4926+
};
4927+
let rule = MD013LineLength::from_config_struct(config);
4928+
4929+
// This is already the correctly-formatted output
4930+
let content = concat!(
4931+
"# Test\n",
4932+
"\n",
4933+
"- Lorem ipsum dolor sit amet.\n",
4934+
"\n",
4935+
" !!! example\n",
4936+
"\n",
4937+
" ```yaml\n",
4938+
" hello: world\n",
4939+
" ```\n",
4940+
"\n",
4941+
" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor\n",
4942+
" incididunt ut labore et dolore magna aliqua.\n",
4943+
);
4944+
4945+
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4946+
let result = rule.check(&ctx).unwrap();
4947+
let fixes: Vec<_> = result.iter().filter(|w| w.fix.is_some()).collect();
4948+
assert!(
4949+
fixes.is_empty(),
4950+
"Already correctly formatted content should not produce fixes; got {} fix(es)",
4951+
fixes.len()
4952+
);
4953+
}
4954+
47404955
#[test]
47414956
fn test_reflow_tab_container_in_list_item() {
47424957
// MkDocs tab containers (=== "Tab Title") inside list items should not

0 commit comments

Comments
 (0)