mdbook_lint_core/rules/standard/
md020.rs1use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12
13pub 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; 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 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 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 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 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 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 assert_eq!(violations.len(), 2);
318 }
319}