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() {
216 if used_markers[i] || markers[i].count != 1 || !can_open(&markers[i]) {
217 continue;
218 }
219 let opening = &markers[i];
220 let Some(j) = (i + 1..markers.len()).find(|&j| {
224 let marker = &markers[j];
225 marker.marker_type == opening.marker_type && marker.count == 1 && (can_open(marker) || can_close(marker))
226 }) else {
227 continue;
228 };
229 let closing = &markers[j];
230 if used_markers[j] || !can_close(closing) {
231 continue;
232 }
233 let content_start = opening.end_pos();
234 let content_end = closing.start_pos;
235 if content_end > content_start {
236 let content = &line[content_start..content_end];
237 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
238 let has_leading_space = content.starts_with(' ') || content.starts_with('\t');
239 let has_trailing_space = content.ends_with(' ') || content.ends_with('\t');
240 spans.push(emit(opening, closing, content, has_leading_space, has_trailing_space));
241 used_markers[i] = true;
242 used_markers[j] = true;
243 }
244 }
245 }
246
247 spans
248}
249
250pub fn find_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
252 find_emphasis_spans_with(line, markers, owned_span)
253}
254
255pub(crate) fn find_emphasis_edges(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisEdges> {
256 find_emphasis_spans_with(line, markers, span_edges)
257}
258
259fn find_emphasis_spans_with<T>(
260 line: &str,
261 markers: &[EmphasisMarker],
262 mut emit: impl FnMut(&EmphasisMarker, &EmphasisMarker, &str, bool, bool) -> T,
263) -> Vec<T> {
264 if markers.len() < 2 {
266 return Vec::new();
267 }
268
269 let mut spans = Vec::new();
270 let mut used_markers = vec![false; markers.len()];
271
272 for i in 0..markers.len() {
273 if used_markers[i] {
274 continue;
275 }
276 let opening = &markers[i];
277 let Some(j) = (i + 1..markers.len()).find(|&j| markers[j].marker_type == opening.marker_type) else {
281 continue;
282 };
283 let closing = &markers[j];
284 if used_markers[j] || closing.count != opening.count {
285 continue;
286 }
287 let content_start = opening.end_pos();
288 let content_end = closing.start_pos;
289 if content_end > content_start {
290 let content = &line[content_start..content_end];
291 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
292 let has_leading_space = content.starts_with(' ') || content.starts_with('\t');
293 let has_trailing_space = content.ends_with(' ') || content.ends_with('\t');
294 spans.push(emit(opening, closing, content, has_leading_space, has_trailing_space));
295 used_markers[i] = true;
296 used_markers[j] = true;
297 }
298 }
299 }
300
301 spans
302}
303
304pub fn find_valid_emphasis_ranges(line: &str, markers: &[EmphasisMarker]) -> Vec<(usize, usize)> {
316 if markers.len() < 2 {
317 return Vec::new();
318 }
319
320 let bytes = line.as_bytes();
321 let is_ws = |b: u8| b == b' ' || b == b'\t';
322 let can_open = |m: &EmphasisMarker| {
323 let after = m.end_pos();
324 after < bytes.len() && !is_ws(bytes[after])
325 };
326 let can_close = |m: &EmphasisMarker| m.start_pos > 0 && !is_ws(bytes[m.start_pos - 1]);
327
328 let mut ranges = Vec::new();
329 let mut used = vec![false; markers.len()];
330
331 for i in 0..markers.len() {
332 if used[i] || !can_open(&markers[i]) {
333 continue;
334 }
335 let opening = &markers[i];
336 let Some(j) = (i + 1..markers.len()).find(|&j| {
339 let marker = &markers[j];
340 marker.marker_type == opening.marker_type && (can_open(marker) || can_close(marker))
341 }) else {
342 continue;
343 };
344 let closing = &markers[j];
345 if used[j] || closing.count != opening.count || !can_close(closing) {
346 continue;
347 }
348 let content_start = opening.end_pos();
349 let content_end = closing.start_pos;
350 if content_end > content_start {
351 let content = &line[content_start..content_end];
352 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
353 ranges.push((opening.start_pos, closing.end_pos()));
354 used[i] = true;
355 used[j] = true;
356 }
357 }
358 }
359
360 ranges
361}
362
363#[inline]
365fn is_valid_emphasis_span_fast(line: &str, opening: &EmphasisMarker, closing: &EmphasisMarker) -> bool {
366 let content_start = opening.end_pos();
367 let content_end = closing.start_pos;
368
369 if content_end <= content_start {
371 return false;
372 }
373
374 let content = &line[content_start..content_end];
375 if content.trim().is_empty() {
376 return false;
377 }
378
379 let bytes = line.as_bytes();
381
382 let valid_opening = opening.start_pos == 0
384 || matches!(
385 bytes.get(opening.start_pos.saturating_sub(1)),
386 Some(&b' ')
387 | Some(&b'\t')
388 | Some(&b'(')
389 | Some(&b'[')
390 | Some(&b'{')
391 | Some(&b'"')
392 | Some(&b'\'')
393 | Some(&b'>')
394 );
395
396 let valid_closing = closing.end_pos() >= bytes.len()
398 || matches!(
399 bytes.get(closing.end_pos()),
400 Some(&b' ')
401 | Some(&b'\t')
402 | Some(&b')')
403 | Some(&b']')
404 | Some(&b'}')
405 | Some(&b'"')
406 | Some(&b'\'')
407 | Some(&b'.')
408 | Some(&b',')
409 | Some(&b'!')
410 | Some(&b'?')
411 | Some(&b';')
412 | Some(&b':')
413 | Some(&b'<')
414 );
415
416 valid_opening && valid_closing && !content.contains('\n')
417}
418
419#[inline]
421fn is_valid_emphasis_content_fast(content: &str) -> bool {
422 !content.trim().is_empty()
423}
424
425pub fn has_doc_patterns(line: &str) -> bool {
427 if line.contains("{*") && TEMPLATE_SHORTCODE_PATTERN.is_match(line) {
430 return true;
431 }
432
433 (QUICK_DOC_CHECK.is_match(line) || QUICK_BOLD_CHECK.is_match(line))
434 && (DOC_METADATA_PATTERN.is_match(line) || BOLD_TEXT_PATTERN.is_match(line))
435}
436
437#[cfg(test)]
438mod masking_contract_tests {
439 use super::*;
440 use proptest::prelude::*;
441
442 fn original_mask(source: &str, regex: &Regex, placeholder: &str) -> String {
445 let mut result = source.to_owned();
446 for captures in regex.captures_iter(source) {
447 if placeholder == "X" && (1..=3).any(|group| captures.get(group).is_none()) {
448 continue;
449 }
450 let matched = captures.get(0).unwrap();
451 result.replace_range(matched.range(), &placeholder.repeat(matched.len()));
452 }
453 result
454 }
455
456 fn assert_contract(source: &str) {
457 assert_eq!(
458 replace_inline_code_cow(source),
459 original_mask(source, &INLINE_CODE, "X")
460 );
461 assert_eq!(
462 replace_inline_math_cow(source),
463 original_mask(source, &INLINE_MATH, "M")
464 );
465 assert_eq!(replace_inline_code_cow(source).len(), source.len());
466 assert_eq!(replace_inline_math_cow(source).len(), source.len());
467 }
468
469 proptest! {
470 #[test]
471 fn source_masks_match_original_replacements(source in prop_oneof!["(?s).{0,128}", "[`$*_aé \\r\\n]{0,1024}"]) {
472 assert_contract(&source);
473 }
474 }
475
476 #[test]
477 fn literal_and_mismatched_delimiters_preserve_source() {
478 for source in [
479 "plain text",
480 "`unclosed",
481 "$$",
482 "é🦀 `é🦀` _text_",
483 "`` a ` b `` $a$ _x_",
484 "`a`` and ```b`` $x$$y$",
485 "`$` _x_ $y$",
486 "`é`\r\n$$ z $$",
487 "`a` _x_ `b`",
488 "$$ a $$ $$ b $$",
489 ] {
490 assert_contract(source);
491 }
492 assert!(matches!(
493 replace_inline_code_cow("literal"),
494 std::borrow::Cow::Borrowed(_)
495 ));
496 assert!(matches!(
497 replace_inline_code_cow("`unclosed"),
498 std::borrow::Cow::Borrowed(_)
499 ));
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_emphasis_marker_parsing() {
509 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
510 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
513 assert_eq!(markers.len(), 4); }
515
516 #[test]
517 fn test_single_emphasis_span_detection() {
518 let markers = find_emphasis_markers("This has *valid* emphasis and **strong** too");
519 let spans = find_single_emphasis_spans("This has *valid* emphasis and **strong** too", &markers);
520 assert_eq!(spans.len(), 1); assert_eq!(spans[0].content, "valid");
522 assert!(!spans[0].has_leading_space);
523 assert!(!spans[0].has_trailing_space);
524 }
525
526 #[test]
527 fn test_emphasis_with_spaces() {
528 let markers = find_emphasis_markers("This has * invalid * emphasis");
529 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
530 assert_eq!(spans.len(), 1);
531 assert_eq!(spans[0].content, " invalid ");
532 assert!(spans[0].has_leading_space);
533 assert!(spans[0].has_trailing_space);
534 }
535
536 #[test]
537 fn test_single_emphasis_rejects_whitespace_flanked_runs() {
538 let line = "foo * bar * baz";
542 let markers = find_emphasis_markers(line);
543 let spans = find_single_emphasis_spans(line, &markers);
544 assert!(
545 spans.is_empty(),
546 "whitespace-flanked run must not be a single-emphasis span: {spans:?}"
547 );
548
549 let md037_spans = find_emphasis_spans(line, &markers);
553 assert_eq!(
554 md037_spans.len(),
555 1,
556 "MD037's span finder must still detect the spaced run: {md037_spans:?}"
557 );
558 assert_eq!(md037_spans[0].content, " bar ");
559 }
560
561 #[test]
562 fn test_valid_emphasis_ranges() {
563 let ranges = |line: &str| {
564 let markers = find_emphasis_markers(line);
565 find_valid_emphasis_ranges(line, &markers)
566 };
567
568 assert_eq!(ranges("a *foo* b"), vec![(2, 7)]);
570 assert_eq!(ranges("a **foo** b"), vec![(2, 9)]);
571
572 assert_eq!(ranges("*foo * bar*"), vec![(0, 11)]);
574 assert_eq!(ranges("**foo ** bar**"), vec![(0, 14)]);
575
576 assert!(ranges("foo * bar * baz").is_empty());
578 assert!(ranges("** spaced **").is_empty());
579 assert!(ranges("* item only").is_empty());
581 }
582
583 #[test]
584 fn test_single_emphasis_spans_literal_marker_inside_emphasis() {
585 let line = "*foo * bar*";
589 let markers = find_emphasis_markers(line);
590 let spans = find_single_emphasis_spans(line, &markers);
591 assert_eq!(spans.len(), 1, "outer emphasis must be detected: {spans:?}");
592 assert_eq!(spans[0].content, "foo * bar");
593
594 let line = "*a *b*";
597 let markers = find_emphasis_markers(line);
598 let spans = find_single_emphasis_spans(line, &markers);
599 assert_eq!(spans.len(), 1, "only inner emphasis: {spans:?}");
600 assert_eq!(spans[0].content, "b");
601 }
602
603 #[test]
604 fn test_mixed_markers() {
605 let markers = find_emphasis_markers("This has *asterisk* and _underscore_ emphasis");
606 let spans = find_single_emphasis_spans("This has *asterisk* and _underscore_ emphasis", &markers);
607 assert_eq!(spans.len(), 2);
608 assert_eq!(spans[0].opening.as_char(), '*');
609 assert_eq!(spans[1].opening.as_char(), '_');
610 }
611
612 #[test]
613 fn test_template_shortcode_detection() {
614 assert!(has_doc_patterns(
616 "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}"
617 ));
618 assert!(has_doc_patterns(
619 "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}"
620 ));
621 assert!(has_doc_patterns("{* file.py *}"));
623 assert!(has_doc_patterns("{* ../path/to/file.py ln[1-10] *}"));
625
626 assert!(!has_doc_patterns("This has *emphasis* text"));
628 assert!(!has_doc_patterns("This has * spaces * in emphasis"));
629 assert!(!has_doc_patterns("{* incomplete"));
631 }
632
633 #[test]
634 fn test_doc_pattern_rejects_spaced_bold_metadata() {
635 assert!(has_doc_patterns("**Key**: value"));
637 assert!(has_doc_patterns("**Name**: another value"));
638 assert!(has_doc_patterns("**X**: single char"));
639 assert!(has_doc_patterns("* **Key**: list item with bold key"));
640
641 assert!(!has_doc_patterns("** Key**: value"));
644 assert!(!has_doc_patterns("**Key **: value"));
645 assert!(!has_doc_patterns("** Key **: value"));
646 assert!(!has_doc_patterns(
647 "** Explicit Import**: Convert markdownlint configs to rumdl format:"
648 ));
649 }
650}
651
652#[cfg(test)]
653mod pairing_tests;