From 96c5db3f5d7689d890be1a786917ae7d155427b8 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 29 Aug 2026 17:32:43 +0200 Subject: [PATCH] fix(markdown): open code blocks for fences after text (#2088) --- packages/markdown_parser/rust/src/block.rs | 67 ++++++++++++++++--- packages/markdown_parser/rust/src/links.rs | 25 +++++++ packages/markdown_parser/rust/src/parser.rs | 5 ++ .../markdown_parser/rust/tests/code_blocks.rs | 24 ++++--- 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/packages/markdown_parser/rust/src/block.rs b/packages/markdown_parser/rust/src/block.rs index ded2d9cd4..0eac6db01 100644 --- a/packages/markdown_parser/rust/src/block.rs +++ b/packages/markdown_parser/rust/src/block.rs @@ -2,7 +2,7 @@ use crate::ast::{AlertType, ListItem, Node, ParserFlags, TableAlignment}; use crate::constants::{MAX_AST_NODES, MAX_LINE_LENGTH}; -use crate::links::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::parser::{MarkdownParser, ParseError, RuntimeState}; use crate::text::{ @@ -161,7 +161,7 @@ pub(crate) fn parse_block(state: &mut RuntimeState<'_>) -> Result) -> Result 1 { + extra_nodes.extend(inline_nodes[1..].iter().cloned()); + } + extra_nodes.push(code_result.node.clone()); + if let Some(extra) = &code_result.extra_content { + state.lines[new_line_index].text = extra.content.clone(); + state.lines[new_line_index].offset = extra.offset; + } + let first_node = inline_nodes.first().cloned().unwrap_or(code_result.node); + return Ok(BlockParseResult { + node: Some(first_node), + extra_nodes: Some(extra_nodes), + new_line_index, + new_node_count: state.parser.node_count + inline_nodes.len() + 1, + }); + } } } else if starts_with(trimmed, "```") { let mut content = line; @@ -422,11 +447,7 @@ fn parse_multiline_blockquote( }) } -fn parse_code_block( - _parser: &mut MarkdownParser, - lines: &[Line], - current: usize, -) -> Result, ParseError> { +fn parse_code_block(lines: &[Line], current: usize) -> Result, ParseError> { if current >= lines.len() { return Ok(None); } @@ -933,7 +954,7 @@ fn try_parse_nested_list( } let trimmed = trim_start(&lines[current].text); if starts_with(trimmed, "```") - && let Some(result) = parse_code_block(parser, lines, current)? + && let Some(result) = parse_code_block(lines, current)? { return Ok(Some(ListResult { node: result.node, @@ -1171,6 +1192,26 @@ pub(crate) fn is_block_start(line: &str, flags: u32) -> bool { || is_blockquote_start(line, flags) } +pub(crate) fn opens_code_block_midline(lines: &[Line], index: usize, flags: u32) -> bool { + if !ParserFlags::has(flags, ParserFlags::ALLOW_CODE_BLOCKS) || index >= lines.len() { + return false; + } + let line = &lines[index].text; + let Some(fence_pos) = line.find("```") else { + return false; + }; + let trimmed = trim_start(line); + if starts_with(trimmed, "```") && fence_pos == line.len() - trimmed.len() { + return false; + } + let prefix = &line[..fence_pos]; + if has_open_inline_code(prefix) { + return false; + } + let code_lines = slice_lines_from_fence(lines, index, fence_pos); + matches!(parse_code_block(&code_lines, 0), Ok(Some(_))) +} + pub(crate) fn is_table_start(lines: &[Line], index: usize, flags: u32) -> bool { if !ParserFlags::has(flags, ParserFlags::ALLOW_TABLES) || index + 2 >= lines.len() { return false; @@ -1226,6 +1267,16 @@ fn alert_type(label: &str) -> Option { } } +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 { + text: lines[current].text[fence_pos..].to_owned(), + offset: lines[current].offset + fence_pos, + }); + out.extend(lines[current + 1..].iter().cloned()); + out +} + fn normalise_ordinal(items: &[ListItem], ordinal: Option, ordered: bool) -> Option { if !ordered { return None; diff --git a/packages/markdown_parser/rust/src/links.rs b/packages/markdown_parser/rust/src/links.rs index 0f54597f1..9fc1919c2 100644 --- a/packages/markdown_parser/rust/src/links.rs +++ b/packages/markdown_parser/rust/src/links.rs @@ -1078,6 +1078,31 @@ fn has_terminal_tld(text: &str) -> bool { letter_count >= 2 && i > 0 && byte_at(text, i - 1) == b'.' } +pub fn has_open_inline_code(text: &str) -> bool { + if !text.contains('`') { + return false; + } + let mut open_len: Option = None; + let mut index = 0; + while index < text.len() { + if byte_at(text, index) != b'`' { + index += 1; + continue; + } + let mut run = 0usize; + while index + run < text.len() && byte_at(text, index + run) == b'`' { + run += 1; + } + if open_len.is_none() { + open_len = Some(run); + } else if open_len == Some(run) { + open_len = None; + } + index += run; + } + open_len.is_some() +} + pub fn has_valid_code_fence_language(language: &str) -> bool { if is_whitespace(byte_at(language, 0)) { return false; diff --git a/packages/markdown_parser/rust/src/parser.rs b/packages/markdown_parser/rust/src/parser.rs index d1110683d..2fc5861b3 100644 --- a/packages/markdown_parser/rust/src/parser.rs +++ b/packages/markdown_parser/rust/src/parser.rs @@ -164,6 +164,11 @@ impl RuntimeState<'_> { self.current_line + consumed, self.parser.flags(), ) + || crate::block::opens_code_block_midline( + &self.lines, + self.current_line + consumed, + self.parser.flags(), + ) || trimmed_next.is_empty() { break; diff --git a/packages/markdown_parser/rust/tests/code_blocks.rs b/packages/markdown_parser/rust/tests/code_blocks.rs index ffb60494e..7142e5890 100644 --- a/packages/markdown_parser/rust/tests/code_blocks.rs +++ b/packages/markdown_parser/rust/tests/code_blocks.rs @@ -174,25 +174,31 @@ fn single_line_fence_without_space_is_content() { } #[test] -fn mid_line_fence_is_literal_text() { +fn fence_after_a_preceding_line_opens_code_block() { assert_eq!( - parse("note text```rust\nfn main() {}\n```"), - json!([{"type":"Text","content":"note text```rust\nfn main() {}\n```"}]) + parse("intro line\nlabel```rust\nfn main() {}\n```"), + json!([ + {"type": "Text", "content": "intro line\nlabel"}, + {"type": "CodeBlock", "language": "rust", "content": "fn main() {}\n"} + ]) ); } #[test] -fn mid_line_fence_spanning_multiple_lines_stays_text() { +fn fenced_body_with_pipes_after_a_line_is_not_a_table() { assert_eq!( - parse("intro line ```js\nbody one\nbody two\n```"), - json!([{"type":"Text","content":"intro line ```js\nbody one\nbody two\n```"}]) + parse("heading text\nrow```md\na | b\n---\nc | d\n```"), + json!([ + {"type": "Text", "content": "heading text\nrow"}, + {"type": "CodeBlock", "language": "md", "content": "a | b\n---\nc | d\n"} + ]) ); } #[test] -fn line_start_fence_still_opens_code_block() { +fn unterminated_midline_fence_after_a_line_stays_text() { assert_eq!( - parse("```rust\nfn main() {}\n```"), - json!([{"type":"CodeBlock","language":"rust","content":"fn main() {}\n"}]) + parse("hello\nfoo```bar with no closing fence"), + json!([{"type": "Text", "content": "hello\nfoo```bar with no closing fence"}]) ); }