Skip to content

Commit 55ad2a8

Browse files
authored
fix(parser): stop panicking on backslash before multibyte chars (#485)
A backslash escape used to slice exactly one byte after the backslash, which panicked on multibyte characters (e.g. \φ), and it stripped the backslash before every character. Per CommonMark, only ASCII punctuation is escapable; before anything else the backslash is literal text and the following character parses normally. Fixes CommonMark spec example 13 in both core and GFM modes.
1 parent 6c0f6ad commit 55ad2a8

3 files changed

Lines changed: 33 additions & 3 deletions

File tree

crates/ox_content_parser/src/parser/inline.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,22 @@ impl<'a> Parser<'a> {
5959
*pos += 2;
6060
}
6161
b'<' => Self::parse_inline_html_or_text(content, offset, children, pos),
62-
b'\\' if *pos + 1 < content.len() => {
62+
b'\\' if *pos + 1 < content.len() && bytes[*pos + 1].is_ascii_punctuation() => {
63+
// A backslash escapes only ASCII punctuation (CommonMark
64+
// "Backslash escapes"). The escaped character is emitted as
65+
// literal text so it can't open any inline construct.
6366
*pos += 1;
6467
let span_start = offset + *pos - 1;
6568
Self::push_text(children, &content[*pos..*pos + 1], span_start, offset + *pos + 1);
6669
*pos += 1;
6770
}
71+
b'\\' => {
72+
// Backslash before anything else (letters, digits, spaces,
73+
// multibyte characters, or end of input) is a literal
74+
// backslash; the following character is parsed normally.
75+
Self::push_text(children, "\\", offset + *pos, offset + *pos + 1);
76+
*pos += 1;
77+
}
6878
b'~' if self.options.strikethrough
6979
&& *pos + 1 < content.len()
7080
&& bytes[*pos + 1] == b'~' =>

crates/ox_content_parser/tests/edge_cases/inline.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ fn escaped_marker_remains_literal_text() {
112112
}
113113
}
114114

115+
#[test]
116+
fn backslash_before_non_punctuation_stays_literal() {
117+
let allocator = Allocator::new();
118+
// CommonMark example 13: only ASCII punctuation is escapable; before
119+
// anything else (including multibyte characters, which used to panic
120+
// on a byte-index slice) the backslash is literal text.
121+
let doc = parse_with_options(&allocator, "\\\t\\A\\a\\ \\3\\φ\\«", ParserOptions::default());
122+
123+
match &doc.children[0] {
124+
Node::Paragraph(paragraph) => {
125+
let text = paragraph
126+
.children
127+
.iter()
128+
.filter_map(first_text)
129+
.collect::<std::vec::Vec<_>>()
130+
.join("");
131+
assert_eq!(text, "\\\t\\A\\a\\ \\3\\φ\\«");
132+
}
133+
other => panic!("expected paragraph, got {other:?}"),
134+
}
135+
}
136+
115137
#[test]
116138
fn unmatched_strikethrough_remains_text() {
117139
let allocator = Allocator::new();

crates/ox_content_renderer/tests/spec_fixtures/commonmark-known-failures.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ core 6 Tabs
99
core 7 Tabs
1010
core 8 Tabs
1111
core 9 Tabs
12-
core 13 Backslash-escapes
1312
core 14 Backslash-escapes
1413
core 17 Backslash-escapes
1514
core 18 Backslash-escapes
@@ -401,7 +400,6 @@ gfm 6 Tabs
401400
gfm 7 Tabs
402401
gfm 8 Tabs
403402
gfm 9 Tabs
404-
gfm 13 Backslash-escapes
405403
gfm 14 Backslash-escapes
406404
gfm 17 Backslash-escapes
407405
gfm 18 Backslash-escapes

0 commit comments

Comments
 (0)