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