1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rules::emphasis_style::EmphasisStyle;
3use crate::utils::emphasis_utils::{find_emphasis_markers, find_single_emphasis_spans, replace_inline_code};
4use lazy_static::lazy_static;
5use regex::Regex;
6
7lazy_static! {
8 static ref REF_DEF_REGEX: Regex = Regex::new(
10 r#"(?m)^[ ]{0,3}\[([^\]]+)\]:\s*([^\s]+)(?:\s+(?:"([^"]*)"|'([^']*)'))?$"#
11 ).unwrap();
12}
13
14mod md049_config;
15use md049_config::MD049Config;
16
17#[derive(Debug, Default, Clone)]
27pub struct MD049EmphasisStyle {
28 config: MD049Config,
29}
30
31impl MD049EmphasisStyle {
32 pub fn new(style: EmphasisStyle) -> Self {
34 MD049EmphasisStyle {
35 config: MD049Config { style },
36 }
37 }
38
39 pub fn from_config_struct(config: MD049Config) -> Self {
40 Self { config }
41 }
42
43 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
45 for link in &ctx.links {
47 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
48 return true;
49 }
50 }
51
52 for image in &ctx.images {
54 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
55 return true;
56 }
57 }
58
59 for m in REF_DEF_REGEX.find_iter(ctx.content) {
61 if m.start() <= byte_pos && byte_pos < m.end() {
62 return true;
63 }
64 }
65
66 false
67 }
68
69 fn collect_emphasis_from_line(
71 &self,
72 line: &str,
73 line_num: usize,
74 line_start_pos: usize,
75 emphasis_info: &mut Vec<(usize, usize, usize, char, String)>, ) {
77 let line_no_code = replace_inline_code(line);
79
80 let markers = find_emphasis_markers(&line_no_code);
82 if markers.is_empty() {
83 return;
84 }
85
86 let spans = find_single_emphasis_spans(&line_no_code, markers);
88
89 for span in spans {
90 let marker_char = span.opening.as_char();
91 let col = span.opening.start_pos + 1; let abs_pos = line_start_pos + span.opening.start_pos;
93
94 emphasis_info.push((line_num, col, abs_pos, marker_char, span.content.clone()));
95 }
96 }
97}
98
99impl Rule for MD049EmphasisStyle {
100 fn name(&self) -> &'static str {
101 "MD049"
102 }
103
104 fn description(&self) -> &'static str {
105 "Emphasis style should be consistent"
106 }
107
108 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
109 let mut warnings = vec![];
110 let content = ctx.content;
111
112 if !ctx.likely_has_emphasis() {
114 return Ok(warnings);
115 }
116
117 let mut emphasis_info = vec![];
121
122 let mut abs_pos = 0;
124
125 for (line_idx, line) in content.lines().enumerate() {
126 let line_num = line_idx + 1;
127
128 if ctx.is_in_code_block(line_num) || ctx.is_in_front_matter(line_num) {
130 abs_pos += line.len() + 1; continue;
132 }
133
134 if !line.contains('*') && !line.contains('_') {
136 abs_pos += line.len() + 1;
137 continue;
138 }
139
140 let line_start = abs_pos;
142 self.collect_emphasis_from_line(line, line_num, line_start, &mut emphasis_info);
143
144 abs_pos += line.len() + 1;
145 }
146
147 emphasis_info.retain(|(_, _, abs_pos, _, _)| !self.is_in_link(ctx, *abs_pos));
149
150 match self.config.style {
151 EmphasisStyle::Consistent => {
152 if emphasis_info.len() < 2 {
154 return Ok(warnings);
155 }
156
157 let target_marker = emphasis_info[0].3;
159
160 for (line_num, col, abs_pos, marker, content) in emphasis_info.iter().skip(1) {
162 if *marker != target_marker {
163 let emphasis_len = 1 + content.len() + 1;
165
166 warnings.push(LintWarning {
167 rule_name: Some(self.name()),
168 line: *line_num,
169 column: *col,
170 end_line: *line_num,
171 end_column: col + emphasis_len,
172 message: format!("Emphasis should use {target_marker} instead of {marker}"),
173 fix: Some(Fix {
174 range: *abs_pos..*abs_pos + emphasis_len,
175 replacement: format!("{target_marker}{content}{target_marker}"),
176 }),
177 severity: Severity::Warning,
178 });
179 }
180 }
181 }
182 EmphasisStyle::Asterisk | EmphasisStyle::Underscore => {
183 let (wrong_marker, correct_marker) = match self.config.style {
184 EmphasisStyle::Asterisk => ('_', '*'),
185 EmphasisStyle::Underscore => ('*', '_'),
186 EmphasisStyle::Consistent => {
187 ('_', '*')
190 }
191 };
192
193 for (line_num, col, abs_pos, marker, content) in &emphasis_info {
194 if *marker == wrong_marker {
195 let emphasis_len = 1 + content.len() + 1;
197
198 warnings.push(LintWarning {
199 rule_name: Some(self.name()),
200 line: *line_num,
201 column: *col,
202 end_line: *line_num,
203 end_column: col + emphasis_len,
204 message: format!("Emphasis should use {correct_marker} instead of {wrong_marker}"),
205 fix: Some(Fix {
206 range: *abs_pos..*abs_pos + emphasis_len,
207 replacement: format!("{correct_marker}{content}{correct_marker}"),
208 }),
209 severity: Severity::Warning,
210 });
211 }
212 }
213 }
214 }
215 Ok(warnings)
216 }
217
218 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
219 let warnings = self.check(ctx)?;
221
222 if warnings.is_empty() {
224 return Ok(ctx.content.to_string());
225 }
226
227 let mut fixes: Vec<_> = warnings
229 .iter()
230 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
231 .collect();
232 fixes.sort_by(|a, b| b.0.cmp(&a.0));
233
234 let mut result = ctx.content.to_string();
236 for (start, end, replacement) in fixes {
237 if start < result.len() && end <= result.len() && start <= end {
238 result.replace_range(start..end, replacement);
239 }
240 }
241
242 Ok(result)
243 }
244
245 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
247 ctx.content.is_empty() || !ctx.likely_has_emphasis()
248 }
249
250 fn as_any(&self) -> &dyn std::any::Any {
251 self
252 }
253
254 fn default_config_section(&self) -> Option<(String, toml::Value)> {
255 let json_value = serde_json::to_value(&self.config).ok()?;
256 Some((
257 self.name().to_string(),
258 crate::rule_config_serde::json_to_toml_value(&json_value)?,
259 ))
260 }
261
262 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
263 where
264 Self: Sized,
265 {
266 let rule_config = crate::rule_config_serde::load_rule_config::<MD049Config>(config);
267 Box::new(Self::from_config_struct(rule_config))
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn test_name() {
277 let rule = MD049EmphasisStyle::default();
278 assert_eq!(rule.name(), "MD049");
279 }
280
281 #[test]
282 fn test_style_from_str() {
283 assert_eq!(EmphasisStyle::from("asterisk"), EmphasisStyle::Asterisk);
284 assert_eq!(EmphasisStyle::from("underscore"), EmphasisStyle::Underscore);
285 assert_eq!(EmphasisStyle::from("other"), EmphasisStyle::Consistent);
286 }
287
288 #[test]
289 fn test_emphasis_in_links_not_flagged() {
290 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
291 let content = r#"Check this [*asterisk*](https://example.com/*pattern*) link and [_underscore_](https://example.com/_private_).
292
293Also see the [`__init__`][__init__] reference.
294
295This should be _flagged_ since we're using asterisk style.
296
297[__init__]: https://example.com/__init__.py"#;
298 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
299 let result = rule.check(&ctx).unwrap();
300
301 assert_eq!(result.len(), 1);
303 assert!(result[0].message.contains("Emphasis should use * instead of _"));
304 assert!(result[0].line == 5); }
307
308 #[test]
309 fn test_emphasis_in_links_vs_outside_links() {
310 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
311 let content = r#"Check [*emphasis*](https://example.com/*test*) and inline *real emphasis* text.
312
313[*link*]: https://example.com/*path*"#;
314 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
315 let result = rule.check(&ctx).unwrap();
316
317 assert_eq!(result.len(), 1);
319 assert!(result[0].message.contains("Emphasis should use _ instead of *"));
320 assert!(result[0].line == 1);
322 }
323}