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