mdbook_lint_core/rules/standard/
md035.rs1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6pub struct MD035 {
8 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 if trimmed.len() < 3 {
30 return None;
31 }
32
33 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 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 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 let chars: Vec<String> = std::iter::repeat_n(char.to_string(), char_count).collect();
72 chars.join(" ")
73 } else {
74 std::iter::repeat_n(char, char_count).collect()
76 }
77 }
78
79 fn get_canonical_style(&self, style: &str) -> String {
80 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 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 horizontal_rules.is_empty() {
146 return Ok(violations);
147 }
148
149 let expected = if self.style == "consistent" {
151 self.get_canonical_style(&horizontal_rules[0].1)
153 } else {
154 self.style.clone()
156 };
157
158 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); }
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 ); }
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); }
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}