mdbook_lint_core/rules/standard/
md026.rs

1//! MD026: Trailing punctuation in headings
2//!
3//! This rule checks that headings do not end with punctuation characters.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check that headings do not end with punctuation
14pub struct MD026 {
15    /// Punctuation characters to check for (default: ".,;:!?")
16    punctuation: String,
17}
18
19impl MD026 {
20    /// Create a new MD026 rule with default settings
21    pub fn new() -> Self {
22        Self {
23            punctuation: ".,;:!?".to_string(),
24        }
25    }
26
27    /// Create a new MD026 rule with custom punctuation characters
28    #[allow(dead_code)]
29    pub fn with_punctuation(punctuation: String) -> Self {
30        Self { punctuation }
31    }
32
33    /// Check if a character is considered punctuation for this rule
34    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        // Find all heading nodes
66        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                // Skip empty headings
74                if heading_text.is_empty() {
75                    continue;
76                }
77
78                // Check if heading ends with punctuation
79                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        // Check each punctuation type
153        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        // Should detect punctuation even with trailing whitespace
277        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        // Should only detect the real heading, not the ones in code blocks
329        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}