mdbook_lint_core/rules/standard/
md050.rs

1//! MD050: Strong style consistency
2//!
3//! This rule checks that strong emphasis markers (bold text) are used consistently throughout the document.
4
5use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11
12/// Rule to check strong emphasis style consistency
13pub struct MD050 {
14    /// Preferred strong emphasis style
15    style: StrongStyle,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum StrongStyle {
20    /// Use double asterisk (**text**)
21    Asterisk,
22    /// Use double underscore (__text__)
23    Underscore,
24    /// Detect from first usage in document
25    Consistent,
26}
27
28impl MD050 {
29    /// Create a new MD050 rule with consistent style detection
30    pub fn new() -> Self {
31        Self {
32            style: StrongStyle::Consistent,
33        }
34    }
35
36    /// Create a new MD050 rule with specific style preference
37    #[allow(dead_code)]
38    pub fn with_style(style: StrongStyle) -> Self {
39        Self { style }
40    }
41
42    /// Find strong emphasis markers in a line and check for style violations
43    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        // Find strong emphasis markers - look for double ** or __
53        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                // Look for closing marker pair
64                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                    // Establish or check style consistency
72                    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, // Convert to 1-based column
86                                Severity::Warning,
87                            ));
88                        }
89                    } else {
90                        // First strong emphasis found - establish the style
91                        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    /// Find the closing strong emphasis marker pair
107    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    /// Get code block ranges to exclude from checking
126    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            // Check for fenced code blocks
134            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, // Detect from first usage
186        };
187
188        for (line_number, line) in lines.iter().enumerate() {
189            let line_number = line_number + 1;
190
191            // Skip lines inside code blocks
192            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            // Update expected style if we detected one
201            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); // All strong uses __, should be consistent
309    }
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        // Code spans are not excluded by this rule (they're handled at line level)
358        // but the strong emphasis should still be consistent
359        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); // Line 3 and line 7 violations
390        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        // Only the properly closed strong should be checked
405        assert_eq!(violations.len(), 0); // __closed strong__ is the only valid strong, so no violation
406    }
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}