mdlint/lint/rules/
md026.rs1use 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 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); }
119}