Skip to main content

rumdl_lib/rules/
md037_spaces_around_emphasis.rs

1/// Rule MD037: No spaces around emphasis markers
2///
3/// See [docs/md037.md](../../docs/md037.md) for full documentation, configuration, and examples.
4use 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/// Check if an emphasis span has spacing issues that should be flagged
18#[inline]
19fn has_spacing_issues(span: &EmphasisSpan) -> bool {
20    span.has_leading_space || span.has_trailing_space
21}
22
23/// Truncate long text for display in warning messages
24/// Shows first ~30 and last ~30 chars with ellipsis in middle for readability
25#[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; // -2 for "..."
32    let suffix_len = max_len / 2 - 2;
33
34    // Use floor_char_boundary to safely find UTF-8 character boundaries
35    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/// Rule MD037: Spaces inside emphasis markers
42#[derive(Clone)]
43pub struct MD037NoSpaceInEmphasis;
44
45impl Default for MD037NoSpaceInEmphasis {
46    fn default() -> Self {
47        Self
48    }
49}
50
51impl MD037NoSpaceInEmphasis {
52    /// Check if a byte position is within a link (inline links, reference links, or reference definitions)
53    fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
54        // Check inline and reference links
55        for link in &ctx.links {
56            if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
57                return true;
58            }
59        }
60
61        // Check images (which use similar syntax)
62        for image in &ctx.images {
63            if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
64                return true;
65            }
66        }
67
68        // Check reference definitions [ref]: url "title" using pre-computed data (O(1) vs O(n))
69        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        // Early return: if no emphasis markers at all, skip processing
87        if !content.contains('*') && !content.contains('_') {
88            return Ok(vec![]);
89        }
90
91        // Create LineIndex for correct byte position calculations across all line ending types
92        let line_index = &ctx.line_index;
93
94        let mut warnings = Vec::new();
95
96        // Process content lines, automatically skipping front matter, code blocks, and math blocks
97        // Math blocks contain LaTeX syntax where _ and * have special meaning
98        for line in ctx
99            .filtered_lines()
100            .skip_front_matter()
101            .skip_code_blocks()
102            .skip_math_blocks()
103        {
104            // Skip if the line doesn't contain any emphasis markers
105            if !line.content.contains('*') && !line.content.contains('_') {
106                continue;
107            }
108
109            // Check for emphasis issues on the original line
110            self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
111        }
112
113        // Filter out warnings for emphasis markers that are inside links, HTML comments, math, or MkDocs markup
114        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            // Find warnings for this line
122            for warning in &warnings {
123                if warning.line == line_num {
124                    // Calculate byte position of the warning
125                    let byte_pos = line_start_pos + (warning.column - 1);
126                    // Calculate position within the line (0-indexed)
127                    let line_pos = warning.column - 1;
128
129                    // Skip if inside links, HTML comments, math contexts, tables, code spans, MDX constructs, or MkDocs markup
130                    // Note: is_in_code_span uses pulldown-cmark and correctly handles multi-line spans
131                    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                            // Convert line-relative range to absolute range
143                            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        // Fast path: if no emphasis markers, return unchanged
161        if !content.contains('*') && !content.contains('_') {
162            return Ok(content.to_string());
163        }
164
165        // First check for issues and get all warnings with fixes
166        let warnings = self.check(ctx)?;
167
168        // If no warnings, return original content
169        if warnings.is_empty() {
170            return Ok(content.to_string());
171        }
172
173        // Apply fixes
174        let mut result = content.to_string();
175        let mut offset: isize = 0;
176
177        // Sort warnings by position to apply fixes in the correct order
178        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                // Apply fix with offset adjustment
184                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                // Make sure we're not out of bounds
188                if actual_start < result.len() && actual_end <= result.len() {
189                    // Replace the text
190                    result.replace_range(actual_start..actual_end, &fix.replacement);
191                    // Update offset for future replacements
192                    offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
193                }
194            }
195        }
196
197        Ok(result)
198    }
199
200    /// Get the category of this rule for selective processing
201    fn category(&self) -> RuleCategory {
202        RuleCategory::Emphasis
203    }
204
205    /// Check if this rule should be skipped
206    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    /// Optimized line checking for emphasis spacing issues
224    #[inline]
225    fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
226        // Quick documentation pattern checks
227        if has_doc_patterns(line) {
228            return;
229        }
230
231        // Optimized list detection with fast path
232        // When a list marker is detected, ALWAYS check only the content after the marker,
233        // never the full line. This prevents the list marker (* + -) from being mistaken
234        // for emphasis markers.
235        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                    // Always check just the remaining content (after the list marker).
246                    // The list marker itself is never emphasis.
247                    self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
248                }
249            }
250            return;
251        }
252
253        // Check the entire line
254        self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
255    }
256
257    /// Optimized line content checking for emphasis issues
258    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        // Replace inline code and inline math to avoid false positives
266        // with emphasis markers inside backticks or dollar signs
267        let processed_content = replace_inline_code(content);
268        let processed_content = replace_inline_math(&processed_content);
269
270        // Find all emphasis markers using optimized parsing
271        let markers = find_emphasis_markers(&processed_content);
272        if markers.is_empty() {
273            return;
274        }
275
276        // Find valid emphasis spans
277        let spans = find_emphasis_spans(&processed_content, markers);
278
279        // Check each span for spacing issues
280        for span in spans {
281            if has_spacing_issues(&span) {
282                // Calculate the full span including markers
283                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                // Skip if this emphasis has a Kramdown span IAL immediately after it
288                // (no space between emphasis and IAL)
289                if full_end < content.len() {
290                    let remaining = &content[full_end..];
291                    // Check if IAL starts immediately after the emphasis (no whitespace)
292                    if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
293                        continue;
294                    }
295                }
296
297                // Create the marker string efficiently
298                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                // Create the fixed version by trimming spaces from content
306                let trimmed_content = span.content.trim();
307                let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
308
309                // Truncate long emphasis spans for readable warning messages
310                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, // +1 because columns are 1-indexed
317                    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); // *, *, **, **
341
342        let markers = find_emphasis_markers("*start* and *end*");
343        assert_eq!(markers.len(), 4); // *, *, *, *
344    }
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        // Test with no spaces inside emphasis - should pass
368        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        // Test with actual spaces inside emphasis - use content that should warn
374        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        // Test with code blocks - emphasis in code should be ignored
380        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        // Test passed - emphasis inside links are filtered out correctly
399
400        // Only the real emphasis outside links should be flagged
401        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        // Should flag "* real spaced emphasis *" but not emphasis patterns inside links
409        assert!(result[0].line == 3); // Line with "* real spaced emphasis *"
410    }
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        // Only the actual emphasis outside links should be flagged
422        assert_eq!(result.len(), 1);
423        assert!(result[0].message.contains("Spaces inside emphasis markers"));
424        // Should be the "* real spaced *" text on line 1
425        assert!(result[0].line == 1);
426    }
427
428    #[test]
429    fn test_issue_49_asterisk_in_inline_code() {
430        // Test for issue #49 - Asterisk within backticks identified as for emphasis
431        let rule = MD037NoSpaceInEmphasis;
432
433        // Test case from issue #49
434        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        // Test for issue #28 - MD037 should not flag inline code inside emphasis as spaces
446        let rule = MD037NoSpaceInEmphasis;
447
448        // Test case 1: inline code with single backticks inside bold emphasis
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.";
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        // Test case 2: multiple inline code snippets inside emphasis
458        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        // Test case 3: inline code with underscores for emphasis
467        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        // Test case 4: single asterisk emphasis with inline code
476        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        // Test case 5: actual spaces that should be flagged
485        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        // Regression test: ensure multi-byte UTF-8 characters don't cause panics
495        // in the truncate_for_display function when handling long emphasis spans.
496        // These test cases include various scripts that could trigger boundary issues.
497        let rule = MD037NoSpaceInEmphasis;
498
499        // Greek text with emphasis
500        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        // Chinese text with emphasis
506        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        // Cyrillic/Russian text with emphasis
512        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        // Mixed multi-byte characters in a long emphasis span that triggers truncation
518        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        // Arabic text (right-to-left) with emphasis
525        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        // Emoji with emphasis
531        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        // Test for FastAPI/MkDocs style template syntax {* ... *}
540        // These should NOT be flagged as emphasis with spaces
541        let rule = MD037NoSpaceInEmphasis;
542
543        // FastAPI style code inclusion
544        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        // Another FastAPI example
553        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        // Multiple shortcodes on different lines
562        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        // But actual emphasis with spaces should still be flagged
571        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        // Test for multi-line code spans - asterisks inside should not be flagged
580        // This tests the case where a code span starts on one line and ends on another
581        let rule = MD037NoSpaceInEmphasis;
582
583        // Code span spanning multiple lines with asterisks inside
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";
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        // Another multi-line code span case
593        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        // Test that MkDocs icon shortcodes with asterisks inside are not flagged
605        let rule = MD037NoSpaceInEmphasis;
606
607        // Icon shortcode syntax like :material-star: should not trigger MD037
608        // because it's valid MkDocs Material syntax
609        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        // Actual emphasis with spaces should still be flagged even in MkDocs mode
618        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        // Test that PyMdown extension markup is not flagged as emphasis issues
630        let rule = MD037NoSpaceInEmphasis;
631
632        // Keys notation (++ctrl+alt+delete++)
633        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        // Mark notation (==highlighted==)
642        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        // Insert notation (^^inserted^^)
651        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        // Mixed content with real emphasis issue and PyMdown markup
660        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}