1use crate::utils::range_utils::{LineIndex, 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.line_info(i + 1)
69 && 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 = LineIndex::new(content.to_string());
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.line_info(i + 1)
122 && 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()),
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 = LineIndex::new(content.to_string());
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.line_info(i + 1)
182 && 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 Ok(result.join("\n"))
204 }
205
206 fn as_any(&self) -> &dyn std::any::Any {
207 self
208 }
209
210 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
212 ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
214 }
215
216 fn default_config_section(&self) -> Option<(String, toml::Value)> {
217 let mut map = toml::map::Map::new();
218 map.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
219 Some((self.name().to_string(), toml::Value::Table(map)))
220 }
221
222 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
223 where
224 Self: Sized,
225 {
226 let style = crate::config::get_rule_config_value::<String>(config, "MD035", "style")
227 .unwrap_or_else(|| "consistent".to_string());
228 Box::new(MD035HRStyle::new(style))
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::lint_context::LintContext;
236
237 #[test]
238 fn test_is_horizontal_rule() {
239 assert!(MD035HRStyle::is_horizontal_rule("---"));
241 assert!(MD035HRStyle::is_horizontal_rule("----"));
242 assert!(MD035HRStyle::is_horizontal_rule("***"));
243 assert!(MD035HRStyle::is_horizontal_rule("****"));
244 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(" --- ")); assert!(!MD035HRStyle::is_horizontal_rule("--")); assert!(!MD035HRStyle::is_horizontal_rule("**"));
254 assert!(!MD035HRStyle::is_horizontal_rule("__"));
255 assert!(!MD035HRStyle::is_horizontal_rule("- -")); assert!(!MD035HRStyle::is_horizontal_rule("* *"));
257 assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
258 assert!(!MD035HRStyle::is_horizontal_rule("text"));
259 assert!(!MD035HRStyle::is_horizontal_rule(""));
260 }
261
262 #[test]
263 fn test_is_potential_setext_heading() {
264 let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
265
266 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"];
275 assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); let lines3 = vec!["***", "---"];
278 assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); }
280
281 #[test]
282 fn test_most_prevalent_hr_style() {
283 let content = "Content\n\n---\n\nMore\n\n---\n\nText";
285 let lines: Vec<&str> = content.lines().collect();
286 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
287 assert_eq!(
288 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
289 Some("---".to_string())
290 );
291
292 let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
294 let lines: Vec<&str> = content.lines().collect();
295 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
296 assert_eq!(
297 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
298 Some("---".to_string())
299 );
300
301 let content = "Content\n\n***\n\nMore\n\n---\n\nText";
303 let lines: Vec<&str> = content.lines().collect();
304 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
305 assert_eq!(
306 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
307 Some("***".to_string())
308 );
309
310 let content = "Just\nRegular\nContent";
312 let lines: Vec<&str> = content.lines().collect();
313 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
314 assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
315
316 let content = "Heading\n---\nContent\n\n***";
318 let lines: Vec<&str> = content.lines().collect();
319 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
320 assert_eq!(
321 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
322 Some("***".to_string())
323 );
324 }
325
326 #[test]
327 fn test_consistent_style() {
328 let rule = MD035HRStyle::new("consistent".to_string());
329 let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
331 let result = rule.check(&ctx).unwrap();
332
333 assert_eq!(result.len(), 1);
335 assert_eq!(result[0].line, 7);
336 assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
337 }
338
339 #[test]
340 fn test_specific_style_dashes() {
341 let rule = MD035HRStyle::new("---".to_string());
342 let content = "Content\n\n***\n\nMore\n\n___\n\nText";
343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
344 let result = rule.check(&ctx).unwrap();
345
346 assert_eq!(result.len(), 2);
348 assert_eq!(result[0].line, 3);
349 assert_eq!(result[1].line, 7);
350 assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
351 }
352
353 #[test]
354 fn test_indented_horizontal_rule() {
355 let rule = MD035HRStyle::new("---".to_string());
356 let content = "Content\n\n ---\n\nMore";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
358 let result = rule.check(&ctx).unwrap();
359
360 assert_eq!(result.len(), 1);
361 assert_eq!(result[0].line, 3);
362 assert_eq!(result[0].message, "Horizontal rule should not be indented");
363 }
364
365 #[test]
366 fn test_setext_heading_not_flagged() {
367 let rule = MD035HRStyle::new("***".to_string());
368 let content = "Heading\n---\nContent\n***";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
370 let result = rule.check(&ctx).unwrap();
371
372 assert_eq!(result.len(), 0);
374 }
375
376 #[test]
377 fn test_fix_consistent_style() {
378 let rule = MD035HRStyle::new("consistent".to_string());
379 let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
381 let fixed = rule.fix(&ctx).unwrap();
382
383 let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
384 assert_eq!(fixed, expected);
385 }
386
387 #[test]
388 fn test_fix_specific_style() {
389 let rule = MD035HRStyle::new("***".to_string());
390 let content = "Content\n\n---\n\nMore\n\n___\n\nText";
391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
392 let fixed = rule.fix(&ctx).unwrap();
393
394 let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
395 assert_eq!(fixed, expected);
396 }
397
398 #[test]
399 fn test_fix_preserves_setext_headings() {
400 let rule = MD035HRStyle::new("***".to_string());
401 let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
403 let fixed = rule.fix(&ctx).unwrap();
404
405 let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
406 assert_eq!(fixed, expected);
407 }
408
409 #[test]
410 fn test_fix_removes_indentation() {
411 let rule = MD035HRStyle::new("---".to_string());
412 let content = "Content\n\n ***\n\nMore\n\n ___\n\nText";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414 let fixed = rule.fix(&ctx).unwrap();
415
416 let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
417 assert_eq!(fixed, expected);
418 }
419
420 #[test]
421 fn test_spaced_styles() {
422 let rule = MD035HRStyle::new("* * *".to_string());
423 let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
425 let result = rule.check(&ctx).unwrap();
426
427 assert_eq!(result.len(), 2);
428 assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
429 }
430
431 #[test]
432 fn test_empty_style_uses_consistent() {
433 let rule = MD035HRStyle::new("".to_string());
434 let content = "Content\n\n---\n\nMore\n\n***\n\nText";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
436 let result = rule.check(&ctx).unwrap();
437
438 assert_eq!(result.len(), 1);
440 assert_eq!(result[0].line, 7);
441 }
442
443 #[test]
444 fn test_all_hr_styles_consistent() {
445 let rule = MD035HRStyle::new("consistent".to_string());
446 let content = "Content\n---\nMore\n---\nText\n---";
447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
448 let result = rule.check(&ctx).unwrap();
449
450 assert_eq!(result.len(), 0);
452 }
453
454 #[test]
455 fn test_no_horizontal_rules() {
456 let rule = MD035HRStyle::new("---".to_string());
457 let content = "Just regular content\nNo horizontal rules here";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459 let result = rule.check(&ctx).unwrap();
460
461 assert_eq!(result.len(), 0);
462 }
463
464 #[test]
465 fn test_mixed_spaced_and_unspaced() {
466 let rule = MD035HRStyle::new("consistent".to_string());
467 let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469 let result = rule.check(&ctx).unwrap();
470
471 assert_eq!(result.len(), 1);
473 assert_eq!(result[0].line, 7);
474 }
475
476 #[test]
477 fn test_trailing_whitespace_in_hr() {
478 let rule = MD035HRStyle::new("---".to_string());
479 let content = "Content\n\n--- \n\nMore";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
482
483 assert_eq!(result.len(), 0);
485 }
486
487 #[test]
488 fn test_hr_in_code_block_not_flagged() {
489 let rule = MD035HRStyle::new("---".to_string());
490 let content =
491 "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let result = rule.check(&ctx).unwrap();
494
495 assert_eq!(result.len(), 0);
497 }
498
499 #[test]
500 fn test_hr_in_code_span_not_flagged() {
501 let rule = MD035HRStyle::new("---".to_string());
502 let content = "Text with inline `---` code span";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504 let result = rule.check(&ctx).unwrap();
505
506 assert_eq!(result.len(), 0);
508 }
509
510 #[test]
511 fn test_hr_with_extra_characters() {
512 let rule = MD035HRStyle::new("---".to_string());
513 let content = "Content\n-----\nMore\n--------\nText";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515 let result = rule.check(&ctx).unwrap();
516
517 assert_eq!(result.len(), 0);
519 }
520
521 #[test]
522 fn test_default_config() {
523 let rule = MD035HRStyle::new("consistent".to_string());
524 let (name, config) = rule.default_config_section().unwrap();
525 assert_eq!(name, "MD035");
526
527 let table = config.as_table().unwrap();
528 assert_eq!(table.get("style").unwrap().as_str().unwrap(), "consistent");
529 }
530}