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