From d7a24cdf675ec6f72fc01b710057ab818fccb040 Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 19 Aug 2026 11:03:39 -0700 Subject: [PATCH 1/5] fix(markdown): escape pipes in code spans inside table cells --- src/render/markdown/inline.rs | 10 ++++++++-- src/render/markdown/table.rs | 4 ++-- src/render/markdown/tests.rs | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index 1461e338..a49f2dd4 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -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 { @@ -232,9 +232,15 @@ 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', " "); let fence = backtick_fence(&text, 1); let pad = if text.starts_with('`') || text.ends_with('`') { " " } else { "" }; + // A row is split into cells before any code span is parsed, so a pipe is + // syntax here even though everything else between the fences is literal. + let text = match ctx { + InlineContext::TableCell => text.replace('|', "\\|"), + _ => text, + }; let _ = write!(out, "{fence}{pad}{text}{pad}{fence}"); } diff --git a/src/render/markdown/table.rs b/src/render/markdown/table.rs index 0c416c67..88f2a3de 100644 --- a/src/render/markdown/table.rs +++ b/src/render/markdown/table.rs @@ -5,7 +5,7 @@ use crate::model::{Block, Cell, CellSlot, Table}; use crate::render::markdown::Ctx; use crate::render::markdown::escape::InlineContext; -use crate::render::markdown::inline::render_inlines; +use crate::render::markdown::inline::{push_code_span, render_inlines}; struct RenderedCell { text: String, @@ -166,7 +166,7 @@ fn cell_block_text(block: &Block, rc: &Ctx, parts: &mut Vec) { let t = text.trim(); if !t.is_empty() { let mut s = String::new(); - crate::render::markdown::inline::push_code_span(t, &mut s); + push_code_span(t, InlineContext::TableCell, &mut s); parts.push(s); } } diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index a4aa8067..e1ee05d3 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -295,6 +295,21 @@ 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::Text { + text: "a | b".into(), + style: Style { code: true, ..Style::PLAIN }, + }]), + Cell::from_inlines(vec![Inline::plain("or")]), + ]], + 0, + )]); + assert_eq!(md, "| | |\n| --- | --- |\n| `a \\| b` | or |\n"); +} + #[test] fn url_angle_brackets_are_encoded_without_bracketing() { let md = doc(vec![Block::Paragraph(vec![Inline::Link { From b93d466347e88b922c2dfda2f50ede3e37133e83 Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 19 Aug 2026 11:49:22 -0700 Subject: [PATCH 2/5] fix(markdown): keep a backslash run from unescaping a cell code span pipe --- src/render/markdown/inline.rs | 28 +++++++++++++++++++++++++++- src/render/markdown/tests.rs | 19 ++++++++----------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index a49f2dd4..e4c6095d 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -239,8 +239,34 @@ pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) { // A row is split into cells before any code span is parsed, so a pipe is // syntax here even though everything else between the fences is literal. let text = match ctx { - InlineContext::TableCell => text.replace('|', "\\|"), + InlineContext::TableCell => escape_cell_pipes(&text), _ => text, }; let _ = write!(out, "{fence}{pad}{text}{pad}{fence}"); } + +/// Escape the pipes in a code span's text so the row cannot split on them. +/// +/// A backslash run already sitting in front of a pipe would pair off with the +/// escape and leave the pipe bare, so it is doubled to keep the escape intact. +/// That doubling survives into the rendered code span: GFM has no encoding for +/// a code span that contains a backslash immediately before a pipe, and an +/// intact row is worth more than the exact backslash count. +fn escape_cell_pipes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut backslashes = 0; + for c in text.chars() { + match c { + '|' => { + for _ in 0..=backslashes { + out.push('\\'); + } + backslashes = 0; + } + '\\' => backslashes += 1, + _ => backslashes = 0, + } + out.push(c); + } + out +} diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index e1ee05d3..b6ea835e 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -297,17 +297,14 @@ fn url_pipes_cannot_split_table_cells() { #[test] fn code_span_pipes_cannot_split_table_cells() { - let md = doc(vec![table_from( - vec![vec![ - Cell::from_inlines(vec![Inline::Text { - text: "a | b".into(), - style: Style { code: true, ..Style::PLAIN }, - }]), - Cell::from_inlines(vec![Inline::plain("or")]), - ]], - 0, - )]); - assert_eq!(md, "| | |\n| --- | --- |\n| `a \\| b` | or |\n"); + let code = |t: &str| { + Cell::from_inlines(vec![Inline::Text { + text: t.into(), + style: Style { code: true, ..Style::PLAIN }, + }]) + }; + let md = doc(vec![table_from(vec![vec![code("a | b"), code(r"a \| b")]], 0)]); + assert_eq!(md, concat!("| | |\n", "| --- | --- |\n", r"| `a \| b` | `a \\\| b` |", "\n")); } #[test] From 043e9aa61ae0a0ca76ed26f7df98d06cfa8de62b Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 19 Aug 2026 11:54:50 -0700 Subject: [PATCH 3/5] refactor(markdown): move cell pipe escaping next to the other pipe rules --- src/render/markdown/escape.rs | 26 ++++++++++++++++++++++++++ src/render/markdown/inline.rs | 29 ++--------------------------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/render/markdown/escape.rs b/src/render/markdown/escape.rs index f2654376..26960a4d 100644 --- a/src/render/markdown/escape.rs +++ b/src/render/markdown/escape.rs @@ -179,6 +179,32 @@ pub(crate) fn escape_url_as_text(url: &str, ctx: InlineContext) -> String { } /// Shortest backtick fence longer than any backtick run in `text`. +/// Escape the pipes in a code span's text so the row cannot split on them. +/// +/// A backslash run already sitting in front of a pipe would pair off with the +/// escape and leave the pipe bare, so it is doubled to keep the escape intact. +/// That doubling survives into the rendered code span: GFM has no encoding for +/// a code span that contains a backslash immediately before a pipe, and an +/// intact row is worth more than the exact backslash count. +pub(crate) fn escape_cell_pipes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut backslashes = 0; + for c in text.chars() { + match c { + '|' => { + for _ in 0..=backslashes { + out.push('\\'); + } + backslashes = 0; + } + '\\' => backslashes += 1, + _ => backslashes = 0, + } + out.push(c); + } + out +} + pub(crate) fn backtick_fence(text: &str, min: usize) -> String { let longest_run = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); "`".repeat((longest_run + 1).max(min)) diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index e4c6095d..ade06c3c 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -3,7 +3,8 @@ use crate::model::{ImageSource, Inline, LinkTarget, Style, inlines_are_empty}; use crate::render::markdown::Ctx; use crate::render::markdown::escape::{ - EscapeOpts, InlineContext, backtick_fence, escape_text, escape_url_as_text, format_url, + EscapeOpts, InlineContext, backtick_fence, escape_cell_pipes, escape_text, escape_url_as_text, + format_url, }; use std::borrow::Cow; use std::fmt::Write as _; @@ -244,29 +245,3 @@ pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) { }; let _ = write!(out, "{fence}{pad}{text}{pad}{fence}"); } - -/// Escape the pipes in a code span's text so the row cannot split on them. -/// -/// A backslash run already sitting in front of a pipe would pair off with the -/// escape and leave the pipe bare, so it is doubled to keep the escape intact. -/// That doubling survives into the rendered code span: GFM has no encoding for -/// a code span that contains a backslash immediately before a pipe, and an -/// intact row is worth more than the exact backslash count. -fn escape_cell_pipes(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut backslashes = 0; - for c in text.chars() { - match c { - '|' => { - for _ in 0..=backslashes { - out.push('\\'); - } - backslashes = 0; - } - '\\' => backslashes += 1, - _ => backslashes = 0, - } - out.push(c); - } - out -} From ff42667d4d02275afd1665a30cadf7382d3fcfa1 Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 19 Aug 2026 11:55:51 -0700 Subject: [PATCH 4/5] docs(markdown): restore the backtick_fence doc comment --- src/render/markdown/escape.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/render/markdown/escape.rs b/src/render/markdown/escape.rs index 26960a4d..954dbf17 100644 --- a/src/render/markdown/escape.rs +++ b/src/render/markdown/escape.rs @@ -178,7 +178,6 @@ pub(crate) fn escape_url_as_text(url: &str, ctx: InlineContext) -> String { ) } -/// Shortest backtick fence longer than any backtick run in `text`. /// Escape the pipes in a code span's text so the row cannot split on them. /// /// A backslash run already sitting in front of a pipe would pair off with the @@ -205,6 +204,7 @@ pub(crate) fn escape_cell_pipes(text: &str) -> String { out } +/// Shortest backtick fence longer than any backtick run in `text`. pub(crate) fn backtick_fence(text: &str, min: usize) -> String { let longest_run = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); "`".repeat((longest_run + 1).max(min)) From 85d36060e8eb77fca9206da4b6879bf0f6b235c2 Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 19 Aug 2026 11:57:09 -0700 Subject: [PATCH 5/5] refactor(markdown): name the cell code span escaper for what it does --- src/render/markdown/escape.rs | 5 +++-- src/render/markdown/inline.rs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/render/markdown/escape.rs b/src/render/markdown/escape.rs index 954dbf17..28ddb277 100644 --- a/src/render/markdown/escape.rs +++ b/src/render/markdown/escape.rs @@ -178,14 +178,15 @@ pub(crate) fn escape_url_as_text(url: &str, ctx: InlineContext) -> String { ) } -/// Escape the pipes in a code span's text so the row cannot split on them. +/// Prepare a code span's text for a table cell, where a pipe is the only +/// character between the fences that is still syntax. /// /// A backslash run already sitting in front of a pipe would pair off with the /// escape and leave the pipe bare, so it is doubled to keep the escape intact. /// That doubling survives into the rendered code span: GFM has no encoding for /// a code span that contains a backslash immediately before a pipe, and an /// intact row is worth more than the exact backslash count. -pub(crate) fn escape_cell_pipes(text: &str) -> String { +pub(crate) fn escape_cell_code_span(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut backslashes = 0; for c in text.chars() { diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index ade06c3c..83a25699 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -3,8 +3,8 @@ use crate::model::{ImageSource, Inline, LinkTarget, Style, inlines_are_empty}; use crate::render::markdown::Ctx; use crate::render::markdown::escape::{ - EscapeOpts, InlineContext, backtick_fence, escape_cell_pipes, escape_text, escape_url_as_text, - format_url, + EscapeOpts, InlineContext, backtick_fence, escape_cell_code_span, escape_text, + escape_url_as_text, format_url, }; use std::borrow::Cow; use std::fmt::Write as _; @@ -240,7 +240,7 @@ pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) { // A row is split into cells before any code span is parsed, so a pipe is // syntax here even though everything else between the fences is literal. let text = match ctx { - InlineContext::TableCell => escape_cell_pipes(&text), + InlineContext::TableCell => escape_cell_code_span(&text), _ => text, }; let _ = write!(out, "{fence}{pad}{text}{pad}{fence}");