mdlint/lint/rules/
md019.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD019;
7
8impl Rule for MD019 {
9 fn name(&self) -> &'static str {
10 "MD019"
11 }
12
13 fn description(&self) -> &'static str {
14 "Multiple spaces after hash on atx style heading"
15 }
16
17 fn tags(&self) -> &[&str] {
18 &["headings", "headers", "atx", "spaces"]
19 }
20
21 fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22 let mut violations = Vec::new();
23 let code_block_lines = parser.get_code_block_line_numbers();
24
25 for (line_num, line) in parser.lines().iter().enumerate() {
26 let line_number = line_num + 1;
27 if code_block_lines.contains(&line_number) {
28 continue;
29 }
30 let trimmed = line.trim();
31
32 if trimmed.starts_with('#') {
34 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
36
37 if hash_count > 0 && hash_count <= 6 && trimmed.len() > hash_count {
39 let after_hashes = &trimmed[hash_count..];
40
41 let space_count = after_hashes.chars().take_while(|&c| c == ' ').count();
43
44 if space_count > 1 {
45 let hashes = "#".repeat(hash_count);
47 let rest = after_hashes[space_count..].trim_start();
48 let replacement = format!("{hashes} {rest}");
49
50 violations.push(Violation {
51 line: line_number,
52 column: Some(hash_count + 2),
53 rule: self.name().to_owned(),
54 message: format!(
55 "Multiple spaces after hash on atx style heading ({space_count} spaces)"
56 ),
57 fix: Some(Fix {
58 line_start: line_number,
59 line_end: line_number,
60 column_start: None,
61 column_end: None,
62 replacement,
63 description: "Replace multiple spaces with single space".to_owned(),
64 }),
65 });
66 }
67 }
68 }
69 }
70
71 violations
72 }
73
74 fn fixable(&self) -> bool {
75 true
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::fix::Fixer;
83
84 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
85 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
86 Fixer::new()
87 .apply_fixes_to_content(content, &fixes)
88 .unwrap()
89 }
90
91 #[test]
92 fn test_correct_single_space() {
93 let content = "# Heading 1\n## Heading 2\n### Heading 3";
94 let parser = MarkdownParser::new(content);
95 let rule = MD019;
96 let violations = rule.check(&parser, None);
97
98 assert_eq!(violations.len(), 0);
99 }
100
101 #[test]
102 fn test_multiple_spaces() {
103 let content = "# Heading with 2 spaces\n## Correct heading";
104 let parser = MarkdownParser::new(content);
105 let rule = MD019;
106 let violations = rule.check(&parser, None);
107
108 assert_eq!(violations.len(), 1);
109 assert_eq!(violations[0].line, 1);
110 }
111
112 #[test]
113 fn test_many_spaces() {
114 let content = "### Heading with 5 spaces";
115 let parser = MarkdownParser::new(content);
116 let rule = MD019;
117 let violations = rule.check(&parser, None);
118
119 assert_eq!(violations.len(), 1);
120 assert!(violations[0].message.contains("5 spaces"));
121 }
122
123 #[test]
124 fn test_heading_in_code_block_not_flagged() {
125 let content = "# Real heading\n\n```\n## WouldBeViolation\n```\n";
126 let parser = MarkdownParser::new(content);
127 let rule = MD019;
128 let violations = rule.check(&parser, None);
129
130 assert_eq!(violations.len(), 0);
131 }
132
133 #[test]
134 fn test_fix_collapses_multiple_spaces() {
135 let content = "# Too many spaces\n\n### Even more\n";
136 let parser = MarkdownParser::new(content);
137 let rule = MD019;
138 let violations = rule.check(&parser, None);
139 assert_eq!(violations.len(), 2);
140 let fixed = apply_fixes(content, &violations);
141 assert_eq!(fixed, "# Too many spaces\n\n### Even more\n");
142 }
143}