Skip to main content

rumdl_lib/utils/
emphasis_utils.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4// Better detection of inline code with support for multiple backticks
5static INLINE_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`+)([^`]|[^`].*?[^`])(`+)").unwrap());
6
7// Inline math pattern - matches both $...$ and $$...$$ syntax
8// The pattern allows zero or more characters between delimiters to handle empty math spans
9static INLINE_MATH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
10
11// Documentation style patterns
12static DOC_METADATA_PATTERN: LazyLock<Regex> =
13    LazyLock::new(|| Regex::new(r"^\s*\*?\s*\*\*(?:[^*\s][^*]*[^*\s]|[^*\s])\*\*\s*:").unwrap());
14
15// Bold text pattern (for preserving bold text in documentation) - only match valid bold without spaces
16static BOLD_TEXT_PATTERN: LazyLock<Regex> =
17    LazyLock::new(|| Regex::new(r"\*\*[^*\s][^*]*[^*\s]\*\*|\*\*[^*\s]\*\*").unwrap());
18
19// Pre-compiled patterns for quick checks
20static QUICK_DOC_CHECK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\*\s+\*").unwrap());
21static QUICK_BOLD_CHECK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*[^*\s]").unwrap());
22
23// Template/shortcode syntax pattern - {* ... *} used by documentation systems like FastAPI/MkDocs
24// These are not emphasis markers but template directives for code inclusion/highlighting
25static TEMPLATE_SHORTCODE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\*.*\*\}").unwrap());
26
27/// Represents an emphasis marker found in text
28#[derive(Debug, Clone, PartialEq)]
29pub struct EmphasisMarker {
30    pub marker_type: u8,  // b'*' or b'_' for faster comparison
31    pub count: u8,        // 1 for single, 2 for double
32    pub start_pos: usize, // Position in the line
33}
34
35impl EmphasisMarker {
36    #[inline]
37    pub fn end_pos(&self) -> usize {
38        self.start_pos + self.count as usize
39    }
40
41    #[inline]
42    pub fn as_char(&self) -> char {
43        self.marker_type as char
44    }
45}
46
47/// Represents a complete emphasis span
48#[derive(Debug, Clone)]
49pub struct EmphasisSpan {
50    pub opening: EmphasisMarker,
51    pub closing: EmphasisMarker,
52    pub content: String,
53    pub has_leading_space: bool,
54    pub has_trailing_space: bool,
55}
56
57/// Delimiter and spacing facts used by built-in rules, without copied text.
58pub(crate) struct EmphasisEdges {
59    pub(crate) opening: EmphasisMarker,
60    pub(crate) closing: EmphasisMarker,
61    pub(crate) has_leading_space: bool,
62    pub(crate) has_trailing_space: bool,
63}
64
65fn owned_span(
66    opening: &EmphasisMarker,
67    closing: &EmphasisMarker,
68    content: &str,
69    has_leading_space: bool,
70    has_trailing_space: bool,
71) -> EmphasisSpan {
72    EmphasisSpan {
73        opening: opening.clone(),
74        closing: closing.clone(),
75        content: content.to_owned(),
76        has_leading_space,
77        has_trailing_space,
78    }
79}
80
81fn span_edges(
82    opening: &EmphasisMarker,
83    closing: &EmphasisMarker,
84    _content: &str,
85    has_leading_space: bool,
86    has_trailing_space: bool,
87) -> EmphasisEdges {
88    EmphasisEdges {
89        opening: opening.clone(),
90        closing: closing.clone(),
91        has_leading_space,
92        has_trailing_space,
93    }
94}
95
96/// Enhanced inline code replacement with optimized performance
97/// Replaces inline code with 'X' characters to prevent false positives in emphasis detection
98#[inline]
99pub fn replace_inline_code(line: &str) -> String {
100    replace_inline_code_cow(line).into_owned()
101}
102
103/// Borrow literal source until a code span actually needs masking. Replacement
104/// is byte-for-byte identical to `replace_inline_code`, including its existing
105/// delimiter grammar and equal-length ASCII placeholders.
106pub(crate) fn replace_inline_code_cow(line: &str) -> std::borrow::Cow<'_, str> {
107    mask_matches(line, '`', &INLINE_CODE, b'X')
108}
109
110/// Replace inline math ($...$ and $$...$$) with placeholder characters.
111pub fn replace_inline_math(line: &str) -> String {
112    replace_inline_math_cow(line).into_owned()
113}
114
115pub(crate) fn replace_inline_math_cow(line: &str) -> std::borrow::Cow<'_, str> {
116    mask_matches(line, '$', &INLINE_MATH, b'M')
117}
118
119fn mask_matches<'a>(line: &'a str, signal: char, regex: &Regex, placeholder: u8) -> std::borrow::Cow<'a, str> {
120    if !line.contains(signal) {
121        return std::borrow::Cow::Borrowed(line);
122    }
123    let mut bytes = None;
124    for matched in regex.find_iter(line) {
125        bytes.get_or_insert_with(|| line.as_bytes().to_vec())[matched.range()].fill(placeholder);
126    }
127    match bytes {
128        Some(bytes) => std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII-only span masking")),
129        None => std::borrow::Cow::Borrowed(line),
130    }
131}
132
133/// Optimized emphasis marker parsing using byte iteration
134#[inline]
135pub fn find_emphasis_markers(line: &str) -> Vec<EmphasisMarker> {
136    let mut markers = Vec::new();
137    find_emphasis_markers_into(line, &mut markers);
138    markers
139}
140
141/// Append line-relative delimiters to a reusable document arena.
142pub(crate) fn find_emphasis_markers_into(line: &str, markers: &mut Vec<EmphasisMarker>) {
143    // Early return for lines without emphasis markers
144    if !line.contains('*') && !line.contains('_') {
145        return;
146    }
147
148    let bytes = line.as_bytes();
149    let mut i = 0;
150
151    while i < bytes.len() {
152        let byte = bytes[i];
153        if byte == b'*' || byte == b'_' {
154            let start_pos = i;
155            let mut count = 1u8;
156
157            // Count consecutive markers (limit to avoid overflow)
158            while i + (count as usize) < bytes.len() && bytes[i + (count as usize)] == byte && count < 3 {
159                count += 1;
160            }
161
162            // Only consider single (*) and double (**) markers
163            if count == 1 || count == 2 {
164                markers.push(EmphasisMarker {
165                    marker_type: byte,
166                    count,
167                    start_pos,
168                });
169            }
170
171            i += count as usize;
172        } else {
173            i += 1;
174        }
175    }
176}
177
178/// Find all emphasis spans in a line, excluding only single emphasis (not strong)
179pub fn find_single_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
180    find_single_emphasis_spans_with(line, markers, owned_span)
181}
182
183pub(crate) fn find_single_emphasis_edges(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisEdges> {
184    find_single_emphasis_spans_with(line, markers, span_edges)
185}
186
187fn find_single_emphasis_spans_with<T>(
188    line: &str,
189    markers: &[EmphasisMarker],
190    mut emit: impl FnMut(&EmphasisMarker, &EmphasisMarker, &str, bool, bool) -> T,
191) -> Vec<T> {
192    // Early return for insufficient markers
193    if markers.len() < 2 {
194        return Vec::new();
195    }
196
197    // CommonMark left/right-flanking (whitespace clause): an emphasis opener
198    // must not be immediately followed by whitespace, and a closer must not be
199    // immediately preceded by it. A marker that can do neither is a literal
200    // `*`/`_` (e.g. a list-marker `*`, or a `*` flanked by spaces) and is
201    // transparent to delimiter matching. `find_emphasis_spans` (MD037)
202    // deliberately keeps such runs so MD037 can flag the spaces inside them;
203    // this single-emphasis finder, used only by MD049, must not.
204    let bytes = line.as_bytes();
205    let is_ws = |b: u8| b == b' ' || b == b'\t';
206    let can_open = |m: &EmphasisMarker| {
207        let after = m.end_pos();
208        after < bytes.len() && !is_ws(bytes[after])
209    };
210    let can_close = |m: &EmphasisMarker| m.start_pos > 0 && !is_ws(bytes[m.start_pos - 1]);
211
212    let mut spans = Vec::new();
213    let mut used_markers = vec![false; markers.len()];
214
215    // Process markers in pairs more efficiently
216    for i in 0..markers.len() {
217        if used_markers[i] || markers[i].count != 1 || !can_open(&markers[i]) {
218            continue;
219        }
220
221        let opening = &markers[i];
222
223        // Look for the nearest matching closing marker using optimized search
224        for j in (i + 1)..markers.len() {
225            if used_markers[j] {
226                continue;
227            }
228
229            let closing = &markers[j];
230
231            // Quick type and count check - only single emphasis that can close
232            if closing.marker_type == opening.marker_type && closing.count == 1 && can_close(closing) {
233                let content_start = opening.end_pos();
234                let content_end = closing.start_pos;
235
236                if content_end > content_start {
237                    let content = &line[content_start..content_end];
238
239                    // Optimized validation checks
240                    if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
241                        // A pairing is only blocked by an intervening *viable*
242                        // delimiter (one that can itself open or close); a
243                        // transparent literal marker does not interfere, which
244                        // lets `*foo * bar*` pair its outer asterisks the way
245                        // CommonMark does.
246                        let crosses_markers = markers[i + 1..j].iter().any(|marker| {
247                            marker.marker_type == opening.marker_type
248                                && marker.count == 1
249                                && (can_open(marker) || can_close(marker))
250                        });
251
252                        if !crosses_markers {
253                            // Flanking guarantees the content is not whitespace-
254                            // padded, but keep the fields honest for callers.
255                            let has_leading_space = content.starts_with(' ') || content.starts_with('\t');
256                            let has_trailing_space = content.ends_with(' ') || content.ends_with('\t');
257
258                            spans.push(emit(opening, closing, content, has_leading_space, has_trailing_space));
259
260                            // Mark both markers as used
261                            used_markers[i] = true;
262                            used_markers[j] = true;
263                            break;
264                        }
265                    }
266                }
267            }
268        }
269    }
270
271    spans
272}
273
274/// Optimized emphasis span finding with reduced complexity (includes both single and strong)
275pub fn find_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
276    find_emphasis_spans_with(line, markers, owned_span)
277}
278
279pub(crate) fn find_emphasis_edges(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisEdges> {
280    find_emphasis_spans_with(line, markers, span_edges)
281}
282
283fn find_emphasis_spans_with<T>(
284    line: &str,
285    markers: &[EmphasisMarker],
286    mut emit: impl FnMut(&EmphasisMarker, &EmphasisMarker, &str, bool, bool) -> T,
287) -> Vec<T> {
288    // Early return for insufficient markers
289    if markers.len() < 2 {
290        return Vec::new();
291    }
292
293    let mut spans = Vec::new();
294    let mut used_markers = vec![false; markers.len()];
295
296    // Process markers in pairs more efficiently
297    for i in 0..markers.len() {
298        if used_markers[i] {
299            continue;
300        }
301
302        let opening = &markers[i];
303
304        // Look for the nearest matching closing marker using optimized search
305        for j in (i + 1)..markers.len() {
306            if used_markers[j] {
307                continue;
308            }
309
310            let closing = &markers[j];
311
312            // Quick type and count check
313            if closing.marker_type == opening.marker_type && closing.count == opening.count {
314                let content_start = opening.end_pos();
315                let content_end = closing.start_pos;
316
317                if content_end > content_start {
318                    let content = &line[content_start..content_end];
319
320                    // Optimized validation checks
321                    if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
322                        // Quick check for crossing markers
323                        let crosses_markers = markers[i + 1..j]
324                            .iter()
325                            .any(|marker| marker.marker_type == opening.marker_type);
326
327                        if !crosses_markers {
328                            let has_leading_space = content.starts_with(' ') || content.starts_with('\t');
329                            let has_trailing_space = content.ends_with(' ') || content.ends_with('\t');
330
331                            spans.push(emit(opening, closing, content, has_leading_space, has_trailing_space));
332
333                            // Mark both markers as used
334                            used_markers[i] = true;
335                            used_markers[j] = true;
336                            break;
337                        }
338                    }
339                }
340            }
341        }
342    }
343
344    spans
345}
346
347/// Byte ranges `[start, end)` of *valid* CommonMark emphasis on a single line,
348/// covering both single (`*`/`_`) and double (`**`/`__`) emphasis.
349///
350/// Unlike [`find_emphasis_spans`] (which greedily pairs markers and keeps
351/// whitespace-padded runs so MD037 can flag them), this applies the CommonMark
352/// left/right-flanking whitespace rule: an opener must not be immediately
353/// followed by whitespace, a closer must not be immediately preceded by it, and
354/// a marker that can do neither is a transparent literal that does not block an
355/// outer pairing. MD037 uses these ranges to avoid flagging text that is in
356/// fact valid emphasis containing an interior literal marker, e.g.
357/// `*foo * bar*` -> `<em>foo * bar</em>`, where the inner `* ` is literal.
358pub fn find_valid_emphasis_ranges(line: &str, markers: &[EmphasisMarker]) -> Vec<(usize, usize)> {
359    if markers.len() < 2 {
360        return Vec::new();
361    }
362
363    let bytes = line.as_bytes();
364    let is_ws = |b: u8| b == b' ' || b == b'\t';
365    let can_open = |m: &EmphasisMarker| {
366        let after = m.end_pos();
367        after < bytes.len() && !is_ws(bytes[after])
368    };
369    let can_close = |m: &EmphasisMarker| m.start_pos > 0 && !is_ws(bytes[m.start_pos - 1]);
370
371    let mut ranges = Vec::new();
372    let mut used = vec![false; markers.len()];
373
374    for i in 0..markers.len() {
375        if used[i] || !can_open(&markers[i]) {
376            continue;
377        }
378
379        let opening = &markers[i];
380
381        for j in (i + 1)..markers.len() {
382            if used[j] {
383                continue;
384            }
385
386            let closing = &markers[j];
387
388            // Same marker run (type and strength) that can validly close.
389            if closing.marker_type == opening.marker_type && closing.count == opening.count && can_close(closing) {
390                let content_start = opening.end_pos();
391                let content_end = closing.start_pos;
392
393                if content_end > content_start {
394                    let content = &line[content_start..content_end];
395
396                    if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
397                        // Only an intervening *viable* delimiter of the same
398                        // type blocks the pairing; transparent literals do not.
399                        let crosses = markers[i + 1..j]
400                            .iter()
401                            .any(|m| m.marker_type == opening.marker_type && (can_open(m) || can_close(m)));
402
403                        if !crosses {
404                            ranges.push((opening.start_pos, closing.end_pos()));
405                            used[i] = true;
406                            used[j] = true;
407                            break;
408                        }
409                    }
410                }
411            }
412        }
413    }
414
415    ranges
416}
417
418/// Fast validation of emphasis span context
419#[inline]
420fn is_valid_emphasis_span_fast(line: &str, opening: &EmphasisMarker, closing: &EmphasisMarker) -> bool {
421    let content_start = opening.end_pos();
422    let content_end = closing.start_pos;
423
424    // Content must exist and not be just whitespace
425    if content_end <= content_start {
426        return false;
427    }
428
429    let content = &line[content_start..content_end];
430    if content.trim().is_empty() {
431        return false;
432    }
433
434    // Quick boundary checks using byte indexing
435    let bytes = line.as_bytes();
436
437    // Opening should be at start or after valid character
438    let valid_opening = opening.start_pos == 0
439        || matches!(
440            bytes.get(opening.start_pos.saturating_sub(1)),
441            Some(&b' ')
442                | Some(&b'\t')
443                | Some(&b'(')
444                | Some(&b'[')
445                | Some(&b'{')
446                | Some(&b'"')
447                | Some(&b'\'')
448                | Some(&b'>')
449        );
450
451    // Closing should be at end or before valid character
452    let valid_closing = closing.end_pos() >= bytes.len()
453        || matches!(
454            bytes.get(closing.end_pos()),
455            Some(&b' ')
456                | Some(&b'\t')
457                | Some(&b')')
458                | Some(&b']')
459                | Some(&b'}')
460                | Some(&b'"')
461                | Some(&b'\'')
462                | Some(&b'.')
463                | Some(&b',')
464                | Some(&b'!')
465                | Some(&b'?')
466                | Some(&b';')
467                | Some(&b':')
468                | Some(&b'<')
469        );
470
471    valid_opening && valid_closing && !content.contains('\n')
472}
473
474/// Fast validation of emphasis content
475#[inline]
476fn is_valid_emphasis_content_fast(content: &str) -> bool {
477    !content.trim().is_empty()
478}
479
480/// Check if line has documentation patterns that should be preserved
481pub fn has_doc_patterns(line: &str) -> bool {
482    // Check for template/shortcode syntax like {* ... *} used by FastAPI/MkDocs
483    // These contain asterisks that are not emphasis markers
484    if line.contains("{*") && TEMPLATE_SHORTCODE_PATTERN.is_match(line) {
485        return true;
486    }
487
488    (QUICK_DOC_CHECK.is_match(line) || QUICK_BOLD_CHECK.is_match(line))
489        && (DOC_METADATA_PATTERN.is_match(line) || BOLD_TEXT_PATTERN.is_match(line))
490}
491
492#[cfg(test)]
493mod masking_contract_tests {
494    use super::*;
495    use proptest::prelude::*;
496
497    // Independent oracle: the original replacement algorithm, before the
498    // shared source-backed view and borrowed no-op fast path.
499    fn original_mask(source: &str, regex: &Regex, placeholder: &str) -> String {
500        let mut result = source.to_owned();
501        for captures in regex.captures_iter(source) {
502            if placeholder == "X" && (1..=3).any(|group| captures.get(group).is_none()) {
503                continue;
504            }
505            let matched = captures.get(0).unwrap();
506            result.replace_range(matched.range(), &placeholder.repeat(matched.len()));
507        }
508        result
509    }
510
511    fn assert_contract(source: &str) {
512        assert_eq!(
513            replace_inline_code_cow(source),
514            original_mask(source, &INLINE_CODE, "X")
515        );
516        assert_eq!(
517            replace_inline_math_cow(source),
518            original_mask(source, &INLINE_MATH, "M")
519        );
520        assert_eq!(replace_inline_code_cow(source).len(), source.len());
521        assert_eq!(replace_inline_math_cow(source).len(), source.len());
522    }
523
524    proptest! {
525        #[test]
526        fn source_masks_match_original_replacements(source in prop_oneof!["(?s).{0,128}", "[`$*_aé \\r\\n]{0,1024}"]) {
527            assert_contract(&source);
528        }
529    }
530
531    #[test]
532    fn literal_and_mismatched_delimiters_preserve_source() {
533        for source in [
534            "plain text",
535            "`unclosed",
536            "$$",
537            "é🦀 `é🦀` _text_",
538            "`` a ` b `` $a$ _x_",
539            "`a`` and ```b`` $x$$y$",
540            "`$` _x_ $y$",
541            "`é`\r\n$$ z $$",
542            "`a` _x_ `b`",
543            "$$ a $$ $$ b $$",
544        ] {
545            assert_contract(source);
546        }
547        assert!(matches!(
548            replace_inline_code_cow("literal"),
549            std::borrow::Cow::Borrowed(_)
550        ));
551        assert!(matches!(
552            replace_inline_code_cow("`unclosed"),
553            std::borrow::Cow::Borrowed(_)
554        ));
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn test_emphasis_marker_parsing() {
564        let markers = find_emphasis_markers("This has *single* and **double** emphasis");
565        assert_eq!(markers.len(), 4); // *, *, **, **
566
567        let markers = find_emphasis_markers("*start* and *end*");
568        assert_eq!(markers.len(), 4); // *, *, *, *
569    }
570
571    #[test]
572    fn test_single_emphasis_span_detection() {
573        let markers = find_emphasis_markers("This has *valid* emphasis and **strong** too");
574        let spans = find_single_emphasis_spans("This has *valid* emphasis and **strong** too", &markers);
575        assert_eq!(spans.len(), 1); // Only the single emphasis
576        assert_eq!(spans[0].content, "valid");
577        assert!(!spans[0].has_leading_space);
578        assert!(!spans[0].has_trailing_space);
579    }
580
581    #[test]
582    fn test_emphasis_with_spaces() {
583        let markers = find_emphasis_markers("This has * invalid * emphasis");
584        let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
585        assert_eq!(spans.len(), 1);
586        assert_eq!(spans[0].content, " invalid ");
587        assert!(spans[0].has_leading_space);
588        assert!(spans[0].has_trailing_space);
589    }
590
591    #[test]
592    fn test_single_emphasis_rejects_whitespace_flanked_runs() {
593        // `find_single_emphasis_spans` powers MD049, which must only see real
594        // emphasis. A whitespace-flanked `* ... *` run is not emphasis per
595        // CommonMark, so no span is produced.
596        let line = "foo * bar * baz";
597        let markers = find_emphasis_markers(line);
598        let spans = find_single_emphasis_spans(line, &markers);
599        assert!(
600            spans.is_empty(),
601            "whitespace-flanked run must not be a single-emphasis span: {spans:?}"
602        );
603
604        // The sibling `find_emphasis_spans` (used by MD037) intentionally keeps
605        // the run so MD037 can flag the spaces. Locking this in prevents a
606        // future "fix" landing in the shared helper and breaking MD037.
607        let md037_spans = find_emphasis_spans(line, &markers);
608        assert_eq!(
609            md037_spans.len(),
610            1,
611            "MD037's span finder must still detect the spaced run: {md037_spans:?}"
612        );
613        assert_eq!(md037_spans[0].content, " bar ");
614    }
615
616    #[test]
617    fn test_valid_emphasis_ranges() {
618        let ranges = |line: &str| {
619            let markers = find_emphasis_markers(line);
620            find_valid_emphasis_ranges(line, &markers)
621        };
622
623        // Plain single and double emphasis yield their full marker-to-marker range.
624        assert_eq!(ranges("a *foo* b"), vec![(2, 7)]);
625        assert_eq!(ranges("a **foo** b"), vec![(2, 9)]);
626
627        // Valid emphasis spanning an interior whitespace-flanked literal marker.
628        assert_eq!(ranges("*foo * bar*"), vec![(0, 11)]);
629        assert_eq!(ranges("**foo ** bar**"), vec![(0, 14)]);
630
631        // Whitespace-flanked runs are not valid emphasis - no range.
632        assert!(ranges("foo * bar * baz").is_empty());
633        assert!(ranges("** spaced **").is_empty());
634        // A leading list marker `*` cannot open emphasis.
635        assert!(ranges("* item only").is_empty());
636    }
637
638    #[test]
639    fn test_single_emphasis_spans_literal_marker_inside_emphasis() {
640        // CommonMark parses `*foo * bar*` as <em>foo * bar</em>: the inner `*`
641        // is whitespace-flanked (a transparent literal), so the outer pair is
642        // still emphasis. A naive "skip on inner marker" would miss this.
643        let line = "*foo * bar*";
644        let markers = find_emphasis_markers(line);
645        let spans = find_single_emphasis_spans(line, &markers);
646        assert_eq!(spans.len(), 1, "outer emphasis must be detected: {spans:?}");
647        assert_eq!(spans[0].content, "foo * bar");
648
649        // `*a *b*` is `*a <em>b</em>`: the inner `*b*` is emphasis, the leading
650        // `*` stays literal. Only the inner span is a single-emphasis span.
651        let line = "*a *b*";
652        let markers = find_emphasis_markers(line);
653        let spans = find_single_emphasis_spans(line, &markers);
654        assert_eq!(spans.len(), 1, "only inner emphasis: {spans:?}");
655        assert_eq!(spans[0].content, "b");
656    }
657
658    #[test]
659    fn test_mixed_markers() {
660        let markers = find_emphasis_markers("This has *asterisk* and _underscore_ emphasis");
661        let spans = find_single_emphasis_spans("This has *asterisk* and _underscore_ emphasis", &markers);
662        assert_eq!(spans.len(), 2);
663        assert_eq!(spans[0].opening.as_char(), '*');
664        assert_eq!(spans[1].opening.as_char(), '_');
665    }
666
667    #[test]
668    fn test_template_shortcode_detection() {
669        // FastAPI/MkDocs style template syntax should be detected as doc pattern
670        assert!(has_doc_patterns(
671            "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}"
672        ));
673        assert!(has_doc_patterns(
674            "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}"
675        ));
676        // Simple shortcode
677        assert!(has_doc_patterns("{* file.py *}"));
678        // With path and options
679        assert!(has_doc_patterns("{* ../path/to/file.py ln[1-10] *}"));
680
681        // Regular emphasis should NOT match
682        assert!(!has_doc_patterns("This has *emphasis* text"));
683        assert!(!has_doc_patterns("This has * spaces * in emphasis"));
684        // Only opening brace without closing should not match
685        assert!(!has_doc_patterns("{* incomplete"));
686    }
687
688    #[test]
689    fn test_doc_pattern_rejects_spaced_bold_metadata() {
690        // Valid bold metadata — should be treated as doc pattern (skip MD037)
691        assert!(has_doc_patterns("**Key**: value"));
692        assert!(has_doc_patterns("**Name**: another value"));
693        assert!(has_doc_patterns("**X**: single char"));
694        assert!(has_doc_patterns("* **Key**: list item with bold key"));
695
696        // Broken bold with internal spaces — should NOT be treated as doc pattern
697        // so MD037 can flag the spacing issue
698        assert!(!has_doc_patterns("** Key**: value"));
699        assert!(!has_doc_patterns("**Key **: value"));
700        assert!(!has_doc_patterns("** Key **: value"));
701        assert!(!has_doc_patterns(
702            "** Explicit Import**: Convert markdownlint configs to rumdl format:"
703        ));
704    }
705}