1use 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#[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 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 fn is_potential_setext_heading(lines: &[&str], i: usize) -> bool {
48 if i == 0 {
49 return false; }
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 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 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 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 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 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 if Self::is_potential_setext_heading(lines, i) {
128 continue;
129 }
130
131 if Self::is_horizontal_rule(line) {
132 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 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 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 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 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 result.push(expected_style.clone());
196 } else {
197 result.push(line.to_string());
199 }
200 }
201
202 let mut fixed = result.join("\n");
203 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 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
216 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 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(" --- ")); assert!(!MD035HRStyle::is_horizontal_rule("--")); assert!(!MD035HRStyle::is_horizontal_rule("**"));
258 assert!(!MD035HRStyle::is_horizontal_rule("__"));
259 assert!(!MD035HRStyle::is_horizontal_rule("- -")); 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 assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); let lines2 = vec!["", "---", "Content"];
279 assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); let lines3 = vec!["***", "---"];
282 assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); }
284
285 #[test]
286 fn test_most_prevalent_hr_style() {
287 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}