1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Clone, Default)]
10pub struct MD065BlanksAroundHorizontalRules;
11
12impl MD065BlanksAroundHorizontalRules {
13 fn is_blank_line(line: &str) -> bool {
18 crate::utils::regex_cache::is_blank_in_blockquote_context(line)
19 }
20
21 fn is_setext_heading_marker(lines: &[&str], line_index: usize) -> bool {
23 if line_index == 0 {
24 return false;
25 }
26
27 let line = lines[line_index].trim();
28 let prev_line = lines[line_index - 1].trim();
29
30 if prev_line.is_empty() {
34 return false;
35 }
36
37 if crate::lint_context::is_horizontal_rule_line(prev_line) {
39 return false;
40 }
41
42 let has_hyphen = line.contains('-');
45 let has_equals = line.contains('=');
46
47 if has_hyphen == has_equals {
49 return false; }
51
52 let marker = if has_hyphen { '-' } else { '=' };
53
54 let trimmed = line.trim();
57 trimmed.chars().all(|c| c == marker)
58 }
59
60 fn count_blank_lines_before(lines: &[&str], line_index: usize) -> usize {
62 let mut count = 0;
63 let mut i = line_index;
64 while i > 0 {
65 i -= 1;
66 if Self::is_blank_line(lines[i]) {
67 count += 1;
68 } else {
69 break;
70 }
71 }
72 count
73 }
74
75 fn count_blank_lines_after(lines: &[&str], line_index: usize) -> usize {
77 let mut count = 0;
78 let mut i = line_index + 1;
79 while i < lines.len() {
80 if Self::is_blank_line(lines[i]) {
81 count += 1;
82 i += 1;
83 } else {
84 break;
85 }
86 }
87 count
88 }
89}
90
91impl Rule for MD065BlanksAroundHorizontalRules {
92 fn name(&self) -> &'static str {
93 "MD065"
94 }
95
96 fn description(&self) -> &'static str {
97 "Horizontal rules should be surrounded by blank lines"
98 }
99
100 fn category(&self) -> RuleCategory {
101 RuleCategory::Whitespace
102 }
103
104 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
105 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.is_horizontal_rule)
110 }
111
112 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
113 let content = ctx.content;
114 let mut warnings = Vec::new();
115
116 if content.is_empty() {
117 return Ok(Vec::new());
118 }
119
120 let lines = ctx.raw_lines();
121
122 for (i, line_info) in ctx.lines.iter().enumerate() {
123 if !line_info.is_horizontal_rule {
126 continue;
127 }
128
129 if ctx.heading_on_line(i).is_some_and(|heading| heading.is_setext())
133 || Self::is_setext_heading_marker(lines, i)
134 {
135 continue;
136 }
137
138 if i > 0 && Self::count_blank_lines_before(lines, i) == 0 {
140 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
141 warnings.push(LintWarning {
142 rule_name: Some(self.name().to_string()),
143 message: "Missing blank line before horizontal rule".to_string(),
144 line: i + 1,
145 column: 1,
146 end_line: i + 1,
147 end_column: 2,
148 severity: Severity::Warning,
149 fix: Some(Fix::new(ctx.line_column_byte_range(i + 1, 1), format!("{bq_prefix}\n"))),
150 });
151 }
152
153 if i < lines.len() - 1 && Self::count_blank_lines_after(lines, i) == 0 {
155 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
156 warnings.push(LintWarning {
157 rule_name: Some(self.name().to_string()),
158 message: "Missing blank line after horizontal rule".to_string(),
159 line: i + 1,
160 column: lines[i].chars().count() + 1,
161 end_line: i + 1,
162 end_column: lines[i].chars().count() + 2,
163 severity: Severity::Warning,
164 fix: Some(Fix::new(
165 ctx.line_column_byte_range(i + 1, lines[i].len() + 1),
166 format!("{bq_prefix}\n"),
167 )),
168 });
169 }
170 }
171
172 Ok(warnings)
173 }
174
175 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
176 let content = ctx.content;
177
178 let warnings = self.check(ctx)?;
179 let mut warnings =
180 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
181 if warnings.is_empty() {
182 return Ok(content.to_string());
183 }
184
185 let lines = ctx.raw_lines();
186 let mut result = Vec::new();
187
188 for (i, line) in lines.iter().enumerate() {
189 let warning_before = warnings
191 .iter()
192 .position(|w| w.line == i + 1 && w.message.contains("before horizontal rule"));
193
194 if let Some(idx) = warning_before {
195 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
196 result.push(bq_prefix);
197 warnings.remove(idx);
198 }
199
200 result.push((*line).to_string());
201
202 let warning_after = warnings
204 .iter()
205 .position(|w| w.line == i + 1 && w.message.contains("after horizontal rule"));
206
207 if let Some(idx) = warning_after {
208 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
209 result.push(bq_prefix);
210 warnings.remove(idx);
211 }
212 }
213
214 let mut fixed = result.join("\n");
215 if content.ends_with('\n') {
216 fixed.push('\n');
217 }
218
219 Ok(fixed)
220 }
221
222 fn as_any(&self) -> &dyn std::any::Any {
223 self
224 }
225
226 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
227 where
228 Self: Sized,
229 {
230 Box::new(MD065BlanksAroundHorizontalRules)
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::lint_context::LintContext;
238
239 #[test]
240 fn test_hr_with_blanks() {
241 let rule = MD065BlanksAroundHorizontalRules;
242 let content = "Some text before.
243
244---
245
246Some text after.";
247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
248 let result = rule.check(&ctx).unwrap();
249
250 assert!(result.is_empty());
251 }
252
253 #[test]
254 fn test_hr_missing_blank_before() {
255 let rule = MD065BlanksAroundHorizontalRules;
256 let content = "Some text before.
258***
259
260Some text after.";
261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
262 let result = rule.check(&ctx).unwrap();
263
264 assert_eq!(result.len(), 1);
265 assert_eq!(result[0].line, 2);
266 assert!(result[0].message.contains("before horizontal rule"));
267 }
268
269 #[test]
270 fn test_hr_missing_blank_after() {
271 let rule = MD065BlanksAroundHorizontalRules;
272 let content = "Some text before.
273
274***
275Some text after.";
276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
277 let result = rule.check(&ctx).unwrap();
278
279 assert_eq!(result.len(), 1);
280 assert_eq!(result[0].line, 3);
281 assert!(result[0].message.contains("after horizontal rule"));
282 }
283
284 #[test]
285 fn test_hr_missing_both_blanks() {
286 let rule = MD065BlanksAroundHorizontalRules;
287 let content = "Some text before.
289***
290Some text after.";
291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
292 let result = rule.check(&ctx).unwrap();
293
294 assert_eq!(result.len(), 2);
295 assert!(result[0].message.contains("before horizontal rule"));
296 assert!(result[1].message.contains("after horizontal rule"));
297 }
298
299 #[test]
300 fn test_hr_at_start_of_document() {
301 let rule = MD065BlanksAroundHorizontalRules;
302 let content = "---
303
304Some text after.";
305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
306 let result = rule.check(&ctx).unwrap();
307
308 assert!(result.is_empty());
310 }
311
312 #[test]
313 fn test_hr_at_end_of_document() {
314 let rule = MD065BlanksAroundHorizontalRules;
315 let content = "Some text before.
316
317---";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319 let result = rule.check(&ctx).unwrap();
320
321 assert!(result.is_empty());
323 }
324
325 #[test]
326 fn test_multiple_hrs() {
327 let rule = MD065BlanksAroundHorizontalRules;
328 let content = "Text before.
330***
331Middle text.
332___
333Text after.";
334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335 let result = rule.check(&ctx).unwrap();
336
337 assert_eq!(result.len(), 4);
338 }
339
340 #[test]
341 fn test_hr_asterisks() {
342 let rule = MD065BlanksAroundHorizontalRules;
343 let content = "Some text.
344***
345More text.";
346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
347 let result = rule.check(&ctx).unwrap();
348
349 assert_eq!(result.len(), 2);
350 }
351
352 #[test]
353 fn test_hr_underscores() {
354 let rule = MD065BlanksAroundHorizontalRules;
355 let content = "Some text.
356___
357More text.";
358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
359 let result = rule.check(&ctx).unwrap();
360
361 assert_eq!(result.len(), 2);
362 }
363
364 #[test]
365 fn test_hr_with_spaces() {
366 let rule = MD065BlanksAroundHorizontalRules;
367 let content = "Some text.
369* * *
370More text.";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372 let result = rule.check(&ctx).unwrap();
373
374 assert_eq!(result.len(), 2);
375 }
376
377 #[test]
378 fn test_hr_long() {
379 let rule = MD065BlanksAroundHorizontalRules;
380 let content = "Some text.
382**********
383More text.";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386
387 assert_eq!(result.len(), 2);
388 }
389
390 #[test]
391 fn test_setext_heading_not_hr() {
392 let rule = MD065BlanksAroundHorizontalRules;
393 let content = "Heading
394---
395
396More text.";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399
400 assert!(result.is_empty());
402 }
403
404 #[test]
405 fn test_setext_heading_equals() {
406 let rule = MD065BlanksAroundHorizontalRules;
407 let content = "Heading
408===
409
410More text.";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413
414 assert!(result.is_empty());
416 }
417
418 #[test]
419 fn test_blockquote_setext_underline_is_not_hr() {
420 let rule = MD065BlanksAroundHorizontalRules;
424 for content in [
425 "> Title\n> ---\n\nText.\n",
426 "> > Deep\n> > ---\n\nText.\n",
427 "> Title\n> ---\n\nText.\n",
428 ] {
429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430 let result = rule.check(&ctx).unwrap();
431 assert!(result.is_empty(), "{content:?} is a heading: {result:?}");
432 assert_eq!(rule.fix(&ctx).unwrap(), content);
433 }
434 }
435
436 #[test]
437 fn test_blockquote_hr_below_blank_line_is_checked() {
438 let rule = MD065BlanksAroundHorizontalRules;
441 let content = "> Text\n>\n> ---\n> More\n";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert_eq!(result.len(), 1, "{result:?}");
445 assert_eq!(result[0].line, 3);
446 assert_eq!(result[0].message, "Missing blank line after horizontal rule");
447 }
448
449 #[test]
450 fn test_hr_in_code_block() {
451 let rule = MD065BlanksAroundHorizontalRules;
452 let content = "Some text.
453
454```
455---
456```
457
458More text.";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461
462 assert!(result.is_empty());
464 }
465
466 #[test]
467 fn test_fix_missing_blanks() {
468 let rule = MD065BlanksAroundHorizontalRules;
469 let content = "Text before.
471***
472Text after.";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let fixed = rule.fix(&ctx).unwrap();
475
476 let expected = "Text before.
477
478***
479
480Text after.";
481 assert_eq!(fixed, expected);
482 }
483
484 #[test]
485 fn test_fix_multiple_hrs() {
486 let rule = MD065BlanksAroundHorizontalRules;
487 let content = "Start
489***
490Middle
491___
492End";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let fixed = rule.fix(&ctx).unwrap();
495
496 let expected = "Start
497
498***
499
500Middle
501
502___
503
504End";
505 assert_eq!(fixed, expected);
506 }
507
508 #[test]
509 fn test_empty_content() {
510 let rule = MD065BlanksAroundHorizontalRules;
511 let content = "";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513 let result = rule.check(&ctx).unwrap();
514
515 assert!(result.is_empty());
516 }
517
518 #[test]
519 fn test_no_hrs() {
520 let rule = MD065BlanksAroundHorizontalRules;
521 let content = "Just regular text.
522No horizontal rules here.
523Only paragraphs.";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525 let result = rule.check(&ctx).unwrap();
526
527 assert!(result.is_empty());
528 }
529
530 #[test]
531 fn test_is_horizontal_rule() {
532 use crate::lint_context::is_horizontal_rule_line;
533
534 assert!(is_horizontal_rule_line("---"));
536 assert!(is_horizontal_rule_line("----"));
537 assert!(is_horizontal_rule_line("***"));
538 assert!(is_horizontal_rule_line("****"));
539 assert!(is_horizontal_rule_line("___"));
540 assert!(is_horizontal_rule_line("____"));
541 assert!(is_horizontal_rule_line("- - -"));
542 assert!(is_horizontal_rule_line("* * *"));
543 assert!(is_horizontal_rule_line("_ _ _"));
544 assert!(is_horizontal_rule_line(" --- "));
545
546 assert!(!is_horizontal_rule_line("--"));
548 assert!(!is_horizontal_rule_line("**"));
549 assert!(!is_horizontal_rule_line("__"));
550 assert!(!is_horizontal_rule_line("- -"));
551 assert!(!is_horizontal_rule_line("text"));
552 assert!(!is_horizontal_rule_line(""));
553 assert!(!is_horizontal_rule_line("==="));
554 }
555
556 #[test]
557 fn test_consecutive_hrs_with_blanks() {
558 let rule = MD065BlanksAroundHorizontalRules;
559 let content = "Text.
560
561---
562
563***
564
565More text.";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568
569 assert!(result.is_empty());
571 }
572
573 #[test]
574 fn test_hr_after_heading() {
575 let rule = MD065BlanksAroundHorizontalRules;
576 let content = "# Heading
578***
579
580Text.";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583
584 assert_eq!(result.len(), 1);
586 assert!(result[0].message.contains("before horizontal rule"));
587 }
588
589 #[test]
590 fn test_hr_before_heading() {
591 let rule = MD065BlanksAroundHorizontalRules;
592 let content = "Text.
593
594***
595# Heading";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598
599 assert_eq!(result.len(), 1);
601 assert!(result[0].message.contains("after horizontal rule"));
602 }
603
604 #[test]
605 fn test_setext_heading_hyphen_not_flagged() {
606 let rule = MD065BlanksAroundHorizontalRules;
607 let content = "Heading Text
609---
610
611More text.";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614
615 assert!(result.is_empty());
617 }
618
619 #[test]
620 fn test_hr_with_blank_before_hyphen() {
621 let rule = MD065BlanksAroundHorizontalRules;
622 let content = "Some text.
624
625---
626More text.";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629
630 assert_eq!(result.len(), 1);
632 assert!(result[0].message.contains("after horizontal rule"));
633 }
634
635 #[test]
640 fn test_frontmatter_not_flagged() {
641 let rule = MD065BlanksAroundHorizontalRules;
642 let content = "---
644title: Test Document
645date: 2024-01-01
646---
647
648# Heading
649
650Content here.";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let result = rule.check(&ctx).unwrap();
653
654 assert!(result.is_empty());
656 }
657
658 #[test]
659 fn test_hr_after_frontmatter() {
660 let rule = MD065BlanksAroundHorizontalRules;
661 let content = "---
662title: Test
663---
664
665Content.
666***
667More content.";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670
671 assert_eq!(result.len(), 2);
673 }
674
675 #[test]
676 fn test_hr_in_indented_code_block() {
677 let rule = MD065BlanksAroundHorizontalRules;
678 let content = "Some text.
680
681 ---
682 code here
683
684More text.";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687
688 assert!(result.is_empty());
690 }
691
692 #[test]
693 fn test_hr_with_leading_spaces() {
694 let rule = MD065BlanksAroundHorizontalRules;
695 let content = "Text.
697 ***
698More text.";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701
702 assert_eq!(result.len(), 2);
704 }
705
706 #[test]
707 fn test_hr_in_html_comment() {
708 let rule = MD065BlanksAroundHorizontalRules;
709 let content = "Text.
710
711<!--
712---
713-->
714
715More text.";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718
719 assert!(result.is_empty());
721 }
722
723 #[test]
724 fn test_hr_in_blockquote() {
725 let rule = MD065BlanksAroundHorizontalRules;
726 let content = "Text.
727
728> Quote text
729> ***
730> More quote
731
732After quote.";
733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx).unwrap();
735
736 assert!(result.len() <= 2); }
742
743 #[test]
744 fn test_hr_after_list() {
745 let rule = MD065BlanksAroundHorizontalRules;
746 let content = "* Item one
748* Item two
749***
750
751More text.";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754
755 assert_eq!(result.len(), 1);
757 assert!(result[0].message.contains("before horizontal rule"));
758 }
759
760 #[test]
761 fn test_mixed_marker_with_many_spaces() {
762 let rule = MD065BlanksAroundHorizontalRules;
763 let content = "Text.
764- - - -
765More text.";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let result = rule.check(&ctx).unwrap();
768
769 assert_eq!(result.len(), 2);
771 }
772
773 #[test]
774 fn test_only_hr_in_document() {
775 let rule = MD065BlanksAroundHorizontalRules;
776 let content = "---";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let result = rule.check(&ctx).unwrap();
779
780 assert!(result.is_empty());
782 }
783
784 #[test]
785 fn test_multiple_blank_lines_already_present() {
786 let rule = MD065BlanksAroundHorizontalRules;
787 let content = "Text.
788
789
790---
791
792
793More text.";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796
797 assert!(result.is_empty());
799 }
800
801 #[test]
802 fn test_hr_at_both_start_and_end() {
803 let rule = MD065BlanksAroundHorizontalRules;
804 let content = "---
805
806Content in the middle.
807
808---";
809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811
812 assert!(result.is_empty());
814 }
815
816 #[test]
817 fn test_consecutive_hrs_without_blanks() {
818 let rule = MD065BlanksAroundHorizontalRules;
819 let content = "Text.
820
821***
822---
823___
824
825More text.";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.check(&ctx).unwrap();
828
829 assert!(result.len() >= 2);
832 }
833
834 #[test]
835 fn test_hr_after_hr_not_setext() {
836 let rule = MD065BlanksAroundHorizontalRules;
838 let content = "***\n---\n# ";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840
841 let fixed = rule.fix(&ctx).unwrap();
843 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
844 let fixed2 = rule.fix(&ctx2).unwrap();
845 assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
846 }
847
848 #[test]
849 fn test_fix_idempotency() {
850 let rule = MD065BlanksAroundHorizontalRules;
851 let content = "Text before.
852***
853Text after.";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let fixed_once = rule.fix(&ctx).unwrap();
856
857 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
859 let fixed_twice = rule.fix(&ctx2).unwrap();
860
861 assert_eq!(fixed_once, fixed_twice);
863 }
864
865 #[test]
866 fn test_setext_heading_long_underline() {
867 let rule = MD065BlanksAroundHorizontalRules;
868 let content = "Heading Text
869----------
870
871More text.";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.check(&ctx).unwrap();
874
875 assert!(result.is_empty());
877 }
878
879 #[test]
880 fn test_hr_with_trailing_whitespace() {
881 let rule = MD065BlanksAroundHorizontalRules;
882 let content = "Text.
883***
884More text.";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887
888 assert_eq!(result.len(), 2);
890 }
891
892 #[test]
893 fn test_hr_in_html_block() {
894 let rule = MD065BlanksAroundHorizontalRules;
895 let content = "Text.
896
897<div>
898---
899</div>
900
901More text.";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904
905 assert!(result.is_empty());
908 }
909
910 #[test]
911 fn test_spaced_hyphens_are_hr_not_setext() {
912 let rule = MD065BlanksAroundHorizontalRules;
913 let content = "Heading
916- - -
917
918More text.";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx).unwrap();
921
922 assert_eq!(result.len(), 1);
924 assert!(result[0].message.contains("before horizontal rule"));
925 }
926
927 #[test]
928 fn test_not_setext_if_prev_line_blank() {
929 let rule = MD065BlanksAroundHorizontalRules;
930 let content = "Some paragraph.
931
932---
933Text after.";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let result = rule.check(&ctx).unwrap();
936
937 assert_eq!(result.len(), 1);
939 assert!(result[0].message.contains("after horizontal rule"));
940 }
941
942 #[test]
943 fn test_asterisk_cannot_be_setext() {
944 let rule = MD065BlanksAroundHorizontalRules;
945 let content = "Some text
947***
948More text.";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951
952 assert_eq!(result.len(), 2);
954 }
955
956 #[test]
957 fn test_underscore_cannot_be_setext() {
958 let rule = MD065BlanksAroundHorizontalRules;
959 let content = "Some text
961___
962More text.";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let result = rule.check(&ctx).unwrap();
965
966 assert_eq!(result.len(), 2);
968 }
969
970 #[test]
971 fn test_fix_preserves_content() {
972 let rule = MD065BlanksAroundHorizontalRules;
973 let content = "First paragraph with **bold** and *italic*.
974***
975Second paragraph with [link](url) and `code`.";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let fixed = rule.fix(&ctx).unwrap();
978
979 assert!(fixed.contains("**bold**"));
981 assert!(fixed.contains("*italic*"));
982 assert!(fixed.contains("[link](url)"));
983 assert!(fixed.contains("`code`"));
984 assert!(fixed.contains("***"));
985 }
986
987 #[test]
988 fn test_fix_only_adds_needed_blanks() {
989 let rule = MD065BlanksAroundHorizontalRules;
990 let content = "Text.
992
993***
994More text.";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let fixed = rule.fix(&ctx).unwrap();
997
998 let expected = "Text.
999
1000***
1001
1002More text.";
1003 assert_eq!(fixed, expected);
1004 }
1005
1006 #[test]
1007 fn test_hr_detection_edge_cases() {
1008 use crate::lint_context::is_horizontal_rule_line;
1009
1010 assert!(is_horizontal_rule_line(" ---"));
1012 assert!(is_horizontal_rule_line("--- "));
1013 assert!(is_horizontal_rule_line(" --- "));
1014 assert!(is_horizontal_rule_line("* * *"));
1015 assert!(is_horizontal_rule_line("_ _ _"));
1016
1017 assert!(!is_horizontal_rule_line("--a"));
1019 assert!(!is_horizontal_rule_line("**a"));
1020 assert!(!is_horizontal_rule_line("-*-"));
1021 assert!(!is_horizontal_rule_line("- * _"));
1022 assert!(!is_horizontal_rule_line(" "));
1023 assert!(!is_horizontal_rule_line("\t---")); }
1025
1026 #[test]
1027 fn test_warning_line_numbers_accurate() {
1028 let rule = MD065BlanksAroundHorizontalRules;
1029 let content = "Line 1
1030Line 2
1031***
1032Line 4";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035
1036 assert_eq!(result.len(), 2);
1038 assert_eq!(result[0].line, 3); assert_eq!(result[1].line, 3);
1040 }
1041
1042 #[test]
1043 fn test_complex_document_structure() {
1044 let rule = MD065BlanksAroundHorizontalRules;
1045 let content = "# Main Title
1046
1047Introduction paragraph.
1048
1049## Section One
1050
1051Content here.
1052
1053***
1054
1055## Section Two
1056
1057More content.
1058
1059---
1060
1061Final thoughts.";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let result = rule.check(&ctx).unwrap();
1064
1065 assert!(result.is_empty());
1067 }
1068
1069 #[test]
1070 fn test_fix_preserves_blockquote_prefix_before_hr() {
1071 let rule = MD065BlanksAroundHorizontalRules;
1073
1074 let content = "> Text before
1075> ***
1076> Text after";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let fixed = rule.fix(&ctx).unwrap();
1079
1080 let expected = "> Text before
1082>
1083> ***
1084>
1085> Text after";
1086 assert_eq!(
1087 fixed, expected,
1088 "Fix should insert '>' blank lines around HR, not plain blank lines"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1094 let rule = MD065BlanksAroundHorizontalRules;
1097
1098 let content = ">> Nested quote
1099>> ***
1100>> More text";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let fixed = rule.fix(&ctx).unwrap();
1103
1104 let expected = ">> Nested quote
1106>>
1107>> ***
1108>>
1109>> More text";
1110 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1111 }
1112
1113 #[test]
1114 fn test_fix_preserves_blockquote_prefix_after_hr() {
1115 let rule = MD065BlanksAroundHorizontalRules;
1117
1118 let content = "> ---
1119> Text after";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let fixed = rule.fix(&ctx).unwrap();
1122
1123 let expected = "> ---
1125>
1126> Text after";
1127 assert_eq!(
1128 fixed, expected,
1129 "Fix should insert '>' blank line after HR, not plain blank line"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1135 let rule = MD065BlanksAroundHorizontalRules;
1138
1139 let content = ">>> Triple nested
1140>>> ***
1141>>> More text";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143 let fixed = rule.fix(&ctx).unwrap();
1144
1145 let expected = ">>> Triple nested
1146>>>
1147>>> ***
1148>>>
1149>>> More text";
1150 assert_eq!(
1151 fixed, expected,
1152 "Fix should preserve triple-nested blockquote prefix '>>>'"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_fix_preserves_trailing_newline() {
1158 let rule = MD065BlanksAroundHorizontalRules;
1159
1160 let content = "Text\n***\nMore text\n";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let fixed = rule.fix(&ctx).unwrap();
1163
1164 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
1165 assert_eq!(fixed, "Text\n\n***\n\nMore text\n");
1166 }
1167
1168 #[test]
1169 fn spaced_thematic_break_is_not_skipped() {
1170 let rule = MD065BlanksAroundHorizontalRules;
1174
1175 for content in [
1176 "Text\n* * *\nMore text\n",
1177 "Text\n- - -\nMore text\n",
1178 "Text\n_ _ _\nMore text\n",
1179 ] {
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 assert!(!rule.should_skip(&ctx), "the whole rule was skipped for {content:?}");
1182 assert_eq!(
1183 rule.check(&ctx).unwrap().len(),
1184 2,
1185 "both missing blank lines should be reported for {content:?}"
1186 );
1187 }
1188 }
1189
1190 #[test]
1191 fn a_document_with_no_thematic_break_is_still_skipped() {
1192 let rule = MD065BlanksAroundHorizontalRules;
1193
1194 for content in ["Text\nMore text\n", "- item\n- item\n", "```\n---\n```\n"] {
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 assert!(
1197 rule.should_skip(&ctx),
1198 "the rule should have been skipped for {content:?}"
1199 );
1200 }
1201 }
1202
1203 #[test]
1204 fn markers_inside_a_hidden_block_are_not_spaced_out() {
1205 let rule = MD065BlanksAroundHorizontalRules;
1209
1210 for content in [
1211 "Text.\n\n<!--\n***\n-->\n\nMore.\n",
1212 "Text.\n\n$$\na = b\n***\nc = d\n$$\n\nMore.\n",
1213 ] {
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 assert!(
1216 rule.check(&ctx).unwrap().is_empty(),
1217 "the hidden markers were reported in {content:?}"
1218 );
1219 assert_eq!(
1220 rule.fix(&ctx).unwrap(),
1221 content,
1222 "the block was rewritten in {content:?}"
1223 );
1224 }
1225 }
1226
1227 #[test]
1228 fn test_fix_preserves_no_trailing_newline() {
1229 let rule = MD065BlanksAroundHorizontalRules;
1230
1231 let content = "Text\n***\nMore text";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233 let fixed = rule.fix(&ctx).unwrap();
1234
1235 assert!(
1236 !fixed.ends_with('\n'),
1237 "Fix should not add trailing newline if original didn't have one"
1238 );
1239 assert_eq!(fixed, "Text\n\n***\n\nMore text");
1240 }
1241}