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 {
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 check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
101 let content = ctx.content;
102 let line_index = &ctx.line_index;
103 let mut warnings = Vec::new();
104
105 if content.is_empty() {
106 return Ok(Vec::new());
107 }
108
109 let lines = ctx.raw_lines();
110
111 for (i, line_info) in ctx.lines.iter().enumerate() {
112 if !line_info.is_horizontal_rule {
115 continue;
116 }
117
118 if Self::is_setext_heading_marker(lines, i) {
120 continue;
121 }
122
123 if i > 0 && Self::count_blank_lines_before(lines, i) == 0 {
125 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
126 warnings.push(LintWarning {
127 rule_name: Some(self.name().to_string()),
128 message: "Missing blank line before horizontal rule".to_string(),
129 line: i + 1,
130 column: 1,
131 end_line: i + 1,
132 end_column: 2,
133 severity: Severity::Warning,
134 fix: Some(Fix {
135 range: line_index.line_col_to_byte_range(i + 1, 1),
136 replacement: format!("{bq_prefix}\n"),
137 }),
138 });
139 }
140
141 if i < lines.len() - 1 && Self::count_blank_lines_after(lines, i) == 0 {
143 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
144 warnings.push(LintWarning {
145 rule_name: Some(self.name().to_string()),
146 message: "Missing blank line after horizontal rule".to_string(),
147 line: i + 1,
148 column: lines[i].len() + 1,
149 end_line: i + 1,
150 end_column: lines[i].len() + 2,
151 severity: Severity::Warning,
152 fix: Some(Fix {
153 range: line_index.line_col_to_byte_range(i + 1, lines[i].len() + 1),
154 replacement: format!("{bq_prefix}\n"),
155 }),
156 });
157 }
158 }
159
160 Ok(warnings)
161 }
162
163 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
164 let content = ctx.content;
165
166 let mut warnings = self.check(ctx)?;
167 if warnings.is_empty() {
168 return Ok(content.to_string());
169 }
170
171 let lines = ctx.raw_lines();
172 let mut result = Vec::new();
173
174 for (i, line) in lines.iter().enumerate() {
175 let warning_before = warnings
177 .iter()
178 .position(|w| w.line == i + 1 && w.message.contains("before horizontal rule"));
179
180 if let Some(idx) = warning_before {
181 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
182 result.push(bq_prefix);
183 warnings.remove(idx);
184 }
185
186 result.push((*line).to_string());
187
188 let warning_after = warnings
190 .iter()
191 .position(|w| w.line == i + 1 && w.message.contains("after horizontal rule"));
192
193 if let Some(idx) = warning_after {
194 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
195 result.push(bq_prefix);
196 warnings.remove(idx);
197 }
198 }
199
200 Ok(result.join("\n"))
201 }
202
203 fn as_any(&self) -> &dyn std::any::Any {
204 self
205 }
206
207 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
208 where
209 Self: Sized,
210 {
211 Box::new(MD065BlanksAroundHorizontalRules)
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::lint_context::LintContext;
219
220 #[test]
221 fn test_hr_with_blanks() {
222 let rule = MD065BlanksAroundHorizontalRules;
223 let content = "Some text before.
224
225---
226
227Some text after.";
228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
229 let result = rule.check(&ctx).unwrap();
230
231 assert!(result.is_empty());
232 }
233
234 #[test]
235 fn test_hr_missing_blank_before() {
236 let rule = MD065BlanksAroundHorizontalRules;
237 let content = "Some text before.
239***
240
241Some text after.";
242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
243 let result = rule.check(&ctx).unwrap();
244
245 assert_eq!(result.len(), 1);
246 assert_eq!(result[0].line, 2);
247 assert!(result[0].message.contains("before horizontal rule"));
248 }
249
250 #[test]
251 fn test_hr_missing_blank_after() {
252 let rule = MD065BlanksAroundHorizontalRules;
253 let content = "Some text before.
254
255***
256Some text after.";
257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
258 let result = rule.check(&ctx).unwrap();
259
260 assert_eq!(result.len(), 1);
261 assert_eq!(result[0].line, 3);
262 assert!(result[0].message.contains("after horizontal rule"));
263 }
264
265 #[test]
266 fn test_hr_missing_both_blanks() {
267 let rule = MD065BlanksAroundHorizontalRules;
268 let content = "Some text before.
270***
271Some text after.";
272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
273 let result = rule.check(&ctx).unwrap();
274
275 assert_eq!(result.len(), 2);
276 assert!(result[0].message.contains("before horizontal rule"));
277 assert!(result[1].message.contains("after horizontal rule"));
278 }
279
280 #[test]
281 fn test_hr_at_start_of_document() {
282 let rule = MD065BlanksAroundHorizontalRules;
283 let content = "---
284
285Some text after.";
286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
287 let result = rule.check(&ctx).unwrap();
288
289 assert!(result.is_empty());
291 }
292
293 #[test]
294 fn test_hr_at_end_of_document() {
295 let rule = MD065BlanksAroundHorizontalRules;
296 let content = "Some text before.
297
298---";
299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
300 let result = rule.check(&ctx).unwrap();
301
302 assert!(result.is_empty());
304 }
305
306 #[test]
307 fn test_multiple_hrs() {
308 let rule = MD065BlanksAroundHorizontalRules;
309 let content = "Text before.
311***
312Middle text.
313___
314Text after.";
315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
316 let result = rule.check(&ctx).unwrap();
317
318 assert_eq!(result.len(), 4);
319 }
320
321 #[test]
322 fn test_hr_asterisks() {
323 let rule = MD065BlanksAroundHorizontalRules;
324 let content = "Some text.
325***
326More text.";
327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
328 let result = rule.check(&ctx).unwrap();
329
330 assert_eq!(result.len(), 2);
331 }
332
333 #[test]
334 fn test_hr_underscores() {
335 let rule = MD065BlanksAroundHorizontalRules;
336 let content = "Some text.
337___
338More text.";
339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
340 let result = rule.check(&ctx).unwrap();
341
342 assert_eq!(result.len(), 2);
343 }
344
345 #[test]
346 fn test_hr_with_spaces() {
347 let rule = MD065BlanksAroundHorizontalRules;
348 let content = "Some text.
350* * *
351More text.";
352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
353 let result = rule.check(&ctx).unwrap();
354
355 assert_eq!(result.len(), 2);
356 }
357
358 #[test]
359 fn test_hr_long() {
360 let rule = MD065BlanksAroundHorizontalRules;
361 let content = "Some text.
363**********
364More text.";
365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
366 let result = rule.check(&ctx).unwrap();
367
368 assert_eq!(result.len(), 2);
369 }
370
371 #[test]
372 fn test_setext_heading_not_hr() {
373 let rule = MD065BlanksAroundHorizontalRules;
374 let content = "Heading
375---
376
377More text.";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let result = rule.check(&ctx).unwrap();
380
381 assert!(result.is_empty());
383 }
384
385 #[test]
386 fn test_setext_heading_equals() {
387 let rule = MD065BlanksAroundHorizontalRules;
388 let content = "Heading
389===
390
391More text.";
392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
393 let result = rule.check(&ctx).unwrap();
394
395 assert!(result.is_empty());
397 }
398
399 #[test]
400 fn test_hr_in_code_block() {
401 let rule = MD065BlanksAroundHorizontalRules;
402 let content = "Some text.
403
404```
405---
406```
407
408More text.";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411
412 assert!(result.is_empty());
414 }
415
416 #[test]
417 fn test_fix_missing_blanks() {
418 let rule = MD065BlanksAroundHorizontalRules;
419 let content = "Text before.
421***
422Text after.";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let fixed = rule.fix(&ctx).unwrap();
425
426 let expected = "Text before.
427
428***
429
430Text after.";
431 assert_eq!(fixed, expected);
432 }
433
434 #[test]
435 fn test_fix_multiple_hrs() {
436 let rule = MD065BlanksAroundHorizontalRules;
437 let content = "Start
439***
440Middle
441___
442End";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let fixed = rule.fix(&ctx).unwrap();
445
446 let expected = "Start
447
448***
449
450Middle
451
452___
453
454End";
455 assert_eq!(fixed, expected);
456 }
457
458 #[test]
459 fn test_empty_content() {
460 let rule = MD065BlanksAroundHorizontalRules;
461 let content = "";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464
465 assert!(result.is_empty());
466 }
467
468 #[test]
469 fn test_no_hrs() {
470 let rule = MD065BlanksAroundHorizontalRules;
471 let content = "Just regular text.
472No horizontal rules here.
473Only paragraphs.";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476
477 assert!(result.is_empty());
478 }
479
480 #[test]
481 fn test_is_horizontal_rule() {
482 use crate::lint_context::is_horizontal_rule_line;
483
484 assert!(is_horizontal_rule_line("---"));
486 assert!(is_horizontal_rule_line("----"));
487 assert!(is_horizontal_rule_line("***"));
488 assert!(is_horizontal_rule_line("****"));
489 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("_ _ _"));
494 assert!(is_horizontal_rule_line(" --- "));
495
496 assert!(!is_horizontal_rule_line("--"));
498 assert!(!is_horizontal_rule_line("**"));
499 assert!(!is_horizontal_rule_line("__"));
500 assert!(!is_horizontal_rule_line("- -"));
501 assert!(!is_horizontal_rule_line("text"));
502 assert!(!is_horizontal_rule_line(""));
503 assert!(!is_horizontal_rule_line("==="));
504 }
505
506 #[test]
507 fn test_consecutive_hrs_with_blanks() {
508 let rule = MD065BlanksAroundHorizontalRules;
509 let content = "Text.
510
511---
512
513***
514
515More text.";
516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517 let result = rule.check(&ctx).unwrap();
518
519 assert!(result.is_empty());
521 }
522
523 #[test]
524 fn test_hr_after_heading() {
525 let rule = MD065BlanksAroundHorizontalRules;
526 let content = "# Heading
528***
529
530Text.";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533
534 assert_eq!(result.len(), 1);
536 assert!(result[0].message.contains("before horizontal rule"));
537 }
538
539 #[test]
540 fn test_hr_before_heading() {
541 let rule = MD065BlanksAroundHorizontalRules;
542 let content = "Text.
543
544***
545# Heading";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let result = rule.check(&ctx).unwrap();
548
549 assert_eq!(result.len(), 1);
551 assert!(result[0].message.contains("after horizontal rule"));
552 }
553
554 #[test]
555 fn test_setext_heading_hyphen_not_flagged() {
556 let rule = MD065BlanksAroundHorizontalRules;
557 let content = "Heading Text
559---
560
561More text.";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564
565 assert!(result.is_empty());
567 }
568
569 #[test]
570 fn test_hr_with_blank_before_hyphen() {
571 let rule = MD065BlanksAroundHorizontalRules;
572 let content = "Some text.
574
575---
576More text.";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579
580 assert_eq!(result.len(), 1);
582 assert!(result[0].message.contains("after horizontal rule"));
583 }
584
585 #[test]
590 fn test_frontmatter_not_flagged() {
591 let rule = MD065BlanksAroundHorizontalRules;
592 let content = "---
594title: Test Document
595date: 2024-01-01
596---
597
598# Heading
599
600Content here.";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603
604 assert!(result.is_empty());
606 }
607
608 #[test]
609 fn test_hr_after_frontmatter() {
610 let rule = MD065BlanksAroundHorizontalRules;
611 let content = "---
612title: Test
613---
614
615Content.
616***
617More content.";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let result = rule.check(&ctx).unwrap();
620
621 assert_eq!(result.len(), 2);
623 }
624
625 #[test]
626 fn test_hr_in_indented_code_block() {
627 let rule = MD065BlanksAroundHorizontalRules;
628 let content = "Some text.
630
631 ---
632 code here
633
634More text.";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637
638 assert!(result.is_empty());
640 }
641
642 #[test]
643 fn test_hr_with_leading_spaces() {
644 let rule = MD065BlanksAroundHorizontalRules;
645 let content = "Text.
647 ***
648More text.";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651
652 assert_eq!(result.len(), 2);
654 }
655
656 #[test]
657 fn test_hr_in_html_comment() {
658 let rule = MD065BlanksAroundHorizontalRules;
659 let content = "Text.
660
661<!--
662---
663-->
664
665More text.";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668
669 assert!(result.is_empty());
671 }
672
673 #[test]
674 fn test_hr_in_blockquote() {
675 let rule = MD065BlanksAroundHorizontalRules;
676 let content = "Text.
677
678> Quote text
679> ***
680> More quote
681
682After quote.";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685
686 assert!(result.len() <= 2); }
692
693 #[test]
694 fn test_hr_after_list() {
695 let rule = MD065BlanksAroundHorizontalRules;
696 let content = "* Item one
698* Item two
699***
700
701More text.";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704
705 assert_eq!(result.len(), 1);
707 assert!(result[0].message.contains("before horizontal rule"));
708 }
709
710 #[test]
711 fn test_mixed_marker_with_many_spaces() {
712 let rule = MD065BlanksAroundHorizontalRules;
713 let content = "Text.
714- - - -
715More text.";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718
719 assert_eq!(result.len(), 2);
721 }
722
723 #[test]
724 fn test_only_hr_in_document() {
725 let rule = MD065BlanksAroundHorizontalRules;
726 let content = "---";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729
730 assert!(result.is_empty());
732 }
733
734 #[test]
735 fn test_multiple_blank_lines_already_present() {
736 let rule = MD065BlanksAroundHorizontalRules;
737 let content = "Text.
738
739
740---
741
742
743More text.";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746
747 assert!(result.is_empty());
749 }
750
751 #[test]
752 fn test_hr_at_both_start_and_end() {
753 let rule = MD065BlanksAroundHorizontalRules;
754 let content = "---
755
756Content in the middle.
757
758---";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761
762 assert!(result.is_empty());
764 }
765
766 #[test]
767 fn test_consecutive_hrs_without_blanks() {
768 let rule = MD065BlanksAroundHorizontalRules;
769 let content = "Text.
770
771***
772---
773___
774
775More text.";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778
779 assert!(result.len() >= 2);
782 }
783
784 #[test]
785 fn test_hr_after_hr_not_setext() {
786 let rule = MD065BlanksAroundHorizontalRules;
788 let content = "***\n---\n# ";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790
791 let fixed = rule.fix(&ctx).unwrap();
793 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
794 let fixed2 = rule.fix(&ctx2).unwrap();
795 assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
796 }
797
798 #[test]
799 fn test_fix_idempotency() {
800 let rule = MD065BlanksAroundHorizontalRules;
801 let content = "Text before.
802***
803Text after.";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let fixed_once = rule.fix(&ctx).unwrap();
806
807 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
809 let fixed_twice = rule.fix(&ctx2).unwrap();
810
811 assert_eq!(fixed_once, fixed_twice);
813 }
814
815 #[test]
816 fn test_setext_heading_long_underline() {
817 let rule = MD065BlanksAroundHorizontalRules;
818 let content = "Heading Text
819----------
820
821More text.";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824
825 assert!(result.is_empty());
827 }
828
829 #[test]
830 fn test_hr_with_trailing_whitespace() {
831 let rule = MD065BlanksAroundHorizontalRules;
832 let content = "Text.
833***
834More text.";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let result = rule.check(&ctx).unwrap();
837
838 assert_eq!(result.len(), 2);
840 }
841
842 #[test]
843 fn test_hr_in_html_block() {
844 let rule = MD065BlanksAroundHorizontalRules;
845 let content = "Text.
846
847<div>
848---
849</div>
850
851More text.";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854
855 assert!(result.is_empty());
858 }
859
860 #[test]
861 fn test_spaced_hyphens_are_hr_not_setext() {
862 let rule = MD065BlanksAroundHorizontalRules;
863 let content = "Heading
866- - -
867
868More text.";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871
872 assert_eq!(result.len(), 1);
874 assert!(result[0].message.contains("before horizontal rule"));
875 }
876
877 #[test]
878 fn test_not_setext_if_prev_line_blank() {
879 let rule = MD065BlanksAroundHorizontalRules;
880 let content = "Some paragraph.
881
882---
883Text after.";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886
887 assert_eq!(result.len(), 1);
889 assert!(result[0].message.contains("after horizontal rule"));
890 }
891
892 #[test]
893 fn test_asterisk_cannot_be_setext() {
894 let rule = MD065BlanksAroundHorizontalRules;
895 let content = "Some text
897***
898More text.";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901
902 assert_eq!(result.len(), 2);
904 }
905
906 #[test]
907 fn test_underscore_cannot_be_setext() {
908 let rule = MD065BlanksAroundHorizontalRules;
909 let content = "Some text
911___
912More text.";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915
916 assert_eq!(result.len(), 2);
918 }
919
920 #[test]
921 fn test_fix_preserves_content() {
922 let rule = MD065BlanksAroundHorizontalRules;
923 let content = "First paragraph with **bold** and *italic*.
924***
925Second paragraph with [link](url) and `code`.";
926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 let fixed = rule.fix(&ctx).unwrap();
928
929 assert!(fixed.contains("**bold**"));
931 assert!(fixed.contains("*italic*"));
932 assert!(fixed.contains("[link](url)"));
933 assert!(fixed.contains("`code`"));
934 assert!(fixed.contains("***"));
935 }
936
937 #[test]
938 fn test_fix_only_adds_needed_blanks() {
939 let rule = MD065BlanksAroundHorizontalRules;
940 let content = "Text.
942
943***
944More text.";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let fixed = rule.fix(&ctx).unwrap();
947
948 let expected = "Text.
949
950***
951
952More text.";
953 assert_eq!(fixed, expected);
954 }
955
956 #[test]
957 fn test_hr_detection_edge_cases() {
958 use crate::lint_context::is_horizontal_rule_line;
959
960 assert!(is_horizontal_rule_line(" ---"));
962 assert!(is_horizontal_rule_line("--- "));
963 assert!(is_horizontal_rule_line(" --- "));
964 assert!(is_horizontal_rule_line("* * *"));
965 assert!(is_horizontal_rule_line("_ _ _"));
966
967 assert!(!is_horizontal_rule_line("--a"));
969 assert!(!is_horizontal_rule_line("**a"));
970 assert!(!is_horizontal_rule_line("-*-"));
971 assert!(!is_horizontal_rule_line("- * _"));
972 assert!(!is_horizontal_rule_line(" "));
973 assert!(!is_horizontal_rule_line("\t---")); }
975
976 #[test]
977 fn test_warning_line_numbers_accurate() {
978 let rule = MD065BlanksAroundHorizontalRules;
979 let content = "Line 1
980Line 2
981***
982Line 4";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985
986 assert_eq!(result.len(), 2);
988 assert_eq!(result[0].line, 3); assert_eq!(result[1].line, 3);
990 }
991
992 #[test]
993 fn test_complex_document_structure() {
994 let rule = MD065BlanksAroundHorizontalRules;
995 let content = "# Main Title
996
997Introduction paragraph.
998
999## Section One
1000
1001Content here.
1002
1003***
1004
1005## Section Two
1006
1007More content.
1008
1009---
1010
1011Final thoughts.";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014
1015 assert!(result.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_fix_preserves_blockquote_prefix_before_hr() {
1021 let rule = MD065BlanksAroundHorizontalRules;
1023
1024 let content = "> Text before
1025> ***
1026> Text after";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let fixed = rule.fix(&ctx).unwrap();
1029
1030 let expected = "> Text before
1032>
1033> ***
1034>
1035> Text after";
1036 assert_eq!(
1037 fixed, expected,
1038 "Fix should insert '>' blank lines around HR, not plain blank lines"
1039 );
1040 }
1041
1042 #[test]
1043 fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1044 let rule = MD065BlanksAroundHorizontalRules;
1046
1047 let content = ">> Nested quote
1048>> ---
1049>> More text";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let fixed = rule.fix(&ctx).unwrap();
1052
1053 let expected = ">> Nested quote
1055>>
1056>> ---
1057>>
1058>> More text";
1059 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1060 }
1061
1062 #[test]
1063 fn test_fix_preserves_blockquote_prefix_after_hr() {
1064 let rule = MD065BlanksAroundHorizontalRules;
1066
1067 let content = "> ---
1068> Text after";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let fixed = rule.fix(&ctx).unwrap();
1071
1072 let expected = "> ---
1074>
1075> Text after";
1076 assert_eq!(
1077 fixed, expected,
1078 "Fix should insert '>' blank line after HR, not plain blank line"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1084 let rule = MD065BlanksAroundHorizontalRules;
1086
1087 let content = ">>> Triple nested
1088>>> ---
1089>>> More text";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let fixed = rule.fix(&ctx).unwrap();
1092
1093 let expected = ">>> Triple nested
1094>>>
1095>>> ---
1096>>>
1097>>> More text";
1098 assert_eq!(
1099 fixed, expected,
1100 "Fix should preserve triple-nested blockquote prefix '>>>'"
1101 );
1102 }
1103}