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