From 75c4313a191169c9aa8877dda70898dc8b909c71 Mon Sep 17 00:00:00 2001 From: abhinavmir Date: Thu, 6 Aug 2026 15:01:30 -0700 Subject: [PATCH] fix: keep delimiters from pairing across runs in one paragraph A delimiter with nothing to pair with is inert, so it was left unescaped. That check ran over one run, but Markdown pairs across the whole paragraph: two runs each ending in a backtick put two of them on the line, and the text between became a code span. Emphasis and links went the same way, so a paragraph reading `see [` / `note](http://example.com)` came out as a working hyperlink the document never had. Runs that still have content after them now escape their pairable delimiters, which leaves at most one raw delimiter per paragraph, at the end, where nothing can reach it. Link labels and image alt text count as having content after them, since `](url)` always follows. Across 684 generated paragraphs pairing delimiters over a hard break, 44 converted to markup that was not in the document; none do now. The fixture corpus is unchanged. --- src/render/markdown/escape.rs | 10 +++++-- src/render/markdown/inline.rs | 51 +++++++++++++++++++++++++++-------- src/render/markdown/tests.rs | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/render/markdown/escape.rs b/src/render/markdown/escape.rs index 5bd9a82..cacf820 100644 --- a/src/render/markdown/escape.rs +++ b/src/render/markdown/escape.rs @@ -23,6 +23,11 @@ pub(crate) struct EscapeOpts { /// The character following the run is unknown or active markup; pairable /// delimiters must assume the worst. pub trailing_active: bool, + /// More of the same paragraph follows this run. A delimiter is left + /// unescaped when nothing in the run pairs with it, but Markdown pairs + /// across the whole paragraph, not one run: the partner can arrive in a + /// later run, with a line break or an anchor in between. + pub pairs_ahead: bool, /// Inside a link label / image alt, where an unmatched `]` (or `[`) /// would terminate the label early. pub in_label: bool, @@ -30,7 +35,7 @@ pub(crate) struct EscapeOpts { /// Escape Markdown syntax in document text. pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> String { - let EscapeOpts { at_line_start, styled, trailing_active, in_label } = opts; + let EscapeOpts { at_line_start, styled, trailing_active, pairs_ahead, in_label } = opts; let chars: Vec = text.chars().collect(); // Last position of each pairable delimiter; a lone one is inert. let mut last: [Option; 5] = [None; 5]; // * _ ~ ` ] @@ -64,7 +69,8 @@ pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> S let next = chars.get(i + 1).copied(); // At the run's end the next character is unknown; trailing_active assumes the worst. let next_nonspace = next.map_or(trailing_active, |n| !n.is_whitespace()); - let paired = |slot: usize| trailing_active || last[slot].is_some_and(|j| j > i); + let paired = + |slot: usize| trailing_active || pairs_ahead || last[slot].is_some_and(|j| j > i); let escape = match c { '\\' => true, ']' if in_label => true, diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index 6c66951..58d1f96 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -79,13 +79,23 @@ pub(crate) fn normalize<'a>(inlines: &'a [Inline], rc: &Ctx) -> Vec> { } pub(crate) fn render_inlines(inlines: &[Inline], ctx: InlineContext, rc: &Ctx) -> String { - render_inlines_mode(inlines, ctx, false, rc) + render_inlines_mode(inlines, ctx, false, false, rc) } -fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, rc: &Ctx) -> String { +/// `more_follows` marks a nested render whose output lands mid-paragraph (a +/// link label, or link content that degraded to plain text), so its last run +/// still has content after it even though its own run list has ended. +fn render_inlines_mode( + inlines: &[Inline], + ctx: InlineContext, + in_label: bool, + more_follows: bool, + rc: &Ctx, +) -> String { let runs = normalize(inlines, rc); let mut out = String::new(); for (idx, run) in runs.iter().enumerate() { + let pairs_ahead = more_follows || idx + 1 < runs.len(); match run { Norm::Text { text, style } => { let next_active = matches!( @@ -95,15 +105,19 @@ fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, r runs.get(idx + 1), Some(Norm::Text { style, .. }) if *style != Style::PLAIN ); - render_text_run(text, *style, ctx, next_active, in_label, &mut out) + render_text_run(text, *style, ctx, next_active, pairs_ahead, in_label, &mut out) } Norm::NoteRef(id) => { if let Some(num) = rc.nums.get(*id) { let _ = write!(out, "[^{num}]"); } } - Norm::Link { content, target } => render_link(content, target, ctx, rc, &mut out), - Norm::Image { alt, source } => render_image(alt, source, ctx, in_label, &mut out), + Norm::Link { content, target } => { + render_link(content, target, ctx, pairs_ahead, rc, &mut out) + } + Norm::Image { alt, source } => { + render_image(alt, source, ctx, in_label, pairs_ahead, &mut out) + } Norm::Anchor(id) => { if let Some(html_id) = rc.anchors.html_id(id) { let _ = write!(out, ""); @@ -123,10 +137,13 @@ fn render_link( content: &[Inline], target: &LinkTarget, ctx: InlineContext, + pairs_ahead: bool, rc: &Ctx, out: &mut String, ) { - let label = render_inlines_mode(content, ctx, true, rc); + // The label is always followed by `](url)`, so its runs never end the + // paragraph. + let label = render_inlines_mode(content, ctx, true, true, rc); let url = match target { LinkTarget::External(url) | LinkTarget::Relative(url) => url.clone(), LinkTarget::Anchor(id) => match rc.anchors.fragment(id) { @@ -134,7 +151,7 @@ fn render_link( None => { // Target exists nowhere in the document: degrade to plain text. log::debug!("unresolved internal link target: {id}"); - out.push_str(&render_inlines_mode(content, ctx, false, rc)); + out.push_str(&render_inlines_mode(content, ctx, false, pairs_ahead, rc)); return; } }, @@ -156,12 +173,17 @@ fn render_image( source: &ImageSource, ctx: InlineContext, in_label: bool, + pairs_ahead: bool, out: &mut String, ) { match source { ImageSource::External(url) => { - let alt = - escape_text(alt.trim(), ctx, EscapeOpts { in_label: true, ..Default::default() }); + // The alt text is always followed by `](url)`. + let alt = escape_text( + alt.trim(), + ctx, + EscapeOpts { in_label: true, pairs_ahead: true, ..Default::default() }, + ); let _ = write!(out, "![{}]({})", alt, format_url(url)); } // Embedded assets render as their alt text: Markdown cannot embed @@ -172,7 +194,7 @@ fn render_image( out.push_str(&escape_text( alt.trim(), ctx, - EscapeOpts { in_label, ..Default::default() }, + EscapeOpts { in_label, pairs_ahead, ..Default::default() }, )); } } @@ -185,6 +207,7 @@ fn render_text_run( style: Style, ctx: InlineContext, trailing_active: bool, + pairs_ahead: bool, in_label: bool, out: &mut String, ) { @@ -193,7 +216,13 @@ fn render_text_run( out.push_str(&escape_text( text, ctx, - EscapeOpts { at_line_start, trailing_active, in_label, ..Default::default() }, + EscapeOpts { + at_line_start, + trailing_active, + pairs_ahead, + in_label, + ..Default::default() + }, )); return; } diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index 463bbfa..e288550 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -337,6 +337,57 @@ fn hard_break() { assert_eq!(md, "line one\\\nline two\n"); } +#[test] +fn delimiters_cannot_pair_across_a_hard_break() { + // A delimiter is inert only while nothing pairs with it. Each run here + // holds one, and a hard break does not end the paragraph, so the pair + // would form a code span, emphasis, or a link out of document text. + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("a `"), + Inline::LineBreak, + Inline::plain("b `"), + ])]); + assert_eq!(md, "a \\`\\\nb `\n"); + + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("a *x"), + Inline::LineBreak, + Inline::plain("y* b"), + ])]); + assert_eq!(md, "a \\*x\\\ny* b\n"); + + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("see ["), + Inline::LineBreak, + Inline::plain("note](http://example.com)"), + ])]); + assert_eq!(md, "see \\[\\\nnote](http://example.com)\n"); +} + +#[test] +fn a_pasted_code_fence_stays_literal() { + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("```"), + Inline::LineBreak, + Inline::plain("let x = 1;"), + Inline::LineBreak, + Inline::plain("```"), + ])]); + assert_eq!(md, "\\`\\`\\`\\\nlet x = 1;\\\n\\`\\``\n"); +} + +#[test] +fn a_delimiter_with_nothing_after_it_stays_bare() { + // The paragraph's last run has no partner ahead of it, so minimal + // escaping still applies. + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("intro"), + Inline::LineBreak, + Inline::plain("only one `"), + ])]); + assert_eq!(md, "intro\\\nonly one `\n"); +} + #[test] fn line_start_escape_after_hard_break() { let md = doc(vec![Block::Paragraph(vec![