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 let mut adjusted_warning = warning.clone();
141 if let Some(fix) = &mut adjusted_warning.fix {
142 let abs_start = line_start_pos + fix.range.start;
144 let abs_end = line_start_pos + fix.range.end;
145 fix.range = abs_start..abs_end;
146 }
147 filtered_warnings.push(adjusted_warning);
148 }
149 }
150 }
151 }
152
153 Ok(filtered_warnings)
154 }
155
156 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
157 let content = ctx.content;
158 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
159
160 if !content.contains('*') && !content.contains('_') {
162 return Ok(content.to_string());
163 }
164
165 let warnings = self.check(ctx)?;
167
168 if warnings.is_empty() {
170 return Ok(content.to_string());
171 }
172
173 let mut result = content.to_string();
175 let mut offset: isize = 0;
176
177 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
179 sorted_warnings.sort_by_key(|w| (w.line, w.column));
180
181 for warning in sorted_warnings {
182 if let Some(fix) = &warning.fix {
183 let actual_start = (fix.range.start as isize + offset) as usize;
185 let actual_end = (fix.range.end as isize + offset) as usize;
186
187 if actual_start < result.len() && actual_end <= result.len() {
189 result.replace_range(actual_start..actual_end, &fix.replacement);
191 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
193 }
194 }
195 }
196
197 Ok(result)
198 }
199
200 fn category(&self) -> RuleCategory {
202 RuleCategory::Emphasis
203 }
204
205 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
207 ctx.content.is_empty() || !ctx.likely_has_emphasis()
208 }
209
210 fn as_any(&self) -> &dyn std::any::Any {
211 self
212 }
213
214 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
215 where
216 Self: Sized,
217 {
218 Box::new(MD037NoSpaceInEmphasis)
219 }
220}
221
222impl MD037NoSpaceInEmphasis {
223 #[inline]
225 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
226 if has_doc_patterns(line) {
228 return;
229 }
230
231 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
236 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
237 {
238 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
239 && let Some(full_match) = caps.get(0)
240 {
241 let list_marker_end = full_match.end();
242 if list_marker_end < line.len() {
243 let remaining_content = &line[list_marker_end..];
244
245 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
248 }
249 }
250 return;
251 }
252
253 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
255 }
256
257 fn check_line_content_for_emphasis_fast(
259 &self,
260 content: &str,
261 line_num: usize,
262 offset: usize,
263 warnings: &mut Vec<LintWarning>,
264 ) {
265 let processed_content = replace_inline_code(content);
268 let processed_content = replace_inline_math(&processed_content);
269
270 let markers = find_emphasis_markers(&processed_content);
272 if markers.is_empty() {
273 return;
274 }
275
276 let spans = find_emphasis_spans(&processed_content, markers);
278
279 for span in spans {
281 if has_spacing_issues(&span) {
282 let full_start = span.opening.start_pos;
284 let full_end = span.closing.end_pos();
285 let full_text = &content[full_start..full_end];
286
287 if full_end < content.len() {
290 let remaining = &content[full_end..];
291 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
293 continue;
294 }
295 }
296
297 let marker_char = span.opening.as_char();
299 let marker_str = if span.opening.count == 1 {
300 marker_char.to_string()
301 } else {
302 format!("{marker_char}{marker_char}")
303 };
304
305 let trimmed_content = span.content.trim();
307 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
308
309 let display_text = truncate_for_display(full_text, 60);
311
312 let warning = LintWarning {
313 rule_name: Some(self.name().to_string()),
314 message: format!("Spaces inside emphasis markers: {display_text:?}"),
315 line: line_num,
316 column: offset + full_start + 1, end_line: line_num,
318 end_column: offset + full_end + 1,
319 severity: Severity::Warning,
320 fix: Some(Fix {
321 range: (offset + full_start)..(offset + full_end),
322 replacement: fixed_text,
323 }),
324 };
325
326 warnings.push(warning);
327 }
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::lint_context::LintContext;
336
337 #[test]
338 fn test_emphasis_marker_parsing() {
339 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
340 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
343 assert_eq!(markers.len(), 4); }
345
346 #[test]
347 fn test_emphasis_span_detection() {
348 let markers = find_emphasis_markers("This has *valid* emphasis");
349 let spans = find_emphasis_spans("This has *valid* emphasis", markers);
350 assert_eq!(spans.len(), 1);
351 assert_eq!(spans[0].content, "valid");
352 assert!(!spans[0].has_leading_space);
353 assert!(!spans[0].has_trailing_space);
354
355 let markers = find_emphasis_markers("This has * invalid * emphasis");
356 let spans = find_emphasis_spans("This has * invalid * emphasis", markers);
357 assert_eq!(spans.len(), 1);
358 assert_eq!(spans[0].content, " invalid ");
359 assert!(spans[0].has_leading_space);
360 assert!(spans[0].has_trailing_space);
361 }
362
363 #[test]
364 fn test_with_document_structure() {
365 let rule = MD037NoSpaceInEmphasis;
366
367 let content = "This is *correct* emphasis and **strong emphasis**";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
370 let result = rule.check(&ctx).unwrap();
371 assert!(result.is_empty(), "No warnings expected for correct emphasis");
372
373 let content = "This is * text with spaces * and more content";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376 let result = rule.check(&ctx).unwrap();
377 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
378
379 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
382 let result = rule.check(&ctx).unwrap();
383 assert!(
384 !result.is_empty(),
385 "Expected warnings for spaces in emphasis outside code block"
386 );
387 }
388
389 #[test]
390 fn test_emphasis_in_links_not_flagged() {
391 let rule = MD037NoSpaceInEmphasis;
392 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
393
394This has * real spaced emphasis * that should be flagged."#;
395 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396 let result = rule.check(&ctx).unwrap();
397
398 assert_eq!(
402 result.len(),
403 1,
404 "Expected exactly 1 warning, but got: {:?}",
405 result.len()
406 );
407 assert!(result[0].message.contains("Spaces inside emphasis markers"));
408 assert!(result[0].line == 3); }
411
412 #[test]
413 fn test_emphasis_in_links_vs_outside_links() {
414 let rule = MD037NoSpaceInEmphasis;
415 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
416
417[* link *]: https://example.com/*path*"#;
418 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419 let result = rule.check(&ctx).unwrap();
420
421 assert_eq!(result.len(), 1);
423 assert!(result[0].message.contains("Spaces inside emphasis markers"));
424 assert!(result[0].line == 1);
426 }
427
428 #[test]
429 fn test_issue_49_asterisk_in_inline_code() {
430 let rule = MD037NoSpaceInEmphasis;
432
433 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert!(
438 result.is_empty(),
439 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
440 );
441 }
442
443 #[test]
444 fn test_issue_28_inline_code_in_emphasis() {
445 let rule = MD037NoSpaceInEmphasis;
447
448 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.";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452 assert!(
453 result.is_empty(),
454 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
455 );
456
457 let content2 = "The **`foo` and `bar`** methods are important.";
459 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
460 let result2 = rule.check(&ctx2).unwrap();
461 assert!(
462 result2.is_empty(),
463 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
464 );
465
466 let content3 = "This is __inline `code`__ with underscores.";
468 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
469 let result3 = rule.check(&ctx3).unwrap();
470 assert!(
471 result3.is_empty(),
472 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
473 );
474
475 let content4 = "This is *inline `test`* with single asterisks.";
477 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
478 let result4 = rule.check(&ctx4).unwrap();
479 assert!(
480 result4.is_empty(),
481 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
482 );
483
484 let content5 = "This has * real spaces * that should be flagged.";
486 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
487 let result5 = rule.check(&ctx5).unwrap();
488 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
489 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
490 }
491
492 #[test]
493 fn test_multibyte_utf8_no_panic() {
494 let rule = MD037NoSpaceInEmphasis;
498
499 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
501 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
502 let result = rule.check(&ctx);
503 assert!(result.is_ok(), "Greek text should not panic");
504
505 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
507 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx);
509 assert!(result.is_ok(), "Chinese text should not panic");
510
511 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
513 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx);
515 assert!(result.is_ok(), "Cyrillic text should not panic");
516
517 let mixed =
519 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
520 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx);
522 assert!(result.is_ok(), "Mixed CJK text should not panic");
523
524 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
526 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
527 let result = rule.check(&ctx);
528 assert!(result.is_ok(), "Arabic text should not panic");
529
530 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
532 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx);
534 assert!(result.is_ok(), "Emoji text should not panic");
535 }
536
537 #[test]
538 fn test_template_shortcode_syntax_not_flagged() {
539 let rule = MD037NoSpaceInEmphasis;
542
543 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547 assert!(
548 result.is_empty(),
549 "Template shortcode syntax should not be flagged. Got: {result:?}"
550 );
551
552 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556 assert!(
557 result.is_empty(),
558 "Template shortcode syntax should not be flagged. Got: {result:?}"
559 );
560
561 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert!(
566 result.is_empty(),
567 "Multiple template shortcodes should not be flagged. Got: {result:?}"
568 );
569
570 let content = "This has * real spaced emphasis * here.";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
575 }
576
577 #[test]
578 fn test_multiline_code_span_not_flagged() {
579 let rule = MD037NoSpaceInEmphasis;
582
583 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";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert!(
588 result.is_empty(),
589 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
590 );
591
592 let content2 = "Text with `code that\nspans * multiple * lines` here.";
594 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
595 let result2 = rule.check(&ctx2).unwrap();
596 assert!(
597 result2.is_empty(),
598 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
599 );
600 }
601
602 #[test]
603 fn test_mkdocs_icon_shortcode_not_flagged() {
604 let rule = MD037NoSpaceInEmphasis;
606
607 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
611 let result = rule.check(&ctx).unwrap();
612 assert!(
613 result.is_empty(),
614 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
615 );
616
617 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
619 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
620 let result2 = rule.check(&ctx2).unwrap();
621 assert!(
622 !result2.is_empty(),
623 "Should still flag real spaced emphasis in MkDocs mode"
624 );
625 }
626
627 #[test]
628 fn test_mkdocs_pymdown_markup_not_flagged() {
629 let rule = MD037NoSpaceInEmphasis;
631
632 let content = "Press ++ctrl+c++ to copy.";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(
637 result.is_empty(),
638 "Should not flag PyMdown Keys notation. Got: {result:?}"
639 );
640
641 let content2 = "This is ==highlighted text== for emphasis.";
643 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
644 let result2 = rule.check(&ctx2).unwrap();
645 assert!(
646 result2.is_empty(),
647 "Should not flag PyMdown Mark notation. Got: {result2:?}"
648 );
649
650 let content3 = "This is ^^inserted text^^ here.";
652 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
653 let result3 = rule.check(&ctx3).unwrap();
654 assert!(
655 result3.is_empty(),
656 "Should not flag PyMdown Insert notation. Got: {result3:?}"
657 );
658
659 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
661 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
662 let result4 = rule.check(&ctx4).unwrap();
663 assert!(
664 !result4.is_empty(),
665 "Should still flag real spaced emphasis alongside PyMdown markup"
666 );
667 }
668}