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)
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 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 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 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 if Self::is_potential_setext_heading(&lines, i) {
129 continue;
130 }
131
132 if Self::is_horizontal_rule(line) {
133 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 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 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 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 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 result.push(expected_style.clone());
197 } else {
198 result.push(line.to_string());
200 }
201 }
202
203 let mut fixed = result.join("\n");
204 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 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
217 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 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(" --- ")); assert!(!MD035HRStyle::is_horizontal_rule("--")); assert!(!MD035HRStyle::is_horizontal_rule("**"));
259 assert!(!MD035HRStyle::is_horizontal_rule("__"));
260 assert!(!MD035HRStyle::is_horizontal_rule("- -")); 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 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"];
280 assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); let lines3 = vec!["***", "---"];
283 assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); }
285
286 #[test]
287 fn test_most_prevalent_hr_style() {
288 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}