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 or a code block (using pre-computed LineInfo)
68            if let Some(line_info) = ctx.lines.get(i)
69                && (line_info.in_front_matter || 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 = &ctx.line_index;
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 frontmatter or a code block (using pre-computed LineInfo)
121            if let Some(line_info) = ctx.lines.get(i)
122                && (line_info.in_front_matter || 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().to_string()),
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 = &ctx.line_index;
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 frontmatter or a code block (using pre-computed LineInfo)
181            if let Some(line_info) = ctx.lines.get(i)
182                && (line_info.in_front_matter || 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        let mut fixed = result.join("\n");
204        // Preserve trailing newline if original content had one
205        if content.ends_with('\n') && !fixed.ends_with('\n') {
206            fixed.push('\n');
207        }
208        Ok(fixed)
209    }
210
211    fn as_any(&self) -> &dyn std::any::Any {
212        self
213    }
214
215    /// Check if this rule should be skipped
216    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
217        // HR can use -, *, or _
218        ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
219    }
220
221    fn default_config_section(&self) -> Option<(String, toml::Value)> {
222        let mut map = toml::map::Map::new();
223        map.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
224        Some((self.name().to_string(), toml::Value::Table(map)))
225    }
226
227    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
228    where
229        Self: Sized,
230    {
231        let style = crate::config::get_rule_config_value::<String>(config, "MD035", "style")
232            .unwrap_or_else(|| "consistent".to_string());
233        Box::new(MD035HRStyle::new(style))
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::lint_context::LintContext;
241
242    #[test]
243    fn test_is_horizontal_rule() {
244        // Valid horizontal rules
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("_ _ _"));
254        assert!(MD035HRStyle::is_horizontal_rule("  ---  ")); // With surrounding whitespace
255
256        // Invalid horizontal rules
257        assert!(!MD035HRStyle::is_horizontal_rule("--")); // Too few characters
258        assert!(!MD035HRStyle::is_horizontal_rule("**"));
259        assert!(!MD035HRStyle::is_horizontal_rule("__"));
260        assert!(!MD035HRStyle::is_horizontal_rule("- -")); // Too few repetitions
261        assert!(!MD035HRStyle::is_horizontal_rule("* *"));
262        assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
263        assert!(!MD035HRStyle::is_horizontal_rule("text"));
264        assert!(!MD035HRStyle::is_horizontal_rule(""));
265    }
266
267    #[test]
268    fn test_is_potential_setext_heading() {
269        let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
270
271        // Valid Setext headings
272        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); // ========= under "Heading 1"
273        assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); // --- under "Heading 2"
274
275        // Not Setext headings
276        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); // First line can't be underline
277        assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); // "Content" is not an underline
278
279        let lines2 = vec!["", "---", "Content"];
280        assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); // Empty line above
281
282        let lines3 = vec!["***", "---"];
283        assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); // HR above
284    }
285
286    #[test]
287    fn test_most_prevalent_hr_style() {
288        // Single style (with blank lines to avoid Setext interpretation)
289        let content = "Content\n\n---\n\nMore\n\n---\n\nText";
290        let lines: Vec<&str> = content.lines().collect();
291        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
292        assert_eq!(
293            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
294            Some("---".to_string())
295        );
296
297        // Multiple styles, one more prevalent
298        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
299        let lines: Vec<&str> = content.lines().collect();
300        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
301        assert_eq!(
302            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
303            Some("---".to_string())
304        );
305
306        // Multiple styles, tie broken by first encountered
307        let content = "Content\n\n***\n\nMore\n\n---\n\nText";
308        let lines: Vec<&str> = content.lines().collect();
309        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310        assert_eq!(
311            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
312            Some("***".to_string())
313        );
314
315        // No horizontal rules
316        let content = "Just\nRegular\nContent";
317        let lines: Vec<&str> = content.lines().collect();
318        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319        assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
320
321        // Exclude Setext headings
322        let content = "Heading\n---\nContent\n\n***";
323        let lines: Vec<&str> = content.lines().collect();
324        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325        assert_eq!(
326            MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
327            Some("***".to_string())
328        );
329    }
330
331    #[test]
332    fn test_consistent_style() {
333        let rule = MD035HRStyle::new("consistent".to_string());
334        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336        let result = rule.check(&ctx).unwrap();
337
338        // Should flag the *** as it doesn't match the most prevalent style ---
339        assert_eq!(result.len(), 1);
340        assert_eq!(result[0].line, 7);
341        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
342    }
343
344    #[test]
345    fn test_specific_style_dashes() {
346        let rule = MD035HRStyle::new("---".to_string());
347        let content = "Content\n\n***\n\nMore\n\n___\n\nText";
348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
349        let result = rule.check(&ctx).unwrap();
350
351        // Should flag both *** and ___ as they don't match ---
352        assert_eq!(result.len(), 2);
353        assert_eq!(result[0].line, 3);
354        assert_eq!(result[1].line, 7);
355        assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
356    }
357
358    #[test]
359    fn test_indented_horizontal_rule() {
360        let rule = MD035HRStyle::new("---".to_string());
361        let content = "Content\n\n  ---\n\nMore";
362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363        let result = rule.check(&ctx).unwrap();
364
365        assert_eq!(result.len(), 1);
366        assert_eq!(result[0].line, 3);
367        assert_eq!(result[0].message, "Horizontal rule should not be indented");
368    }
369
370    #[test]
371    fn test_setext_heading_not_flagged() {
372        let rule = MD035HRStyle::new("***".to_string());
373        let content = "Heading\n---\nContent\n***";
374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375        let result = rule.check(&ctx).unwrap();
376
377        // Should not flag the --- under "Heading" as it's a Setext heading
378        assert_eq!(result.len(), 0);
379    }
380
381    #[test]
382    fn test_fix_consistent_style() {
383        let rule = MD035HRStyle::new("consistent".to_string());
384        let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386        let fixed = rule.fix(&ctx).unwrap();
387
388        let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
389        assert_eq!(fixed, expected);
390    }
391
392    #[test]
393    fn test_fix_specific_style() {
394        let rule = MD035HRStyle::new("***".to_string());
395        let content = "Content\n\n---\n\nMore\n\n___\n\nText";
396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397        let fixed = rule.fix(&ctx).unwrap();
398
399        let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
400        assert_eq!(fixed, expected);
401    }
402
403    #[test]
404    fn test_fix_preserves_setext_headings() {
405        let rule = MD035HRStyle::new("***".to_string());
406        let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408        let fixed = rule.fix(&ctx).unwrap();
409
410        let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
411        assert_eq!(fixed, expected);
412    }
413
414    #[test]
415    fn test_fix_removes_indentation() {
416        let rule = MD035HRStyle::new("---".to_string());
417        let content = "Content\n\n  ***\n\nMore\n\n   ___\n\nText";
418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419        let fixed = rule.fix(&ctx).unwrap();
420
421        let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
422        assert_eq!(fixed, expected);
423    }
424
425    #[test]
426    fn test_spaced_styles() {
427        let rule = MD035HRStyle::new("* * *".to_string());
428        let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430        let result = rule.check(&ctx).unwrap();
431
432        assert_eq!(result.len(), 2);
433        assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
434    }
435
436    #[test]
437    fn test_empty_style_uses_consistent() {
438        let rule = MD035HRStyle::new("".to_string());
439        let content = "Content\n\n---\n\nMore\n\n***\n\nText";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441        let result = rule.check(&ctx).unwrap();
442
443        // Empty style should behave like "consistent"
444        assert_eq!(result.len(), 1);
445        assert_eq!(result[0].line, 7);
446    }
447
448    #[test]
449    fn test_all_hr_styles_consistent() {
450        let rule = MD035HRStyle::new("consistent".to_string());
451        let content = "Content\n---\nMore\n---\nText\n---";
452        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
453        let result = rule.check(&ctx).unwrap();
454
455        // All HRs are the same style, should not flag anything
456        assert_eq!(result.len(), 0);
457    }
458
459    #[test]
460    fn test_no_horizontal_rules() {
461        let rule = MD035HRStyle::new("---".to_string());
462        let content = "Just regular content\nNo horizontal rules here";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464        let result = rule.check(&ctx).unwrap();
465
466        assert_eq!(result.len(), 0);
467    }
468
469    #[test]
470    fn test_mixed_spaced_and_unspaced() {
471        let rule = MD035HRStyle::new("consistent".to_string());
472        let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let result = rule.check(&ctx).unwrap();
475
476        // Should flag the spaced style as inconsistent
477        assert_eq!(result.len(), 1);
478        assert_eq!(result[0].line, 7);
479    }
480
481    #[test]
482    fn test_trailing_whitespace_in_hr() {
483        let rule = MD035HRStyle::new("---".to_string());
484        let content = "Content\n\n---   \n\nMore";
485        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486        let result = rule.check(&ctx).unwrap();
487
488        // Trailing whitespace is OK for HRs
489        assert_eq!(result.len(), 0);
490    }
491
492    #[test]
493    fn test_hr_in_code_block_not_flagged() {
494        let rule = MD035HRStyle::new("---".to_string());
495        let content =
496            "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498        let result = rule.check(&ctx).unwrap();
499
500        // Should not flag horizontal rule patterns inside code blocks
501        assert_eq!(result.len(), 0);
502    }
503
504    #[test]
505    fn test_hr_in_code_span_not_flagged() {
506        let rule = MD035HRStyle::new("---".to_string());
507        let content = "Text with inline `---` code span";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510
511        // Should not flag horizontal rule patterns inside code spans
512        assert_eq!(result.len(), 0);
513    }
514
515    #[test]
516    fn test_hr_with_extra_characters() {
517        let rule = MD035HRStyle::new("---".to_string());
518        let content = "Content\n-----\nMore\n--------\nText";
519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520        let result = rule.check(&ctx).unwrap();
521
522        // Extra characters in the same style should not be flagged
523        assert_eq!(result.len(), 0);
524    }
525
526    #[test]
527    fn test_default_config() {
528        let rule = MD035HRStyle::new("consistent".to_string());
529        let (name, config) = rule.default_config_section().unwrap();
530        assert_eq!(name, "MD035");
531
532        let table = config.as_table().unwrap();
533        assert_eq!(table.get("style").unwrap().as_str().unwrap(), "consistent");
534    }
535}