Skip to content

Commit 2654364

Browse files
committed
fix(md065): leave markers alone inside a block that hides its content
`is_horizontal_rule` was computed from the line text before the passes that know which block a line belongs to had run, so a run of `*`, `-` or `_` inside an HTML comment, an HTML block, a math block, an MDX or Obsidian comment, or an Azure DevOps colon fence was a thematic break to every rule reading the flag. MD065 acted on it and `rumdl fmt` inserted blank lines into the block, which in display math ends the block. The flag is now settled once those passes have answered, the way the kramdown sanitization beside it already settles its own fields. Containers whose body is markdown (Pandoc divs, MkDocs admonitions and tabs, PyMdown blocks, MyST directives, JSX component bodies) are left alone, so a break written in one is still reported.
1 parent 7874edf commit 2654364

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

‎src/lint_context/mod.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,29 @@ impl<'a> LintContext<'a> {
683683
}
684684
}
685685

686+
// A run of `-`, `*` or `_` is a thematic break only because of the block it
687+
// sits in, and that block is known only now: the passes above are what mark
688+
// an HTML comment, an HTML block, a math block, an MDX or Obsidian comment,
689+
// and the colon fences a flavor reads as code. The flag was computed from the
690+
// line text before any of them ran, so it is settled here against the answers
691+
// they produced, the way the kramdown sanitization above settles its own.
692+
//
693+
// Left alone deliberately: containers whose body IS markdown (Pandoc divs,
694+
// MkDocs admonitions and tabs, PyMdown blocks, MyST directives) render a
695+
// thematic break written in them.
696+
for line in &mut lines {
697+
if line.is_horizontal_rule
698+
&& (line.in_code_block
699+
|| line.in_html_block
700+
|| line.in_html_comment
701+
|| line.in_math_block
702+
|| line.in_mdx_comment
703+
|| line.in_obsidian_comment)
704+
{
705+
line.is_horizontal_rule = false;
706+
}
707+
}
708+
686709
// Parse code spans early so we can exclude them from link/image parsing
687710
let mut code_spans = profile_section!(
688711
"Code spans",

‎src/lint_context/tests.rs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2269,3 +2269,88 @@ fn test_an_unclosed_comment_in_an_admonition_hides_a_fence_below_it() {
22692269
assert!(ctx.is_in_html_comment(content.find("code").unwrap()));
22702270
assert!(ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
22712271
}
2272+
2273+
/// Line number (1-indexed) of the only line carrying `is_horizontal_rule`, or
2274+
/// `None` when no line does.
2275+
fn horizontal_rule_line(ctx: &LintContext) -> Option<usize> {
2276+
let marked: Vec<usize> = ctx
2277+
.lines
2278+
.iter()
2279+
.enumerate()
2280+
.filter(|(_, line)| line.is_horizontal_rule)
2281+
.map(|(i, _)| i + 1)
2282+
.collect();
2283+
assert!(marked.len() <= 1, "expected at most one marked line, got {marked:?}");
2284+
marked.first().copied()
2285+
}
2286+
2287+
#[test]
2288+
fn test_a_break_in_a_block_that_hides_its_content_is_not_a_horizontal_rule() {
2289+
// The markers are computed from the line text, before the passes that know
2290+
// which block the line belongs to have run.
2291+
for (content, flavor) in [
2292+
("Text.\n\n<!--\n***\n-->\n\nMore.\n", MarkdownFlavor::Standard),
2293+
("Text.\n\n<div>\n***\n</div>\n\nMore.\n", MarkdownFlavor::Standard),
2294+
("Text.\n\n$$\n***\n$$\n\nMore.\n", MarkdownFlavor::Standard),
2295+
("Text.\n\n::: mermaid\n***\n:::\n\nMore.\n", MarkdownFlavor::AzureDevOps),
2296+
("Text.\n\n{/*\n***\n*/}\n\nMore.\n", MarkdownFlavor::MDX),
2297+
("Text.\n\n%%\n***\n%%\n\nMore.\n", MarkdownFlavor::Obsidian),
2298+
] {
2299+
let ctx = LintContext::new(content, flavor, None);
2300+
assert_eq!(
2301+
horizontal_rule_line(&ctx),
2302+
None,
2303+
"line 4 was still a horizontal rule in {content:?} ({flavor:?})"
2304+
);
2305+
}
2306+
}
2307+
2308+
#[test]
2309+
fn test_a_break_in_a_container_whose_body_is_markdown_stays_a_horizontal_rule() {
2310+
// These containers render their body as markdown, so a break written in one
2311+
// is a real break and the sweep above must leave it alone.
2312+
for (content, flavor) in [
2313+
("Text.\n\n***\n\nMore.\n", MarkdownFlavor::Standard),
2314+
("::: note\nText.\n***\nMore.\n:::\n", MarkdownFlavor::Pandoc),
2315+
("<Tabs>\nText.\n***\nMore.\n</Tabs>\n", MarkdownFlavor::MDX),
2316+
("/// note\nText.\n***\nMore.\n///\n", MarkdownFlavor::MkDocs),
2317+
("> Text.\n> ***\n> More.\n", MarkdownFlavor::Standard),
2318+
// A `markdown` attribute takes effect for content a blank line separates
2319+
// from the tag, which is also where rumdl starts linting the body.
2320+
(
2321+
"<div markdown>\n\nText.\n***\nMore.\n\n</div>\n",
2322+
MarkdownFlavor::Standard,
2323+
),
2324+
(
2325+
"<div markdown>\n\nText.\n***\nMore.\n\n</div>\n",
2326+
MarkdownFlavor::MkDocs,
2327+
),
2328+
] {
2329+
let ctx = LintContext::new(content, flavor, None);
2330+
assert!(
2331+
horizontal_rule_line(&ctx).is_some(),
2332+
"the break stopped being a horizontal rule in {content:?} ({flavor:?})"
2333+
);
2334+
}
2335+
}
2336+
2337+
#[test]
2338+
fn test_a_break_written_flush_against_an_html_tag_is_raw_html() {
2339+
// Without the blank line the `markdown` attribute needs, the body is a plain
2340+
// HTML block: no other rule reads it as markdown (a heading there draws no
2341+
// MD022, mixed list markers no MD004), so a break there is not one either.
2342+
for (content, flavor) in [
2343+
("<div markdown>\nText.\n***\nMore.\n</div>\n", MarkdownFlavor::Standard),
2344+
(
2345+
"<div markdown=\"1\">\nText.\n***\nMore.\n</div>\n",
2346+
MarkdownFlavor::MkDocs,
2347+
),
2348+
] {
2349+
let ctx = LintContext::new(content, flavor, None);
2350+
assert_eq!(
2351+
horizontal_rule_line(&ctx),
2352+
None,
2353+
"the raw HTML was still a horizontal rule in {content:?} ({flavor:?})"
2354+
);
2355+
}
2356+
}

‎src/rules/md065_blanks_around_horizontal_rules.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,30 @@ Final thoughts.";
11671167
}
11681168
}
11691169

1170+
#[test]
1171+
fn markers_inside_a_hidden_block_are_not_spaced_out() {
1172+
// The fix inserts blank lines around what it takes for a thematic break, so
1173+
// reporting one that a comment hides or a math block owns rewrites the block
1174+
// itself - a blank line in display math ends it.
1175+
let rule = MD065BlanksAroundHorizontalRules;
1176+
1177+
for content in [
1178+
"Text.\n\n<!--\n***\n-->\n\nMore.\n",
1179+
"Text.\n\n$$\na = b\n***\nc = d\n$$\n\nMore.\n",
1180+
] {
1181+
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182+
assert!(
1183+
rule.check(&ctx).unwrap().is_empty(),
1184+
"the hidden markers were reported in {content:?}"
1185+
);
1186+
assert_eq!(
1187+
rule.fix(&ctx).unwrap(),
1188+
content,
1189+
"the block was rewritten in {content:?}"
1190+
);
1191+
}
1192+
}
1193+
11701194
#[test]
11711195
fn test_fix_preserves_no_trailing_newline() {
11721196
let rule = MD065BlanksAroundHorizontalRules;

0 commit comments

Comments
 (0)