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 is_top_level(line: &LineInfo) -> bool {
54 line.blockquote.is_none() && !line.in_list_block
55 }
56
57 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 fn is_phantom_container_heading(line: &LineInfo) -> bool {
77 Self::is_setext_record(line) && line.in_flavor_container()
78 }
79
80 fn is_setext_text(line: &LineInfo) -> bool {
83 Self::is_setext_record(line) && !Self::is_phantom_container_heading(line)
84 }
85
86 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 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 let heading_idx = heading.first_line_num() - 1;
173 if !Self::is_top_level(heading.line_info) || Self::is_phantom_container_heading(heading.line_info) {
176 continue;
177 }
178
179 let mut delete_end = heading_idx;
184 let mut idx = heading_idx;
185 while idx > 0 {
186 idx -= 1;
187 if Self::is_blank_line(lines[idx].content(ctx.content)) {
192 continue;
193 }
194 if !Self::is_top_level_break(ctx, lines, idx) {
195 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 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 fn lines(content: &str) -> Vec<usize> {
276 check(content).iter().map(|w| w.line).collect()
277 }
278
279 #[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 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 assert!(lines("Prose\n---\n\n## Next\n").is_empty());
323 }
324
325 #[test]
326 fn emphasis_setext_underline_is_not_a_break() {
327 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(lines("---\n\n# Title\n"), [1]);
608 }
609
610 #[test]
611 fn leading_break_pair_is_front_matter_not_a_run() {
612 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 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 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 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 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 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 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 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 #[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 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 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}