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