Skip to main content

rumdl_lib/rules/
md065_blanks_around_horizontal_rules.rs

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