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