mdlint/lint/rules/
md050.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD050;
7
8impl Rule for MD050 {
9 fn name(&self) -> &'static str {
10 "MD050"
11 }
12
13 fn description(&self) -> &'static str {
14 "Strong style should be consistent"
15 }
16
17 fn tags(&self) -> &[&str] {
18 &["emphasis"]
19 }
20
21 #[allow(clippy::too_many_lines)] fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23 let style = config
24 .and_then(|c| c.get("style"))
25 .and_then(|v| v.as_str())
26 .unwrap_or("asterisk");
27
28 let mut violations = Vec::new();
29 let mut first_style: Option<&str> = None;
30
31 let code_ranges = parser.get_code_ranges();
33
34 let is_in_code = |line_num: usize, byte_offset: usize| -> bool {
36 let absolute_offset = parser.line_offset_to_absolute(line_num, byte_offset);
37 code_ranges
38 .iter()
39 .any(|range| range.contains(&absolute_offset))
40 };
41
42 for (line_num, line) in parser.lines().iter().enumerate() {
43 let line_number = line_num + 1;
44
45 let chars: Vec<char> = line.chars().collect();
47 let mut i = 0;
48
49 while i + 1 < chars.len() {
50 if i + 1 < chars.len() {
52 let two_char = format!(
53 "{}{}",
54 chars.get(i).expect("i + 1 < chars.len()"),
55 chars.get(i + 1).expect("i + 1 < chars.len()")
56 );
57
58 if two_char == "**" || two_char == "__" {
59 let mut found_close = false;
61 for j in (i + 2)..chars.len().saturating_sub(1) {
62 if j + 1 < chars.len() {
63 let close_two = format!(
64 "{}{}",
65 chars.get(j).expect("j + 1 < chars.len()"),
66 chars.get(j + 1).expect("j + 1 < chars.len()")
67 );
68 if close_two == two_char {
69 if is_in_code(line_number, i) {
71 i = j; break;
73 }
74
75 found_close = true;
76
77 let current_style = if two_char == "**" {
79 "asterisk"
80 } else {
81 "underscore"
82 };
83
84 let make_fix = |col: usize, target: &str| Fix {
85 line_start: line_number,
86 line_end: line_number,
87 column_start: Some(col),
88 column_end: Some(col + 1),
89 replacement: target.to_owned(),
90 description: "Replace strong marker".to_owned(),
91 };
92
93 if style == "consistent" {
94 if let Some(first) = first_style {
95 if current_style != first {
96 let expected_marker =
97 if first == "asterisk" { "**" } else { "__" };
98 violations.push(Violation {
100 line: line_number,
101 column: Some(i + 1),
102 rule: self.name().to_owned(),
103 message: format!(
104 "Strong style should be consistent: expected '{expected_marker}', found '{two_char}'"
105 ),
106 fix: Some(make_fix(i + 1, expected_marker)),
107 });
108 violations.push(Violation {
109 line: line_number,
110 column: Some(j + 1),
111 rule: self.name().to_owned(),
112 message: format!(
113 "Strong style should be consistent: expected '{expected_marker}', found '{close_two}'"
114 ),
115 fix: Some(make_fix(j + 1, expected_marker)),
116 });
117 }
118 } else {
119 first_style = Some(current_style);
120 }
121 } else {
122 let expected_marker =
123 if style == "asterisk" { "**" } else { "__" };
124 if two_char != expected_marker {
125 violations.push(Violation {
127 line: line_number,
128 column: Some(i + 1),
129 rule: self.name().to_owned(),
130 message: format!(
131 "Strong style should be '{expected_marker}', found '{two_char}'"
132 ),
133 fix: Some(make_fix(i + 1, expected_marker)),
134 });
135 violations.push(Violation {
136 line: line_number,
137 column: Some(j + 1),
138 rule: self.name().to_owned(),
139 message: format!(
140 "Strong style should be '{expected_marker}', found '{close_two}'"
141 ),
142 fix: Some(make_fix(j + 1, expected_marker)),
143 });
144 }
145 }
146
147 i = j + 1; break;
149 }
150 }
151 }
152
153 if found_close {
154 i += 1;
155 continue;
156 }
157 }
158 }
159
160 i += 1;
161 }
162 }
163
164 violations
165 }
166
167 fn fixable(&self) -> bool {
168 true
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn test_consistent_asterisk() {
178 let content = "This is **bold** and **more bold**.";
179 let parser = MarkdownParser::new(content);
180 let rule = MD050;
181 let violations = rule.check(&parser, None);
182
183 assert_eq!(violations.len(), 0);
184 }
185
186 #[test]
187 fn test_consistent_underscore() {
188 let content = "This is __bold__ and __more bold__.";
189 let parser = MarkdownParser::new(content);
190 let rule = MD050;
191 let config = serde_json::json!({ "style": "consistent" });
192 let violations = rule.check(&parser, Some(&config));
193
194 assert_eq!(violations.len(), 0);
195 }
196
197 #[test]
198 fn test_inconsistent() {
199 let content = "This is **bold** and __also bold__.";
200 let parser = MarkdownParser::new(content);
201 let rule = MD050;
202 let violations = rule.check(&parser, None);
203
204 assert_eq!(violations.len(), 2);
206 }
207
208 #[test]
209 fn test_enforced_style() {
210 let content = "This is __bold__ text.";
211 let parser = MarkdownParser::new(content);
212 let rule = MD050;
213 let config = serde_json::json!({ "style": "asterisk" });
214 let violations = rule.check(&parser, Some(&config));
215
216 assert_eq!(violations.len(), 2);
218 }
219
220 #[test]
221 fn test_code_block_with_underscores() {
222 let content = "Some **bold** text.\n\n\
223 ```txt\n__tests__\n```\n\n\
224 More **bold** text.";
225 let parser = MarkdownParser::new(content);
226 let rule = MD050;
227 let violations = rule.check(&parser, None);
228
229 assert_eq!(violations.len(), 0);
231 }
232
233 #[test]
234 fn test_inline_code_with_underscores() {
235 let content = "Some `__code__`, **bold** text and `__code__`.";
236 let parser = MarkdownParser::new(content);
237 let rule = MD050;
238 let violations = rule.check(&parser, None);
239
240 assert_eq!(violations.len(), 0);
242 }
243}