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};
9use crate::utils::kramdown_utils::has_span_ial;
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};
12
13#[inline]
15fn has_spacing_issues(span: &EmphasisSpan) -> bool {
16 span.has_leading_space || span.has_trailing_space
17}
18
19#[inline]
22fn truncate_for_display(text: &str, max_len: usize) -> String {
23 if text.len() <= max_len {
24 return text.to_string();
25 }
26
27 let prefix_len = max_len / 2 - 2; let suffix_len = max_len / 2 - 2;
29
30 let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
32 let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
33
34 format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
35}
36
37#[derive(Clone)]
39pub struct MD037NoSpaceInEmphasis;
40
41impl Default for MD037NoSpaceInEmphasis {
42 fn default() -> Self {
43 Self
44 }
45}
46
47impl MD037NoSpaceInEmphasis {
48 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
50 for link in &ctx.links {
52 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
53 return true;
54 }
55 }
56
57 for image in &ctx.images {
59 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
60 return true;
61 }
62 }
63
64 ctx.is_in_reference_def(byte_pos)
66 }
67}
68
69impl Rule for MD037NoSpaceInEmphasis {
70 fn name(&self) -> &'static str {
71 "MD037"
72 }
73
74 fn description(&self) -> &'static str {
75 "Spaces inside emphasis markers"
76 }
77
78 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
79 let content = ctx.content;
80 let _timer = crate::profiling::ScopedTimer::new("MD037_check");
81
82 if !content.contains('*') && !content.contains('_') {
84 return Ok(vec![]);
85 }
86
87 let line_index = &ctx.line_index;
89
90 let mut warnings = Vec::new();
91
92 for line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
94 if !line.content.contains('*') && !line.content.contains('_') {
96 continue;
97 }
98
99 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
101 }
102
103 let mut filtered_warnings = Vec::new();
105
106 for (line_idx, _line) in content.lines().enumerate() {
107 let line_num = line_idx + 1;
108 let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
109
110 for warning in &warnings {
112 if warning.line == line_num {
113 let byte_pos = line_start_pos + (warning.column - 1);
115
116 if !self.is_in_link(ctx, byte_pos)
118 && !is_in_html_comment(content, byte_pos)
119 && !is_in_math_context(ctx, byte_pos)
120 && !is_in_table_cell(ctx, line_num, warning.column)
121 {
122 filtered_warnings.push(warning.clone());
123 }
124 }
125 }
126 }
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 line_index = &ctx.line_index;
150
151 let mut result = content.to_string();
153 let mut offset: isize = 0;
154
155 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
157 sorted_warnings.sort_by_key(|w| (w.line, w.column));
158
159 for warning in sorted_warnings {
160 if let Some(fix) = &warning.fix {
161 let line_start = line_index.get_line_start_byte(warning.line).unwrap_or(0);
163 let abs_start = line_start + warning.column - 1;
164 let abs_end = abs_start + (fix.range.end - fix.range.start);
165
166 let actual_start = (abs_start as isize + offset) as usize;
168 let actual_end = (abs_end as isize + offset) as usize;
169
170 if actual_start < result.len() && actual_end <= result.len() {
172 result.replace_range(actual_start..actual_end, &fix.replacement);
174 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
176 }
177 }
178 }
179
180 Ok(result)
181 }
182
183 fn category(&self) -> RuleCategory {
185 RuleCategory::Emphasis
186 }
187
188 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
190 ctx.content.is_empty() || !ctx.likely_has_emphasis()
191 }
192
193 fn as_any(&self) -> &dyn std::any::Any {
194 self
195 }
196
197 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
198 where
199 Self: Sized,
200 {
201 Box::new(MD037NoSpaceInEmphasis)
202 }
203}
204
205impl MD037NoSpaceInEmphasis {
206 #[inline]
208 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
209 if has_doc_patterns(line) {
211 return;
212 }
213
214 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
219 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
220 {
221 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
222 && let Some(full_match) = caps.get(0)
223 {
224 let list_marker_end = full_match.end();
225 if list_marker_end < line.len() {
226 let remaining_content = &line[list_marker_end..];
227
228 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
231 }
232 }
233 return;
234 }
235
236 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
238 }
239
240 fn check_line_content_for_emphasis_fast(
242 &self,
243 content: &str,
244 line_num: usize,
245 offset: usize,
246 warnings: &mut Vec<LintWarning>,
247 ) {
248 let processed_content = replace_inline_code(content);
250
251 let markers = find_emphasis_markers(&processed_content);
253 if markers.is_empty() {
254 return;
255 }
256
257 let spans = find_emphasis_spans(&processed_content, markers);
259
260 for span in spans {
262 if has_spacing_issues(&span) {
263 let full_start = span.opening.start_pos;
265 let full_end = span.closing.end_pos();
266 let full_text = &content[full_start..full_end];
267
268 if full_end < content.len() {
271 let remaining = &content[full_end..];
272 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
274 continue;
275 }
276 }
277
278 let marker_char = span.opening.as_char();
280 let marker_str = if span.opening.count == 1 {
281 marker_char.to_string()
282 } else {
283 format!("{marker_char}{marker_char}")
284 };
285
286 let trimmed_content = span.content.trim();
288 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
289
290 let display_text = truncate_for_display(full_text, 60);
292
293 let warning = LintWarning {
294 rule_name: Some(self.name().to_string()),
295 message: format!("Spaces inside emphasis markers: {display_text:?}"),
296 line: line_num,
297 column: offset + full_start + 1, end_line: line_num,
299 end_column: offset + full_end + 1,
300 severity: Severity::Warning,
301 fix: Some(Fix {
302 range: (offset + full_start)..(offset + full_end),
303 replacement: fixed_text,
304 }),
305 };
306
307 warnings.push(warning);
308 }
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::lint_context::LintContext;
317
318 #[test]
319 fn test_emphasis_marker_parsing() {
320 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
321 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
324 assert_eq!(markers.len(), 4); }
326
327 #[test]
328 fn test_emphasis_span_detection() {
329 let markers = find_emphasis_markers("This has *valid* emphasis");
330 let spans = find_emphasis_spans("This has *valid* emphasis", markers);
331 assert_eq!(spans.len(), 1);
332 assert_eq!(spans[0].content, "valid");
333 assert!(!spans[0].has_leading_space);
334 assert!(!spans[0].has_trailing_space);
335
336 let markers = find_emphasis_markers("This has * invalid * emphasis");
337 let spans = find_emphasis_spans("This has * invalid * emphasis", markers);
338 assert_eq!(spans.len(), 1);
339 assert_eq!(spans[0].content, " invalid ");
340 assert!(spans[0].has_leading_space);
341 assert!(spans[0].has_trailing_space);
342 }
343
344 #[test]
345 fn test_with_document_structure() {
346 let rule = MD037NoSpaceInEmphasis;
347
348 let content = "This is *correct* emphasis and **strong emphasis**";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352 assert!(result.is_empty(), "No warnings expected for correct emphasis");
353
354 let content = "This is * text with spaces * and more content";
356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357 let result = rule.check(&ctx).unwrap();
358 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
359
360 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363 let result = rule.check(&ctx).unwrap();
364 assert!(
365 !result.is_empty(),
366 "Expected warnings for spaces in emphasis outside code block"
367 );
368 }
369
370 #[test]
371 fn test_emphasis_in_links_not_flagged() {
372 let rule = MD037NoSpaceInEmphasis;
373 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
374
375This has * real spaced emphasis * that should be flagged."#;
376 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378
379 assert_eq!(
383 result.len(),
384 1,
385 "Expected exactly 1 warning, but got: {:?}",
386 result.len()
387 );
388 assert!(result[0].message.contains("Spaces inside emphasis markers"));
389 assert!(result[0].line == 3); }
392
393 #[test]
394 fn test_emphasis_in_links_vs_outside_links() {
395 let rule = MD037NoSpaceInEmphasis;
396 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
397
398[* link *]: https://example.com/*path*"#;
399 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401
402 assert_eq!(result.len(), 1);
404 assert!(result[0].message.contains("Spaces inside emphasis markers"));
405 assert!(result[0].line == 1);
407 }
408
409 #[test]
410 fn test_issue_49_asterisk_in_inline_code() {
411 let rule = MD037NoSpaceInEmphasis;
413
414 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418 assert!(
419 result.is_empty(),
420 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
421 );
422 }
423
424 #[test]
425 fn test_issue_28_inline_code_in_emphasis() {
426 let rule = MD037NoSpaceInEmphasis;
428
429 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.";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432 let result = rule.check(&ctx).unwrap();
433 assert!(
434 result.is_empty(),
435 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
436 );
437
438 let content2 = "The **`foo` and `bar`** methods are important.";
440 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
441 let result2 = rule.check(&ctx2).unwrap();
442 assert!(
443 result2.is_empty(),
444 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
445 );
446
447 let content3 = "This is __inline `code`__ with underscores.";
449 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
450 let result3 = rule.check(&ctx3).unwrap();
451 assert!(
452 result3.is_empty(),
453 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
454 );
455
456 let content4 = "This is *inline `test`* with single asterisks.";
458 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
459 let result4 = rule.check(&ctx4).unwrap();
460 assert!(
461 result4.is_empty(),
462 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
463 );
464
465 let content5 = "This has * real spaces * that should be flagged.";
467 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
468 let result5 = rule.check(&ctx5).unwrap();
469 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
470 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
471 }
472
473 #[test]
474 fn test_multibyte_utf8_no_panic() {
475 let rule = MD037NoSpaceInEmphasis;
479
480 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
482 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
483 let result = rule.check(&ctx);
484 assert!(result.is_ok(), "Greek text should not panic");
485
486 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
488 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx);
490 assert!(result.is_ok(), "Chinese text should not panic");
491
492 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
494 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx);
496 assert!(result.is_ok(), "Cyrillic text should not panic");
497
498 let mixed =
500 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
501 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
502 let result = rule.check(&ctx);
503 assert!(result.is_ok(), "Mixed CJK text should not panic");
504
505 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
507 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx);
509 assert!(result.is_ok(), "Arabic text should not panic");
510
511 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
513 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx);
515 assert!(result.is_ok(), "Emoji text should not panic");
516 }
517
518 #[test]
519 fn test_template_shortcode_syntax_not_flagged() {
520 let rule = MD037NoSpaceInEmphasis;
523
524 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let result = rule.check(&ctx).unwrap();
528 assert!(
529 result.is_empty(),
530 "Template shortcode syntax should not be flagged. Got: {result:?}"
531 );
532
533 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537 assert!(
538 result.is_empty(),
539 "Template shortcode syntax should not be flagged. Got: {result:?}"
540 );
541
542 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546 assert!(
547 result.is_empty(),
548 "Multiple template shortcodes should not be flagged. Got: {result:?}"
549 );
550
551 let content = "This has * real spaced emphasis * here.";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
556 }
557}