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
6 changes: 4 additions & 2 deletions src/render/markdown/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ fn render_text_run(
}
if !core.is_empty() {
if style.code {
push_code_span(core, out);
push_code_span(core, ctx, out);
} else {
let mut open = String::new();
if style.strike {
Expand All @@ -232,8 +232,10 @@ fn render_text_run(
}
}

pub(crate) fn push_code_span(text: &str, out: &mut String) {
pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) {
let text = text.replace('\n', " ");
// A raw pipe splits a GFM table cell even inside a code span.
let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 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.

P1: When code content already has an odd number of backslashes before |, this replacement makes the run even and the generated GFM row can still split at that pipe. Add a backslash only when the existing run is even, and cover code such as a \| b with a regression test.

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

<comment>When code content already has an odd number of backslashes before `|`, this replacement makes the run even and the generated GFM row can still split at that pipe. Add a backslash only when the existing run is even, and cover code such as `a \| b` with a regression test.</comment>

<file context>
@@ -232,8 +232,10 @@ fn render_text_run(
+pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) {
     let text = text.replace('\n', " ");
+    // A raw pipe splits a GFM table cell even inside a code span.
+    let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };
     let fence = backtick_fence(&text, 1);
     let pad = if text.starts_with('`') || text.ends_with('`') { " " } else { "" };
</file context>
Suggested change
let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };
let text = if ctx == InlineContext::TableCell {
let mut escaped = String::with_capacity(text.len());
let mut backslashes = 0;
for c in text.chars() {
if c == '|' && backslashes % 2 == 0 {
escaped.push('\\');
}
escaped.push(c);
backslashes = if c == '\\' { backslashes + 1 } else { 0 };
}
escaped
} else {
text
};
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the careful look — I checked this against the GFM reference implementation and against GitHub itself, and the row cannot split there: GFM's cell scanning is not parity-based. The cell scanner (ext_scanners.re L32–L35, table_cell = (escaped_char|[^|\r\n])+) matches greedily, so a pipe preceded by any backslash never ends a cell, and unescape_pipes() then strips exactly one backslash before each |. So for code text a \| b, the emitted `a \\| b` renders back as <code>a \| b</code> — an exact round-trip.

Verified via GitHub's own renderer (gh api /markdown):

| A | B |
| --- | --- |
| `one \| two` | X |
| `one \\| two` | Y |

Both rows keep two columns, and the cells come back as <code>one | two</code> and <code>one \| two</code>. pulldown-cmark matches GitHub exactly; comrak also keeps every row intact.

The parity variant would instead leave a \| b unescaped, and GFM unescapes \|| inside code spans too (spec §4.10, example 200), so the backslash would be silently dropped — on every renderer I tested (GitHub, comrak, pulldown-cmark, marked, micromark).

For completeness: marked and micromark do split on `one \\| two` — their cell splitters count backslash parity and deviate from cmark-gfm here. Under those parsers a backslash directly before a pipe inside a code span is unrepresentable either way (escaped, the row splits; unescaped, the backslash is lost), so I kept the encoding that GitHub and the reference implementation render exactly.

Added a regression test covering a \| b in d6fdf04.

let fence = backtick_fence(&text, 1);
let pad = if text.starts_with('`') || text.ends_with('`') { " " } else { "" };
let _ = write!(out, "{fence}{pad}{text}{pad}{fence}");
Expand Down
6 changes: 5 additions & 1 deletion src/render/markdown/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ fn cell_block_text(block: &Block, rc: &Ctx, parts: &mut Vec<String>) {
let t = text.trim();
if !t.is_empty() {
let mut s = String::new();
crate::render::markdown::inline::push_code_span(t, &mut s);
crate::render::markdown::inline::push_code_span(
t,
InlineContext::TableCell,
&mut s,
);
parts.push(s);
}
}
Expand Down
47 changes: 47 additions & 0 deletions src/render/markdown/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,53 @@ fn url_pipes_cannot_split_table_cells() {
assert_eq!(md, "| |\n| --- |\n| [https://e.test/a\\|b](https://e.test/a%7Cb) |\n");
}

#[test]
fn code_span_pipes_cannot_split_table_cells() {
let md = doc(vec![table_from(
vec![
vec![
Cell::from_inlines(vec![Inline::plain("Operator")]),
Cell::from_inlines(vec![Inline::plain("Meaning")]),
],
vec![
Cell::from_inlines(vec![styled("a | b", Style { code: true, ..Style::PLAIN })]),
Cell::from_inlines(vec![Inline::plain("bitwise or")]),
],
],
1,
)]);
assert_eq!(md, "| Operator | Meaning |\n| --- | --- |\n| `a \\| b` | bitwise or |\n");
}

#[test]
fn code_block_pipes_cannot_split_table_cells() {
let cell = Cell::new(vec![Block::CodeBlock { lang: None, text: "ls | wc -l".into() }]);
let md = doc(vec![table_from(vec![vec![cell]], 0)]);
assert_eq!(md, "| |\n| --- |\n| `ls \\| wc -l` |\n");
}

#[test]
fn code_span_backslash_before_pipe_round_trips() {
// GFM's cell scanner never splits at a pipe preceded by a backslash
// (`table_cell = (escaped_char|[^|\r\n])+` is matched greedily, no parity
// counting), and unescape_pipes() strips exactly one backslash before each
// `|`. Emitting `\\|` for code text `\|` therefore renders back as `\|`.
let md = doc(vec![table_from(
vec![
vec![
Cell::from_inlines(vec![Inline::plain("Pattern")]),
Cell::from_inlines(vec![Inline::plain("Meaning")]),
],
vec![
Cell::from_inlines(vec![styled("a \\| b", Style { code: true, ..Style::PLAIN })]),
Cell::from_inlines(vec![Inline::plain("BRE alternation")]),
],
],
1,
)]);
assert_eq!(md, "| Pattern | Meaning |\n| --- | --- |\n| `a \\\\| b` | BRE alternation |\n");
}

#[test]
fn url_angle_brackets_are_encoded_without_bracketing() {
let md = doc(vec![Block::Paragraph(vec![Inline::Link {
Expand Down