mdbook_lint_core/rules/standard/
md020.rs

1//! MD020: No space inside hashes on closed ATX heading
2//!
3//! This rule checks for spaces inside hash characters on closed ATX style headings.
4//! Closed ATX headings should not have spaces 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 spaces inside hashes on closed ATX style headings
14pub struct MD020;
15
16impl Rule for MD020 {
17    fn id(&self) -> &'static str {
18        "MD020"
19    }
20
21    fn name(&self) -> &'static str {
22        "no-space-inside-atx"
23    }
24
25    fn description(&self) -> &'static str {
26        "No space 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 whitespace at the beginning or end of the content
59                        if content_with_spaces.starts_with(|c: char| c.is_whitespace())
60                            || content_with_spaces.ends_with(|c: char| c.is_whitespace())
61                        {
62                            violations.push(self.create_violation(
63                                "Whitespace found inside hashes on closed ATX heading".to_string(),
64                                line_num,
65                                1,
66                                Severity::Warning,
67                            ));
68                        }
69                    }
70                }
71            }
72        }
73
74        Ok(violations)
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::Document;
82    use crate::rule::Rule;
83    use std::path::PathBuf;
84
85    #[test]
86    fn test_md020_no_violations() {
87        let content = r#"# Open ATX heading (not checked)
88
89## Another open heading
90
91#No spaces inside#
92
93##No spaces here either##
94
95###Content without spaces###
96
97####Multiple words but no spaces####
98
99#####Another valid closed heading#####
100
101######Level 6 valid######
102
103Regular paragraph text.
104
105Not a heading: # this has text before it #
106
107Shebang line should be ignored:
108#!/bin/bash
109"#;
110        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
111        let rule = MD020;
112        let violations = rule.check(&document).unwrap();
113
114        assert_eq!(violations.len(), 0);
115    }
116
117    #[test]
118    fn test_md020_space_at_beginning() {
119        let content = r#"# Open heading is fine
120
121## Space at beginning of closed heading ##
122
123### Another violation ###
124
125Regular text.
126"#;
127        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
128        let rule = MD020;
129        let violations = rule.check(&document).unwrap();
130
131        assert_eq!(violations.len(), 2);
132        assert_eq!(violations[0].line, 3);
133        assert_eq!(violations[1].line, 5);
134        assert!(
135            violations[0]
136                .message
137                .contains("Whitespace found inside hashes")
138        );
139        assert!(
140            violations[1]
141                .message
142                .contains("Whitespace found inside hashes")
143        );
144    }
145
146    #[test]
147    fn test_md020_space_at_end() {
148        let content = r#"# Open heading is fine
149
150##Content with space at end ##
151
152###Another space at end ###
153
154Regular text.
155"#;
156        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
157        let rule = MD020;
158        let violations = rule.check(&document).unwrap();
159
160        assert_eq!(violations.len(), 2);
161        assert_eq!(violations[0].line, 3);
162        assert_eq!(violations[1].line, 5);
163    }
164
165    #[test]
166    fn test_md020_spaces_both_sides() {
167        let content = r#"# Open heading is fine
168
169## Spaces on both sides ##
170
171### More spaces on both sides ###
172
173####  Even more spaces  ####
174
175Regular text.
176"#;
177        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
178        let rule = MD020;
179        let violations = rule.check(&document).unwrap();
180
181        assert_eq!(violations.len(), 3);
182        assert_eq!(violations[0].line, 3);
183        assert_eq!(violations[1].line, 5);
184        assert_eq!(violations[2].line, 7);
185    }
186
187    #[test]
188    fn test_md020_mixed_valid_invalid() {
189        let content = r#"#Valid closed heading#
190
191## Invalid with spaces ##
192
193###Another valid###
194
195#### Another invalid ####
196
197#####Valid again#####
198
199###### Final invalid ######
200"#;
201        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
202        let rule = MD020;
203        let violations = rule.check(&document).unwrap();
204
205        assert_eq!(violations.len(), 3);
206        assert_eq!(violations[0].line, 3);
207        assert_eq!(violations[1].line, 7);
208        assert_eq!(violations[2].line, 11);
209    }
210
211    #[test]
212    fn test_md020_asymmetric_hashes() {
213        let content = r#"# Open heading with one hash
214
215##Content##
216
217###More content####
218
219####Even more#####
220
221Regular text.
222"#;
223        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
224        let rule = MD020;
225        let violations = rule.check(&document).unwrap();
226
227        // Should detect closed headings regardless of hash count symmetry
228        assert_eq!(violations.len(), 0);
229    }
230
231    #[test]
232    fn test_md020_empty_closed_heading() {
233        let content = r#"# Valid open heading
234
235##Empty closed##
236
237###Another empty###
238
239####Content####
240
241Regular text.
242"#;
243        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
244        let rule = MD020;
245        let violations = rule.check(&document).unwrap();
246
247        // Empty closed headings should not trigger violations (no spaces to check)
248        assert_eq!(violations.len(), 0);
249    }
250
251    #[test]
252    fn test_md020_indented_headings() {
253        let content = r#"# Valid open heading
254
255    ## Indented with spaces ##
256
257Regular text.
258
259  ### Another indented ###
260"#;
261        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
262        let rule = MD020;
263        let violations = rule.check(&document).unwrap();
264
265        // Should detect spaces in indented closed headings
266        assert_eq!(violations.len(), 2);
267        assert_eq!(violations[0].line, 3);
268        assert_eq!(violations[1].line, 7);
269    }
270
271    #[test]
272    fn test_md020_only_closing_hash() {
273        let content = r#"# Valid open heading
274
275This is not a heading #
276
277##This is valid##
278
279Regular text ending with hash #
280"#;
281        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
282        let rule = MD020;
283        let violations = rule.check(&document).unwrap();
284
285        // Should only check actual headings (lines starting with #)
286        assert_eq!(violations.len(), 0);
287    }
288
289    #[test]
290    fn test_md020_all_heading_levels() {
291        let content = r#"# Content with spaces #
292## Content with spaces ##
293### Content with spaces ###
294#### Content with spaces ####
295##### Content with spaces #####
296###### Content with spaces ######
297"#;
298        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
299        let rule = MD020;
300        let violations = rule.check(&document).unwrap();
301
302        assert_eq!(violations.len(), 6);
303        for (i, violation) in violations.iter().enumerate() {
304            assert_eq!(violation.line, i + 1);
305            assert!(violation.message.contains("Whitespace found inside hashes"));
306        }
307    }
308
309    #[test]
310    fn test_md020_tabs_inside_hashes() {
311        let content = "#\tContent with tab\t#\n\n##\tAnother tab\t##\n";
312        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
313        let rule = MD020;
314        let violations = rule.check(&document).unwrap();
315
316        // Should detect tabs as whitespace inside hashes
317        assert_eq!(violations.len(), 2);
318    }
319}