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                        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        // Fast path: if no emphasis markers, return unchanged
154        if !content.contains('*') && !content.contains('_') {
155            return Ok(content.to_string());
156        }
157
158        // First check for issues and get all warnings with fixes
159        let warnings = self.check(ctx)?;
160
161        // If no warnings, return original content
162        if warnings.is_empty() {
163            return Ok(content.to_string());
164        }
165
166        // Create LineIndex for correct byte position calculations across all line ending types
167        let line_index = &ctx.line_index;
168
169        // Apply fixes
170        let mut result = content.to_string();
171        let mut offset: isize = 0;
172
173        // Sort warnings by position to apply fixes in the correct order
174        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                // Calculate the absolute position in the file
180                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                // Apply fix with offset adjustment
185                let actual_start = (abs_start as isize + offset) as usize;
186                let actual_end = (abs_end as isize + offset) as usize;
187
188                // Make sure we're not out of bounds
189                if actual_start < result.len() && actual_end <= result.len() {
190                    // Replace the text
191                    result.replace_range(actual_start..actual_end, &fix.replacement);
192                    // Update offset for future replacements
193                    offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
194                }
195            }
196        }
197
198        Ok(result)
199    }
200
201    /// Get the category of this rule for selective processing
202    fn category(&self) -> RuleCategory {
203        RuleCategory::Emphasis
204    }
205
206    /// Check if this rule should be skipped
207    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    /// Optimized line checking for emphasis spacing issues
225    #[inline]
226    fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
227        // Quick documentation pattern checks
228        if has_doc_patterns(line) {
229            return;
230        }
231
232        // Optimized list detection with fast path
233        // When a list marker is detected, ALWAYS check only the content after the marker,
234        // never the full line. This prevents the list marker (* + -) from being mistaken
235        // for emphasis markers.
236        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                    // Always check just the remaining content (after the list marker).
247                    // The list marker itself is never emphasis.
248                    self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
249                }
250            }
251            return;
252        }
253
254        // Check the entire line
255        self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
256    }
257
258    /// Optimized line content checking for emphasis issues
259    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        // Replace inline code and inline math to avoid false positives
267        // with emphasis markers inside backticks or dollar signs
268        let processed_content = replace_inline_code(content);
269        let processed_content = replace_inline_math(&processed_content);
270
271        // Find all emphasis markers using optimized parsing
272        let markers = find_emphasis_markers(&processed_content);
273        if markers.is_empty() {
274            return;
275        }
276
277        // Find valid emphasis spans
278        let spans = find_emphasis_spans(&processed_content, markers);
279
280        // Check each span for spacing issues
281        for span in spans {
282            if has_spacing_issues(&span) {
283                // Calculate the full span including markers
284                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                // Skip if this emphasis has a Kramdown span IAL immediately after it
289                // (no space between emphasis and IAL)
290                if full_end < content.len() {
291                    let remaining = &content[full_end..];
292                    // Check if IAL starts immediately after the emphasis (no whitespace)
293                    if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
294                        continue;
295                    }
296                }
297
298                // Create the marker string efficiently
299                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                // Create the fixed version by trimming spaces from content
307                let trimmed_content = span.content.trim();
308                let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
309
310                // Truncate long emphasis spans for readable warning messages
311                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, // +1 because columns are 1-indexed
318                    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); // *, *, **, **
342
343        let markers = find_emphasis_markers("*start* and *end*");
344        assert_eq!(markers.len(), 4); // *, *, *, *
345    }
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        // Test with no spaces inside emphasis - should pass
369        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        // Test with actual spaces inside emphasis - use content that should warn
375        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        // Test with code blocks - emphasis in code should be ignored
381        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        // Test passed - emphasis inside links are filtered out correctly
400
401        // Only the real emphasis outside links should be flagged
402        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        // Should flag "* real spaced emphasis *" but not emphasis patterns inside links
410        assert!(result[0].line == 3); // Line with "* real spaced emphasis *"
411    }
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        // Only the actual emphasis outside links should be flagged
423        assert_eq!(result.len(), 1);
424        assert!(result[0].message.contains("Spaces inside emphasis markers"));
425        // Should be the "* real spaced *" text on line 1
426        assert!(result[0].line == 1);
427    }
428
429    #[test]
430    fn test_issue_49_asterisk_in_inline_code() {
431        // Test for issue #49 - Asterisk within backticks identified as for emphasis
432        let rule = MD037NoSpaceInEmphasis;
433
434        // Test case from issue #49
435        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        // Test for issue #28 - MD037 should not flag inline code inside emphasis as spaces
447        let rule = MD037NoSpaceInEmphasis;
448
449        // Test case 1: inline code with single backticks inside bold emphasis
450        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        // Test case 2: multiple inline code snippets inside emphasis
459        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        // Test case 3: inline code with underscores for emphasis
468        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        // Test case 4: single asterisk emphasis with inline code
477        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        // Test case 5: actual spaces that should be flagged
486        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        // Regression test: ensure multi-byte UTF-8 characters don't cause panics
496        // in the truncate_for_display function when handling long emphasis spans.
497        // These test cases include various scripts that could trigger boundary issues.
498        let rule = MD037NoSpaceInEmphasis;
499
500        // Greek text with emphasis
501        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        // Chinese text with emphasis
507        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        // Cyrillic/Russian text with emphasis
513        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        // Mixed multi-byte characters in a long emphasis span that triggers truncation
519        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        // Arabic text (right-to-left) with emphasis
526        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        // Emoji with emphasis
532        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        // Test for FastAPI/MkDocs style template syntax {* ... *}
541        // These should NOT be flagged as emphasis with spaces
542        let rule = MD037NoSpaceInEmphasis;
543
544        // FastAPI style code inclusion
545        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        // Another FastAPI example
554        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        // Multiple shortcodes on different lines
563        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        // But actual emphasis with spaces should still be flagged
572        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        // Test for multi-line code spans - asterisks inside should not be flagged
581        // This tests the case where a code span starts on one line and ends on another
582        let rule = MD037NoSpaceInEmphasis;
583
584        // Code span spanning multiple lines with asterisks inside
585        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        // Another multi-line code span case
594        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        // Test that MkDocs icon shortcodes with asterisks inside are not flagged
606        let rule = MD037NoSpaceInEmphasis;
607
608        // Icon shortcode syntax like :material-star: should not trigger MD037
609        // because it's valid MkDocs Material syntax
610        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        // Actual emphasis with spaces should still be flagged even in MkDocs mode
619        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        // Test that PyMdown extension markup is not flagged as emphasis issues
631        let rule = MD037NoSpaceInEmphasis;
632
633        // Keys notation (++ctrl+alt+delete++)
634        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        // Mark notation (==highlighted==)
643        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        // Insert notation (^^inserted^^)
652        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        // Mixed content with real emphasis issue and PyMdown markup
661        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}