mdbook_lint_core/rules/standard/
md019.rs1use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12
13pub struct MD019;
15
16impl Rule for MD019 {
17 fn id(&self) -> &'static str {
18 "MD019"
19 }
20
21 fn name(&self) -> &'static str {
22 "no-multiple-space-atx"
23 }
24
25 fn description(&self) -> &'static str {
26 "Multiple spaces after hash on atx style heading"
27 }
28
29 fn metadata(&self) -> RuleMetadata {
30 RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
31 }
32
33 fn check_with_ast<'a>(
34 &self,
35 document: &Document,
36 _ast: Option<&'a comrak::nodes::AstNode<'a>>,
37 ) -> Result<Vec<Violation>> {
38 let mut violations = Vec::new();
39
40 for (line_number, line) in document.lines.iter().enumerate() {
41 let line_num = line_number + 1; let trimmed = line.trim_start();
46 if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
47 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
49
50 if trimmed.len() > hash_count {
52 let after_hashes = &trimmed[hash_count..];
53
54 if after_hashes.starts_with(" ")
56 || after_hashes.starts_with("\t")
57 || (after_hashes.starts_with(" ")
58 && after_hashes.chars().nth(1) == Some('\t'))
59 {
60 let whitespace_count = after_hashes
61 .chars()
62 .take_while(|&c| c.is_whitespace())
63 .count();
64
65 violations.push(self.create_violation(
66 format!("Multiple spaces after hash on ATX heading: found {whitespace_count} whitespace characters, expected 1"),
67 line_num,
68 hash_count + 1, Severity::Warning,
70 ));
71 }
72 } else if trimmed.len() == hash_count {
73 continue;
75 }
76 }
77 }
78
79 Ok(violations)
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use crate::Document;
87 use crate::rule::Rule;
88 use std::path::PathBuf;
89
90 #[test]
91 fn test_md019_no_violations() {
92 let content = r#"# Single space heading
93
94## Another single space
95
96### Level 3 with single space
97
98#### Level 4 heading
99
100##### Level 5
101
102###### Level 6
103
104Regular paragraph text.
105
106Not a heading: # this has text before it
107
108Also not a heading:
109# this is indented
110
111Shebang line should be ignored:
112#!/bin/bash
113"#;
114 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
115 let rule = MD019;
116 let violations = rule.check(&document).unwrap();
117
118 assert_eq!(violations.len(), 0);
119 }
120
121 #[test]
122 fn test_md019_multiple_spaces_violation() {
123 let content = r#"# Single space is fine
124
125## Two spaces after hash
126
127### Three spaces after hash
128
129#### Four spaces after hash
130
131Regular text here.
132"#;
133 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
134 let rule = MD019;
135 let violations = rule.check(&document).unwrap();
136
137 assert_eq!(violations.len(), 3);
138 assert!(
139 violations[0]
140 .message
141 .contains("found 2 whitespace characters, expected 1")
142 );
143 assert!(
144 violations[1]
145 .message
146 .contains("found 3 whitespace characters, expected 1")
147 );
148 assert!(
149 violations[2]
150 .message
151 .contains("found 4 whitespace characters, expected 1")
152 );
153 assert_eq!(violations[0].line, 3);
154 assert_eq!(violations[1].line, 5);
155 assert_eq!(violations[2].line, 7);
156 }
157
158 #[test]
159 fn test_md019_mixed_valid_invalid() {
160 let content = r#"# Valid heading
161
162## Invalid: two spaces
163
164### Valid heading
165
166#### Invalid: two spaces again
167
168##### Valid heading
169
170###### Invalid: three spaces
171"#;
172 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
173 let rule = MD019;
174 let violations = rule.check(&document).unwrap();
175
176 assert_eq!(violations.len(), 3);
177 assert_eq!(violations[0].line, 3);
178 assert_eq!(violations[1].line, 7);
179 assert_eq!(violations[2].line, 11);
180 }
181
182 #[test]
183 fn test_md019_no_space_after_hash() {
184 let content = r#"# Valid heading
185
186##No space after hash (different rule)
187
188### Valid heading
189
190####Multiple spaces after hash
191
192Regular text.
193"#;
194 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
195 let rule = MD019;
196 let violations = rule.check(&document).unwrap();
197
198 assert_eq!(violations.len(), 0);
200 }
201
202 #[test]
203 fn test_md019_tabs_and_mixed_whitespace() {
204 let content = "# Valid heading\n\n##\t\tTwo tabs after hash\n\n### \tSpace then tab\n\n#### \t Space tab space\n";
205 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
206 let rule = MD019;
207 let violations = rule.check(&document).unwrap();
208
209 assert_eq!(violations.len(), 3);
211 assert!(violations[0].message.contains("whitespace characters"));
212 assert!(violations[1].message.contains("whitespace characters"));
213 assert!(violations[2].message.contains("whitespace characters"));
214 }
215
216 #[test]
217 fn test_md019_heading_with_no_content() {
218 let content = r#"# Valid heading
219
220##
221
222###
223
224####
225
226Text here.
227"#;
228 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
229 let rule = MD019;
230 let violations = rule.check(&document).unwrap();
231
232 assert_eq!(violations.len(), 0);
234 }
235
236 #[test]
237 fn test_md019_shebang_and_hash_comments() {
238 let content = r#"#!/bin/bash
239
240# Valid heading
241
242## Invalid heading
243
244# This is a comment in some contexts but valid markdown heading
245
246Regular text.
247"#;
248 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
249 let rule = MD019;
250 let violations = rule.check(&document).unwrap();
251
252 assert_eq!(violations.len(), 1);
254 assert_eq!(violations[0].line, 5);
255 }
256
257 #[test]
258 fn test_md019_indented_headings() {
259 let content = r#"# Valid heading
260
261 ## Indented heading with multiple spaces
262
263Regular text.
264
265 ### Another indented heading
266"#;
267 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
268 let rule = MD019;
269 let violations = rule.check(&document).unwrap();
270
271 assert_eq!(violations.len(), 2);
273 assert_eq!(violations[0].line, 3);
274 assert_eq!(violations[1].line, 7);
275 }
276
277 #[test]
278 fn test_md019_all_heading_levels() {
279 let content = r#"# Level 1 with multiple spaces
280## Level 2 with multiple spaces
281### Level 3 with multiple spaces
282#### Level 4 with multiple spaces
283##### Level 5 with multiple spaces
284###### Level 6 with multiple spaces
285"#;
286 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
287 let rule = MD019;
288 let violations = rule.check(&document).unwrap();
289
290 assert_eq!(violations.len(), 6);
291 for (i, violation) in violations.iter().enumerate() {
292 assert_eq!(violation.line, i + 1);
293 assert!(
294 violation
295 .message
296 .contains("found 2 whitespace characters, expected 1")
297 );
298 }
299 }
300}