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