mdbook_lint_core/rules/standard/
md021.rs1use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12
13pub struct MD021;
15
16impl Rule for MD021 {
17 fn id(&self) -> &'static str {
18 "MD021"
19 }
20
21 fn name(&self) -> &'static str {
22 "no-multiple-space-closed-atx"
23 }
24
25 fn description(&self) -> &'static str {
26 "Multiple spaces inside hashes on closed 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 if trimmed.ends_with('#') {
49 let opening_hash_count = trimmed.chars().take_while(|&c| c == '#').count();
50 let closing_hash_count =
51 trimmed.chars().rev().take_while(|&c| c == '#').count();
52
53 if trimmed.len() > opening_hash_count + closing_hash_count {
55 let content_with_spaces =
56 &trimmed[opening_hash_count..trimmed.len() - closing_hash_count];
57
58 let leading_whitespace_count = content_with_spaces
60 .chars()
61 .take_while(|c| c.is_whitespace())
62 .count();
63 if leading_whitespace_count > 1 {
64 violations.push(self.create_violation(
65 format!("Multiple spaces after opening hashes in closed ATX heading: found {leading_whitespace_count} whitespace characters, expected 1"),
66 line_num,
67 opening_hash_count + 1,
68 Severity::Warning,
69 ));
70 }
71
72 let trailing_whitespace_count = content_with_spaces
74 .chars()
75 .rev()
76 .take_while(|c| c.is_whitespace())
77 .count();
78 if trailing_whitespace_count > 1 {
79 violations.push(self.create_violation(
80 format!("Multiple spaces before closing hashes in closed ATX heading: found {trailing_whitespace_count} whitespace characters, expected 1"),
81 line_num,
82 trimmed.len() - closing_hash_count - trailing_whitespace_count + 1,
83 Severity::Warning,
84 ));
85 }
86 }
87 }
88 }
89 }
90
91 Ok(violations)
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::Document;
99 use crate::rule::Rule;
100 use std::path::PathBuf;
101
102 #[test]
103 fn test_md021_no_violations() {
104 let content = r#"# Open ATX heading (not checked)
105
106## Another open heading
107
108# Single space inside #
109
110## Single space here ##
111
112### Valid closed heading ###
113
114#### Multiple words single space ####
115
116##### Another valid closed heading #####
117
118###### Level 6 valid ######
119
120Regular paragraph text.
121
122Not a heading: # this has text before it #
123
124Also not a heading:
125# this is indented #
126
127Shebang line should be ignored:
128#!/bin/bash
129"#;
130 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
131 let rule = MD021;
132 let violations = rule.check(&document).unwrap();
133
134 assert_eq!(violations.len(), 0);
135 }
136
137 #[test]
138 fn test_md021_multiple_spaces_at_beginning() {
139 let content = r#"# Open heading is fine
140
141## Two spaces after opening ##
142
143### Three spaces after opening ###
144
145#### Four spaces after opening ####
146
147Regular text.
148"#;
149 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
150 let rule = MD021;
151 let violations = rule.check(&document).unwrap();
152
153 assert_eq!(violations.len(), 3);
154 assert!(
155 violations[0]
156 .message
157 .contains("found 2 whitespace characters, expected 1")
158 );
159 assert!(
160 violations[1]
161 .message
162 .contains("found 3 whitespace characters, expected 1")
163 );
164 assert!(
165 violations[2]
166 .message
167 .contains("found 4 whitespace characters, expected 1")
168 );
169 assert_eq!(violations[0].line, 3);
170 assert_eq!(violations[1].line, 5);
171 assert_eq!(violations[2].line, 7);
172 }
173
174 #[test]
175 fn test_md021_multiple_spaces_at_end() {
176 let content = r#"# Open heading is fine
177
178## Content with two spaces ##
179
180### Content with three spaces ###
181
182#### Content with four spaces ####
183
184Regular text.
185"#;
186 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
187 let rule = MD021;
188 let violations = rule.check(&document).unwrap();
189
190 assert_eq!(violations.len(), 3);
191 assert!(
192 violations[0]
193 .message
194 .contains("found 2 whitespace characters, expected 1")
195 );
196 assert!(
197 violations[1]
198 .message
199 .contains("found 3 whitespace characters, expected 1")
200 );
201 assert!(
202 violations[2]
203 .message
204 .contains("found 4 whitespace characters, expected 1")
205 );
206 assert_eq!(violations[0].line, 3);
207 assert_eq!(violations[1].line, 5);
208 assert_eq!(violations[2].line, 7);
209 }
210
211 #[test]
212 fn test_md021_multiple_spaces_both_sides() {
213 let content = r#"# Open heading is fine
214
215## Two spaces both sides ##
216
217### Three spaces both sides ###
218
219#### Four spaces both sides ####
220
221Regular text.
222"#;
223 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
224 let rule = MD021;
225 let violations = rule.check(&document).unwrap();
226
227 assert_eq!(violations.len(), 6);
229 assert_eq!(violations[0].line, 3); assert_eq!(violations[1].line, 3); assert_eq!(violations[2].line, 5); assert_eq!(violations[3].line, 5); assert_eq!(violations[4].line, 7); assert_eq!(violations[5].line, 7); }
237
238 #[test]
239 fn test_md021_mixed_valid_invalid() {
240 let content = r#"# Valid closed heading #
241
242## Invalid: two spaces after ##
243
244### Valid closed heading ###
245
246#### Invalid: two spaces both sides ####
247
248##### Valid closed heading #####
249
250###### Invalid: three spaces after ######
251"#;
252 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
253 let rule = MD021;
254 let violations = rule.check(&document).unwrap();
255
256 assert_eq!(violations.len(), 4);
257 assert_eq!(violations[0].line, 3); assert_eq!(violations[1].line, 7); assert_eq!(violations[2].line, 7); assert_eq!(violations[3].line, 11); }
262
263 #[test]
264 fn test_md021_tabs_and_mixed_whitespace() {
265 let content = "#\t\tTwo tabs after opening##\n\n##Content with tab at end\t\t##\n\n###\t Content tab space mix \t###\n";
266 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
267 let rule = MD021;
268 let violations = rule.check(&document).unwrap();
269
270 assert_eq!(violations.len(), 4);
272 }
273
274 #[test]
275 fn test_md021_empty_closed_heading() {
276 let content = r#"# Valid open heading
277
278## ##
279
280### ###
281
282#### ####
283
284Regular text.
285"#;
286 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
287 let rule = MD021;
288 let violations = rule.check(&document).unwrap();
289
290 assert_eq!(violations.len(), 0);
292 }
293
294 #[test]
295 fn test_md021_no_space_inside() {
296 let content = r#"# Valid open heading
297
298##No space inside##
299
300###Content###
301
302####Text####
303
304Regular text.
305"#;
306 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
307 let rule = MD021;
308 let violations = rule.check(&document).unwrap();
309
310 assert_eq!(violations.len(), 0);
312 }
313
314 #[test]
315 fn test_md021_indented_headings() {
316 let content = r#"# Valid open heading
317
318 ## Indented with multiple spaces ##
319
320Regular text.
321
322 ### Another indented with multiple spaces ###
323"#;
324 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
325 let rule = MD021;
326 let violations = rule.check(&document).unwrap();
327
328 assert_eq!(violations.len(), 4);
330 assert_eq!(violations[0].line, 3); assert_eq!(violations[1].line, 3); assert_eq!(violations[2].line, 7); assert_eq!(violations[3].line, 7); }
335
336 #[test]
337 fn test_md021_asymmetric_hashes() {
338 let content = r#"# Open heading with one hash
339
340## Content with multiple spaces ####
341
342### More content #####
343
344#### Even more ######
345
346Regular text.
347"#;
348 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
349 let rule = MD021;
350 let violations = rule.check(&document).unwrap();
351
352 assert_eq!(violations.len(), 6);
354 }
355
356 #[test]
357 fn test_md021_all_heading_levels() {
358 let content = r#"# Content with multiple spaces #
359## Content with multiple spaces ##
360### Content with multiple spaces ###
361#### Content with multiple spaces ####
362##### Content with multiple spaces #####
363###### Content with multiple spaces ######
364"#;
365 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
366 let rule = MD021;
367 let violations = rule.check(&document).unwrap();
368
369 assert_eq!(violations.len(), 12);
371 for (i, violation) in violations.iter().enumerate() {
372 let line_num = (i / 2) + 1; assert_eq!(violation.line, line_num);
374 assert!(
375 violation
376 .message
377 .contains("found 2 whitespace characters, expected 1")
378 );
379 }
380 }
381
382 #[test]
383 fn test_md021_single_space_valid() {
384 let content = r#"# Content with single space #
385## Content with single space ##
386### Content with single space ###
387#### Content with single space ####
388##### Content with single space #####
389###### Content with single space ######
390"#;
391 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
392 let rule = MD021;
393 let violations = rule.check(&document).unwrap();
394
395 assert_eq!(violations.len(), 0);
397 }
398}