1use crate::filtered_lines::FilteredLinesExt;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::emphasis_utils::{
7 EmphasisSpan, find_emphasis_markers, find_emphasis_spans, has_doc_patterns, replace_inline_code,
8 replace_inline_math,
9};
10use crate::utils::kramdown_utils::has_span_ial;
11use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
12use crate::utils::skip_context::{
13 is_in_html_comment, is_in_jsx_expression, is_in_math_context, is_in_mdx_comment, is_in_mkdocs_markup,
14 is_in_table_cell,
15};
16
17#[inline]
19fn has_spacing_issues(span: &EmphasisSpan) -> bool {
20 span.has_leading_space || span.has_trailing_space
21}
22
23#[inline]
26fn truncate_for_display(text: &str, max_len: usize) -> String {
27 if text.len() <= max_len {
28 return text.to_string();
29 }
30
31 let prefix_len = max_len / 2 - 2; let suffix_len = max_len / 2 - 2;
33
34 let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
36 let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
37
38 format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
39}
40
41#[derive(Clone)]
43pub struct MD037NoSpaceInEmphasis;
44
45impl Default for MD037NoSpaceInEmphasis {
46 fn default() -> Self {
47 Self
48 }
49}
50
51impl MD037NoSpaceInEmphasis {
52 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
54 for link in &ctx.links {
56 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
57 return true;
58 }
59 }
60
61 for image in &ctx.images {
63 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
64 return true;
65 }
66 }
67
68 ctx.is_in_reference_def(byte_pos)
70 }
71}
72
73impl Rule for MD037NoSpaceInEmphasis {
74 fn name(&self) -> &'static str {
75 "MD037"
76 }
77
78 fn description(&self) -> &'static str {
79 "Spaces inside emphasis markers"
80 }
81
82 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
83 let content = ctx.content;
84 let _timer = crate::profiling::ScopedTimer::new("MD037_check");
85
86 if !content.contains('*') && !content.contains('_') {
88 return Ok(vec![]);
89 }
90
91 let line_index = &ctx.line_index;
93
94 let mut warnings = Vec::new();
95
96 for line in ctx
99 .filtered_lines()
100 .skip_front_matter()
101 .skip_code_blocks()
102 .skip_math_blocks()
103 {
104 if !line.content.contains('*') && !line.content.contains('_') {
106 continue;
107 }
108
109 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
111 }
112
113 let mut filtered_warnings = Vec::new();
115 let lines: Vec<&str> = content.lines().collect();
116
117 for (line_idx, line) in lines.iter().enumerate() {
118 let line_num = line_idx + 1;
119 let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
120
121 for warning in &warnings {
123 if warning.line == line_num {
124 let byte_pos = line_start_pos + (warning.column - 1);
126 let line_pos = warning.column - 1;
128
129 if !self.is_in_link(ctx, byte_pos)
132 && !is_in_html_comment(content, byte_pos)
133 && !is_in_math_context(ctx, byte_pos)
134 && !is_in_table_cell(ctx, line_num, warning.column)
135 && !ctx.is_in_code_span(line_num, warning.column)
136 && !is_in_jsx_expression(ctx, byte_pos)
137 && !is_in_mdx_comment(ctx, byte_pos)
138 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
139 {
140 filtered_warnings.push(warning.clone());
141 }
142 }
143 }
144 }
145
146 Ok(filtered_warnings)
147 }
148
149 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
150 let content = ctx.content;
151 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
152
153 if !content.contains('*') && !content.contains('_') {
155 return Ok(content.to_string());
156 }
157
158 let warnings = self.check(ctx)?;
160
161 if warnings.is_empty() {
163 return Ok(content.to_string());
164 }
165
166 let line_index = &ctx.line_index;
168
169 let mut result = content.to_string();
171 let mut offset: isize = 0;
172
173 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
175 sorted_warnings.sort_by_key(|w| (w.line, w.column));
176
177 for warning in sorted_warnings {
178 if let Some(fix) = &warning.fix {
179 let line_start = line_index.get_line_start_byte(warning.line).unwrap_or(0);
181 let abs_start = line_start + warning.column - 1;
182 let abs_end = abs_start + (fix.range.end - fix.range.start);
183
184 let actual_start = (abs_start as isize + offset) as usize;
186 let actual_end = (abs_end as isize + offset) as usize;
187
188 if actual_start < result.len() && actual_end <= result.len() {
190 result.replace_range(actual_start..actual_end, &fix.replacement);
192 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
194 }
195 }
196 }
197
198 Ok(result)
199 }
200
201 fn category(&self) -> RuleCategory {
203 RuleCategory::Emphasis
204 }
205
206 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
208 ctx.content.is_empty() || !ctx.likely_has_emphasis()
209 }
210
211 fn as_any(&self) -> &dyn std::any::Any {
212 self
213 }
214
215 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
216 where
217 Self: Sized,
218 {
219 Box::new(MD037NoSpaceInEmphasis)
220 }
221}
222
223impl MD037NoSpaceInEmphasis {
224 #[inline]
226 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
227 if has_doc_patterns(line) {
229 return;
230 }
231
232 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
237 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
238 {
239 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
240 && let Some(full_match) = caps.get(0)
241 {
242 let list_marker_end = full_match.end();
243 if list_marker_end < line.len() {
244 let remaining_content = &line[list_marker_end..];
245
246 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
249 }
250 }
251 return;
252 }
253
254 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
256 }
257
258 fn check_line_content_for_emphasis_fast(
260 &self,
261 content: &str,
262 line_num: usize,
263 offset: usize,
264 warnings: &mut Vec<LintWarning>,
265 ) {
266 let processed_content = replace_inline_code(content);
269 let processed_content = replace_inline_math(&processed_content);
270
271 let markers = find_emphasis_markers(&processed_content);
273 if markers.is_empty() {
274 return;
275 }
276
277 let spans = find_emphasis_spans(&processed_content, markers);
279
280 for span in spans {
282 if has_spacing_issues(&span) {
283 let full_start = span.opening.start_pos;
285 let full_end = span.closing.end_pos();
286 let full_text = &content[full_start..full_end];
287
288 if full_end < content.len() {
291 let remaining = &content[full_end..];
292 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
294 continue;
295 }
296 }
297
298 let marker_char = span.opening.as_char();
300 let marker_str = if span.opening.count == 1 {
301 marker_char.to_string()
302 } else {
303 format!("{marker_char}{marker_char}")
304 };
305
306 let trimmed_content = span.content.trim();
308 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
309
310 let display_text = truncate_for_display(full_text, 60);
312
313 let warning = LintWarning {
314 rule_name: Some(self.name().to_string()),
315 message: format!("Spaces inside emphasis markers: {display_text:?}"),
316 line: line_num,
317 column: offset + full_start + 1, end_line: line_num,
319 end_column: offset + full_end + 1,
320 severity: Severity::Warning,
321 fix: Some(Fix {
322 range: (offset + full_start)..(offset + full_end),
323 replacement: fixed_text,
324 }),
325 };
326
327 warnings.push(warning);
328 }
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::lint_context::LintContext;
337
338 #[test]
339 fn test_emphasis_marker_parsing() {
340 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
341 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
344 assert_eq!(markers.len(), 4); }
346
347 #[test]
348 fn test_emphasis_span_detection() {
349 let markers = find_emphasis_markers("This has *valid* emphasis");
350 let spans = find_emphasis_spans("This has *valid* emphasis", markers);
351 assert_eq!(spans.len(), 1);
352 assert_eq!(spans[0].content, "valid");
353 assert!(!spans[0].has_leading_space);
354 assert!(!spans[0].has_trailing_space);
355
356 let markers = find_emphasis_markers("This has * invalid * emphasis");
357 let spans = find_emphasis_spans("This has * invalid * emphasis", markers);
358 assert_eq!(spans.len(), 1);
359 assert_eq!(spans[0].content, " invalid ");
360 assert!(spans[0].has_leading_space);
361 assert!(spans[0].has_trailing_space);
362 }
363
364 #[test]
365 fn test_with_document_structure() {
366 let rule = MD037NoSpaceInEmphasis;
367
368 let content = "This is *correct* emphasis and **strong emphasis**";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371 let result = rule.check(&ctx).unwrap();
372 assert!(result.is_empty(), "No warnings expected for correct emphasis");
373
374 let content = "This is * text with spaces * and more content";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
379
380 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384 assert!(
385 !result.is_empty(),
386 "Expected warnings for spaces in emphasis outside code block"
387 );
388 }
389
390 #[test]
391 fn test_emphasis_in_links_not_flagged() {
392 let rule = MD037NoSpaceInEmphasis;
393 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
394
395This has * real spaced emphasis * that should be flagged."#;
396 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397 let result = rule.check(&ctx).unwrap();
398
399 assert_eq!(
403 result.len(),
404 1,
405 "Expected exactly 1 warning, but got: {:?}",
406 result.len()
407 );
408 assert!(result[0].message.contains("Spaces inside emphasis markers"));
409 assert!(result[0].line == 3); }
412
413 #[test]
414 fn test_emphasis_in_links_vs_outside_links() {
415 let rule = MD037NoSpaceInEmphasis;
416 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
417
418[* link *]: https://example.com/*path*"#;
419 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420 let result = rule.check(&ctx).unwrap();
421
422 assert_eq!(result.len(), 1);
424 assert!(result[0].message.contains("Spaces inside emphasis markers"));
425 assert!(result[0].line == 1);
427 }
428
429 #[test]
430 fn test_issue_49_asterisk_in_inline_code() {
431 let rule = MD037NoSpaceInEmphasis;
433
434 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert!(
439 result.is_empty(),
440 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
441 );
442 }
443
444 #[test]
445 fn test_issue_28_inline_code_in_emphasis() {
446 let rule = MD037NoSpaceInEmphasis;
448
449 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.";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert!(
454 result.is_empty(),
455 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
456 );
457
458 let content2 = "The **`foo` and `bar`** methods are important.";
460 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
461 let result2 = rule.check(&ctx2).unwrap();
462 assert!(
463 result2.is_empty(),
464 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
465 );
466
467 let content3 = "This is __inline `code`__ with underscores.";
469 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
470 let result3 = rule.check(&ctx3).unwrap();
471 assert!(
472 result3.is_empty(),
473 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
474 );
475
476 let content4 = "This is *inline `test`* with single asterisks.";
478 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
479 let result4 = rule.check(&ctx4).unwrap();
480 assert!(
481 result4.is_empty(),
482 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
483 );
484
485 let content5 = "This has * real spaces * that should be flagged.";
487 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
488 let result5 = rule.check(&ctx5).unwrap();
489 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
490 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
491 }
492
493 #[test]
494 fn test_multibyte_utf8_no_panic() {
495 let rule = MD037NoSpaceInEmphasis;
499
500 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
502 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx);
504 assert!(result.is_ok(), "Greek text should not panic");
505
506 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
508 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx);
510 assert!(result.is_ok(), "Chinese text should not panic");
511
512 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
514 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx);
516 assert!(result.is_ok(), "Cyrillic text should not panic");
517
518 let mixed =
520 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
521 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx);
523 assert!(result.is_ok(), "Mixed CJK text should not panic");
524
525 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
527 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx);
529 assert!(result.is_ok(), "Arabic text should not panic");
530
531 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
533 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx);
535 assert!(result.is_ok(), "Emoji text should not panic");
536 }
537
538 #[test]
539 fn test_template_shortcode_syntax_not_flagged() {
540 let rule = MD037NoSpaceInEmphasis;
543
544 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let result = rule.check(&ctx).unwrap();
548 assert!(
549 result.is_empty(),
550 "Template shortcode syntax should not be flagged. Got: {result:?}"
551 );
552
553 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557 assert!(
558 result.is_empty(),
559 "Template shortcode syntax should not be flagged. Got: {result:?}"
560 );
561
562 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert!(
567 result.is_empty(),
568 "Multiple template shortcodes should not be flagged. Got: {result:?}"
569 );
570
571 let content = "This has * real spaced emphasis * here.";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
576 }
577
578 #[test]
579 fn test_multiline_code_span_not_flagged() {
580 let rule = MD037NoSpaceInEmphasis;
583
584 let content = "# Test\n\naffects the structure. `1 + 0 + 0` is parsed as `(1 + 0) +\n0` while `1 + 0 * 0` is parsed as `1 + (0 * 0)`. Since the pattern";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(
589 result.is_empty(),
590 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
591 );
592
593 let content2 = "Text with `code that\nspans * multiple * lines` here.";
595 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
596 let result2 = rule.check(&ctx2).unwrap();
597 assert!(
598 result2.is_empty(),
599 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
600 );
601 }
602
603 #[test]
604 fn test_mkdocs_icon_shortcode_not_flagged() {
605 let rule = MD037NoSpaceInEmphasis;
607
608 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
612 let result = rule.check(&ctx).unwrap();
613 assert!(
614 result.is_empty(),
615 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
616 );
617
618 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
620 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
621 let result2 = rule.check(&ctx2).unwrap();
622 assert!(
623 !result2.is_empty(),
624 "Should still flag real spaced emphasis in MkDocs mode"
625 );
626 }
627
628 #[test]
629 fn test_mkdocs_pymdown_markup_not_flagged() {
630 let rule = MD037NoSpaceInEmphasis;
632
633 let content = "Press ++ctrl+c++ to copy.";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
636 let result = rule.check(&ctx).unwrap();
637 assert!(
638 result.is_empty(),
639 "Should not flag PyMdown Keys notation. Got: {result:?}"
640 );
641
642 let content2 = "This is ==highlighted text== for emphasis.";
644 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
645 let result2 = rule.check(&ctx2).unwrap();
646 assert!(
647 result2.is_empty(),
648 "Should not flag PyMdown Mark notation. Got: {result2:?}"
649 );
650
651 let content3 = "This is ^^inserted text^^ here.";
653 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
654 let result3 = rule.check(&ctx3).unwrap();
655 assert!(
656 result3.is_empty(),
657 "Should not flag PyMdown Insert notation. Got: {result3:?}"
658 );
659
660 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
662 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
663 let result4 = rule.check(&ctx4).unwrap();
664 assert!(
665 !result4.is_empty(),
666 "Should still flag real spaced emphasis alongside PyMdown markup"
667 );
668 }
669}