Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/render/markdown/escape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ 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,
}

/// 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<char> = text.chars().collect();
// Last position of each pairable delimiter; a lone one is inert.
let mut last: [Option<usize>; 5] = [None; 5]; // * _ ~ ` ]
Expand Down Expand Up @@ -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);

@cubic-dev-ai cubic-dev-ai Bot Aug 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new pairs_ahead in paired does not escape a *, _, or ~ that sits at the very end of a run and is followed by more of the paragraph (a line break or anchor then more plain text). For those characters the escape is gated by next_nonspace, which at run end falls back to trailing_active (false when the next run is plain), so the trailing delimiter stays raw and can pair with a later run's raw delimiter — e.g. ["a *", LineBreak, "b *"] renders two unescaped * in one paragraph, defeating the fix. Consider making the run-end next_nonspace fall back to trailing_active || pairs_ahead (or short-circuit on pairs_ahead) so a delimiter before more paragraph content is always escaped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/render/markdown/escape.rs, line 73:

<comment>The new `pairs_ahead` in `paired` does not escape a `*`, `_`, or `~` that sits at the very end of a run and is followed by more of the paragraph (a line break or anchor then more plain text). For those characters the escape is gated by `next_nonspace`, which at run end falls back to `trailing_active` (false when the next run is plain), so the trailing delimiter stays raw and can pair with a later run's raw delimiter — e.g. ["a *", LineBreak, "b *"] renders two unescaped `*` in one paragraph, defeating the fix. Consider making the run-end `next_nonspace` fall back to `trailing_active || pairs_ahead` (or short-circuit on `pairs_ahead`) so a delimiter before more paragraph content is always escaped.</comment>

<file context>
@@ -64,7 +69,8 @@ pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> S
         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,
</file context>
Fix with cubic

let escape = match c {
'\\' => true,
']' if in_label => true,
Expand Down
51 changes: 40 additions & 11 deletions src/render/markdown/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,23 @@ pub(crate) fn normalize<'a>(inlines: &'a [Inline], rc: &Ctx) -> Vec<Norm<'a>> {
}

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!(
Expand All @@ -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, "<a id=\"{html_id}\"></a>");
Expand All @@ -123,18 +137,21 @@ 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) {
Some(fragment) => format!("#{fragment}"),
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;
}
},
Expand All @@ -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
Expand All @@ -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() },
));
}
}
Expand All @@ -185,6 +207,7 @@ fn render_text_run(
style: Style,
ctx: InlineContext,
trailing_active: bool,
pairs_ahead: bool,
in_label: bool,
out: &mut String,
) {
Expand All @@ -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;
}
Expand Down
51 changes: 51 additions & 0 deletions src/render/markdown/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down