mdbook_lint_core/rules/standard/
md036.rs1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6pub struct MD036 {
8 pub punctuation: String,
10}
11
12impl MD036 {
13 pub fn new() -> Self {
14 Self {
15 punctuation: ".,;:!?。,;:!?".to_string(),
16 }
17 }
18
19 #[allow(dead_code)]
20 pub fn with_punctuation(mut self, punctuation: &str) -> Self {
21 self.punctuation = punctuation.to_string();
22 self
23 }
24
25 fn is_emphasis_as_heading(&self, line: &str) -> bool {
26 let trimmed = line.trim();
27
28 if trimmed.is_empty() {
30 return false;
31 }
32
33 let is_bold = (trimmed.starts_with("**") && trimmed.ends_with("**") && trimmed.len() > 4)
35 || (trimmed.starts_with("__") && trimmed.ends_with("__") && trimmed.len() > 4);
36
37 let is_italic = (trimmed.starts_with('*')
39 && trimmed.ends_with('*')
40 && trimmed.len() > 2
41 && !trimmed.starts_with("**"))
42 || (trimmed.starts_with('_')
43 && trimmed.ends_with('_')
44 && trimmed.len() > 2
45 && !trimmed.starts_with("__"));
46
47 if !is_bold && !is_italic {
48 return false;
49 }
50
51 let inner_text = if is_bold {
53 &trimmed[2..trimmed.len() - 2]
54 } else {
55 &trimmed[1..trimmed.len() - 1]
56 };
57
58 if let Some(last_char) = inner_text.chars().last()
60 && self.punctuation.contains(last_char)
61 {
62 return false;
63 }
64
65 if inner_text.trim().is_empty() {
67 return false;
68 }
69
70 true
74 }
75
76 fn is_paragraph_context(&self, lines: &[&str], line_index: usize) -> bool {
77 let has_blank_before = line_index == 0 || lines[line_index - 1].trim().is_empty();
79 let has_blank_after =
80 line_index == lines.len() - 1 || lines[line_index + 1].trim().is_empty();
81
82 has_blank_before && has_blank_after
83 }
84}
85
86impl Default for MD036 {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92impl Rule for MD036 {
93 fn id(&self) -> &'static str {
94 "MD036"
95 }
96
97 fn name(&self) -> &'static str {
98 "no-emphasis-as-heading"
99 }
100
101 fn description(&self) -> &'static str {
102 "Emphasis used instead of a heading"
103 }
104
105 fn metadata(&self) -> RuleMetadata {
106 RuleMetadata::stable(RuleCategory::Structure)
107 }
108
109 fn check_with_ast<'a>(
110 &self,
111 document: &Document,
112 _ast: Option<&'a comrak::nodes::AstNode<'a>>,
113 ) -> Result<Vec<Violation>> {
114 let mut violations = Vec::new();
115 let lines: Vec<&str> = document.content.lines().collect();
116
117 for (line_index, line) in lines.iter().enumerate() {
118 let line_number = line_index + 1;
119
120 if line.trim().is_empty() {
122 continue;
123 }
124
125 if self.is_emphasis_as_heading(line) && self.is_paragraph_context(&lines, line_index) {
127 violations.push(self.create_violation(
128 "Emphasis used instead of a heading".to_string(),
129 line_number,
130 1,
131 Severity::Warning,
132 ));
133 }
134 }
135
136 Ok(violations)
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::Document;
144 use std::path::PathBuf;
145
146 #[test]
147 fn test_md036_no_violations() {
148 let content = r#"# Proper heading
149
150Some normal text with **bold** and *italic* within the paragraph.
151
152## Another heading
153
154Regular paragraph with emphasis.
155"#;
156
157 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
158 let rule = MD036::new();
159 let violations = rule.check(&document).unwrap();
160 assert_eq!(violations.len(), 0);
161 }
162
163 #[test]
164 fn test_md036_bold_as_heading() {
165 let content = r#"Some text
166
167**My document**
168
169Lorem ipsum dolor sit amet...
170"#;
171
172 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
173 let rule = MD036::new();
174 let violations = rule.check(&document).unwrap();
175 assert_eq!(violations.len(), 1);
176 assert_eq!(violations[0].line, 3);
177 assert!(
178 violations[0]
179 .message
180 .contains("Emphasis used instead of a heading")
181 );
182 }
183
184 #[test]
185 fn test_md036_italic_as_heading() {
186 let content = r#"Some text
187
188_Another section_
189
190Consectetur adipiscing elit, sed do eiusmod.
191"#;
192
193 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
194 let rule = MD036::new();
195 let violations = rule.check(&document).unwrap();
196 assert_eq!(violations.len(), 1);
197 assert_eq!(violations[0].line, 3);
198 assert!(
199 violations[0]
200 .message
201 .contains("Emphasis used instead of a heading")
202 );
203 }
204
205 #[test]
206 fn test_md036_underscore_bold_as_heading() {
207 let content = r#"Introduction
208
209__Important Section__
210
211This is the content of the section.
212"#;
213
214 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
215 let rule = MD036::new();
216 let violations = rule.check(&document).unwrap();
217 assert_eq!(violations.len(), 1);
218 assert_eq!(violations[0].line, 3);
219 }
220
221 #[test]
222 fn test_md036_with_punctuation_allowed() {
223 let content = r#"Some text
224
225**Section with period.**
226
227More content here.
228"#;
229
230 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
231 let rule = MD036::new();
232 let violations = rule.check(&document).unwrap();
233 assert_eq!(violations.len(), 0); }
235
236 #[test]
237 fn test_md036_custom_punctuation() {
238 let content = r#"Some text
239
240**Section with period.**
241
242More content here.
243"#;
244
245 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
246 let rule = MD036::new().with_punctuation("!?"); let violations = rule.check(&document).unwrap();
248 assert_eq!(violations.len(), 1); assert_eq!(violations[0].line, 3);
250 }
251
252 #[test]
253 fn test_md036_inline_emphasis_ignored() {
254 let content = r#"This is a paragraph with **bold text** in the middle and *italic text* as well.
255
256Another paragraph with normal content.
257"#;
258
259 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
260 let rule = MD036::new();
261 let violations = rule.check(&document).unwrap();
262 assert_eq!(violations.len(), 0);
263 }
264
265 #[test]
266 fn test_md036_no_surrounding_blank_lines() {
267 let content = r#"Some text
268**Not a heading because no blank line above**
269More text
270"#;
271
272 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
273 let rule = MD036::new();
274 let violations = rule.check(&document).unwrap();
275 assert_eq!(violations.len(), 0);
276 }
277
278 #[test]
279 fn test_md036_multiple_violations() {
280 let content = r#"Introduction
281
282**First Section**
283
284Some content here.
285
286_Second Section_
287
288More content here.
289
290__Third Section__
291
292Final content.
293"#;
294
295 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
296 let rule = MD036::new();
297 let violations = rule.check(&document).unwrap();
298 assert_eq!(violations.len(), 3);
299 assert_eq!(violations[0].line, 3);
300 assert_eq!(violations[1].line, 7);
301 assert_eq!(violations[2].line, 11);
302 }
303
304 #[test]
305 fn test_md036_empty_emphasis() {
306 let content = r#"Some text
307
308****
309
310** **
311
312More text.
313"#;
314
315 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
316 let rule = MD036::new();
317 let violations = rule.check(&document).unwrap();
318 assert_eq!(violations.len(), 0); }
320
321 #[test]
322 fn test_md036_mixed_punctuation() {
323 let content = r#"Some text
324
325**Question?**
326
327**Exclamation!**
328
329**Normal heading**
330
331More content.
332"#;
333
334 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
335 let rule = MD036::new();
336 let violations = rule.check(&document).unwrap();
337 assert_eq!(violations.len(), 1); assert_eq!(violations[0].line, 7);
339 }
340}