mdbook_lint_core/rules/standard/
md050.rs1use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11
12pub struct MD050 {
14 style: StrongStyle,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum StrongStyle {
20 Asterisk,
22 Underscore,
24 Consistent,
26}
27
28impl MD050 {
29 pub fn new() -> Self {
31 Self {
32 style: StrongStyle::Consistent,
33 }
34 }
35
36 #[allow(dead_code)]
38 pub fn with_style(style: StrongStyle) -> Self {
39 Self { style }
40 }
41
42 fn check_line_strong(
44 &self,
45 line: &str,
46 line_number: usize,
47 expected_style: Option<StrongStyle>,
48 ) -> (Vec<Violation>, Option<StrongStyle>) {
49 let mut violations = Vec::new();
50 let mut detected_style = expected_style;
51
52 let chars: Vec<char> = line.chars().collect();
54 let mut i = 0;
55
56 while i < chars.len() {
57 if (chars[i] == '*' || chars[i] == '_')
58 && i + 1 < chars.len()
59 && chars[i + 1] == chars[i]
60 {
61 let marker = chars[i];
62
63 if let Some(end_pos) = self.find_closing_strong_marker(&chars, i + 2, marker) {
65 let current_style = if marker == '*' {
66 StrongStyle::Asterisk
67 } else {
68 StrongStyle::Underscore
69 };
70
71 if let Some(ref expected) = detected_style {
73 if *expected != current_style {
74 let expected_marker = if *expected == StrongStyle::Asterisk {
75 "**"
76 } else {
77 "__"
78 };
79 let found_marker = if marker == '*' { "**" } else { "__" };
80 violations.push(self.create_violation(
81 format!(
82 "Strong emphasis style inconsistent - expected '{expected_marker}' but found '{found_marker}'"
83 ),
84 line_number,
85 i + 1, Severity::Warning,
87 ));
88 }
89 } else {
90 detected_style = Some(current_style);
92 }
93
94 i = end_pos + 2;
95 } else {
96 i += 2;
97 }
98 } else {
99 i += 1;
100 }
101 }
102
103 (violations, detected_style)
104 }
105
106 fn find_closing_strong_marker(
108 &self,
109 chars: &[char],
110 start: usize,
111 marker: char,
112 ) -> Option<usize> {
113 let mut i = start;
114
115 while i + 1 < chars.len() {
116 if chars[i] == marker && chars[i + 1] == marker {
117 return Some(i);
118 }
119 i += 1;
120 }
121
122 None
123 }
124
125 fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
127 let mut in_code_block = vec![false; lines.len()];
128 let mut in_fenced_block = false;
129
130 for (i, line) in lines.iter().enumerate() {
131 let trimmed = line.trim();
132
133 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
135 in_fenced_block = !in_fenced_block;
136 in_code_block[i] = true;
137 continue;
138 }
139
140 if in_fenced_block {
141 in_code_block[i] = true;
142 continue;
143 }
144 }
145
146 in_code_block
147 }
148}
149
150impl Default for MD050 {
151 fn default() -> Self {
152 Self::new()
153 }
154}
155
156impl Rule for MD050 {
157 fn id(&self) -> &'static str {
158 "MD050"
159 }
160
161 fn name(&self) -> &'static str {
162 "strong-style"
163 }
164
165 fn description(&self) -> &'static str {
166 "Strong emphasis style should be consistent"
167 }
168
169 fn metadata(&self) -> RuleMetadata {
170 RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
171 }
172
173 fn check_with_ast<'a>(
174 &self,
175 document: &Document,
176 _ast: Option<&'a comrak::nodes::AstNode<'a>>,
177 ) -> Result<Vec<Violation>> {
178 let mut violations = Vec::new();
179 let lines: Vec<&str> = document.content.lines().collect();
180 let in_code_block = self.get_code_block_ranges(&lines);
181
182 let mut expected_style = match self.style {
183 StrongStyle::Asterisk => Some(StrongStyle::Asterisk),
184 StrongStyle::Underscore => Some(StrongStyle::Underscore),
185 StrongStyle::Consistent => None, };
187
188 for (line_number, line) in lines.iter().enumerate() {
189 let line_number = line_number + 1;
190
191 if in_code_block[line_number - 1] {
193 continue;
194 }
195
196 let (line_violations, detected_style) =
197 self.check_line_strong(line, line_number, expected_style);
198 violations.extend(line_violations);
199
200 if expected_style.is_none() && detected_style.is_some() {
202 expected_style = detected_style;
203 }
204 }
205
206 Ok(violations)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::rule::Rule;
214 use std::path::PathBuf;
215
216 fn create_test_document(content: &str) -> Document {
217 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
218 }
219
220 #[test]
221 fn test_md050_consistent_asterisk_style() {
222 let content = r#"This has **strong** and more **bold text** here.
223
224Another paragraph with **more strong** text.
225"#;
226
227 let document = create_test_document(content);
228 let rule = MD050::new();
229 let violations = rule.check(&document).unwrap();
230 assert_eq!(violations.len(), 0);
231 }
232
233 #[test]
234 fn test_md050_consistent_underscore_style() {
235 let content = r#"This has __strong__ and more __bold text__ here.
236
237Another paragraph with __more strong__ text.
238"#;
239
240 let document = create_test_document(content);
241 let rule = MD050::new();
242 let violations = rule.check(&document).unwrap();
243 assert_eq!(violations.len(), 0);
244 }
245
246 #[test]
247 fn test_md050_mixed_styles_violation() {
248 let content = r#"This has **strong** and more __bold text__ here.
249
250Another paragraph with **more strong** text.
251"#;
252
253 let document = create_test_document(content);
254 let rule = MD050::new();
255 let violations = rule.check(&document).unwrap();
256 assert_eq!(violations.len(), 1);
257 assert_eq!(violations[0].rule_id, "MD050");
258 assert_eq!(violations[0].line, 1);
259 assert!(
260 violations[0]
261 .message
262 .contains("expected '**' but found '__'")
263 );
264 }
265
266 #[test]
267 fn test_md050_preferred_asterisk_style() {
268 let content = r#"This has __strong__ text.
269"#;
270
271 let document = create_test_document(content);
272 let rule = MD050::with_style(StrongStyle::Asterisk);
273 let violations = rule.check(&document).unwrap();
274 assert_eq!(violations.len(), 1);
275 assert!(
276 violations[0]
277 .message
278 .contains("expected '**' but found '__'")
279 );
280 }
281
282 #[test]
283 fn test_md050_preferred_underscore_style() {
284 let content = r#"This has **strong** text.
285"#;
286
287 let document = create_test_document(content);
288 let rule = MD050::with_style(StrongStyle::Underscore);
289 let violations = rule.check(&document).unwrap();
290 assert_eq!(violations.len(), 1);
291 assert!(
292 violations[0]
293 .message
294 .contains("expected '__' but found '**'")
295 );
296 }
297
298 #[test]
299 fn test_md050_emphasis_ignored() {
300 let content = r#"This has *italic text* and __strong text__.
301
302More *italic* and __strong__ here.
303"#;
304
305 let document = create_test_document(content);
306 let rule = MD050::new();
307 let violations = rule.check(&document).unwrap();
308 assert_eq!(violations.len(), 0); }
310
311 #[test]
312 fn test_md050_mixed_emphasis_and_strong() {
313 let content = r#"This has *italic* and **strong** and __also strong__.
314
315More text here.
316"#;
317
318 let document = create_test_document(content);
319 let rule = MD050::new();
320 let violations = rule.check(&document).unwrap();
321 assert_eq!(violations.len(), 1);
322 assert!(
323 violations[0]
324 .message
325 .contains("expected '**' but found '__'")
326 );
327 }
328
329 #[test]
330 fn test_md050_code_blocks_ignored() {
331 let content = r#"This has **strong** text.
332
333```
334Code with **asterisks** and __underscores__ should be ignored.
335```
336
337This has __different style__ which should trigger violation.
338"#;
339
340 let document = create_test_document(content);
341 let rule = MD050::new();
342 let violations = rule.check(&document).unwrap();
343 assert_eq!(violations.len(), 1);
344 assert_eq!(violations[0].line, 7);
345 }
346
347 #[test]
348 fn test_md050_inline_code_spans() {
349 let content = r#"This has **strong** and `code with **asterisks**` text.
350
351More **strong** text here.
352"#;
353
354 let document = create_test_document(content);
355 let rule = MD050::new();
356 let violations = rule.check(&document).unwrap();
357 assert_eq!(violations.len(), 0);
360 }
361
362 #[test]
363 fn test_md050_no_strong() {
364 let content = r#"This document has no strong emphasis at all.
365
366Just regular text with *italic* formatting.
367"#;
368
369 let document = create_test_document(content);
370 let rule = MD050::new();
371 let violations = rule.check(&document).unwrap();
372 assert_eq!(violations.len(), 0);
373 }
374
375 #[test]
376 fn test_md050_multiple_violations() {
377 let content = r#"Start with **strong** text.
378
379Then switch to __different style__.
380
381Back to **original style**.
382
383And __different again__.
384"#;
385
386 let document = create_test_document(content);
387 let rule = MD050::new();
388 let violations = rule.check(&document).unwrap();
389 assert_eq!(violations.len(), 2); assert_eq!(violations[0].line, 3);
391 assert_eq!(violations[1].line, 7);
392 }
393
394 #[test]
395 fn test_md050_unclosed_strong() {
396 let content = r#"This has **unclosed strong and __closed strong__.
397
398More text here.
399"#;
400
401 let document = create_test_document(content);
402 let rule = MD050::new();
403 let violations = rule.check(&document).unwrap();
404 assert_eq!(violations.len(), 0); }
407
408 #[test]
409 fn test_md050_nested_formatting() {
410 let content = r#"This has **strong with *nested italic* text**.
411
412More __strong__ text.
413"#;
414
415 let document = create_test_document(content);
416 let rule = MD050::new();
417 let violations = rule.check(&document).unwrap();
418 assert_eq!(violations.len(), 1);
419 assert!(
420 violations[0]
421 .message
422 .contains("expected '**' but found '__'")
423 );
424 }
425}