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