Skip to main content

mdlint/lint/rules/
md050.rs

1use 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)] // rule logic requires tracking multiple style variants per event
22    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        // Get byte ranges that are in code (more precise than line numbers)
32        let code_ranges = parser.get_code_ranges();
33
34        // Helper function to check if a position is within code
35        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            // Look for strong patterns: **text** or __text__
46            let chars: Vec<char> = line.chars().collect();
47            let mut i = 0;
48
49            while i + 1 < chars.len() {
50                // Check for ** or __
51                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                        // Find closing marker
60                        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                                    // Skip if this emphasis is inside code
70                                    if is_in_code(line_number, i) {
71                                        i = j; // Skip to after closing
72                                        break;
73                                    }
74
75                                    found_close = true;
76
77                                    // Track style
78                                    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                                                // Report violation for both opening and closing markers
99                                                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                                            // Report violation for both opening and closing markers
126                                            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; // Skip to after closing
148                                    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        // Reports violation for both opening and closing markers of the second strong emphasis
205        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        // Reports violation for both opening and closing markers
217        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        // Should not flag underscores in code as strong markers
230        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        // Should not flag underscores inside inline code as strong markers
241        assert_eq!(violations.len(), 0);
242    }
243}