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