Skip to main content

mdlint/lint/rules/
md026.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::{Event, Tag, TagEnd};
5use serde_json::Value;
6
7pub struct MD026;
8
9impl Rule for MD026 {
10    fn name(&self) -> &'static str {
11        "MD026"
12    }
13
14    fn description(&self) -> &'static str {
15        "Trailing punctuation in heading"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["headings"]
20    }
21
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let punctuation = config
24            .and_then(|c| c.get("punctuation"))
25            .and_then(|v| v.as_str())
26            .unwrap_or(".,;:!");
27
28        let mut violations = Vec::new();
29        let mut in_heading = false;
30        let mut current_heading_text = String::new();
31        let mut current_heading_line = 0;
32
33        for (event, range) in parser.parse_with_offsets() {
34            match event {
35                Event::Start(Tag::Heading { .. }) => {
36                    in_heading = true;
37                    current_heading_text.clear();
38                    current_heading_line = parser.offset_to_line(range.start);
39                }
40                Event::Text(text) if in_heading => {
41                    current_heading_text.push_str(&text);
42                }
43                Event::Code(text) if in_heading => {
44                    current_heading_text.push_str(&text);
45                }
46                Event::End(TagEnd::Heading(_)) if in_heading => {
47                    let trimmed = current_heading_text.trim();
48                    if let Some(last_char) = trimmed.chars().last()
49                        && punctuation.contains(last_char)
50                    {
51                        violations.push(Violation {
52                            line: current_heading_line,
53                            column: Some(1),
54                            rule: self.name().to_owned(),
55                            message: format!("Trailing punctuation in heading: '{last_char}'"),
56                            fix: None,
57                        });
58                    }
59                    in_heading = false;
60                }
61                _ => {}
62            }
63        }
64
65        violations
66    }
67
68    fn fixable(&self) -> bool {
69        false
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_no_trailing_punctuation() {
79        let content = "# Heading\n## Another Heading";
80        let parser = MarkdownParser::new(content);
81        let rule = MD026;
82        let violations = rule.check(&parser, None);
83
84        assert_eq!(violations.len(), 0);
85    }
86
87    #[test]
88    fn test_trailing_period() {
89        let content = "# Heading.";
90        let parser = MarkdownParser::new(content);
91        let rule = MD026;
92        let violations = rule.check(&parser, None);
93
94        assert_eq!(violations.len(), 1);
95        assert!(violations[0].message.contains("'.'"));
96    }
97
98    #[test]
99    fn test_trailing_question() {
100        let content = "## What is this?";
101        let parser = MarkdownParser::new(content);
102        let rule = MD026;
103        let violations = rule.check(&parser, None);
104
105        // Question marks are not in the default punctuation set
106        assert_eq!(violations.len(), 0);
107    }
108
109    #[test]
110    fn test_custom_punctuation() {
111        let content = "# Heading!";
112        let parser = MarkdownParser::new(content);
113        let rule = MD026;
114        let config = serde_json::json!({ "punctuation": "." });
115        let violations = rule.check(&parser, Some(&config));
116
117        assert_eq!(violations.len(), 0); // ! not in custom punctuation list
118    }
119}