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 {
144 range: line_index.line_col_to_byte_range(i + 1, 1),
145 replacement: 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].len() + 1,
158 end_line: i + 1,
159 end_column: lines[i].len() + 2,
160 severity: Severity::Warning,
161 fix: Some(Fix {
162 range: line_index.line_col_to_byte_range(i + 1, lines[i].len() + 1),
163 replacement: 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 Ok(result.join("\n"))
212 }
213
214 fn as_any(&self) -> &dyn std::any::Any {
215 self
216 }
217
218 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
219 where
220 Self: Sized,
221 {
222 Box::new(MD065BlanksAroundHorizontalRules)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::lint_context::LintContext;
230
231 #[test]
232 fn test_hr_with_blanks() {
233 let rule = MD065BlanksAroundHorizontalRules;
234 let content = "Some text before.
235
236---
237
238Some text after.";
239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
240 let result = rule.check(&ctx).unwrap();
241
242 assert!(result.is_empty());
243 }
244
245 #[test]
246 fn test_hr_missing_blank_before() {
247 let rule = MD065BlanksAroundHorizontalRules;
248 let content = "Some text before.
250***
251
252Some text after.";
253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
254 let result = rule.check(&ctx).unwrap();
255
256 assert_eq!(result.len(), 1);
257 assert_eq!(result[0].line, 2);
258 assert!(result[0].message.contains("before horizontal rule"));
259 }
260
261 #[test]
262 fn test_hr_missing_blank_after() {
263 let rule = MD065BlanksAroundHorizontalRules;
264 let content = "Some text before.
265
266***
267Some text after.";
268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
269 let result = rule.check(&ctx).unwrap();
270
271 assert_eq!(result.len(), 1);
272 assert_eq!(result[0].line, 3);
273 assert!(result[0].message.contains("after horizontal rule"));
274 }
275
276 #[test]
277 fn test_hr_missing_both_blanks() {
278 let rule = MD065BlanksAroundHorizontalRules;
279 let content = "Some text before.
281***
282Some text after.";
283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
284 let result = rule.check(&ctx).unwrap();
285
286 assert_eq!(result.len(), 2);
287 assert!(result[0].message.contains("before horizontal rule"));
288 assert!(result[1].message.contains("after horizontal rule"));
289 }
290
291 #[test]
292 fn test_hr_at_start_of_document() {
293 let rule = MD065BlanksAroundHorizontalRules;
294 let content = "---
295
296Some text after.";
297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
298 let result = rule.check(&ctx).unwrap();
299
300 assert!(result.is_empty());
302 }
303
304 #[test]
305 fn test_hr_at_end_of_document() {
306 let rule = MD065BlanksAroundHorizontalRules;
307 let content = "Some text before.
308
309---";
310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
311 let result = rule.check(&ctx).unwrap();
312
313 assert!(result.is_empty());
315 }
316
317 #[test]
318 fn test_multiple_hrs() {
319 let rule = MD065BlanksAroundHorizontalRules;
320 let content = "Text before.
322***
323Middle text.
324___
325Text after.";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let result = rule.check(&ctx).unwrap();
328
329 assert_eq!(result.len(), 4);
330 }
331
332 #[test]
333 fn test_hr_asterisks() {
334 let rule = MD065BlanksAroundHorizontalRules;
335 let content = "Some text.
336***
337More text.";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339 let result = rule.check(&ctx).unwrap();
340
341 assert_eq!(result.len(), 2);
342 }
343
344 #[test]
345 fn test_hr_underscores() {
346 let rule = MD065BlanksAroundHorizontalRules;
347 let content = "Some text.
348___
349More text.";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352
353 assert_eq!(result.len(), 2);
354 }
355
356 #[test]
357 fn test_hr_with_spaces() {
358 let rule = MD065BlanksAroundHorizontalRules;
359 let content = "Some text.
361* * *
362More text.";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
364 let result = rule.check(&ctx).unwrap();
365
366 assert_eq!(result.len(), 2);
367 }
368
369 #[test]
370 fn test_hr_long() {
371 let rule = MD065BlanksAroundHorizontalRules;
372 let content = "Some text.
374**********
375More text.";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378
379 assert_eq!(result.len(), 2);
380 }
381
382 #[test]
383 fn test_setext_heading_not_hr() {
384 let rule = MD065BlanksAroundHorizontalRules;
385 let content = "Heading
386---
387
388More text.";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391
392 assert!(result.is_empty());
394 }
395
396 #[test]
397 fn test_setext_heading_equals() {
398 let rule = MD065BlanksAroundHorizontalRules;
399 let content = "Heading
400===
401
402More text.";
403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404 let result = rule.check(&ctx).unwrap();
405
406 assert!(result.is_empty());
408 }
409
410 #[test]
411 fn test_hr_in_code_block() {
412 let rule = MD065BlanksAroundHorizontalRules;
413 let content = "Some text.
414
415```
416---
417```
418
419More text.";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422
423 assert!(result.is_empty());
425 }
426
427 #[test]
428 fn test_fix_missing_blanks() {
429 let rule = MD065BlanksAroundHorizontalRules;
430 let content = "Text before.
432***
433Text after.";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let fixed = rule.fix(&ctx).unwrap();
436
437 let expected = "Text before.
438
439***
440
441Text after.";
442 assert_eq!(fixed, expected);
443 }
444
445 #[test]
446 fn test_fix_multiple_hrs() {
447 let rule = MD065BlanksAroundHorizontalRules;
448 let content = "Start
450***
451Middle
452___
453End";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let fixed = rule.fix(&ctx).unwrap();
456
457 let expected = "Start
458
459***
460
461Middle
462
463___
464
465End";
466 assert_eq!(fixed, expected);
467 }
468
469 #[test]
470 fn test_empty_content() {
471 let rule = MD065BlanksAroundHorizontalRules;
472 let content = "";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475
476 assert!(result.is_empty());
477 }
478
479 #[test]
480 fn test_no_hrs() {
481 let rule = MD065BlanksAroundHorizontalRules;
482 let content = "Just regular text.
483No horizontal rules here.
484Only paragraphs.";
485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486 let result = rule.check(&ctx).unwrap();
487
488 assert!(result.is_empty());
489 }
490
491 #[test]
492 fn test_is_horizontal_rule() {
493 use crate::lint_context::is_horizontal_rule_line;
494
495 assert!(is_horizontal_rule_line("---"));
497 assert!(is_horizontal_rule_line("----"));
498 assert!(is_horizontal_rule_line("***"));
499 assert!(is_horizontal_rule_line("****"));
500 assert!(is_horizontal_rule_line("___"));
501 assert!(is_horizontal_rule_line("____"));
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
507 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("text"));
513 assert!(!is_horizontal_rule_line(""));
514 assert!(!is_horizontal_rule_line("==="));
515 }
516
517 #[test]
518 fn test_consecutive_hrs_with_blanks() {
519 let rule = MD065BlanksAroundHorizontalRules;
520 let content = "Text.
521
522---
523
524***
525
526More text.";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx).unwrap();
529
530 assert!(result.is_empty());
532 }
533
534 #[test]
535 fn test_hr_after_heading() {
536 let rule = MD065BlanksAroundHorizontalRules;
537 let content = "# Heading
539***
540
541Text.";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544
545 assert_eq!(result.len(), 1);
547 assert!(result[0].message.contains("before horizontal rule"));
548 }
549
550 #[test]
551 fn test_hr_before_heading() {
552 let rule = MD065BlanksAroundHorizontalRules;
553 let content = "Text.
554
555***
556# Heading";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559
560 assert_eq!(result.len(), 1);
562 assert!(result[0].message.contains("after horizontal rule"));
563 }
564
565 #[test]
566 fn test_setext_heading_hyphen_not_flagged() {
567 let rule = MD065BlanksAroundHorizontalRules;
568 let content = "Heading Text
570---
571
572More text.";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575
576 assert!(result.is_empty());
578 }
579
580 #[test]
581 fn test_hr_with_blank_before_hyphen() {
582 let rule = MD065BlanksAroundHorizontalRules;
583 let content = "Some text.
585
586---
587More text.";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
589 let result = rule.check(&ctx).unwrap();
590
591 assert_eq!(result.len(), 1);
593 assert!(result[0].message.contains("after horizontal rule"));
594 }
595
596 #[test]
601 fn test_frontmatter_not_flagged() {
602 let rule = MD065BlanksAroundHorizontalRules;
603 let content = "---
605title: Test Document
606date: 2024-01-01
607---
608
609# Heading
610
611Content here.";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614
615 assert!(result.is_empty());
617 }
618
619 #[test]
620 fn test_hr_after_frontmatter() {
621 let rule = MD065BlanksAroundHorizontalRules;
622 let content = "---
623title: Test
624---
625
626Content.
627***
628More content.";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631
632 assert_eq!(result.len(), 2);
634 }
635
636 #[test]
637 fn test_hr_in_indented_code_block() {
638 let rule = MD065BlanksAroundHorizontalRules;
639 let content = "Some text.
641
642 ---
643 code here
644
645More text.";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648
649 assert!(result.is_empty());
651 }
652
653 #[test]
654 fn test_hr_with_leading_spaces() {
655 let rule = MD065BlanksAroundHorizontalRules;
656 let content = "Text.
658 ***
659More text.";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662
663 assert_eq!(result.len(), 2);
665 }
666
667 #[test]
668 fn test_hr_in_html_comment() {
669 let rule = MD065BlanksAroundHorizontalRules;
670 let content = "Text.
671
672<!--
673---
674-->
675
676More text.";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679
680 assert!(result.is_empty());
682 }
683
684 #[test]
685 fn test_hr_in_blockquote() {
686 let rule = MD065BlanksAroundHorizontalRules;
687 let content = "Text.
688
689> Quote text
690> ***
691> More quote
692
693After quote.";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696
697 assert!(result.len() <= 2); }
703
704 #[test]
705 fn test_hr_after_list() {
706 let rule = MD065BlanksAroundHorizontalRules;
707 let content = "* Item one
709* Item two
710***
711
712More text.";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715
716 assert_eq!(result.len(), 1);
718 assert!(result[0].message.contains("before horizontal rule"));
719 }
720
721 #[test]
722 fn test_mixed_marker_with_many_spaces() {
723 let rule = MD065BlanksAroundHorizontalRules;
724 let content = "Text.
725- - - -
726More text.";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729
730 assert_eq!(result.len(), 2);
732 }
733
734 #[test]
735 fn test_only_hr_in_document() {
736 let rule = MD065BlanksAroundHorizontalRules;
737 let content = "---";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740
741 assert!(result.is_empty());
743 }
744
745 #[test]
746 fn test_multiple_blank_lines_already_present() {
747 let rule = MD065BlanksAroundHorizontalRules;
748 let content = "Text.
749
750
751---
752
753
754More text.";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757
758 assert!(result.is_empty());
760 }
761
762 #[test]
763 fn test_hr_at_both_start_and_end() {
764 let rule = MD065BlanksAroundHorizontalRules;
765 let content = "---
766
767Content in the middle.
768
769---";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772
773 assert!(result.is_empty());
775 }
776
777 #[test]
778 fn test_consecutive_hrs_without_blanks() {
779 let rule = MD065BlanksAroundHorizontalRules;
780 let content = "Text.
781
782***
783---
784___
785
786More text.";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789
790 assert!(result.len() >= 2);
793 }
794
795 #[test]
796 fn test_hr_after_hr_not_setext() {
797 let rule = MD065BlanksAroundHorizontalRules;
799 let content = "***\n---\n# ";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801
802 let fixed = rule.fix(&ctx).unwrap();
804 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
805 let fixed2 = rule.fix(&ctx2).unwrap();
806 assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
807 }
808
809 #[test]
810 fn test_fix_idempotency() {
811 let rule = MD065BlanksAroundHorizontalRules;
812 let content = "Text before.
813***
814Text after.";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let fixed_once = rule.fix(&ctx).unwrap();
817
818 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
820 let fixed_twice = rule.fix(&ctx2).unwrap();
821
822 assert_eq!(fixed_once, fixed_twice);
824 }
825
826 #[test]
827 fn test_setext_heading_long_underline() {
828 let rule = MD065BlanksAroundHorizontalRules;
829 let content = "Heading Text
830----------
831
832More text.";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835
836 assert!(result.is_empty());
838 }
839
840 #[test]
841 fn test_hr_with_trailing_whitespace() {
842 let rule = MD065BlanksAroundHorizontalRules;
843 let content = "Text.
844***
845More text.";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848
849 assert_eq!(result.len(), 2);
851 }
852
853 #[test]
854 fn test_hr_in_html_block() {
855 let rule = MD065BlanksAroundHorizontalRules;
856 let content = "Text.
857
858<div>
859---
860</div>
861
862More text.";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865
866 assert!(result.is_empty());
869 }
870
871 #[test]
872 fn test_spaced_hyphens_are_hr_not_setext() {
873 let rule = MD065BlanksAroundHorizontalRules;
874 let content = "Heading
877- - -
878
879More text.";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882
883 assert_eq!(result.len(), 1);
885 assert!(result[0].message.contains("before horizontal rule"));
886 }
887
888 #[test]
889 fn test_not_setext_if_prev_line_blank() {
890 let rule = MD065BlanksAroundHorizontalRules;
891 let content = "Some paragraph.
892
893---
894Text after.";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897
898 assert_eq!(result.len(), 1);
900 assert!(result[0].message.contains("after horizontal rule"));
901 }
902
903 #[test]
904 fn test_asterisk_cannot_be_setext() {
905 let rule = MD065BlanksAroundHorizontalRules;
906 let content = "Some text
908***
909More text.";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912
913 assert_eq!(result.len(), 2);
915 }
916
917 #[test]
918 fn test_underscore_cannot_be_setext() {
919 let rule = MD065BlanksAroundHorizontalRules;
920 let content = "Some text
922___
923More text.";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926
927 assert_eq!(result.len(), 2);
929 }
930
931 #[test]
932 fn test_fix_preserves_content() {
933 let rule = MD065BlanksAroundHorizontalRules;
934 let content = "First paragraph with **bold** and *italic*.
935***
936Second paragraph with [link](url) and `code`.";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let fixed = rule.fix(&ctx).unwrap();
939
940 assert!(fixed.contains("**bold**"));
942 assert!(fixed.contains("*italic*"));
943 assert!(fixed.contains("[link](url)"));
944 assert!(fixed.contains("`code`"));
945 assert!(fixed.contains("***"));
946 }
947
948 #[test]
949 fn test_fix_only_adds_needed_blanks() {
950 let rule = MD065BlanksAroundHorizontalRules;
951 let content = "Text.
953
954***
955More text.";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let fixed = rule.fix(&ctx).unwrap();
958
959 let expected = "Text.
960
961***
962
963More text.";
964 assert_eq!(fixed, expected);
965 }
966
967 #[test]
968 fn test_hr_detection_edge_cases() {
969 use crate::lint_context::is_horizontal_rule_line;
970
971 assert!(is_horizontal_rule_line(" ---"));
973 assert!(is_horizontal_rule_line("--- "));
974 assert!(is_horizontal_rule_line(" --- "));
975 assert!(is_horizontal_rule_line("* * *"));
976 assert!(is_horizontal_rule_line("_ _ _"));
977
978 assert!(!is_horizontal_rule_line("--a"));
980 assert!(!is_horizontal_rule_line("**a"));
981 assert!(!is_horizontal_rule_line("-*-"));
982 assert!(!is_horizontal_rule_line("- * _"));
983 assert!(!is_horizontal_rule_line(" "));
984 assert!(!is_horizontal_rule_line("\t---")); }
986
987 #[test]
988 fn test_warning_line_numbers_accurate() {
989 let rule = MD065BlanksAroundHorizontalRules;
990 let content = "Line 1
991Line 2
992***
993Line 4";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 assert_eq!(result.len(), 2);
999 assert_eq!(result[0].line, 3); assert_eq!(result[1].line, 3);
1001 }
1002
1003 #[test]
1004 fn test_complex_document_structure() {
1005 let rule = MD065BlanksAroundHorizontalRules;
1006 let content = "# Main Title
1007
1008Introduction paragraph.
1009
1010## Section One
1011
1012Content here.
1013
1014***
1015
1016## Section Two
1017
1018More content.
1019
1020---
1021
1022Final thoughts.";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025
1026 assert!(result.is_empty());
1028 }
1029
1030 #[test]
1031 fn test_fix_preserves_blockquote_prefix_before_hr() {
1032 let rule = MD065BlanksAroundHorizontalRules;
1034
1035 let content = "> Text before
1036> ***
1037> Text after";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let fixed = rule.fix(&ctx).unwrap();
1040
1041 let expected = "> Text before
1043>
1044> ***
1045>
1046> Text after";
1047 assert_eq!(
1048 fixed, expected,
1049 "Fix should insert '>' blank lines around HR, not plain blank lines"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1055 let rule = MD065BlanksAroundHorizontalRules;
1057
1058 let content = ">> Nested quote
1059>> ---
1060>> More text";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062 let fixed = rule.fix(&ctx).unwrap();
1063
1064 let expected = ">> Nested quote
1066>>
1067>> ---
1068>>
1069>> More text";
1070 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1071 }
1072
1073 #[test]
1074 fn test_fix_preserves_blockquote_prefix_after_hr() {
1075 let rule = MD065BlanksAroundHorizontalRules;
1077
1078 let content = "> ---
1079> Text after";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let fixed = rule.fix(&ctx).unwrap();
1082
1083 let expected = "> ---
1085>
1086> Text after";
1087 assert_eq!(
1088 fixed, expected,
1089 "Fix should insert '>' blank line after HR, not plain blank line"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1095 let rule = MD065BlanksAroundHorizontalRules;
1097
1098 let content = ">>> Triple nested
1099>>> ---
1100>>> More text";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let fixed = rule.fix(&ctx).unwrap();
1103
1104 let expected = ">>> Triple nested
1105>>>
1106>>> ---
1107>>>
1108>>> More text";
1109 assert_eq!(
1110 fixed, expected,
1111 "Fix should preserve triple-nested blockquote prefix '>>>'"
1112 );
1113 }
1114}