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