mdbook_lint_core/rules/standard/
md035.rs

1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6/// MD035 - Horizontal rule style
7pub struct MD035 {
8    /// Horizontal rule style: "consistent", "---", "***", "___", etc.
9    pub style: String,
10}
11
12impl MD035 {
13    pub fn new() -> Self {
14        Self {
15            style: "consistent".to_string(),
16        }
17    }
18
19    #[allow(dead_code)]
20    pub fn with_style(mut self, style: &str) -> Self {
21        self.style = style.to_string();
22        self
23    }
24
25    fn is_horizontal_rule(&self, line: &str) -> Option<String> {
26        let trimmed = line.trim();
27
28        // Must be at least 3 characters
29        if trimmed.len() < 3 {
30            return None;
31        }
32
33        // Check for various horizontal rule patterns
34        if self.is_hr_pattern(trimmed, '-') {
35            Some(self.normalize_hr_style(trimmed, '-'))
36        } else if self.is_hr_pattern(trimmed, '*') {
37            Some(self.normalize_hr_style(trimmed, '*'))
38        } else if self.is_hr_pattern(trimmed, '_') {
39            Some(self.normalize_hr_style(trimmed, '_'))
40        } else {
41            None
42        }
43    }
44
45    fn is_hr_pattern(&self, line: &str, char: char) -> bool {
46        let mut char_count = 0;
47        let mut has_other = false;
48
49        for c in line.chars() {
50            if c == char {
51                char_count += 1;
52            } else if c == ' ' || c == '\t' {
53                // Spaces and tabs are allowed
54                continue;
55            } else {
56                has_other = true;
57                break;
58            }
59        }
60
61        char_count >= 3 && !has_other
62    }
63
64    fn normalize_hr_style(&self, line: &str, char: char) -> String {
65        // Count the character and determine if there are spaces
66        let char_count = line.chars().filter(|&c| c == char).count();
67        let has_spaces = line.contains(' ') || line.contains('\t');
68
69        if has_spaces {
70            // Return the style with spaces (e.g., "* * *")
71            let chars: Vec<String> = std::iter::repeat_n(char.to_string(), char_count).collect();
72            chars.join(" ")
73        } else {
74            // Return the style without spaces (e.g., "***")
75            std::iter::repeat_n(char, char_count).collect()
76        }
77    }
78
79    fn get_canonical_style(&self, style: &str) -> String {
80        // Normalize common variations to canonical forms
81        let first_char = style.chars().next().unwrap_or('-');
82        let has_spaces = style.contains(' ');
83        let _char_count = style.chars().filter(|&c| c == first_char).count();
84
85        if has_spaces {
86            match first_char {
87                '-' => "- - -".to_string(),
88                '*' => "* * *".to_string(),
89                '_' => "_ _ _".to_string(),
90                _ => style.to_string(),
91            }
92        } else {
93            match first_char {
94                '-' => "---".to_string(),
95                '*' => "***".to_string(),
96                '_' => "___".to_string(),
97                _ => style.to_string(),
98            }
99        }
100    }
101}
102
103impl Default for MD035 {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl Rule for MD035 {
110    fn id(&self) -> &'static str {
111        "MD035"
112    }
113
114    fn name(&self) -> &'static str {
115        "hr-style"
116    }
117
118    fn description(&self) -> &'static str {
119        "Horizontal rule style"
120    }
121
122    fn metadata(&self) -> RuleMetadata {
123        RuleMetadata::stable(RuleCategory::Formatting)
124    }
125
126    fn check_with_ast<'a>(
127        &self,
128        document: &Document,
129        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
130    ) -> Result<Vec<Violation>> {
131        let mut violations = Vec::new();
132        let lines = document.content.lines();
133        let mut horizontal_rules = Vec::new();
134
135        // First pass: collect all horizontal rules
136        for (line_number, line) in lines.enumerate() {
137            let line_number = line_number + 1;
138
139            if let Some(hr_style) = self.is_horizontal_rule(line) {
140                horizontal_rules.push((line_number, hr_style));
141            }
142        }
143
144        // If no horizontal rules found, no violations
145        if horizontal_rules.is_empty() {
146            return Ok(violations);
147        }
148
149        // Determine expected style
150        let expected = if self.style == "consistent" {
151            // Use the style of the first horizontal rule
152            self.get_canonical_style(&horizontal_rules[0].1)
153        } else {
154            // Use the configured style
155            self.style.clone()
156        };
157
158        // Second pass: check for violations
159        for (line_number, hr_style) in horizontal_rules {
160            let canonical_style = self.get_canonical_style(&hr_style);
161
162            if canonical_style != expected {
163                violations.push(self.create_violation(
164                    format!(
165                        "Horizontal rule style mismatch: Expected '{expected}', found '{canonical_style}'"
166                    ),
167                    line_number,
168                    1,
169                    Severity::Warning,
170                ));
171            }
172        }
173
174        Ok(violations)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::Document;
182    use std::path::PathBuf;
183
184    #[test]
185    fn test_md035_consistent_style() {
186        let content = r#"# Heading
187
188---
189
190Some content
191
192---
193
194More content
195
196---
197"#;
198
199        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
200        let rule = MD035::new();
201        let violations = rule.check(&document).unwrap();
202        assert_eq!(violations.len(), 0);
203    }
204
205    #[test]
206    fn test_md035_inconsistent_style() {
207        let content = r#"# Heading
208
209---
210
211Some content
212
213***
214
215More content
216
217___
218"#;
219
220        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
221        let rule = MD035::new();
222        let violations = rule.check(&document).unwrap();
223        assert_eq!(violations.len(), 2);
224        assert_eq!(violations[0].line, 7);
225        assert_eq!(violations[1].line, 11);
226        assert!(
227            violations[0]
228                .message
229                .contains("Expected '---', found '***'")
230        );
231        assert!(
232            violations[1]
233                .message
234                .contains("Expected '---', found '___'")
235        );
236    }
237
238    #[test]
239    fn test_md035_spaced_style_consistent() {
240        let content = r#"# Heading
241
242* * *
243
244Some content
245
246* * * * *
247
248More content
249
250- - -
251"#;
252
253        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
254        let rule = MD035::new();
255        let violations = rule.check(&document).unwrap();
256        assert_eq!(violations.len(), 1);
257        assert_eq!(violations[0].line, 11);
258        assert!(
259            violations[0]
260                .message
261                .contains("Expected '* * *', found '- - -'")
262        );
263    }
264
265    #[test]
266    fn test_md035_specific_style() {
267        let content = r#"# Heading
268
269---
270
271Some content
272
273***
274
275More content
276"#;
277
278        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
279        let rule = MD035::new().with_style("***");
280        let violations = rule.check(&document).unwrap();
281        assert_eq!(violations.len(), 1);
282        assert_eq!(violations[0].line, 3);
283        assert!(
284            violations[0]
285                .message
286                .contains("Expected '***', found '---'")
287        );
288    }
289
290    #[test]
291    fn test_md035_various_lengths() {
292        let content = r#"---
293
294-----
295
296---------
297"#;
298
299        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
300        let rule = MD035::new();
301        let violations = rule.check(&document).unwrap();
302        assert_eq!(violations.len(), 0); // All use dashes, so consistent
303    }
304
305    #[test]
306    fn test_md035_mixed_spacing() {
307        let content = r#"---
308
309- - -
310
311-- --
312"#;
313
314        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
315        let rule = MD035::new();
316        let violations = rule.check(&document).unwrap();
317        assert_eq!(violations.len(), 2);
318        assert!(
319            violations[0]
320                .message
321                .contains("Expected '---', found '- - -'")
322        );
323        assert!(
324            violations[1]
325                .message
326                .contains("Expected '---', found '- - -'")
327        ); // Normalized
328    }
329
330    #[test]
331    fn test_md035_not_horizontal_rules() {
332        let content = r#"# Heading
333
334Some text with -- dashes
335
336* List item
337* Another item
338
339-- Not enough dashes
340
341Code with ---
342    ---
343
344> Block quote with
345> ---
346"#;
347
348        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
349        let rule = MD035::new();
350        let violations = rule.check(&document).unwrap();
351        assert_eq!(violations.len(), 0);
352    }
353
354    #[test]
355    fn test_md035_minimum_length() {
356        let content = r#"--
357
358---
359
360----
361"#;
362
363        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
364        let rule = MD035::new();
365        let violations = rule.check(&document).unwrap();
366        assert_eq!(violations.len(), 0); // First line is too short, so not an HR
367    }
368
369    #[test]
370    fn test_md035_with_spaces_around() {
371        let content = r#"   ---
372
373  ***
374
375    ___
376"#;
377
378        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
379        let rule = MD035::new();
380        let violations = rule.check(&document).unwrap();
381        assert_eq!(violations.len(), 2);
382        assert!(
383            violations[0]
384                .message
385                .contains("Expected '---', found '***'")
386        );
387        assert!(
388            violations[1]
389                .message
390                .contains("Expected '---', found '___'")
391        );
392    }
393
394    #[test]
395    fn test_md035_underscore_style() {
396        let content = r#"___
397
398___
399
400***
401"#;
402
403        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
404        let rule = MD035::new();
405        let violations = rule.check(&document).unwrap();
406        assert_eq!(violations.len(), 1);
407        assert_eq!(violations[0].line, 5);
408        assert!(
409            violations[0]
410                .message
411                .contains("Expected '___', found '***'")
412        );
413    }
414
415    #[test]
416    fn test_md035_no_horizontal_rules() {
417        let content = r#"# Heading
418
419Some content
420
421## Another heading
422
423More content
424"#;
425
426        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
427        let rule = MD035::new();
428        let violations = rule.check(&document).unwrap();
429        assert_eq!(violations.len(), 0);
430    }
431}