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, find_valid_emphasis_ranges, has_doc_patterns,
8    replace_inline_code, replace_inline_math,
9};
10use crate::utils::kramdown_utils::has_span_ial;
11use crate::utils::range_utils::byte_to_char_count;
12use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
13use crate::utils::skip_context::{
14    is_in_inline_html_code, is_in_jsx_expression, is_in_math_context, is_in_mdx_comment, is_in_mkdocs_markup,
15    is_in_table_cell,
16};
17
18/// Check if an emphasis span has spacing issues that should be flagged
19#[inline]
20fn has_spacing_issues(span: &EmphasisSpan) -> bool {
21    span.has_leading_space || span.has_trailing_space
22}
23
24/// Truncate long text for display in warning messages
25/// Shows first ~30 and last ~30 chars with ellipsis in middle for readability
26#[inline]
27fn truncate_for_display(text: &str, max_len: usize) -> String {
28    if text.len() <= max_len {
29        return text.to_string();
30    }
31
32    let prefix_len = max_len / 2 - 2; // -2 for "..."
33    let suffix_len = max_len / 2 - 2;
34
35    // Use floor_char_boundary to safely find UTF-8 character boundaries
36    let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
37    let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
38
39    format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
40}
41
42/// Rule MD037: Spaces inside emphasis markers
43#[derive(Clone)]
44pub struct MD037NoSpaceInEmphasis;
45
46impl Default for MD037NoSpaceInEmphasis {
47    fn default() -> Self {
48        Self
49    }
50}
51
52impl MD037NoSpaceInEmphasis {
53    /// Check if a byte position is within a link (inline links, reference links, or reference definitions)
54    fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
55        // Check inline and reference links
56        for link in &ctx.links {
57            if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
58                if link.is_reference && link.url.is_empty() {
59                    continue;
60                }
61                return true;
62            }
63        }
64
65        // Check images (which use similar syntax)
66        for image in &ctx.images {
67            if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
68                return true;
69            }
70        }
71
72        // Check reference definitions [ref]: url "title" using pre-computed data (O(1) vs O(n))
73        ctx.is_in_reference_def(byte_pos)
74    }
75}
76
77impl Rule for MD037NoSpaceInEmphasis {
78    fn name(&self) -> &'static str {
79        "MD037"
80    }
81
82    fn description(&self) -> &'static str {
83        "Spaces inside emphasis markers"
84    }
85
86    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
87        let content = ctx.content;
88        let _timer = crate::profiling::ScopedTimer::new("MD037_check");
89
90        // Early return: if no emphasis markers at all, skip processing
91        if !content.contains('*') && !content.contains('_') {
92            return Ok(vec![]);
93        }
94
95        // Create LineIndex for correct byte position calculations across all line ending types
96        let line_index = &ctx.line_index;
97
98        let mut warnings = Vec::new();
99
100        // Process content lines, automatically skipping front matter, code blocks, math blocks,
101        // and Obsidian comments (when in Obsidian flavor)
102        // Math blocks contain LaTeX syntax where _ and * have special meaning
103        for line in ctx
104            .filtered_lines()
105            .skip_front_matter()
106            .skip_code_blocks()
107            .skip_math_blocks()
108            .skip_html_blocks()
109            .skip_jsx_expressions()
110            .skip_mdx_comments()
111            .skip_obsidian_comments()
112            .skip_mkdocstrings()
113        {
114            // Skip if the line doesn't contain any emphasis markers
115            if !line.content.contains('*') && !line.content.contains('_') {
116                continue;
117            }
118
119            // Check for emphasis issues on the original line
120            self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
121        }
122
123        // Filter out warnings for emphasis markers that are inside links, HTML comments, math, or MkDocs markup
124        let mut filtered_warnings = Vec::new();
125        let lines = ctx.raw_lines();
126
127        for (line_idx, line) in lines.iter().enumerate() {
128            let line_num = line_idx + 1;
129            let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
130
131            // Find warnings for this line
132            for warning in &warnings {
133                if warning.line == line_num {
134                    // `warning.column` is a byte offset within the line at this stage.
135                    // Derive the byte position (for byte-based skip checks) and the
136                    // character column (for char-based checks and the emitted warning).
137                    let byte_pos = line_start_pos + (warning.column - 1);
138                    // Calculate byte position within the line (0-indexed)
139                    let line_pos = warning.column - 1;
140                    let char_col = byte_to_char_count(line, warning.column - 1);
141
142                    // Skip if inside links, HTML comments, math contexts, tables, code spans, MDX constructs, or MkDocs markup
143                    // Note: is_in_code_span uses pulldown-cmark and correctly handles multi-line spans
144                    // Pandoc bracketed spans `[text]{.class}` may contain spaced
145                    // emphasis markers as literal content; suppress MD037 there.
146                    // Subscripts/superscripts cannot contain whitespace per the
147                    // detector grammar, so MD037's spaced-emphasis warnings can
148                    // never land inside one.
149                    let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos);
150                    if !in_pandoc_construct
151                        && !self.is_in_link(ctx, byte_pos)
152                        && !ctx.is_in_html_comment(byte_pos)
153                        && !is_in_math_context(ctx, byte_pos)
154                        && !is_in_table_cell(ctx, line_num, char_col)
155                        && !ctx.is_in_code_span(line_num, char_col)
156                        && !is_in_inline_html_code(line, line_pos)
157                        && !is_in_jsx_expression(ctx, byte_pos)
158                        && !is_in_mdx_comment(ctx, byte_pos)
159                        && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
160                        && !ctx.is_position_in_obsidian_comment(line_num, char_col)
161                    {
162                        let mut adjusted_warning = warning.clone();
163                        // Emit character-based columns (byte-based skip checks done above).
164                        adjusted_warning.column = char_col;
165                        adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
166                        if let Some(fix) = &mut adjusted_warning.fix {
167                            // Convert line-relative range to absolute range
168                            let abs_start = line_start_pos + fix.range.start;
169                            let abs_end = line_start_pos + fix.range.end;
170                            fix.range = abs_start..abs_end;
171                        }
172                        filtered_warnings.push(adjusted_warning);
173                    }
174                }
175            }
176        }
177
178        Ok(filtered_warnings)
179    }
180
181    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
182        let content = ctx.content;
183        let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
184
185        // Fast path: if no emphasis markers, return unchanged
186        if !content.contains('*') && !content.contains('_') {
187            return Ok(content.to_string());
188        }
189
190        // First check for issues and get all warnings with fixes
191        let warnings = self.check(ctx)?;
192        let warnings =
193            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
194
195        // If no warnings, return original content
196        if warnings.is_empty() {
197            return Ok(content.to_string());
198        }
199
200        // Apply fixes
201        let mut result = content.to_string();
202        let mut offset: isize = 0;
203
204        // Sort warnings by position to apply fixes in the correct order
205        let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
206        sorted_warnings.sort_by_key(|w| (w.line, w.column));
207
208        for warning in sorted_warnings {
209            if let Some(fix) = &warning.fix {
210                // Apply fix with offset adjustment
211                let actual_start = (fix.range.start as isize + offset) as usize;
212                let actual_end = (fix.range.end as isize + offset) as usize;
213
214                // Make sure we're not out of bounds
215                if actual_start < result.len() && actual_end <= result.len() {
216                    // Replace the text
217                    result.replace_range(actual_start..actual_end, &fix.replacement);
218                    // Update offset for future replacements
219                    offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
220                }
221            }
222        }
223
224        Ok(result)
225    }
226
227    /// Get the category of this rule for selective processing
228    fn category(&self) -> RuleCategory {
229        RuleCategory::Emphasis
230    }
231
232    /// Check if this rule should be skipped
233    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
234        ctx.content.is_empty() || !ctx.likely_has_emphasis()
235    }
236
237    fn as_any(&self) -> &dyn std::any::Any {
238        self
239    }
240
241    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
242    where
243        Self: Sized,
244    {
245        Box::new(MD037NoSpaceInEmphasis)
246    }
247}
248
249impl MD037NoSpaceInEmphasis {
250    /// Optimized line checking for emphasis spacing issues
251    #[inline]
252    fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
253        // Quick documentation pattern checks
254        if has_doc_patterns(line) {
255            return;
256        }
257
258        // Optimized list detection with fast path
259        // When a list marker is detected, ALWAYS check only the content after the marker,
260        // never the full line. This prevents the list marker (* + -) from being mistaken
261        // for emphasis markers.
262        if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
263            && UNORDERED_LIST_MARKER_REGEX.is_match(line)
264        {
265            if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
266                && let Some(full_match) = caps.get(0)
267            {
268                let list_marker_end = full_match.end();
269                if list_marker_end < line.len() {
270                    let remaining_content = &line[list_marker_end..];
271
272                    // Always check just the remaining content (after the list marker).
273                    // The list marker itself is never emphasis.
274                    self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
275                }
276            }
277            return;
278        }
279
280        // Check the entire line
281        self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
282    }
283
284    /// Optimized line content checking for emphasis issues
285    fn check_line_content_for_emphasis_fast(
286        &self,
287        content: &str,
288        line_num: usize,
289        offset: usize,
290        warnings: &mut Vec<LintWarning>,
291    ) {
292        // Replace inline code and inline math to avoid false positives
293        // with emphasis markers inside backticks or dollar signs
294        let processed_content = replace_inline_code(content);
295        let processed_content = replace_inline_math(&processed_content);
296
297        // Find all emphasis markers using optimized parsing
298        let markers = find_emphasis_markers(&processed_content);
299        if markers.is_empty() {
300            return;
301        }
302
303        // Find valid emphasis spans
304        let spans = find_emphasis_spans(&processed_content, &markers);
305
306        // Byte ranges that are genuine CommonMark emphasis. A spaced span that
307        // falls entirely inside one of these is not "spaces inside emphasis
308        // markers" but the interior of valid emphasis containing a literal
309        // marker (e.g. `*foo * bar*`), which the greedy finder above mispairs.
310        let valid_ranges = find_valid_emphasis_ranges(&processed_content, &markers);
311
312        // Check each span for spacing issues
313        for span in spans {
314            if has_spacing_issues(&span) {
315                let full_start = span.opening.start_pos;
316                let full_end = span.closing.end_pos();
317
318                // Suppress when the spaced run is contained in valid emphasis.
319                if valid_ranges
320                    .iter()
321                    .any(|&(start, end)| start <= full_start && full_end <= end)
322                {
323                    continue;
324                }
325
326                let full_text = &content[full_start..full_end];
327
328                // Skip if this emphasis has a Kramdown span IAL immediately after it
329                // (no space between emphasis and IAL)
330                if full_end < content.len() {
331                    let remaining = &content[full_end..];
332                    // Check if IAL starts immediately after the emphasis (no whitespace)
333                    if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
334                        continue;
335                    }
336                }
337
338                // Create the marker string efficiently
339                let marker_char = span.opening.as_char();
340                let marker_str = if span.opening.count == 1 {
341                    marker_char.to_string()
342                } else {
343                    format!("{marker_char}{marker_char}")
344                };
345
346                // Create the fixed version by trimming spaces from content.
347                // Slice the content from the *original* line, not from the
348                // code/math-masked copy: `span.content` would contain the 'X'/'M'
349                // placeholders, which must never leak into the generated fix.
350                // Masking is length-preserving, so the span byte offsets are
351                // valid in `content`.
352                let original_content = &content[span.opening.end_pos()..span.closing.start_pos];
353                let trimmed_content = original_content.trim();
354                let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
355
356                // Truncate long emphasis spans for readable warning messages
357                let display_text = truncate_for_display(full_text, 60);
358
359                let warning = LintWarning {
360                    rule_name: Some(self.name().to_string()),
361                    message: format!("Spaces inside emphasis markers: {display_text:?}"),
362                    // Byte-based columns within the line. The filter pass below relies
363                    // on `column` being a byte offset for its skip checks, then converts
364                    // the emitted columns to character offsets.
365                    line: line_num,
366                    column: offset + full_start + 1,
367                    end_line: line_num,
368                    end_column: offset + full_end + 1,
369                    severity: Severity::Warning,
370                    fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
371                };
372
373                warnings.push(warning);
374            }
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::lint_context::LintContext;
383
384    #[test]
385    fn test_emphasis_marker_parsing() {
386        let markers = find_emphasis_markers("This has *single* and **double** emphasis");
387        assert_eq!(markers.len(), 4); // *, *, **, **
388
389        let markers = find_emphasis_markers("*start* and *end*");
390        assert_eq!(markers.len(), 4); // *, *, *, *
391    }
392
393    #[test]
394    fn test_emphasis_span_detection() {
395        let markers = find_emphasis_markers("This has *valid* emphasis");
396        let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
397        assert_eq!(spans.len(), 1);
398        assert_eq!(spans[0].content, "valid");
399        assert!(!spans[0].has_leading_space);
400        assert!(!spans[0].has_trailing_space);
401
402        let markers = find_emphasis_markers("This has * invalid * emphasis");
403        let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
404        assert_eq!(spans.len(), 1);
405        assert_eq!(spans[0].content, " invalid ");
406        assert!(spans[0].has_leading_space);
407        assert!(spans[0].has_trailing_space);
408    }
409
410    #[test]
411    fn test_with_document_structure() {
412        let rule = MD037NoSpaceInEmphasis;
413
414        // Test with no spaces inside emphasis - should pass
415        let content = "This is *correct* emphasis and **strong emphasis**";
416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417        let result = rule.check(&ctx).unwrap();
418        assert!(result.is_empty(), "No warnings expected for correct emphasis");
419
420        // Test with actual spaces inside emphasis - use content that should warn
421        let content = "This is * text with spaces * and more content";
422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423        let result = rule.check(&ctx).unwrap();
424        assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
425
426        // Test with code blocks - emphasis in code should be ignored
427        let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429        let result = rule.check(&ctx).unwrap();
430        assert!(
431            !result.is_empty(),
432            "Expected warnings for spaces in emphasis outside code block"
433        );
434    }
435
436    #[test]
437    fn test_inline_code_inside_spaced_emphasis_preserved_on_fix() {
438        // Regression test: inline code inside a spaced emphasis span must survive the
439        // space-trimming fix. Previously the 'X' masking placeholder leaked into
440        // the fix output, destroying the code span.
441        let rule = MD037NoSpaceInEmphasis;
442        let content = "Set * the `id` field * below.";
443        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444        let fixed = rule.fix(&ctx).unwrap();
445        assert_eq!(fixed, "Set *the `id` field* below.");
446        assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
447    }
448
449    #[test]
450    fn test_emphasis_in_links_not_flagged() {
451        let rule = MD037NoSpaceInEmphasis;
452        let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
453
454This has * real spaced emphasis * that should be flagged."#;
455        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457
458        // Test passed - emphasis inside links are filtered out correctly
459
460        // Only the real emphasis outside links should be flagged
461        assert_eq!(
462            result.len(),
463            1,
464            "Expected exactly 1 warning, but got: {:?}",
465            result.len()
466        );
467        assert!(result[0].message.contains("Spaces inside emphasis markers"));
468        // Should flag "* real spaced emphasis *" but not emphasis patterns inside links
469        assert!(result[0].line == 3); // Line with "* real spaced emphasis *"
470    }
471
472    #[test]
473    fn test_emphasis_in_links_vs_outside_links() {
474        let rule = MD037NoSpaceInEmphasis;
475        let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
476
477[* link *]: https://example.com/*path*"#;
478        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480
481        // Only the actual emphasis outside links should be flagged
482        assert_eq!(result.len(), 1);
483        assert!(result[0].message.contains("Spaces inside emphasis markers"));
484        // Should be the "* real spaced *" text on line 1
485        assert!(result[0].line == 1);
486    }
487
488    #[test]
489    fn test_issue_49_asterisk_in_inline_code() {
490        // Test for issue #49 - Asterisk within backticks identified as for emphasis
491        let rule = MD037NoSpaceInEmphasis;
492
493        // Test case from issue #49
494        let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let result = rule.check(&ctx).unwrap();
497        assert!(
498            result.is_empty(),
499            "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
500        );
501    }
502
503    #[test]
504    fn test_issue_28_inline_code_in_emphasis() {
505        // Test for issue #28 - MD037 should not flag inline code inside emphasis as spaces
506        let rule = MD037NoSpaceInEmphasis;
507
508        // Test case 1: inline code with single backticks inside bold emphasis
509        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.";
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511        let result = rule.check(&ctx).unwrap();
512        assert!(
513            result.is_empty(),
514            "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
515        );
516
517        // Test case 2: multiple inline code snippets inside emphasis
518        let content2 = "The **`foo` and `bar`** methods are important.";
519        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
520        let result2 = rule.check(&ctx2).unwrap();
521        assert!(
522            result2.is_empty(),
523            "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
524        );
525
526        // Test case 3: inline code with underscores for emphasis
527        let content3 = "This is __inline `code`__ with underscores.";
528        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
529        let result3 = rule.check(&ctx3).unwrap();
530        assert!(
531            result3.is_empty(),
532            "Should not flag inline code with underscore emphasis. Got: {result3:?}"
533        );
534
535        // Test case 4: single asterisk emphasis with inline code
536        let content4 = "This is *inline `test`* with single asterisks.";
537        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
538        let result4 = rule.check(&ctx4).unwrap();
539        assert!(
540            result4.is_empty(),
541            "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
542        );
543
544        // Test case 5: actual spaces that should be flagged
545        let content5 = "This has * real spaces * that should be flagged.";
546        let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
547        let result5 = rule.check(&ctx5).unwrap();
548        assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
549        assert!(result5[0].message.contains("Spaces inside emphasis markers"));
550    }
551
552    #[test]
553    fn test_multibyte_utf8_no_panic() {
554        // Regression test: ensure multi-byte UTF-8 characters don't cause panics
555        // in the truncate_for_display function when handling long emphasis spans.
556        // These test cases include various scripts that could trigger boundary issues.
557        let rule = MD037NoSpaceInEmphasis;
558
559        // Greek text with emphasis
560        let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
561        let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
562        let result = rule.check(&ctx);
563        assert!(result.is_ok(), "Greek text should not panic");
564
565        // Chinese text with emphasis
566        let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
567        let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
568        let result = rule.check(&ctx);
569        assert!(result.is_ok(), "Chinese text should not panic");
570
571        // Cyrillic/Russian text with emphasis
572        let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
573        let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
574        let result = rule.check(&ctx);
575        assert!(result.is_ok(), "Cyrillic text should not panic");
576
577        // Mixed multi-byte characters in a long emphasis span that triggers truncation
578        let mixed =
579            "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
580        let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
581        let result = rule.check(&ctx);
582        assert!(result.is_ok(), "Mixed CJK text should not panic");
583
584        // Arabic text (right-to-left) with emphasis
585        let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
586        let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx);
588        assert!(result.is_ok(), "Arabic text should not panic");
589
590        // Emoji with emphasis
591        let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
592        let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
593        let result = rule.check(&ctx);
594        assert!(result.is_ok(), "Emoji text should not panic");
595    }
596
597    #[test]
598    fn test_template_shortcode_syntax_not_flagged() {
599        // Test for FastAPI/MkDocs style template syntax {* ... *}
600        // These should NOT be flagged as emphasis with spaces
601        let rule = MD037NoSpaceInEmphasis;
602
603        // FastAPI style code inclusion
604        let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606        let result = rule.check(&ctx).unwrap();
607        assert!(
608            result.is_empty(),
609            "Template shortcode syntax should not be flagged. Got: {result:?}"
610        );
611
612        // Another FastAPI example
613        let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615        let result = rule.check(&ctx).unwrap();
616        assert!(
617            result.is_empty(),
618            "Template shortcode syntax should not be flagged. Got: {result:?}"
619        );
620
621        // Multiple shortcodes on different lines
622        let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624        let result = rule.check(&ctx).unwrap();
625        assert!(
626            result.is_empty(),
627            "Multiple template shortcodes should not be flagged. Got: {result:?}"
628        );
629
630        // But actual emphasis with spaces should still be flagged
631        let content = "This has * real spaced emphasis * here.";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let result = rule.check(&ctx).unwrap();
634        assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
635    }
636
637    #[test]
638    fn test_multiline_code_span_not_flagged() {
639        // Test for multi-line code spans - asterisks inside should not be flagged
640        // This tests the case where a code span starts on one line and ends on another
641        let rule = MD037NoSpaceInEmphasis;
642
643        // Code span spanning multiple lines with asterisks inside
644        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";
645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646        let result = rule.check(&ctx).unwrap();
647        assert!(
648            result.is_empty(),
649            "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
650        );
651
652        // Another multi-line code span case
653        let content2 = "Text with `code that\nspans * multiple * lines` here.";
654        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
655        let result2 = rule.check(&ctx2).unwrap();
656        assert!(
657            result2.is_empty(),
658            "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
659        );
660    }
661
662    #[test]
663    fn test_html_block_asterisks_not_flagged() {
664        let rule = MD037NoSpaceInEmphasis;
665
666        // Asterisks used as multiplication inside HTML <code> tags within an HTML table
667        let content = r#"<table>
668<tr><td>Format</td><td>Size</td></tr>
669<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
670<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
671</table>"#;
672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673        let result = rule.check(&ctx).unwrap();
674        assert!(
675            result.is_empty(),
676            "Should not flag asterisks inside HTML blocks. Got: {result:?}"
677        );
678
679        // Standalone HTML block with emphasis-like patterns
680        let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
681        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
682        let result2 = rule.check(&ctx2).unwrap();
683        assert!(
684            result2.is_empty(),
685            "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
686        );
687
688        // Regular markdown with spaced emphasis should still be flagged
689        let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
690        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
691        let result3 = rule.check(&ctx3).unwrap();
692        assert_eq!(
693            result3.len(),
694            1,
695            "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
696        );
697        assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
698    }
699
700    #[test]
701    fn test_mkdocs_icon_shortcode_not_flagged() {
702        // Test that MkDocs icon shortcodes with asterisks inside are not flagged
703        let rule = MD037NoSpaceInEmphasis;
704
705        // Icon shortcode syntax like :material-star: should not trigger MD037
706        // because it's valid MkDocs Material syntax
707        let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
709        let result = rule.check(&ctx).unwrap();
710        assert!(
711            result.is_empty(),
712            "Should not flag MkDocs icon shortcodes. Got: {result:?}"
713        );
714
715        // Actual emphasis with spaces should still be flagged even in MkDocs mode
716        let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
717        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
718        let result2 = rule.check(&ctx2).unwrap();
719        assert!(
720            !result2.is_empty(),
721            "Should still flag real spaced emphasis in MkDocs mode"
722        );
723    }
724
725    #[test]
726    fn test_mkdocs_pymdown_markup_not_flagged() {
727        // Test that PyMdown extension markup is not flagged as emphasis issues
728        let rule = MD037NoSpaceInEmphasis;
729
730        // Keys notation (++ctrl+alt+delete++)
731        let content = "Press ++ctrl+c++ to copy.";
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
733        let result = rule.check(&ctx).unwrap();
734        assert!(
735            result.is_empty(),
736            "Should not flag PyMdown Keys notation. Got: {result:?}"
737        );
738
739        // Mark notation (==highlighted==)
740        let content2 = "This is ==highlighted text== for emphasis.";
741        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
742        let result2 = rule.check(&ctx2).unwrap();
743        assert!(
744            result2.is_empty(),
745            "Should not flag PyMdown Mark notation. Got: {result2:?}"
746        );
747
748        // Insert notation (^^inserted^^)
749        let content3 = "This is ^^inserted text^^ here.";
750        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
751        let result3 = rule.check(&ctx3).unwrap();
752        assert!(
753            result3.is_empty(),
754            "Should not flag PyMdown Insert notation. Got: {result3:?}"
755        );
756
757        // Mixed content with real emphasis issue and PyMdown markup
758        let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
759        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
760        let result4 = rule.check(&ctx4).unwrap();
761        assert!(
762            !result4.is_empty(),
763            "Should still flag real spaced emphasis alongside PyMdown markup"
764        );
765    }
766
767    // ==================== Obsidian highlight tests ====================
768
769    #[test]
770    fn test_obsidian_highlight_not_flagged() {
771        // Test that Obsidian highlight syntax (==text==) is not flagged as emphasis
772        let rule = MD037NoSpaceInEmphasis;
773
774        // Simple highlight
775        let content = "This is ==highlighted text== here.";
776        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
777        let result = rule.check(&ctx).unwrap();
778        assert!(
779            result.is_empty(),
780            "Should not flag Obsidian highlight syntax. Got: {result:?}"
781        );
782    }
783
784    #[test]
785    fn test_obsidian_highlight_multiple_on_line() {
786        // Multiple highlights on one line
787        let rule = MD037NoSpaceInEmphasis;
788
789        let content = "Both ==one== and ==two== are highlighted.";
790        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
791        let result = rule.check(&ctx).unwrap();
792        assert!(
793            result.is_empty(),
794            "Should not flag multiple Obsidian highlights. Got: {result:?}"
795        );
796    }
797
798    #[test]
799    fn test_obsidian_highlight_entire_paragraph() {
800        // Entire paragraph highlighted
801        let rule = MD037NoSpaceInEmphasis;
802
803        let content = "==Entire paragraph highlighted==";
804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
805        let result = rule.check(&ctx).unwrap();
806        assert!(
807            result.is_empty(),
808            "Should not flag entire highlighted paragraph. Got: {result:?}"
809        );
810    }
811
812    #[test]
813    fn test_obsidian_highlight_with_emphasis() {
814        // Highlights nested with other emphasis
815        let rule = MD037NoSpaceInEmphasis;
816
817        // Bold highlight
818        let content = "**==bold highlight==**";
819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
820        let result = rule.check(&ctx).unwrap();
821        assert!(
822            result.is_empty(),
823            "Should not flag bold highlight combination. Got: {result:?}"
824        );
825
826        // Italic highlight
827        let content2 = "*==italic highlight==*";
828        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
829        let result2 = rule.check(&ctx2).unwrap();
830        assert!(
831            result2.is_empty(),
832            "Should not flag italic highlight combination. Got: {result2:?}"
833        );
834    }
835
836    #[test]
837    fn test_obsidian_highlight_in_lists() {
838        // Highlights in list items
839        let rule = MD037NoSpaceInEmphasis;
840
841        let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
843        let result = rule.check(&ctx).unwrap();
844        assert!(
845            result.is_empty(),
846            "Should not flag highlights in list items. Got: {result:?}"
847        );
848    }
849
850    #[test]
851    fn test_obsidian_highlight_in_blockquote() {
852        // Highlights in blockquotes
853        let rule = MD037NoSpaceInEmphasis;
854
855        let content = "> This quote has ==highlighted== text.";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
857        let result = rule.check(&ctx).unwrap();
858        assert!(
859            result.is_empty(),
860            "Should not flag highlights in blockquotes. Got: {result:?}"
861        );
862    }
863
864    #[test]
865    fn test_obsidian_highlight_in_tables() {
866        // Highlights in tables
867        let rule = MD037NoSpaceInEmphasis;
868
869        let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
871        let result = rule.check(&ctx).unwrap();
872        assert!(
873            result.is_empty(),
874            "Should not flag highlights in tables. Got: {result:?}"
875        );
876    }
877
878    #[test]
879    fn test_obsidian_highlight_in_code_blocks_ignored() {
880        // Highlights inside code blocks should be ignored (they're in code)
881        let rule = MD037NoSpaceInEmphasis;
882
883        let content = "```\n==not highlight in code==\n```";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
885        let result = rule.check(&ctx).unwrap();
886        assert!(
887            result.is_empty(),
888            "Should ignore highlights in code blocks. Got: {result:?}"
889        );
890    }
891
892    #[test]
893    fn test_obsidian_highlight_edge_case_three_equals() {
894        // Three equals signs (===) should not be treated as highlight
895        let rule = MD037NoSpaceInEmphasis;
896
897        // This is not valid highlight syntax
898        let content = "Test === something === here";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
900        let result = rule.check(&ctx).unwrap();
901        // This may or may not generate warnings depending on if it looks like emphasis
902        // The key is it shouldn't crash and should be handled gracefully
903        let _ = result;
904    }
905
906    #[test]
907    fn test_obsidian_highlight_edge_case_four_equals() {
908        // Four equals signs (====) - empty highlight
909        let rule = MD037NoSpaceInEmphasis;
910
911        let content = "Test ==== here";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
913        let result = rule.check(&ctx).unwrap();
914        // Empty highlights should not match as valid highlights
915        let _ = result;
916    }
917
918    #[test]
919    fn test_obsidian_highlight_adjacent() {
920        // Adjacent highlights
921        let rule = MD037NoSpaceInEmphasis;
922
923        let content = "==one====two==";
924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
925        let result = rule.check(&ctx).unwrap();
926        // Should handle adjacent highlights gracefully
927        let _ = result;
928    }
929
930    #[test]
931    fn test_obsidian_highlight_with_special_chars() {
932        // Highlights with special characters inside
933        let rule = MD037NoSpaceInEmphasis;
934
935        // Highlight with backtick inside
936        let content = "Test ==code: `test`== here";
937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
938        let result = rule.check(&ctx).unwrap();
939        // Should handle gracefully
940        let _ = result;
941    }
942
943    #[test]
944    fn test_obsidian_highlight_unclosed() {
945        // Unclosed highlight should not cause issues
946        let rule = MD037NoSpaceInEmphasis;
947
948        let content = "This ==starts but never ends";
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
950        let result = rule.check(&ctx).unwrap();
951        // Unclosed highlight should not match anything special
952        let _ = result;
953    }
954
955    #[test]
956    fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
957        // Real emphasis issues should still be flagged in Obsidian mode
958        let rule = MD037NoSpaceInEmphasis;
959
960        let content = "This has * spaced emphasis * and ==valid highlight==";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
962        let result = rule.check(&ctx).unwrap();
963        assert!(
964            !result.is_empty(),
965            "Should still flag real spaced emphasis in Obsidian mode"
966        );
967        assert!(
968            result.len() == 1,
969            "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
970        );
971    }
972
973    #[test]
974    fn test_standard_flavor_does_not_recognize_highlight() {
975        // Standard flavor should NOT recognize ==highlight== as special
976        // It may or may not flag it as emphasis depending on context
977        let rule = MD037NoSpaceInEmphasis;
978
979        let content = "This is ==highlighted text== here.";
980        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981        let result = rule.check(&ctx).unwrap();
982        // In standard flavor, == is not recognized as highlight syntax
983        // It won't be flagged as "spaces in emphasis" because == is not * or _
984        // The key is that standard flavor doesn't give special treatment to ==
985        let _ = result; // Just ensure it runs without error
986    }
987
988    #[test]
989    fn test_obsidian_highlight_mixed_with_regular_emphasis() {
990        // Mix of highlights and regular emphasis
991        let rule = MD037NoSpaceInEmphasis;
992
993        let content = "==highlighted== and *italic* and **bold** text";
994        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
995        let result = rule.check(&ctx).unwrap();
996        assert!(
997            result.is_empty(),
998            "Should not flag valid highlight and emphasis. Got: {result:?}"
999        );
1000    }
1001
1002    #[test]
1003    fn test_obsidian_highlight_unicode() {
1004        // Highlights with Unicode content
1005        let rule = MD037NoSpaceInEmphasis;
1006
1007        let content = "Text ==日本語 highlighted== here";
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1009        let result = rule.check(&ctx).unwrap();
1010        assert!(
1011            result.is_empty(),
1012            "Should handle Unicode in highlights. Got: {result:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_obsidian_highlight_with_html() {
1018        // Highlights inside HTML should be handled
1019        let rule = MD037NoSpaceInEmphasis;
1020
1021        let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
1022        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1023        let result = rule.check(&ctx).unwrap();
1024        // The highlight in HTML comment should be ignored, only the actual highlight is processed
1025        let _ = result;
1026    }
1027
1028    #[test]
1029    fn test_obsidian_inline_comment_emphasis_ignored() {
1030        // Emphasis inside Obsidian comments should be ignored
1031        let rule = MD037NoSpaceInEmphasis;
1032
1033        let content = "Visible %%* spaced emphasis *%% still visible.";
1034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1035        let result = rule.check(&ctx).unwrap();
1036
1037        assert!(
1038            result.is_empty(),
1039            "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
1040        );
1041    }
1042
1043    #[test]
1044    fn test_inline_html_code_not_flagged() {
1045        let rule = MD037NoSpaceInEmphasis;
1046
1047        // Asterisks used as multiplication inside inline <code> tags
1048        let content = "The formula is <code>a * b * c</code> in math.";
1049        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050        let result = rule.check(&ctx).unwrap();
1051        assert!(
1052            result.is_empty(),
1053            "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1054        );
1055
1056        // Multiple inline code-like tags on the same line
1057        let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1058        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1059        let result2 = rule.check(&ctx2).unwrap();
1060        assert!(
1061            result2.is_empty(),
1062            "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1063        );
1064
1065        // Code tag with attributes
1066        let content3 = r#"Result: <code class="math">a * b</code> done."#;
1067        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1068        let result3 = rule.check(&ctx3).unwrap();
1069        assert!(
1070            result3.is_empty(),
1071            "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1072        );
1073
1074        // Real emphasis on the same line as inline code should still be flagged
1075        let content4 = "Text * spaced * and <code>a * b</code>.";
1076        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1077        let result4 = rule.check(&ctx4).unwrap();
1078        assert_eq!(
1079            result4.len(),
1080            1,
1081            "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1082        );
1083        assert_eq!(result4[0].column, 6);
1084    }
1085
1086    /// Emphasis with spaces inside Pandoc bracketed spans should be suppressed under Pandoc,
1087    /// but regular emphasis-with-spaces outside bracketed spans must still be flagged.
1088    #[test]
1089    fn test_pandoc_bracketed_span_guard() {
1090        use crate::config::MarkdownFlavor;
1091        let rule = MD037NoSpaceInEmphasis;
1092        // Emphasis-like pattern inside a bracketed span (Pandoc construct)
1093        let content = "See [* important *]{.highlight} for details.\n";
1094        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1095        let result = rule.check(&ctx).unwrap();
1096        assert!(
1097            result.is_empty(),
1098            "MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
1099        );
1100
1101        // Outside Pandoc flavor, the same text should still be flagged
1102        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1103        let result_std = rule.check(&ctx_std).unwrap();
1104        assert!(
1105            !result_std.is_empty(),
1106            "MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_spaced_bold_metadata_pattern_detected() {
1112        let rule = MD037NoSpaceInEmphasis;
1113
1114        // Broken bold metadata — leading space after opening **
1115        let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117        let result = rule.check(&ctx).unwrap();
1118        assert_eq!(
1119            result.len(),
1120            1,
1121            "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1122        );
1123        assert_eq!(result[0].line, 3);
1124
1125        // Trailing space before closing **
1126        let content2 = "# Test\n\n**trailing only **: some text";
1127        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1128        let result2 = rule.check(&ctx2).unwrap();
1129        assert_eq!(
1130            result2.len(),
1131            1,
1132            "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1133        );
1134
1135        // Both leading and trailing spaces with colon
1136        let content3 = "# Test\n\n** both spaces **: some text";
1137        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1138        let result3 = rule.check(&ctx3).unwrap();
1139        assert_eq!(
1140            result3.len(),
1141            1,
1142            "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1143        );
1144
1145        // Valid bold metadata — should NOT be flagged
1146        let content4 = "# Test\n\n**Key**: value";
1147        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1148        let result4 = rule.check(&ctx4).unwrap();
1149        assert!(
1150            result4.is_empty(),
1151            "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1152        );
1153    }
1154}