Skip to main content

rumdl_lib/rules/
md037_spaces_around_emphasis.rs

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