mdbook_lint_core/rules/standard/
md021.rs

1//! MD021: Multiple spaces inside hashes on closed ATX heading
2//!
3//! This rule checks for multiple spaces inside hash characters on closed ATX style headings.
4//! Only one space should be used between the content and the closing hashes.
5
6use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9    Document,
10    violation::{Severity, Violation},
11};
12
13/// Rule to check for multiple spaces inside hashes on closed ATX style headings
14pub 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; // Convert to 1-based line numbers
42
43            // Check if this is an ATX-style heading (starts with #)
44            // Skip shebang lines (#!/...)
45            let trimmed = line.trim_start();
46            if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
47                // Check if this is a closed ATX heading (ends with #)
48                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                    // Extract the content between opening and closing hashes
54                    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                        // Check for multiple whitespace at the beginning
59                        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                        // Check for multiple whitespace at the end
73                        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        // Should detect violations on both sides
228        assert_eq!(violations.len(), 6);
229        // Each heading should generate 2 violations (beginning and end)
230        assert_eq!(violations[0].line, 3); // Two spaces after opening
231        assert_eq!(violations[1].line, 3); // Two spaces before closing
232        assert_eq!(violations[2].line, 5); // Three spaces after opening
233        assert_eq!(violations[3].line, 5); // Three spaces before closing
234        assert_eq!(violations[4].line, 7); // Four spaces after opening
235        assert_eq!(violations[5].line, 7); // Four spaces before closing
236    }
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); // Two spaces after opening
258        assert_eq!(violations[1].line, 7); // Two spaces after opening
259        assert_eq!(violations[2].line, 7); // Two spaces before closing
260        assert_eq!(violations[3].line, 11); // Three spaces after opening
261    }
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        // Should detect multiple whitespace characters (spaces and tabs)
271        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        // Empty closed headings with single space should be valid
291        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        // No spaces inside is handled by MD020, not this rule
311        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        // Should detect multiple spaces in indented closed headings
329        assert_eq!(violations.len(), 4);
330        assert_eq!(violations[0].line, 3); // Two spaces after opening
331        assert_eq!(violations[1].line, 3); // Two spaces before closing
332        assert_eq!(violations[2].line, 7); // Three spaces after opening
333        assert_eq!(violations[3].line, 7); // Three spaces before closing
334    }
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        // Should detect multiple spaces regardless of hash count symmetry
353        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        // Each heading should generate 2 violations (beginning and end)
370        assert_eq!(violations.len(), 12);
371        for (i, violation) in violations.iter().enumerate() {
372            let line_num = (i / 2) + 1; // Two violations per line
373            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        // Single spaces should be valid
396        assert_eq!(violations.len(), 0);
397    }
398}