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