mdbook_lint_core/rules/standard/
md036.rs

1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6/// MD036 - Emphasis used instead of a heading
7pub struct MD036 {
8    /// Punctuation characters that prevent treating emphasis as heading
9    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        // Must be a single line paragraph
29        if trimmed.is_empty() {
30            return false;
31        }
32
33        // Check for bold emphasis (**text** or __text__)
34        let is_bold = (trimmed.starts_with("**") && trimmed.ends_with("**") && trimmed.len() > 4)
35            || (trimmed.starts_with("__") && trimmed.ends_with("__") && trimmed.len() > 4);
36
37        // Check for italic emphasis (*text* or _text_)
38        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        // Extract the inner text
52        let inner_text = if is_bold {
53            &trimmed[2..trimmed.len() - 2]
54        } else {
55            &trimmed[1..trimmed.len() - 1]
56        };
57
58        // Must not end with punctuation
59        if let Some(last_char) = inner_text.chars().last()
60            && self.punctuation.contains(last_char)
61        {
62            return false;
63        }
64
65        // Must not be empty after removing emphasis markers
66        if inner_text.trim().is_empty() {
67            return false;
68        }
69
70        // Must not contain line breaks (already handled by single line check)
71        // Must be the entire content of the line (already handled by starts_with/ends_with)
72
73        true
74    }
75
76    fn is_paragraph_context(&self, lines: &[&str], line_index: usize) -> bool {
77        // Check if this line is surrounded by blank lines (paragraph context)
78        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            // Skip empty lines
121            if line.trim().is_empty() {
122                continue;
123            }
124
125            // Check if this line uses emphasis as a heading
126            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); // No violation because of punctuation
234    }
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("!?"); // Allow periods
247        let violations = rule.check(&document).unwrap();
248        assert_eq!(violations.len(), 1); // Now triggers because period is allowed
249        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); // Empty emphasis should not trigger
319    }
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); // Only the one without punctuation
338        assert_eq!(violations[0].line, 7);
339    }
340}