Skip to main content

rumdl_lib/rules/
md065_blanks_around_horizontal_rules.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3/// Rule MD065: Blanks around horizontal rules
4///
5/// See [docs/md065.md](../../docs/md065.md) for full documentation and examples.
6///
7/// Ensures horizontal rules have blank lines before and after them
8
9#[derive(Clone, Default)]
10pub struct MD065BlanksAroundHorizontalRules;
11
12impl MD065BlanksAroundHorizontalRules {
13    /// Check if a line is blank (including blockquote continuation lines)
14    ///
15    /// Uses the shared `is_blank_in_blockquote_context` utility function for
16    /// consistent blank line detection across all rules.
17    fn is_blank_line(line: &str) -> bool {
18        crate::utils::regex_cache::is_blank_in_blockquote_context(line)
19    }
20
21    /// Check if this might be a setext heading underline (not a horizontal rule)
22    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        // Setext markers are only - or = (not * or _)
31        // And the previous line must have content (not blank, not itself an HR)
32        // CommonMark: setext underlines can have leading/trailing spaces but NO internal spaces
33        if prev_line.is_empty() {
34            return false;
35        }
36
37        // If the previous line is itself a horizontal rule, this cannot be a setext heading
38        if crate::lint_context::is_horizontal_rule_line(prev_line) {
39            return false;
40        }
41
42        // Check if all non-space characters are the same marker (- or =)
43        // and there are no internal spaces (spaces between markers)
44        let has_hyphen = line.contains('-');
45        let has_equals = line.contains('=');
46
47        // Must have exactly one type of marker
48        if has_hyphen == has_equals {
49            return false; // Either has both or neither
50        }
51
52        let marker = if has_hyphen { '-' } else { '=' };
53
54        // Setext underline: optional leading spaces, then only marker chars, then optional trailing spaces
55        // No internal spaces allowed
56        let trimmed = line.trim();
57        trimmed.chars().all(|c| c == marker)
58    }
59
60    /// Count the number of blank lines before a given line index
61    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    /// Count the number of blank lines after a given line index
76    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            // Use pre-computed is_horizontal_rule from LineInfo
122            // This already excludes code blocks, frontmatter, and does proper HR detection
123            if !line_info.is_horizontal_rule {
124                continue;
125            }
126
127            // Skip if this is actually a setext heading marker
128            if Self::is_setext_heading_marker(lines, i) {
129                continue;
130            }
131
132            // Check for blank line before HR (unless at start of document)
133            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            // Check for blank line after HR (unless at end of document)
151            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            // Check for warning about missing blank line before this line
187            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            // Check for warning about missing blank line after this line
200            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        // Use *** which cannot be a setext heading marker
249        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        // Use *** which cannot be a setext heading marker
280        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        // No blank line needed before HR at start of document
301        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        // No blank line needed after HR at end of document
314        assert!(result.is_empty());
315    }
316
317    #[test]
318    fn test_multiple_hrs() {
319        let rule = MD065BlanksAroundHorizontalRules;
320        // Use *** and ___ which cannot be setext heading markers
321        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        // Use * * * which cannot be a setext heading marker
360        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        // Use asterisks which cannot be a setext heading marker
373        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        // Should not flag setext heading marker as HR
393        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        // === is not a valid HR, only setext heading
407        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        // HR in code block should be ignored
424        assert!(result.is_empty());
425    }
426
427    #[test]
428    fn test_fix_missing_blanks() {
429        let rule = MD065BlanksAroundHorizontalRules;
430        // Use *** which cannot be a setext heading marker
431        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        // Use *** and ___ which cannot be setext heading markers
449        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        // Valid horizontal rules
496        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        // Invalid horizontal rules
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("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        // Both HRs have proper blank lines
531        assert!(result.is_empty());
532    }
533
534    #[test]
535    fn test_hr_after_heading() {
536        let rule = MD065BlanksAroundHorizontalRules;
537        // Use *** which cannot be a setext heading marker
538        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        // HR after heading needs blank line before
546        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        // HR before heading needs blank line after
561        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        // --- immediately after text is a setext heading, not HR
569        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        // Should not flag setext heading as missing blank lines
577        assert!(result.is_empty());
578    }
579
580    #[test]
581    fn test_hr_with_blank_before_hyphen() {
582        let rule = MD065BlanksAroundHorizontalRules;
583        // --- after a blank line IS a horizontal rule, not setext heading
584        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        // Should flag missing blank line after
592        assert_eq!(result.len(), 1);
593        assert!(result[0].message.contains("after horizontal rule"));
594    }
595
596    // ============================================================
597    // Additional comprehensive tests for edge cases
598    // ============================================================
599
600    #[test]
601    fn test_frontmatter_not_flagged() {
602        let rule = MD065BlanksAroundHorizontalRules;
603        // YAML frontmatter uses --- delimiters which should NOT be flagged
604        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        // Frontmatter delimiters should not be flagged as HRs
616        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        // HR after frontmatter content should be flagged
633        assert_eq!(result.len(), 2);
634    }
635
636    #[test]
637    fn test_hr_in_indented_code_block() {
638        let rule = MD065BlanksAroundHorizontalRules;
639        // 4-space indented code block
640        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        // HR in indented code block should be ignored
650        assert!(result.is_empty());
651    }
652
653    #[test]
654    fn test_hr_with_leading_spaces() {
655        let rule = MD065BlanksAroundHorizontalRules;
656        // 1-3 spaces of indentation is still a valid HR
657        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        // Indented HR (1-3 spaces) should be detected
664        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        // HR inside HTML comment should be ignored
681        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        // HR inside blockquote - the "> ***" line contains a valid HR pattern
698        // but within blockquote context. This tests blockquote awareness.
699        // Note: blockquotes don't skip HR detection, so this may flag.
700        // The actual behavior depends on implementation.
701        assert!(result.len() <= 2); // May or may not flag based on blockquote handling
702    }
703
704    #[test]
705    fn test_hr_after_list() {
706        let rule = MD065BlanksAroundHorizontalRules;
707        // Real-world case from Node.js repo
708        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        // HR immediately after list should be flagged
717        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        // HR with multiple spaces between markers
731        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        // Single HR alone in document - no blanks needed
742        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        // Multiple blank lines should not trigger warnings
759        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        // HRs at start and end with proper spacing
774        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        // Consecutive HRs need blanks between them
791        // --- after *** is also an HR (not setext), since *** is an HR not text
792        assert!(result.len() >= 2);
793    }
794
795    #[test]
796    fn test_hr_after_hr_not_setext() {
797        // Regression: --- after *** should be treated as HR, not setext heading
798        let rule = MD065BlanksAroundHorizontalRules;
799        let content = "***\n---\n# ";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801
802        // Both *** and --- are HRs, both need blanks
803        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        // Apply fix again
819        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
820        let fixed_twice = rule.fix(&ctx2).unwrap();
821
822        // Second fix should not change anything
823        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        // Long underline is still setext heading, not HR
837        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        // HR with trailing whitespace should still be detected
850        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        // HR inside HTML block should be ignored (depends on HTML block detection)
867        // This tests HTML block awareness
868        assert!(result.is_empty());
869    }
870
871    #[test]
872    fn test_spaced_hyphens_are_hr_not_setext() {
873        let rule = MD065BlanksAroundHorizontalRules;
874        // CommonMark: setext underlines cannot have internal spaces
875        // So "- - -" is a thematic break, not a setext heading
876        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        // "- - -" with internal spaces is HR, needs blank before
884        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        // --- after blank line is HR, not setext heading
899        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        // *** immediately after text is still HR (asterisks can't be setext markers)
907        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        // *** is always HR, never setext
914        assert_eq!(result.len(), 2);
915    }
916
917    #[test]
918    fn test_underscore_cannot_be_setext() {
919        let rule = MD065BlanksAroundHorizontalRules;
920        // ___ immediately after text is still HR (underscores can't be setext markers)
921        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        // ___ is always HR, never setext
928        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        // Verify content is preserved
941        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        // Already has blank before, missing blank after
952        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        // Valid HRs with various spacing (0-3 leading spaces allowed)
972        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        // Invalid patterns
979        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---")); // Tabs not allowed per CommonMark
985    }
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        // Verify line numbers are 1-indexed and accurate
998        assert_eq!(result.len(), 2);
999        assert_eq!(result[0].line, 3); // HR is on line 3
1000        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        // Well-structured document should have no warnings
1027        assert!(result.is_empty());
1028    }
1029
1030    #[test]
1031    fn test_fix_preserves_blockquote_prefix_before_hr() {
1032        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
1033        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        // The blank lines inserted should have the blockquote prefix
1042        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        // Nested blockquotes should preserve the full prefix
1056        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        // Should insert ">>" blank lines
1065        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        // Issue #268: Fix should insert blockquote-prefixed blank lines after HR
1076        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        // The blank line inserted after the HR should have the blockquote prefix
1084        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        // Triple-nested blockquotes should preserve full prefix
1096        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}