rumdl_lib/utils/
emphasis_utils.rs1use 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
57#[inline]
60pub fn replace_inline_code(line: &str) -> String {
61 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 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
86pub fn replace_inline_math(line: &str) -> String {
89 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 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#[inline]
114pub fn find_emphasis_markers(line: &str) -> Vec<EmphasisMarker> {
115 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 while i + (count as usize) < bytes.len() && bytes[i + (count as usize)] == byte && count < 3 {
132 count += 1;
133 }
134
135 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
153pub fn find_single_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
155 if markers.len() < 2 {
157 return Vec::new();
158 }
159
160 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 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 for j in (i + 1)..markers.len() {
188 if used_markers[j] {
189 continue;
190 }
191
192 let closing = &markers[j];
193
194 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 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
204 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 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 used_markers[i] = true;
231 used_markers[j] = true;
232 break;
233 }
234 }
235 }
236 }
237 }
238 }
239
240 spans
241}
242
243pub fn find_emphasis_spans(line: &str, markers: &[EmphasisMarker]) -> Vec<EmphasisSpan> {
245 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 for i in 0..markers.len() {
255 if used_markers[i] {
256 continue;
257 }
258
259 let opening = &markers[i];
260
261 for j in (i + 1)..markers.len() {
263 if used_markers[j] {
264 continue;
265 }
266
267 let closing = &markers[j];
268
269 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 if is_valid_emphasis_content_fast(content) && is_valid_emphasis_span_fast(line, opening, closing) {
279 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 used_markers[i] = true;
298 used_markers[j] = true;
299 break;
300 }
301 }
302 }
303 }
304 }
305 }
306
307 spans
308}
309
310pub 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 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 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#[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 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 let bytes = line.as_bytes();
399
400 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 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#[inline]
439fn is_valid_emphasis_content_fast(content: &str) -> bool {
440 !content.trim().is_empty()
441}
442
443pub fn has_doc_patterns(line: &str) -> bool {
445 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); let markers = find_emphasis_markers("*start* and *end*");
465 assert_eq!(markers.len(), 4); }
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); 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 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 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 assert_eq!(ranges("a *foo* b"), vec![(2, 7)]);
522 assert_eq!(ranges("a **foo** b"), vec![(2, 9)]);
523
524 assert_eq!(ranges("*foo * bar*"), vec![(0, 11)]);
526 assert_eq!(ranges("**foo ** bar**"), vec![(0, 14)]);
527
528 assert!(ranges("foo * bar * baz").is_empty());
530 assert!(ranges("** spaced **").is_empty());
531 assert!(ranges("* item only").is_empty());
533 }
534
535 #[test]
536 fn test_single_emphasis_spans_literal_marker_inside_emphasis() {
537 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 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 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 assert!(has_doc_patterns("{* file.py *}"));
575 assert!(has_doc_patterns("{* ../path/to/file.py ln[1-10] *}"));
577
578 assert!(!has_doc_patterns("This has *emphasis* text"));
580 assert!(!has_doc_patterns("This has * spaces * in emphasis"));
581 assert!(!has_doc_patterns("{* incomplete"));
583 }
584
585 #[test]
586 fn test_doc_pattern_rejects_spaced_bold_metadata() {
587 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 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}