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