1use regex::Regex;
2use std::sync::LazyLock;
3
4static INLINE_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`+)([^`]|[^`].*?[^`])(`+)").unwrap());
6
7static INLINE_MATH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
10
11static DOC_METADATA_PATTERN: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"^\s*\*?\s*\*\*(?:[^*\s][^*]*[^*\s]|[^*\s])\*\*\s*:").unwrap());
14
15static BOLD_TEXT_PATTERN: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"\*\*[^*\s][^*]*[^*\s]\*\*|\*\*[^*\s]\*\*").unwrap());
18
19static 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
23static TEMPLATE_SHORTCODE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\*.*\*\}").unwrap());
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct EmphasisMarker {
30 pub marker_type: u8, pub count: u8, pub start_pos: usize, }
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#[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
57pub(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#[inline]
99pub fn replace_inline_code(line: &str) -> String {
100 replace_inline_code_cow(line).into_owned()
101}
102
103pub(crate) fn replace_inline_code_cow(line: &str) -> std::borrow::Cow<'_, str> {
107 mask_matches(line, '`', &INLINE_CODE, b'X')
108}
109
110pub 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#[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
141pub(crate) fn find_emphasis_markers_into(line: &str, markers: &mut Vec<EmphasisMarker>) {
143 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 while i + (count as usize) < bytes.len() && bytes[i + (count as usize)] == byte && count < 3 {
159 count += 1;
160 }
161
162 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
178pub 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 if markers.len() < 2 {
194 return Vec::new();
195 }
196
197 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 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 for j in (i + 1)..markers.len() {
225 if used_markers[j] {
226 continue;
227 }
228
229 let closing = &markers[j];
230
231 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 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
241 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 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 used_markers[i] = true;
262 used_markers[j] = true;
263 break;
264 }
265 }
266 }
267 }
268 }
269 }
270
271 spans
272}
273
274pub 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 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 for i in 0..markers.len() {
298 if used_markers[i] {
299 continue;
300 }
301
302 let opening = &markers[i];
303
304 for j in (i + 1)..markers.len() {
306 if used_markers[j] {
307 continue;
308 }
309
310 let closing = &markers[j];
311
312 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 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
322 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 used_markers[i] = true;
335 used_markers[j] = true;
336 break;
337 }
338 }
339 }
340 }
341 }
342 }
343
344 spans
345}
346
347pub 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 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 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#[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 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 let bytes = line.as_bytes();
436
437 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 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#[inline]
476fn is_valid_emphasis_content_fast(content: &str) -> bool {
477 !content.trim().is_empty()
478}
479
480pub fn has_doc_patterns(line: &str) -> bool {
482 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 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); let markers = find_emphasis_markers("*start* and *end*");
568 assert_eq!(markers.len(), 4); }
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); 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 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 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 assert_eq!(ranges("a *foo* b"), vec![(2, 7)]);
625 assert_eq!(ranges("a **foo** b"), vec![(2, 9)]);
626
627 assert_eq!(ranges("*foo * bar*"), vec![(0, 11)]);
629 assert_eq!(ranges("**foo ** bar**"), vec![(0, 14)]);
630
631 assert!(ranges("foo * bar * baz").is_empty());
633 assert!(ranges("** spaced **").is_empty());
634 assert!(ranges("* item only").is_empty());
636 }
637
638 #[test]
639 fn test_single_emphasis_spans_literal_marker_inside_emphasis() {
640 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 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 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 assert!(has_doc_patterns("{* file.py *}"));
678 assert!(has_doc_patterns("{* ../path/to/file.py ln[1-10] *}"));
680
681 assert!(!has_doc_patterns("This has *emphasis* text"));
683 assert!(!has_doc_patterns("This has * spaces * in emphasis"));
684 assert!(!has_doc_patterns("{* incomplete"));
686 }
687
688 #[test]
689 fn test_doc_pattern_rejects_spaced_bold_metadata() {
690 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 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}