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::new(
144                        line_index.line_col_to_byte_range(i + 1, 1),
145                        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].chars().count() + 1,
158                    end_line: i + 1,
159                    end_column: lines[i].chars().count() + 2,
160                    severity: Severity::Warning,
161                    fix: Some(Fix::new(
162                        line_index.line_col_to_byte_range(i + 1, lines[i].len() + 1),
163                        format!("{bq_prefix}\n"),
164                    )),
165                });
166            }
167        }
168
169        Ok(warnings)
170    }
171
172    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
173        let content = ctx.content;
174
175        let warnings = self.check(ctx)?;
176        let mut warnings =
177            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
178        if warnings.is_empty() {
179            return Ok(content.to_string());
180        }
181
182        let lines = ctx.raw_lines();
183        let mut result = Vec::new();
184
185        for (i, line) in lines.iter().enumerate() {
186            // 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        let mut fixed = result.join("\n");
212        if content.ends_with('\n') {
213            fixed.push('\n');
214        }
215
216        Ok(fixed)
217    }
218
219    fn as_any(&self) -> &dyn std::any::Any {
220        self
221    }
222
223    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
224    where
225        Self: Sized,
226    {
227        Box::new(MD065BlanksAroundHorizontalRules)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::lint_context::LintContext;
235
236    #[test]
237    fn test_hr_with_blanks() {
238        let rule = MD065BlanksAroundHorizontalRules;
239        let content = "Some text before.
240
241---
242
243Some text after.";
244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
245        let result = rule.check(&ctx).unwrap();
246
247        assert!(result.is_empty());
248    }
249
250    #[test]
251    fn test_hr_missing_blank_before() {
252        let rule = MD065BlanksAroundHorizontalRules;
253        // Use *** which cannot be a setext heading marker
254        let content = "Some text before.
255***
256
257Some text after.";
258        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
259        let result = rule.check(&ctx).unwrap();
260
261        assert_eq!(result.len(), 1);
262        assert_eq!(result[0].line, 2);
263        assert!(result[0].message.contains("before horizontal rule"));
264    }
265
266    #[test]
267    fn test_hr_missing_blank_after() {
268        let rule = MD065BlanksAroundHorizontalRules;
269        let content = "Some text before.
270
271***
272Some text after.";
273        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
274        let result = rule.check(&ctx).unwrap();
275
276        assert_eq!(result.len(), 1);
277        assert_eq!(result[0].line, 3);
278        assert!(result[0].message.contains("after horizontal rule"));
279    }
280
281    #[test]
282    fn test_hr_missing_both_blanks() {
283        let rule = MD065BlanksAroundHorizontalRules;
284        // Use *** which cannot be a setext heading marker
285        let content = "Some text before.
286***
287Some text after.";
288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
289        let result = rule.check(&ctx).unwrap();
290
291        assert_eq!(result.len(), 2);
292        assert!(result[0].message.contains("before horizontal rule"));
293        assert!(result[1].message.contains("after horizontal rule"));
294    }
295
296    #[test]
297    fn test_hr_at_start_of_document() {
298        let rule = MD065BlanksAroundHorizontalRules;
299        let content = "---
300
301Some text after.";
302        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
303        let result = rule.check(&ctx).unwrap();
304
305        // No blank line needed before HR at start of document
306        assert!(result.is_empty());
307    }
308
309    #[test]
310    fn test_hr_at_end_of_document() {
311        let rule = MD065BlanksAroundHorizontalRules;
312        let content = "Some text before.
313
314---";
315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
316        let result = rule.check(&ctx).unwrap();
317
318        // No blank line needed after HR at end of document
319        assert!(result.is_empty());
320    }
321
322    #[test]
323    fn test_multiple_hrs() {
324        let rule = MD065BlanksAroundHorizontalRules;
325        // Use *** and ___ which cannot be setext heading markers
326        let content = "Text before.
327***
328Middle text.
329___
330Text after.";
331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332        let result = rule.check(&ctx).unwrap();
333
334        assert_eq!(result.len(), 4);
335    }
336
337    #[test]
338    fn test_hr_asterisks() {
339        let rule = MD065BlanksAroundHorizontalRules;
340        let content = "Some text.
341***
342More text.";
343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
344        let result = rule.check(&ctx).unwrap();
345
346        assert_eq!(result.len(), 2);
347    }
348
349    #[test]
350    fn test_hr_underscores() {
351        let rule = MD065BlanksAroundHorizontalRules;
352        let content = "Some text.
353___
354More text.";
355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356        let result = rule.check(&ctx).unwrap();
357
358        assert_eq!(result.len(), 2);
359    }
360
361    #[test]
362    fn test_hr_with_spaces() {
363        let rule = MD065BlanksAroundHorizontalRules;
364        // Use * * * which cannot be a setext heading marker
365        let content = "Some text.
366* * *
367More text.";
368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
369        let result = rule.check(&ctx).unwrap();
370
371        assert_eq!(result.len(), 2);
372    }
373
374    #[test]
375    fn test_hr_long() {
376        let rule = MD065BlanksAroundHorizontalRules;
377        // Use asterisks which cannot be a setext heading marker
378        let content = "Some text.
379**********
380More text.";
381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
382        let result = rule.check(&ctx).unwrap();
383
384        assert_eq!(result.len(), 2);
385    }
386
387    #[test]
388    fn test_setext_heading_not_hr() {
389        let rule = MD065BlanksAroundHorizontalRules;
390        let content = "Heading
391---
392
393More text.";
394        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
395        let result = rule.check(&ctx).unwrap();
396
397        // Should not flag setext heading marker as HR
398        assert!(result.is_empty());
399    }
400
401    #[test]
402    fn test_setext_heading_equals() {
403        let rule = MD065BlanksAroundHorizontalRules;
404        let content = "Heading
405===
406
407More text.";
408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409        let result = rule.check(&ctx).unwrap();
410
411        // === is not a valid HR, only setext heading
412        assert!(result.is_empty());
413    }
414
415    #[test]
416    fn test_hr_in_code_block() {
417        let rule = MD065BlanksAroundHorizontalRules;
418        let content = "Some text.
419
420```
421---
422```
423
424More text.";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let result = rule.check(&ctx).unwrap();
427
428        // HR in code block should be ignored
429        assert!(result.is_empty());
430    }
431
432    #[test]
433    fn test_fix_missing_blanks() {
434        let rule = MD065BlanksAroundHorizontalRules;
435        // Use *** which cannot be a setext heading marker
436        let content = "Text before.
437***
438Text after.";
439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440        let fixed = rule.fix(&ctx).unwrap();
441
442        let expected = "Text before.
443
444***
445
446Text after.";
447        assert_eq!(fixed, expected);
448    }
449
450    #[test]
451    fn test_fix_multiple_hrs() {
452        let rule = MD065BlanksAroundHorizontalRules;
453        // Use *** and ___ which cannot be setext heading markers
454        let content = "Start
455***
456Middle
457___
458End";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let fixed = rule.fix(&ctx).unwrap();
461
462        let expected = "Start
463
464***
465
466Middle
467
468___
469
470End";
471        assert_eq!(fixed, expected);
472    }
473
474    #[test]
475    fn test_empty_content() {
476        let rule = MD065BlanksAroundHorizontalRules;
477        let content = "";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480
481        assert!(result.is_empty());
482    }
483
484    #[test]
485    fn test_no_hrs() {
486        let rule = MD065BlanksAroundHorizontalRules;
487        let content = "Just regular text.
488No horizontal rules here.
489Only paragraphs.";
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491        let result = rule.check(&ctx).unwrap();
492
493        assert!(result.is_empty());
494    }
495
496    #[test]
497    fn test_is_horizontal_rule() {
498        use crate::lint_context::is_horizontal_rule_line;
499
500        // Valid horizontal rules
501        assert!(is_horizontal_rule_line("---"));
502        assert!(is_horizontal_rule_line("----"));
503        assert!(is_horizontal_rule_line("***"));
504        assert!(is_horizontal_rule_line("****"));
505        assert!(is_horizontal_rule_line("___"));
506        assert!(is_horizontal_rule_line("____"));
507        assert!(is_horizontal_rule_line("- - -"));
508        assert!(is_horizontal_rule_line("* * *"));
509        assert!(is_horizontal_rule_line("_ _ _"));
510        assert!(is_horizontal_rule_line("  ---  "));
511
512        // Invalid horizontal rules
513        assert!(!is_horizontal_rule_line("--"));
514        assert!(!is_horizontal_rule_line("**"));
515        assert!(!is_horizontal_rule_line("__"));
516        assert!(!is_horizontal_rule_line("- -"));
517        assert!(!is_horizontal_rule_line("text"));
518        assert!(!is_horizontal_rule_line(""));
519        assert!(!is_horizontal_rule_line("==="));
520    }
521
522    #[test]
523    fn test_consecutive_hrs_with_blanks() {
524        let rule = MD065BlanksAroundHorizontalRules;
525        let content = "Text.
526
527---
528
529***
530
531More text.";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534
535        // Both HRs have proper blank lines
536        assert!(result.is_empty());
537    }
538
539    #[test]
540    fn test_hr_after_heading() {
541        let rule = MD065BlanksAroundHorizontalRules;
542        // Use *** which cannot be a setext heading marker
543        let content = "# Heading
544***
545
546Text.";
547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548        let result = rule.check(&ctx).unwrap();
549
550        // HR after heading needs blank line before
551        assert_eq!(result.len(), 1);
552        assert!(result[0].message.contains("before horizontal rule"));
553    }
554
555    #[test]
556    fn test_hr_before_heading() {
557        let rule = MD065BlanksAroundHorizontalRules;
558        let content = "Text.
559
560***
561# Heading";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563        let result = rule.check(&ctx).unwrap();
564
565        // HR before heading needs blank line after
566        assert_eq!(result.len(), 1);
567        assert!(result[0].message.contains("after horizontal rule"));
568    }
569
570    #[test]
571    fn test_setext_heading_hyphen_not_flagged() {
572        let rule = MD065BlanksAroundHorizontalRules;
573        // --- immediately after text is a setext heading, not HR
574        let content = "Heading Text
575---
576
577More text.";
578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579        let result = rule.check(&ctx).unwrap();
580
581        // Should not flag setext heading as missing blank lines
582        assert!(result.is_empty());
583    }
584
585    #[test]
586    fn test_hr_with_blank_before_hyphen() {
587        let rule = MD065BlanksAroundHorizontalRules;
588        // --- after a blank line IS a horizontal rule, not setext heading
589        let content = "Some text.
590
591---
592More text.";
593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594        let result = rule.check(&ctx).unwrap();
595
596        // Should flag missing blank line after
597        assert_eq!(result.len(), 1);
598        assert!(result[0].message.contains("after horizontal rule"));
599    }
600
601    // ============================================================
602    // Additional comprehensive tests for edge cases
603    // ============================================================
604
605    #[test]
606    fn test_frontmatter_not_flagged() {
607        let rule = MD065BlanksAroundHorizontalRules;
608        // YAML frontmatter uses --- delimiters which should NOT be flagged
609        let content = "---
610title: Test Document
611date: 2024-01-01
612---
613
614# Heading
615
616Content here.";
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618        let result = rule.check(&ctx).unwrap();
619
620        // Frontmatter delimiters should not be flagged as HRs
621        assert!(result.is_empty());
622    }
623
624    #[test]
625    fn test_hr_after_frontmatter() {
626        let rule = MD065BlanksAroundHorizontalRules;
627        let content = "---
628title: Test
629---
630
631Content.
632***
633More content.";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let result = rule.check(&ctx).unwrap();
636
637        // HR after frontmatter content should be flagged
638        assert_eq!(result.len(), 2);
639    }
640
641    #[test]
642    fn test_hr_in_indented_code_block() {
643        let rule = MD065BlanksAroundHorizontalRules;
644        // 4-space indented code block
645        let content = "Some text.
646
647    ---
648    code here
649
650More text.";
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652        let result = rule.check(&ctx).unwrap();
653
654        // HR in indented code block should be ignored
655        assert!(result.is_empty());
656    }
657
658    #[test]
659    fn test_hr_with_leading_spaces() {
660        let rule = MD065BlanksAroundHorizontalRules;
661        // 1-3 spaces of indentation is still a valid HR
662        let content = "Text.
663   ***
664More text.";
665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666        let result = rule.check(&ctx).unwrap();
667
668        // Indented HR (1-3 spaces) should be detected
669        assert_eq!(result.len(), 2);
670    }
671
672    #[test]
673    fn test_hr_in_html_comment() {
674        let rule = MD065BlanksAroundHorizontalRules;
675        let content = "Text.
676
677<!--
678---
679-->
680
681More text.";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684
685        // HR inside HTML comment should be ignored
686        assert!(result.is_empty());
687    }
688
689    #[test]
690    fn test_hr_in_blockquote() {
691        let rule = MD065BlanksAroundHorizontalRules;
692        let content = "Text.
693
694> Quote text
695> ***
696> More quote
697
698After quote.";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.check(&ctx).unwrap();
701
702        // HR inside blockquote - the "> ***" line contains a valid HR pattern
703        // but within blockquote context. This tests blockquote awareness.
704        // Note: blockquotes don't skip HR detection, so this may flag.
705        // The actual behavior depends on implementation.
706        assert!(result.len() <= 2); // May or may not flag based on blockquote handling
707    }
708
709    #[test]
710    fn test_hr_after_list() {
711        let rule = MD065BlanksAroundHorizontalRules;
712        // Real-world case from Node.js repo
713        let content = "* Item one
714* Item two
715***
716
717More text.";
718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719        let result = rule.check(&ctx).unwrap();
720
721        // HR immediately after list should be flagged
722        assert_eq!(result.len(), 1);
723        assert!(result[0].message.contains("before horizontal rule"));
724    }
725
726    #[test]
727    fn test_mixed_marker_with_many_spaces() {
728        let rule = MD065BlanksAroundHorizontalRules;
729        let content = "Text.
730-  -  -  -
731More text.";
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733        let result = rule.check(&ctx).unwrap();
734
735        // HR with multiple spaces between markers
736        assert_eq!(result.len(), 2);
737    }
738
739    #[test]
740    fn test_only_hr_in_document() {
741        let rule = MD065BlanksAroundHorizontalRules;
742        let content = "---";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let result = rule.check(&ctx).unwrap();
745
746        // Single HR alone in document - no blanks needed
747        assert!(result.is_empty());
748    }
749
750    #[test]
751    fn test_multiple_blank_lines_already_present() {
752        let rule = MD065BlanksAroundHorizontalRules;
753        let content = "Text.
754
755
756---
757
758
759More text.";
760        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761        let result = rule.check(&ctx).unwrap();
762
763        // Multiple blank lines should not trigger warnings
764        assert!(result.is_empty());
765    }
766
767    #[test]
768    fn test_hr_at_both_start_and_end() {
769        let rule = MD065BlanksAroundHorizontalRules;
770        let content = "---
771
772Content in the middle.
773
774---";
775        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776        let result = rule.check(&ctx).unwrap();
777
778        // HRs at start and end with proper spacing
779        assert!(result.is_empty());
780    }
781
782    #[test]
783    fn test_consecutive_hrs_without_blanks() {
784        let rule = MD065BlanksAroundHorizontalRules;
785        let content = "Text.
786
787***
788---
789___
790
791More text.";
792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793        let result = rule.check(&ctx).unwrap();
794
795        // Consecutive HRs need blanks between them
796        // --- after *** is also an HR (not setext), since *** is an HR not text
797        assert!(result.len() >= 2);
798    }
799
800    #[test]
801    fn test_hr_after_hr_not_setext() {
802        // Regression: --- after *** should be treated as HR, not setext heading
803        let rule = MD065BlanksAroundHorizontalRules;
804        let content = "***\n---\n# ";
805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806
807        // Both *** and --- are HRs, both need blanks
808        let fixed = rule.fix(&ctx).unwrap();
809        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
810        let fixed2 = rule.fix(&ctx2).unwrap();
811        assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
812    }
813
814    #[test]
815    fn test_fix_idempotency() {
816        let rule = MD065BlanksAroundHorizontalRules;
817        let content = "Text before.
818***
819Text after.";
820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821        let fixed_once = rule.fix(&ctx).unwrap();
822
823        // Apply fix again
824        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
825        let fixed_twice = rule.fix(&ctx2).unwrap();
826
827        // Second fix should not change anything
828        assert_eq!(fixed_once, fixed_twice);
829    }
830
831    #[test]
832    fn test_setext_heading_long_underline() {
833        let rule = MD065BlanksAroundHorizontalRules;
834        let content = "Heading Text
835----------
836
837More text.";
838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let result = rule.check(&ctx).unwrap();
840
841        // Long underline is still setext heading, not HR
842        assert!(result.is_empty());
843    }
844
845    #[test]
846    fn test_hr_with_trailing_whitespace() {
847        let rule = MD065BlanksAroundHorizontalRules;
848        let content = "Text.
849***
850More text.";
851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852        let result = rule.check(&ctx).unwrap();
853
854        // HR with trailing whitespace should still be detected
855        assert_eq!(result.len(), 2);
856    }
857
858    #[test]
859    fn test_hr_in_html_block() {
860        let rule = MD065BlanksAroundHorizontalRules;
861        let content = "Text.
862
863<div>
864---
865</div>
866
867More text.";
868        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869        let result = rule.check(&ctx).unwrap();
870
871        // HR inside HTML block should be ignored (depends on HTML block detection)
872        // This tests HTML block awareness
873        assert!(result.is_empty());
874    }
875
876    #[test]
877    fn test_spaced_hyphens_are_hr_not_setext() {
878        let rule = MD065BlanksAroundHorizontalRules;
879        // CommonMark: setext underlines cannot have internal spaces
880        // So "- - -" is a thematic break, not a setext heading
881        let content = "Heading
882- - -
883
884More text.";
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886        let result = rule.check(&ctx).unwrap();
887
888        // "- - -" with internal spaces is HR, needs blank before
889        assert_eq!(result.len(), 1);
890        assert!(result[0].message.contains("before horizontal rule"));
891    }
892
893    #[test]
894    fn test_not_setext_if_prev_line_blank() {
895        let rule = MD065BlanksAroundHorizontalRules;
896        let content = "Some paragraph.
897
898---
899Text after.";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901        let result = rule.check(&ctx).unwrap();
902
903        // --- after blank line is HR, not setext heading
904        assert_eq!(result.len(), 1);
905        assert!(result[0].message.contains("after horizontal rule"));
906    }
907
908    #[test]
909    fn test_asterisk_cannot_be_setext() {
910        let rule = MD065BlanksAroundHorizontalRules;
911        // *** immediately after text is still HR (asterisks can't be setext markers)
912        let content = "Some text
913***
914More text.";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let result = rule.check(&ctx).unwrap();
917
918        // *** is always HR, never setext
919        assert_eq!(result.len(), 2);
920    }
921
922    #[test]
923    fn test_underscore_cannot_be_setext() {
924        let rule = MD065BlanksAroundHorizontalRules;
925        // ___ immediately after text is still HR (underscores can't be setext markers)
926        let content = "Some text
927___
928More text.";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931
932        // ___ is always HR, never setext
933        assert_eq!(result.len(), 2);
934    }
935
936    #[test]
937    fn test_fix_preserves_content() {
938        let rule = MD065BlanksAroundHorizontalRules;
939        let content = "First paragraph with **bold** and *italic*.
940***
941Second paragraph with [link](url) and `code`.";
942        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943        let fixed = rule.fix(&ctx).unwrap();
944
945        // Verify content is preserved
946        assert!(fixed.contains("**bold**"));
947        assert!(fixed.contains("*italic*"));
948        assert!(fixed.contains("[link](url)"));
949        assert!(fixed.contains("`code`"));
950        assert!(fixed.contains("***"));
951    }
952
953    #[test]
954    fn test_fix_only_adds_needed_blanks() {
955        let rule = MD065BlanksAroundHorizontalRules;
956        // Already has blank before, missing blank after
957        let content = "Text.
958
959***
960More text.";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962        let fixed = rule.fix(&ctx).unwrap();
963
964        let expected = "Text.
965
966***
967
968More text.";
969        assert_eq!(fixed, expected);
970    }
971
972    #[test]
973    fn test_hr_detection_edge_cases() {
974        use crate::lint_context::is_horizontal_rule_line;
975
976        // Valid HRs with various spacing (0-3 leading spaces allowed)
977        assert!(is_horizontal_rule_line("   ---"));
978        assert!(is_horizontal_rule_line("---   "));
979        assert!(is_horizontal_rule_line("   ---   "));
980        assert!(is_horizontal_rule_line("*  *  *"));
981        assert!(is_horizontal_rule_line("_    _    _"));
982
983        // Invalid patterns
984        assert!(!is_horizontal_rule_line("--a"));
985        assert!(!is_horizontal_rule_line("**a"));
986        assert!(!is_horizontal_rule_line("-*-"));
987        assert!(!is_horizontal_rule_line("- * _"));
988        assert!(!is_horizontal_rule_line("   "));
989        assert!(!is_horizontal_rule_line("\t---")); // Tabs not allowed per CommonMark
990    }
991
992    #[test]
993    fn test_warning_line_numbers_accurate() {
994        let rule = MD065BlanksAroundHorizontalRules;
995        let content = "Line 1
996Line 2
997***
998Line 4";
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000        let result = rule.check(&ctx).unwrap();
1001
1002        // Verify line numbers are 1-indexed and accurate
1003        assert_eq!(result.len(), 2);
1004        assert_eq!(result[0].line, 3); // HR is on line 3
1005        assert_eq!(result[1].line, 3);
1006    }
1007
1008    #[test]
1009    fn test_complex_document_structure() {
1010        let rule = MD065BlanksAroundHorizontalRules;
1011        let content = "# Main Title
1012
1013Introduction paragraph.
1014
1015## Section One
1016
1017Content here.
1018
1019***
1020
1021## Section Two
1022
1023More content.
1024
1025---
1026
1027Final thoughts.";
1028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029        let result = rule.check(&ctx).unwrap();
1030
1031        // Well-structured document should have no warnings
1032        assert!(result.is_empty());
1033    }
1034
1035    #[test]
1036    fn test_fix_preserves_blockquote_prefix_before_hr() {
1037        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
1038        let rule = MD065BlanksAroundHorizontalRules;
1039
1040        let content = "> Text before
1041> ***
1042> Text after";
1043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044        let fixed = rule.fix(&ctx).unwrap();
1045
1046        // The blank lines inserted should have the blockquote prefix
1047        let expected = "> Text before
1048>
1049> ***
1050>
1051> Text after";
1052        assert_eq!(
1053            fixed, expected,
1054            "Fix should insert '>' blank lines around HR, not plain blank lines"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1060        // Nested blockquotes should preserve the full prefix
1061        let rule = MD065BlanksAroundHorizontalRules;
1062
1063        let content = ">> Nested quote
1064>> ---
1065>> More text";
1066        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067        let fixed = rule.fix(&ctx).unwrap();
1068
1069        // Should insert ">>" blank lines
1070        let expected = ">> Nested quote
1071>>
1072>> ---
1073>>
1074>> More text";
1075        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1076    }
1077
1078    #[test]
1079    fn test_fix_preserves_blockquote_prefix_after_hr() {
1080        // Issue #268: Fix should insert blockquote-prefixed blank lines after HR
1081        let rule = MD065BlanksAroundHorizontalRules;
1082
1083        let content = "> ---
1084> Text after";
1085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086        let fixed = rule.fix(&ctx).unwrap();
1087
1088        // The blank line inserted after the HR should have the blockquote prefix
1089        let expected = "> ---
1090>
1091> Text after";
1092        assert_eq!(
1093            fixed, expected,
1094            "Fix should insert '>' blank line after HR, not plain blank line"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1100        // Triple-nested blockquotes should preserve full prefix
1101        let rule = MD065BlanksAroundHorizontalRules;
1102
1103        let content = ">>> Triple nested
1104>>> ---
1105>>> More text";
1106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107        let fixed = rule.fix(&ctx).unwrap();
1108
1109        let expected = ">>> Triple nested
1110>>>
1111>>> ---
1112>>>
1113>>> More text";
1114        assert_eq!(
1115            fixed, expected,
1116            "Fix should preserve triple-nested blockquote prefix '>>>'"
1117        );
1118    }
1119
1120    #[test]
1121    fn test_fix_preserves_trailing_newline() {
1122        let rule = MD065BlanksAroundHorizontalRules;
1123
1124        let content = "Text\n***\nMore text\n";
1125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126        let fixed = rule.fix(&ctx).unwrap();
1127
1128        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
1129        assert_eq!(fixed, "Text\n\n***\n\nMore text\n");
1130    }
1131
1132    #[test]
1133    fn test_fix_preserves_no_trailing_newline() {
1134        let rule = MD065BlanksAroundHorizontalRules;
1135
1136        let content = "Text\n***\nMore text";
1137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138        let fixed = rule.fix(&ctx).unwrap();
1139
1140        assert!(
1141            !fixed.ends_with('\n'),
1142            "Fix should not add trailing newline if original didn't have one"
1143        );
1144        assert_eq!(fixed, "Text\n\n***\n\nMore text");
1145    }
1146}