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