Skip to main content

rumdl_lib/rules/
md037_spaces_around_emphasis.rs

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