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