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, Severity};
9use crate::utils::regex_cache::{
10    HR_ASTERISK, HR_DASH, HR_SPACED_ASTERISK, HR_SPACED_DASH, HR_SPACED_UNDERSCORE, HR_UNDERSCORE,
11};
12use toml;
13
14mod md035_config;
15use md035_config::MD035Config;
16
17/// Represents the style for horizontal rules
18#[derive(Clone, Default)]
19pub struct MD035HRStyle {
20    config: MD035Config,
21}
22
23impl MD035HRStyle {
24    pub fn new(style: String) -> Self {
25        Self {
26            config: MD035Config { style },
27        }
28    }
29
30    pub fn from_config_struct(config: MD035Config) -> Self {
31        Self { config }
32    }
33
34    /// Determines if a line is a horizontal rule
35    fn is_horizontal_rule(line: &str) -> bool {
36        let line = line.trim();
37
38        HR_DASH.is_match(line)
39            || HR_ASTERISK.is_match(line)
40            || HR_UNDERSCORE.is_match(line)
41            || HR_SPACED_DASH.is_match(line)
42            || HR_SPACED_ASTERISK.is_match(line)
43            || HR_SPACED_UNDERSCORE.is_match(line)
44    }
45
46    /// Check if a line might be a Setext heading underline
47    fn is_potential_setext_heading(lines: &[&str], i: usize) -> bool {
48        if i == 0 {
49            return false; // First line can't be a Setext heading underline
50        }
51
52        let line = lines[i].trim();
53        let prev_line = lines[i - 1].trim();
54
55        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
56        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
57        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
58        (is_dash_line || is_equals_line) && prev_line_has_content
59    }
60
61    /// Find the most prevalent HR style in the document (excluding setext headings, code blocks, and frontmatter)
62    fn most_prevalent_hr_style(lines: &[&str], ctx: &crate::lint_context::LintContext) -> Option<String> {
63        use std::collections::HashMap;
64        let mut counts: HashMap<&str, usize> = HashMap::new();
65        let mut order: Vec<&str> = Vec::new();
66        for (i, line) in lines.iter().enumerate() {
67            // Skip if this line is in frontmatter, code block, or MkDocs markdown HTML div
68            if let Some(line_info) = ctx.lines.get(i)
69                && (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
70            {
71                continue;
72            }
73
74            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(lines, i) {
75                let style = line.trim();
76                let counter = counts.entry(style).or_insert(0);
77                *counter += 1;
78                if *counter == 1 {
79                    order.push(style);
80                }
81            }
82        }
83        // Find the style with the highest count, breaking ties by first encountered
84        counts
85            .iter()
86            .max_by_key(|&(style, count)| {
87                (
88                    *count,
89                    -(order.iter().position(|&s| s == *style).unwrap_or(usize::MAX) as isize),
90                )
91            })
92            .map(|(style, _)| style.to_string())
93    }
94}
95
96impl Rule for MD035HRStyle {
97    fn name(&self) -> &'static str {
98        "MD035"
99    }
100
101    fn description(&self) -> &'static str {
102        "Horizontal rule style"
103    }
104
105    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
106        let _line_index = &ctx.line_index;
107
108        let mut warnings = Vec::new();
109        let lines = ctx.raw_lines();
110
111        // Use the configured style or find the most prevalent HR style
112        let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
113            Self::most_prevalent_hr_style(lines, ctx).unwrap_or_else(|| "---".to_string())
114        } else {
115            self.config.style.clone()
116        };
117
118        for (i, line) in lines.iter().enumerate() {
119            // Skip if this line is in frontmatter, code block, or MkDocs markdown HTML div (grid cards use indented HRs)
120            if let Some(line_info) = ctx.lines.get(i)
121                && (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
122            {
123                continue;
124            }
125
126            // Skip if this is a potential Setext heading underline
127            if Self::is_potential_setext_heading(lines, i) {
128                continue;
129            }
130
131            if Self::is_horizontal_rule(line) {
132                // Check if this HR matches the expected style
133                let has_indentation = line.len() > line.trim_start().len();
134                let style_mismatch = line.trim() != expected_style;
135
136                if style_mismatch || has_indentation {
137                    // Calculate precise character range for the entire horizontal rule
138                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
139
140                    warnings.push(LintWarning {
141                        rule_name: Some(self.name().to_string()),
142                        line: start_line,
143                        column: start_col,
144                        end_line,
145                        end_column: end_col,
146                        message: if has_indentation {
147                            "Horizontal rule should not be indented".to_string()
148                        } else {
149                            format!("Horizontal rule style should be \"{expected_style}\"")
150                        },
151                        severity: Severity::Warning,
152                        fix: Some(Fix {
153                            range: _line_index.line_col_to_byte_range(i + 1, 1),
154                            replacement: expected_style.clone(),
155                        }),
156                    });
157                }
158            }
159        }
160
161        Ok(warnings)
162    }
163
164    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
165        let content = ctx.content;
166        let _line_index = &ctx.line_index;
167
168        let mut result = 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        for (i, line) in lines.iter().enumerate() {
179            // Skip if this line is in frontmatter or a code block (using pre-computed LineInfo)
180            if let Some(line_info) = ctx.lines.get(i)
181                && (line_info.in_front_matter || line_info.in_code_block)
182            {
183                result.push(line.to_string());
184                continue;
185            }
186
187            // Skip if this is a potential Setext heading underline
188            if Self::is_potential_setext_heading(lines, i) {
189                result.push(line.to_string());
190                continue;
191            }
192
193            if Self::is_horizontal_rule(line) {
194                // Here we have a proper horizontal rule - replace it with the expected style
195                result.push(expected_style.clone());
196            } else {
197                // Not a horizontal rule, keep the original line
198                result.push(line.to_string());
199            }
200        }
201
202        let mut fixed = result.join("\n");
203        // Preserve trailing newline if original content had one
204        if content.ends_with('\n') && !fixed.ends_with('\n') {
205            fixed.push('\n');
206        }
207        Ok(fixed)
208    }
209
210    fn as_any(&self) -> &dyn std::any::Any {
211        self
212    }
213
214    /// Check if this rule should be skipped
215    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
216        // HR can use -, *, or _
217        ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
218    }
219
220    fn default_config_section(&self) -> Option<(String, toml::Value)> {
221        let mut map = toml::map::Map::new();
222        map.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
223        Some((self.name().to_string(), toml::Value::Table(map)))
224    }
225
226    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
227    where
228        Self: Sized,
229    {
230        let style = crate::config::get_rule_config_value::<String>(config, "MD035", "style")
231            .unwrap_or_else(|| "consistent".to_string());
232        Box::new(MD035HRStyle::new(style))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::lint_context::LintContext;
240
241    #[test]
242    fn test_is_horizontal_rule() {
243        // Valid horizontal rules
244        assert!(MD035HRStyle::is_horizontal_rule("---"));
245        assert!(MD035HRStyle::is_horizontal_rule("----"));
246        assert!(MD035HRStyle::is_horizontal_rule("***"));
247        assert!(MD035HRStyle::is_horizontal_rule("****"));
248        assert!(MD035HRStyle::is_horizontal_rule("___"));
249        assert!(MD035HRStyle::is_horizontal_rule("____"));
250        assert!(MD035HRStyle::is_horizontal_rule("- - -"));
251        assert!(MD035HRStyle::is_horizontal_rule("* * *"));
252        assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
253        assert!(MD035HRStyle::is_horizontal_rule("  ---  ")); // With surrounding whitespace
254
255        // Invalid horizontal rules
256        assert!(!MD035HRStyle::is_horizontal_rule("--")); // Too few characters
257        assert!(!MD035HRStyle::is_horizontal_rule("**"));
258        assert!(!MD035HRStyle::is_horizontal_rule("__"));
259        assert!(!MD035HRStyle::is_horizontal_rule("- -")); // Too few repetitions
260        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
261        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
262        assert!(!MD035HRStyle::is_horizontal_rule("text"));
263        assert!(!MD035HRStyle::is_horizontal_rule(""));
264    }
265
266    #[test]
267    fn test_is_potential_setext_heading() {
268        let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
269
270        // Valid Setext headings
271        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); // ========= under "Heading 1"
272        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); // --- under "Heading 2"
273
274        // Not Setext headings
275        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); // First line can't be underline
276        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); // "Content" is not an underline
277
278        let lines2 = vec!["", "---", "Content"];
279        assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); // Empty line above
280
281        let lines3 = vec!["***", "---"];
282        assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); // HR above
283    }
284
285    #[test]
286    fn test_most_prevalent_hr_style() {
287        // Single style (with blank lines to avoid Setext interpretation)
288        let content = "Content\n\n---\n\nMore\n\n---\n\nText";
289        let lines: Vec<&str> = content.lines().collect();
290        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
291        assert_eq!(
292            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
293            Some("---".to_string())
294        );
295
296        // Multiple styles, one more prevalent
297        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
298        let lines: Vec<&str> = content.lines().collect();
299        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
300        assert_eq!(
301            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
302            Some("---".to_string())
303        );
304
305        // Multiple styles, tie broken by first encountered
306        let content = "Content\n\n***\n\nMore\n\n---\n\nText";
307        let lines: Vec<&str> = content.lines().collect();
308        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
309        assert_eq!(
310            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
311            Some("***".to_string())
312        );
313
314        // No horizontal rules
315        let content = "Just\nRegular\nContent";
316        let lines: Vec<&str> = content.lines().collect();
317        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
318        assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
319
320        // Exclude Setext headings
321        let content = "Heading\n---\nContent\n\n***";
322        let lines: Vec<&str> = content.lines().collect();
323        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
324        assert_eq!(
325            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
326            Some("***".to_string())
327        );
328    }
329
330    #[test]
331    fn test_consistent_style() {
332        let rule = MD035HRStyle::new("consistent".to_string());
333        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335        let result = rule.check(&ctx).unwrap();
336
337        // Should flag the *** as it doesn't match the most prevalent style ---
338        assert_eq!(result.len(), 1);
339        assert_eq!(result[0].line, 7);
340        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
341    }
342
343    #[test]
344    fn test_specific_style_dashes() {
345        let rule = MD035HRStyle::new("---".to_string());
346        let content = "Content\n\n***\n\nMore\n\n___\n\nText";
347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
348        let result = rule.check(&ctx).unwrap();
349
350        // Should flag both *** and ___ as they don't match ---
351        assert_eq!(result.len(), 2);
352        assert_eq!(result[0].line, 3);
353        assert_eq!(result[1].line, 7);
354        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
355    }
356
357    #[test]
358    fn test_indented_horizontal_rule() {
359        let rule = MD035HRStyle::new("---".to_string());
360        let content = "Content\n\n  ---\n\nMore";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362        let result = rule.check(&ctx).unwrap();
363
364        assert_eq!(result.len(), 1);
365        assert_eq!(result[0].line, 3);
366        assert_eq!(result[0].message, "Horizontal rule should not be indented");
367    }
368
369    #[test]
370    fn test_setext_heading_not_flagged() {
371        let rule = MD035HRStyle::new("***".to_string());
372        let content = "Heading\n---\nContent\n***";
373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374        let result = rule.check(&ctx).unwrap();
375
376        // Should not flag the --- under "Heading" as it's a Setext heading
377        assert_eq!(result.len(), 0);
378    }
379
380    #[test]
381    fn test_fix_consistent_style() {
382        let rule = MD035HRStyle::new("consistent".to_string());
383        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
384        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385        let fixed = rule.fix(&ctx).unwrap();
386
387        let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
388        assert_eq!(fixed, expected);
389    }
390
391    #[test]
392    fn test_fix_specific_style() {
393        let rule = MD035HRStyle::new("***".to_string());
394        let content = "Content\n\n---\n\nMore\n\n___\n\nText";
395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396        let fixed = rule.fix(&ctx).unwrap();
397
398        let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
399        assert_eq!(fixed, expected);
400    }
401
402    #[test]
403    fn test_fix_preserves_setext_headings() {
404        let rule = MD035HRStyle::new("***".to_string());
405        let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407        let fixed = rule.fix(&ctx).unwrap();
408
409        let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
410        assert_eq!(fixed, expected);
411    }
412
413    #[test]
414    fn test_fix_removes_indentation() {
415        let rule = MD035HRStyle::new("---".to_string());
416        let content = "Content\n\n  ***\n\nMore\n\n   ___\n\nText";
417        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418        let fixed = rule.fix(&ctx).unwrap();
419
420        let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
421        assert_eq!(fixed, expected);
422    }
423
424    #[test]
425    fn test_spaced_styles() {
426        let rule = MD035HRStyle::new("* * *".to_string());
427        let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429        let result = rule.check(&ctx).unwrap();
430
431        assert_eq!(result.len(), 2);
432        assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
433    }
434
435    #[test]
436    fn test_empty_style_uses_consistent() {
437        let rule = MD035HRStyle::new("".to_string());
438        let content = "Content\n\n---\n\nMore\n\n***\n\nText";
439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440        let result = rule.check(&ctx).unwrap();
441
442        // Empty style should behave like "consistent"
443        assert_eq!(result.len(), 1);
444        assert_eq!(result[0].line, 7);
445    }
446
447    #[test]
448    fn test_all_hr_styles_consistent() {
449        let rule = MD035HRStyle::new("consistent".to_string());
450        let content = "Content\n---\nMore\n---\nText\n---";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452        let result = rule.check(&ctx).unwrap();
453
454        // All HRs are the same style, should not flag anything
455        assert_eq!(result.len(), 0);
456    }
457
458    #[test]
459    fn test_no_horizontal_rules() {
460        let rule = MD035HRStyle::new("---".to_string());
461        let content = "Just regular content\nNo horizontal rules here";
462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463        let result = rule.check(&ctx).unwrap();
464
465        assert_eq!(result.len(), 0);
466    }
467
468    #[test]
469    fn test_mixed_spaced_and_unspaced() {
470        let rule = MD035HRStyle::new("consistent".to_string());
471        let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473        let result = rule.check(&ctx).unwrap();
474
475        // Should flag the spaced style as inconsistent
476        assert_eq!(result.len(), 1);
477        assert_eq!(result[0].line, 7);
478    }
479
480    #[test]
481    fn test_trailing_whitespace_in_hr() {
482        let rule = MD035HRStyle::new("---".to_string());
483        let content = "Content\n\n---   \n\nMore";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let result = rule.check(&ctx).unwrap();
486
487        // Trailing whitespace is OK for HRs
488        assert_eq!(result.len(), 0);
489    }
490
491    #[test]
492    fn test_hr_in_code_block_not_flagged() {
493        let rule = MD035HRStyle::new("---".to_string());
494        let content =
495            "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497        let result = rule.check(&ctx).unwrap();
498
499        // Should not flag horizontal rule patterns inside code blocks
500        assert_eq!(result.len(), 0);
501    }
502
503    #[test]
504    fn test_hr_in_code_span_not_flagged() {
505        let rule = MD035HRStyle::new("---".to_string());
506        let content = "Text with inline `---` code span";
507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508        let result = rule.check(&ctx).unwrap();
509
510        // Should not flag horizontal rule patterns inside code spans
511        assert_eq!(result.len(), 0);
512    }
513
514    #[test]
515    fn test_hr_with_extra_characters() {
516        let rule = MD035HRStyle::new("---".to_string());
517        let content = "Content\n-----\nMore\n--------\nText";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519        let result = rule.check(&ctx).unwrap();
520
521        // Extra characters in the same style should not be flagged
522        assert_eq!(result.len(), 0);
523    }
524
525    #[test]
526    fn test_default_config() {
527        let rule = MD035HRStyle::new("consistent".to_string());
528        let (name, config) = rule.default_config_section().unwrap();
529        assert_eq!(name, "MD035");
530
531        let table = config.as_table().unwrap();
532        assert_eq!(table.get("style").unwrap().as_str().unwrap(), "consistent");
533    }
534}