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