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