quickmark_core/rules/
md036.rs

1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};
7
8use super::{Rule, RuleType};
9
10// MD036-specific configuration types
11#[derive(Debug, PartialEq, Clone, Deserialize)]
12pub struct MD036EmphasisAsHeadingTable {
13    #[serde(default)]
14    pub punctuation: String,
15}
16
17impl Default for MD036EmphasisAsHeadingTable {
18    fn default() -> Self {
19        Self {
20            punctuation: ".,;:!?。,;:!?".to_string(),
21        }
22    }
23}
24
25pub(crate) struct MD036Linter {
26    context: Rc<Context>,
27    violations: Vec<RuleViolation>,
28}
29
30impl MD036Linter {
31    pub fn new(context: Rc<Context>) -> Self {
32        Self {
33            context,
34            violations: Vec::new(),
35        }
36    }
37
38    fn is_meaningful_node(node: &Node) -> bool {
39        matches!(
40            node.kind(),
41            "text" | "emphasis" | "strong_emphasis" | "inline"
42        )
43    }
44
45    fn extract_text_content(&self, node: &Node) -> String {
46        let source = self.context.get_document_content();
47        let start_byte = node.start_byte();
48        let end_byte = node.end_byte();
49        source[start_byte..end_byte].to_string()
50    }
51
52    fn check_inline_for_emphasis_heading(&mut self, inline_node: &Node) {
53        // Get the text content of the inline node
54        let inline_text = self.extract_text_content(inline_node);
55        let trimmed_text = inline_text.trim();
56
57        // Check if the entire inline text is emphasis (starts and ends with * or ** or _ or __)
58        if (trimmed_text.starts_with("**")
59            && trimmed_text.ends_with("**")
60            && trimmed_text.len() > 4)
61            || (trimmed_text.starts_with("__")
62                && trimmed_text.ends_with("__")
63                && trimmed_text.len() > 4)
64            || (trimmed_text.starts_with("*")
65                && trimmed_text.ends_with("*")
66                && trimmed_text.len() > 2
67                && !trimmed_text.starts_with("**"))
68            || (trimmed_text.starts_with("_")
69                && trimmed_text.ends_with("_")
70                && trimmed_text.len() > 2
71                && !trimmed_text.starts_with("__"))
72        {
73            // Extract the text inside the emphasis markers
74            let inner_text = if trimmed_text.starts_with("**") || trimmed_text.starts_with("__") {
75                &trimmed_text[2..trimmed_text.len() - 2]
76            } else {
77                &trimmed_text[1..trimmed_text.len() - 1]
78            };
79
80            // Process this as emphasis
81            self.process_emphasis_text(inner_text, inline_node);
82        }
83    }
84
85    fn process_emphasis_text(&mut self, inner_text: &str, source_node: &Node) {
86        // Skip if text is empty
87        if inner_text.trim().is_empty() {
88            return;
89        }
90
91        // Check if text is single line (no newlines)
92        if inner_text.contains('\n') {
93            return;
94        }
95
96        // Check if text contains links - if so, allow it
97        if inner_text.contains("[") && inner_text.contains("](") {
98            return;
99        }
100
101        // Get punctuation configuration
102        let punctuation_chars = &self
103            .context
104            .config
105            .linters
106            .settings
107            .emphasis_as_heading
108            .punctuation;
109
110        // Check if text ends with punctuation
111        if let Some(last_char) = inner_text.trim().chars().last() {
112            if punctuation_chars.contains(last_char) {
113                return; // Allow if ends with punctuation
114            }
115        }
116
117        // Create violation
118        let range = tree_sitter::Range {
119            start_byte: 0, // Not used by range_from_tree_sitter
120            end_byte: 0,   // Not used by range_from_tree_sitter
121            start_point: tree_sitter::Point {
122                row: source_node.start_position().row,
123                column: source_node.start_position().column,
124            },
125            end_point: tree_sitter::Point {
126                row: source_node.end_position().row,
127                column: source_node.end_position().column,
128            },
129        };
130
131        self.violations.push(RuleViolation::new(
132            &MD036,
133            format!("Emphasis used instead of heading: '{}'", inner_text.trim()),
134            self.context.file_path.clone(),
135            range_from_tree_sitter(&range),
136        ));
137    }
138
139    fn process_emphasis_node(&mut self, emphasis_node: &Node) {
140        let text_content = self.extract_text_content(emphasis_node);
141        let trimmed_text = text_content.trim();
142
143        // Skip if text is empty
144        if trimmed_text.is_empty() {
145            return;
146        }
147
148        // Check if text is single line (no newlines)
149        if trimmed_text.contains('\n') {
150            return;
151        }
152
153        // Check if the emphasis contains only text (no links, etc.)
154        let mut has_only_text = true;
155        let mut inner_cursor = emphasis_node.walk();
156        if inner_cursor.goto_first_child() {
157            loop {
158                let inner_child = inner_cursor.node();
159                if inner_child.kind() != "text" && !inner_child.kind().is_empty() {
160                    has_only_text = false;
161                    break;
162                }
163                if !inner_cursor.goto_next_sibling() {
164                    break;
165                }
166            }
167        }
168
169        if !has_only_text {
170            return;
171        }
172
173        // Get punctuation configuration
174        let punctuation_chars = &self
175            .context
176            .config
177            .linters
178            .settings
179            .emphasis_as_heading
180            .punctuation;
181
182        // Check if text ends with punctuation
183        if let Some(last_char) = trimmed_text.chars().last() {
184            if punctuation_chars.contains(last_char) {
185                return; // Allow if ends with punctuation
186            }
187        }
188
189        // Create violation
190        let range = tree_sitter::Range {
191            start_byte: 0, // Not used by range_from_tree_sitter
192            end_byte: 0,   // Not used by range_from_tree_sitter
193            start_point: tree_sitter::Point {
194                row: emphasis_node.start_position().row,
195                column: emphasis_node.start_position().column,
196            },
197            end_point: tree_sitter::Point {
198                row: emphasis_node.end_position().row,
199                column: emphasis_node.end_position().column,
200            },
201        };
202
203        self.violations.push(RuleViolation::new(
204            &MD036,
205            format!("Emphasis used instead of heading: '{trimmed_text}'"),
206            self.context.file_path.clone(),
207            range_from_tree_sitter(&range),
208        ));
209    }
210
211    fn is_inside_list_item(&self, node: &Node) -> bool {
212        let mut current = node.parent();
213        while let Some(parent) = current {
214            match parent.kind() {
215                "list_item" => return true,
216                "document" => return false, // Reached document root
217                _ => current = parent.parent(),
218            }
219        }
220        false
221    }
222
223    fn check_paragraph_for_emphasis_heading(&mut self, paragraph_node: &Node) {
224        // Check if this paragraph is inside a list item - if so, skip it
225        if self.is_inside_list_item(paragraph_node) {
226            return;
227        }
228
229        // Check if paragraph contains only emphasis or strong emphasis
230        let mut meaningful_children = Vec::new();
231        let mut cursor = paragraph_node.walk();
232
233        // Get all children of the paragraph
234        if cursor.goto_first_child() {
235            loop {
236                let child = cursor.node();
237                if Self::is_meaningful_node(&child) {
238                    meaningful_children.push(child);
239                }
240                if !cursor.goto_next_sibling() {
241                    break;
242                }
243            }
244        }
245
246        // Check if paragraph has exactly one meaningful child that is an inline node
247        if meaningful_children.len() == 1 {
248            let child = meaningful_children[0];
249            match child.kind() {
250                "inline" => {
251                    // Look inside the inline node for emphasis or strong emphasis
252                    self.check_inline_for_emphasis_heading(&child);
253                }
254                "emphasis" | "strong_emphasis" => {
255                    // Direct emphasis node (shouldn't happen with markdown structure, but handle it)
256                    self.process_emphasis_node(&child);
257                }
258                _ => {
259                    // Not an emphasis node, skip
260                }
261            }
262        }
263    }
264}
265
266impl RuleLinter for MD036Linter {
267    fn feed(&mut self, node: &Node) {
268        match node.kind() {
269            "paragraph" => self.check_paragraph_for_emphasis_heading(node),
270            _ => {
271                // Ignore other nodes
272            }
273        }
274    }
275
276    fn finalize(&mut self) -> Vec<RuleViolation> {
277        std::mem::take(&mut self.violations)
278    }
279}
280
281pub const MD036: Rule = Rule {
282    id: "MD036",
283    alias: "no-emphasis-as-heading",
284    tags: &["headings", "emphasis"],
285    description: "Emphasis used instead of a heading",
286    rule_type: RuleType::Token,
287    required_nodes: &["paragraph"],
288    new_linter: |context| Box::new(MD036Linter::new(context)),
289};
290
291#[cfg(test)]
292mod test {
293    use std::path::PathBuf;
294
295    use crate::config::{LintersSettingsTable, MD036EmphasisAsHeadingTable, RuleSeverity};
296    use crate::linter::MultiRuleLinter;
297    use crate::test_utils::test_helpers::test_config_with_settings;
298
299    fn test_config(punctuation: &str) -> crate::config::QuickmarkConfig {
300        test_config_with_settings(
301            vec![("no-emphasis-as-heading", RuleSeverity::Error)],
302            LintersSettingsTable {
303                emphasis_as_heading: MD036EmphasisAsHeadingTable {
304                    punctuation: punctuation.to_string(),
305                },
306                ..Default::default()
307            },
308        )
309    }
310
311    fn test_default_config() -> crate::config::QuickmarkConfig {
312        test_config(".,;:!?。,;:!?")
313    }
314
315    #[test]
316    fn test_emphasis_as_heading_violation() {
317        let config = test_default_config();
318        let input = "**Section 1**\n\nSome content here.";
319
320        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
321        let violations = linter.analyze();
322        assert_eq!(violations.len(), 1);
323        assert!(violations[0].message().contains("Section 1"));
324    }
325
326    #[test]
327    fn test_italic_emphasis_as_heading_violation() {
328        let config = test_default_config();
329        let input = "*Section 1*\n\nSome content here.";
330
331        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
332        let violations = linter.analyze();
333        assert_eq!(violations.len(), 1);
334        assert!(violations[0].message().contains("Section 1"));
335    }
336
337    #[test]
338    fn test_valid_emphasis_in_paragraph() {
339        let config = test_default_config();
340        let input = "This is a normal paragraph with **some emphasis** in it.";
341
342        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
343        let violations = linter.analyze();
344        assert_eq!(violations.len(), 0);
345    }
346
347    #[test]
348    fn test_emphasis_with_punctuation_allowed() {
349        let config = test_default_config();
350        let input = "**This ends with punctuation.**\n\nSome content.";
351
352        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
353        let violations = linter.analyze();
354        assert_eq!(violations.len(), 0);
355    }
356
357    #[test]
358    fn test_multiline_emphasis_allowed() {
359        let config = test_default_config();
360        let input = "**This is an entire paragraph that has been emphasized\nand spans multiple lines**\n\nContent.";
361
362        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
363        let violations = linter.analyze();
364        assert_eq!(violations.len(), 0);
365    }
366
367    #[test]
368    fn test_custom_punctuation() {
369        let config = test_config(".,;:");
370        let input = "**This heading has exclamation!**\n\nContent.";
371
372        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
373        let violations = linter.analyze();
374        assert_eq!(violations.len(), 1); // '!' not in custom punctuation
375    }
376
377    #[test]
378    fn test_custom_punctuation_with_allowed() {
379        let config = test_config(".,;:");
380        let input = "**This heading has period.**\n\nContent.";
381
382        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
383        let violations = linter.analyze();
384        assert_eq!(violations.len(), 0);
385    }
386
387    #[test]
388    fn test_mixed_emphasis_and_normal_text() {
389        let config = test_default_config();
390        let input = "**Violation here**\n\nThis is a normal paragraph\n**that just happens to have emphasized text in**\neven though the emphasized text is on its own line.";
391
392        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
393        let violations = linter.analyze();
394        assert_eq!(violations.len(), 1); // Only the first one should be flagged
395    }
396
397    #[test]
398    fn test_emphasis_with_link() {
399        let config = test_default_config();
400        let input = "**[This is a link](https://example.com)**\n\nContent.";
401
402        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
403        let violations = linter.analyze();
404        assert_eq!(violations.len(), 0); // Links should be allowed
405    }
406
407    #[test]
408    fn test_full_width_punctuation() {
409        let config = test_default_config();
410        let input = "**Section with full-width punctuation。**\n\nContent.";
411
412        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
413        let violations = linter.analyze();
414        assert_eq!(violations.len(), 0);
415    }
416}