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 line_index = &ctx.line_index;
115        let mut warnings = Vec::new();
116
117        if content.is_empty() {
118            return Ok(Vec::new());
119        }
120
121        let lines = ctx.raw_lines();
122
123        for (i, line_info) in ctx.lines.iter().enumerate() {
124            // Use pre-computed is_horizontal_rule from LineInfo
125            // This already excludes code blocks, frontmatter, and does proper HR detection
126            if !line_info.is_horizontal_rule {
127                continue;
128            }
129
130            // Skip if this is actually a setext heading marker
131            if Self::is_setext_heading_marker(lines, i) {
132                continue;
133            }
134
135            // Check for blank line before HR (unless at start of document)
136            if i > 0 && Self::count_blank_lines_before(lines, i) == 0 {
137                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
138                warnings.push(LintWarning {
139                    rule_name: Some(self.name().to_string()),
140                    message: "Missing blank line before horizontal rule".to_string(),
141                    line: i + 1,
142                    column: 1,
143                    end_line: i + 1,
144                    end_column: 2,
145                    severity: Severity::Warning,
146                    fix: Some(Fix::new(
147                        line_index.line_col_to_byte_range(i + 1, 1),
148                        format!("{bq_prefix}\n"),
149                    )),
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                        line_index.line_col_to_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_hr_in_code_block() {
420        let rule = MD065BlanksAroundHorizontalRules;
421        let content = "Some text.
422
423```
424---
425```
426
427More text.";
428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429        let result = rule.check(&ctx).unwrap();
430
431        // HR in code block should be ignored
432        assert!(result.is_empty());
433    }
434
435    #[test]
436    fn test_fix_missing_blanks() {
437        let rule = MD065BlanksAroundHorizontalRules;
438        // Use *** which cannot be a setext heading marker
439        let content = "Text before.
440***
441Text after.";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let fixed = rule.fix(&ctx).unwrap();
444
445        let expected = "Text before.
446
447***
448
449Text after.";
450        assert_eq!(fixed, expected);
451    }
452
453    #[test]
454    fn test_fix_multiple_hrs() {
455        let rule = MD065BlanksAroundHorizontalRules;
456        // Use *** and ___ which cannot be setext heading markers
457        let content = "Start
458***
459Middle
460___
461End";
462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463        let fixed = rule.fix(&ctx).unwrap();
464
465        let expected = "Start
466
467***
468
469Middle
470
471___
472
473End";
474        assert_eq!(fixed, expected);
475    }
476
477    #[test]
478    fn test_empty_content() {
479        let rule = MD065BlanksAroundHorizontalRules;
480        let content = "";
481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482        let result = rule.check(&ctx).unwrap();
483
484        assert!(result.is_empty());
485    }
486
487    #[test]
488    fn test_no_hrs() {
489        let rule = MD065BlanksAroundHorizontalRules;
490        let content = "Just regular text.
491No horizontal rules here.
492Only paragraphs.";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494        let result = rule.check(&ctx).unwrap();
495
496        assert!(result.is_empty());
497    }
498
499    #[test]
500    fn test_is_horizontal_rule() {
501        use crate::lint_context::is_horizontal_rule_line;
502
503        // Valid horizontal rules
504        assert!(is_horizontal_rule_line("---"));
505        assert!(is_horizontal_rule_line("----"));
506        assert!(is_horizontal_rule_line("***"));
507        assert!(is_horizontal_rule_line("****"));
508        assert!(is_horizontal_rule_line("___"));
509        assert!(is_horizontal_rule_line("____"));
510        assert!(is_horizontal_rule_line("- - -"));
511        assert!(is_horizontal_rule_line("* * *"));
512        assert!(is_horizontal_rule_line("_ _ _"));
513        assert!(is_horizontal_rule_line("  ---  "));
514
515        // Invalid horizontal rules
516        assert!(!is_horizontal_rule_line("--"));
517        assert!(!is_horizontal_rule_line("**"));
518        assert!(!is_horizontal_rule_line("__"));
519        assert!(!is_horizontal_rule_line("- -"));
520        assert!(!is_horizontal_rule_line("text"));
521        assert!(!is_horizontal_rule_line(""));
522        assert!(!is_horizontal_rule_line("==="));
523    }
524
525    #[test]
526    fn test_consecutive_hrs_with_blanks() {
527        let rule = MD065BlanksAroundHorizontalRules;
528        let content = "Text.
529
530---
531
532***
533
534More text.";
535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536        let result = rule.check(&ctx).unwrap();
537
538        // Both HRs have proper blank lines
539        assert!(result.is_empty());
540    }
541
542    #[test]
543    fn test_hr_after_heading() {
544        let rule = MD065BlanksAroundHorizontalRules;
545        // Use *** which cannot be a setext heading marker
546        let content = "# Heading
547***
548
549Text.";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        let result = rule.check(&ctx).unwrap();
552
553        // HR after heading needs blank line before
554        assert_eq!(result.len(), 1);
555        assert!(result[0].message.contains("before horizontal rule"));
556    }
557
558    #[test]
559    fn test_hr_before_heading() {
560        let rule = MD065BlanksAroundHorizontalRules;
561        let content = "Text.
562
563***
564# Heading";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566        let result = rule.check(&ctx).unwrap();
567
568        // HR before heading needs blank line after
569        assert_eq!(result.len(), 1);
570        assert!(result[0].message.contains("after horizontal rule"));
571    }
572
573    #[test]
574    fn test_setext_heading_hyphen_not_flagged() {
575        let rule = MD065BlanksAroundHorizontalRules;
576        // --- immediately after text is a setext heading, not HR
577        let content = "Heading Text
578---
579
580More text.";
581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582        let result = rule.check(&ctx).unwrap();
583
584        // Should not flag setext heading as missing blank lines
585        assert!(result.is_empty());
586    }
587
588    #[test]
589    fn test_hr_with_blank_before_hyphen() {
590        let rule = MD065BlanksAroundHorizontalRules;
591        // --- after a blank line IS a horizontal rule, not setext heading
592        let content = "Some text.
593
594---
595More text.";
596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597        let result = rule.check(&ctx).unwrap();
598
599        // Should flag missing blank line after
600        assert_eq!(result.len(), 1);
601        assert!(result[0].message.contains("after horizontal rule"));
602    }
603
604    // ============================================================
605    // Additional comprehensive tests for edge cases
606    // ============================================================
607
608    #[test]
609    fn test_frontmatter_not_flagged() {
610        let rule = MD065BlanksAroundHorizontalRules;
611        // YAML frontmatter uses --- delimiters which should NOT be flagged
612        let content = "---
613title: Test Document
614date: 2024-01-01
615---
616
617# Heading
618
619Content here.";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621        let result = rule.check(&ctx).unwrap();
622
623        // Frontmatter delimiters should not be flagged as HRs
624        assert!(result.is_empty());
625    }
626
627    #[test]
628    fn test_hr_after_frontmatter() {
629        let rule = MD065BlanksAroundHorizontalRules;
630        let content = "---
631title: Test
632---
633
634Content.
635***
636More content.";
637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638        let result = rule.check(&ctx).unwrap();
639
640        // HR after frontmatter content should be flagged
641        assert_eq!(result.len(), 2);
642    }
643
644    #[test]
645    fn test_hr_in_indented_code_block() {
646        let rule = MD065BlanksAroundHorizontalRules;
647        // 4-space indented code block
648        let content = "Some text.
649
650    ---
651    code here
652
653More text.";
654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655        let result = rule.check(&ctx).unwrap();
656
657        // HR in indented code block should be ignored
658        assert!(result.is_empty());
659    }
660
661    #[test]
662    fn test_hr_with_leading_spaces() {
663        let rule = MD065BlanksAroundHorizontalRules;
664        // 1-3 spaces of indentation is still a valid HR
665        let content = "Text.
666   ***
667More text.";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670
671        // Indented HR (1-3 spaces) should be detected
672        assert_eq!(result.len(), 2);
673    }
674
675    #[test]
676    fn test_hr_in_html_comment() {
677        let rule = MD065BlanksAroundHorizontalRules;
678        let content = "Text.
679
680<!--
681---
682-->
683
684More text.";
685        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686        let result = rule.check(&ctx).unwrap();
687
688        // HR inside HTML comment should be ignored
689        assert!(result.is_empty());
690    }
691
692    #[test]
693    fn test_hr_in_blockquote() {
694        let rule = MD065BlanksAroundHorizontalRules;
695        let content = "Text.
696
697> Quote text
698> ***
699> More quote
700
701After quote.";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let result = rule.check(&ctx).unwrap();
704
705        // HR inside blockquote - the "> ***" line contains a valid HR pattern
706        // but within blockquote context. This tests blockquote awareness.
707        // Note: blockquotes don't skip HR detection, so this may flag.
708        // The actual behavior depends on implementation.
709        assert!(result.len() <= 2); // May or may not flag based on blockquote handling
710    }
711
712    #[test]
713    fn test_hr_after_list() {
714        let rule = MD065BlanksAroundHorizontalRules;
715        // Real-world case from Node.js repo
716        let content = "* Item one
717* Item two
718***
719
720More text.";
721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722        let result = rule.check(&ctx).unwrap();
723
724        // HR immediately after list should be flagged
725        assert_eq!(result.len(), 1);
726        assert!(result[0].message.contains("before horizontal rule"));
727    }
728
729    #[test]
730    fn test_mixed_marker_with_many_spaces() {
731        let rule = MD065BlanksAroundHorizontalRules;
732        let content = "Text.
733-  -  -  -
734More text.";
735        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736        let result = rule.check(&ctx).unwrap();
737
738        // HR with multiple spaces between markers
739        assert_eq!(result.len(), 2);
740    }
741
742    #[test]
743    fn test_only_hr_in_document() {
744        let rule = MD065BlanksAroundHorizontalRules;
745        let content = "---";
746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747        let result = rule.check(&ctx).unwrap();
748
749        // Single HR alone in document - no blanks needed
750        assert!(result.is_empty());
751    }
752
753    #[test]
754    fn test_multiple_blank_lines_already_present() {
755        let rule = MD065BlanksAroundHorizontalRules;
756        let content = "Text.
757
758
759---
760
761
762More text.";
763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764        let result = rule.check(&ctx).unwrap();
765
766        // Multiple blank lines should not trigger warnings
767        assert!(result.is_empty());
768    }
769
770    #[test]
771    fn test_hr_at_both_start_and_end() {
772        let rule = MD065BlanksAroundHorizontalRules;
773        let content = "---
774
775Content in the middle.
776
777---";
778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779        let result = rule.check(&ctx).unwrap();
780
781        // HRs at start and end with proper spacing
782        assert!(result.is_empty());
783    }
784
785    #[test]
786    fn test_consecutive_hrs_without_blanks() {
787        let rule = MD065BlanksAroundHorizontalRules;
788        let content = "Text.
789
790***
791---
792___
793
794More text.";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796        let result = rule.check(&ctx).unwrap();
797
798        // Consecutive HRs need blanks between them
799        // --- after *** is also an HR (not setext), since *** is an HR not text
800        assert!(result.len() >= 2);
801    }
802
803    #[test]
804    fn test_hr_after_hr_not_setext() {
805        // Regression: --- after *** should be treated as HR, not setext heading
806        let rule = MD065BlanksAroundHorizontalRules;
807        let content = "***\n---\n# ";
808        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809
810        // Both *** and --- are HRs, both need blanks
811        let fixed = rule.fix(&ctx).unwrap();
812        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
813        let fixed2 = rule.fix(&ctx2).unwrap();
814        assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
815    }
816
817    #[test]
818    fn test_fix_idempotency() {
819        let rule = MD065BlanksAroundHorizontalRules;
820        let content = "Text before.
821***
822Text after.";
823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824        let fixed_once = rule.fix(&ctx).unwrap();
825
826        // Apply fix again
827        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
828        let fixed_twice = rule.fix(&ctx2).unwrap();
829
830        // Second fix should not change anything
831        assert_eq!(fixed_once, fixed_twice);
832    }
833
834    #[test]
835    fn test_setext_heading_long_underline() {
836        let rule = MD065BlanksAroundHorizontalRules;
837        let content = "Heading Text
838----------
839
840More text.";
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842        let result = rule.check(&ctx).unwrap();
843
844        // Long underline is still setext heading, not HR
845        assert!(result.is_empty());
846    }
847
848    #[test]
849    fn test_hr_with_trailing_whitespace() {
850        let rule = MD065BlanksAroundHorizontalRules;
851        let content = "Text.
852***
853More text.";
854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855        let result = rule.check(&ctx).unwrap();
856
857        // HR with trailing whitespace should still be detected
858        assert_eq!(result.len(), 2);
859    }
860
861    #[test]
862    fn test_hr_in_html_block() {
863        let rule = MD065BlanksAroundHorizontalRules;
864        let content = "Text.
865
866<div>
867---
868</div>
869
870More text.";
871        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872        let result = rule.check(&ctx).unwrap();
873
874        // HR inside HTML block should be ignored (depends on HTML block detection)
875        // This tests HTML block awareness
876        assert!(result.is_empty());
877    }
878
879    #[test]
880    fn test_spaced_hyphens_are_hr_not_setext() {
881        let rule = MD065BlanksAroundHorizontalRules;
882        // CommonMark: setext underlines cannot have internal spaces
883        // So "- - -" is a thematic break, not a setext heading
884        let content = "Heading
885- - -
886
887More text.";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890
891        // "- - -" with internal spaces is HR, needs blank before
892        assert_eq!(result.len(), 1);
893        assert!(result[0].message.contains("before horizontal rule"));
894    }
895
896    #[test]
897    fn test_not_setext_if_prev_line_blank() {
898        let rule = MD065BlanksAroundHorizontalRules;
899        let content = "Some paragraph.
900
901---
902Text after.";
903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904        let result = rule.check(&ctx).unwrap();
905
906        // --- after blank line is HR, not setext heading
907        assert_eq!(result.len(), 1);
908        assert!(result[0].message.contains("after horizontal rule"));
909    }
910
911    #[test]
912    fn test_asterisk_cannot_be_setext() {
913        let rule = MD065BlanksAroundHorizontalRules;
914        // *** immediately after text is still HR (asterisks can't be setext markers)
915        let content = "Some text
916***
917More text.";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920
921        // *** is always HR, never setext
922        assert_eq!(result.len(), 2);
923    }
924
925    #[test]
926    fn test_underscore_cannot_be_setext() {
927        let rule = MD065BlanksAroundHorizontalRules;
928        // ___ immediately after text is still HR (underscores can't be setext markers)
929        let content = "Some text
930___
931More text.";
932        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933        let result = rule.check(&ctx).unwrap();
934
935        // ___ is always HR, never setext
936        assert_eq!(result.len(), 2);
937    }
938
939    #[test]
940    fn test_fix_preserves_content() {
941        let rule = MD065BlanksAroundHorizontalRules;
942        let content = "First paragraph with **bold** and *italic*.
943***
944Second paragraph with [link](url) and `code`.";
945        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946        let fixed = rule.fix(&ctx).unwrap();
947
948        // Verify content is preserved
949        assert!(fixed.contains("**bold**"));
950        assert!(fixed.contains("*italic*"));
951        assert!(fixed.contains("[link](url)"));
952        assert!(fixed.contains("`code`"));
953        assert!(fixed.contains("***"));
954    }
955
956    #[test]
957    fn test_fix_only_adds_needed_blanks() {
958        let rule = MD065BlanksAroundHorizontalRules;
959        // Already has blank before, missing blank after
960        let content = "Text.
961
962***
963More text.";
964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965        let fixed = rule.fix(&ctx).unwrap();
966
967        let expected = "Text.
968
969***
970
971More text.";
972        assert_eq!(fixed, expected);
973    }
974
975    #[test]
976    fn test_hr_detection_edge_cases() {
977        use crate::lint_context::is_horizontal_rule_line;
978
979        // Valid HRs with various spacing (0-3 leading spaces allowed)
980        assert!(is_horizontal_rule_line("   ---"));
981        assert!(is_horizontal_rule_line("---   "));
982        assert!(is_horizontal_rule_line("   ---   "));
983        assert!(is_horizontal_rule_line("*  *  *"));
984        assert!(is_horizontal_rule_line("_    _    _"));
985
986        // Invalid patterns
987        assert!(!is_horizontal_rule_line("--a"));
988        assert!(!is_horizontal_rule_line("**a"));
989        assert!(!is_horizontal_rule_line("-*-"));
990        assert!(!is_horizontal_rule_line("- * _"));
991        assert!(!is_horizontal_rule_line("   "));
992        assert!(!is_horizontal_rule_line("\t---")); // Tabs not allowed per CommonMark
993    }
994
995    #[test]
996    fn test_warning_line_numbers_accurate() {
997        let rule = MD065BlanksAroundHorizontalRules;
998        let content = "Line 1
999Line 2
1000***
1001Line 4";
1002        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003        let result = rule.check(&ctx).unwrap();
1004
1005        // Verify line numbers are 1-indexed and accurate
1006        assert_eq!(result.len(), 2);
1007        assert_eq!(result[0].line, 3); // HR is on line 3
1008        assert_eq!(result[1].line, 3);
1009    }
1010
1011    #[test]
1012    fn test_complex_document_structure() {
1013        let rule = MD065BlanksAroundHorizontalRules;
1014        let content = "# Main Title
1015
1016Introduction paragraph.
1017
1018## Section One
1019
1020Content here.
1021
1022***
1023
1024## Section Two
1025
1026More content.
1027
1028---
1029
1030Final thoughts.";
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032        let result = rule.check(&ctx).unwrap();
1033
1034        // Well-structured document should have no warnings
1035        assert!(result.is_empty());
1036    }
1037
1038    #[test]
1039    fn test_fix_preserves_blockquote_prefix_before_hr() {
1040        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
1041        let rule = MD065BlanksAroundHorizontalRules;
1042
1043        let content = "> Text before
1044> ***
1045> Text after";
1046        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047        let fixed = rule.fix(&ctx).unwrap();
1048
1049        // The blank lines inserted should have the blockquote prefix
1050        let expected = "> Text before
1051>
1052> ***
1053>
1054> Text after";
1055        assert_eq!(
1056            fixed, expected,
1057            "Fix should insert '>' blank lines around HR, not plain blank lines"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1063        // Nested blockquotes should preserve the full prefix
1064        let rule = MD065BlanksAroundHorizontalRules;
1065
1066        let content = ">> Nested quote
1067>> ---
1068>> More text";
1069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070        let fixed = rule.fix(&ctx).unwrap();
1071
1072        // Should insert ">>" blank lines
1073        let expected = ">> Nested quote
1074>>
1075>> ---
1076>>
1077>> More text";
1078        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1079    }
1080
1081    #[test]
1082    fn test_fix_preserves_blockquote_prefix_after_hr() {
1083        // Issue #268: Fix should insert blockquote-prefixed blank lines after HR
1084        let rule = MD065BlanksAroundHorizontalRules;
1085
1086        let content = "> ---
1087> Text after";
1088        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089        let fixed = rule.fix(&ctx).unwrap();
1090
1091        // The blank line inserted after the HR should have the blockquote prefix
1092        let expected = "> ---
1093>
1094> Text after";
1095        assert_eq!(
1096            fixed, expected,
1097            "Fix should insert '>' blank line after HR, not plain blank line"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1103        // Triple-nested blockquotes should preserve full prefix
1104        let rule = MD065BlanksAroundHorizontalRules;
1105
1106        let content = ">>> Triple nested
1107>>> ---
1108>>> More text";
1109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110        let fixed = rule.fix(&ctx).unwrap();
1111
1112        let expected = ">>> Triple nested
1113>>>
1114>>> ---
1115>>>
1116>>> More text";
1117        assert_eq!(
1118            fixed, expected,
1119            "Fix should preserve triple-nested blockquote prefix '>>>'"
1120        );
1121    }
1122
1123    #[test]
1124    fn test_fix_preserves_trailing_newline() {
1125        let rule = MD065BlanksAroundHorizontalRules;
1126
1127        let content = "Text\n***\nMore text\n";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129        let fixed = rule.fix(&ctx).unwrap();
1130
1131        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
1132        assert_eq!(fixed, "Text\n\n***\n\nMore text\n");
1133    }
1134
1135    #[test]
1136    fn spaced_thematic_break_is_not_skipped() {
1137        // `* * *`, `- - -` and `_ _ _` are thematic breaks that contain none of the
1138        // substrings a content scan looks for, so whether the rule ran at all used to
1139        // depend on how the break was written.
1140        let rule = MD065BlanksAroundHorizontalRules;
1141
1142        for content in [
1143            "Text\n* * *\nMore text\n",
1144            "Text\n- - -\nMore text\n",
1145            "Text\n_ _ _\nMore text\n",
1146        ] {
1147            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148            assert!(!rule.should_skip(&ctx), "the whole rule was skipped for {content:?}");
1149            assert_eq!(
1150                rule.check(&ctx).unwrap().len(),
1151                2,
1152                "both missing blank lines should be reported for {content:?}"
1153            );
1154        }
1155    }
1156
1157    #[test]
1158    fn a_document_with_no_thematic_break_is_still_skipped() {
1159        let rule = MD065BlanksAroundHorizontalRules;
1160
1161        for content in ["Text\nMore text\n", "- item\n- item\n", "```\n---\n```\n"] {
1162            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163            assert!(
1164                rule.should_skip(&ctx),
1165                "the rule should have been skipped for {content:?}"
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn markers_inside_a_hidden_block_are_not_spaced_out() {
1172        // The fix inserts blank lines around what it takes for a thematic break, so
1173        // reporting one that a comment hides or a math block owns rewrites the block
1174        // itself - a blank line in display math ends it.
1175        let rule = MD065BlanksAroundHorizontalRules;
1176
1177        for content in [
1178            "Text.\n\n<!--\n***\n-->\n\nMore.\n",
1179            "Text.\n\n$$\na = b\n***\nc = d\n$$\n\nMore.\n",
1180        ] {
1181            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182            assert!(
1183                rule.check(&ctx).unwrap().is_empty(),
1184                "the hidden markers were reported in {content:?}"
1185            );
1186            assert_eq!(
1187                rule.fix(&ctx).unwrap(),
1188                content,
1189                "the block was rewritten in {content:?}"
1190            );
1191        }
1192    }
1193
1194    #[test]
1195    fn test_fix_preserves_no_trailing_newline() {
1196        let rule = MD065BlanksAroundHorizontalRules;
1197
1198        let content = "Text\n***\nMore text";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let fixed = rule.fix(&ctx).unwrap();
1201
1202        assert!(
1203            !fixed.ends_with('\n'),
1204            "Fix should not add trailing newline if original didn't have one"
1205        );
1206        assert_eq!(fixed, "Text\n\n***\n\nMore text");
1207    }
1208}