diff --git a/packages/markdown_parser/rust/src/block.rs b/packages/markdown_parser/rust/src/block.rs index 0eac6db01..f3cc23e0c 100644 --- a/packages/markdown_parser/rust/src/block.rs +++ b/packages/markdown_parser/rust/src/block.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later 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::normalize::{normalize_nodes, replace_trailing_whitespace_with_newline}; use crate::parser::{MarkdownParser, ParseError, RuntimeState}; @@ -157,7 +157,7 @@ pub(crate) fn parse_block(state: &mut RuntimeState<'_>) -> Result Result Option { } } +fn find_unescaped_fence(line: &str) -> Option { + 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 { let mut out = Vec::with_capacity(lines.len() - current); out.push(Line { diff --git a/packages/markdown_parser/rust/src/constants.rs b/packages/markdown_parser/rust/src/constants.rs index 2577118ae..33b4a168c 100644 --- a/packages/markdown_parser/rust/src/constants.rs +++ b/packages/markdown_parser/rust/src/constants.rs @@ -1,5 +1,6 @@ // 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_INLINE_DEPTH: usize = 10; pub const MAX_LINES: usize = 10_000; diff --git a/packages/markdown_parser/rust/src/inline.rs b/packages/markdown_parser/rust/src/inline.rs index 75b954bda..121d9e88b 100644 --- a/packages/markdown_parser/rust/src/inline.rs +++ b/packages/markdown_parser/rust/src/inline.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later 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::normalize::{ combine_adjacent_text, compact_empty_text_nodes, flatten_top_level_formatting, @@ -125,6 +125,14 @@ fn parse_inline_with_context( position += 1 + emoji.len; 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) || is_ordered_list_marker_dot_escape(text, position) || is_word_dot_escape(text, position) @@ -349,6 +357,14 @@ fn regional_indicator_letter(ch: char) -> Option { 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 { if backslash_position + 2 >= text.len() || byte_at(text, backslash_position + 1) != b'.' diff --git a/packages/markdown_parser/rust/tests/code_blocks.rs b/packages/markdown_parser/rust/tests/code_blocks.rs index 7142e5890..46467c911 100644 --- a/packages/markdown_parser/rust/tests/code_blocks.rs +++ b/packages/markdown_parser/rust/tests/code_blocks.rs @@ -202,3 +202,46 @@ fn unterminated_midline_fence_after_a_line_stays_text() { 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````"}]) + ); +}