rumdl_lib/rules/
md065_blanks_around_horizontal_rules.rs

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