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