quickmark_core/rules/
md050.rs

1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7    linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation},
8    rules::{Rule, RuleType},
9};
10
11// MD050-specific configuration types
12#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub enum StrongStyle {
14    #[serde(rename = "consistent")]
15    Consistent,
16    #[serde(rename = "asterisk")]
17    Asterisk,
18    #[serde(rename = "underscore")]
19    Underscore,
20}
21
22impl Default for StrongStyle {
23    fn default() -> Self {
24        Self::Consistent
25    }
26}
27
28#[derive(Debug, PartialEq, Clone, Deserialize)]
29pub struct MD050StrongStyleTable {
30    #[serde(default)]
31    pub style: StrongStyle,
32}
33
34impl Default for MD050StrongStyleTable {
35    fn default() -> Self {
36        Self {
37            style: StrongStyle::Consistent,
38        }
39    }
40}
41
42#[derive(Debug, PartialEq, Clone)]
43enum StrongMarkerType {
44    Asterisk,
45    Underscore,
46}
47
48pub(crate) struct MD050Linter {
49    context: Rc<Context>,
50    violations: Vec<RuleViolation>,
51    first_strong_marker: Option<StrongMarkerType>,
52    line_start_bytes: Vec<usize>,
53}
54
55impl MD050Linter {
56    pub fn new(context: Rc<Context>) -> Self {
57        let line_start_bytes = {
58            let content = context.get_document_content();
59            std::iter::once(0)
60                .chain(content.match_indices('\n').map(|(i, _)| i + 1))
61                .collect()
62        };
63
64        Self {
65            context,
66            violations: Vec::new(),
67            first_strong_marker: None,
68            line_start_bytes,
69        }
70    }
71
72    fn is_in_code_context(&self, node: &Node) -> bool {
73        // Check if this node is inside a code span or code block
74        let mut current = Some(*node);
75        while let Some(node_to_check) = current {
76            if matches!(
77                node_to_check.kind(),
78                "code_span" | "fenced_code_block" | "indented_code_block"
79            ) {
80                return true;
81            }
82            current = node_to_check.parent();
83        }
84        false
85    }
86
87    fn find_strong_violations_in_text(&mut self, node: &Node) {
88        if self.is_in_code_context(node) {
89            return;
90        }
91
92        let node_start_byte = node.start_byte();
93        let text = {
94            let content = self.context.get_document_content();
95            node.utf8_text(content.as_bytes()).unwrap_or("").to_string()
96        };
97
98        if !text.is_empty() {
99            self.find_strong_patterns(&text, node_start_byte);
100        }
101    }
102
103    fn find_strong_patterns(&mut self, text: &str, text_start_byte: usize) {
104        let config = &self.context.config.linters.settings.strong_style;
105
106        // Look for all strong emphasis markers - both opening and closing
107        let mut i = 0;
108        let chars: Vec<char> = text.chars().collect();
109
110        while i < chars.len() {
111            if i + 1 < chars.len() {
112                let current_char = chars[i];
113                let next_char = chars[i + 1];
114
115                // Check for strong emphasis markers (both ** and __)
116                if (current_char == '*' && next_char == '*')
117                    || (current_char == '_' && next_char == '_')
118                {
119                    // Skip if this is part of a longer sequence that would make it invalid
120                    // e.g., ____ should not be detected as __ + __
121                    if i + 2 < chars.len() && chars[i + 2] == current_char {
122                        // This is at least a triple marker, could be *** or ___
123                        if i + 3 < chars.len() && chars[i + 3] == current_char {
124                            // This is a quadruple marker like ____ or ****
125                            // Skip the entire sequence
126                            let mut skip_count = 4;
127                            while i + skip_count < chars.len()
128                                && chars[i + skip_count] == current_char
129                            {
130                                skip_count += 1;
131                            }
132                            i += skip_count;
133                            continue;
134                        }
135                        // Triple marker (*** or ___) - handle as strong emphasis
136                    }
137
138                    let marker_type = if current_char == '*' {
139                        StrongMarkerType::Asterisk
140                    } else {
141                        StrongMarkerType::Underscore
142                    };
143
144                    // Check if we should report a violation for this marker
145                    let should_report_violation = match config.style {
146                        StrongStyle::Consistent => {
147                            if self.first_strong_marker.is_none() {
148                                self.first_strong_marker = Some(marker_type.clone());
149                                false
150                            } else {
151                                self.first_strong_marker.as_ref() != Some(&marker_type)
152                            }
153                        }
154                        StrongStyle::Asterisk => marker_type != StrongMarkerType::Asterisk,
155                        StrongStyle::Underscore => marker_type != StrongMarkerType::Underscore,
156                    };
157
158                    if should_report_violation {
159                        let expected_style = match config.style {
160                            StrongStyle::Asterisk => "asterisk",
161                            StrongStyle::Underscore => "underscore",
162                            StrongStyle::Consistent => {
163                                match self.first_strong_marker.as_ref().unwrap() {
164                                    StrongMarkerType::Asterisk => "asterisk",
165                                    StrongMarkerType::Underscore => "underscore",
166                                }
167                            }
168                        };
169
170                        let actual_style = match marker_type {
171                            StrongMarkerType::Asterisk => "asterisk",
172                            StrongMarkerType::Underscore => "underscore",
173                        };
174
175                        // Calculate byte position - markdownlint reports position of the second character for double markers,
176                        // and the third character for opening triple markers only
177                        let is_opening_triple_marker = i + 2 < chars.len()
178                            && chars[i + 2] == current_char
179                            && (i == 0 || (i > 0 && chars[i - 1] != current_char));
180                        let position_offset = if is_opening_triple_marker { 2 } else { 1 };
181                        let char_start_byte = text_start_byte
182                            + text
183                                .chars()
184                                .take(i + position_offset)
185                                .map(|c| c.len_utf8())
186                                .sum::<usize>()
187                            - 1;
188                        let char_end_byte = char_start_byte + current_char.len_utf8();
189
190                        let range = tree_sitter::Range {
191                            start_byte: char_start_byte,
192                            end_byte: char_end_byte,
193                            start_point: self.byte_to_point(char_start_byte),
194                            end_point: self.byte_to_point(char_end_byte),
195                        };
196
197                        self.violations.push(RuleViolation::new(
198                            &MD050,
199                            format!("Expected: {expected_style}; Actual: {actual_style}"),
200                            self.context.file_path.clone(),
201                            range_from_tree_sitter(&range),
202                        ));
203                    }
204
205                    // Move past this marker pair
206                    i += 2;
207                } else {
208                    i += 1;
209                }
210            } else {
211                i += 1;
212            }
213        }
214    }
215
216    fn byte_to_point(&self, byte_pos: usize) -> tree_sitter::Point {
217        let line = self.line_start_bytes.partition_point(|&x| x <= byte_pos) - 1;
218        let column = byte_pos - self.line_start_bytes[line];
219        tree_sitter::Point { row: line, column }
220    }
221}
222
223impl RuleLinter for MD050Linter {
224    fn feed(&mut self, node: &Node) {
225        if matches!(node.kind(), "text" | "inline") {
226            self.find_strong_violations_in_text(node);
227        }
228    }
229
230    fn finalize(&mut self) -> Vec<RuleViolation> {
231        std::mem::take(&mut self.violations)
232    }
233}
234
235pub const MD050: Rule = Rule {
236    id: "MD050",
237    alias: "strong-style",
238    tags: &["emphasis"],
239    description: "Strong style should be consistent",
240    rule_type: RuleType::Token,
241    required_nodes: &["strong_emphasis"],
242    new_linter: |context| Box::new(MD050Linter::new(context)),
243};
244
245#[cfg(test)]
246mod test {
247    use std::path::PathBuf;
248
249    use crate::config::{RuleSeverity, StrongStyle};
250    use crate::linter::MultiRuleLinter;
251    use crate::test_utils::test_helpers::test_config_with_rules;
252
253    fn test_config() -> crate::config::QuickmarkConfig {
254        test_config_with_rules(vec![("strong-style", RuleSeverity::Error)])
255    }
256
257    fn test_config_with_style(style: StrongStyle) -> crate::config::QuickmarkConfig {
258        let mut config = test_config();
259        config.linters.settings.strong_style.style = style;
260        config
261    }
262
263    #[test]
264    fn test_no_violations_consistent_asterisk() {
265        let config = test_config_with_style(StrongStyle::Consistent);
266        let input = "This has **strong text** and **another strong**.";
267
268        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
269        let violations = linter.analyze();
270        let md050_violations: Vec<_> = violations
271            .iter()
272            .filter(|v| v.rule().id == "MD050")
273            .collect();
274        assert_eq!(md050_violations.len(), 0);
275    }
276
277    #[test]
278    fn test_no_violations_consistent_underscore() {
279        let config = test_config_with_style(StrongStyle::Consistent);
280        let input = "This has __strong text__ and __another strong__.";
281
282        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
283        let violations = linter.analyze();
284        let md050_violations: Vec<_> = violations
285            .iter()
286            .filter(|v| v.rule().id == "MD050")
287            .collect();
288        assert_eq!(md050_violations.len(), 0);
289    }
290
291    #[test]
292    fn test_violations_inconsistent_mixed() {
293        let config = test_config_with_style(StrongStyle::Consistent);
294        let input = "This has **strong text** and __inconsistent strong__.";
295
296        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
297        let violations = linter.analyze();
298        let md050_violations: Vec<_> = violations
299            .iter()
300            .filter(|v| v.rule().id == "MD050")
301            .collect();
302
303        // Should find 2 violations for the inconsistent underscore strong (opening and closing)
304        assert_eq!(md050_violations.len(), 2);
305    }
306
307    #[test]
308    fn test_no_violations_asterisk_style() {
309        let config = test_config_with_style(StrongStyle::Asterisk);
310        let input = "This has **strong text** and **another strong**.";
311
312        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
313        let violations = linter.analyze();
314        let md050_violations: Vec<_> = violations
315            .iter()
316            .filter(|v| v.rule().id == "MD050")
317            .collect();
318        assert_eq!(md050_violations.len(), 0);
319    }
320
321    #[test]
322    fn test_violations_asterisk_style_with_underscore() {
323        let config = test_config_with_style(StrongStyle::Asterisk);
324        let input = "This has **strong text** and __invalid strong__.";
325
326        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
327        let violations = linter.analyze();
328        let md050_violations: Vec<_> = violations
329            .iter()
330            .filter(|v| v.rule().id == "MD050")
331            .collect();
332
333        // Should find 2 violations for the underscore strong when asterisk is required (opening and closing)
334        assert_eq!(md050_violations.len(), 2);
335    }
336
337    #[test]
338    fn test_no_violations_underscore_style() {
339        let config = test_config_with_style(StrongStyle::Underscore);
340        let input = "This has __strong text__ and __another strong__.";
341
342        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
343        let violations = linter.analyze();
344        let md050_violations: Vec<_> = violations
345            .iter()
346            .filter(|v| v.rule().id == "MD050")
347            .collect();
348        assert_eq!(md050_violations.len(), 0);
349    }
350
351    #[test]
352    fn test_violations_underscore_style_with_asterisk() {
353        let config = test_config_with_style(StrongStyle::Underscore);
354        let input = "This has __strong text__ and **invalid strong**.";
355
356        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
357        let violations = linter.analyze();
358        let md050_violations: Vec<_> = violations
359            .iter()
360            .filter(|v| v.rule().id == "MD050")
361            .collect();
362
363        // Should find 2 violations for the asterisk strong when underscore is required (opening and closing)
364        assert_eq!(md050_violations.len(), 2);
365    }
366
367    #[test]
368    fn test_mixed_emphasis_and_strong() {
369        let config = test_config_with_style(StrongStyle::Consistent);
370        let input = "This has *emphasis* and **strong** and __inconsistent strong__.";
371
372        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
373        let violations = linter.analyze();
374        let md050_violations: Vec<_> = violations
375            .iter()
376            .filter(|v| v.rule().id == "MD050")
377            .collect();
378
379        // Should find 2 violations for the inconsistent strong (opening and closing, emphasis should not be considered)
380        assert_eq!(md050_violations.len(), 2);
381    }
382
383    #[test]
384    fn test_strong_emphasis_combination() {
385        let config = test_config_with_style(StrongStyle::Consistent);
386        let input = "This has ***strong emphasis*** and ***another***.";
387
388        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
389        let violations = linter.analyze();
390        let md050_violations: Vec<_> = violations
391            .iter()
392            .filter(|v| v.rule().id == "MD050")
393            .collect();
394
395        // Should find no violations as both use asterisk consistently
396        assert_eq!(md050_violations.len(), 0);
397    }
398
399    #[test]
400    fn test_strong_emphasis_inconsistent() {
401        let config = test_config_with_style(StrongStyle::Consistent);
402        let input = "This has ***strong emphasis*** and ___inconsistent___. ";
403
404        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
405        let violations = linter.analyze();
406        let md050_violations: Vec<_> = violations
407            .iter()
408            .filter(|v| v.rule().id == "MD050")
409            .collect();
410
411        // Should find 2 violations for the inconsistent strong emphasis (opening and closing)
412        assert_eq!(md050_violations.len(), 2);
413    }
414}