Skip to main content

rumdl_lib/rules/
md035_hr_style.rs

1//!
2//! Rule MD035: Horizontal rule style
3//!
4//! See [docs/md035.md](../../docs/md035.md) for full documentation, configuration, and examples.
5
6use crate::utils::range_utils::calculate_line_range;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use toml;
10
11mod md035_config;
12use md035_config::MD035Config;
13
14/// Represents the style for horizontal rules
15#[derive(Clone, Default)]
16pub struct MD035HRStyle {
17    config: MD035Config,
18}
19
20impl MD035HRStyle {
21    pub fn new(style: String) -> Self {
22        Self {
23            config: MD035Config { style },
24        }
25    }
26
27    pub fn from_config_struct(config: MD035Config) -> Self {
28        Self { config }
29    }
30
31    fn is_horizontal_rule(line: &str) -> bool {
32        crate::utils::thematic_break::is_thematic_break(line)
33    }
34
35    /// Check if a line might be a Setext heading underline
36    fn is_potential_setext_heading(lines: &[&str], i: usize) -> bool {
37        if i == 0 {
38            return false; // First line can't be a Setext heading underline
39        }
40
41        let line = lines[i].trim();
42        let prev_line = lines[i - 1].trim();
43
44        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
45        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
46        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
47        (is_dash_line || is_equals_line) && prev_line_has_content
48    }
49
50    /// Find the most prevalent HR style in the document (excluding setext headings, code blocks, and frontmatter)
51    fn most_prevalent_hr_style(lines: &[&str], ctx: &crate::lint_context::LintContext) -> Option<String> {
52        use std::collections::HashMap;
53        let mut counts: HashMap<&str, usize> = HashMap::new();
54        let mut order: Vec<&str> = Vec::new();
55        for (i, line) in lines.iter().enumerate() {
56            // Skip if this line is in frontmatter, code block, or MkDocs markdown HTML div
57            if let Some(line_info) = ctx.lines.get(i)
58                && (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
59            {
60                continue;
61            }
62
63            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(lines, i) {
64                let style = line.trim();
65                let counter = counts.entry(style).or_insert(0);
66                *counter += 1;
67                if *counter == 1 {
68                    order.push(style);
69                }
70            }
71        }
72        // Find the style with the highest count, breaking ties by first encountered
73        counts
74            .iter()
75            .max_by_key(|&(style, count)| {
76                (
77                    *count,
78                    -(order.iter().position(|&s| s == *style).unwrap_or(usize::MAX) as isize),
79                )
80            })
81            .map(|(style, _)| style.to_string())
82    }
83}
84
85impl Rule for MD035HRStyle {
86    fn name(&self) -> &'static str {
87        "MD035"
88    }
89
90    fn description(&self) -> &'static str {
91        "Horizontal rule style"
92    }
93
94    fn category(&self) -> RuleCategory {
95        RuleCategory::Whitespace
96    }
97
98    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
99        let mut warnings = Vec::new();
100        let lines = ctx.raw_lines();
101
102        // Use the configured style or find the most prevalent HR style
103        let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
104            Self::most_prevalent_hr_style(lines, ctx).unwrap_or_else(|| "---".to_string())
105        } else {
106            self.config.style.clone()
107        };
108
109        for (i, line) in lines.iter().enumerate() {
110            // Skip if this line is in frontmatter, code block, or MkDocs markdown HTML div (grid cards use indented HRs)
111            if let Some(line_info) = ctx.lines.get(i)
112                && (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
113            {
114                continue;
115            }
116
117            // Skip if this is a potential Setext heading underline
118            if Self::is_potential_setext_heading(lines, i) {
119                continue;
120            }
121
122            if Self::is_horizontal_rule(line) {
123                // Check if this HR matches the expected style
124                let has_indentation = line.len() > line.trim_start().len();
125                let style_mismatch = line.trim() != expected_style;
126
127                if style_mismatch || has_indentation {
128                    // Calculate precise character range for the entire horizontal rule
129                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
130
131                    warnings.push(LintWarning {
132                        rule_name: Some(self.name().to_string()),
133                        line: start_line,
134                        column: start_col,
135                        end_line,
136                        end_column: end_col,
137                        message: if has_indentation {
138                            "Horizontal rule should not be indented".to_string()
139                        } else {
140                            format!("Horizontal rule style should be \"{expected_style}\"")
141                        },
142                        severity: Severity::Warning,
143                        fix: Some(Fix::new(
144                            ctx.line_column_byte_range_with_length(i + 1, 1, line.chars().count()),
145                            expected_style.clone(),
146                        )),
147                    });
148                }
149            }
150        }
151
152        Ok(warnings)
153    }
154
155    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
156        if self.should_skip(ctx) {
157            return Ok(ctx.content.to_string());
158        }
159        let warnings = self.check(ctx)?;
160        if warnings.is_empty() {
161            return Ok(ctx.content.to_string());
162        }
163        let warnings =
164            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
165        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
166            .map_err(crate::rule::LintError::InvalidInput)
167    }
168
169    fn as_any(&self) -> &dyn std::any::Any {
170        self
171    }
172
173    /// Check if this rule should be skipped
174    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
175        // HR can use -, *, or _
176        ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
177    }
178
179    crate::impl_rule_config_methods!(MD035Config);
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::lint_context::LintContext;
186
187    #[test]
188    fn test_is_horizontal_rule() {
189        // Valid horizontal rules
190        assert!(MD035HRStyle::is_horizontal_rule("---"));
191        assert!(MD035HRStyle::is_horizontal_rule("----"));
192        assert!(MD035HRStyle::is_horizontal_rule("***"));
193        assert!(MD035HRStyle::is_horizontal_rule("****"));
194        assert!(MD035HRStyle::is_horizontal_rule("___"));
195        assert!(MD035HRStyle::is_horizontal_rule("____"));
196        assert!(MD035HRStyle::is_horizontal_rule("- - -"));
197        assert!(MD035HRStyle::is_horizontal_rule("* * *"));
198        assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
199        assert!(MD035HRStyle::is_horizontal_rule("  ---  ")); // With surrounding whitespace
200
201        // Invalid horizontal rules
202        assert!(!MD035HRStyle::is_horizontal_rule("--")); // Too few characters
203        assert!(!MD035HRStyle::is_horizontal_rule("**"));
204        assert!(!MD035HRStyle::is_horizontal_rule("__"));
205        assert!(!MD035HRStyle::is_horizontal_rule("- -")); // Too few repetitions
206        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
207        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
208        assert!(!MD035HRStyle::is_horizontal_rule("text"));
209        assert!(!MD035HRStyle::is_horizontal_rule(""));
210    }
211
212    #[test]
213    fn test_is_potential_setext_heading() {
214        let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
215
216        // Valid Setext headings
217        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); // ========= under "Heading 1"
218        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); // --- under "Heading 2"
219
220        // Not Setext headings
221        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); // First line can't be underline
222        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); // "Content" is not an underline
223
224        let lines2 = vec!["", "---", "Content"];
225        assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); // Empty line above
226
227        let lines3 = vec!["***", "---"];
228        assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); // HR above
229    }
230
231    #[test]
232    fn test_most_prevalent_hr_style() {
233        // Single style (with blank lines to avoid Setext interpretation)
234        let content = "Content\n\n---\n\nMore\n\n---\n\nText";
235        let lines: Vec<&str> = content.lines().collect();
236        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
237        assert_eq!(
238            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
239            Some("---".to_string())
240        );
241
242        // Multiple styles, one more prevalent
243        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
244        let lines: Vec<&str> = content.lines().collect();
245        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246        assert_eq!(
247            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
248            Some("---".to_string())
249        );
250
251        // Multiple styles, tie broken by first encountered
252        let content = "Content\n\n***\n\nMore\n\n---\n\nText";
253        let lines: Vec<&str> = content.lines().collect();
254        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
255        assert_eq!(
256            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
257            Some("***".to_string())
258        );
259
260        // No horizontal rules
261        let content = "Just\nRegular\nContent";
262        let lines: Vec<&str> = content.lines().collect();
263        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
264        assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
265
266        // Exclude Setext headings
267        let content = "Heading\n---\nContent\n\n***";
268        let lines: Vec<&str> = content.lines().collect();
269        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
270        assert_eq!(
271            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
272            Some("***".to_string())
273        );
274    }
275
276    #[test]
277    fn test_consistent_style() {
278        let rule = MD035HRStyle::new("consistent".to_string());
279        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
280        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
281        let result = rule.check(&ctx).unwrap();
282
283        // Should flag the *** as it doesn't match the most prevalent style ---
284        assert_eq!(result.len(), 1);
285        assert_eq!(result[0].line, 7);
286        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
287    }
288
289    #[test]
290    fn test_specific_style_dashes() {
291        let rule = MD035HRStyle::new("---".to_string());
292        let content = "Content\n\n***\n\nMore\n\n___\n\nText";
293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
294        let result = rule.check(&ctx).unwrap();
295
296        // Should flag both *** and ___ as they don't match ---
297        assert_eq!(result.len(), 2);
298        assert_eq!(result[0].line, 3);
299        assert_eq!(result[1].line, 7);
300        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
301    }
302
303    #[test]
304    fn test_indented_horizontal_rule() {
305        let rule = MD035HRStyle::new("---".to_string());
306        let content = "Content\n\n  ---\n\nMore";
307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308        let result = rule.check(&ctx).unwrap();
309
310        assert_eq!(result.len(), 1);
311        assert_eq!(result[0].line, 3);
312        assert_eq!(result[0].message, "Horizontal rule should not be indented");
313    }
314
315    #[test]
316    fn test_setext_heading_not_flagged() {
317        let rule = MD035HRStyle::new("***".to_string());
318        let content = "Heading\n---\nContent\n***";
319        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
320        let result = rule.check(&ctx).unwrap();
321
322        // Should not flag the --- under "Heading" as it's a Setext heading
323        assert_eq!(result.len(), 0);
324    }
325
326    #[test]
327    fn test_fix_consistent_style() {
328        let rule = MD035HRStyle::new("consistent".to_string());
329        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
331        let fixed = rule.fix(&ctx).unwrap();
332
333        let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
334        assert_eq!(fixed, expected);
335    }
336
337    #[test]
338    fn test_fix_specific_style() {
339        let rule = MD035HRStyle::new("***".to_string());
340        let content = "Content\n\n---\n\nMore\n\n___\n\nText";
341        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342        let fixed = rule.fix(&ctx).unwrap();
343
344        let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
345        assert_eq!(fixed, expected);
346    }
347
348    #[test]
349    fn test_fix_preserves_setext_headings() {
350        let rule = MD035HRStyle::new("***".to_string());
351        let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
352        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
353        let fixed = rule.fix(&ctx).unwrap();
354
355        let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
356        assert_eq!(fixed, expected);
357    }
358
359    #[test]
360    fn test_fix_removes_indentation() {
361        let rule = MD035HRStyle::new("---".to_string());
362        let content = "Content\n\n  ***\n\nMore\n\n   ___\n\nText";
363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
364        let fixed = rule.fix(&ctx).unwrap();
365
366        let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
367        assert_eq!(fixed, expected);
368    }
369
370    #[test]
371    fn test_spaced_styles() {
372        let rule = MD035HRStyle::new("* * *".to_string());
373        let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375        let result = rule.check(&ctx).unwrap();
376
377        assert_eq!(result.len(), 2);
378        assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
379    }
380
381    #[test]
382    fn test_empty_style_uses_consistent() {
383        let rule = MD035HRStyle::new("".to_string());
384        let content = "Content\n\n---\n\nMore\n\n***\n\nText";
385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386        let result = rule.check(&ctx).unwrap();
387
388        // Empty style should behave like "consistent"
389        assert_eq!(result.len(), 1);
390        assert_eq!(result[0].line, 7);
391    }
392
393    #[test]
394    fn test_all_hr_styles_consistent() {
395        let rule = MD035HRStyle::new("consistent".to_string());
396        let content = "Content\n---\nMore\n---\nText\n---";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399
400        // All HRs are the same style, should not flag anything
401        assert_eq!(result.len(), 0);
402    }
403
404    #[test]
405    fn test_no_horizontal_rules() {
406        let rule = MD035HRStyle::new("---".to_string());
407        let content = "Just regular content\nNo horizontal rules here";
408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409        let result = rule.check(&ctx).unwrap();
410
411        assert_eq!(result.len(), 0);
412    }
413
414    #[test]
415    fn test_mixed_spaced_and_unspaced() {
416        let rule = MD035HRStyle::new("consistent".to_string());
417        let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419        let result = rule.check(&ctx).unwrap();
420
421        // Should flag the spaced style as inconsistent
422        assert_eq!(result.len(), 1);
423        assert_eq!(result[0].line, 7);
424    }
425
426    #[test]
427    fn test_trailing_whitespace_in_hr() {
428        let rule = MD035HRStyle::new("---".to_string());
429        let content = "Content\n\n---   \n\nMore";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432
433        // Trailing whitespace is OK for HRs
434        assert_eq!(result.len(), 0);
435    }
436
437    #[test]
438    fn test_hr_in_code_block_not_flagged() {
439        let rule = MD035HRStyle::new("---".to_string());
440        let content =
441            "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let result = rule.check(&ctx).unwrap();
444
445        // Should not flag horizontal rule patterns inside code blocks
446        assert_eq!(result.len(), 0);
447    }
448
449    #[test]
450    fn test_hr_in_code_span_not_flagged() {
451        let rule = MD035HRStyle::new("---".to_string());
452        let content = "Text with inline `---` code span";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454        let result = rule.check(&ctx).unwrap();
455
456        // Should not flag horizontal rule patterns inside code spans
457        assert_eq!(result.len(), 0);
458    }
459
460    #[test]
461    fn test_hr_with_extra_characters() {
462        let rule = MD035HRStyle::new("---".to_string());
463        let content = "Content\n-----\nMore\n--------\nText";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let result = rule.check(&ctx).unwrap();
466
467        // Extra characters in the same style should not be flagged
468        assert_eq!(result.len(), 0);
469    }
470
471    #[test]
472    fn test_default_config() {
473        // The section publishes the rule's DEFAULT, not the instance's setting: every
474        // consumer builds its rules from `Config::default()` and prints this as the
475        // value a user would get without configuring anything.
476        let style = |rule: &MD035HRStyle| {
477            let (name, config) = rule.default_config_section().unwrap();
478            assert_eq!(name, "MD035");
479            config
480                .as_table()
481                .unwrap()
482                .get("style")
483                .unwrap()
484                .as_str()
485                .unwrap()
486                .to_string()
487        };
488
489        // "consistent" is what an unconfigured MD035 enforces, so it is what the
490        // published default must say.
491        assert_eq!(style(&MD035HRStyle::default()), "consistent");
492        assert_eq!(
493            style(&MD035HRStyle::new("***".to_string())),
494            "consistent",
495            "a configured instance must still publish the default"
496        );
497    }
498
499    #[test]
500    fn test_fix_skips_mkdocs_html_markdown() {
501        // MkDocs grid cards use `---` inside `<div markdown>` blocks as card separators
502        // fix() should not replace these with a different HR style
503        let rule = MD035HRStyle::new("***".to_string());
504
505        let content = "Some content\n\n***\n\n<div class=\"grid cards\" markdown>\n\n- Card 1 content\n\n    ---\n\n    Card 1 footer\n\n</div>\n";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
507
508        // check() should not flag the --- inside the div markdown block
509        let warnings = rule.check(&ctx).unwrap();
510        for w in &warnings {
511            assert_ne!(w.line, 9, "check() should not flag --- inside <div markdown> block");
512        }
513
514        // fix() should not modify the --- inside the div markdown block
515        let fixed = rule.fix(&ctx).unwrap();
516        assert!(
517            fixed.contains("    ---"),
518            "fix() should preserve --- inside <div markdown> block, got: {fixed}"
519        );
520    }
521
522    #[test]
523    fn test_is_horizontal_rule_edge_cases() {
524        // Valid: many dashes/asterisks/underscores
525        assert!(MD035HRStyle::is_horizontal_rule("----------"));
526        assert!(MD035HRStyle::is_horizontal_rule("**********"));
527        assert!(MD035HRStyle::is_horizontal_rule("__________"));
528
529        // Valid: spaced with 4+ markers
530        assert!(MD035HRStyle::is_horizontal_rule("- - - -"));
531        assert!(MD035HRStyle::is_horizontal_rule("* * * * *"));
532        assert!(MD035HRStyle::is_horizontal_rule("_ _ _ _ _ _"));
533
534        // Valid: spaced with multiple spaces between markers
535        assert!(MD035HRStyle::is_horizontal_rule("*   *   *"));
536        assert!(MD035HRStyle::is_horizontal_rule("-    -    -"));
537        assert!(MD035HRStyle::is_horizontal_rule("_  _  _"));
538
539        // Valid: trailing space after compact HR
540        assert!(MD035HRStyle::is_horizontal_rule("--- "));
541        assert!(MD035HRStyle::is_horizontal_rule("*** "));
542        assert!(MD035HRStyle::is_horizontal_rule("___ "));
543
544        // Valid: trailing space after spaced HR
545        assert!(MD035HRStyle::is_horizontal_rule("- - - "));
546        assert!(MD035HRStyle::is_horizontal_rule("* * * "));
547
548        // Invalid: mixed marker characters
549        assert!(!MD035HRStyle::is_horizontal_rule("-*-"));
550        assert!(!MD035HRStyle::is_horizontal_rule("- * -"));
551        assert!(!MD035HRStyle::is_horizontal_rule("_-_"));
552        assert!(!MD035HRStyle::is_horizontal_rule("*_*"));
553
554        // Invalid: text after markers
555        assert!(!MD035HRStyle::is_horizontal_rule("---text"));
556        assert!(!MD035HRStyle::is_horizontal_rule("***text"));
557        assert!(!MD035HRStyle::is_horizontal_rule("- - - text"));
558
559        // Invalid: only two markers (spaced)
560        assert!(!MD035HRStyle::is_horizontal_rule("- -"));
561        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
562        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
563
564        // Invalid: letters mixed in
565        assert!(!MD035HRStyle::is_horizontal_rule("-a-b-"));
566        assert!(!MD035HRStyle::is_horizontal_rule("*x*x*"));
567
568        // Invalid: single character
569        assert!(!MD035HRStyle::is_horizontal_rule("-"));
570        assert!(!MD035HRStyle::is_horizontal_rule("*"));
571        assert!(!MD035HRStyle::is_horizontal_rule("_"));
572
573        // Valid: tabs count as whitespace in spaced HRs
574        assert!(MD035HRStyle::is_horizontal_rule("*\t*\t*"));
575        assert!(MD035HRStyle::is_horizontal_rule("-\t-\t-"));
576
577        // Valid: very long HR
578        let long_hr = "-".repeat(200);
579        assert!(MD035HRStyle::is_horizontal_rule(&long_hr));
580    }
581
582    #[test]
583    fn test_frontmatter_not_treated_as_hr() {
584        let rule = MD035HRStyle::new("***".to_string());
585        let content = "---\ntitle: Test\n---\n\n***\n\nContent";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588
589        // Only the *** should be checked, not the frontmatter ---
590        assert_eq!(result.len(), 0);
591    }
592
593    #[test]
594    fn test_fix_skips_mkdocs_html_markdown_preserves_outside() {
595        // Ensure fix() still changes HRs outside of MkDocs blocks
596        let rule = MD035HRStyle::new("***".to_string());
597
598        let content = "Some content\n\n---\n\n<div class=\"grid cards\" markdown>\n\n- Card content\n\n    ---\n\n    Card footer\n\n</div>\n";
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
600
601        let fixed = rule.fix(&ctx).unwrap();
602        // The --- on line 3 (outside div) should be changed to ***
603        let lines: Vec<&str> = fixed.lines().collect();
604        assert_eq!(lines[2], "***", "fix() should change --- outside <div markdown> to ***");
605        // The --- inside the div should remain unchanged
606        assert!(
607            fixed.contains("    ---"),
608            "fix() should preserve --- inside <div markdown>"
609        );
610    }
611
612    /// Helper: assert that fix() produces content with zero check() warnings
613    fn assert_fix_roundtrip(rule: &MD035HRStyle, content: &str, flavor: crate::config::MarkdownFlavor) {
614        let ctx = LintContext::new(content, flavor, None);
615        let fixed = rule.fix(&ctx).unwrap();
616        let ctx2 = LintContext::new(&fixed, flavor, None);
617        let warnings = rule.check(&ctx2).unwrap();
618        assert!(
619            warnings.is_empty(),
620            "fix() output should produce zero check() warnings.\nOriginal:\n{content}\nFixed:\n{fixed}\nWarnings: {warnings:?}"
621        );
622    }
623
624    #[test]
625    fn test_roundtrip_consistent_style() {
626        let rule = MD035HRStyle::new("consistent".to_string());
627        assert_fix_roundtrip(
628            &rule,
629            "Content\n\n---\n\nMore\n\n***\n\nText\n\n---",
630            crate::config::MarkdownFlavor::Standard,
631        );
632    }
633
634    #[test]
635    fn test_roundtrip_specific_style() {
636        let rule = MD035HRStyle::new("***".to_string());
637        assert_fix_roundtrip(
638            &rule,
639            "Content\n\n---\n\nMore\n\n___\n\nText",
640            crate::config::MarkdownFlavor::Standard,
641        );
642    }
643
644    #[test]
645    fn test_roundtrip_indented_hr() {
646        let rule = MD035HRStyle::new("---".to_string());
647        assert_fix_roundtrip(
648            &rule,
649            "Content\n\n  ***\n\nMore\n\n   ___\n\nText",
650            crate::config::MarkdownFlavor::Standard,
651        );
652    }
653
654    #[test]
655    fn test_roundtrip_setext_headings() {
656        let rule = MD035HRStyle::new("***".to_string());
657        assert_fix_roundtrip(
658            &rule,
659            "Heading 1\n=========\nHeading 2\n---\nContent\n\n---",
660            crate::config::MarkdownFlavor::Standard,
661        );
662    }
663
664    #[test]
665    fn test_roundtrip_frontmatter() {
666        let rule = MD035HRStyle::new("***".to_string());
667        assert_fix_roundtrip(
668            &rule,
669            "---\ntitle: Test\n---\n\n***\n\nContent",
670            crate::config::MarkdownFlavor::Standard,
671        );
672    }
673
674    #[test]
675    fn test_roundtrip_mkdocs_html_markdown() {
676        let rule = MD035HRStyle::new("***".to_string());
677        let content = "Some content\n\n---\n\n<div class=\"grid cards\" markdown>\n\n- Card content\n\n    ---\n\n    Card footer\n\n</div>\n";
678        assert_fix_roundtrip(&rule, content, crate::config::MarkdownFlavor::MkDocs);
679    }
680
681    #[test]
682    fn test_roundtrip_spaced_styles() {
683        let rule = MD035HRStyle::new("* * *".to_string());
684        assert_fix_roundtrip(
685            &rule,
686            "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText",
687            crate::config::MarkdownFlavor::Standard,
688        );
689    }
690
691    #[test]
692    fn test_roundtrip_no_warnings() {
693        let rule = MD035HRStyle::new("---".to_string());
694        assert_fix_roundtrip(
695            &rule,
696            "Content\n\n---\n\nMore\n\n---\n\nText",
697            crate::config::MarkdownFlavor::Standard,
698        );
699    }
700
701    #[test]
702    fn test_roundtrip_trailing_newline() {
703        let rule = MD035HRStyle::new("***".to_string());
704        assert_fix_roundtrip(
705            &rule,
706            "Content\n\n---\n\nMore\n",
707            crate::config::MarkdownFlavor::Standard,
708        );
709    }
710}