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