Skip to main content

rumdl_lib/rules/
md065_blanks_around_horizontal_rules.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::fix_utils::apply_warning_fixes;
3
4use std::collections::HashSet;
5
6/// Rule MD065: Blanks around horizontal rules
7///
8/// See [docs/md065.md](../../docs/md065.md) for full documentation and examples.
9///
10/// Ensures horizontal rules have blank lines before and after them
11#[derive(Clone, Default)]
12pub struct MD065BlanksAroundHorizontalRules;
13
14/// What the parser says about the lines that look like thematic breaks.
15///
16/// A line's text cannot say that it underlines a paragraph through a lazy
17/// continuation, or that it begins the item opened by the bare marker above it:
18/// both depend on the containers around the line. Lines are 0-based.
19struct RuleLines {
20    /// The last line of every setext heading. Its underline is not a rule, and a
21    /// blank line before it would end the paragraph it underlines.
22    setext_underlines: HashSet<usize>,
23    /// Rules that are the first block of an item whose marker line holds nothing
24    /// else. A blank line between the marker and the rule ends the item (an item
25    /// can begin with at most one blank line), which leaves the rule outside it.
26    item_openers: HashSet<usize>,
27}
28
29impl RuleLines {
30    fn new(ctx: &crate::lint_context::LintContext) -> Self {
31        let line_of = |offset: usize| ctx.offset_to_line_col(offset).0 - 1;
32        let mut setext_underlines = HashSet::new();
33        let mut item_openers = HashSet::new();
34        // The line of the item just opened, until the next event shows whether a
35        // rule comes first.
36        let mut item_line = None;
37
38        use crate::lint_context::document_parser::blocks::BlockKind;
39        for (kind, range) in &ctx.parser_blocks().events {
40            let opened = item_line.take();
41            match kind {
42                BlockKind::ItemStart => item_line = Some(line_of(range.start)),
43                BlockKind::Rule => {
44                    let line = line_of(range.start);
45                    if opened.is_some_and(|marker_line| line > marker_line) {
46                        item_openers.insert(line);
47                    }
48                }
49                BlockKind::HeadingStart => {
50                    let last = line_of(range.end.saturating_sub(1).max(range.start));
51                    if last > line_of(range.start) {
52                        setext_underlines.insert(last);
53                    }
54                }
55                _ => {}
56            }
57        }
58
59        Self {
60            setext_underlines,
61            item_openers,
62        }
63    }
64}
65
66impl MD065BlanksAroundHorizontalRules {
67    /// Check if a line is blank (including blockquote continuation lines)
68    ///
69    /// Uses the shared `is_blank_in_blockquote_context` utility function for
70    /// consistent blank line detection across all rules.
71    fn is_blank_line(line: &str) -> bool {
72        crate::utils::regex_cache::is_blank_in_blockquote_context(line)
73    }
74
75    /// Check if this might be a setext heading underline (not a horizontal rule)
76    fn is_setext_heading_marker(lines: &[&str], line_index: usize) -> bool {
77        if line_index == 0 {
78            return false;
79        }
80
81        let line = lines[line_index].trim();
82        let prev_line = lines[line_index - 1].trim();
83
84        // Setext markers are only - or = (not * or _)
85        // And the previous line must have content (not blank, not itself an HR)
86        // CommonMark: setext underlines can have leading/trailing spaces but NO internal spaces
87        if prev_line.is_empty() {
88            return false;
89        }
90
91        // If the previous line is itself a horizontal rule, this cannot be a setext heading
92        if crate::lint_context::is_horizontal_rule_line(prev_line) {
93            return false;
94        }
95
96        // Check if all non-space characters are the same marker (- or =)
97        // and there are no internal spaces (spaces between markers)
98        let has_hyphen = line.contains('-');
99        let has_equals = line.contains('=');
100
101        // Must have exactly one type of marker
102        if has_hyphen == has_equals {
103            return false; // Either has both or neither
104        }
105
106        let marker = if has_hyphen { '-' } else { '=' };
107
108        // Setext underline: optional leading spaces, then only marker chars, then optional trailing spaces
109        // No internal spaces allowed
110        let trimmed = line.trim();
111        trimmed.chars().all(|c| c == marker)
112    }
113
114    /// Count the number of blank lines before a given line index
115    fn count_blank_lines_before(lines: &[&str], line_index: usize) -> usize {
116        let mut count = 0;
117        let mut i = line_index;
118        while i > 0 {
119            i -= 1;
120            if Self::is_blank_line(lines[i]) {
121                count += 1;
122            } else {
123                break;
124            }
125        }
126        count
127    }
128
129    /// Count the number of blank lines after a given line index
130    fn count_blank_lines_after(lines: &[&str], line_index: usize) -> usize {
131        let mut count = 0;
132        let mut i = line_index + 1;
133        while i < lines.len() {
134            if Self::is_blank_line(lines[i]) {
135                count += 1;
136                i += 1;
137            } else {
138                break;
139            }
140        }
141        count
142    }
143
144    /// The document with the blank lines of the warnings that still carry a fix.
145    fn fix_with(content: &str, warnings: &[LintWarning]) -> Result<String, LintError> {
146        apply_warning_fixes(content, warnings).map_err(LintError::FixFailed)
147    }
148}
149
150impl Rule for MD065BlanksAroundHorizontalRules {
151    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
152        Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(65))
153    }
154
155    fn name(&self) -> &'static str {
156        "MD065"
157    }
158
159    fn description(&self) -> &'static str {
160        "Horizontal rules should be surrounded by blank lines"
161    }
162
163    fn category(&self) -> RuleCategory {
164        RuleCategory::Whitespace
165    }
166
167    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
168        // A thematic break may be written with spaces between its markers (`* * *`,
169        // `- - -`, `_ _ _`), so no substring of the content identifies one. The check
170        // below reads the same per-line flag the rule itself acts on, which is already
171        // computed and already excludes code blocks and front matter.
172        ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.is_horizontal_rule)
173    }
174
175    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
176        let content = ctx.content;
177        let mut warnings = Vec::new();
178
179        if content.is_empty() {
180            return Ok(Vec::new());
181        }
182
183        let lines = ctx.raw_lines();
184        let rule_lines = RuleLines::new(ctx);
185
186        for (i, line_info) in ctx.lines.iter().enumerate() {
187            // Use pre-computed is_horizontal_rule from LineInfo
188            // This already excludes code blocks, frontmatter, and does proper HR detection
189            if !line_info.is_horizontal_rule {
190                continue;
191            }
192
193            // Skip the underline of a setext heading. The parser's reading of the
194            // document finds every one, lazy text lines included; the context's
195            // heading and the text check cover underlines the parser reads as
196            // something else, such as one below `#tag`.
197            if rule_lines.setext_underlines.contains(&i)
198                || ctx.heading_on_line(i).is_some_and(|heading| heading.is_setext())
199                || Self::is_setext_heading_marker(lines, i)
200            {
201                continue;
202            }
203
204            // Check for blank line before HR (unless at start of document, or at
205            // the start of the list item whose bare marker is the line above)
206            if i > 0 && !rule_lines.item_openers.contains(&i) && Self::count_blank_lines_before(lines, i) == 0 {
207                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
208                warnings.push(LintWarning {
209                    rule_name: Some(self.name().to_string()),
210                    message: "Missing blank line before horizontal rule".to_string(),
211                    line: i + 1,
212                    column: 1,
213                    end_line: i + 1,
214                    end_column: 2,
215                    severity: Severity::Warning,
216                    fix: Some(Fix::new(ctx.line_column_byte_range(i + 1, 1), format!("{bq_prefix}\n"))),
217                });
218            }
219
220            // Check for blank line after HR (unless at end of document)
221            if i < lines.len() - 1 && Self::count_blank_lines_after(lines, i) == 0 {
222                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
223                warnings.push(LintWarning {
224                    rule_name: Some(self.name().to_string()),
225                    message: "Missing blank line after horizontal rule".to_string(),
226                    line: i + 1,
227                    column: lines[i].chars().count() + 1,
228                    end_line: i + 1,
229                    end_column: lines[i].chars().count() + 2,
230                    severity: Severity::Warning,
231                    // Inserted at the end of the line, ahead of its own line ending.
232                    fix: Some(Fix::new(
233                        ctx.line_column_byte_range(i + 1, lines[i].chars().count() + 1),
234                        format!("\n{bq_prefix}"),
235                    )),
236                });
237            }
238        }
239
240        Ok(warnings)
241    }
242
243    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
244        let content = ctx.content;
245
246        let warnings = self.check(ctx)?;
247        let warnings =
248            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
249        if warnings.is_empty() {
250            return Ok(content.to_string());
251        }
252        Self::fix_with(content, &warnings)
253    }
254
255    fn as_any(&self) -> &dyn std::any::Any {
256        self
257    }
258
259    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
260    where
261        Self: Sized,
262    {
263        Box::new(MD065BlanksAroundHorizontalRules)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::lint_context::LintContext;
271
272    #[test]
273    fn test_hr_with_blanks() {
274        let rule = MD065BlanksAroundHorizontalRules;
275        let content = "Some text before.
276
277---
278
279Some text after.";
280        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
281        let result = rule.check(&ctx).unwrap();
282
283        assert!(result.is_empty());
284    }
285
286    #[test]
287    fn test_hr_missing_blank_before() {
288        let rule = MD065BlanksAroundHorizontalRules;
289        // Use *** which cannot be a setext heading marker
290        let content = "Some text before.
291***
292
293Some text after.";
294        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
295        let result = rule.check(&ctx).unwrap();
296
297        assert_eq!(result.len(), 1);
298        assert_eq!(result[0].line, 2);
299        assert!(result[0].message.contains("before horizontal rule"));
300    }
301
302    #[test]
303    fn test_hr_missing_blank_after() {
304        let rule = MD065BlanksAroundHorizontalRules;
305        let content = "Some text before.
306
307***
308Some text after.";
309        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310        let result = rule.check(&ctx).unwrap();
311
312        assert_eq!(result.len(), 1);
313        assert_eq!(result[0].line, 3);
314        assert!(result[0].message.contains("after horizontal rule"));
315    }
316
317    #[test]
318    fn test_hr_missing_both_blanks() {
319        let rule = MD065BlanksAroundHorizontalRules;
320        // Use *** which cannot be a setext heading marker
321        let content = "Some text before.
322***
323Some text after.";
324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325        let result = rule.check(&ctx).unwrap();
326
327        assert_eq!(result.len(), 2);
328        assert!(result[0].message.contains("before horizontal rule"));
329        assert!(result[1].message.contains("after horizontal rule"));
330    }
331
332    #[test]
333    fn test_hr_at_start_of_document() {
334        let rule = MD065BlanksAroundHorizontalRules;
335        let content = "---
336
337Some text after.";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339        let result = rule.check(&ctx).unwrap();
340
341        // No blank line needed before HR at start of document
342        assert!(result.is_empty());
343    }
344
345    #[test]
346    fn test_hr_at_end_of_document() {
347        let rule = MD065BlanksAroundHorizontalRules;
348        let content = "Some text before.
349
350---";
351        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352        let result = rule.check(&ctx).unwrap();
353
354        // No blank line needed after HR at end of document
355        assert!(result.is_empty());
356    }
357
358    #[test]
359    fn test_multiple_hrs() {
360        let rule = MD065BlanksAroundHorizontalRules;
361        // Use *** and ___ which cannot be setext heading markers
362        let content = "Text before.
363***
364Middle text.
365___
366Text after.";
367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368        let result = rule.check(&ctx).unwrap();
369
370        assert_eq!(result.len(), 4);
371    }
372
373    #[test]
374    fn test_hr_asterisks() {
375        let rule = MD065BlanksAroundHorizontalRules;
376        let content = "Some text.
377***
378More text.";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380        let result = rule.check(&ctx).unwrap();
381
382        assert_eq!(result.len(), 2);
383    }
384
385    #[test]
386    fn test_hr_underscores() {
387        let rule = MD065BlanksAroundHorizontalRules;
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_hr_with_spaces() {
399        let rule = MD065BlanksAroundHorizontalRules;
400        // Use * * * which cannot be a setext heading marker
401        let content = "Some text.
402* * *
403More text.";
404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405        let result = rule.check(&ctx).unwrap();
406
407        assert_eq!(result.len(), 2);
408    }
409
410    #[test]
411    fn test_hr_long() {
412        let rule = MD065BlanksAroundHorizontalRules;
413        // Use asterisks which cannot be a setext heading marker
414        let content = "Some text.
415**********
416More text.";
417        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418        let result = rule.check(&ctx).unwrap();
419
420        assert_eq!(result.len(), 2);
421    }
422
423    #[test]
424    fn test_setext_heading_not_hr() {
425        let rule = MD065BlanksAroundHorizontalRules;
426        let content = "Heading
427---
428
429More text.";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432
433        // Should not flag setext heading marker as HR
434        assert!(result.is_empty());
435    }
436
437    #[test]
438    fn test_setext_heading_equals() {
439        let rule = MD065BlanksAroundHorizontalRules;
440        let content = "Heading
441===
442
443More text.";
444        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445        let result = rule.check(&ctx).unwrap();
446
447        // === is not a valid HR, only setext heading
448        assert!(result.is_empty());
449    }
450
451    #[test]
452    fn test_blockquote_setext_underline_is_not_hr() {
453        // A quoted underline belongs to the heading above it. A blank line
454        // inserted before it would end that paragraph and leave a thematic
455        // break where the heading was.
456        let rule = MD065BlanksAroundHorizontalRules;
457        for content in [
458            "> Title\n> ---\n\nText.\n",
459            "> > Deep\n> > ---\n\nText.\n",
460            "> Title\n>    ---\n\nText.\n",
461        ] {
462            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463            let result = rule.check(&ctx).unwrap();
464            assert!(result.is_empty(), "{content:?} is a heading: {result:?}");
465            assert_eq!(rule.fix(&ctx).unwrap(), content);
466        }
467    }
468
469    #[test]
470    fn test_blockquote_hr_below_blank_line_is_checked() {
471        // A quoted blank line ends the paragraph, so the `---` below it is a
472        // thematic break that still needs a blank line after it.
473        let rule = MD065BlanksAroundHorizontalRules;
474        let content = "> Text\n>\n> ---\n> More\n";
475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476        let result = rule.check(&ctx).unwrap();
477        assert_eq!(result.len(), 1, "{result:?}");
478        assert_eq!(result[0].line, 3);
479        assert_eq!(result[0].message, "Missing blank line after horizontal rule");
480    }
481
482    #[test]
483    fn test_hr_in_code_block() {
484        let rule = MD065BlanksAroundHorizontalRules;
485        let content = "Some text.
486
487```
488---
489```
490
491More text.";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493        let result = rule.check(&ctx).unwrap();
494
495        // HR in code block should be ignored
496        assert!(result.is_empty());
497    }
498
499    #[test]
500    fn test_fix_missing_blanks() {
501        let rule = MD065BlanksAroundHorizontalRules;
502        // Use *** which cannot be a setext heading marker
503        let content = "Text before.
504***
505Text after.";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let fixed = rule.fix(&ctx).unwrap();
508
509        let expected = "Text before.
510
511***
512
513Text after.";
514        assert_eq!(fixed, expected);
515    }
516
517    #[test]
518    fn test_fix_multiple_hrs() {
519        let rule = MD065BlanksAroundHorizontalRules;
520        // Use *** and ___ which cannot be setext heading markers
521        let content = "Start
522***
523Middle
524___
525End";
526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527        let fixed = rule.fix(&ctx).unwrap();
528
529        let expected = "Start
530
531***
532
533Middle
534
535___
536
537End";
538        assert_eq!(fixed, expected);
539    }
540
541    #[test]
542    fn test_empty_content() {
543        let rule = MD065BlanksAroundHorizontalRules;
544        let content = "";
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546        let result = rule.check(&ctx).unwrap();
547
548        assert!(result.is_empty());
549    }
550
551    #[test]
552    fn test_no_hrs() {
553        let rule = MD065BlanksAroundHorizontalRules;
554        let content = "Just regular text.
555No horizontal rules here.
556Only paragraphs.";
557        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558        let result = rule.check(&ctx).unwrap();
559
560        assert!(result.is_empty());
561    }
562
563    #[test]
564    fn test_is_horizontal_rule() {
565        use crate::lint_context::is_horizontal_rule_line;
566
567        // Valid horizontal rules
568        assert!(is_horizontal_rule_line("---"));
569        assert!(is_horizontal_rule_line("----"));
570        assert!(is_horizontal_rule_line("***"));
571        assert!(is_horizontal_rule_line("****"));
572        assert!(is_horizontal_rule_line("___"));
573        assert!(is_horizontal_rule_line("____"));
574        assert!(is_horizontal_rule_line("- - -"));
575        assert!(is_horizontal_rule_line("* * *"));
576        assert!(is_horizontal_rule_line("_ _ _"));
577        assert!(is_horizontal_rule_line("  ---  "));
578
579        // Invalid horizontal rules
580        assert!(!is_horizontal_rule_line("--"));
581        assert!(!is_horizontal_rule_line("**"));
582        assert!(!is_horizontal_rule_line("__"));
583        assert!(!is_horizontal_rule_line("- -"));
584        assert!(!is_horizontal_rule_line("text"));
585        assert!(!is_horizontal_rule_line(""));
586        assert!(!is_horizontal_rule_line("==="));
587    }
588
589    #[test]
590    fn test_consecutive_hrs_with_blanks() {
591        let rule = MD065BlanksAroundHorizontalRules;
592        let content = "Text.
593
594---
595
596***
597
598More text.";
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600        let result = rule.check(&ctx).unwrap();
601
602        // Both HRs have proper blank lines
603        assert!(result.is_empty());
604    }
605
606    #[test]
607    fn test_hr_after_heading() {
608        let rule = MD065BlanksAroundHorizontalRules;
609        // Use *** which cannot be a setext heading marker
610        let content = "# Heading
611***
612
613Text.";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615        let result = rule.check(&ctx).unwrap();
616
617        // HR after heading needs blank line before
618        assert_eq!(result.len(), 1);
619        assert!(result[0].message.contains("before horizontal rule"));
620    }
621
622    #[test]
623    fn test_hr_before_heading() {
624        let rule = MD065BlanksAroundHorizontalRules;
625        let content = "Text.
626
627***
628# Heading";
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630        let result = rule.check(&ctx).unwrap();
631
632        // HR before heading needs blank line after
633        assert_eq!(result.len(), 1);
634        assert!(result[0].message.contains("after horizontal rule"));
635    }
636
637    #[test]
638    fn test_setext_heading_hyphen_not_flagged() {
639        let rule = MD065BlanksAroundHorizontalRules;
640        // --- immediately after text is a setext heading, not HR
641        let content = "Heading Text
642---
643
644More text.";
645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646        let result = rule.check(&ctx).unwrap();
647
648        // Should not flag setext heading as missing blank lines
649        assert!(result.is_empty());
650    }
651
652    #[test]
653    fn test_hr_with_blank_before_hyphen() {
654        let rule = MD065BlanksAroundHorizontalRules;
655        // --- after a blank line IS a horizontal rule, not setext heading
656        let content = "Some text.
657
658---
659More text.";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662
663        // Should flag missing blank line after
664        assert_eq!(result.len(), 1);
665        assert!(result[0].message.contains("after horizontal rule"));
666    }
667
668    // ============================================================
669    // Additional comprehensive tests for edge cases
670    // ============================================================
671
672    #[test]
673    fn test_frontmatter_not_flagged() {
674        let rule = MD065BlanksAroundHorizontalRules;
675        // YAML frontmatter uses --- delimiters which should NOT be flagged
676        let content = "---
677title: Test Document
678date: 2024-01-01
679---
680
681# Heading
682
683Content here.";
684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let result = rule.check(&ctx).unwrap();
686
687        // Frontmatter delimiters should not be flagged as HRs
688        assert!(result.is_empty());
689    }
690
691    #[test]
692    fn test_hr_after_frontmatter() {
693        let rule = MD065BlanksAroundHorizontalRules;
694        let content = "---
695title: Test
696---
697
698Content.
699***
700More content.";
701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702        let result = rule.check(&ctx).unwrap();
703
704        // HR after frontmatter content should be flagged
705        assert_eq!(result.len(), 2);
706    }
707
708    #[test]
709    fn test_hr_in_indented_code_block() {
710        let rule = MD065BlanksAroundHorizontalRules;
711        // 4-space indented code block
712        let content = "Some text.
713
714    ---
715    code here
716
717More text.";
718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719        let result = rule.check(&ctx).unwrap();
720
721        // HR in indented code block should be ignored
722        assert!(result.is_empty());
723    }
724
725    #[test]
726    fn test_hr_with_leading_spaces() {
727        let rule = MD065BlanksAroundHorizontalRules;
728        // 1-3 spaces of indentation is still a valid HR
729        let content = "Text.
730   ***
731More text.";
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733        let result = rule.check(&ctx).unwrap();
734
735        // Indented HR (1-3 spaces) should be detected
736        assert_eq!(result.len(), 2);
737    }
738
739    #[test]
740    fn test_hr_in_html_comment() {
741        let rule = MD065BlanksAroundHorizontalRules;
742        let content = "Text.
743
744<!--
745---
746-->
747
748More text.";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let result = rule.check(&ctx).unwrap();
751
752        // HR inside HTML comment should be ignored
753        assert!(result.is_empty());
754    }
755
756    #[test]
757    fn test_hr_in_blockquote() {
758        let rule = MD065BlanksAroundHorizontalRules;
759        let content = "Text.
760
761> Quote text
762> ***
763> More quote
764
765After quote.";
766        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767        let result = rule.check(&ctx).unwrap();
768
769        // HR inside blockquote - the "> ***" line contains a valid HR pattern
770        // but within blockquote context. This tests blockquote awareness.
771        // Note: blockquotes don't skip HR detection, so this may flag.
772        // The actual behavior depends on implementation.
773        assert!(result.len() <= 2); // May or may not flag based on blockquote handling
774    }
775
776    #[test]
777    fn test_hr_after_list() {
778        let rule = MD065BlanksAroundHorizontalRules;
779        // Real-world case from Node.js repo
780        let content = "* Item one
781* Item two
782***
783
784More text.";
785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786        let result = rule.check(&ctx).unwrap();
787
788        // HR immediately after list should be flagged
789        assert_eq!(result.len(), 1);
790        assert!(result[0].message.contains("before horizontal rule"));
791    }
792
793    #[test]
794    fn test_mixed_marker_with_many_spaces() {
795        let rule = MD065BlanksAroundHorizontalRules;
796        let content = "Text.
797-  -  -  -
798More text.";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let result = rule.check(&ctx).unwrap();
801
802        // HR with multiple spaces between markers
803        assert_eq!(result.len(), 2);
804    }
805
806    #[test]
807    fn test_only_hr_in_document() {
808        let rule = MD065BlanksAroundHorizontalRules;
809        let content = "---";
810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811        let result = rule.check(&ctx).unwrap();
812
813        // Single HR alone in document - no blanks needed
814        assert!(result.is_empty());
815    }
816
817    #[test]
818    fn test_multiple_blank_lines_already_present() {
819        let rule = MD065BlanksAroundHorizontalRules;
820        let content = "Text.
821
822
823---
824
825
826More text.";
827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828        let result = rule.check(&ctx).unwrap();
829
830        // Multiple blank lines should not trigger warnings
831        assert!(result.is_empty());
832    }
833
834    #[test]
835    fn test_hr_at_both_start_and_end() {
836        let rule = MD065BlanksAroundHorizontalRules;
837        let content = "---
838
839Content in the middle.
840
841---";
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843        let result = rule.check(&ctx).unwrap();
844
845        // HRs at start and end with proper spacing
846        assert!(result.is_empty());
847    }
848
849    #[test]
850    fn test_consecutive_hrs_without_blanks() {
851        let rule = MD065BlanksAroundHorizontalRules;
852        let content = "Text.
853
854***
855---
856___
857
858More text.";
859        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860        let result = rule.check(&ctx).unwrap();
861
862        // Consecutive HRs need blanks between them
863        // --- after *** is also an HR (not setext), since *** is an HR not text
864        assert!(result.len() >= 2);
865    }
866
867    #[test]
868    fn test_hr_after_hr_not_setext() {
869        // Regression: --- after *** should be treated as HR, not setext heading
870        let rule = MD065BlanksAroundHorizontalRules;
871        let content = "***\n---\n# ";
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873
874        // Both *** and --- are HRs, both need blanks
875        let fixed = rule.fix(&ctx).unwrap();
876        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
877        let fixed2 = rule.fix(&ctx2).unwrap();
878        assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
879    }
880
881    #[test]
882    fn test_fix_idempotency() {
883        let rule = MD065BlanksAroundHorizontalRules;
884        let content = "Text before.
885***
886Text after.";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let fixed_once = rule.fix(&ctx).unwrap();
889
890        // Apply fix again
891        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
892        let fixed_twice = rule.fix(&ctx2).unwrap();
893
894        // Second fix should not change anything
895        assert_eq!(fixed_once, fixed_twice);
896    }
897
898    #[test]
899    fn test_setext_heading_long_underline() {
900        let rule = MD065BlanksAroundHorizontalRules;
901        let content = "Heading Text
902----------
903
904More text.";
905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906        let result = rule.check(&ctx).unwrap();
907
908        // Long underline is still setext heading, not HR
909        assert!(result.is_empty());
910    }
911
912    #[test]
913    fn test_hr_with_trailing_whitespace() {
914        let rule = MD065BlanksAroundHorizontalRules;
915        let content = "Text.
916***
917More text.";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920
921        // HR with trailing whitespace should still be detected
922        assert_eq!(result.len(), 2);
923    }
924
925    #[test]
926    fn test_hr_in_html_block() {
927        let rule = MD065BlanksAroundHorizontalRules;
928        let content = "Text.
929
930<div>
931---
932</div>
933
934More text.";
935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936        let result = rule.check(&ctx).unwrap();
937
938        // HR inside HTML block should be ignored (depends on HTML block detection)
939        // This tests HTML block awareness
940        assert!(result.is_empty());
941    }
942
943    #[test]
944    fn test_spaced_hyphens_are_hr_not_setext() {
945        let rule = MD065BlanksAroundHorizontalRules;
946        // CommonMark: setext underlines cannot have internal spaces
947        // So "- - -" is a thematic break, not a setext heading
948        let content = "Heading
949- - -
950
951More text.";
952        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953        let result = rule.check(&ctx).unwrap();
954
955        // "- - -" with internal spaces is HR, needs blank before
956        assert_eq!(result.len(), 1);
957        assert!(result[0].message.contains("before horizontal rule"));
958    }
959
960    #[test]
961    fn test_not_setext_if_prev_line_blank() {
962        let rule = MD065BlanksAroundHorizontalRules;
963        let content = "Some paragraph.
964
965---
966Text after.";
967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968        let result = rule.check(&ctx).unwrap();
969
970        // --- after blank line is HR, not setext heading
971        assert_eq!(result.len(), 1);
972        assert!(result[0].message.contains("after horizontal rule"));
973    }
974
975    #[test]
976    fn test_asterisk_cannot_be_setext() {
977        let rule = MD065BlanksAroundHorizontalRules;
978        // *** immediately after text is still HR (asterisks can't be setext markers)
979        let content = "Some text
980***
981More text.";
982        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983        let result = rule.check(&ctx).unwrap();
984
985        // *** is always HR, never setext
986        assert_eq!(result.len(), 2);
987    }
988
989    #[test]
990    fn test_underscore_cannot_be_setext() {
991        let rule = MD065BlanksAroundHorizontalRules;
992        // ___ immediately after text is still HR (underscores can't be setext markers)
993        let content = "Some text
994___
995More text.";
996        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997        let result = rule.check(&ctx).unwrap();
998
999        // ___ is always HR, never setext
1000        assert_eq!(result.len(), 2);
1001    }
1002
1003    #[test]
1004    fn test_fix_preserves_content() {
1005        let rule = MD065BlanksAroundHorizontalRules;
1006        let content = "First paragraph with **bold** and *italic*.
1007***
1008Second paragraph with [link](url) and `code`.";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010        let fixed = rule.fix(&ctx).unwrap();
1011
1012        // Verify content is preserved
1013        assert!(fixed.contains("**bold**"));
1014        assert!(fixed.contains("*italic*"));
1015        assert!(fixed.contains("[link](url)"));
1016        assert!(fixed.contains("`code`"));
1017        assert!(fixed.contains("***"));
1018    }
1019
1020    #[test]
1021    fn test_fix_only_adds_needed_blanks() {
1022        let rule = MD065BlanksAroundHorizontalRules;
1023        // Already has blank before, missing blank after
1024        let content = "Text.
1025
1026***
1027More text.";
1028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029        let fixed = rule.fix(&ctx).unwrap();
1030
1031        let expected = "Text.
1032
1033***
1034
1035More text.";
1036        assert_eq!(fixed, expected);
1037    }
1038
1039    #[test]
1040    fn test_hr_detection_edge_cases() {
1041        use crate::lint_context::is_horizontal_rule_line;
1042
1043        // Valid HRs with various spacing (0-3 leading spaces allowed)
1044        assert!(is_horizontal_rule_line("   ---"));
1045        assert!(is_horizontal_rule_line("---   "));
1046        assert!(is_horizontal_rule_line("   ---   "));
1047        assert!(is_horizontal_rule_line("*  *  *"));
1048        assert!(is_horizontal_rule_line("_    _    _"));
1049
1050        // Invalid patterns
1051        assert!(!is_horizontal_rule_line("--a"));
1052        assert!(!is_horizontal_rule_line("**a"));
1053        assert!(!is_horizontal_rule_line("-*-"));
1054        assert!(!is_horizontal_rule_line("- * _"));
1055        assert!(!is_horizontal_rule_line("   "));
1056        assert!(!is_horizontal_rule_line("\t---")); // Tabs not allowed per CommonMark
1057    }
1058
1059    #[test]
1060    fn test_warning_line_numbers_accurate() {
1061        let rule = MD065BlanksAroundHorizontalRules;
1062        let content = "Line 1
1063Line 2
1064***
1065Line 4";
1066        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067        let result = rule.check(&ctx).unwrap();
1068
1069        // Verify line numbers are 1-indexed and accurate
1070        assert_eq!(result.len(), 2);
1071        assert_eq!(result[0].line, 3); // HR is on line 3
1072        assert_eq!(result[1].line, 3);
1073    }
1074
1075    #[test]
1076    fn test_complex_document_structure() {
1077        let rule = MD065BlanksAroundHorizontalRules;
1078        let content = "# Main Title
1079
1080Introduction paragraph.
1081
1082## Section One
1083
1084Content here.
1085
1086***
1087
1088## Section Two
1089
1090More content.
1091
1092---
1093
1094Final thoughts.";
1095        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096        let result = rule.check(&ctx).unwrap();
1097
1098        // Well-structured document should have no warnings
1099        assert!(result.is_empty());
1100    }
1101
1102    #[test]
1103    fn test_fix_preserves_blockquote_prefix_before_hr() {
1104        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
1105        let rule = MD065BlanksAroundHorizontalRules;
1106
1107        let content = "> Text before
1108> ***
1109> Text after";
1110        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111        let fixed = rule.fix(&ctx).unwrap();
1112
1113        // The blank lines inserted should have the blockquote prefix
1114        let expected = "> Text before
1115>
1116> ***
1117>
1118> Text after";
1119        assert_eq!(
1120            fixed, expected,
1121            "Fix should insert '>' blank lines around HR, not plain blank lines"
1122        );
1123    }
1124
1125    #[test]
1126    fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
1127        // Nested blockquotes should preserve the full prefix. The break is
1128        // `***` because a `---` under the quoted text would underline it.
1129        let rule = MD065BlanksAroundHorizontalRules;
1130
1131        let content = ">> Nested quote
1132>> ***
1133>> More text";
1134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135        let fixed = rule.fix(&ctx).unwrap();
1136
1137        // Should insert ">>" blank lines
1138        let expected = ">> Nested quote
1139>>
1140>> ***
1141>>
1142>> More text";
1143        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1144    }
1145
1146    #[test]
1147    fn test_fix_preserves_blockquote_prefix_after_hr() {
1148        // Issue #268: Fix should insert blockquote-prefixed blank lines after HR
1149        let rule = MD065BlanksAroundHorizontalRules;
1150
1151        let content = "> ---
1152> Text after";
1153        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154        let fixed = rule.fix(&ctx).unwrap();
1155
1156        // The blank line inserted after the HR should have the blockquote prefix
1157        let expected = "> ---
1158>
1159> Text after";
1160        assert_eq!(
1161            fixed, expected,
1162            "Fix should insert '>' blank line after HR, not plain blank line"
1163        );
1164    }
1165
1166    #[test]
1167    fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
1168        // Triple-nested blockquotes should preserve full prefix. The break is
1169        // `***` because a `---` under the quoted text would underline it.
1170        let rule = MD065BlanksAroundHorizontalRules;
1171
1172        let content = ">>> Triple nested
1173>>> ***
1174>>> More text";
1175        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176        let fixed = rule.fix(&ctx).unwrap();
1177
1178        let expected = ">>> Triple nested
1179>>>
1180>>> ***
1181>>>
1182>>> More text";
1183        assert_eq!(
1184            fixed, expected,
1185            "Fix should preserve triple-nested blockquote prefix '>>>'"
1186        );
1187    }
1188
1189    #[test]
1190    fn test_fix_preserves_trailing_newline() {
1191        let rule = MD065BlanksAroundHorizontalRules;
1192
1193        let content = "Text\n***\nMore text\n";
1194        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195        let fixed = rule.fix(&ctx).unwrap();
1196
1197        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
1198        assert_eq!(fixed, "Text\n\n***\n\nMore text\n");
1199    }
1200
1201    #[test]
1202    fn spaced_thematic_break_is_not_skipped() {
1203        // `* * *`, `- - -` and `_ _ _` are thematic breaks that contain none of the
1204        // substrings a content scan looks for, so whether the rule ran at all used to
1205        // depend on how the break was written.
1206        let rule = MD065BlanksAroundHorizontalRules;
1207
1208        for content in [
1209            "Text\n* * *\nMore text\n",
1210            "Text\n- - -\nMore text\n",
1211            "Text\n_ _ _\nMore text\n",
1212        ] {
1213            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214            assert!(!rule.should_skip(&ctx), "the whole rule was skipped for {content:?}");
1215            assert_eq!(
1216                rule.check(&ctx).unwrap().len(),
1217                2,
1218                "both missing blank lines should be reported for {content:?}"
1219            );
1220        }
1221    }
1222
1223    #[test]
1224    fn a_document_with_no_thematic_break_is_still_skipped() {
1225        let rule = MD065BlanksAroundHorizontalRules;
1226
1227        for content in ["Text\nMore text\n", "- item\n- item\n", "```\n---\n```\n"] {
1228            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229            assert!(
1230                rule.should_skip(&ctx),
1231                "the rule should have been skipped for {content:?}"
1232            );
1233        }
1234    }
1235
1236    #[test]
1237    fn markers_inside_a_hidden_block_are_not_spaced_out() {
1238        // The fix inserts blank lines around what it takes for a thematic break, so
1239        // reporting one that a comment hides or a math block owns rewrites the block
1240        // itself - a blank line in display math ends it.
1241        let rule = MD065BlanksAroundHorizontalRules;
1242
1243        for content in [
1244            "Text.\n\n<!--\n***\n-->\n\nMore.\n",
1245            "Text.\n\n$$\na = b\n***\nc = d\n$$\n\nMore.\n",
1246        ] {
1247            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248            assert!(
1249                rule.check(&ctx).unwrap().is_empty(),
1250                "the hidden markers were reported in {content:?}"
1251            );
1252            assert_eq!(
1253                rule.fix(&ctx).unwrap(),
1254                content,
1255                "the block was rewritten in {content:?}"
1256            );
1257        }
1258    }
1259
1260    #[test]
1261    fn test_fix_preserves_no_trailing_newline() {
1262        let rule = MD065BlanksAroundHorizontalRules;
1263
1264        let content = "Text\n***\nMore text";
1265        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266        let fixed = rule.fix(&ctx).unwrap();
1267
1268        assert!(
1269            !fixed.ends_with('\n'),
1270            "Fix should not add trailing newline if original didn't have one"
1271        );
1272        assert_eq!(fixed, "Text\n\n***\n\nMore text");
1273    }
1274
1275    #[test]
1276    fn test_indented_lazy_line_in_blockquote_is_not_a_horizontal_rule() {
1277        let rule = MD065BlanksAroundHorizontalRules;
1278        for content in [
1279            "> o\n>     ---\n",
1280            "> o\n>     ***\n",
1281            ">o\n>     ***\n",
1282            "> o\n>     - - -\n",
1283            "> > o\n> >     ***\n",
1284            "- > o\n  >     ***\n",
1285        ] {
1286            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1288            assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1289        }
1290    }
1291
1292    #[test]
1293    fn test_real_horizontal_rule_in_blockquote_still_needs_blank_lines() {
1294        let rule = MD065BlanksAroundHorizontalRules;
1295        let content = "> a\n>\n> ***\n> b\n";
1296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1298        assert_eq!(rule.fix(&ctx).unwrap(), "> a\n>\n> ***\n>\n> b\n");
1299    }
1300
1301    fn messages(content: &str) -> Vec<String> {
1302        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303        let rule = MD065BlanksAroundHorizontalRules;
1304        rule.check(&ctx).unwrap().into_iter().map(|w| w.message).collect()
1305    }
1306
1307    #[test]
1308    fn a_rule_opening_a_bare_marker_item_has_nothing_before_it() {
1309        // A blank line after a bare marker ends the item, so the rule would leave
1310        // the list. Nothing precedes it in its container, as at the start of a
1311        // document.
1312        let rule = MD065BlanksAroundHorizontalRules;
1313        for content in [
1314            "-\n  ***\n",
1315            "x\n\n-\n  ***\n",
1316            "1.\n   ***\n",
1317            "-\n  ___\n",
1318            "-\n  * * *\n",
1319            "-\n  - - -\n",
1320            "> -\n>   ***\n",
1321            "-\n  -\n    ***\n",
1322            "- a\n  -\n    ***\n",
1323        ] {
1324            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1326            assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1327        }
1328    }
1329
1330    #[test]
1331    fn a_rule_opening_a_bare_marker_item_still_needs_a_blank_after() {
1332        let rule = MD065BlanksAroundHorizontalRules;
1333        for (content, expected) in [
1334            ("-\n  ***\n  text\n", "-\n  ***\n\n  text\n"),
1335            ("1.\n   ***\n   text\n", "1.\n   ***\n\n   text\n"),
1336            ("> -\n>   ***\n>   text\n", "> -\n>   ***\n>\n>   text\n"),
1337        ] {
1338            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339            let warnings = rule.check(&ctx).unwrap();
1340            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1341            assert_eq!(warnings[0].message, "Missing blank line after horizontal rule");
1342            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1343        }
1344    }
1345
1346    #[test]
1347    fn a_rule_after_item_content_still_needs_a_blank_before() {
1348        // The item has content before the rule, so the blank stays inside it.
1349        let rule = MD065BlanksAroundHorizontalRules;
1350        for (content, expected) in [
1351            ("- a\n  ***\n", "- a\n\n  ***\n"),
1352            ("-\n  a\n  ***\n", "-\n  a\n\n  ***\n"),
1353            ("1. a\n   ***\n", "1. a\n\n   ***\n"),
1354        ] {
1355            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356            assert_eq!(
1357                messages(content),
1358                ["Missing blank line before horizontal rule"],
1359                "{content:?}"
1360            );
1361            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1362        }
1363    }
1364
1365    #[test]
1366    fn a_rule_outside_the_list_after_a_bare_marker_is_unchanged() {
1367        // A blank line after a bare marker already ended the item.
1368        assert!(messages("-\n\n  ***\n").is_empty());
1369        assert!(messages("-\n\n***\n").is_empty());
1370    }
1371
1372    #[test]
1373    fn a_lazy_quoted_setext_underline_is_not_a_rule() {
1374        // The text line without a `>` continues the quoted paragraph, which the
1375        // underline turns into a heading; a blank line before it would leave a
1376        // paragraph and a thematic break.
1377        let rule = MD065BlanksAroundHorizontalRules;
1378        for content in [
1379            "> a\nb\n> ---\n",
1380            "> a\nb\n>   ---\n",
1381            "> > a\n> b\n> > ---\n",
1382            "> a\nb\nc\n> ---\n",
1383            "> - a\nb\n>   ---\n",
1384            "- a\nb\n  ---\n",
1385            "a\nb\n---\n",
1386        ] {
1387            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1389            assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1390        }
1391    }
1392
1393    #[test]
1394    fn a_lazy_line_before_a_real_rule_is_still_separated() {
1395        // `***` cannot underline, so it is a rule and the paragraph above ends.
1396        let rule = MD065BlanksAroundHorizontalRules;
1397        for (content, expected) in [
1398            ("> a\nb\n> ***\n", "> a\nb\n>\n> ***\n"),
1399            ("> a\nb\n> - - -\n", "> a\nb\n>\n> - - -\n"),
1400            ("> a\nb\n> ***\n> c\n", "> a\nb\n>\n> ***\n>\n> c\n"),
1401        ] {
1402            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1403            assert!(!rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1404            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1405        }
1406    }
1407
1408    #[test]
1409    fn the_fix_values_apply_to_the_same_text_as_fix() {
1410        // The LSP applies each warning's own edit, so the edits have to produce
1411        // what `fix` writes, with the quote marker on the added line.
1412        let rule = MD065BlanksAroundHorizontalRules;
1413        for content in [
1414            "Text\n***\nMore text\n",
1415            "Text\n***\nMore text",
1416            "> a\n> ***\n> b\n",
1417            "> > a\n> > ***\n> > b\n",
1418            "> a\nb\n> ***\nc\n",
1419            "- a\n  ***\n  b\n",
1420            "-\n  ***\n  b\n",
1421            "a\n***\n***\nb\n",
1422        ] {
1423            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424            let warnings = rule.check(&ctx).unwrap();
1425            assert!(!warnings.is_empty(), "{content:?}");
1426            let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1427            assert_eq!(applied, rule.fix(&ctx).unwrap(), "{content:?}");
1428        }
1429    }
1430
1431    #[test]
1432    fn the_fix_is_not_written_for_a_warning_without_a_fix() {
1433        let rule = MD065BlanksAroundHorizontalRules;
1434        let content = "a\n***\nb\n";
1435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436        let mut warnings = rule.check(&ctx).unwrap();
1437        assert_eq!(warnings.len(), 2);
1438        warnings[0].fix = None; // the one before the rule
1439        assert_eq!(
1440            MD065BlanksAroundHorizontalRules::fix_with(content, &warnings).unwrap(),
1441            "a\n***\n\nb\n"
1442        );
1443    }
1444
1445    #[test]
1446    fn loosening_a_list_keeps_its_fixes_in_every_flavor() {
1447        let rule = MD065BlanksAroundHorizontalRules;
1448        for flavor in [
1449            crate::config::MarkdownFlavor::Standard,
1450            crate::config::MarkdownFlavor::MDX,
1451        ] {
1452            let content = "- a\n  ***\n  b\n";
1453            let ctx = LintContext::new(content, flavor, None);
1454            let warnings = rule.check(&ctx).unwrap();
1455            assert_eq!(warnings.len(), 2, "{flavor:?}");
1456            assert!(warnings.iter().all(|w| w.fix.is_some()), "{flavor:?}: {warnings:?}");
1457            assert_eq!(rule.fix(&ctx).unwrap(), "- a\n\n  ***\n\n  b\n", "{flavor:?}");
1458        }
1459    }
1460
1461    #[test]
1462    fn mdx_reads_no_indented_code_so_its_fixes_stay() {
1463        // CommonMark reads the line as paragraph text of the quote, MDX as a rule.
1464        let rule = MD065BlanksAroundHorizontalRules;
1465        let content = "> o\n>     ***\n> after\n";
1466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1467        let warnings = rule.check(&ctx).unwrap();
1468        assert_eq!(warnings.len(), 2, "{warnings:?}");
1469        assert!(warnings.iter().all(|w| w.fix.is_some()), "{warnings:?}");
1470    }
1471}
1472
1473#[cfg(test)]
1474mod block_adjacency_regressions {
1475    #[test]
1476    fn paragraph_between_item_start_and_rule_clears_opener() {
1477        let ctx =
1478            crate::lint_context::LintContext::new("- para\n\n  ---\n", crate::config::MarkdownFlavor::Standard, None);
1479        assert!(super::RuleLines::new(&ctx).item_openers.is_empty());
1480    }
1481}