fix(markdown): let a backslash escape a code fence (#2562)

This commit is contained in:
Hampus
2026-09-06 22:45:29 +02:00
committed by GitHub
parent 69ca462930
commit ea6e4a75db
4 changed files with 87 additions and 5 deletions
+26 -4
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::ast::{AlertType, ListItem, Node, ParserFlags, TableAlignment}; use crate::ast::{AlertType, ListItem, Node, ParserFlags, TableAlignment};
use crate::constants::{MAX_AST_NODES, MAX_LINE_LENGTH}; use crate::constants::{CODE_FENCE_LENGTH, MAX_AST_NODES, MAX_LINE_LENGTH};
use crate::links::{has_open_inline_code, has_valid_code_fence_language}; use crate::links::{has_open_inline_code, has_valid_code_fence_language};
use crate::normalize::{normalize_nodes, replace_trailing_whitespace_with_newline}; use crate::normalize::{normalize_nodes, replace_trailing_whitespace_with_newline};
use crate::parser::{MarkdownParser, ParseError, RuntimeState}; use crate::parser::{MarkdownParser, ParseError, RuntimeState};
@@ -157,7 +157,7 @@ pub(crate) fn parse_block(state: &mut RuntimeState<'_>) -> Result<BlockParseResu
} }
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_CODE_BLOCKS) { if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_CODE_BLOCKS) {
if let Some(fence_pos) = line.find("```") { if let Some(fence_pos) = find_unescaped_fence(&line) {
let starts_with_fence = let starts_with_fence =
starts_with(trimmed, "```") && fence_pos == line.len() - trimmed.len(); starts_with(trimmed, "```") && fence_pos == line.len() - trimmed.len();
if starts_with_fence { if starts_with_fence {
@@ -459,7 +459,7 @@ fn parse_code_block(lines: &[Line], current: usize) -> Result<Option<CodeBlockRe
while fence_length < trimmed_line.len() && byte_at(trimmed_line, fence_length) == b'`' { while fence_length < trimmed_line.len() && byte_at(trimmed_line, fence_length) == b'`' {
fence_length += 1; fence_length += 1;
} }
if fence_length < 3 { if fence_length < CODE_FENCE_LENGTH {
return Ok(None); return Ok(None);
} }
let language_part = &trimmed_line[fence_length..]; let language_part = &trimmed_line[fence_length..];
@@ -1197,7 +1197,7 @@ pub(crate) fn opens_code_block_midline(lines: &[Line], index: usize, flags: u32)
return false; return false;
} }
let line = &lines[index].text; let line = &lines[index].text;
let Some(fence_pos) = line.find("```") else { let Some(fence_pos) = find_unescaped_fence(line) else {
return false; return false;
}; };
let trimmed = trim_start(line); let trimmed = trim_start(line);
@@ -1267,6 +1267,28 @@ fn alert_type(label: &str) -> Option<AlertType> {
} }
} }
fn find_unescaped_fence(line: &str) -> Option<usize> {
let mut search = 0usize;
while let Some(offset) = line[search..].find("```") {
let fence_pos = search + offset;
if !is_escaped_fence(line, fence_pos) {
return Some(fence_pos);
}
search = fence_pos + 1;
}
None
}
fn is_escaped_fence(line: &str, fence_pos: usize) -> bool {
let mut backslashes = 0usize;
let mut index = fence_pos;
while index > 0 && byte_at(line, index - 1) == b'\\' {
backslashes += 1;
index -= 1;
}
backslashes % 2 == 1
}
fn slice_lines_from_fence(lines: &[Line], current: usize, fence_pos: usize) -> Vec<Line> { fn slice_lines_from_fence(lines: &[Line], current: usize, fence_pos: usize) -> Vec<Line> {
let mut out = Vec::with_capacity(lines.len() - current); let mut out = Vec::with_capacity(lines.len() - current);
out.push(Line { out.push(Line {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
pub const CODE_FENCE_LENGTH: usize = 3;
pub const MAX_AST_NODES: usize = 10_000; pub const MAX_AST_NODES: usize = 10_000;
pub const MAX_INLINE_DEPTH: usize = 10; pub const MAX_INLINE_DEPTH: usize = 10;
pub const MAX_LINES: usize = 10_000; pub const MAX_LINES: usize = 10_000;
+17 -1
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::ast::{EmojiKind, Node, ParserFlags, ParserResult}; use crate::ast::{EmojiKind, Node, ParserFlags, ParserResult};
use crate::constants::{MAX_INLINE_DEPTH, MAX_LINE_LENGTH}; use crate::constants::{CODE_FENCE_LENGTH, MAX_INLINE_DEPTH, MAX_LINE_LENGTH};
use crate::links; use crate::links;
use crate::normalize::{ use crate::normalize::{
combine_adjacent_text, compact_empty_text_nodes, flatten_top_level_formatting, combine_adjacent_text, compact_empty_text_nodes, flatten_top_level_formatting,
@@ -125,6 +125,14 @@ fn parse_inline_with_context(
position += 1 + emoji.len; position += 1 + emoji.len;
continue; continue;
} }
if next == b'`' {
let run = backtick_run_length(text, position + 1);
if run >= CODE_FENCE_LENGTH {
accumulated.extend(std::iter::repeat_n(b'`', run));
position += 1 + run;
continue;
}
}
if is_escapable_character(next) if is_escapable_character(next)
|| is_ordered_list_marker_dot_escape(text, position) || is_ordered_list_marker_dot_escape(text, position)
|| is_word_dot_escape(text, position) || is_word_dot_escape(text, position)
@@ -349,6 +357,14 @@ fn regional_indicator_letter(ch: char) -> Option<char> {
char::from_u32(u32::from(b'a') + codepoint - 0x1f1e6) char::from_u32(u32::from(b'a') + codepoint - 0x1f1e6)
} }
fn backtick_run_length(text: &str, start: usize) -> usize {
let mut run = 0usize;
while start + run < text.len() && byte_at(text, start + run) == b'`' {
run += 1;
}
run
}
fn is_ordered_list_marker_dot_escape(text: &str, backslash_position: usize) -> bool { fn is_ordered_list_marker_dot_escape(text: &str, backslash_position: usize) -> bool {
if backslash_position + 2 >= text.len() if backslash_position + 2 >= text.len()
|| byte_at(text, backslash_position + 1) != b'.' || byte_at(text, backslash_position + 1) != b'.'
@@ -202,3 +202,46 @@ fn unterminated_midline_fence_after_a_line_stays_text() {
json!([{"type": "Text", "content": "hello\nfoo```bar with no closing fence"}]) json!([{"type": "Text", "content": "hello\nfoo```bar with no closing fence"}])
); );
} }
#[test]
fn escaped_fence_stays_text_on_one_line() {
assert_eq!(
parse("\\```hello```"),
json!([{"type": "Text", "content": "```hello```"}])
);
}
#[test]
fn escaped_fence_stays_text_across_lines() {
assert_eq!(
parse("\\```rust\nfn main() {}\n```"),
json!([{"type": "Text", "content": "```rust\nfn main() {}\n```"}])
);
}
#[test]
fn escaped_midline_fence_stays_text() {
assert_eq!(
parse("label\\```rust\nfn main() {}\n```"),
json!([{"type": "Text", "content": "label```rust\nfn main() {}\n```"}])
);
}
#[test]
fn escaped_backslash_before_fence_still_opens_a_code_block() {
assert_eq!(
parse("\\\\```hello```"),
json!([
{"type": "Text", "content": "\\"},
{"type": "CodeBlock", "content": "hello"}
])
);
}
#[test]
fn longer_escaped_fence_stays_text() {
assert_eq!(
parse("\\````hello````"),
json!([{"type": "Text", "content": "````hello````"}])
);
}