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    pub(crate) 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    /// Whether `replacement` is a setext underline when it follows a paragraph line: only
51    /// `-` characters, ignoring surrounding spaces and tabs. A spaced run such as `- - -`
52    /// is not an underline, and neither is a run of `*` or `_`.
53    fn is_setext_underline_text(replacement: &str) -> bool {
54        let trimmed = replacement.trim_matches(|c| c == ' ' || c == '\t');
55        !trimmed.is_empty() && trimmed.chars().all(|c| c == '-')
56    }
57
58    /// Whether writing `replacement` on line `index` could turn the line above into a
59    /// setext heading. Any non-blank line above counts, whatever block it belongs to: the
60    /// rule cannot tell every paragraph continuation apart from other text, and a declined
61    /// fix is retried once a blank line separates the two.
62    fn would_form_setext_underline(lines: &[&str], index: usize, replacement: &str) -> bool {
63        Self::is_setext_underline_text(replacement)
64            && index > 0
65            && lines.get(index - 1).is_some_and(|prev| !prev.trim().is_empty())
66    }
67
68    /// Whether writing `replacement` on the first line would make the document open with
69    /// front matter. A `---` first line followed by another `---` line is a front matter
70    /// block, so the rewrite would turn two breaks into metadata and hide them from every
71    /// later pass.
72    fn would_open_front_matter(ctx: &crate::lint_context::LintContext, index: usize, replacement: &str) -> bool {
73        if index != 0 || ctx.front_matter_end_line() > 0 {
74            return false;
75        }
76        let rest = ctx.content.find('\n').map_or("", |end| &ctx.content[end..]);
77        let candidate = format!("{replacement}{rest}");
78        crate::lint_context::LintContext::new(&candidate, ctx.flavor, None).front_matter_end_line() > 0
79    }
80
81    /// Zero-based indices of the lines holding a thematic break that the parser places
82    /// inside a list item or block quote. Their indentation positions them in the
83    /// container and is not a style error.
84    fn container_break_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
85        use crate::lint_context::document_parser::blocks::BlockKind;
86        let mut depth = 0usize;
87        let mut result = std::collections::HashSet::new();
88        for (kind, range) in &ctx.parser_blocks().events {
89            match kind {
90                BlockKind::ItemStart | BlockKind::QuoteStart => depth += 1,
91                BlockKind::ItemEnd | BlockKind::QuoteEnd => depth = depth.saturating_sub(1),
92                BlockKind::Rule if depth > 0 => {
93                    let line = ctx.lines.partition_point(|l| l.byte_offset <= range.start);
94                    result.insert(line.saturating_sub(1));
95                }
96                _ => {}
97            }
98        }
99        result
100    }
101
102    /// Exclude markers owned by literal syntax before checking or inferring style.
103    fn should_skip_hr_line(ctx: &crate::lint_context::LintContext, index: usize) -> bool {
104        let Some(line) = ctx.lines.get(index) else {
105            return false;
106        };
107        let marker_offset = line.byte_offset + line.indent;
108        line.in_front_matter
109            || line.in_code_block
110            || line.in_mkdocs_html_markdown
111            || line.in_html_block
112            || ctx.line_holds_html_block(index)
113            || line.in_html_comment
114            || ctx.is_inside_template_code(marker_offset)
115            || ctx.is_inside_mdx_code(marker_offset)
116    }
117
118    /// Find the most prevalent style among visible horizontal rules.
119    fn most_prevalent_hr_style(lines: &[&str], ctx: &crate::lint_context::LintContext) -> Option<String> {
120        use std::collections::HashMap;
121        let mut counts: HashMap<&str, usize> = HashMap::new();
122        let mut order: Vec<&str> = Vec::new();
123        for (i, line) in lines.iter().enumerate() {
124            if !Self::is_horizontal_rule(line) || Self::should_skip_hr_line(ctx, i) {
125                continue;
126            }
127
128            if !Self::is_potential_setext_heading(lines, i) {
129                let style = line.trim();
130                let counter = counts.entry(style).or_insert(0);
131                *counter += 1;
132                if *counter == 1 {
133                    order.push(style);
134                }
135            }
136        }
137        // Find the style with the highest count, breaking ties by first encountered
138        counts
139            .iter()
140            .max_by_key(|&(style, count)| {
141                (
142                    *count,
143                    -(order.iter().position(|&s| s == *style).unwrap_or(usize::MAX) as isize),
144                )
145            })
146            .map(|(style, _)| style.to_string())
147    }
148}
149
150impl Rule for MD035HRStyle {
151    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
152        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(35))
153    }
154
155    fn name(&self) -> &'static str {
156        "MD035"
157    }
158
159    fn description(&self) -> &'static str {
160        "Horizontal rule style"
161    }
162
163    fn category(&self) -> RuleCategory {
164        RuleCategory::Whitespace
165    }
166
167    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
168        let mut warnings = Vec::new();
169        let lines = ctx.raw_lines();
170
171        // Use the configured style or find the most prevalent HR style
172        let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
173            Self::most_prevalent_hr_style(lines, ctx).unwrap_or_else(|| "---".to_string())
174        } else {
175            self.config.style.clone()
176        };
177
178        let mut container_breaks: Option<std::collections::HashSet<usize>> = None;
179
180        for (i, line) in lines.iter().enumerate() {
181            if !Self::is_horizontal_rule(line) || Self::should_skip_hr_line(ctx, i) {
182                continue;
183            }
184
185            // Skip if this is a potential Setext heading underline
186            if Self::is_potential_setext_heading(lines, i) {
187                continue;
188            }
189
190            // Check if this HR matches the expected style
191            let leading = &line[..line.len() - line.trim_start().len()];
192            // Indentation is only an error for a break that sits in no list item or quote;
193            // inside one it is the break's membership in the container and is kept.
194            let in_container = !leading.is_empty()
195                && container_breaks
196                    .get_or_insert_with(|| Self::container_break_lines(ctx))
197                    .contains(&i);
198            let has_indentation = !leading.is_empty() && !in_container;
199            let style_mismatch = line.trim() != expected_style;
200
201            if style_mismatch || has_indentation {
202                // Calculate precise character range for the entire horizontal rule
203                let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
204
205                let replacement = if in_container {
206                    format!("{leading}{expected_style}")
207                } else {
208                    expected_style.clone()
209                };
210                let fix = if Self::would_form_setext_underline(lines, i, &replacement)
211                    || Self::would_open_front_matter(ctx, i, &replacement)
212                {
213                    None
214                } else {
215                    Some(Fix::new(
216                        ctx.line_column_byte_range_with_length(i + 1, 1, line.chars().count()),
217                        replacement,
218                    ))
219                };
220
221                warnings.push(LintWarning {
222                    rule_name: Some(self.name().to_string()),
223                    line: start_line,
224                    column: start_col,
225                    end_line,
226                    end_column: end_col,
227                    message: if has_indentation {
228                        "Horizontal rule should not be indented".to_string()
229                    } else {
230                        format!("Horizontal rule style should be \"{expected_style}\"")
231                    },
232                    severity: Severity::Warning,
233                    fix,
234                });
235            }
236        }
237
238        Ok(warnings)
239    }
240
241    fn fixes_from_diagnostics(&self) -> bool {
242        true
243    }
244
245    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
246        if self.should_skip(ctx) {
247            return Ok(ctx.content.to_string());
248        }
249        let warnings = self.check(ctx)?;
250        if warnings.is_empty() {
251            return Ok(ctx.content.to_string());
252        }
253        let warnings =
254            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
255        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
256            .map_err(crate::rule::LintError::InvalidInput)
257    }
258
259    fn as_any(&self) -> &dyn std::any::Any {
260        self
261    }
262
263    /// Check if this rule should be skipped
264    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
265        // HR can use -, *, or _
266        ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
267    }
268
269    crate::impl_rule_config_methods!(MD035Config);
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::lint_context::LintContext;
276
277    #[test]
278    fn test_is_horizontal_rule() {
279        // Valid horizontal rules
280        assert!(MD035HRStyle::is_horizontal_rule("---"));
281        assert!(MD035HRStyle::is_horizontal_rule("----"));
282        assert!(MD035HRStyle::is_horizontal_rule("***"));
283        assert!(MD035HRStyle::is_horizontal_rule("****"));
284        assert!(MD035HRStyle::is_horizontal_rule("___"));
285        assert!(MD035HRStyle::is_horizontal_rule("____"));
286        assert!(MD035HRStyle::is_horizontal_rule("- - -"));
287        assert!(MD035HRStyle::is_horizontal_rule("* * *"));
288        assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
289        assert!(MD035HRStyle::is_horizontal_rule("  ---  ")); // With surrounding whitespace
290
291        // Invalid horizontal rules
292        assert!(!MD035HRStyle::is_horizontal_rule("--")); // Too few characters
293        assert!(!MD035HRStyle::is_horizontal_rule("**"));
294        assert!(!MD035HRStyle::is_horizontal_rule("__"));
295        assert!(!MD035HRStyle::is_horizontal_rule("- -")); // Too few repetitions
296        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
297        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
298        assert!(!MD035HRStyle::is_horizontal_rule("text"));
299        assert!(!MD035HRStyle::is_horizontal_rule(""));
300    }
301
302    #[test]
303    fn test_is_potential_setext_heading() {
304        let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
305
306        // Valid Setext headings
307        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); // ========= under "Heading 1"
308        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); // --- under "Heading 2"
309
310        // Not Setext headings
311        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); // First line can't be underline
312        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); // "Content" is not an underline
313
314        let lines2 = vec!["", "---", "Content"];
315        assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); // Empty line above
316
317        let lines3 = vec!["***", "---"];
318        assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); // HR above
319    }
320
321    #[test]
322    fn test_most_prevalent_hr_style() {
323        // Single style (with blank lines to avoid Setext interpretation)
324        let content = "Content\n\n---\n\nMore\n\n---\n\nText";
325        let lines: Vec<&str> = content.lines().collect();
326        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327        assert_eq!(
328            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
329            Some("---".to_string())
330        );
331
332        // Multiple styles, one more prevalent
333        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
334        let lines: Vec<&str> = content.lines().collect();
335        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336        assert_eq!(
337            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
338            Some("---".to_string())
339        );
340
341        // Multiple styles, tie broken by first encountered
342        let content = "Content\n\n***\n\nMore\n\n---\n\nText";
343        let lines: Vec<&str> = content.lines().collect();
344        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345        assert_eq!(
346            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
347            Some("***".to_string())
348        );
349
350        // No horizontal rules
351        let content = "Just\nRegular\nContent";
352        let lines: Vec<&str> = content.lines().collect();
353        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354        assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
355
356        // Exclude Setext headings
357        let content = "Heading\n---\nContent\n\n***";
358        let lines: Vec<&str> = content.lines().collect();
359        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360        assert_eq!(
361            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
362            Some("***".to_string())
363        );
364    }
365
366    #[test]
367    fn test_consistent_style() {
368        let rule = MD035HRStyle::new("consistent".to_string());
369        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
370        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371        let result = rule.check(&ctx).unwrap();
372
373        // Should flag the *** as it doesn't match the most prevalent style ---
374        assert_eq!(result.len(), 1);
375        assert_eq!(result[0].line, 7);
376        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
377    }
378
379    #[test]
380    fn test_specific_style_dashes() {
381        let rule = MD035HRStyle::new("---".to_string());
382        let content = "Content\n\n***\n\nMore\n\n___\n\nText";
383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384        let result = rule.check(&ctx).unwrap();
385
386        // Should flag both *** and ___ as they don't match ---
387        assert_eq!(result.len(), 2);
388        assert_eq!(result[0].line, 3);
389        assert_eq!(result[1].line, 7);
390        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
391    }
392
393    #[test]
394    fn test_indented_horizontal_rule() {
395        let rule = MD035HRStyle::new("---".to_string());
396        let content = "Content\n\n  ---\n\nMore";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399
400        assert_eq!(result.len(), 1);
401        assert_eq!(result[0].line, 3);
402        assert_eq!(result[0].message, "Horizontal rule should not be indented");
403    }
404
405    #[test]
406    fn test_setext_heading_not_flagged() {
407        let rule = MD035HRStyle::new("***".to_string());
408        let content = "Heading\n---\nContent\n***";
409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410        let result = rule.check(&ctx).unwrap();
411
412        // Should not flag the --- under "Heading" as it's a Setext heading
413        assert_eq!(result.len(), 0);
414    }
415
416    #[test]
417    fn test_fix_consistent_style() {
418        let rule = MD035HRStyle::new("consistent".to_string());
419        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421        let fixed = rule.fix(&ctx).unwrap();
422
423        let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
424        assert_eq!(fixed, expected);
425    }
426
427    #[test]
428    fn test_fix_specific_style() {
429        let rule = MD035HRStyle::new("***".to_string());
430        let content = "Content\n\n---\n\nMore\n\n___\n\nText";
431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432        let fixed = rule.fix(&ctx).unwrap();
433
434        let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
435        assert_eq!(fixed, expected);
436    }
437
438    #[test]
439    fn test_fix_preserves_setext_headings() {
440        let rule = MD035HRStyle::new("***".to_string());
441        let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let fixed = rule.fix(&ctx).unwrap();
444
445        let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
446        assert_eq!(fixed, expected);
447    }
448
449    #[test]
450    fn test_fix_removes_indentation() {
451        let rule = MD035HRStyle::new("---".to_string());
452        let content = "Content\n\n  ***\n\nMore\n\n   ___\n\nText";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454        let fixed = rule.fix(&ctx).unwrap();
455
456        let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
457        assert_eq!(fixed, expected);
458    }
459
460    #[test]
461    fn test_spaced_styles() {
462        let rule = MD035HRStyle::new("* * *".to_string());
463        let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let result = rule.check(&ctx).unwrap();
466
467        assert_eq!(result.len(), 2);
468        assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
469    }
470
471    #[test]
472    fn test_empty_style_uses_consistent() {
473        let rule = MD035HRStyle::new("".to_string());
474        let content = "Content\n\n---\n\nMore\n\n***\n\nText";
475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476        let result = rule.check(&ctx).unwrap();
477
478        // Empty style should behave like "consistent"
479        assert_eq!(result.len(), 1);
480        assert_eq!(result[0].line, 7);
481    }
482
483    #[test]
484    fn test_all_hr_styles_consistent() {
485        let rule = MD035HRStyle::new("consistent".to_string());
486        let content = "Content\n---\nMore\n---\nText\n---";
487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488        let result = rule.check(&ctx).unwrap();
489
490        // All HRs are the same style, should not flag anything
491        assert_eq!(result.len(), 0);
492    }
493
494    #[test]
495    fn test_no_horizontal_rules() {
496        let rule = MD035HRStyle::new("---".to_string());
497        let content = "Just regular content\nNo horizontal rules here";
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499        let result = rule.check(&ctx).unwrap();
500
501        assert_eq!(result.len(), 0);
502    }
503
504    #[test]
505    fn test_mixed_spaced_and_unspaced() {
506        let rule = MD035HRStyle::new("consistent".to_string());
507        let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510
511        // Should flag the spaced style as inconsistent
512        assert_eq!(result.len(), 1);
513        assert_eq!(result[0].line, 7);
514    }
515
516    #[test]
517    fn test_trailing_whitespace_in_hr() {
518        let rule = MD035HRStyle::new("---".to_string());
519        let content = "Content\n\n---   \n\nMore";
520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521        let result = rule.check(&ctx).unwrap();
522
523        // Trailing whitespace is OK for HRs
524        assert_eq!(result.len(), 0);
525    }
526
527    #[test]
528    fn test_hr_in_code_block_not_flagged() {
529        let rule = MD035HRStyle::new("---".to_string());
530        let content =
531            "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534
535        // Should not flag horizontal rule patterns inside code blocks
536        assert_eq!(result.len(), 0);
537    }
538
539    #[test]
540    fn test_hr_in_code_span_not_flagged() {
541        let rule = MD035HRStyle::new("---".to_string());
542        let content = "Text with inline `---` code span";
543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544        let result = rule.check(&ctx).unwrap();
545
546        // Should not flag horizontal rule patterns inside code spans
547        assert_eq!(result.len(), 0);
548    }
549
550    #[test]
551    fn test_hr_with_extra_characters() {
552        let rule = MD035HRStyle::new("---".to_string());
553        let content = "Content\n-----\nMore\n--------\nText";
554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555        let result = rule.check(&ctx).unwrap();
556
557        // Extra characters in the same style should not be flagged
558        assert_eq!(result.len(), 0);
559    }
560
561    #[test]
562    fn test_default_config() {
563        // The section publishes the rule's DEFAULT, not the instance's setting: every
564        // consumer builds its rules from `Config::default()` and prints this as the
565        // value a user would get without configuring anything.
566        let style = |rule: &MD035HRStyle| {
567            let (name, config) = rule.default_config_section().unwrap();
568            assert_eq!(name, "MD035");
569            config
570                .as_table()
571                .unwrap()
572                .get("style")
573                .unwrap()
574                .as_str()
575                .unwrap()
576                .to_string()
577        };
578
579        // "consistent" is what an unconfigured MD035 enforces, so it is what the
580        // published default must say.
581        assert_eq!(style(&MD035HRStyle::default()), "consistent");
582        assert_eq!(
583            style(&MD035HRStyle::new("***".to_string())),
584            "consistent",
585            "a configured instance must still publish the default"
586        );
587    }
588
589    #[test]
590    fn test_fix_skips_mkdocs_html_markdown() {
591        // MkDocs grid cards use `---` inside `<div markdown>` blocks as card separators
592        // fix() should not replace these with a different HR style
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 1 content\n\n    ---\n\n    Card 1 footer\n\n</div>\n";
596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
597
598        // check() should not flag the --- inside the div markdown block
599        let warnings = rule.check(&ctx).unwrap();
600        for w in &warnings {
601            assert_ne!(w.line, 9, "check() should not flag --- inside <div markdown> block");
602        }
603
604        // fix() should not modify the --- inside the div markdown block
605        let fixed = rule.fix(&ctx).unwrap();
606        assert!(
607            fixed.contains("    ---"),
608            "fix() should preserve --- inside <div markdown> block, got: {fixed}"
609        );
610    }
611
612    #[test]
613    fn test_is_horizontal_rule_edge_cases() {
614        // Valid: many dashes/asterisks/underscores
615        assert!(MD035HRStyle::is_horizontal_rule("----------"));
616        assert!(MD035HRStyle::is_horizontal_rule("**********"));
617        assert!(MD035HRStyle::is_horizontal_rule("__________"));
618
619        // Valid: spaced with 4+ markers
620        assert!(MD035HRStyle::is_horizontal_rule("- - - -"));
621        assert!(MD035HRStyle::is_horizontal_rule("* * * * *"));
622        assert!(MD035HRStyle::is_horizontal_rule("_ _ _ _ _ _"));
623
624        // Valid: spaced with multiple spaces between markers
625        assert!(MD035HRStyle::is_horizontal_rule("*   *   *"));
626        assert!(MD035HRStyle::is_horizontal_rule("-    -    -"));
627        assert!(MD035HRStyle::is_horizontal_rule("_  _  _"));
628
629        // Valid: trailing space after compact HR
630        assert!(MD035HRStyle::is_horizontal_rule("--- "));
631        assert!(MD035HRStyle::is_horizontal_rule("*** "));
632        assert!(MD035HRStyle::is_horizontal_rule("___ "));
633
634        // Valid: trailing space after spaced HR
635        assert!(MD035HRStyle::is_horizontal_rule("- - - "));
636        assert!(MD035HRStyle::is_horizontal_rule("* * * "));
637
638        // Invalid: mixed marker characters
639        assert!(!MD035HRStyle::is_horizontal_rule("-*-"));
640        assert!(!MD035HRStyle::is_horizontal_rule("- * -"));
641        assert!(!MD035HRStyle::is_horizontal_rule("_-_"));
642        assert!(!MD035HRStyle::is_horizontal_rule("*_*"));
643
644        // Invalid: text after markers
645        assert!(!MD035HRStyle::is_horizontal_rule("---text"));
646        assert!(!MD035HRStyle::is_horizontal_rule("***text"));
647        assert!(!MD035HRStyle::is_horizontal_rule("- - - text"));
648
649        // Invalid: only two markers (spaced)
650        assert!(!MD035HRStyle::is_horizontal_rule("- -"));
651        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
652        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
653
654        // Invalid: letters mixed in
655        assert!(!MD035HRStyle::is_horizontal_rule("-a-b-"));
656        assert!(!MD035HRStyle::is_horizontal_rule("*x*x*"));
657
658        // Invalid: single character
659        assert!(!MD035HRStyle::is_horizontal_rule("-"));
660        assert!(!MD035HRStyle::is_horizontal_rule("*"));
661        assert!(!MD035HRStyle::is_horizontal_rule("_"));
662
663        // Valid: tabs count as whitespace in spaced HRs
664        assert!(MD035HRStyle::is_horizontal_rule("*\t*\t*"));
665        assert!(MD035HRStyle::is_horizontal_rule("-\t-\t-"));
666
667        // Valid: very long HR
668        let long_hr = "-".repeat(200);
669        assert!(MD035HRStyle::is_horizontal_rule(&long_hr));
670    }
671
672    #[test]
673    fn test_frontmatter_not_treated_as_hr() {
674        let rule = MD035HRStyle::new("***".to_string());
675        let content = "---\ntitle: Test\n---\n\n***\n\nContent";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let result = rule.check(&ctx).unwrap();
678
679        // Only the *** should be checked, not the frontmatter ---
680        assert_eq!(result.len(), 0);
681    }
682
683    #[test]
684    fn test_fix_skips_mkdocs_html_markdown_preserves_outside() {
685        // Ensure fix() still changes HRs outside of MkDocs blocks
686        let rule = MD035HRStyle::new("***".to_string());
687
688        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";
689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
690
691        let fixed = rule.fix(&ctx).unwrap();
692        // The --- on line 3 (outside div) should be changed to ***
693        let lines: Vec<&str> = fixed.lines().collect();
694        assert_eq!(lines[2], "***", "fix() should change --- outside <div markdown> to ***");
695        // The --- inside the div should remain unchanged
696        assert!(
697            fixed.contains("    ---"),
698            "fix() should preserve --- inside <div markdown>"
699        );
700    }
701
702    /// Helper: assert that fix() produces content with zero check() warnings
703    fn assert_fix_roundtrip(rule: &MD035HRStyle, content: &str, flavor: crate::config::MarkdownFlavor) {
704        let ctx = LintContext::new(content, flavor, None);
705        let fixed = rule.fix(&ctx).unwrap();
706        let ctx2 = LintContext::new(&fixed, flavor, None);
707        let warnings = rule.check(&ctx2).unwrap();
708        assert!(
709            warnings.is_empty(),
710            "fix() output should produce zero check() warnings.\nOriginal:\n{content}\nFixed:\n{fixed}\nWarnings: {warnings:?}"
711        );
712    }
713
714    #[test]
715    fn test_roundtrip_consistent_style() {
716        let rule = MD035HRStyle::new("consistent".to_string());
717        assert_fix_roundtrip(
718            &rule,
719            "Content\n\n---\n\nMore\n\n***\n\nText\n\n---",
720            crate::config::MarkdownFlavor::Standard,
721        );
722    }
723
724    #[test]
725    fn test_roundtrip_specific_style() {
726        let rule = MD035HRStyle::new("***".to_string());
727        assert_fix_roundtrip(
728            &rule,
729            "Content\n\n---\n\nMore\n\n___\n\nText",
730            crate::config::MarkdownFlavor::Standard,
731        );
732    }
733
734    #[test]
735    fn test_roundtrip_indented_hr() {
736        let rule = MD035HRStyle::new("---".to_string());
737        assert_fix_roundtrip(
738            &rule,
739            "Content\n\n  ***\n\nMore\n\n   ___\n\nText",
740            crate::config::MarkdownFlavor::Standard,
741        );
742    }
743
744    #[test]
745    fn test_roundtrip_setext_headings() {
746        let rule = MD035HRStyle::new("***".to_string());
747        assert_fix_roundtrip(
748            &rule,
749            "Heading 1\n=========\nHeading 2\n---\nContent\n\n---",
750            crate::config::MarkdownFlavor::Standard,
751        );
752    }
753
754    #[test]
755    fn test_roundtrip_frontmatter() {
756        let rule = MD035HRStyle::new("***".to_string());
757        assert_fix_roundtrip(
758            &rule,
759            "---\ntitle: Test\n---\n\n***\n\nContent",
760            crate::config::MarkdownFlavor::Standard,
761        );
762    }
763
764    #[test]
765    fn test_roundtrip_mkdocs_html_markdown() {
766        let rule = MD035HRStyle::new("***".to_string());
767        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";
768        assert_fix_roundtrip(&rule, content, crate::config::MarkdownFlavor::MkDocs);
769    }
770
771    #[test]
772    fn test_roundtrip_spaced_styles() {
773        let rule = MD035HRStyle::new("* * *".to_string());
774        assert_fix_roundtrip(
775            &rule,
776            "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText",
777            crate::config::MarkdownFlavor::Standard,
778        );
779    }
780
781    #[test]
782    fn test_roundtrip_no_warnings() {
783        let rule = MD035HRStyle::new("---".to_string());
784        assert_fix_roundtrip(
785            &rule,
786            "Content\n\n---\n\nMore\n\n---\n\nText",
787            crate::config::MarkdownFlavor::Standard,
788        );
789    }
790
791    #[test]
792    fn test_roundtrip_trailing_newline() {
793        let rule = MD035HRStyle::new("***".to_string());
794        assert_fix_roundtrip(
795            &rule,
796            "Content\n\n---\n\nMore\n",
797            crate::config::MarkdownFlavor::Standard,
798        );
799    }
800
801    #[test]
802    fn test_literal_rules_are_preserved_beside_visible_rules() {
803        use crate::config::MarkdownFlavor;
804        let rule = MD035HRStyle::new("---".to_string());
805        for (flavor, code) in [
806            (
807                MarkdownFlavor::Standard,
808                "{% set unused=\"first\n\n***\n\nlast\" %}{{ unused|length }}",
809            ),
810            (MarkdownFlavor::Hugo, "{{< note title=`first\n\n***\n\nlast` >}}"),
811            (
812                MarkdownFlavor::MDX,
813                "export const text = `first\n\n***\n\nlast`\n\n{text}",
814            ),
815            (MarkdownFlavor::MDX, "{`first\n\n***\n\nlast`}"),
816            (MarkdownFlavor::Standard, "<pre>\n\n***\n\n</pre>"),
817            (MarkdownFlavor::Standard, "<!--\n\n***\n\n-->"),
818        ] {
819            for ending in ["\n", "\r\n"] {
820                let source = format!("{code}\n\n***\n").replace('\n', ending);
821                let expected = format!("{code}\n\n---\n").replace('\n', ending);
822                let ctx = LintContext::new(&source, flavor, None);
823                assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
824                assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
825            }
826        }
827    }
828
829    #[test]
830    fn test_literal_rules_do_not_choose_visible_style() {
831        use crate::config::MarkdownFlavor;
832        let rule = MD035HRStyle::default();
833        for (flavor, code) in [
834            (
835                MarkdownFlavor::Standard,
836                "{% set unused=\"first\n\n___\n\n___\n\nlast\" %}{{ unused|length }}",
837            ),
838            (
839                MarkdownFlavor::MDX,
840                "export const text = `first\n\n___\n\n___\n\nlast`\n\n{text}",
841            ),
842            (MarkdownFlavor::Standard, "<pre>\n\n___\n\n___\n\n</pre>"),
843            (MarkdownFlavor::Standard, "<!--\n\n___\n\n___\n\n-->"),
844        ] {
845            let source = format!("{code}\n\n***\n");
846            let ctx = LintContext::new(&source, flavor, None);
847            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
848            assert_eq!(rule.fix(&ctx).unwrap(), source);
849        }
850    }
851
852    #[test]
853    fn test_visible_mdx_jsx_body_rules_remain_fixable() {
854        let rule = MD035HRStyle::new("---".to_string());
855        for tag in ["div", "pre"] {
856            let source = format!("<{tag}>\n\n***\n\n</{tag}>\n\n***\n");
857            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
858            assert_eq!(rule.check(&ctx).unwrap().len(), 2);
859            assert_eq!(rule.fix(&ctx).unwrap(), source.replace("***", "---"));
860        }
861    }
862
863    /// Parser events of a document with source offsets dropped, so two texts can be
864    /// compared for identical block structure.
865    fn structure(text: &str) -> Vec<String> {
866        pulldown_cmark::Parser::new_ext(text, crate::utils::parser_options::rumdl_parser_options_for(text))
867            .map(|event| format!("{event:?}"))
868            .collect()
869    }
870
871    fn fix_with_style(style: &str, content: &str) -> (String, Vec<crate::rule::LintWarning>) {
872        let rule = MD035HRStyle::new(style.to_string());
873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874        (rule.fix(&ctx).unwrap(), rule.check(&ctx).unwrap())
875    }
876
877    #[test]
878    fn test_fix_declined_when_dash_run_would_underline_paragraph() {
879        let cases: &[(&str, &str)] = &[
880            ("underscores", "Text\n___\nMore\n"),
881            ("asterisks", "Text\n***\nMore\n"),
882            ("spaced asterisks", "Text\n* * *\nMore\n"),
883            ("spaced underscores", "Text\n_ _ _\nMore\n"),
884            ("long underscores", "Text\n_____\nMore\n"),
885            ("indent 1", "Text\n ___\nMore\n"),
886            ("indent 3", "Text\n   ***\nMore\n"),
887            ("trailing whitespace", "Text\n***   \nMore\n"),
888            (
889                "two-line paragraph",
890                "First line.\nSecond line.\n___\nNext paragraph.\n",
891            ),
892            ("one-row table", "| a |\n***\n"),
893            ("div marker above", "Text\n:::\n***\n"),
894            ("reference definition above", "[a]: http://example.com\n***\n"),
895            ("heading above", "# H\n***\n\nB\n"),
896            ("fence close above", "```\ncode\n```\n***\n"),
897            ("list item paragraph", "- item\n  para\n  ___\n"),
898            ("ordered list item paragraph", "1. one\n   two\n   ***\n"),
899            ("list item at column 0", "- item\n___\n"),
900            ("lazy blockquote", "> quote\n___\n"),
901        ];
902        for (name, content) in cases {
903            let (fixed, warnings) = fix_with_style("---", content);
904            assert_eq!(&fixed, content, "{name}: the fix must leave the document unchanged");
905            assert_eq!(warnings.len(), 1, "{name}: the style warning must remain");
906            assert!(warnings[0].fix.is_none(), "{name}: the warning must carry no fix");
907        }
908    }
909
910    #[test]
911    fn test_fix_declined_on_issue_input_keeps_paragraph() {
912        let content = "# Doc\n\nIntro.\n\n---\n\nFirst line.\nSecond line.\n___\nNext paragraph.\n";
913        let rule = MD035HRStyle::new("consistent".to_string());
914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915        let warnings = rule.check(&ctx).unwrap();
916        assert_eq!(warnings.len(), 1);
917        assert_eq!(warnings[0].line, 9);
918        assert!(warnings[0].fix.is_none());
919        let fixed = rule.fix(&ctx).unwrap();
920        assert_eq!(fixed, content);
921        assert!(
922            !structure(&fixed)
923                .iter()
924                .any(|e| e.contains("Heading") && e.contains("H2")),
925            "the paragraph must not become a heading"
926        );
927    }
928
929    #[test]
930    fn test_fix_declined_with_padded_dash_style() {
931        for style in ["--- ", " ---", "\t---\t"] {
932            let content = "Text\n***\nMore\n";
933            let (fixed, warnings) = fix_with_style(style, content);
934            assert_eq!(fixed, content, "style {style:?}");
935            assert!(warnings.iter().all(|w| w.fix.is_none()), "style {style:?}");
936        }
937    }
938
939    #[test]
940    fn test_fix_still_applies_when_replacement_is_not_an_underline() {
941        // Non-dash styles, and the spaced dash style, are thematic breaks under a paragraph.
942        for (style, content, expected) in [
943            ("***", "Text\n___\nMore\n", "Text\n***\nMore\n"),
944            ("___", "Text\n***\nMore\n", "Text\n___\nMore\n"),
945            ("- - -", "Text\n***\nMore\n", "Text\n- - -\nMore\n"),
946        ] {
947            let (fixed, _) = fix_with_style(style, content);
948            assert_eq!(fixed, expected, "style {style:?}");
949            assert_eq!(structure(&fixed), structure(content), "style {style:?}");
950        }
951    }
952
953    #[test]
954    fn test_fix_still_applies_after_blank_line() {
955        for (content, expected) in [
956            ("Text\n\n***\n\nMore\n", "Text\n\n---\n\nMore\n"),
957            ("***\n\n***\n", "---\n\n---\n"),
958        ] {
959            let (fixed, _) = fix_with_style("---", content);
960            assert_eq!(fixed, expected);
961            assert_eq!(structure(&fixed), structure(content));
962        }
963    }
964
965    #[test]
966    fn test_indentation_inside_list_item_is_kept() {
967        let content = "- a\n\n  ***\n\n  more\n";
968        let (fixed, warnings) = fix_with_style("---", content);
969        assert_eq!(fixed, "- a\n\n  ---\n\n  more\n");
970        assert_eq!(
971            structure(&fixed),
972            structure(content),
973            "the break must stay inside the item"
974        );
975        assert_eq!(warnings.len(), 1);
976        assert!(
977            warnings[0].message.contains("style should be"),
978            "{}",
979            warnings[0].message
980        );
981
982        let ordered = "1. one\n\n   ___\n\n   more\n";
983        let (fixed, _) = fix_with_style("---", ordered);
984        assert_eq!(fixed, "1. one\n\n   ---\n\n   more\n");
985        assert_eq!(structure(&fixed), structure(ordered));
986    }
987
988    #[test]
989    fn test_conforming_break_inside_list_item_is_not_indentation_error() {
990        let (_, warnings) = fix_with_style("---", "- a\n\n  ---\n\n  more\n");
991        assert!(warnings.is_empty(), "{warnings:?}");
992        let (_, warnings) = fix_with_style("---", "1. one\n\n   ---\n\n   more\n");
993        assert!(warnings.is_empty(), "{warnings:?}");
994    }
995
996    #[test]
997    fn test_indentation_outside_list_item_is_still_stripped() {
998        // Indented less than the item's content column: the break ends the list.
999        let content = "- a\n\n ***\n\nmore\n";
1000        let (fixed, _) = fix_with_style("---", content);
1001        assert_eq!(fixed, "- a\n\n---\n\nmore\n");
1002        assert_eq!(structure(&fixed), structure(content));
1003
1004        let content = "Text\n\n  ---\n\nMore\n";
1005        let (fixed, _) = fix_with_style("---", content);
1006        assert_eq!(fixed, "Text\n\n---\n\nMore\n");
1007    }
1008
1009    #[test]
1010    fn test_fix_is_idempotent_on_consecutive_breaks_with_declined_fix() {
1011        let content = "___\n---\n***\n---\n";
1012        let rule = MD035HRStyle::default();
1013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1014        let fixed1 = rule.fix(&ctx).unwrap();
1015        let ctx = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1016        let fixed2 = rule.fix(&ctx).unwrap();
1017        assert_eq!(fixed1, fixed2, "first fix: {fixed1:?}");
1018    }
1019
1020    #[test]
1021    fn test_fix_declined_when_first_line_would_open_front_matter() {
1022        for content in ["___\n---\n***\n---\n", "***\n---\ntext\n\n---\n"] {
1023            let rule = MD035HRStyle::new("---".to_string());
1024            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025            let warnings = rule.check(&ctx).unwrap();
1026            assert!(
1027                warnings.iter().any(|w| w.line == 1 && w.fix.is_none()),
1028                "{content:?}: {warnings:?}"
1029            );
1030            assert_eq!(rule.fix(&ctx).unwrap().lines().next(), content.lines().next());
1031        }
1032    }
1033
1034    #[test]
1035    fn test_fix_applies_on_first_line_when_no_front_matter_results() {
1036        let rule = MD035HRStyle::new("---".to_string());
1037        let content = "***\n\ntext\n";
1038        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039        assert_eq!(rule.fix(&ctx).unwrap(), "---\n\ntext\n");
1040    }
1041}