1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::emphasis_utils::{
6 EmphasisSpan, find_emphasis_markers, find_emphasis_spans, has_doc_patterns, replace_inline_code,
7};
8use crate::utils::kramdown_utils::has_span_ial;
9use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
10use crate::utils::skip_context::{is_in_html_comment, is_in_math_context, is_in_table_cell};
11use lazy_static::lazy_static;
12use regex::Regex;
13
14lazy_static! {
15 static ref REF_DEF_REGEX: Regex = Regex::new(
17 r#"(?m)^[ ]{0,3}\[([^\]]+)\]:\s*([^\s]+)(?:\s+(?:"([^"]*)"|'([^']*)'))?$"#
18 ).unwrap();
19}
20
21#[inline]
23fn has_spacing_issues(span: &EmphasisSpan) -> bool {
24 span.has_leading_space || span.has_trailing_space
25}
26
27#[derive(Clone)]
29pub struct MD037NoSpaceInEmphasis;
30
31impl Default for MD037NoSpaceInEmphasis {
32 fn default() -> Self {
33 Self
34 }
35}
36
37impl MD037NoSpaceInEmphasis {
38 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
40 for link in &ctx.links {
42 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
43 return true;
44 }
45 }
46
47 for image in &ctx.images {
49 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
50 return true;
51 }
52 }
53
54 for m in REF_DEF_REGEX.find_iter(ctx.content) {
56 if m.start() <= byte_pos && byte_pos < m.end() {
57 return true;
58 }
59 }
60
61 false
62 }
63}
64
65impl Rule for MD037NoSpaceInEmphasis {
66 fn name(&self) -> &'static str {
67 "MD037"
68 }
69
70 fn description(&self) -> &'static str {
71 "Spaces inside emphasis markers"
72 }
73
74 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
75 let content = ctx.content;
76 let _timer = crate::profiling::ScopedTimer::new("MD037_check");
77
78 if !content.contains('*') && !content.contains('_') {
80 return Ok(vec![]);
81 }
82
83 let mut warnings = Vec::new();
84
85 for (line_num, line) in content.lines().enumerate() {
87 if ctx.is_in_code_block(line_num + 1) || ctx.is_in_front_matter(line_num + 1) {
89 continue;
90 }
91
92 if !line.contains('*') && !line.contains('_') {
94 continue;
95 }
96
97 self.check_line_for_emphasis_issues_fast(line, line_num + 1, &mut warnings);
99 }
100
101 let mut filtered_warnings = Vec::new();
103 let mut line_start_pos = 0;
104
105 for (line_idx, line) in content.lines().enumerate() {
106 let line_num = line_idx + 1;
107
108 for warning in &warnings {
110 if warning.line == line_num {
111 let byte_pos = line_start_pos + (warning.column - 1);
113
114 if !self.is_in_link(ctx, byte_pos)
116 && !is_in_html_comment(content, byte_pos)
117 && !is_in_math_context(ctx, byte_pos)
118 && !is_in_table_cell(ctx, line_num, warning.column)
119 {
120 filtered_warnings.push(warning.clone());
121 }
122 }
123 }
124
125 line_start_pos += line.len() + 1; }
127
128 Ok(filtered_warnings)
129 }
130
131 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
132 let content = ctx.content;
133 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
134
135 if !content.contains('*') && !content.contains('_') {
137 return Ok(content.to_string());
138 }
139
140 let warnings = self.check(ctx)?;
142
143 if warnings.is_empty() {
145 return Ok(content.to_string());
146 }
147
148 let mut line_positions = Vec::new();
150 let mut pos = 0;
151 for line in content.lines() {
152 line_positions.push(pos);
153 pos += line.len() + 1; }
155
156 let mut result = content.to_string();
158 let mut offset: isize = 0;
159
160 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
162 sorted_warnings.sort_by_key(|w| (w.line, w.column));
163
164 for warning in sorted_warnings {
165 if let Some(fix) = &warning.fix {
166 let line_start = line_positions.get(warning.line - 1).copied().unwrap_or(0);
168 let abs_start = line_start + warning.column - 1;
169 let abs_end = abs_start + (fix.range.end - fix.range.start);
170
171 let actual_start = (abs_start as isize + offset) as usize;
173 let actual_end = (abs_end as isize + offset) as usize;
174
175 if actual_start < result.len() && actual_end <= result.len() {
177 result.replace_range(actual_start..actual_end, &fix.replacement);
179 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
181 }
182 }
183 }
184
185 Ok(result)
186 }
187
188 fn category(&self) -> RuleCategory {
190 RuleCategory::Emphasis
191 }
192
193 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
195 ctx.content.is_empty() || !ctx.likely_has_emphasis()
196 }
197
198 fn as_any(&self) -> &dyn std::any::Any {
199 self
200 }
201
202 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
203 where
204 Self: Sized,
205 {
206 Box::new(MD037NoSpaceInEmphasis)
207 }
208}
209
210impl MD037NoSpaceInEmphasis {
211 #[inline]
213 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
214 if has_doc_patterns(line) {
216 return;
217 }
218
219 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
221 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
222 {
223 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
224 && let Some(full_match) = caps.get(0)
225 {
226 let list_marker_end = full_match.end();
227 if list_marker_end < line.len() {
228 let remaining_content = &line[list_marker_end..];
229
230 if self.is_likely_list_item_fast(remaining_content) {
231 self.check_line_content_for_emphasis_fast(
232 remaining_content,
233 line_num,
234 list_marker_end,
235 warnings,
236 );
237 } else {
238 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
239 }
240 }
241 }
242 return;
243 }
244
245 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
247 }
248
249 #[inline]
251 fn is_likely_list_item_fast(&self, content: &str) -> bool {
252 let trimmed = content.trim();
253
254 if trimmed.is_empty() || trimmed.len() < 3 {
256 return false;
257 }
258
259 let word_count = trimmed.split_whitespace().count();
261
262 if word_count <= 2 && trimmed.ends_with('*') && !trimmed.ends_with("**") {
264 return false;
265 }
266
267 if word_count >= 4 {
269 if !trimmed.contains('*') && !trimmed.contains('_') {
271 return true;
272 }
273 }
274
275 false
277 }
278
279 fn check_line_content_for_emphasis_fast(
281 &self,
282 content: &str,
283 line_num: usize,
284 offset: usize,
285 warnings: &mut Vec<LintWarning>,
286 ) {
287 let processed_content = replace_inline_code(content);
289
290 let markers = find_emphasis_markers(&processed_content);
292 if markers.is_empty() {
293 return;
294 }
295
296 let spans = find_emphasis_spans(&processed_content, markers);
298
299 for span in spans {
301 if has_spacing_issues(&span) {
302 let full_start = span.opening.start_pos;
304 let full_end = span.closing.end_pos();
305 let full_text = &content[full_start..full_end];
306
307 if full_end < content.len() {
310 let remaining = &content[full_end..];
311 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
313 continue;
314 }
315 }
316
317 let marker_char = span.opening.as_char();
319 let marker_str = if span.opening.count == 1 {
320 marker_char.to_string()
321 } else {
322 format!("{marker_char}{marker_char}")
323 };
324
325 let trimmed_content = span.content.trim();
327 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
328
329 let warning = LintWarning {
330 rule_name: Some(self.name()),
331 message: format!("Spaces inside emphasis markers: {full_text:?}"),
332 line: line_num,
333 column: offset + full_start + 1, end_line: line_num,
335 end_column: offset + full_end + 1,
336 severity: Severity::Warning,
337 fix: Some(Fix {
338 range: (offset + full_start)..(offset + full_end),
339 replacement: fixed_text,
340 }),
341 };
342
343 warnings.push(warning);
344 }
345 }
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::lint_context::LintContext;
353
354 #[test]
355 fn test_emphasis_marker_parsing() {
356 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
357 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
360 assert_eq!(markers.len(), 4); }
362
363 #[test]
364 fn test_emphasis_span_detection() {
365 let markers = find_emphasis_markers("This has *valid* emphasis");
366 let spans = find_emphasis_spans("This has *valid* emphasis", markers);
367 assert_eq!(spans.len(), 1);
368 assert_eq!(spans[0].content, "valid");
369 assert!(!spans[0].has_leading_space);
370 assert!(!spans[0].has_trailing_space);
371
372 let markers = find_emphasis_markers("This has * invalid * emphasis");
373 let spans = find_emphasis_spans("This has * invalid * emphasis", markers);
374 assert_eq!(spans.len(), 1);
375 assert_eq!(spans[0].content, " invalid ");
376 assert!(spans[0].has_leading_space);
377 assert!(spans[0].has_trailing_space);
378 }
379
380 #[test]
381 fn test_with_document_structure() {
382 let rule = MD037NoSpaceInEmphasis;
383
384 let content = "This is *correct* emphasis and **strong emphasis**";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
387 let result = rule.check(&ctx).unwrap();
388 assert!(result.is_empty(), "No warnings expected for correct emphasis");
389
390 let content = "This is * text with spaces * and more content";
392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
393 let result = rule.check(&ctx).unwrap();
394 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
395
396 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
399 let result = rule.check(&ctx).unwrap();
400 assert!(
401 !result.is_empty(),
402 "Expected warnings for spaces in emphasis outside code block"
403 );
404 }
405
406 #[test]
407 fn test_emphasis_in_links_not_flagged() {
408 let rule = MD037NoSpaceInEmphasis;
409 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
410
411This has * real spaced emphasis * that should be flagged."#;
412 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
413 let result = rule.check(&ctx).unwrap();
414
415 assert_eq!(
419 result.len(),
420 1,
421 "Expected exactly 1 warning, but got: {:?}",
422 result.len()
423 );
424 assert!(result[0].message.contains("Spaces inside emphasis markers"));
425 assert!(result[0].line == 3); }
428
429 #[test]
430 fn test_emphasis_in_links_vs_outside_links() {
431 let rule = MD037NoSpaceInEmphasis;
432 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
433
434[* link *]: https://example.com/*path*"#;
435 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
436 let result = rule.check(&ctx).unwrap();
437
438 assert_eq!(result.len(), 1);
440 assert!(result[0].message.contains("Spaces inside emphasis markers"));
441 assert!(result[0].line == 1);
443 }
444
445 #[test]
446 fn test_issue_49_asterisk_in_inline_code() {
447 let rule = MD037NoSpaceInEmphasis;
449
450 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
453 let result = rule.check(&ctx).unwrap();
454 assert!(
455 result.is_empty(),
456 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
457 );
458 }
459
460 #[test]
461 fn test_issue_28_inline_code_in_emphasis() {
462 let rule = MD037NoSpaceInEmphasis;
464
465 let content = "Though, we often call this an **inline `if`** because it looks sort of like an `if`-`else` statement all in *one line* of code.";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468 let result = rule.check(&ctx).unwrap();
469 assert!(
470 result.is_empty(),
471 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
472 );
473
474 let content2 = "The **`foo` and `bar`** methods are important.";
476 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
477 let result2 = rule.check(&ctx2).unwrap();
478 assert!(
479 result2.is_empty(),
480 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
481 );
482
483 let content3 = "This is __inline `code`__ with underscores.";
485 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
486 let result3 = rule.check(&ctx3).unwrap();
487 assert!(
488 result3.is_empty(),
489 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
490 );
491
492 let content4 = "This is *inline `test`* with single asterisks.";
494 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard);
495 let result4 = rule.check(&ctx4).unwrap();
496 assert!(
497 result4.is_empty(),
498 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
499 );
500
501 let content5 = "This has * real spaces * that should be flagged.";
503 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard);
504 let result5 = rule.check(&ctx5).unwrap();
505 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
506 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
507 }
508}