Skip to main content

rumdl_lib/rules/
md090_no_hr_before_heading.rs

1//! Rule MD090: Flag a thematic break that sits directly above a heading.
2//!
3//! A heading already marks a section boundary, so a horizontal rule right
4//! above it (`---`, `***`, `___`) draws the same line twice. Generated
5//! Markdown produces the pattern constantly. This rule (opt-in) reports each
6//! such break, and its fix deletes the break together with the blank lines
7//! between it and the heading, keeping the blank line above the break so the
8//! heading stays separated from the paragraph before it. A break with no
9//! blank line above it is replaced by one blank line for the same reason:
10//! text butted against a setext heading would merge into the heading.
11//!
12//! Only blank lines may sit between the break and the heading, and a blank
13//! line holds nothing but spaces and tabs; other whitespace, such as a
14//! no-break space, renders as content. A comment, a
15//! reference definition or any other line in between means the break is not
16//! directly above the heading and nothing is reported. Both the break and the
17//! heading must be top-level: inside a blockquote or a list item a thematic
18//! break is that container's content. Containers whose body is ordinary
19//! Markdown (a fenced div, a MyST directive, a `markdown="1"` element) are
20//! transparent: a break there is a real break, MD082 draws the same line,
21//! and the fix has no container prefix to disturb. Inside such a container
22//! only an ATX heading is a target: a container's opening marker followed by
23//! a dash run is recorded as a setext heading though it marks no section, and
24//! since deleting a break is destructive the rule declines every setext
25//! heading in a container rather than trying to tell the two apart. A
26//! container whose body is indented rather than fenced (a MkDocs admonition, a
27//! content tab) reports nothing at all, because the shared line data does not
28//! read an indented dash run as a break; that is rumdl-wide and MD035 is
29//! silent there too. A markdown-bodied directive written with backticks
30//! rather than colons is silent for the same shared reason: its body starts
31//! as a code fence, so the break flag is settled to false before the fence is
32//! reinterpreted, and MD082 misses the same break.
33//!
34//! Off by default because slide formats (Marp, Slidev, reveal.js, Pandoc) use
35//! a thematic break as the slide separator, nearly always followed by the
36//! slide's heading.
37
38use crate::lint_context::{HeadingStyle, LineInfo, LintContext, is_setext_underline_content};
39use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
40
41#[derive(Debug, Clone, Default)]
42pub struct MD090NoHrBeforeHeading;
43
44impl MD090NoHrBeforeHeading {
45    pub fn new() -> Self {
46        Self
47    }
48
49    /// A line outside blockquotes and list items, the two containers whose
50    /// content carries a prefix the fix cannot safely edit. Containers whose
51    /// body is ordinary Markdown, such as fenced divs and MyST directives,
52    /// deliberately pass: MD082 applies the same test to the same lines.
53    fn is_top_level(line: &LineInfo) -> bool {
54        line.blockquote.is_none() && !line.in_list_block
55    }
56
57    /// Whether the detector recorded a setext heading on this line's text.
58    fn is_setext_record(line: &LineInfo) -> bool {
59        line.heading
60            .as_deref()
61            .is_some_and(|h| matches!(h.style, HeadingStyle::Setext1 | HeadingStyle::Setext2))
62    }
63
64    /// Whether a setext record on this line is a phantom rather than a
65    /// section boundary.
66    ///
67    /// A container's opening marker (`::: note`, `!!! note`, `/// note`) is
68    /// structure, and the detector reads it as setext text whenever a dash
69    /// run follows it. Distinguishing an opener from body text needs the exact
70    /// opening syntax of every container in every flavor, and each one missed
71    /// costs a deleted line, so this asks the wider question the shared line
72    /// data already answers: is the line in a container at all? Inside one,
73    /// only an ATX heading is treated as a section boundary. The cost is one
74    /// unreported break above a setext heading written inside a container; the
75    /// alternative cost is deleting a break that renders.
76    fn is_phantom_container_heading(line: &LineInfo) -> bool {
77        Self::is_setext_record(line) && line.in_flavor_container()
78    }
79
80    /// Whether this line is the text of a setext heading that bounds a
81    /// section, with its underline on the line below.
82    fn is_setext_text(line: &LineInfo) -> bool {
83        Self::is_setext_record(line) && !Self::is_phantom_container_heading(line)
84    }
85
86    /// Whether line `idx` is a thematic break that renders as one.
87    ///
88    /// `is_horizontal_rule` is computed from the line text alone, so it is also
89    /// set on the `---` underline of a setext heading. Where the detector
90    /// recorded that heading, its text line carries the record and settles the
91    /// question, for the line below the underline too: the underline ends the
92    /// paragraph, so a dash run there has nothing to underline and is a break.
93    /// A dash run the detector left unrecorded still counts as an underline
94    /// whenever the line above it is top-level paragraph text: deleting it
95    /// would demote a real heading to a paragraph, while the cost of reading a
96    /// break as an underline is one unreported break. An ATX record without the
97    /// space after its `#`s (`#hashtag`) is structurally paragraph text, as its
98    /// `is_valid` says, so it stays eligible to hold an underline; and a setext
99    /// record on a line inside a flavor container settles nothing, because the
100    /// record may be the container's own marker rather than setext text, so the
101    /// dash run below it is judged like any other line. In a flavor that gives
102    /// the marker no meaning the line is in no container, is ordinary
103    /// paragraph text, and its record is real. Only a plain dash run is
104    /// ambiguous; `***`, `___` and spaced forms like `- - -` can never
105    /// underline. A table row reads as paragraph context but cannot carry an
106    /// underline: a dash run below one ends the table and is a break, so
107    /// `in_table_block` takes the row out of the ambiguous set. The flag is
108    /// populated from the parser's table blocks, so a lone pipe line in a
109    /// flavor without tables stays ordinary paragraph text and keeps its
110    /// underline reading. One false negative is accepted, an error of silence:
111    /// a paragraph line lazily continuing a blockquote reads here as paragraph
112    /// text though its underline reading is forbidden. Untangling it needs the
113    /// detector's own analysis, and being wrong the other way deletes a
114    /// heading.
115    fn is_top_level_break(ctx: &LintContext, lines: &[LineInfo], idx: usize) -> bool {
116        let line = &lines[idx];
117        if !line.is_horizontal_rule || !Self::is_top_level(line) {
118            return false;
119        }
120        if idx == 0 {
121            return true;
122        }
123        let above = &lines[idx - 1];
124        if Self::is_setext_text(above) {
125            return false;
126        }
127        if idx >= 2 && Self::is_setext_text(&lines[idx - 2]) {
128            return true;
129        }
130        let above_content = above.content(ctx.content);
131        let may_underline = is_setext_underline_content(line.content(ctx.content))
132            && !Self::is_blank_line(above_content)
133            && Self::is_top_level(above)
134            && !above.in_table_block
135            && (above.is_paragraph_context() || above.heading.as_deref().is_some_and(|h| !h.is_valid));
136        !may_underline
137    }
138
139    /// Whether a line is blank the way CommonMark defines it: empty, or
140    /// spaces and tabs only. Other Unicode whitespace, such as a no-break
141    /// space, renders as content, so a line holding one keeps the break
142    /// from sitting directly above the heading.
143    fn is_blank_line(text: &str) -> bool {
144        text.chars().all(|c| c == ' ' || c == '\t')
145    }
146}
147
148impl Rule for MD090NoHrBeforeHeading {
149    fn name(&self) -> &'static str {
150        "MD090"
151    }
152
153    fn description(&self) -> &'static str {
154        "Horizontal rules should not precede headings"
155    }
156
157    fn category(&self) -> RuleCategory {
158        RuleCategory::Heading
159    }
160
161    fn should_skip(&self, ctx: &LintContext) -> bool {
162        !ctx.has_valid_headings() || !ctx.lines.iter().any(|line| line.is_horizontal_rule)
163    }
164
165    fn check(&self, ctx: &LintContext) -> LintResult {
166        let lines = &ctx.lines;
167        let mut warnings: Vec<LintWarning> = Vec::new();
168
169        for heading in ctx.valid_headings() {
170            // A setext heading's text is the whole paragraph its underline ends,
171            // so the break sits above the first of those lines.
172            let heading_idx = heading.first_line_num() - 1;
173            // A container's opening line carrying a phantom setext record
174            // marks no section, so a break above it is a real break to keep.
175            if !Self::is_top_level(heading.line_info) || Self::is_phantom_container_heading(heading.line_info) {
176                continue;
177            }
178
179            // Walk up from the heading over blank lines. Each break found is
180            // deleted up to the line the previous deletion started on: the
181            // heading for the nearest break, then the break below it for a run
182            // of breaks, so the ranges abut without overlapping.
183            let mut delete_end = heading_idx;
184            let mut idx = heading_idx;
185            while idx > 0 {
186                idx -= 1;
187                // Only a line that is blank in the source is skipped.
188                // `LineInfo::is_blank` is container-aware and reports a bare
189                // `>` as blank, but that line is an empty blockquote the fix
190                // must not delete.
191                if Self::is_blank_line(lines[idx].content(ctx.content)) {
192                    continue;
193                }
194                if !Self::is_top_level_break(ctx, lines, idx) {
195                    // The walk stopped on content. When that content sits on
196                    // the line directly above the topmost deleted break,
197                    // deleting the break would butt it against what follows,
198                    // merging it into a setext heading's text, so that
199                    // deletion leaves one blank line behind.
200                    if delete_end == idx + 1
201                        && delete_end != heading_idx
202                        && let Some(warning) = warnings.last_mut()
203                        && let Some(fix) = warning.fix.as_mut()
204                    {
205                        fix.replacement = "\n".to_string();
206                    }
207                    break;
208                }
209                let break_line = &lines[idx];
210                warnings.push(LintWarning {
211                    rule_name: Some(self.name().to_string()),
212                    severity: Severity::Warning,
213                    line: idx + 1,
214                    column: 1,
215                    end_line: idx + 1,
216                    end_column: break_line.content(ctx.content).chars().count() + 1,
217                    message: format!("Horizontal rule before heading '{}' is redundant", heading.heading.text),
218                    fix: Some(Fix::new(
219                        break_line.byte_offset..lines[delete_end].byte_offset,
220                        String::new(),
221                    )),
222                });
223                delete_end = idx;
224            }
225        }
226
227        // A run of breaks is discovered bottom-up; report in document order.
228        warnings.sort_by_key(|w| w.line);
229        Ok(warnings)
230    }
231
232    fn fix_capability(&self) -> FixCapability {
233        FixCapability::FullyFixable
234    }
235
236    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
237        let warnings = self.check(ctx)?;
238        let warnings =
239            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
240        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
241    }
242
243    fn as_any(&self) -> &dyn std::any::Any {
244        self
245    }
246
247    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
248    where
249        Self: Sized,
250    {
251        Box::new(Self::new())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::config::MarkdownFlavor;
259
260    fn check_in(content: &str, flavor: MarkdownFlavor) -> Vec<LintWarning> {
261        let ctx = LintContext::new(content, flavor, None);
262        MD090NoHrBeforeHeading::new().check(&ctx).unwrap()
263    }
264
265    fn check(content: &str) -> Vec<LintWarning> {
266        check_in(content, MarkdownFlavor::Standard)
267    }
268
269    fn fix(content: &str) -> String {
270        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
271        MD090NoHrBeforeHeading::new().fix(&ctx).unwrap()
272    }
273
274    /// The 1-based lines the rule reported, in output order.
275    fn lines(content: &str) -> Vec<usize> {
276        check(content).iter().map(|w| w.line).collect()
277    }
278
279    // Detection
280
281    #[test]
282    fn flags_break_between_paragraph_and_heading() {
283        let content = "# Title\n\n## Topic\n\nProse.\n\n---\n\n## Next Topic\n\nMore.\n";
284        let w = check(content);
285        assert_eq!(w.len(), 1, "got: {w:?}");
286        assert_eq!(w[0].line, 7);
287        assert_eq!(w[0].column, 1);
288        assert_eq!(w[0].end_line, 7);
289        assert_eq!(w[0].end_column, 4, "extent covers the three marker characters");
290        assert_eq!(w[0].message, "Horizontal rule before heading 'Next Topic' is redundant");
291    }
292
293    #[test]
294    fn warning_carries_deletion_of_break_and_blank_lines_below_it() {
295        let content = "Prose.\n\n---\n\n## Next\n";
296        let w = check(content);
297        let fix = w[0].fix.as_ref().expect("fix is populated");
298        assert_eq!(&content[fix.range.clone()], "---\n\n");
299        assert_eq!(fix.replacement, "");
300    }
301
302    #[test]
303    fn flags_break_above_a_multi_line_setext_heading() {
304        // A setext heading's text is the whole paragraph its underline ends, so
305        // the walk for a break starts at the first of those lines rather than
306        // stopping on the line above the underline.
307        let content = "Intro\n\n---\n\nFirst\nsecond\n===\n";
308        let w = check(content);
309        assert_eq!(w.len(), 1, "got: {w:?}");
310        assert_eq!(w[0].line, 3);
311        assert_eq!(
312            w[0].message,
313            "Horizontal rule before heading 'First second' is redundant"
314        );
315        assert_eq!(fix(content), "Intro\n\nFirst\nsecond\n===\n");
316    }
317
318    #[test]
319    fn setext_underline_is_not_a_break() {
320        // `---` directly under text is the underline of a level-2 setext
321        // heading, so there is no thematic break in this document at all.
322        assert!(lines("Prose\n---\n\n## Next\n").is_empty());
323    }
324
325    #[test]
326    fn emphasis_setext_underline_is_not_a_break() {
327        // `*` followed by no space opens no list item, so CommonMark reads
328        // `*Label*` + `---` as a level-2 heading and its underline is no break.
329        let content = "*Label*\n---\n\n## Next\n";
330        assert!(lines(content).is_empty());
331        assert_eq!(fix(content), content);
332    }
333
334    #[test]
335    fn inline_html_setext_underline_is_not_a_break() {
336        // Inline HTML opens no HTML block, so the line is paragraph text and
337        // the `---` under it underlines a setext heading.
338        let content = "<span>Label</span>\n---\n\n## Next\n";
339        assert!(lines(content).is_empty());
340        assert_eq!(fix(content), content);
341    }
342
343    #[test]
344    fn star_run_under_paragraph_text_is_still_a_break() {
345        // Only a dash run can underline; `***` under paragraph text is a
346        // thematic break interrupting the paragraph, and the tight fix
347        // leaves a blank line so `*Label*` stays separated from the heading.
348        let content = "*Label*\n***\n\n## Next\n";
349        assert_eq!(lines(content), [2]);
350        assert_eq!(fix(content), "*Label*\n\n## Next\n");
351    }
352
353    #[test]
354    fn atx_heading_above_dash_run_keeps_it_a_break() {
355        // An ATX heading is not paragraph text, so the `---` under it is a
356        // thematic break, not an underline.
357        assert_eq!(lines("## A\n---\n\n## B\n"), [2]);
358    }
359
360    #[test]
361    fn list_item_above_dash_run_keeps_it_a_break() {
362        // A `---` cannot lazily underline a paragraph inside a list item, so
363        // it closes the list as a top-level thematic break.
364        assert_eq!(lines("- item\n---\n\n## H\n"), [2]);
365    }
366
367    #[test]
368    fn table_row_above_dash_run_keeps_it_a_break() {
369        // A dash run below a table row ends the table and is a thematic
370        // break: a table row cannot carry a setext underline.
371        assert_eq!(lines("| a |\n| - |\n| x |\n---\n\n## H\n"), [4]);
372    }
373
374    #[test]
375    fn pipe_paragraph_that_is_no_table_keeps_its_underline() {
376        // Without a delimiter row the pipes are ordinary text, so the line is
377        // paragraph content and the dash run under it is its setext underline.
378        // The text is a `#tag`, which the detector records as an ATX heading
379        // without its space rather than as setext text, so the rule's own
380        // underline reading is what answers: a guard keyed on the pipe rather
381        // than on the table block would delete the underline here.
382        assert!(check("#tag | x\n---\n\n## H\n").is_empty());
383    }
384
385    #[test]
386    fn closing_fence_above_dash_run_keeps_it_a_break() {
387        // A closed code block holds no paragraph open, so the `---` under
388        // its closing fence is a thematic break.
389        assert_eq!(lines("Text\n\n```\ncode\n```\n---\n\n## H\n"), [6]);
390    }
391
392    #[test]
393    fn dash_run_below_an_equals_underline_is_a_break() {
394        // The `===` underlines `Title` and ends its paragraph, so the dash run
395        // under it renders as a thematic break.
396        assert_eq!(lines("Title\n===\n---\n\n## H\n"), [3]);
397        assert_eq!(fix("Title\n===\n---\n\n## H\n"), "Title\n===\n\n## H\n");
398        // A second `===` is a paragraph of its own, and the dash run
399        // underlines it.
400        assert!(lines("Title\n===\n===\n---\n\n## H\n").is_empty());
401    }
402
403    #[test]
404    fn equals_paragraph_at_document_start_is_underlined_not_broken() {
405        // With nothing above it, `===` is a paragraph of its own, and the
406        // dash run underlines it into a level-2 heading.
407        assert!(lines("===\n---\n\n## H\n").is_empty());
408    }
409
410    #[test]
411    fn lazy_blockquote_continuation_above_dash_run_is_the_accepted_false_negative() {
412        // `Foo` lazily continues the blockquote's paragraph, and a setext
413        // underline cannot be lazy, so the `---` renders as a real break.
414        // Telling that apart from an underline needs the detector's
415        // continuation analysis; the rule reads `Foo` as plain paragraph
416        // text and deliberately declines the break rather than risk deleting
417        // an underline. One unreported break is the accepted cost.
418        assert!(lines("> q\nFoo\n---\n\n## H\n").is_empty());
419    }
420
421    #[test]
422    fn invalid_atx_above_dash_run_is_a_setext_underline() {
423        // `#hashtag` has no space after its `#`, so it renders as paragraph
424        // text - its heading record says `is_valid == false` - and the dash
425        // run under it underlines a level-2 setext heading.
426        let content = "#hashtag\n---\n\n## H\n";
427        assert!(lines(content).is_empty());
428        assert_eq!(fix(content), content);
429    }
430
431    #[test]
432    fn star_run_under_invalid_atx_is_still_a_break() {
433        // `***` cannot underline, so it is a thematic break interrupting the
434        // `#hashtag` paragraph.
435        assert_eq!(lines("#hashtag\n***\n\n## H\n"), [2]);
436    }
437
438    #[test]
439    fn div_marker_above_dash_run_keeps_it_a_break() {
440        // The detector records `::: note` as setext text underlined by the
441        // dash run, but a div marker is never setext text: the record is a
442        // phantom, and the `---` opens the div's body as a real break.
443        let content = "::: note\n---\n\n# H\n:::\n";
444        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
445        let rule = MD090NoHrBeforeHeading::new();
446        let w = rule.check(&ctx).unwrap();
447        assert_eq!(w.iter().map(|w| w.line).collect::<Vec<_>>(), [2]);
448        assert_eq!(rule.fix(&ctx).unwrap(), "::: note\n\n# H\n:::\n");
449    }
450
451    #[test]
452    fn break_above_a_container_opener_is_kept() {
453        // Same phantom record, read as a target this time: an opening line
454        // carries a container marker, so it marks no section a break above it
455        // could duplicate. Each flavor's own detection populates the flag, so
456        // every marker is read in the flavor that gives it meaning, and a
457        // marker nested inside another container of the same kind carries it
458        // exactly as an outermost one does.
459        let cases: &[(&str, MarkdownFlavor, &str)] = &[
460            ("pandoc div", MarkdownFlavor::Quarto, "***\n::: note\n---\n\n# H\n:::\n"),
461            (
462                "myst directive",
463                MarkdownFlavor::MyST,
464                "***\n:::{note}\n---\n\n# H\n:::\n",
465            ),
466            (
467                "mkdocs content tab",
468                MarkdownFlavor::MkDocs,
469                "***\n=== \"Tab\"\n---\n\n# H\n",
470            ),
471            (
472                "mkdocs admonition",
473                MarkdownFlavor::MkDocs,
474                "***\n!!! note\n---\n\n# H\n",
475            ),
476            (
477                "mkdocstrings",
478                MarkdownFlavor::MkDocs,
479                "***\n::: mod.path\n---\n\n# H\n",
480            ),
481            (
482                "pymdown block",
483                MarkdownFlavor::MkDocs,
484                "***\n/// note\n---\n\n# H\n///\n",
485            ),
486            (
487                "div nested in a div",
488                MarkdownFlavor::Quarto,
489                ":::: outer\n\n***\n::: inner\n---\n\n# H\n:::\n::::\n",
490            ),
491        ];
492        for (name, flavor, content) in cases {
493            let ctx = LintContext::new(content, *flavor, None);
494            let rule = MD090NoHrBeforeHeading::new();
495            let break_line = content.lines().position(|l| l == "***").unwrap() + 1;
496            let w = rule.check(&ctx).unwrap();
497            assert!(
498                !w.iter().any(|w| w.line == break_line),
499                "{name}: reported the break above the opener: {w:?}"
500            );
501            assert!(
502                rule.fix(&ctx).unwrap().contains("***\n"),
503                "{name}: the fix deleted the break above the opener"
504            );
505        }
506
507        // The quarto case in full: the break inside the div is still removed,
508        // so a guard that simply gave up on containers would fail here.
509        let content = "***\n::: note\n---\n\n# H\n:::\n";
510        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
511        let rule = MD090NoHrBeforeHeading::new();
512        assert_eq!(
513            rule.check(&ctx).unwrap().iter().map(|w| w.line).collect::<Vec<_>>(),
514            [3]
515        );
516        assert_eq!(rule.fix(&ctx).unwrap(), "***\n::: note\n\n# H\n:::\n");
517    }
518
519    #[test]
520    fn setext_heading_inside_a_container_is_not_a_target_but_atx_is() {
521        // The accepted cost of not telling an opener from body text: a setext
522        // heading inside a container is left alone, because from the line data
523        // it is indistinguishable from a container marker with a dash run
524        // under it. An ATX heading in the same position carries no such
525        // ambiguity and is still reported, so the exemption stays narrow.
526        for content in [
527            "::: note\n\nProse\n\n***\n\nHeading\n-------\n:::\n",
528            "::: note\nProse\n\n***\n\nHeading\n-------\n:::\n",
529        ] {
530            let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
531            let rule = MD090NoHrBeforeHeading::new();
532            assert!(rule.check(&ctx).unwrap().is_empty(), "content {content:?} was reported");
533            assert_eq!(rule.fix(&ctx).unwrap(), content);
534        }
535
536        let content = "::: note\n\nProse\n\n***\n\n## Heading\n:::\n";
537        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
538        let rule = MD090NoHrBeforeHeading::new();
539        assert_eq!(
540            rule.check(&ctx).unwrap().iter().map(|w| w.line).collect::<Vec<_>>(),
541            [5]
542        );
543        assert_eq!(rule.fix(&ctx).unwrap(), "::: note\n\nProse\n\n## Heading\n:::\n");
544    }
545
546    #[test]
547    fn backtick_myst_directive_body_is_the_accepted_false_negative() {
548        // A markdown-bodied directive written with backticks starts as a code
549        // fence, so `is_horizontal_rule` is settled to false on its body
550        // before MyST detection clears `in_code_block`, and no rule reading
551        // that flag sees the break. This is rumdl-wide, not this rule's:
552        // MD082 draws the same line and is silent on the same document. The
553        // colon-fenced form is the positive control - identical text, one
554        // finding - so this test fails the day the shared flag is settled and
555        // the false negative can be retired.
556        let backtick = "# T\n\n```{note}\nIntro\n\n---\n\n## H\n```\n";
557        let ctx = LintContext::new(backtick, MarkdownFlavor::MyST, None);
558        assert!(!ctx.lines[5].is_horizontal_rule, "the shared flag was settled");
559        assert!(MD090NoHrBeforeHeading::new().check(&ctx).unwrap().is_empty());
560
561        let colon = "# T\n\n:::{note}\nIntro\n\n---\n\n## H\n:::\n";
562        let ctx = LintContext::new(colon, MarkdownFlavor::MyST, None);
563        let rule = MD090NoHrBeforeHeading::new();
564        assert_eq!(
565            rule.check(&ctx).unwrap().iter().map(|w| w.line).collect::<Vec<_>>(),
566            [6]
567        );
568        assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n:::{note}\nIntro\n\n## H\n:::\n");
569    }
570
571    #[test]
572    fn break_above_a_colon_paragraph_is_reported_in_standard() {
573        // Standard flavor gives `:::` no meaning, so `::: note` really is
574        // setext text and the heading it forms is a section boundary: the
575        // break above it is redundant, and the dash run stays its underline.
576        let content = "***\n::: note\n---\n\n# H\n";
577        assert_eq!(lines(content), [1]);
578        assert_eq!(fix(content), "::: note\n---\n\n# H\n");
579    }
580
581    #[test]
582    fn colon_paragraph_above_dash_run_is_a_setext_underline_in_standard() {
583        // Standard flavor gives `:::` no meaning, so `::: note` is ordinary
584        // paragraph text and the dash run under it is its real underline.
585        let content = "::: note\n---\n\n# H\n";
586        assert!(lines(content).is_empty());
587        assert_eq!(fix(content), content);
588    }
589
590    #[test]
591    fn multi_line_setext_break_is_reported() {
592        // `Foo\nbar\n===` is one setext heading whose text starts on `Foo`, so
593        // the break above the blank line stands directly before the heading.
594        // The parser supplies that first line, so the upward walk reaches the
595        // break instead of stopping on `Foo`.
596        assert_eq!(lines("***\n\nFoo\nbar\n===\n"), [1]);
597    }
598
599    #[test]
600    fn tight_spacing_is_still_a_break_before_a_heading() {
601        assert_eq!(lines("Prose\n\n---\n## Next\n"), [3]);
602    }
603
604    #[test]
605    fn break_on_first_line_is_flagged() {
606        // A lone `---` on line 1 with no later `---` is a thematic break.
607        assert_eq!(lines("---\n\n# Title\n"), [1]);
608    }
609
610    #[test]
611    fn leading_break_pair_is_front_matter_not_a_run() {
612        // A document that starts with `---` and contains a later `---` is
613        // YAML front matter to the parser, so neither line is a break here.
614        assert!(lines("---\n\n---\n\n## H\n").is_empty());
615    }
616
617    #[test]
618    fn front_matter_delimiters_are_not_breaks() {
619        assert!(lines("---\ntitle: x\n---\n\n# Title\n").is_empty());
620    }
621
622    #[test]
623    fn break_after_front_matter_is_flagged() {
624        assert_eq!(lines("---\ntitle: x\n---\n\n---\n\n# Title\n"), [5]);
625    }
626
627    #[test]
628    fn every_break_spelling_is_flagged() {
629        for marker in ["***", "___", "- - -", "* * *", "   ---", "-----"] {
630            let content = format!("Prose\n\n{marker}\n\n## H\n");
631            assert_eq!(lines(&content), [3], "marker {marker:?}");
632        }
633    }
634
635    #[test]
636    fn indented_code_is_not_a_break() {
637        assert!(lines("Prose\n\n    ---\n\n## H\n").is_empty());
638    }
639
640    #[test]
641    fn break_before_setext_heading_is_flagged() {
642        assert_eq!(lines("Prose\n\n---\n\nNext topic\n----------\n"), [3]);
643    }
644
645    #[test]
646    fn run_of_breaks_flags_each_with_disjoint_ranges() {
647        let content = "Prose\n\n---\n\n---\n\n## H\n";
648        let w = check(content);
649        assert_eq!(w.iter().map(|w| w.line).collect::<Vec<_>>(), [3, 5]);
650        let first = w[0].fix.as_ref().unwrap().range.clone();
651        let second = w[1].fix.as_ref().unwrap().range.clone();
652        assert_eq!(&content[first.clone()], "---\n\n");
653        assert_eq!(&content[second.clone()], "---\n\n");
654        assert_eq!(first.end, second.start, "the two deletions abut and do not overlap");
655    }
656
657    #[test]
658    fn comment_between_break_and_heading_is_not_adjacent() {
659        assert!(lines("Prose\n\n---\n\n<!-- c -->\n\n## H\n").is_empty());
660    }
661
662    #[test]
663    fn reference_definition_between_is_not_adjacent() {
664        assert!(lines("Prose\n\n---\n\n[ref]: https://example.com\n\n## H\n").is_empty());
665    }
666
667    #[test]
668    fn break_after_heading_is_not_flagged() {
669        assert!(lines("## H\n\n---\n\nProse\n").is_empty());
670    }
671
672    #[test]
673    fn break_inside_blockquote_is_left_alone() {
674        assert!(lines("> ---\n>\n> ## H\n").is_empty());
675    }
676
677    #[test]
678    fn break_inside_list_item_is_left_alone() {
679        assert!(lines("- item\n\n  ---\n\n  ## H\n").is_empty());
680    }
681
682    #[test]
683    fn break_that_ends_a_list_is_flagged() {
684        // A thematic break at column 0 closes the list, so it is top-level.
685        assert_eq!(lines("- item\n\n---\n\n## H\n"), [3]);
686    }
687
688    #[test]
689    fn breaks_hidden_in_fences_comments_and_math_are_ignored() {
690        assert!(lines("```\n---\n```\n\n## H\n").is_empty());
691        assert!(
692            lines(" ```\n---\n```\n\n## H\n").is_empty(),
693            "a fence may be indented up to three spaces"
694        );
695        assert!(lines("<!--\n---\n-->\n\n## H\n").is_empty());
696        assert!(lines("$$\n---\n$$\n\n## H\n").is_empty());
697    }
698
699    #[test]
700    fn hashtag_is_not_a_heading() {
701        assert!(lines("Prose\n\n---\n\n#hashtag\n").is_empty());
702    }
703
704    #[test]
705    fn headings_missing_their_space_follow_the_parser_verdict() {
706        // `valid_headings()` is rumdl's shared definition: `##hashtag` and
707        // `#Hashtag` are headings missing their space (MD018 fixes them, MD022
708        // spaces them), so the break above them is redundant just the same.
709        assert_eq!(lines("Prose\n\n---\n\n##hashtag\n"), [3]);
710        assert_eq!(lines("Prose\n\n---\n\n#Hashtag\n"), [3]);
711    }
712
713    #[test]
714    fn attribute_line_between_is_content() {
715        // A standalone `{#id}` line is not blank, so the break is not directly
716        // before the heading.
717        assert!(lines("Prose\n\n---\n\n{#custom}\n## H\n").is_empty());
718    }
719
720    #[test]
721    fn break_inside_markdown_html_block_is_flagged_in_every_flavor() {
722        // `markdown="1"` opts the element's content into Markdown, and the blank
723        // line after the opening tag ends the HTML block, so the break and the
724        // heading are ordinary top-level lines under both flavors.
725        let content = "<div markdown=\"1\">\n\n---\n\n## H\n\n</div>\n";
726        for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs] {
727            let reported: Vec<usize> = check_in(content, flavor).iter().map(|w| w.line).collect();
728            assert_eq!(reported, [3], "flavor {flavor:?}");
729        }
730    }
731
732    #[test]
733    fn break_inside_pandoc_div_is_flagged_and_fix_keeps_the_fences() {
734        // A fenced div's body is ordinary Markdown, so the break is real and
735        // its lines carry no prefix; the deletion touches no fence line.
736        let content = "# T\n\n::: note\nIntro\n\n---\n\n## H\n\nBody\n:::\n";
737        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
738        let rule = MD090NoHrBeforeHeading::new();
739        let w = rule.check(&ctx).unwrap();
740        assert_eq!(w.iter().map(|w| w.line).collect::<Vec<_>>(), [6]);
741        assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n::: note\nIntro\n\n## H\n\nBody\n:::\n");
742    }
743
744    #[test]
745    fn break_inside_myst_directive_is_flagged() {
746        let content = "# T\n\n:::{note}\nIntro\n\n---\n\n## H\n\nBody\n:::\n";
747        let reported: Vec<usize> = check_in(content, MarkdownFlavor::MyST).iter().map(|w| w.line).collect();
748        assert_eq!(reported, [6]);
749    }
750
751    #[test]
752    fn heading_inside_blockquote_is_left_alone() {
753        assert!(lines("Prose\n\n---\n\n> ## H\n").is_empty());
754    }
755
756    #[test]
757    fn empty_blockquote_line_between_is_content_not_a_blank() {
758        // `LineInfo::is_blank` is true for a bare `>`; the walk must read the
759        // source instead, or the fix deletes the blockquote.
760        assert!(lines("Prose\n\n---\n\n>\n\n## H\n").is_empty());
761        assert!(lines("Prose\n\n---\n\n> \n\n## H\n").is_empty());
762    }
763
764    #[test]
765    fn nbsp_line_between_break_and_heading_is_content() {
766        // A no-break space renders as content, so the line holding it is not
767        // blank and the break is not directly before the heading.
768        assert!(lines("Prose\n\n***\n\u{00A0}\n## H\n").is_empty());
769    }
770
771    #[test]
772    fn space_and_tab_line_between_break_and_heading_is_blank() {
773        assert_eq!(lines("Prose\n\n---\n \t\n## H\n"), [3]);
774    }
775
776    #[test]
777    fn skips_documents_without_headings_or_breaks() {
778        let ctx = LintContext::new("Prose\n\n---\n\nMore prose\n", MarkdownFlavor::Standard, None);
779        assert!(MD090NoHrBeforeHeading::new().should_skip(&ctx));
780        let ctx = LintContext::new("# Only a heading\n", MarkdownFlavor::Standard, None);
781        assert!(MD090NoHrBeforeHeading::new().should_skip(&ctx));
782        let ctx = LintContext::new("Prose\n\n---\n\n## H\n", MarkdownFlavor::Standard, None);
783        assert!(!MD090NoHrBeforeHeading::new().should_skip(&ctx));
784    }
785
786    // Fix
787
788    #[test]
789    fn fix_removes_break_and_keeps_blank_above_it() {
790        assert_eq!(
791            fix("# Title\n\n## Topic\n\nProse.\n\n---\n\n## Next Topic\n\nMore.\n"),
792            "# Title\n\n## Topic\n\nProse.\n\n## Next Topic\n\nMore.\n"
793        );
794    }
795
796    #[test]
797    fn fix_tight_spacing_leaves_one_blank_line() {
798        assert_eq!(fix("Prose\n\n---\n## Next\n"), "Prose\n\n## Next\n");
799    }
800
801    #[test]
802    fn fix_tight_break_above_setext_heading_keeps_separation() {
803        // With no blank line above the break, a plain deletion would butt
804        // `Prose` against `Next`, merging both into one setext heading.
805        assert_eq!(fix("Prose\n***\nNext\n====\n"), "Prose\n\nNext\n====\n");
806    }
807
808    #[test]
809    fn fix_tight_break_above_atx_heading_keeps_separation() {
810        assert_eq!(fix("Prose\n***\n## Next\n"), "Prose\n\n## Next\n");
811    }
812
813    #[test]
814    fn fix_tight_run_leaves_a_single_blank_line() {
815        assert_eq!(fix("Prose\n***\n***\n## H\n"), "Prose\n\n## H\n");
816    }
817
818    #[test]
819    fn fix_break_on_first_line_puts_heading_first() {
820        assert_eq!(fix("---\n\n# Title\n"), "# Title\n");
821    }
822
823    #[test]
824    fn fix_break_after_front_matter() {
825        assert_eq!(
826            fix("---\ntitle: x\n---\n\n---\n\n# Title\n"),
827            "---\ntitle: x\n---\n\n# Title\n"
828        );
829    }
830
831    #[test]
832    fn fix_break_before_setext_heading() {
833        assert_eq!(
834            fix("Prose\n\n---\n\nNext topic\n----------\n"),
835            "Prose\n\nNext topic\n----------\n"
836        );
837    }
838
839    #[test]
840    fn fix_run_of_breaks_in_one_pass_and_is_idempotent() {
841        let once = fix("Prose\n\n---\n\n---\n\n## H\n");
842        assert_eq!(once, "Prose\n\n## H\n");
843        assert_eq!(fix(&once), once);
844    }
845
846    #[test]
847    fn fix_preserves_crlf_line_endings() {
848        // The LSP hands the rule the editor's text as is, so a deletion must
849        // remove the break's own CRLF and nothing else.
850        assert_eq!(fix("Prose\r\n\r\n---\r\n\r\n## H\r\n"), "Prose\r\n\r\n## H\r\n");
851    }
852
853    #[test]
854    fn fix_returns_clean_document_unchanged() {
855        let content = "Prose\n\n## H\n\nMore\n\n---\n\nTail\n";
856        assert_eq!(fix(content), content);
857    }
858}