mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(markdown): open code blocks for fences after text (#2088)
This commit is contained in:
@@ -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"}])
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user