diff --git a/src/utils/text_reflow.rs b/src/utils/text_reflow.rs index d5061030..bf11f2a2 100644 --- a/src/utils/text_reflow.rs +++ b/src/utils/text_reflow.rs @@ -746,16 +746,21 @@ struct EmphasisSpan { /// - GFM strikethrough (~~text~~) /// /// Returns spans sorted by start position. -fn extract_emphasis_spans(text: &str) -> Vec { - // Every emphasis, strong, or strikethrough marker starts with one of these - // bytes, so their absence rules out any span without running the parser. - if !text.contains(['*', '_', '~']) { - return Vec::new(); +fn extract_emphasis_and_code_spans(text: &str) -> (Vec, Vec) { + // If neither marker is present, skip the parser entirely. + let has_emphasis = text.contains(['*', '_', '~']); + let has_code = text.contains('`'); + if !has_emphasis && !has_code { + return (Vec::new(), Vec::new()); } - let mut spans = Vec::new(); + let mut emphasis_spans = Vec::new(); + let mut code_spans = Vec::new(); + let mut options = Options::empty(); - options.insert(Options::ENABLE_STRIKETHROUGH); + if has_emphasis { + options.insert(Options::ENABLE_STRIKETHROUGH); + } // Stacks to track nested formatting with their start positions let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); // (start_byte, uses_underscore) @@ -766,6 +771,12 @@ fn extract_emphasis_spans(text: &str) -> Vec { for (event, range) in parser { match event { + Event::Code(_) => { + code_spans.push(CodeSpan { + start: range.start, + end: range.end, + }); + } Event::Start(Tag::Emphasis) => { // Check if this uses underscore by looking at the original text let uses_underscore = text.get(range.start..range.start + 1) == Some("_"); @@ -773,13 +784,12 @@ fn extract_emphasis_spans(text: &str) -> Vec { } Event::End(TagEnd::Emphasis) => { if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() { - // Extract content between the markers (1 char marker on each side) let content_start = start_byte + 1; let content_end = range.end - 1; if content_end > content_start && let Some(content) = text.get(content_start..content_end) { - spans.push(EmphasisSpan { + emphasis_spans.push(EmphasisSpan { start: start_byte, end: range.end, content: content.to_string(), @@ -792,19 +802,17 @@ fn extract_emphasis_spans(text: &str) -> Vec { } } Event::Start(Tag::Strong) => { - // Check if this uses underscore by looking at the original text let uses_underscore = text.get(range.start..range.start + 2) == Some("__"); strong_stack.push((range.start, uses_underscore)); } Event::End(TagEnd::Strong) => { if let Some((start_byte, uses_underscore)) = strong_stack.pop() { - // Extract content between the markers (2 char marker on each side) let content_start = start_byte + 2; let content_end = range.end - 2; if content_end > content_start && let Some(content) = text.get(content_start..content_end) { - spans.push(EmphasisSpan { + emphasis_spans.push(EmphasisSpan { start: start_byte, end: range.end, content: content.to_string(), @@ -821,9 +829,6 @@ fn extract_emphasis_spans(text: &str) -> Vec { } Event::End(TagEnd::Strikethrough) => { if let Some(start_byte) = strikethrough_stack.pop() { - // pulldown-cmark's GFM strikethrough accepts both ~~text~~ and - // ~text~. Detect the actual marker width so single-tilde spans - // keep their first and last content character. let double = text.get(start_byte..start_byte + 2) == Some("~~"); let marker_len = if double { 2 } else { 1 }; let content_start = start_byte + marker_len; @@ -831,7 +836,7 @@ fn extract_emphasis_spans(text: &str) -> Vec { if content_end > content_start && let Some(content) = text.get(content_start..content_end) { - spans.push(EmphasisSpan { + emphasis_spans.push(EmphasisSpan { start: start_byte, end: range.end, content: content.to_string(), @@ -847,9 +852,8 @@ fn extract_emphasis_spans(text: &str) -> Vec { } } - // Sort by start position - spans.sort_by_key(|s| s.start); - spans + emphasis_spans.sort_by_key(|s| s.start); + (emphasis_spans, code_spans) } #[derive(Debug, Clone)] @@ -988,7 +992,7 @@ fn extract_link_spans(text: &str, defined_references: Option<&HashSet>) /// a `{`, a name starting with an ASCII letter or `_` and continuing with /// alphanumerics / `-` / `_` / `:` / `.`, a closing `}`, then a balanced inline /// code span using one or more backticks. Returns `None` when any part is missing. -fn myst_role_len_at(text: &str) -> Option { +fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option { let bytes = text.as_bytes(); if bytes.first() != Some(&b'{') { return None; @@ -1013,26 +1017,11 @@ fn myst_role_len_at(text: &str) -> Option { j += 1; // past '}' // Must be immediately followed by an inline code span. - if bytes.get(j) != Some(&b'`') { - return None; - } - let backtick_start = j; - while bytes.get(j) == Some(&b'`') { - j += 1; - } - let backtick_count = j - backtick_start; - - // Find the matching run of `backtick_count` backticks. - while j + backtick_count <= bytes.len() { - if bytes[j] == b'`' { - let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count(); - if close_count == backtick_count { - return Some(j + close_count); - } - j += close_count; - } else { - j += 1; - } + let code_span_start = absolute_pos + j; + if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) { + let span = &code_spans[idx]; + let code_span_len = span.end - span.start; + return Some(j + code_span_len); } None @@ -1065,15 +1054,11 @@ fn parse_markdown_elements_inner( let mut remaining = text; // Pre-extract emphasis spans, link spans, and code spans using pulldown-cmark. - // These run as separate parses rather than one shared parser: link resolution - // (the broken-link callback, needed to keep undefined shortcuts/references - // atomic) changes which bracket runs collapse into link nodes, which shifts - // where nearby emphasis delimiters pair up in edge cases (e.g. an unresolved - // shortcut containing `**`). Sharing a parser would leak that shift into - // emphasis_spans; each parse keeps its own event stream self-consistent. - let emphasis_spans = extract_emphasis_spans(text); + // Emphasis and code spans are extracted in a single shared parse to reduce cmark overhead. + // Link spans must run as a separate parse because link resolution (the broken-link + // callback) changes bracket collapses, which shifts delimiter range boundaries. + let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text); let link_spans = extract_link_spans(text, defined_references); - let code_spans = extract_code_spans(text); // Caching state to avoid O(N^2) worst case on long inputs let mut cached_wiki_link: Option> = None; @@ -1331,7 +1316,7 @@ fn parse_markdown_elements_inner( if myst_roles && let Some(pos) = next_curly_pos && pos < next_special - && let Some(role_len) = myst_role_len_at(&remaining[pos..]) + && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans) { next_special = pos; special_type = "myst_role"; @@ -1482,11 +1467,7 @@ fn parse_markdown_elements_inner( elements.push(Element::HtmlTag(remaining[pos..match_end].to_string())); remaining = &remaining[match_end..]; } - _ => { - // Unknown pattern, treat as text - elements.push(Element::Text("[".to_string())); - remaining = &remaining[1..]; - } + _ => unreachable!("unknown pattern type: {}", pattern_type), } } else { // Process non-link special characters @@ -1516,30 +1497,25 @@ fn parse_markdown_elements_inner( } "pulldown_emphasis" => { // Use pre-extracted emphasis/strikethrough span from pulldown-cmark - if let Some(span) = pulldown_emphasis { - let span_len = span.end - span.start; - if span.is_strikethrough { - elements.push(Element::Strikethrough { - content: span.content.clone(), - double: span.strikethrough_double, - }); - } else if span.is_strong { - elements.push(Element::Bold { - content: span.content.clone(), - underscore: span.uses_underscore, - }); - } else { - elements.push(Element::Italic { - content: span.content.clone(), - underscore: span.uses_underscore, - }); - } - remaining = &remaining[span_len..]; + let span = pulldown_emphasis.expect("pulldown_emphasis must be set"); + let span_len = span.end - span.start; + if span.is_strikethrough { + elements.push(Element::Strikethrough { + content: span.content.clone(), + double: span.strikethrough_double, + }); + } else if span.is_strong { + elements.push(Element::Bold { + content: span.content.clone(), + underscore: span.uses_underscore, + }); } else { - // Fallback - shouldn't happen - elements.push(Element::Text(remaining[..1].to_string())); - remaining = &remaining[1..]; + elements.push(Element::Italic { + content: span.content.clone(), + underscore: span.uses_underscore, + }); } + remaining = &remaining[span_len..]; } _ => { // No special elements found, add all remaining text @@ -1550,7 +1526,21 @@ fn parse_markdown_elements_inner( } } - elements + // Merge contiguous text elements to clean up the output. + let mut merged_elements = Vec::new(); + for el in elements { + match el { + Element::Text(s) => { + if let Some(Element::Text(last_s)) = merged_elements.last_mut() { + last_s.push_str(&s); + } else { + merged_elements.push(Element::Text(s)); + } + } + other => merged_elements.push(other), + } + } + merged_elements } fn should_insert_space_before_join(current: &str) -> bool {