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/// Enhanced inline code replacement with optimized performance
58/// Replaces inline code with 'X' characters to prevent false positives in emphasis detection
59#[inline]
60pub fn replace_inline_code(line: &str) -> String {
61    // Quick check: if no backticks, return original
62    if !line.contains('`') {
63        return line.to_string();
64    }
65
66    let mut result = line.to_string();
67    let mut offset = 0;
68
69    for cap in INLINE_CODE.captures_iter(line) {
70        if let (Some(full_match), Some(_opening), Some(_content), Some(_closing)) =
71            (cap.get(0), cap.get(1), cap.get(2), cap.get(3))
72        {
73            let match_start = full_match.start();
74            let match_end = full_match.end();
75            // Use 'X' instead of spaces to avoid false positives for "spaces in emphasis"
76            let placeholder = "X".repeat(match_end - match_start);
77
78            result.replace_range(match_start + offset..match_end + offset, &placeholder);
79            offset += placeholder.len() - (match_end - match_start);
80        }
81    }
82
83    result
84}
85
86/// Replace inline math ($...$ and $$...$$) with placeholder characters
87/// This prevents math content from being mistaken for emphasis markers
88pub fn replace_inline_math(line: &str) -> String {
89    // Quick check: if no dollar signs, return original
90    if !line.contains('$') {
91        return line.to_string();
92    }
93
94    let mut result = line.to_string();
95    let mut offset: isize = 0;
96
97    for m in INLINE_MATH.find_iter(line) {
98        let match_start = m.start();
99        let match_end = m.end();
100        // Use 'M' instead of spaces or asterisks to avoid affecting emphasis detection
101        let placeholder = "M".repeat(match_end - match_start);
102
103        let adjusted_start = (match_start as isize + offset) as usize;
104        let adjusted_end = (match_end as isize + offset) as usize;
105        result.replace_range(adjusted_start..adjusted_end, &placeholder);
106        offset += placeholder.len() as isize - (match_end - match_start) as isize;
107    }
108
109    result
110}
111
112/// Optimized emphasis marker parsing using byte iteration
113#[inline]
114pub fn find_emphasis_markers(line: &str) -> Vec<EmphasisMarker> {
115    // Early return for lines without emphasis markers
116    if !line.contains('*') && !line.contains('_') {
117        return Vec::new();
118    }
119
120    let mut markers = Vec::new();
121    let bytes = line.as_bytes();
122    let mut i = 0;
123
124    while i < bytes.len() {
125        let byte = bytes[i];
126        if byte == b'*' || byte == b'_' {
127            let start_pos = i;
128            let mut count = 1u8;
129
130            // Count consecutive markers (limit to avoid overflow)
131            while i + (count as usize) < bytes.len() && bytes[i + (count as usize)] == byte && count < 3 {
132                count += 1;
133            }
134
135            // Only consider single (*) and double (**) markers
136            if count == 1 || count == 2 {
137                markers.push(EmphasisMarker {
138                    marker_type: byte,
139                    count,
140                    start_pos,
141                });
142            }
143
144            i += count as usize;
145        } else {
146            i += 1;
147        }
148    }
149
150    markers
151}
152
153/// Find all emphasis spans in a line, excluding only single emphasis (not strong)
154pub fn find_single_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
155    // Early return for insufficient markers
156    if markers.len() < 2 {
157        return Vec::new();
158    }
159
160    // CommonMark left/right-flanking (whitespace clause): an emphasis opener
161    // must not be immediately followed by whitespace, and a closer must not be
162    // immediately preceded by it. A marker that can do neither is a literal
163    // `*`/`_` (e.g. a list-marker `*`, or a `*` flanked by spaces) and is
164    // transparent to delimiter matching. `find_emphasis_spans` (MD037)
165    // deliberately keeps such runs so MD037 can flag the spaces inside them;
166    // this single-emphasis finder, used only by MD049, must not.
167    let bytes = line.as_bytes();
168    let is_ws = |b: u8| b == b' ' || b == b'\t';
169    let can_open = |m: &EmphasisMarker| {
170        let after = m.end_pos();
171        after < bytes.len() && !is_ws(bytes[after])
172    };
173    let can_close = |m: &EmphasisMarker| m.start_pos > 0 && !is_ws(bytes[m.start_pos - 1]);
174
175    let mut spans = Vec::new();
176    let mut used_markers = vec![false; markers.len()];
177
178    // Process markers in pairs more efficiently
179    for i in 0..markers.len() {
180        if used_markers[i] || markers[i].count != 1 || !can_open(&markers[i]) {
181            continue;
182        }
183
184        let opening = &markers[i];
185
186        // Look for the nearest matching closing marker using optimized search
187        for j in (i + 1)..markers.len() {
188            if used_markers[j] {
189                continue;
190            }
191
192            let closing = &markers[j];
193
194            // Quick type and count check - only single emphasis that can close
195            if closing.marker_type == opening.marker_type && closing.count == 1 && can_close(closing) {
196                let content_start = opening.end_pos();
197                let content_end = closing.start_pos;
198
199                if content_end > content_start {
200                    let content = &line[content_start..content_end];
201
202                    // Optimized validation checks
203                    if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
204                        // A pairing is only blocked by an intervening *viable*
205                        // delimiter (one that can itself open or close); a
206                        // transparent literal marker does not interfere, which
207                        // lets `*foo * bar*` pair its outer asterisks the way
208                        // CommonMark does.
209                        let crosses_markers = markers[i + 1..j].iter().any(|marker| {
210                            marker.marker_type == opening.marker_type
211                                && marker.count == 1
212                                && (can_open(marker) || can_close(marker))
213                        });
214
215                        if !crosses_markers {
216                            // Flanking guarantees the content is not whitespace-
217                            // padded, but keep the fields honest for callers.
218                            let has_leading_space = content.starts_with(' ') || content.starts_with('\t');
219                            let has_trailing_space = content.ends_with(' ') || content.ends_with('\t');
220
221                            spans.push(EmphasisSpan {
222                                opening: opening.clone(),
223                                closing: closing.clone(),
224                                content: content.to_string(),
225                                has_leading_space,
226                                has_trailing_space,
227                            });
228
229                            // Mark both markers as used
230                            used_markers[i] = true;
231                            used_markers[j] = true;
232                            break;
233                        }
234                    }
235                }
236            }
237        }
238    }
239
240    spans
241}
242
243/// Optimized emphasis span finding with reduced complexity (includes both single and strong)
244pub fn find_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
245    // Early return for insufficient markers
246    if markers.len() < 2 {
247        return Vec::new();
248    }
249
250    let mut spans = Vec::new();
251    let mut used_markers = vec![false; markers.len()];
252
253    // Process markers in pairs more efficiently
254    for i in 0..markers.len() {
255        if used_markers[i] {
256            continue;
257        }
258
259        let opening = &markers[i];
260
261        // Look for the nearest matching closing marker using optimized search
262        for j in (i + 1)..markers.len() {
263            if used_markers[j] {
264                continue;
265            }
266
267            let closing = &markers[j];
268
269            // Quick type and count check
270            if closing.marker_type == opening.marker_type && closing.count == opening.count {
271                let content_start = opening.end_pos();
272                let content_end = closing.start_pos;
273
274                if content_end > content_start {
275                    let content = &line[content_start..content_end];
276
277                    // Optimized validation checks
278                    if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
279                        // Quick check for crossing markers
280                        let crosses_markers = markers[i + 1..j]
281                            .iter()
282                            .any(|marker| marker.marker_type == opening.marker_type);
283
284                        if !crosses_markers {
285                            let has_leading_space = content.starts_with(' ') || content.starts_with('\t');
286                            let has_trailing_space = content.ends_with(' ') || content.ends_with('\t');
287
288                            spans.push(EmphasisSpan {
289                                opening: opening.clone(),
290                                closing: closing.clone(),
291                                content: content.to_string(),
292                                has_leading_space,
293                                has_trailing_space,
294                            });
295
296                            // Mark both markers as used
297                            used_markers[i] = true;
298                            used_markers[j] = true;
299                            break;
300                        }
301                    }
302                }
303            }
304        }
305    }
306
307    spans
308}
309
310/// Byte ranges `[start, end)` of *valid* CommonMark emphasis on a single line,
311/// covering both single (`*`/`_`) and double (`**`/`__`) emphasis.
312///
313/// Unlike [`find_emphasis_spans`] (which greedily pairs markers and keeps
314/// whitespace-padded runs so MD037 can flag them), this applies the CommonMark
315/// left/right-flanking whitespace rule: an opener must not be immediately
316/// followed by whitespace, a closer must not be immediately preceded by it, and
317/// a marker that can do neither is a transparent literal that does not block an
318/// outer pairing. MD037 uses these ranges to avoid flagging text that is in
319/// fact valid emphasis containing an interior literal marker, e.g.
320/// `*foo * bar*` -> `<em>foo * bar</em>`, where the inner `* ` is literal.
321pub fn find_valid_emphasis_ranges(line: &str, markers: &[EmphasisMarker]) -> Vec<(usize, usize)> {
322    if markers.len() < 2 {
323        return Vec::new();
324    }
325
326    let bytes = line.as_bytes();
327    let is_ws = |b: u8| b == b' ' || b == b'\t';
328    let can_open = |m: &EmphasisMarker| {
329        let after = m.end_pos();
330        after < bytes.len() && !is_ws(bytes[after])
331    };
332    let can_close = |m: &EmphasisMarker| m.start_pos > 0 && !is_ws(bytes[m.start_pos - 1]);
333
334    let mut ranges = Vec::new();
335    let mut used = vec![false; markers.len()];
336
337    for i in 0..markers.len() {
338        if used[i] || !can_open(&markers[i]) {
339            continue;
340        }
341
342        let opening = &markers[i];
343
344        for j in (i + 1)..markers.len() {
345            if used[j] {
346                continue;
347            }
348
349            let closing = &markers[j];
350
351            // Same marker run (type and strength) that can validly close.
352            if closing.marker_type == opening.marker_type && closing.count == opening.count && can_close(closing) {
353                let content_start = opening.end_pos();
354                let content_end = closing.start_pos;
355
356                if content_end > content_start {
357                    let content = &line[content_start..content_end];
358
359                    if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
360                        // Only an intervening *viable* delimiter of the same
361                        // type blocks the pairing; transparent literals do not.
362                        let crosses = markers[i + 1..j]
363                            .iter()
364                            .any(|m| m.marker_type == opening.marker_type && (can_open(m) || can_close(m)));
365
366                        if !crosses {
367                            ranges.push((opening.start_pos, closing.end_pos()));
368                            used[i] = true;
369                            used[j] = true;
370                            break;
371                        }
372                    }
373                }
374            }
375        }
376    }
377
378    ranges
379}
380
381/// Fast validation of emphasis span context
382#[inline]
383fn is_valid_emphasis_span_fast(line: &str, opening: &EmphasisMarker, closing: &EmphasisMarker) -> bool {
384    let content_start = opening.end_pos();
385    let content_end = closing.start_pos;
386
387    // Content must exist and not be just whitespace
388    if content_end <= content_start {
389        return false;
390    }
391
392    let content = &line[content_start..content_end];
393    if content.trim().is_empty() {
394        return false;
395    }
396
397    // Quick boundary checks using byte indexing
398    let bytes = line.as_bytes();
399
400    // Opening should be at start or after valid character
401    let valid_opening = opening.start_pos == 0
402        || matches!(
403            bytes.get(opening.start_pos.saturating_sub(1)),
404            Some(&b' ')
405                | Some(&b'\t')
406                | Some(&b'(')
407                | Some(&b'[')
408                | Some(&b'{')
409                | Some(&b'"')
410                | Some(&b'\'')
411                | Some(&b'>')
412        );
413
414    // Closing should be at end or before valid character
415    let valid_closing = closing.end_pos() >= bytes.len()
416        || matches!(
417            bytes.get(closing.end_pos()),
418            Some(&b' ')
419                | Some(&b'\t')
420                | Some(&b')')
421                | Some(&b']')
422                | Some(&b'}')
423                | Some(&b'"')
424                | Some(&b'\'')
425                | Some(&b'.')
426                | Some(&b',')
427                | Some(&b'!')
428                | Some(&b'?')
429                | Some(&b';')
430                | Some(&b':')
431                | Some(&b'<')
432        );
433
434    valid_opening && valid_closing && !content.contains('\n')
435}
436
437/// Fast validation of emphasis content
438#[inline]
439fn is_valid_emphasis_content_fast(content: &str) -> bool {
440    !content.trim().is_empty()
441}
442
443/// Check if line has documentation patterns that should be preserved
444pub fn has_doc_patterns(line: &str) -> bool {
445    // Check for template/shortcode syntax like {* ... *} used by FastAPI/MkDocs
446    // These contain asterisks that are not emphasis markers
447    if line.contains("{*") && TEMPLATE_SHORTCODE_PATTERN.is_match(line) {
448        return true;
449    }
450
451    (QUICK_DOC_CHECK.is_match(line) || QUICK_BOLD_CHECK.is_match(line))
452        && (DOC_METADATA_PATTERN.is_match(line) || BOLD_TEXT_PATTERN.is_match(line))
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_emphasis_marker_parsing() {
461        let markers = find_emphasis_markers("This has *single* and **double** emphasis");
462        assert_eq!(markers.len(), 4); // *, *, **, **
463
464        let markers = find_emphasis_markers("*start* and *end*");
465        assert_eq!(markers.len(), 4); // *, *, *, *
466    }
467
468    #[test]
469    fn test_single_emphasis_span_detection() {
470        let markers = find_emphasis_markers("This has *valid* emphasis and **strong** too");
471        let spans = find_single_emphasis_spans("This has *valid* emphasis and **strong** too", &markers);
472        assert_eq!(spans.len(), 1); // Only the single emphasis
473        assert_eq!(spans[0].content, "valid");
474        assert!(!spans[0].has_leading_space);
475        assert!(!spans[0].has_trailing_space);
476    }
477
478    #[test]
479    fn test_emphasis_with_spaces() {
480        let markers = find_emphasis_markers("This has * invalid * emphasis");
481        let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
482        assert_eq!(spans.len(), 1);
483        assert_eq!(spans[0].content, " invalid ");
484        assert!(spans[0].has_leading_space);
485        assert!(spans[0].has_trailing_space);
486    }
487
488    #[test]
489    fn test_single_emphasis_rejects_whitespace_flanked_runs() {
490        // `find_single_emphasis_spans` powers MD049, which must only see real
491        // emphasis. A whitespace-flanked `* ... *` run is not emphasis per
492        // CommonMark, so no span is produced.
493        let line = "foo * bar * baz";
494        let markers = find_emphasis_markers(line);
495        let spans = find_single_emphasis_spans(line, &markers);
496        assert!(
497            spans.is_empty(),
498            "whitespace-flanked run must not be a single-emphasis span: {spans:?}"
499        );
500
501        // The sibling `find_emphasis_spans` (used by MD037) intentionally keeps
502        // the run so MD037 can flag the spaces. Locking this in prevents a
503        // future "fix" landing in the shared helper and breaking MD037.
504        let md037_spans = find_emphasis_spans(line, &markers);
505        assert_eq!(
506            md037_spans.len(),
507            1,
508            "MD037's span finder must still detect the spaced run: {md037_spans:?}"
509        );
510        assert_eq!(md037_spans[0].content, " bar ");
511    }
512
513    #[test]
514    fn test_valid_emphasis_ranges() {
515        let ranges = |line: &str| {
516            let markers = find_emphasis_markers(line);
517            find_valid_emphasis_ranges(line, &markers)
518        };
519
520        // Plain single and double emphasis yield their full marker-to-marker range.
521        assert_eq!(ranges("a *foo* b"), vec![(2, 7)]);
522        assert_eq!(ranges("a **foo** b"), vec![(2, 9)]);
523
524        // Valid emphasis spanning an interior whitespace-flanked literal marker.
525        assert_eq!(ranges("*foo * bar*"), vec![(0, 11)]);
526        assert_eq!(ranges("**foo ** bar**"), vec![(0, 14)]);
527
528        // Whitespace-flanked runs are not valid emphasis - no range.
529        assert!(ranges("foo * bar * baz").is_empty());
530        assert!(ranges("** spaced **").is_empty());
531        // A leading list marker `*` cannot open emphasis.
532        assert!(ranges("* item only").is_empty());
533    }
534
535    #[test]
536    fn test_single_emphasis_spans_literal_marker_inside_emphasis() {
537        // CommonMark parses `*foo * bar*` as <em>foo * bar</em>: the inner `*`
538        // is whitespace-flanked (a transparent literal), so the outer pair is
539        // still emphasis. A naive "skip on inner marker" would miss this.
540        let line = "*foo * bar*";
541        let markers = find_emphasis_markers(line);
542        let spans = find_single_emphasis_spans(line, &markers);
543        assert_eq!(spans.len(), 1, "outer emphasis must be detected: {spans:?}");
544        assert_eq!(spans[0].content, "foo * bar");
545
546        // `*a *b*` is `*a <em>b</em>`: the inner `*b*` is emphasis, the leading
547        // `*` stays literal. Only the inner span is a single-emphasis span.
548        let line = "*a *b*";
549        let markers = find_emphasis_markers(line);
550        let spans = find_single_emphasis_spans(line, &markers);
551        assert_eq!(spans.len(), 1, "only inner emphasis: {spans:?}");
552        assert_eq!(spans[0].content, "b");
553    }
554
555    #[test]
556    fn test_mixed_markers() {
557        let markers = find_emphasis_markers("This has *asterisk* and _underscore_ emphasis");
558        let spans = find_single_emphasis_spans("This has *asterisk* and _underscore_ emphasis", &markers);
559        assert_eq!(spans.len(), 2);
560        assert_eq!(spans[0].opening.as_char(), '*');
561        assert_eq!(spans[1].opening.as_char(), '_');
562    }
563
564    #[test]
565    fn test_template_shortcode_detection() {
566        // FastAPI/MkDocs style template syntax should be detected as doc pattern
567        assert!(has_doc_patterns(
568            "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}"
569        ));
570        assert!(has_doc_patterns(
571            "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}"
572        ));
573        // Simple shortcode
574        assert!(has_doc_patterns("{* file.py *}"));
575        // With path and options
576        assert!(has_doc_patterns("{* ../path/to/file.py ln[1-10] *}"));
577
578        // Regular emphasis should NOT match
579        assert!(!has_doc_patterns("This has *emphasis* text"));
580        assert!(!has_doc_patterns("This has * spaces * in emphasis"));
581        // Only opening brace without closing should not match
582        assert!(!has_doc_patterns("{* incomplete"));
583    }
584
585    #[test]
586    fn test_doc_pattern_rejects_spaced_bold_metadata() {
587        // Valid bold metadata — should be treated as doc pattern (skip MD037)
588        assert!(has_doc_patterns("**Key**: value"));
589        assert!(has_doc_patterns("**Name**: another value"));
590        assert!(has_doc_patterns("**X**: single char"));
591        assert!(has_doc_patterns("* **Key**: list item with bold key"));
592
593        // Broken bold with internal spaces — should NOT be treated as doc pattern
594        // so MD037 can flag the spacing issue
595        assert!(!has_doc_patterns("** Key**: value"));
596        assert!(!has_doc_patterns("**Key **: value"));
597        assert!(!has_doc_patterns("** Key **: value"));
598        assert!(!has_doc_patterns(
599            "** Explicit Import**: Convert markdownlint configs to rumdl format:"
600        ));
601    }
602}