mdbook_lint_core/rules/standard/
md019.rs

1//! MD019: Multiple spaces after hash on ATX heading
2//!
3//! This rule checks for multiple spaces after hash characters in ATX style headings.
4//! Only one space should be used after the hash characters.
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 after hash on ATX style headings
14pub 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; // 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                // Find where the heading content starts
48                let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
49
50                // Check if there's content after the hashes
51                if trimmed.len() > hash_count {
52                    let after_hashes = &trimmed[hash_count..];
53
54                    // Check for multiple whitespace characters after the hashes
55                    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, // Position after the last hash
69                            Severity::Warning,
70                        ));
71                    }
72                } else if trimmed.len() == hash_count {
73                    // Handle empty headings like "##" - they should have no space after
74                    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        // Should only detect the multiple spaces, not the missing space
199        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        // Should detect multiple whitespace characters (spaces and tabs)
210        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        // Empty headings (## with no content) should not be flagged by this rule
233        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        // Should ignore shebang but detect the invalid heading
253        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        // Should detect multiple spaces even in indented headings
272        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}