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) -> &str {
10 "MD019"
11 }
12
13 fn description(&self) -> &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_string(),
54 message: format!(
55 "Multiple spaces after hash on atx style heading ({} spaces)",
56 space_count
57 ),
58 fix: Some(Fix {
59 line_start: line_number,
60 line_end: line_number,
61 column_start: None,
62 column_end: None,
63 replacement,
64 description: "Replace multiple spaces with single space"
65 .to_string(),
66 }),
67 });
68 }
69 }
70 }
71 }
72
73 violations
74 }
75
76 fn fixable(&self) -> bool {
77 true
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::fix::Fixer;
85
86 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
87 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
88 Fixer::new()
89 .apply_fixes_to_content(content, &fixes)
90 .unwrap()
91 }
92
93 #[test]
94 fn test_correct_single_space() {
95 let content = "# Heading 1\n## Heading 2\n### Heading 3";
96 let parser = MarkdownParser::new(content);
97 let rule = MD019;
98 let violations = rule.check(&parser, None);
99
100 assert_eq!(violations.len(), 0);
101 }
102
103 #[test]
104 fn test_multiple_spaces() {
105 let content = "# Heading with 2 spaces\n## Correct heading";
106 let parser = MarkdownParser::new(content);
107 let rule = MD019;
108 let violations = rule.check(&parser, None);
109
110 assert_eq!(violations.len(), 1);
111 assert_eq!(violations[0].line, 1);
112 }
113
114 #[test]
115 fn test_many_spaces() {
116 let content = "### Heading with 5 spaces";
117 let parser = MarkdownParser::new(content);
118 let rule = MD019;
119 let violations = rule.check(&parser, None);
120
121 assert_eq!(violations.len(), 1);
122 assert!(violations[0].message.contains("5 spaces"));
123 }
124
125 #[test]
126 fn test_heading_in_code_block_not_flagged() {
127 let content = "# Real heading\n\n```\n## WouldBeViolation\n```\n";
128 let parser = MarkdownParser::new(content);
129 let rule = MD019;
130 let violations = rule.check(&parser, None);
131
132 assert_eq!(violations.len(), 0);
133 }
134
135 #[test]
136 fn test_fix_collapses_multiple_spaces() {
137 let content = "# Too many spaces\n\n### Even more\n";
138 let parser = MarkdownParser::new(content);
139 let rule = MD019;
140 let violations = rule.check(&parser, None);
141 assert_eq!(violations.len(), 2);
142 let fixed = apply_fixes(content, &violations);
143 assert_eq!(fixed, "# Too many spaces\n\n### Even more\n");
144 }
145}