mdbook_lint_core/rules/standard/
md026.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13pub struct MD026 {
15 punctuation: String,
17}
18
19impl MD026 {
20 pub fn new() -> Self {
22 Self {
23 punctuation: ".,;:!?".to_string(),
24 }
25 }
26
27 #[allow(dead_code)]
29 pub fn with_punctuation(punctuation: String) -> Self {
30 Self { punctuation }
31 }
32
33 fn is_punctuation(&self, ch: char) -> bool {
35 self.punctuation.contains(ch)
36 }
37}
38
39impl Default for MD026 {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl AstRule for MD026 {
46 fn id(&self) -> &'static str {
47 "MD026"
48 }
49
50 fn name(&self) -> &'static str {
51 "no-trailing-punctuation"
52 }
53
54 fn description(&self) -> &'static str {
55 "Trailing punctuation in heading"
56 }
57
58 fn metadata(&self) -> RuleMetadata {
59 RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
60 }
61
62 fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
63 let mut violations = Vec::new();
64
65 for node in ast.descendants() {
67 if let NodeValue::Heading(_heading) = &node.data.borrow().value
68 && let Some((line, column)) = document.node_position(node)
69 {
70 let heading_text = document.node_text(node);
71 let heading_text = heading_text.trim();
72
73 if heading_text.is_empty() {
75 continue;
76 }
77
78 if let Some(last_char) = heading_text.chars().last()
80 && self.is_punctuation(last_char)
81 {
82 violations.push(self.create_violation(
83 format!(
84 "Heading should not end with punctuation '{last_char}': {heading_text}"
85 ),
86 line,
87 column,
88 Severity::Warning,
89 ));
90 }
91 }
92 }
93
94 Ok(violations)
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::Document;
102 use crate::rule::Rule;
103 use std::path::PathBuf;
104
105 #[test]
106 fn test_md026_no_punctuation() {
107 let content = r#"# Valid heading
108## Another valid heading
109### Third level heading
110"#;
111 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
112 let rule = MD026::new();
113 let violations = rule.check(&document).unwrap();
114
115 assert_eq!(violations.len(), 0);
116 }
117
118 #[test]
119 fn test_md026_period_violation() {
120 let content = r#"# Heading with period.
121Some content here.
122"#;
123 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
124 let rule = MD026::new();
125 let violations = rule.check(&document).unwrap();
126
127 assert_eq!(violations.len(), 1);
128 assert!(
129 violations[0]
130 .message
131 .contains("should not end with punctuation '.'")
132 );
133 assert!(violations[0].message.contains("Heading with period."));
134 assert_eq!(violations[0].line, 1);
135 }
136
137 #[test]
138 fn test_md026_multiple_punctuation_types() {
139 let content = r#"# Heading with period.
140## Heading with comma,
141### Heading with semicolon;
142#### Heading with colon:
143##### Heading with exclamation!
144###### Heading with question?
145"#;
146 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
147 let rule = MD026::new();
148 let violations = rule.check(&document).unwrap();
149
150 assert_eq!(violations.len(), 6);
151
152 assert!(
154 violations[0]
155 .message
156 .contains("should not end with punctuation '.'")
157 );
158 assert!(
159 violations[1]
160 .message
161 .contains("should not end with punctuation ','")
162 );
163 assert!(
164 violations[2]
165 .message
166 .contains("should not end with punctuation ';'")
167 );
168 assert!(
169 violations[3]
170 .message
171 .contains("should not end with punctuation ':'")
172 );
173 assert!(
174 violations[4]
175 .message
176 .contains("should not end with punctuation '!'")
177 );
178 assert!(
179 violations[5]
180 .message
181 .contains("should not end with punctuation '?'")
182 );
183 }
184
185 #[test]
186 fn test_md026_custom_punctuation() {
187 let content = r#"# Heading with period.
188## Heading with custom @
189### Heading with allowed!
190"#;
191 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
192 let rule = MD026::with_punctuation(".@".to_string());
193 let violations = rule.check(&document).unwrap();
194
195 assert_eq!(violations.len(), 2);
196 assert!(
197 violations[0]
198 .message
199 .contains("should not end with punctuation '.'")
200 );
201 assert!(
202 violations[1]
203 .message
204 .contains("should not end with punctuation '@'")
205 );
206 }
207
208 #[test]
209 fn test_md026_setext_headings() {
210 let content = r#"Setext heading with period.
211===========================
212
213Another setext with question?
214-----------------------------
215"#;
216 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
217 let rule = MD026::new();
218 let violations = rule.check(&document).unwrap();
219
220 assert_eq!(violations.len(), 2);
221 assert!(
222 violations[0]
223 .message
224 .contains("should not end with punctuation '.'")
225 );
226 assert!(
227 violations[1]
228 .message
229 .contains("should not end with punctuation '?'")
230 );
231 }
232
233 #[test]
234 fn test_md026_empty_headings_ignored() {
235 let content = r#"#
236
237##
238
239###
240"#;
241 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
242 let rule = MD026::new();
243 let violations = rule.check(&document).unwrap();
244
245 assert_eq!(violations.len(), 0);
246 }
247
248 #[test]
249 fn test_md026_punctuation_in_middle() {
250 let content = r#"# Heading with punctuation, but not at end
251## Question? No, this is fine at end!
252### Period. In middle is ok
253"#;
254 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
255 let rule = MD026::new();
256 let violations = rule.check(&document).unwrap();
257
258 assert_eq!(violations.len(), 1);
259 assert!(
260 violations[0]
261 .message
262 .contains("should not end with punctuation '!'")
263 );
264 assert_eq!(violations[0].line, 2);
265 }
266
267 #[test]
268 fn test_md026_whitespace_after_punctuation() {
269 let content = r#"# Heading with period.
270## Heading with spaces after punctuation.
271"#;
272 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
273 let rule = MD026::new();
274 let violations = rule.check(&document).unwrap();
275
276 assert_eq!(violations.len(), 2);
278 assert!(
279 violations[0]
280 .message
281 .contains("should not end with punctuation '.'")
282 );
283 assert!(
284 violations[1]
285 .message
286 .contains("should not end with punctuation '.'")
287 );
288 }
289
290 #[test]
291 fn test_md026_closed_atx_headings() {
292 let content = r#"# Closed ATX heading. #
293## Another closed heading! ##
294### Valid closed heading ###
295"#;
296 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
297 let rule = MD026::new();
298 let violations = rule.check(&document).unwrap();
299
300 assert_eq!(violations.len(), 2);
301 assert!(
302 violations[0]
303 .message
304 .contains("should not end with punctuation '.'")
305 );
306 assert!(
307 violations[1]
308 .message
309 .contains("should not end with punctuation '!'")
310 );
311 }
312
313 #[test]
314 fn test_md026_headings_in_code_blocks() {
315 let content = r#"Some text here.
316
317```markdown
318# This heading has punctuation.
319## This one too!
320```
321
322# But this real heading also has punctuation.
323"#;
324 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
325 let rule = MD026::new();
326 let violations = rule.check(&document).unwrap();
327
328 assert_eq!(violations.len(), 1);
330 assert!(
331 violations[0]
332 .message
333 .contains("should not end with punctuation '.'")
334 );
335 assert_eq!(violations[0].line, 8);
336 }
337}