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#[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 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 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 if (current_char == '*' && next_char == '*')
117 || (current_char == '_' && next_char == '_')
118 {
119 if i + 2 < chars.len() && chars[i + 2] == current_char {
122 if i + 3 < chars.len() && chars[i + 3] == current_char {
124 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 }
137
138 let marker_type = if current_char == '*' {
139 StrongMarkerType::Asterisk
140 } else {
141 StrongMarkerType::Underscore
142 };
143
144 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 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 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 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 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 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 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 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 assert_eq!(md050_violations.len(), 2);
413 }
414}