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