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 {
146                            range: line_index.line_col_to_byte_range_with_length(i + 1, 1, line.chars().count()),
147                            replacement: 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    fn default_config_section(&self) -> Option<(String, toml::Value)> {
182        let mut map = toml::map::Map::new();
183        map.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
184        Some((self.name().to_string(), toml::Value::Table(map)))
185    }
186
187    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
188    where
189        Self: Sized,
190    {
191        let style = crate::config::get_rule_config_value::<String>(config, "MD035", "style")
192            .unwrap_or_else(|| "consistent".to_string());
193        Box::new(MD035HRStyle::new(style))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::lint_context::LintContext;
201
202    #[test]
203    fn test_is_horizontal_rule() {
204        // Valid horizontal rules
205        assert!(MD035HRStyle::is_horizontal_rule("---"));
206        assert!(MD035HRStyle::is_horizontal_rule("----"));
207        assert!(MD035HRStyle::is_horizontal_rule("***"));
208        assert!(MD035HRStyle::is_horizontal_rule("****"));
209        assert!(MD035HRStyle::is_horizontal_rule("___"));
210        assert!(MD035HRStyle::is_horizontal_rule("____"));
211        assert!(MD035HRStyle::is_horizontal_rule("- - -"));
212        assert!(MD035HRStyle::is_horizontal_rule("* * *"));
213        assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
214        assert!(MD035HRStyle::is_horizontal_rule("  ---  ")); // With surrounding whitespace
215
216        // Invalid horizontal rules
217        assert!(!MD035HRStyle::is_horizontal_rule("--")); // Too few characters
218        assert!(!MD035HRStyle::is_horizontal_rule("**"));
219        assert!(!MD035HRStyle::is_horizontal_rule("__"));
220        assert!(!MD035HRStyle::is_horizontal_rule("- -")); // Too few repetitions
221        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
222        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
223        assert!(!MD035HRStyle::is_horizontal_rule("text"));
224        assert!(!MD035HRStyle::is_horizontal_rule(""));
225    }
226
227    #[test]
228    fn test_is_potential_setext_heading() {
229        let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
230
231        // Valid Setext headings
232        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); // ========= under "Heading 1"
233        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); // --- under "Heading 2"
234
235        // Not Setext headings
236        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); // First line can't be underline
237        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); // "Content" is not an underline
238
239        let lines2 = vec!["", "---", "Content"];
240        assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); // Empty line above
241
242        let lines3 = vec!["***", "---"];
243        assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); // HR above
244    }
245
246    #[test]
247    fn test_most_prevalent_hr_style() {
248        // Single style (with blank lines to avoid Setext interpretation)
249        let content = "Content\n\n---\n\nMore\n\n---\n\nText";
250        let lines: Vec<&str> = content.lines().collect();
251        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
252        assert_eq!(
253            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
254            Some("---".to_string())
255        );
256
257        // Multiple styles, one more prevalent
258        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
259        let lines: Vec<&str> = content.lines().collect();
260        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
261        assert_eq!(
262            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
263            Some("---".to_string())
264        );
265
266        // Multiple styles, tie broken by first encountered
267        let content = "Content\n\n***\n\nMore\n\n---\n\nText";
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        // No horizontal rules
276        let content = "Just\nRegular\nContent";
277        let lines: Vec<&str> = content.lines().collect();
278        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279        assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
280
281        // Exclude Setext headings
282        let content = "Heading\n---\nContent\n\n***";
283        let lines: Vec<&str> = content.lines().collect();
284        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
285        assert_eq!(
286            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
287            Some("***".to_string())
288        );
289    }
290
291    #[test]
292    fn test_consistent_style() {
293        let rule = MD035HRStyle::new("consistent".to_string());
294        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
296        let result = rule.check(&ctx).unwrap();
297
298        // Should flag the *** as it doesn't match the most prevalent style ---
299        assert_eq!(result.len(), 1);
300        assert_eq!(result[0].line, 7);
301        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
302    }
303
304    #[test]
305    fn test_specific_style_dashes() {
306        let rule = MD035HRStyle::new("---".to_string());
307        let content = "Content\n\n***\n\nMore\n\n___\n\nText";
308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
309        let result = rule.check(&ctx).unwrap();
310
311        // Should flag both *** and ___ as they don't match ---
312        assert_eq!(result.len(), 2);
313        assert_eq!(result[0].line, 3);
314        assert_eq!(result[1].line, 7);
315        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
316    }
317
318    #[test]
319    fn test_indented_horizontal_rule() {
320        let rule = MD035HRStyle::new("---".to_string());
321        let content = "Content\n\n  ---\n\nMore";
322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
323        let result = rule.check(&ctx).unwrap();
324
325        assert_eq!(result.len(), 1);
326        assert_eq!(result[0].line, 3);
327        assert_eq!(result[0].message, "Horizontal rule should not be indented");
328    }
329
330    #[test]
331    fn test_setext_heading_not_flagged() {
332        let rule = MD035HRStyle::new("***".to_string());
333        let content = "Heading\n---\nContent\n***";
334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335        let result = rule.check(&ctx).unwrap();
336
337        // Should not flag the --- under "Heading" as it's a Setext heading
338        assert_eq!(result.len(), 0);
339    }
340
341    #[test]
342    fn test_fix_consistent_style() {
343        let rule = MD035HRStyle::new("consistent".to_string());
344        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
346        let fixed = rule.fix(&ctx).unwrap();
347
348        let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
349        assert_eq!(fixed, expected);
350    }
351
352    #[test]
353    fn test_fix_specific_style() {
354        let rule = MD035HRStyle::new("***".to_string());
355        let content = "Content\n\n---\n\nMore\n\n___\n\nText";
356        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357        let fixed = rule.fix(&ctx).unwrap();
358
359        let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
360        assert_eq!(fixed, expected);
361    }
362
363    #[test]
364    fn test_fix_preserves_setext_headings() {
365        let rule = MD035HRStyle::new("***".to_string());
366        let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368        let fixed = rule.fix(&ctx).unwrap();
369
370        let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
371        assert_eq!(fixed, expected);
372    }
373
374    #[test]
375    fn test_fix_removes_indentation() {
376        let rule = MD035HRStyle::new("---".to_string());
377        let content = "Content\n\n  ***\n\nMore\n\n   ___\n\nText";
378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379        let fixed = rule.fix(&ctx).unwrap();
380
381        let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
382        assert_eq!(fixed, expected);
383    }
384
385    #[test]
386    fn test_spaced_styles() {
387        let rule = MD035HRStyle::new("* * *".to_string());
388        let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390        let result = rule.check(&ctx).unwrap();
391
392        assert_eq!(result.len(), 2);
393        assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
394    }
395
396    #[test]
397    fn test_empty_style_uses_consistent() {
398        let rule = MD035HRStyle::new("".to_string());
399        let content = "Content\n\n---\n\nMore\n\n***\n\nText";
400        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401        let result = rule.check(&ctx).unwrap();
402
403        // Empty style should behave like "consistent"
404        assert_eq!(result.len(), 1);
405        assert_eq!(result[0].line, 7);
406    }
407
408    #[test]
409    fn test_all_hr_styles_consistent() {
410        let rule = MD035HRStyle::new("consistent".to_string());
411        let content = "Content\n---\nMore\n---\nText\n---";
412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413        let result = rule.check(&ctx).unwrap();
414
415        // All HRs are the same style, should not flag anything
416        assert_eq!(result.len(), 0);
417    }
418
419    #[test]
420    fn test_no_horizontal_rules() {
421        let rule = MD035HRStyle::new("---".to_string());
422        let content = "Just regular content\nNo horizontal rules here";
423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424        let result = rule.check(&ctx).unwrap();
425
426        assert_eq!(result.len(), 0);
427    }
428
429    #[test]
430    fn test_mixed_spaced_and_unspaced() {
431        let rule = MD035HRStyle::new("consistent".to_string());
432        let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
433        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434        let result = rule.check(&ctx).unwrap();
435
436        // Should flag the spaced style as inconsistent
437        assert_eq!(result.len(), 1);
438        assert_eq!(result[0].line, 7);
439    }
440
441    #[test]
442    fn test_trailing_whitespace_in_hr() {
443        let rule = MD035HRStyle::new("---".to_string());
444        let content = "Content\n\n---   \n\nMore";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447
448        // Trailing whitespace is OK for HRs
449        assert_eq!(result.len(), 0);
450    }
451
452    #[test]
453    fn test_hr_in_code_block_not_flagged() {
454        let rule = MD035HRStyle::new("---".to_string());
455        let content =
456            "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458        let result = rule.check(&ctx).unwrap();
459
460        // Should not flag horizontal rule patterns inside code blocks
461        assert_eq!(result.len(), 0);
462    }
463
464    #[test]
465    fn test_hr_in_code_span_not_flagged() {
466        let rule = MD035HRStyle::new("---".to_string());
467        let content = "Text with inline `---` code span";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469        let result = rule.check(&ctx).unwrap();
470
471        // Should not flag horizontal rule patterns inside code spans
472        assert_eq!(result.len(), 0);
473    }
474
475    #[test]
476    fn test_hr_with_extra_characters() {
477        let rule = MD035HRStyle::new("---".to_string());
478        let content = "Content\n-----\nMore\n--------\nText";
479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480        let result = rule.check(&ctx).unwrap();
481
482        // Extra characters in the same style should not be flagged
483        assert_eq!(result.len(), 0);
484    }
485
486    #[test]
487    fn test_default_config() {
488        let rule = MD035HRStyle::new("consistent".to_string());
489        let (name, config) = rule.default_config_section().unwrap();
490        assert_eq!(name, "MD035");
491
492        let table = config.as_table().unwrap();
493        assert_eq!(table.get("style").unwrap().as_str().unwrap(), "consistent");
494    }
495
496    #[test]
497    fn test_fix_skips_mkdocs_html_markdown() {
498        // MkDocs grid cards use `---` inside `<div markdown>` blocks as card separators
499        // fix() should not replace these with a different HR style
500        let rule = MD035HRStyle::new("***".to_string());
501
502        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";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
504
505        // check() should not flag the --- inside the div markdown block
506        let warnings = rule.check(&ctx).unwrap();
507        for w in &warnings {
508            assert_ne!(w.line, 9, "check() should not flag --- inside <div markdown> block");
509        }
510
511        // fix() should not modify the --- inside the div markdown block
512        let fixed = rule.fix(&ctx).unwrap();
513        assert!(
514            fixed.contains("    ---"),
515            "fix() should preserve --- inside <div markdown> block, got: {fixed}"
516        );
517    }
518
519    #[test]
520    fn test_is_horizontal_rule_edge_cases() {
521        // Valid: many dashes/asterisks/underscores
522        assert!(MD035HRStyle::is_horizontal_rule("----------"));
523        assert!(MD035HRStyle::is_horizontal_rule("**********"));
524        assert!(MD035HRStyle::is_horizontal_rule("__________"));
525
526        // Valid: spaced with 4+ markers
527        assert!(MD035HRStyle::is_horizontal_rule("- - - -"));
528        assert!(MD035HRStyle::is_horizontal_rule("* * * * *"));
529        assert!(MD035HRStyle::is_horizontal_rule("_ _ _ _ _ _"));
530
531        // Valid: spaced with multiple spaces between markers
532        assert!(MD035HRStyle::is_horizontal_rule("*   *   *"));
533        assert!(MD035HRStyle::is_horizontal_rule("-    -    -"));
534        assert!(MD035HRStyle::is_horizontal_rule("_  _  _"));
535
536        // Valid: trailing space after compact HR
537        assert!(MD035HRStyle::is_horizontal_rule("--- "));
538        assert!(MD035HRStyle::is_horizontal_rule("*** "));
539        assert!(MD035HRStyle::is_horizontal_rule("___ "));
540
541        // Valid: trailing space after spaced HR
542        assert!(MD035HRStyle::is_horizontal_rule("- - - "));
543        assert!(MD035HRStyle::is_horizontal_rule("* * * "));
544
545        // Invalid: mixed marker characters
546        assert!(!MD035HRStyle::is_horizontal_rule("-*-"));
547        assert!(!MD035HRStyle::is_horizontal_rule("- * -"));
548        assert!(!MD035HRStyle::is_horizontal_rule("_-_"));
549        assert!(!MD035HRStyle::is_horizontal_rule("*_*"));
550
551        // Invalid: text after markers
552        assert!(!MD035HRStyle::is_horizontal_rule("---text"));
553        assert!(!MD035HRStyle::is_horizontal_rule("***text"));
554        assert!(!MD035HRStyle::is_horizontal_rule("- - - text"));
555
556        // Invalid: only two markers (spaced)
557        assert!(!MD035HRStyle::is_horizontal_rule("- -"));
558        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
559        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
560
561        // Invalid: letters mixed in
562        assert!(!MD035HRStyle::is_horizontal_rule("-a-b-"));
563        assert!(!MD035HRStyle::is_horizontal_rule("*x*x*"));
564
565        // Invalid: single character
566        assert!(!MD035HRStyle::is_horizontal_rule("-"));
567        assert!(!MD035HRStyle::is_horizontal_rule("*"));
568        assert!(!MD035HRStyle::is_horizontal_rule("_"));
569
570        // Valid: tabs count as whitespace in spaced HRs
571        assert!(MD035HRStyle::is_horizontal_rule("*\t*\t*"));
572        assert!(MD035HRStyle::is_horizontal_rule("-\t-\t-"));
573
574        // Valid: very long HR
575        let long_hr = "-".repeat(200);
576        assert!(MD035HRStyle::is_horizontal_rule(&long_hr));
577    }
578
579    #[test]
580    fn test_frontmatter_not_treated_as_hr() {
581        let rule = MD035HRStyle::new("***".to_string());
582        let content = "---\ntitle: Test\n---\n\n***\n\nContent";
583        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584        let result = rule.check(&ctx).unwrap();
585
586        // Only the *** should be checked, not the frontmatter ---
587        assert_eq!(result.len(), 0);
588    }
589
590    #[test]
591    fn test_fix_skips_mkdocs_html_markdown_preserves_outside() {
592        // Ensure fix() still changes HRs outside of MkDocs blocks
593        let rule = MD035HRStyle::new("***".to_string());
594
595        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";
596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
597
598        let fixed = rule.fix(&ctx).unwrap();
599        // The --- on line 3 (outside div) should be changed to ***
600        let lines: Vec<&str> = fixed.lines().collect();
601        assert_eq!(lines[2], "***", "fix() should change --- outside <div markdown> to ***");
602        // The --- inside the div should remain unchanged
603        assert!(
604            fixed.contains("    ---"),
605            "fix() should preserve --- inside <div markdown>"
606        );
607    }
608
609    /// Helper: assert that fix() produces content with zero check() warnings
610    fn assert_fix_roundtrip(rule: &MD035HRStyle, content: &str, flavor: crate::config::MarkdownFlavor) {
611        let ctx = LintContext::new(content, flavor, None);
612        let fixed = rule.fix(&ctx).unwrap();
613        let ctx2 = LintContext::new(&fixed, flavor, None);
614        let warnings = rule.check(&ctx2).unwrap();
615        assert!(
616            warnings.is_empty(),
617            "fix() output should produce zero check() warnings.\nOriginal:\n{content}\nFixed:\n{fixed}\nWarnings: {warnings:?}"
618        );
619    }
620
621    #[test]
622    fn test_roundtrip_consistent_style() {
623        let rule = MD035HRStyle::new("consistent".to_string());
624        assert_fix_roundtrip(
625            &rule,
626            "Content\n\n---\n\nMore\n\n***\n\nText\n\n---",
627            crate::config::MarkdownFlavor::Standard,
628        );
629    }
630
631    #[test]
632    fn test_roundtrip_specific_style() {
633        let rule = MD035HRStyle::new("***".to_string());
634        assert_fix_roundtrip(
635            &rule,
636            "Content\n\n---\n\nMore\n\n___\n\nText",
637            crate::config::MarkdownFlavor::Standard,
638        );
639    }
640
641    #[test]
642    fn test_roundtrip_indented_hr() {
643        let rule = MD035HRStyle::new("---".to_string());
644        assert_fix_roundtrip(
645            &rule,
646            "Content\n\n  ***\n\nMore\n\n   ___\n\nText",
647            crate::config::MarkdownFlavor::Standard,
648        );
649    }
650
651    #[test]
652    fn test_roundtrip_setext_headings() {
653        let rule = MD035HRStyle::new("***".to_string());
654        assert_fix_roundtrip(
655            &rule,
656            "Heading 1\n=========\nHeading 2\n---\nContent\n\n---",
657            crate::config::MarkdownFlavor::Standard,
658        );
659    }
660
661    #[test]
662    fn test_roundtrip_frontmatter() {
663        let rule = MD035HRStyle::new("***".to_string());
664        assert_fix_roundtrip(
665            &rule,
666            "---\ntitle: Test\n---\n\n***\n\nContent",
667            crate::config::MarkdownFlavor::Standard,
668        );
669    }
670
671    #[test]
672    fn test_roundtrip_mkdocs_html_markdown() {
673        let rule = MD035HRStyle::new("***".to_string());
674        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";
675        assert_fix_roundtrip(&rule, content, crate::config::MarkdownFlavor::MkDocs);
676    }
677
678    #[test]
679    fn test_roundtrip_spaced_styles() {
680        let rule = MD035HRStyle::new("* * *".to_string());
681        assert_fix_roundtrip(
682            &rule,
683            "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText",
684            crate::config::MarkdownFlavor::Standard,
685        );
686    }
687
688    #[test]
689    fn test_roundtrip_no_warnings() {
690        let rule = MD035HRStyle::new("---".to_string());
691        assert_fix_roundtrip(
692            &rule,
693            "Content\n\n---\n\nMore\n\n---\n\nText",
694            crate::config::MarkdownFlavor::Standard,
695        );
696    }
697
698    #[test]
699    fn test_roundtrip_trailing_newline() {
700        let rule = MD035HRStyle::new("***".to_string());
701        assert_fix_roundtrip(
702            &rule,
703            "Content\n\n---\n\nMore\n",
704            crate::config::MarkdownFlavor::Standard,
705        );
706    }
707}