1use 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 fn is_top_level(line: &LineInfo) -> bool {
61 line.blockquote.is_none() && !line.in_list_block
62 }
63
64 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 fn is_phantom_container_heading(line: &LineInfo) -> bool {
84 Self::is_setext_record(line) && line.in_flavor_container()
85 }
86
87 fn is_setext_text(line: &LineInfo) -> bool {
90 Self::is_setext_record(line) && !Self::is_phantom_container_heading(line)
91 }
92
93 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 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 let heading_idx = heading.first_line_num() - 1;
188 if !Self::is_top_level(heading.line_info) || Self::is_phantom_container_heading(heading.line_info) {
191 continue;
192 }
193
194 let mut delete_end = heading_idx;
199 let mut idx = heading_idx;
200 while idx > 0 {
201 idx -= 1;
202 if Self::is_blank_line(lines[idx].content(ctx.content)) {
207 continue;
208 }
209 if !Self::is_top_level_break(ctx, lines, idx) {
210 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 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 fn lines(content: &str) -> Vec<usize> {
295 check(content).iter().map(|w| w.line).collect()
296 }
297
298 #[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 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 assert!(lines("Prose\n---\n\n## Next\n").is_empty());
342 }
343
344 #[test]
345 fn emphasis_setext_underline_is_not_a_break() {
346 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(lines("---\n\n# Title\n"), [1]);
627 }
628
629 #[test]
630 fn leading_break_pair_is_front_matter_not_a_run() {
631 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 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 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 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 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 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 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 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 #[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 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 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}