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::{LineIndex, 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 and code blocks)
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 a code block
68            if let Some(line_info) = ctx.line_info(i + 1)
69                && line_info.in_code_block
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 content = ctx.content;
107        let _line_index = LineIndex::new(content.to_string());
108
109        let mut warnings = Vec::new();
110        let lines: Vec<&str> = content.lines().collect();
111
112        // Use the configured style or find the most prevalent HR style
113        let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
114            Self::most_prevalent_hr_style(&lines, ctx).unwrap_or_else(|| "---".to_string())
115        } else {
116            self.config.style.clone()
117        };
118
119        for (i, line) in lines.iter().enumerate() {
120            // Skip if this line is in a code block or code span
121            if let Some(line_info) = ctx.line_info(i + 1)
122                && line_info.in_code_block
123            {
124                continue;
125            }
126
127            // Skip if this is a potential Setext heading underline
128            if Self::is_potential_setext_heading(&lines, i) {
129                continue;
130            }
131
132            if Self::is_horizontal_rule(line) {
133                // Check if this HR matches the expected style
134                let has_indentation = line.len() > line.trim_start().len();
135                let style_mismatch = line.trim() != expected_style;
136
137                if style_mismatch || has_indentation {
138                    // Calculate precise character range for the entire horizontal rule
139                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
140
141                    warnings.push(LintWarning {
142                        rule_name: Some(self.name()),
143                        line: start_line,
144                        column: start_col,
145                        end_line,
146                        end_column: end_col,
147                        message: if has_indentation {
148                            "Horizontal rule should not be indented".to_string()
149                        } else {
150                            format!("Horizontal rule style should be \"{expected_style}\"")
151                        },
152                        severity: Severity::Warning,
153                        fix: Some(Fix {
154                            range: _line_index.line_col_to_byte_range(i + 1, 1),
155                            replacement: expected_style.clone(),
156                        }),
157                    });
158                }
159            }
160        }
161
162        Ok(warnings)
163    }
164
165    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
166        let content = ctx.content;
167        let _line_index = LineIndex::new(content.to_string());
168
169        let mut result = Vec::new();
170        let lines: Vec<&str> = content.lines().collect();
171
172        // Use the configured style or find the most prevalent HR style
173        let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
174            Self::most_prevalent_hr_style(&lines, ctx).unwrap_or_else(|| "---".to_string())
175        } else {
176            self.config.style.clone()
177        };
178
179        for (i, line) in lines.iter().enumerate() {
180            // Skip if this line is in a code block or code span
181            if let Some(line_info) = ctx.line_info(i + 1)
182                && line_info.in_code_block
183            {
184                result.push(line.to_string());
185                continue;
186            }
187
188            // Skip if this is a potential Setext heading underline
189            if Self::is_potential_setext_heading(&lines, i) {
190                result.push(line.to_string());
191                continue;
192            }
193
194            if Self::is_horizontal_rule(line) {
195                // Here we have a proper horizontal rule - replace it with the expected style
196                result.push(expected_style.clone());
197            } else {
198                // Not a horizontal rule, keep the original line
199                result.push(line.to_string());
200            }
201        }
202
203        Ok(result.join("\n"))
204    }
205
206    fn as_any(&self) -> &dyn std::any::Any {
207        self
208    }
209
210    /// Check if this rule should be skipped
211    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
212        // HR can use -, *, or _
213        ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
214    }
215
216    fn default_config_section(&self) -> Option<(String, toml::Value)> {
217        let mut map = toml::map::Map::new();
218        map.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
219        Some((self.name().to_string(), toml::Value::Table(map)))
220    }
221
222    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
223    where
224        Self: Sized,
225    {
226        let style = crate::config::get_rule_config_value::<String>(config, "MD035", "style")
227            .unwrap_or_else(|| "consistent".to_string());
228        Box::new(MD035HRStyle::new(style))
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::lint_context::LintContext;
236
237    #[test]
238    fn test_is_horizontal_rule() {
239        // Valid horizontal rules
240        assert!(MD035HRStyle::is_horizontal_rule("---"));
241        assert!(MD035HRStyle::is_horizontal_rule("----"));
242        assert!(MD035HRStyle::is_horizontal_rule("***"));
243        assert!(MD035HRStyle::is_horizontal_rule("****"));
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("  ---  ")); // With surrounding whitespace
250
251        // Invalid horizontal rules
252        assert!(!MD035HRStyle::is_horizontal_rule("--")); // Too few characters
253        assert!(!MD035HRStyle::is_horizontal_rule("**"));
254        assert!(!MD035HRStyle::is_horizontal_rule("__"));
255        assert!(!MD035HRStyle::is_horizontal_rule("- -")); // Too few repetitions
256        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
257        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
258        assert!(!MD035HRStyle::is_horizontal_rule("text"));
259        assert!(!MD035HRStyle::is_horizontal_rule(""));
260    }
261
262    #[test]
263    fn test_is_potential_setext_heading() {
264        let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
265
266        // Valid Setext headings
267        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); // ========= under "Heading 1"
268        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); // --- under "Heading 2"
269
270        // Not Setext headings
271        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); // First line can't be underline
272        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); // "Content" is not an underline
273
274        let lines2 = vec!["", "---", "Content"];
275        assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); // Empty line above
276
277        let lines3 = vec!["***", "---"];
278        assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); // HR above
279    }
280
281    #[test]
282    fn test_most_prevalent_hr_style() {
283        // Single style (with blank lines to avoid Setext interpretation)
284        let content = "Content\n\n---\n\nMore\n\n---\n\nText";
285        let lines: Vec<&str> = content.lines().collect();
286        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
287        assert_eq!(
288            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
289            Some("---".to_string())
290        );
291
292        // Multiple styles, one more prevalent
293        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
294        let lines: Vec<&str> = content.lines().collect();
295        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
296        assert_eq!(
297            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
298            Some("---".to_string())
299        );
300
301        // Multiple styles, tie broken by first encountered
302        let content = "Content\n\n***\n\nMore\n\n---\n\nText";
303        let lines: Vec<&str> = content.lines().collect();
304        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
305        assert_eq!(
306            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
307            Some("***".to_string())
308        );
309
310        // No horizontal rules
311        let content = "Just\nRegular\nContent";
312        let lines: Vec<&str> = content.lines().collect();
313        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
314        assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
315
316        // Exclude Setext headings
317        let content = "Heading\n---\nContent\n\n***";
318        let lines: Vec<&str> = content.lines().collect();
319        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
320        assert_eq!(
321            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
322            Some("***".to_string())
323        );
324    }
325
326    #[test]
327    fn test_consistent_style() {
328        let rule = MD035HRStyle::new("consistent".to_string());
329        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
331        let result = rule.check(&ctx).unwrap();
332
333        // Should flag the *** as it doesn't match the most prevalent style ---
334        assert_eq!(result.len(), 1);
335        assert_eq!(result[0].line, 7);
336        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
337    }
338
339    #[test]
340    fn test_specific_style_dashes() {
341        let rule = MD035HRStyle::new("---".to_string());
342        let content = "Content\n\n***\n\nMore\n\n___\n\nText";
343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
344        let result = rule.check(&ctx).unwrap();
345
346        // Should flag both *** and ___ as they don't match ---
347        assert_eq!(result.len(), 2);
348        assert_eq!(result[0].line, 3);
349        assert_eq!(result[1].line, 7);
350        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
351    }
352
353    #[test]
354    fn test_indented_horizontal_rule() {
355        let rule = MD035HRStyle::new("---".to_string());
356        let content = "Content\n\n  ---\n\nMore";
357        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
358        let result = rule.check(&ctx).unwrap();
359
360        assert_eq!(result.len(), 1);
361        assert_eq!(result[0].line, 3);
362        assert_eq!(result[0].message, "Horizontal rule should not be indented");
363    }
364
365    #[test]
366    fn test_setext_heading_not_flagged() {
367        let rule = MD035HRStyle::new("***".to_string());
368        let content = "Heading\n---\nContent\n***";
369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
370        let result = rule.check(&ctx).unwrap();
371
372        // Should not flag the --- under "Heading" as it's a Setext heading
373        assert_eq!(result.len(), 0);
374    }
375
376    #[test]
377    fn test_fix_consistent_style() {
378        let rule = MD035HRStyle::new("consistent".to_string());
379        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
380        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
381        let fixed = rule.fix(&ctx).unwrap();
382
383        let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
384        assert_eq!(fixed, expected);
385    }
386
387    #[test]
388    fn test_fix_specific_style() {
389        let rule = MD035HRStyle::new("***".to_string());
390        let content = "Content\n\n---\n\nMore\n\n___\n\nText";
391        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
392        let fixed = rule.fix(&ctx).unwrap();
393
394        let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
395        assert_eq!(fixed, expected);
396    }
397
398    #[test]
399    fn test_fix_preserves_setext_headings() {
400        let rule = MD035HRStyle::new("***".to_string());
401        let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
403        let fixed = rule.fix(&ctx).unwrap();
404
405        let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
406        assert_eq!(fixed, expected);
407    }
408
409    #[test]
410    fn test_fix_removes_indentation() {
411        let rule = MD035HRStyle::new("---".to_string());
412        let content = "Content\n\n  ***\n\nMore\n\n   ___\n\nText";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414        let fixed = rule.fix(&ctx).unwrap();
415
416        let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
417        assert_eq!(fixed, expected);
418    }
419
420    #[test]
421    fn test_spaced_styles() {
422        let rule = MD035HRStyle::new("* * *".to_string());
423        let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
425        let result = rule.check(&ctx).unwrap();
426
427        assert_eq!(result.len(), 2);
428        assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
429    }
430
431    #[test]
432    fn test_empty_style_uses_consistent() {
433        let rule = MD035HRStyle::new("".to_string());
434        let content = "Content\n\n---\n\nMore\n\n***\n\nText";
435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
436        let result = rule.check(&ctx).unwrap();
437
438        // Empty style should behave like "consistent"
439        assert_eq!(result.len(), 1);
440        assert_eq!(result[0].line, 7);
441    }
442
443    #[test]
444    fn test_all_hr_styles_consistent() {
445        let rule = MD035HRStyle::new("consistent".to_string());
446        let content = "Content\n---\nMore\n---\nText\n---";
447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
448        let result = rule.check(&ctx).unwrap();
449
450        // All HRs are the same style, should not flag anything
451        assert_eq!(result.len(), 0);
452    }
453
454    #[test]
455    fn test_no_horizontal_rules() {
456        let rule = MD035HRStyle::new("---".to_string());
457        let content = "Just regular content\nNo horizontal rules here";
458        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459        let result = rule.check(&ctx).unwrap();
460
461        assert_eq!(result.len(), 0);
462    }
463
464    #[test]
465    fn test_mixed_spaced_and_unspaced() {
466        let rule = MD035HRStyle::new("consistent".to_string());
467        let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469        let result = rule.check(&ctx).unwrap();
470
471        // Should flag the spaced style as inconsistent
472        assert_eq!(result.len(), 1);
473        assert_eq!(result[0].line, 7);
474    }
475
476    #[test]
477    fn test_trailing_whitespace_in_hr() {
478        let rule = MD035HRStyle::new("---".to_string());
479        let content = "Content\n\n---   \n\nMore";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481        let result = rule.check(&ctx).unwrap();
482
483        // Trailing whitespace is OK for HRs
484        assert_eq!(result.len(), 0);
485    }
486
487    #[test]
488    fn test_hr_in_code_block_not_flagged() {
489        let rule = MD035HRStyle::new("---".to_string());
490        let content =
491            "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493        let result = rule.check(&ctx).unwrap();
494
495        // Should not flag horizontal rule patterns inside code blocks
496        assert_eq!(result.len(), 0);
497    }
498
499    #[test]
500    fn test_hr_in_code_span_not_flagged() {
501        let rule = MD035HRStyle::new("---".to_string());
502        let content = "Text with inline `---` code span";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504        let result = rule.check(&ctx).unwrap();
505
506        // Should not flag horizontal rule patterns inside code spans
507        assert_eq!(result.len(), 0);
508    }
509
510    #[test]
511    fn test_hr_with_extra_characters() {
512        let rule = MD035HRStyle::new("---".to_string());
513        let content = "Content\n-----\nMore\n--------\nText";
514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515        let result = rule.check(&ctx).unwrap();
516
517        // Extra characters in the same style should not be flagged
518        assert_eq!(result.len(), 0);
519    }
520
521    #[test]
522    fn test_default_config() {
523        let rule = MD035HRStyle::new("consistent".to_string());
524        let (name, config) = rule.default_config_section().unwrap();
525        assert_eq!(name, "MD035");
526
527        let table = config.as_table().unwrap();
528        assert_eq!(table.get("style").unwrap().as_str().unwrap(), "consistent");
529    }
530}