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#[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 let inline_text = self.extract_text_content(inline_node);
55 let trimmed_text = inline_text.trim();
56
57 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 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 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 if inner_text.trim().is_empty() {
88 return;
89 }
90
91 if inner_text.contains('\n') {
93 return;
94 }
95
96 if inner_text.contains("[") && inner_text.contains("](") {
98 return;
99 }
100
101 let punctuation_chars = &self
103 .context
104 .config
105 .linters
106 .settings
107 .emphasis_as_heading
108 .punctuation;
109
110 if let Some(last_char) = inner_text.trim().chars().last() {
112 if punctuation_chars.contains(last_char) {
113 return; }
115 }
116
117 let range = tree_sitter::Range {
119 start_byte: 0, end_byte: 0, 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 if trimmed_text.is_empty() {
145 return;
146 }
147
148 if trimmed_text.contains('\n') {
150 return;
151 }
152
153 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 let punctuation_chars = &self
175 .context
176 .config
177 .linters
178 .settings
179 .emphasis_as_heading
180 .punctuation;
181
182 if let Some(last_char) = trimmed_text.chars().last() {
184 if punctuation_chars.contains(last_char) {
185 return; }
187 }
188
189 let range = tree_sitter::Range {
191 start_byte: 0, end_byte: 0, 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, _ => current = parent.parent(),
218 }
219 }
220 false
221 }
222
223 fn check_paragraph_for_emphasis_heading(&mut self, paragraph_node: &Node) {
224 if self.is_inside_list_item(paragraph_node) {
226 return;
227 }
228
229 let mut meaningful_children = Vec::new();
231 let mut cursor = paragraph_node.walk();
232
233 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 if meaningful_children.len() == 1 {
248 let child = meaningful_children[0];
249 match child.kind() {
250 "inline" => {
251 self.check_inline_for_emphasis_heading(&child);
253 }
254 "emphasis" | "strong_emphasis" => {
255 self.process_emphasis_node(&child);
257 }
258 _ => {
259 }
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 }
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); }
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); }
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); }
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}