1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2
3#[derive(Clone, Default)]
10pub struct MD065BlanksAroundHorizontalRules;
11
12impl MD065BlanksAroundHorizontalRules {
13 fn is_blank_line(line: &str) -> bool {
15 line.trim().is_empty()
16 }
17
18 fn is_setext_heading_marker(lines: &[&str], line_index: usize) -> bool {
20 if line_index == 0 {
21 return false;
22 }
23
24 let line = lines[line_index].trim();
25 let prev_line = lines[line_index - 1].trim();
26
27 if prev_line.is_empty() {
31 return false;
32 }
33
34 let has_hyphen = line.contains('-');
37 let has_equals = line.contains('=');
38
39 if has_hyphen == has_equals {
41 return false; }
43
44 let marker = if has_hyphen { '-' } else { '=' };
45
46 let trimmed = line.trim();
49 trimmed.chars().all(|c| c == marker)
50 }
51
52 fn count_blank_lines_before(lines: &[&str], line_index: usize) -> usize {
54 let mut count = 0;
55 let mut i = line_index;
56 while i > 0 {
57 i -= 1;
58 if Self::is_blank_line(lines[i]) {
59 count += 1;
60 } else {
61 break;
62 }
63 }
64 count
65 }
66
67 fn count_blank_lines_after(lines: &[&str], line_index: usize) -> usize {
69 let mut count = 0;
70 let mut i = line_index + 1;
71 while i < lines.len() {
72 if Self::is_blank_line(lines[i]) {
73 count += 1;
74 i += 1;
75 } else {
76 break;
77 }
78 }
79 count
80 }
81}
82
83impl Rule for MD065BlanksAroundHorizontalRules {
84 fn name(&self) -> &'static str {
85 "MD065"
86 }
87
88 fn description(&self) -> &'static str {
89 "Horizontal rules should be surrounded by blank lines"
90 }
91
92 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
93 let content = ctx.content;
94 let line_index = &ctx.line_index;
95 let mut warnings = Vec::new();
96
97 if content.is_empty() {
98 return Ok(Vec::new());
99 }
100
101 let lines: Vec<&str> = content.lines().collect();
102
103 for (i, line_info) in ctx.lines.iter().enumerate() {
104 if !line_info.is_horizontal_rule {
107 continue;
108 }
109
110 if Self::is_setext_heading_marker(&lines, i) {
112 continue;
113 }
114
115 if i > 0 && Self::count_blank_lines_before(&lines, i) == 0 {
117 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
118 warnings.push(LintWarning {
119 rule_name: Some(self.name().to_string()),
120 message: "Missing blank line before horizontal rule".to_string(),
121 line: i + 1,
122 column: 1,
123 end_line: i + 1,
124 end_column: 2,
125 severity: Severity::Warning,
126 fix: Some(Fix {
127 range: line_index.line_col_to_byte_range(i + 1, 1),
128 replacement: format!("{bq_prefix}\n"),
129 }),
130 });
131 }
132
133 if i < lines.len() - 1 && Self::count_blank_lines_after(&lines, i) == 0 {
135 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
136 warnings.push(LintWarning {
137 rule_name: Some(self.name().to_string()),
138 message: "Missing blank line after horizontal rule".to_string(),
139 line: i + 1,
140 column: lines[i].len() + 1,
141 end_line: i + 1,
142 end_column: lines[i].len() + 2,
143 severity: Severity::Warning,
144 fix: Some(Fix {
145 range: line_index.line_col_to_byte_range(i + 1, lines[i].len() + 1),
146 replacement: format!("{bq_prefix}\n"),
147 }),
148 });
149 }
150 }
151
152 Ok(warnings)
153 }
154
155 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
156 let content = ctx.content;
157
158 let mut warnings = self.check(ctx)?;
159 if warnings.is_empty() {
160 return Ok(content.to_string());
161 }
162
163 let lines: Vec<&str> = content.lines().collect();
164 let mut result = Vec::new();
165
166 for (i, line) in lines.iter().enumerate() {
167 let warning_before = warnings
169 .iter()
170 .position(|w| w.line == i + 1 && w.message.contains("before horizontal rule"));
171
172 if let Some(idx) = warning_before {
173 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
174 result.push(bq_prefix);
175 warnings.remove(idx);
176 }
177
178 result.push((*line).to_string());
179
180 let warning_after = warnings
182 .iter()
183 .position(|w| w.line == i + 1 && w.message.contains("after horizontal rule"));
184
185 if let Some(idx) = warning_after {
186 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
187 result.push(bq_prefix);
188 warnings.remove(idx);
189 }
190 }
191
192 Ok(result.join("\n"))
193 }
194
195 fn as_any(&self) -> &dyn std::any::Any {
196 self
197 }
198
199 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
200 where
201 Self: Sized,
202 {
203 Box::new(MD065BlanksAroundHorizontalRules)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::lint_context::LintContext;
211
212 #[test]
213 fn test_hr_with_blanks() {
214 let rule = MD065BlanksAroundHorizontalRules;
215 let content = "Some text before.
216
217---
218
219Some text after.";
220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
221 let result = rule.check(&ctx).unwrap();
222
223 assert!(result.is_empty());
224 }
225
226 #[test]
227 fn test_hr_missing_blank_before() {
228 let rule = MD065BlanksAroundHorizontalRules;
229 let content = "Some text before.
231***
232
233Some text after.";
234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
235 let result = rule.check(&ctx).unwrap();
236
237 assert_eq!(result.len(), 1);
238 assert_eq!(result[0].line, 2);
239 assert!(result[0].message.contains("before horizontal rule"));
240 }
241
242 #[test]
243 fn test_hr_missing_blank_after() {
244 let rule = MD065BlanksAroundHorizontalRules;
245 let content = "Some text before.
246
247***
248Some text after.";
249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
250 let result = rule.check(&ctx).unwrap();
251
252 assert_eq!(result.len(), 1);
253 assert_eq!(result[0].line, 3);
254 assert!(result[0].message.contains("after horizontal rule"));
255 }
256
257 #[test]
258 fn test_hr_missing_both_blanks() {
259 let rule = MD065BlanksAroundHorizontalRules;
260 let content = "Some text before.
262***
263Some text after.";
264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
265 let result = rule.check(&ctx).unwrap();
266
267 assert_eq!(result.len(), 2);
268 assert!(result[0].message.contains("before horizontal rule"));
269 assert!(result[1].message.contains("after horizontal rule"));
270 }
271
272 #[test]
273 fn test_hr_at_start_of_document() {
274 let rule = MD065BlanksAroundHorizontalRules;
275 let content = "---
276
277Some text after.";
278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279 let result = rule.check(&ctx).unwrap();
280
281 assert!(result.is_empty());
283 }
284
285 #[test]
286 fn test_hr_at_end_of_document() {
287 let rule = MD065BlanksAroundHorizontalRules;
288 let content = "Some text before.
289
290---";
291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
292 let result = rule.check(&ctx).unwrap();
293
294 assert!(result.is_empty());
296 }
297
298 #[test]
299 fn test_multiple_hrs() {
300 let rule = MD065BlanksAroundHorizontalRules;
301 let content = "Text before.
303***
304Middle text.
305___
306Text after.";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308 let result = rule.check(&ctx).unwrap();
309
310 assert_eq!(result.len(), 4);
311 }
312
313 #[test]
314 fn test_hr_asterisks() {
315 let rule = MD065BlanksAroundHorizontalRules;
316 let content = "Some text.
317***
318More text.";
319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
320 let result = rule.check(&ctx).unwrap();
321
322 assert_eq!(result.len(), 2);
323 }
324
325 #[test]
326 fn test_hr_underscores() {
327 let rule = MD065BlanksAroundHorizontalRules;
328 let content = "Some text.
329___
330More text.";
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 let result = rule.check(&ctx).unwrap();
333
334 assert_eq!(result.len(), 2);
335 }
336
337 #[test]
338 fn test_hr_with_spaces() {
339 let rule = MD065BlanksAroundHorizontalRules;
340 let content = "Some text.
342* * *
343More text.";
344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 let result = rule.check(&ctx).unwrap();
346
347 assert_eq!(result.len(), 2);
348 }
349
350 #[test]
351 fn test_hr_long() {
352 let rule = MD065BlanksAroundHorizontalRules;
353 let content = "Some text.
355**********
356More text.";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358 let result = rule.check(&ctx).unwrap();
359
360 assert_eq!(result.len(), 2);
361 }
362
363 #[test]
364 fn test_setext_heading_not_hr() {
365 let rule = MD065BlanksAroundHorizontalRules;
366 let content = "Heading
367---
368
369More text.";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371 let result = rule.check(&ctx).unwrap();
372
373 assert!(result.is_empty());
375 }
376
377 #[test]
378 fn test_setext_heading_equals() {
379 let rule = MD065BlanksAroundHorizontalRules;
380 let content = "Heading
381===
382
383More text.";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386
387 assert!(result.is_empty());
389 }
390
391 #[test]
392 fn test_hr_in_code_block() {
393 let rule = MD065BlanksAroundHorizontalRules;
394 let content = "Some text.
395
396```
397---
398```
399
400More text.";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.check(&ctx).unwrap();
403
404 assert!(result.is_empty());
406 }
407
408 #[test]
409 fn test_fix_missing_blanks() {
410 let rule = MD065BlanksAroundHorizontalRules;
411 let content = "Text before.
413***
414Text after.";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let fixed = rule.fix(&ctx).unwrap();
417
418 let expected = "Text before.
419
420***
421
422Text after.";
423 assert_eq!(fixed, expected);
424 }
425
426 #[test]
427 fn test_fix_multiple_hrs() {
428 let rule = MD065BlanksAroundHorizontalRules;
429 let content = "Start
431***
432Middle
433___
434End";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let fixed = rule.fix(&ctx).unwrap();
437
438 let expected = "Start
439
440***
441
442Middle
443
444___
445
446End";
447 assert_eq!(fixed, expected);
448 }
449
450 #[test]
451 fn test_empty_content() {
452 let rule = MD065BlanksAroundHorizontalRules;
453 let content = "";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let result = rule.check(&ctx).unwrap();
456
457 assert!(result.is_empty());
458 }
459
460 #[test]
461 fn test_no_hrs() {
462 let rule = MD065BlanksAroundHorizontalRules;
463 let content = "Just regular text.
464No horizontal rules here.
465Only paragraphs.";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let result = rule.check(&ctx).unwrap();
468
469 assert!(result.is_empty());
470 }
471
472 #[test]
473 fn test_is_horizontal_rule() {
474 use crate::lint_context::is_horizontal_rule_line;
475
476 assert!(is_horizontal_rule_line("---"));
478 assert!(is_horizontal_rule_line("----"));
479 assert!(is_horizontal_rule_line("***"));
480 assert!(is_horizontal_rule_line("****"));
481 assert!(is_horizontal_rule_line("___"));
482 assert!(is_horizontal_rule_line("____"));
483 assert!(is_horizontal_rule_line("- - -"));
484 assert!(is_horizontal_rule_line("* * *"));
485 assert!(is_horizontal_rule_line("_ _ _"));
486 assert!(is_horizontal_rule_line(" --- "));
487
488 assert!(!is_horizontal_rule_line("--"));
490 assert!(!is_horizontal_rule_line("**"));
491 assert!(!is_horizontal_rule_line("__"));
492 assert!(!is_horizontal_rule_line("- -"));
493 assert!(!is_horizontal_rule_line("text"));
494 assert!(!is_horizontal_rule_line(""));
495 assert!(!is_horizontal_rule_line("==="));
496 }
497
498 #[test]
499 fn test_consecutive_hrs_with_blanks() {
500 let rule = MD065BlanksAroundHorizontalRules;
501 let content = "Text.
502
503---
504
505***
506
507More text.";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510
511 assert!(result.is_empty());
513 }
514
515 #[test]
516 fn test_hr_after_heading() {
517 let rule = MD065BlanksAroundHorizontalRules;
518 let content = "# Heading
520***
521
522Text.";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525
526 assert_eq!(result.len(), 1);
528 assert!(result[0].message.contains("before horizontal rule"));
529 }
530
531 #[test]
532 fn test_hr_before_heading() {
533 let rule = MD065BlanksAroundHorizontalRules;
534 let content = "Text.
535
536***
537# Heading";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540
541 assert_eq!(result.len(), 1);
543 assert!(result[0].message.contains("after horizontal rule"));
544 }
545
546 #[test]
547 fn test_setext_heading_hyphen_not_flagged() {
548 let rule = MD065BlanksAroundHorizontalRules;
549 let content = "Heading Text
551---
552
553More text.";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556
557 assert!(result.is_empty());
559 }
560
561 #[test]
562 fn test_hr_with_blank_before_hyphen() {
563 let rule = MD065BlanksAroundHorizontalRules;
564 let content = "Some text.
566
567---
568More text.";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571
572 assert_eq!(result.len(), 1);
574 assert!(result[0].message.contains("after horizontal rule"));
575 }
576
577 #[test]
582 fn test_frontmatter_not_flagged() {
583 let rule = MD065BlanksAroundHorizontalRules;
584 let content = "---
586title: Test Document
587date: 2024-01-01
588---
589
590# Heading
591
592Content here.";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595
596 assert!(result.is_empty());
598 }
599
600 #[test]
601 fn test_hr_after_frontmatter() {
602 let rule = MD065BlanksAroundHorizontalRules;
603 let content = "---
604title: Test
605---
606
607Content.
608***
609More content.";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let result = rule.check(&ctx).unwrap();
612
613 assert_eq!(result.len(), 2);
615 }
616
617 #[test]
618 fn test_hr_in_indented_code_block() {
619 let rule = MD065BlanksAroundHorizontalRules;
620 let content = "Some text.
622
623 ---
624 code here
625
626More text.";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629
630 assert!(result.is_empty());
632 }
633
634 #[test]
635 fn test_hr_with_leading_spaces() {
636 let rule = MD065BlanksAroundHorizontalRules;
637 let content = "Text.
639 ***
640More text.";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643
644 assert_eq!(result.len(), 2);
646 }
647
648 #[test]
649 fn test_hr_in_html_comment() {
650 let rule = MD065BlanksAroundHorizontalRules;
651 let content = "Text.
652
653<!--
654---
655-->
656
657More text.";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660
661 assert!(result.is_empty());
663 }
664
665 #[test]
666 fn test_hr_in_blockquote() {
667 let rule = MD065BlanksAroundHorizontalRules;
668 let content = "Text.
669
670> Quote text
671> ***
672> More quote
673
674After quote.";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676 let result = rule.check(&ctx).unwrap();
677
678 assert!(result.len() <= 2); }
684
685 #[test]
686 fn test_hr_after_list() {
687 let rule = MD065BlanksAroundHorizontalRules;
688 let content = "* Item one
690* Item two
691***
692
693More text.";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696
697 assert_eq!(result.len(), 1);
699 assert!(result[0].message.contains("before horizontal rule"));
700 }
701
702 #[test]
703 fn test_mixed_marker_with_many_spaces() {
704 let rule = MD065BlanksAroundHorizontalRules;
705 let content = "Text.
706- - - -
707More text.";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710
711 assert_eq!(result.len(), 2);
713 }
714
715 #[test]
716 fn test_only_hr_in_document() {
717 let rule = MD065BlanksAroundHorizontalRules;
718 let content = "---";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721
722 assert!(result.is_empty());
724 }
725
726 #[test]
727 fn test_multiple_blank_lines_already_present() {
728 let rule = MD065BlanksAroundHorizontalRules;
729 let content = "Text.
730
731
732---
733
734
735More text.";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738
739 assert!(result.is_empty());
741 }
742
743 #[test]
744 fn test_hr_at_both_start_and_end() {
745 let rule = MD065BlanksAroundHorizontalRules;
746 let content = "---
747
748Content in the middle.
749
750---";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753
754 assert!(result.is_empty());
756 }
757
758 #[test]
759 fn test_consecutive_hrs_without_blanks() {
760 let rule = MD065BlanksAroundHorizontalRules;
761 let content = "Text.
762
763***
764---
765___
766
767More text.";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770
771 assert!(result.len() >= 2);
776 }
777
778 #[test]
779 fn test_fix_idempotency() {
780 let rule = MD065BlanksAroundHorizontalRules;
781 let content = "Text before.
782***
783Text after.";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let fixed_once = rule.fix(&ctx).unwrap();
786
787 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
789 let fixed_twice = rule.fix(&ctx2).unwrap();
790
791 assert_eq!(fixed_once, fixed_twice);
793 }
794
795 #[test]
796 fn test_setext_heading_long_underline() {
797 let rule = MD065BlanksAroundHorizontalRules;
798 let content = "Heading Text
799----------
800
801More text.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804
805 assert!(result.is_empty());
807 }
808
809 #[test]
810 fn test_hr_with_trailing_whitespace() {
811 let rule = MD065BlanksAroundHorizontalRules;
812 let content = "Text.
813***
814More text.";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 2);
820 }
821
822 #[test]
823 fn test_hr_in_html_block() {
824 let rule = MD065BlanksAroundHorizontalRules;
825 let content = "Text.
826
827<div>
828---
829</div>
830
831More text.";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834
835 assert!(result.is_empty());
838 }
839
840 #[test]
841 fn test_spaced_hyphens_are_hr_not_setext() {
842 let rule = MD065BlanksAroundHorizontalRules;
843 let content = "Heading
846- - -
847
848More text.";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let result = rule.check(&ctx).unwrap();
851
852 assert_eq!(result.len(), 1);
854 assert!(result[0].message.contains("before horizontal rule"));
855 }
856
857 #[test]
858 fn test_not_setext_if_prev_line_blank() {
859 let rule = MD065BlanksAroundHorizontalRules;
860 let content = "Some paragraph.
861
862---
863Text after.";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866
867 assert_eq!(result.len(), 1);
869 assert!(result[0].message.contains("after horizontal rule"));
870 }
871
872 #[test]
873 fn test_asterisk_cannot_be_setext() {
874 let rule = MD065BlanksAroundHorizontalRules;
875 let content = "Some text
877***
878More text.";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881
882 assert_eq!(result.len(), 2);
884 }
885
886 #[test]
887 fn test_underscore_cannot_be_setext() {
888 let rule = MD065BlanksAroundHorizontalRules;
889 let content = "Some text
891___
892More text.";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895
896 assert_eq!(result.len(), 2);
898 }
899
900 #[test]
901 fn test_fix_preserves_content() {
902 let rule = MD065BlanksAroundHorizontalRules;
903 let content = "First paragraph with **bold** and *italic*.
904***
905Second paragraph with [link](url) and `code`.";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let fixed = rule.fix(&ctx).unwrap();
908
909 assert!(fixed.contains("**bold**"));
911 assert!(fixed.contains("*italic*"));
912 assert!(fixed.contains("[link](url)"));
913 assert!(fixed.contains("`code`"));
914 assert!(fixed.contains("***"));
915 }
916
917 #[test]
918 fn test_fix_only_adds_needed_blanks() {
919 let rule = MD065BlanksAroundHorizontalRules;
920 let content = "Text.
922
923***
924More text.";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let fixed = rule.fix(&ctx).unwrap();
927
928 let expected = "Text.
929
930***
931
932More text.";
933 assert_eq!(fixed, expected);
934 }
935
936 #[test]
937 fn test_hr_detection_edge_cases() {
938 use crate::lint_context::is_horizontal_rule_line;
939
940 assert!(is_horizontal_rule_line(" ---"));
942 assert!(is_horizontal_rule_line("--- "));
943 assert!(is_horizontal_rule_line(" --- "));
944 assert!(is_horizontal_rule_line("* * *"));
945 assert!(is_horizontal_rule_line("_ _ _"));
946
947 assert!(!is_horizontal_rule_line("--a"));
949 assert!(!is_horizontal_rule_line("**a"));
950 assert!(!is_horizontal_rule_line("-*-"));
951 assert!(!is_horizontal_rule_line("- * _"));
952 assert!(!is_horizontal_rule_line(" "));
953 assert!(!is_horizontal_rule_line("\t---")); }
955
956 #[test]
957 fn test_warning_line_numbers_accurate() {
958 let rule = MD065BlanksAroundHorizontalRules;
959 let content = "Line 1
960Line 2
961***
962Line 4";
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 assert_eq!(result[0].line, 3); assert_eq!(result[1].line, 3);
970 }
971
972 #[test]
973 fn test_complex_document_structure() {
974 let rule = MD065BlanksAroundHorizontalRules;
975 let content = "# Main Title
976
977Introduction paragraph.
978
979## Section One
980
981Content here.
982
983***
984
985## Section Two
986
987More content.
988
989---
990
991Final thoughts.";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994
995 assert!(result.is_empty());
997 }
998
999 #[test]
1000 fn test_fix_preserves_blockquote_prefix_before_hr() {
1001 let rule = MD065BlanksAroundHorizontalRules;
1003
1004 let content = "> Text before
1005> ***
1006> Text after";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let fixed = rule.fix(&ctx).unwrap();
1009
1010 let expected = "> Text before
1012>
1013> ***
1014>
1015> Text after";
1016 assert_eq!(
1017 fixed, expected,
1018 "Fix should insert '>' blank lines around HR, not plain blank lines"
1019 );
1020 }
1021
1022 #[test]
1023 fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1024 let rule = MD065BlanksAroundHorizontalRules;
1026
1027 let content = ">> Nested quote
1028>> ---
1029>> More text";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let fixed = rule.fix(&ctx).unwrap();
1032
1033 let expected = ">> Nested quote
1035>>
1036>> ---
1037>>
1038>> More text";
1039 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1040 }
1041
1042 #[test]
1043 fn test_fix_preserves_blockquote_prefix_after_hr() {
1044 let rule = MD065BlanksAroundHorizontalRules;
1046
1047 let content = "> ---
1048> Text after";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let fixed = rule.fix(&ctx).unwrap();
1051
1052 let expected = "> ---
1054>
1055> Text after";
1056 assert_eq!(
1057 fixed, expected,
1058 "Fix should insert '>' blank line after HR, not plain blank line"
1059 );
1060 }
1061
1062 #[test]
1063 fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1064 let rule = MD065BlanksAroundHorizontalRules;
1066
1067 let content = ">>> Triple nested
1068>>> ---
1069>>> More text";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let fixed = rule.fix(&ctx).unwrap();
1072
1073 let expected = ">>> Triple nested
1074>>>
1075>>> ---
1076>>>
1077>>> More text";
1078 assert_eq!(
1079 fixed, expected,
1080 "Fix should preserve triple-nested blockquote prefix '>>>'"
1081 );
1082 }
1083}