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