1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::fix_utils::apply_warning_fixes;
3
4use std::collections::HashSet;
5
6#[derive(Clone, Default)]
12pub struct MD065BlanksAroundHorizontalRules;
13
14struct RuleLines {
20 setext_underlines: HashSet<usize>,
23 item_openers: HashSet<usize>,
27}
28
29impl RuleLines {
30 fn new(ctx: &crate::lint_context::LintContext) -> Self {
31 let line_of = |offset: usize| ctx.offset_to_line_col(offset).0 - 1;
32 let mut setext_underlines = HashSet::new();
33 let mut item_openers = HashSet::new();
34 let mut item_line = None;
37
38 use crate::lint_context::document_parser::blocks::BlockKind;
39 for (kind, range) in &ctx.parser_blocks().events {
40 let opened = item_line.take();
41 match kind {
42 BlockKind::ItemStart => item_line = Some(line_of(range.start)),
43 BlockKind::Rule => {
44 let line = line_of(range.start);
45 if opened.is_some_and(|marker_line| line > marker_line) {
46 item_openers.insert(line);
47 }
48 }
49 BlockKind::HeadingStart => {
50 let last = line_of(range.end.saturating_sub(1).max(range.start));
51 if last > line_of(range.start) {
52 setext_underlines.insert(last);
53 }
54 }
55 _ => {}
56 }
57 }
58
59 Self {
60 setext_underlines,
61 item_openers,
62 }
63 }
64}
65
66impl MD065BlanksAroundHorizontalRules {
67 fn is_blank_line(line: &str) -> bool {
72 crate::utils::regex_cache::is_blank_in_blockquote_context(line)
73 }
74
75 fn is_setext_heading_marker(lines: &[&str], line_index: usize) -> bool {
77 if line_index == 0 {
78 return false;
79 }
80
81 let line = lines[line_index].trim();
82 let prev_line = lines[line_index - 1].trim();
83
84 if prev_line.is_empty() {
88 return false;
89 }
90
91 if crate::lint_context::is_horizontal_rule_line(prev_line) {
93 return false;
94 }
95
96 let has_hyphen = line.contains('-');
99 let has_equals = line.contains('=');
100
101 if has_hyphen == has_equals {
103 return false; }
105
106 let marker = if has_hyphen { '-' } else { '=' };
107
108 let trimmed = line.trim();
111 trimmed.chars().all(|c| c == marker)
112 }
113
114 fn count_blank_lines_before(lines: &[&str], line_index: usize) -> usize {
116 let mut count = 0;
117 let mut i = line_index;
118 while i > 0 {
119 i -= 1;
120 if Self::is_blank_line(lines[i]) {
121 count += 1;
122 } else {
123 break;
124 }
125 }
126 count
127 }
128
129 fn count_blank_lines_after(lines: &[&str], line_index: usize) -> usize {
131 let mut count = 0;
132 let mut i = line_index + 1;
133 while i < lines.len() {
134 if Self::is_blank_line(lines[i]) {
135 count += 1;
136 i += 1;
137 } else {
138 break;
139 }
140 }
141 count
142 }
143
144 fn fix_with(content: &str, warnings: &[LintWarning]) -> Result<String, LintError> {
146 apply_warning_fixes(content, warnings).map_err(LintError::FixFailed)
147 }
148}
149
150impl Rule for MD065BlanksAroundHorizontalRules {
151 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
152 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(65))
153 }
154
155 fn name(&self) -> &'static str {
156 "MD065"
157 }
158
159 fn description(&self) -> &'static str {
160 "Horizontal rules should be surrounded by blank lines"
161 }
162
163 fn category(&self) -> RuleCategory {
164 RuleCategory::Whitespace
165 }
166
167 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
168 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.is_horizontal_rule)
173 }
174
175 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
176 let content = ctx.content;
177 let mut warnings = Vec::new();
178
179 if content.is_empty() {
180 return Ok(Vec::new());
181 }
182
183 let lines = ctx.raw_lines();
184 let rule_lines = RuleLines::new(ctx);
185
186 for (i, line_info) in ctx.lines.iter().enumerate() {
187 if !line_info.is_horizontal_rule {
190 continue;
191 }
192
193 if rule_lines.setext_underlines.contains(&i)
198 || ctx.heading_on_line(i).is_some_and(|heading| heading.is_setext())
199 || Self::is_setext_heading_marker(lines, i)
200 {
201 continue;
202 }
203
204 if i > 0 && !rule_lines.item_openers.contains(&i) && Self::count_blank_lines_before(lines, i) == 0 {
207 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
208 warnings.push(LintWarning {
209 rule_name: Some(self.name().to_string()),
210 message: "Missing blank line before horizontal rule".to_string(),
211 line: i + 1,
212 column: 1,
213 end_line: i + 1,
214 end_column: 2,
215 severity: Severity::Warning,
216 fix: Some(Fix::new(ctx.line_column_byte_range(i + 1, 1), format!("{bq_prefix}\n"))),
217 });
218 }
219
220 if i < lines.len() - 1 && Self::count_blank_lines_after(lines, i) == 0 {
222 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
223 warnings.push(LintWarning {
224 rule_name: Some(self.name().to_string()),
225 message: "Missing blank line after horizontal rule".to_string(),
226 line: i + 1,
227 column: lines[i].chars().count() + 1,
228 end_line: i + 1,
229 end_column: lines[i].chars().count() + 2,
230 severity: Severity::Warning,
231 fix: Some(Fix::new(
233 ctx.line_column_byte_range(i + 1, lines[i].chars().count() + 1),
234 format!("\n{bq_prefix}"),
235 )),
236 });
237 }
238 }
239
240 Ok(warnings)
241 }
242
243 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
244 let content = ctx.content;
245
246 let warnings = self.check(ctx)?;
247 let warnings =
248 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
249 if warnings.is_empty() {
250 return Ok(content.to_string());
251 }
252 Self::fix_with(content, &warnings)
253 }
254
255 fn as_any(&self) -> &dyn std::any::Any {
256 self
257 }
258
259 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
260 where
261 Self: Sized,
262 {
263 Box::new(MD065BlanksAroundHorizontalRules)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::lint_context::LintContext;
271
272 #[test]
273 fn test_hr_with_blanks() {
274 let rule = MD065BlanksAroundHorizontalRules;
275 let content = "Some text before.
276
277---
278
279Some text after.";
280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
281 let result = rule.check(&ctx).unwrap();
282
283 assert!(result.is_empty());
284 }
285
286 #[test]
287 fn test_hr_missing_blank_before() {
288 let rule = MD065BlanksAroundHorizontalRules;
289 let content = "Some text before.
291***
292
293Some text after.";
294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
295 let result = rule.check(&ctx).unwrap();
296
297 assert_eq!(result.len(), 1);
298 assert_eq!(result[0].line, 2);
299 assert!(result[0].message.contains("before horizontal rule"));
300 }
301
302 #[test]
303 fn test_hr_missing_blank_after() {
304 let rule = MD065BlanksAroundHorizontalRules;
305 let content = "Some text before.
306
307***
308Some text after.";
309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310 let result = rule.check(&ctx).unwrap();
311
312 assert_eq!(result.len(), 1);
313 assert_eq!(result[0].line, 3);
314 assert!(result[0].message.contains("after horizontal rule"));
315 }
316
317 #[test]
318 fn test_hr_missing_both_blanks() {
319 let rule = MD065BlanksAroundHorizontalRules;
320 let content = "Some text before.
322***
323Some text after.";
324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325 let result = rule.check(&ctx).unwrap();
326
327 assert_eq!(result.len(), 2);
328 assert!(result[0].message.contains("before horizontal rule"));
329 assert!(result[1].message.contains("after horizontal rule"));
330 }
331
332 #[test]
333 fn test_hr_at_start_of_document() {
334 let rule = MD065BlanksAroundHorizontalRules;
335 let content = "---
336
337Some text after.";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339 let result = rule.check(&ctx).unwrap();
340
341 assert!(result.is_empty());
343 }
344
345 #[test]
346 fn test_hr_at_end_of_document() {
347 let rule = MD065BlanksAroundHorizontalRules;
348 let content = "Some text before.
349
350---";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353
354 assert!(result.is_empty());
356 }
357
358 #[test]
359 fn test_multiple_hrs() {
360 let rule = MD065BlanksAroundHorizontalRules;
361 let content = "Text before.
363***
364Middle text.
365___
366Text after.";
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368 let result = rule.check(&ctx).unwrap();
369
370 assert_eq!(result.len(), 4);
371 }
372
373 #[test]
374 fn test_hr_asterisks() {
375 let rule = MD065BlanksAroundHorizontalRules;
376 let content = "Some text.
377***
378More text.";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381
382 assert_eq!(result.len(), 2);
383 }
384
385 #[test]
386 fn test_hr_underscores() {
387 let rule = MD065BlanksAroundHorizontalRules;
388 let content = "Some text.
389___
390More text.";
391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
392 let result = rule.check(&ctx).unwrap();
393
394 assert_eq!(result.len(), 2);
395 }
396
397 #[test]
398 fn test_hr_with_spaces() {
399 let rule = MD065BlanksAroundHorizontalRules;
400 let content = "Some text.
402* * *
403More text.";
404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405 let result = rule.check(&ctx).unwrap();
406
407 assert_eq!(result.len(), 2);
408 }
409
410 #[test]
411 fn test_hr_long() {
412 let rule = MD065BlanksAroundHorizontalRules;
413 let content = "Some text.
415**********
416More text.";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418 let result = rule.check(&ctx).unwrap();
419
420 assert_eq!(result.len(), 2);
421 }
422
423 #[test]
424 fn test_setext_heading_not_hr() {
425 let rule = MD065BlanksAroundHorizontalRules;
426 let content = "Heading
427---
428
429More text.";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432
433 assert!(result.is_empty());
435 }
436
437 #[test]
438 fn test_setext_heading_equals() {
439 let rule = MD065BlanksAroundHorizontalRules;
440 let content = "Heading
441===
442
443More text.";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445 let result = rule.check(&ctx).unwrap();
446
447 assert!(result.is_empty());
449 }
450
451 #[test]
452 fn test_blockquote_setext_underline_is_not_hr() {
453 let rule = MD065BlanksAroundHorizontalRules;
457 for content in [
458 "> Title\n> ---\n\nText.\n",
459 "> > Deep\n> > ---\n\nText.\n",
460 "> Title\n> ---\n\nText.\n",
461 ] {
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert!(result.is_empty(), "{content:?} is a heading: {result:?}");
465 assert_eq!(rule.fix(&ctx).unwrap(), content);
466 }
467 }
468
469 #[test]
470 fn test_blockquote_hr_below_blank_line_is_checked() {
471 let rule = MD065BlanksAroundHorizontalRules;
474 let content = "> Text\n>\n> ---\n> More\n";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let result = rule.check(&ctx).unwrap();
477 assert_eq!(result.len(), 1, "{result:?}");
478 assert_eq!(result[0].line, 3);
479 assert_eq!(result[0].message, "Missing blank line after horizontal rule");
480 }
481
482 #[test]
483 fn test_hr_in_code_block() {
484 let rule = MD065BlanksAroundHorizontalRules;
485 let content = "Some text.
486
487```
488---
489```
490
491More text.";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494
495 assert!(result.is_empty());
497 }
498
499 #[test]
500 fn test_fix_missing_blanks() {
501 let rule = MD065BlanksAroundHorizontalRules;
502 let content = "Text before.
504***
505Text after.";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let fixed = rule.fix(&ctx).unwrap();
508
509 let expected = "Text before.
510
511***
512
513Text after.";
514 assert_eq!(fixed, expected);
515 }
516
517 #[test]
518 fn test_fix_multiple_hrs() {
519 let rule = MD065BlanksAroundHorizontalRules;
520 let content = "Start
522***
523Middle
524___
525End";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let fixed = rule.fix(&ctx).unwrap();
528
529 let expected = "Start
530
531***
532
533Middle
534
535___
536
537End";
538 assert_eq!(fixed, expected);
539 }
540
541 #[test]
542 fn test_empty_content() {
543 let rule = MD065BlanksAroundHorizontalRules;
544 let content = "";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547
548 assert!(result.is_empty());
549 }
550
551 #[test]
552 fn test_no_hrs() {
553 let rule = MD065BlanksAroundHorizontalRules;
554 let content = "Just regular text.
555No horizontal rules here.
556Only paragraphs.";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559
560 assert!(result.is_empty());
561 }
562
563 #[test]
564 fn test_is_horizontal_rule() {
565 use crate::lint_context::is_horizontal_rule_line;
566
567 assert!(is_horizontal_rule_line("---"));
569 assert!(is_horizontal_rule_line("----"));
570 assert!(is_horizontal_rule_line("***"));
571 assert!(is_horizontal_rule_line("****"));
572 assert!(is_horizontal_rule_line("___"));
573 assert!(is_horizontal_rule_line("____"));
574 assert!(is_horizontal_rule_line("- - -"));
575 assert!(is_horizontal_rule_line("* * *"));
576 assert!(is_horizontal_rule_line("_ _ _"));
577 assert!(is_horizontal_rule_line(" --- "));
578
579 assert!(!is_horizontal_rule_line("--"));
581 assert!(!is_horizontal_rule_line("**"));
582 assert!(!is_horizontal_rule_line("__"));
583 assert!(!is_horizontal_rule_line("- -"));
584 assert!(!is_horizontal_rule_line("text"));
585 assert!(!is_horizontal_rule_line(""));
586 assert!(!is_horizontal_rule_line("==="));
587 }
588
589 #[test]
590 fn test_consecutive_hrs_with_blanks() {
591 let rule = MD065BlanksAroundHorizontalRules;
592 let content = "Text.
593
594---
595
596***
597
598More text.";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601
602 assert!(result.is_empty());
604 }
605
606 #[test]
607 fn test_hr_after_heading() {
608 let rule = MD065BlanksAroundHorizontalRules;
609 let content = "# Heading
611***
612
613Text.";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616
617 assert_eq!(result.len(), 1);
619 assert!(result[0].message.contains("before horizontal rule"));
620 }
621
622 #[test]
623 fn test_hr_before_heading() {
624 let rule = MD065BlanksAroundHorizontalRules;
625 let content = "Text.
626
627***
628# Heading";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631
632 assert_eq!(result.len(), 1);
634 assert!(result[0].message.contains("after horizontal rule"));
635 }
636
637 #[test]
638 fn test_setext_heading_hyphen_not_flagged() {
639 let rule = MD065BlanksAroundHorizontalRules;
640 let content = "Heading Text
642---
643
644More text.";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647
648 assert!(result.is_empty());
650 }
651
652 #[test]
653 fn test_hr_with_blank_before_hyphen() {
654 let rule = MD065BlanksAroundHorizontalRules;
655 let content = "Some text.
657
658---
659More text.";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662
663 assert_eq!(result.len(), 1);
665 assert!(result[0].message.contains("after horizontal rule"));
666 }
667
668 #[test]
673 fn test_frontmatter_not_flagged() {
674 let rule = MD065BlanksAroundHorizontalRules;
675 let content = "---
677title: Test Document
678date: 2024-01-01
679---
680
681# Heading
682
683Content here.";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686
687 assert!(result.is_empty());
689 }
690
691 #[test]
692 fn test_hr_after_frontmatter() {
693 let rule = MD065BlanksAroundHorizontalRules;
694 let content = "---
695title: Test
696---
697
698Content.
699***
700More content.";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703
704 assert_eq!(result.len(), 2);
706 }
707
708 #[test]
709 fn test_hr_in_indented_code_block() {
710 let rule = MD065BlanksAroundHorizontalRules;
711 let content = "Some text.
713
714 ---
715 code here
716
717More text.";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720
721 assert!(result.is_empty());
723 }
724
725 #[test]
726 fn test_hr_with_leading_spaces() {
727 let rule = MD065BlanksAroundHorizontalRules;
728 let content = "Text.
730 ***
731More text.";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734
735 assert_eq!(result.len(), 2);
737 }
738
739 #[test]
740 fn test_hr_in_html_comment() {
741 let rule = MD065BlanksAroundHorizontalRules;
742 let content = "Text.
743
744<!--
745---
746-->
747
748More text.";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751
752 assert!(result.is_empty());
754 }
755
756 #[test]
757 fn test_hr_in_blockquote() {
758 let rule = MD065BlanksAroundHorizontalRules;
759 let content = "Text.
760
761> Quote text
762> ***
763> More quote
764
765After quote.";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let result = rule.check(&ctx).unwrap();
768
769 assert!(result.len() <= 2); }
775
776 #[test]
777 fn test_hr_after_list() {
778 let rule = MD065BlanksAroundHorizontalRules;
779 let content = "* Item one
781* Item two
782***
783
784More text.";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787
788 assert_eq!(result.len(), 1);
790 assert!(result[0].message.contains("before horizontal rule"));
791 }
792
793 #[test]
794 fn test_mixed_marker_with_many_spaces() {
795 let rule = MD065BlanksAroundHorizontalRules;
796 let content = "Text.
797- - - -
798More text.";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801
802 assert_eq!(result.len(), 2);
804 }
805
806 #[test]
807 fn test_only_hr_in_document() {
808 let rule = MD065BlanksAroundHorizontalRules;
809 let content = "---";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812
813 assert!(result.is_empty());
815 }
816
817 #[test]
818 fn test_multiple_blank_lines_already_present() {
819 let rule = MD065BlanksAroundHorizontalRules;
820 let content = "Text.
821
822
823---
824
825
826More text.";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829
830 assert!(result.is_empty());
832 }
833
834 #[test]
835 fn test_hr_at_both_start_and_end() {
836 let rule = MD065BlanksAroundHorizontalRules;
837 let content = "---
838
839Content in the middle.
840
841---";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844
845 assert!(result.is_empty());
847 }
848
849 #[test]
850 fn test_consecutive_hrs_without_blanks() {
851 let rule = MD065BlanksAroundHorizontalRules;
852 let content = "Text.
853
854***
855---
856___
857
858More text.";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let result = rule.check(&ctx).unwrap();
861
862 assert!(result.len() >= 2);
865 }
866
867 #[test]
868 fn test_hr_after_hr_not_setext() {
869 let rule = MD065BlanksAroundHorizontalRules;
871 let content = "***\n---\n# ";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873
874 let fixed = rule.fix(&ctx).unwrap();
876 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
877 let fixed2 = rule.fix(&ctx2).unwrap();
878 assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
879 }
880
881 #[test]
882 fn test_fix_idempotency() {
883 let rule = MD065BlanksAroundHorizontalRules;
884 let content = "Text before.
885***
886Text after.";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let fixed_once = rule.fix(&ctx).unwrap();
889
890 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
892 let fixed_twice = rule.fix(&ctx2).unwrap();
893
894 assert_eq!(fixed_once, fixed_twice);
896 }
897
898 #[test]
899 fn test_setext_heading_long_underline() {
900 let rule = MD065BlanksAroundHorizontalRules;
901 let content = "Heading Text
902----------
903
904More text.";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let result = rule.check(&ctx).unwrap();
907
908 assert!(result.is_empty());
910 }
911
912 #[test]
913 fn test_hr_with_trailing_whitespace() {
914 let rule = MD065BlanksAroundHorizontalRules;
915 let content = "Text.
916***
917More text.";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.check(&ctx).unwrap();
920
921 assert_eq!(result.len(), 2);
923 }
924
925 #[test]
926 fn test_hr_in_html_block() {
927 let rule = MD065BlanksAroundHorizontalRules;
928 let content = "Text.
929
930<div>
931---
932</div>
933
934More text.";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937
938 assert!(result.is_empty());
941 }
942
943 #[test]
944 fn test_spaced_hyphens_are_hr_not_setext() {
945 let rule = MD065BlanksAroundHorizontalRules;
946 let content = "Heading
949- - -
950
951More text.";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954
955 assert_eq!(result.len(), 1);
957 assert!(result[0].message.contains("before horizontal rule"));
958 }
959
960 #[test]
961 fn test_not_setext_if_prev_line_blank() {
962 let rule = MD065BlanksAroundHorizontalRules;
963 let content = "Some paragraph.
964
965---
966Text after.";
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968 let result = rule.check(&ctx).unwrap();
969
970 assert_eq!(result.len(), 1);
972 assert!(result[0].message.contains("after horizontal rule"));
973 }
974
975 #[test]
976 fn test_asterisk_cannot_be_setext() {
977 let rule = MD065BlanksAroundHorizontalRules;
978 let content = "Some text
980***
981More text.";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.check(&ctx).unwrap();
984
985 assert_eq!(result.len(), 2);
987 }
988
989 #[test]
990 fn test_underscore_cannot_be_setext() {
991 let rule = MD065BlanksAroundHorizontalRules;
992 let content = "Some text
994___
995More text.";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998
999 assert_eq!(result.len(), 2);
1001 }
1002
1003 #[test]
1004 fn test_fix_preserves_content() {
1005 let rule = MD065BlanksAroundHorizontalRules;
1006 let content = "First paragraph with **bold** and *italic*.
1007***
1008Second paragraph with [link](url) and `code`.";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let fixed = rule.fix(&ctx).unwrap();
1011
1012 assert!(fixed.contains("**bold**"));
1014 assert!(fixed.contains("*italic*"));
1015 assert!(fixed.contains("[link](url)"));
1016 assert!(fixed.contains("`code`"));
1017 assert!(fixed.contains("***"));
1018 }
1019
1020 #[test]
1021 fn test_fix_only_adds_needed_blanks() {
1022 let rule = MD065BlanksAroundHorizontalRules;
1023 let content = "Text.
1025
1026***
1027More text.";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let fixed = rule.fix(&ctx).unwrap();
1030
1031 let expected = "Text.
1032
1033***
1034
1035More text.";
1036 assert_eq!(fixed, expected);
1037 }
1038
1039 #[test]
1040 fn test_hr_detection_edge_cases() {
1041 use crate::lint_context::is_horizontal_rule_line;
1042
1043 assert!(is_horizontal_rule_line(" ---"));
1045 assert!(is_horizontal_rule_line("--- "));
1046 assert!(is_horizontal_rule_line(" --- "));
1047 assert!(is_horizontal_rule_line("* * *"));
1048 assert!(is_horizontal_rule_line("_ _ _"));
1049
1050 assert!(!is_horizontal_rule_line("--a"));
1052 assert!(!is_horizontal_rule_line("**a"));
1053 assert!(!is_horizontal_rule_line("-*-"));
1054 assert!(!is_horizontal_rule_line("- * _"));
1055 assert!(!is_horizontal_rule_line(" "));
1056 assert!(!is_horizontal_rule_line("\t---")); }
1058
1059 #[test]
1060 fn test_warning_line_numbers_accurate() {
1061 let rule = MD065BlanksAroundHorizontalRules;
1062 let content = "Line 1
1063Line 2
1064***
1065Line 4";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068
1069 assert_eq!(result.len(), 2);
1071 assert_eq!(result[0].line, 3); assert_eq!(result[1].line, 3);
1073 }
1074
1075 #[test]
1076 fn test_complex_document_structure() {
1077 let rule = MD065BlanksAroundHorizontalRules;
1078 let content = "# Main Title
1079
1080Introduction paragraph.
1081
1082## Section One
1083
1084Content here.
1085
1086***
1087
1088## Section Two
1089
1090More content.
1091
1092---
1093
1094Final thoughts.";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097
1098 assert!(result.is_empty());
1100 }
1101
1102 #[test]
1103 fn test_fix_preserves_blockquote_prefix_before_hr() {
1104 let rule = MD065BlanksAroundHorizontalRules;
1106
1107 let content = "> Text before
1108> ***
1109> Text after";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let fixed = rule.fix(&ctx).unwrap();
1112
1113 let expected = "> Text before
1115>
1116> ***
1117>
1118> Text after";
1119 assert_eq!(
1120 fixed, expected,
1121 "Fix should insert '>' blank lines around HR, not plain blank lines"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1127 let rule = MD065BlanksAroundHorizontalRules;
1130
1131 let content = ">> Nested quote
1132>> ***
1133>> More text";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135 let fixed = rule.fix(&ctx).unwrap();
1136
1137 let expected = ">> Nested quote
1139>>
1140>> ***
1141>>
1142>> More text";
1143 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1144 }
1145
1146 #[test]
1147 fn test_fix_preserves_blockquote_prefix_after_hr() {
1148 let rule = MD065BlanksAroundHorizontalRules;
1150
1151 let content = "> ---
1152> Text after";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let fixed = rule.fix(&ctx).unwrap();
1155
1156 let expected = "> ---
1158>
1159> Text after";
1160 assert_eq!(
1161 fixed, expected,
1162 "Fix should insert '>' blank line after HR, not plain blank line"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1168 let rule = MD065BlanksAroundHorizontalRules;
1171
1172 let content = ">>> Triple nested
1173>>> ***
1174>>> More text";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let fixed = rule.fix(&ctx).unwrap();
1177
1178 let expected = ">>> Triple nested
1179>>>
1180>>> ***
1181>>>
1182>>> More text";
1183 assert_eq!(
1184 fixed, expected,
1185 "Fix should preserve triple-nested blockquote prefix '>>>'"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_fix_preserves_trailing_newline() {
1191 let rule = MD065BlanksAroundHorizontalRules;
1192
1193 let content = "Text\n***\nMore text\n";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 let fixed = rule.fix(&ctx).unwrap();
1196
1197 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
1198 assert_eq!(fixed, "Text\n\n***\n\nMore text\n");
1199 }
1200
1201 #[test]
1202 fn spaced_thematic_break_is_not_skipped() {
1203 let rule = MD065BlanksAroundHorizontalRules;
1207
1208 for content in [
1209 "Text\n* * *\nMore text\n",
1210 "Text\n- - -\nMore text\n",
1211 "Text\n_ _ _\nMore text\n",
1212 ] {
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214 assert!(!rule.should_skip(&ctx), "the whole rule was skipped for {content:?}");
1215 assert_eq!(
1216 rule.check(&ctx).unwrap().len(),
1217 2,
1218 "both missing blank lines should be reported for {content:?}"
1219 );
1220 }
1221 }
1222
1223 #[test]
1224 fn a_document_with_no_thematic_break_is_still_skipped() {
1225 let rule = MD065BlanksAroundHorizontalRules;
1226
1227 for content in ["Text\nMore text\n", "- item\n- item\n", "```\n---\n```\n"] {
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 assert!(
1230 rule.should_skip(&ctx),
1231 "the rule should have been skipped for {content:?}"
1232 );
1233 }
1234 }
1235
1236 #[test]
1237 fn markers_inside_a_hidden_block_are_not_spaced_out() {
1238 let rule = MD065BlanksAroundHorizontalRules;
1242
1243 for content in [
1244 "Text.\n\n<!--\n***\n-->\n\nMore.\n",
1245 "Text.\n\n$$\na = b\n***\nc = d\n$$\n\nMore.\n",
1246 ] {
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 assert!(
1249 rule.check(&ctx).unwrap().is_empty(),
1250 "the hidden markers were reported in {content:?}"
1251 );
1252 assert_eq!(
1253 rule.fix(&ctx).unwrap(),
1254 content,
1255 "the block was rewritten in {content:?}"
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn test_fix_preserves_no_trailing_newline() {
1262 let rule = MD065BlanksAroundHorizontalRules;
1263
1264 let content = "Text\n***\nMore text";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let fixed = rule.fix(&ctx).unwrap();
1267
1268 assert!(
1269 !fixed.ends_with('\n'),
1270 "Fix should not add trailing newline if original didn't have one"
1271 );
1272 assert_eq!(fixed, "Text\n\n***\n\nMore text");
1273 }
1274
1275 #[test]
1276 fn test_indented_lazy_line_in_blockquote_is_not_a_horizontal_rule() {
1277 let rule = MD065BlanksAroundHorizontalRules;
1278 for content in [
1279 "> o\n> ---\n",
1280 "> o\n> ***\n",
1281 ">o\n> ***\n",
1282 "> o\n> - - -\n",
1283 "> > o\n> > ***\n",
1284 "- > o\n > ***\n",
1285 ] {
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1288 assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1289 }
1290 }
1291
1292 #[test]
1293 fn test_real_horizontal_rule_in_blockquote_still_needs_blank_lines() {
1294 let rule = MD065BlanksAroundHorizontalRules;
1295 let content = "> a\n>\n> ***\n> b\n";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1298 assert_eq!(rule.fix(&ctx).unwrap(), "> a\n>\n> ***\n>\n> b\n");
1299 }
1300
1301 fn messages(content: &str) -> Vec<String> {
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303 let rule = MD065BlanksAroundHorizontalRules;
1304 rule.check(&ctx).unwrap().into_iter().map(|w| w.message).collect()
1305 }
1306
1307 #[test]
1308 fn a_rule_opening_a_bare_marker_item_has_nothing_before_it() {
1309 let rule = MD065BlanksAroundHorizontalRules;
1313 for content in [
1314 "-\n ***\n",
1315 "x\n\n-\n ***\n",
1316 "1.\n ***\n",
1317 "-\n ___\n",
1318 "-\n * * *\n",
1319 "-\n - - -\n",
1320 "> -\n> ***\n",
1321 "-\n -\n ***\n",
1322 "- a\n -\n ***\n",
1323 ] {
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1326 assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1327 }
1328 }
1329
1330 #[test]
1331 fn a_rule_opening_a_bare_marker_item_still_needs_a_blank_after() {
1332 let rule = MD065BlanksAroundHorizontalRules;
1333 for (content, expected) in [
1334 ("-\n ***\n text\n", "-\n ***\n\n text\n"),
1335 ("1.\n ***\n text\n", "1.\n ***\n\n text\n"),
1336 ("> -\n> ***\n> text\n", "> -\n> ***\n>\n> text\n"),
1337 ] {
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 let warnings = rule.check(&ctx).unwrap();
1340 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1341 assert_eq!(warnings[0].message, "Missing blank line after horizontal rule");
1342 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1343 }
1344 }
1345
1346 #[test]
1347 fn a_rule_after_item_content_still_needs_a_blank_before() {
1348 let rule = MD065BlanksAroundHorizontalRules;
1350 for (content, expected) in [
1351 ("- a\n ***\n", "- a\n\n ***\n"),
1352 ("-\n a\n ***\n", "-\n a\n\n ***\n"),
1353 ("1. a\n ***\n", "1. a\n\n ***\n"),
1354 ] {
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 assert_eq!(
1357 messages(content),
1358 ["Missing blank line before horizontal rule"],
1359 "{content:?}"
1360 );
1361 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1362 }
1363 }
1364
1365 #[test]
1366 fn a_rule_outside_the_list_after_a_bare_marker_is_unchanged() {
1367 assert!(messages("-\n\n ***\n").is_empty());
1369 assert!(messages("-\n\n***\n").is_empty());
1370 }
1371
1372 #[test]
1373 fn a_lazy_quoted_setext_underline_is_not_a_rule() {
1374 let rule = MD065BlanksAroundHorizontalRules;
1378 for content in [
1379 "> a\nb\n> ---\n",
1380 "> a\nb\n> ---\n",
1381 "> > a\n> b\n> > ---\n",
1382 "> a\nb\nc\n> ---\n",
1383 "> - a\nb\n> ---\n",
1384 "- a\nb\n ---\n",
1385 "a\nb\n---\n",
1386 ] {
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1389 assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1390 }
1391 }
1392
1393 #[test]
1394 fn a_lazy_line_before_a_real_rule_is_still_separated() {
1395 let rule = MD065BlanksAroundHorizontalRules;
1397 for (content, expected) in [
1398 ("> a\nb\n> ***\n", "> a\nb\n>\n> ***\n"),
1399 ("> a\nb\n> - - -\n", "> a\nb\n>\n> - - -\n"),
1400 ("> a\nb\n> ***\n> c\n", "> a\nb\n>\n> ***\n>\n> c\n"),
1401 ] {
1402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1403 assert!(!rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1404 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1405 }
1406 }
1407
1408 #[test]
1409 fn the_fix_values_apply_to_the_same_text_as_fix() {
1410 let rule = MD065BlanksAroundHorizontalRules;
1413 for content in [
1414 "Text\n***\nMore text\n",
1415 "Text\n***\nMore text",
1416 "> a\n> ***\n> b\n",
1417 "> > a\n> > ***\n> > b\n",
1418 "> a\nb\n> ***\nc\n",
1419 "- a\n ***\n b\n",
1420 "-\n ***\n b\n",
1421 "a\n***\n***\nb\n",
1422 ] {
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 let warnings = rule.check(&ctx).unwrap();
1425 assert!(!warnings.is_empty(), "{content:?}");
1426 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1427 assert_eq!(applied, rule.fix(&ctx).unwrap(), "{content:?}");
1428 }
1429 }
1430
1431 #[test]
1432 fn the_fix_is_not_written_for_a_warning_without_a_fix() {
1433 let rule = MD065BlanksAroundHorizontalRules;
1434 let content = "a\n***\nb\n";
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436 let mut warnings = rule.check(&ctx).unwrap();
1437 assert_eq!(warnings.len(), 2);
1438 warnings[0].fix = None; assert_eq!(
1440 MD065BlanksAroundHorizontalRules::fix_with(content, &warnings).unwrap(),
1441 "a\n***\n\nb\n"
1442 );
1443 }
1444
1445 #[test]
1446 fn loosening_a_list_keeps_its_fixes_in_every_flavor() {
1447 let rule = MD065BlanksAroundHorizontalRules;
1448 for flavor in [
1449 crate::config::MarkdownFlavor::Standard,
1450 crate::config::MarkdownFlavor::MDX,
1451 ] {
1452 let content = "- a\n ***\n b\n";
1453 let ctx = LintContext::new(content, flavor, None);
1454 let warnings = rule.check(&ctx).unwrap();
1455 assert_eq!(warnings.len(), 2, "{flavor:?}");
1456 assert!(warnings.iter().all(|w| w.fix.is_some()), "{flavor:?}: {warnings:?}");
1457 assert_eq!(rule.fix(&ctx).unwrap(), "- a\n\n ***\n\n b\n", "{flavor:?}");
1458 }
1459 }
1460
1461 #[test]
1462 fn mdx_reads_no_indented_code_so_its_fixes_stay() {
1463 let rule = MD065BlanksAroundHorizontalRules;
1465 let content = "> o\n> ***\n> after\n";
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1467 let warnings = rule.check(&ctx).unwrap();
1468 assert_eq!(warnings.len(), 2, "{warnings:?}");
1469 assert!(warnings.iter().all(|w| w.fix.is_some()), "{warnings:?}");
1470 }
1471}
1472
1473#[cfg(test)]
1474mod block_adjacency_regressions {
1475 #[test]
1476 fn paragraph_between_item_start_and_rule_clears_opener() {
1477 let ctx =
1478 crate::lint_context::LintContext::new("- para\n\n ---\n", crate::config::MarkdownFlavor::Standard, None);
1479 assert!(super::RuleLines::new(&ctx).item_openers.is_empty());
1480 }
1481}