fix(markdown): open code blocks for fences after text (#2088)

This commit is contained in:
Hampus
2026-08-29 17:32:43 +02:00
committed by GitHub
parent 78e403819e
commit 96c5db3f5d
4 changed files with 104 additions and 17 deletions
+59 -8
View File
@@ -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<BlockParseResu
let starts_with_fence =
starts_with(trimmed, "```") && fence_pos == line.len() - trimmed.len();
if starts_with_fence {
if let Some(result) = parse_code_block(state.parser, &state.lines, current)? {
if let Some(result) = parse_code_block(&state.lines, current)? {
if let Some(extra) = &result.extra_content {
state.lines[result.new_line_index].text = extra.content.clone();
state.lines[result.new_line_index].offset = extra.offset;
@@ -175,6 +175,31 @@ pub(crate) fn parse_block(state: &mut RuntimeState<'_>) -> Result<BlockParseResu
}
return Ok(no_block(current, state.parser.node_count));
}
let prefix = &line[..fence_pos];
if has_open_inline_code(prefix) {
return Ok(no_block(current, state.parser.node_count));
}
let inline_nodes = crate::inline::parse_inline(state.parser, prefix, line_offset)?;
let code_lines = slice_lines_from_fence(&state.lines, current, fence_pos);
if let Some(code_result) = parse_code_block(&code_lines, 0)? {
let new_line_index = current + code_result.new_line_index;
let mut extra_nodes = Vec::new();
if inline_nodes.len() > 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<Option<CodeBlockResult>, ParseError> {
fn parse_code_block(lines: &[Line], current: usize) -> Result<Option<CodeBlockResult>, 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<AlertType> {
}
}
fn slice_lines_from_fence(lines: &[Line], current: usize, fence_pos: usize) -> Vec<Line> {
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<usize>, ordered: bool) -> Option<usize> {
if !ordered {
return None;
@@ -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<usize> = 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;
@@ -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;
@@ -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"}])
);
}