Files
fluxer/packages/markdown_parser/rust/src/block.rs
T

1305 lines
42 KiB
Rust

// 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::links::has_valid_code_fence_language;
use crate::normalize::{normalize_nodes, replace_trailing_whitespace_with_newline};
use crate::parser::{MarkdownParser, ParseError, RuntimeState};
use crate::text::{
Line, bounded_line_text, byte_at, concat, concat3, has_visible_content, line_as_text,
lines_to_text, starts_with, trim, trim_line_window, trim_right, trim_start,
trim_start_newline_whitespace,
};
#[derive(Clone, Debug)]
pub(crate) struct BlockParseResult {
pub node: Option<Node>,
pub new_line_index: usize,
pub new_node_count: usize,
pub extra_nodes: Option<Vec<Node>>,
}
#[derive(Clone, Debug)]
struct CodeBlockResult {
node: Node,
new_line_index: usize,
extra_content: Option<ExtraContent>,
}
#[derive(Clone, Debug)]
struct ExtraContent {
content: String,
offset: usize,
}
#[derive(Clone, Debug)]
struct BlockSpoilerResult {
node: Node,
new_line_index: usize,
extra_content: Option<ExtraContent>,
}
#[derive(Clone, Debug)]
struct ClosingFence<'a> {
fence_index: usize,
backtick_count: usize,
trailing_text: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct ListMatch<'a> {
ordered: bool,
indent_level: usize,
content: &'a str,
ordinal: Option<usize>,
}
#[derive(Clone, Debug)]
struct ListResult {
node: Node,
new_line_index: usize,
new_node_count: usize,
}
#[derive(Clone, Debug)]
struct TableResult {
node: Node,
new_line_index: usize,
}
#[derive(Clone, Debug)]
struct TableCellText {
text: String,
offset: usize,
}
pub(crate) fn parse_block(state: &mut RuntimeState<'_>) -> Result<BlockParseResult, ParseError> {
let current = state.current_line;
let line = state.lines[current].text.clone();
let line_offset = state.lines[current].offset;
let trimmed = trim_start(&line);
let trimmed_offset = line_offset + (line.len() - trimmed.len());
if starts_with(trimmed, ">>> ") {
if !ParserFlags::has(
state.parser.flags(),
ParserFlags::ALLOW_MULTILINE_BLOCKQUOTES,
) {
return Ok(BlockParseResult {
node: Some(Node::Text {
content: line_as_text(&state.lines, current),
}),
new_line_index: current + 1,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
return parse_multiline_blockquote(
state.parser,
&state.lines,
current,
state.parser.node_count,
);
}
if starts_with(trimmed, "> ") {
if !ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_BLOCKQUOTES) {
return Ok(no_block(current, state.parser.node_count));
}
return parse_blockquote(state.parser, &state.lines, current, state.parser.node_count);
}
if let Some(list_match) = match_list_item(&line) {
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_LISTS) {
let list = parse_list(
state.parser,
&state.lines,
current,
list_match.ordered,
list_match.indent_level,
1,
state.parser.node_count,
)?;
return Ok(BlockParseResult {
node: Some(list.node),
new_line_index: list.new_line_index,
new_node_count: list.new_node_count,
extra_nodes: None,
});
}
return Ok(BlockParseResult {
node: Some(Node::Text { content: line }),
new_line_index: current + 1,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
if starts_with(trimmed, "||") && !trimmed[2..].contains("||") {
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_SPOILERS) {
let result = parse_block_spoiler(state.parser, &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;
}
return Ok(BlockParseResult {
node: Some(result.node),
new_line_index: result.new_line_index,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
return Ok(BlockParseResult {
node: Some(Node::Text { content: line }),
new_line_index: current + 1,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_CODE_BLOCKS) {
if let Some(fence_pos) = line.find("```") {
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(extra) = &result.extra_content {
state.lines[result.new_line_index].text = extra.content.clone();
state.lines[result.new_line_index].offset = extra.offset;
}
return Ok(BlockParseResult {
node: Some(result.node),
new_line_index: result.new_line_index,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
return Ok(no_block(current, state.parser.node_count));
}
}
} else if starts_with(trimmed, "```") {
let mut content = line;
let mut end_index = current + 1;
while end_index < state.lines.len() {
content = concat3(&content, "\n", &state.lines[end_index].text);
if trim(&state.lines[end_index].text) == "```" {
end_index += 1;
break;
}
end_index += 1;
}
return Ok(BlockParseResult {
node: Some(Node::Text { content }),
new_line_index: end_index,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
if starts_with(trimmed, "-#") {
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_SUBTEXT)
&& let Some(node) = parse_subtext_node(state.parser, trimmed, trimmed_offset)?
{
return Ok(BlockParseResult {
node: Some(node),
new_line_index: current + 1,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
return Ok(BlockParseResult {
node: Some(Node::Text {
content: line_as_text(&state.lines, current),
}),
new_line_index: current + 1,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
if starts_with(trimmed, "#") {
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_HEADINGS)
&& let Some(node) = parse_heading_node(state.parser, trimmed, trimmed_offset)?
{
return Ok(BlockParseResult {
node: Some(node),
new_line_index: current + 1,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
return Ok(no_block(current, state.parser.node_count));
}
if trimmed.contains('|')
&& ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_TABLES)
&& let Some(table) = parse_table(state.parser, &state.lines, current)?
{
return Ok(BlockParseResult {
node: Some(table.node),
new_line_index: table.new_line_index,
new_node_count: state.parser.node_count + 1,
extra_nodes: None,
});
}
Ok(no_block(current, state.parser.node_count))
}
fn no_block(current: usize, node_count: usize) -> BlockParseResult {
BlockParseResult {
node: None,
new_line_index: current,
new_node_count: node_count,
extra_nodes: None,
}
}
pub(crate) fn parse_heading_node(
parser: &mut MarkdownParser,
trimmed_line: &str,
offset: usize,
) -> Result<Option<Node>, ParseError> {
let mut level = 0usize;
while level < trimmed_line.len() && level < 4 && byte_at(trimmed_line, level) == b'#' {
level += 1;
}
if (1..=4).contains(&level)
&& level < trimmed_line.len()
&& byte_at(trimmed_line, level) == b' '
{
let content = &trimmed_line[level + 1..];
if !has_visible_content(content) && trim(content).is_empty() {
return Ok(None);
}
let children = crate::inline::parse_inline(parser, content, offset + level + 1)?;
return Ok(Some(Node::Heading {
level: level as u8,
children,
}));
}
Ok(None)
}
fn parse_subtext_node(
parser: &mut MarkdownParser,
trimmed_line: &str,
offset: usize,
) -> Result<Option<Node>, ParseError> {
if !starts_with(trimmed_line, "-#") {
return Ok(None);
}
if (trimmed_line.len() > 2 && byte_at(trimmed_line, 2) != b' ')
|| (trimmed_line.len() > 3 && byte_at(trimmed_line, 3) == b' ')
{
return Ok(None);
}
if trimmed_line.len() <= 3 {
return Ok(None);
}
let content = &trimmed_line[3..];
if !has_visible_content(content) {
return Ok(None);
}
let children = crate::inline::parse_inline(parser, content, offset + 3)?;
Ok(Some(Node::Subtext { children }))
}
fn parse_blockquote(
parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
node_count: usize,
) -> Result<BlockParseResult, ParseError> {
let mut child_lines = Vec::new();
let mut content_len = 0usize;
let mut index = current;
while index < lines.len() {
let trimmed_line = trim_start(&lines[index].text);
let trimmed_offset = lines[index].text.len() - trimmed_line.len();
let mut child_text = "";
let mut child_offset = lines[index].offset + trimmed_offset;
if trimmed_line == "> " || trimmed_line == "> " {
child_offset += 2;
} else if starts_with(trimmed_line, "> ") {
child_text = &trimmed_line[2..];
child_offset += 2;
} else {
break;
}
child_lines.push(Line {
text: bounded_line_text(child_text).to_owned(),
offset: child_offset,
});
content_len += child_text.len() + 1;
if content_len > MAX_LINE_LENGTH * 100 {
break;
}
index += 1;
}
if child_lines.is_empty() {
return Ok(no_block(index, node_count));
}
if ParserFlags::has(parser.flags(), ParserFlags::ALLOW_ALERTS)
&& let Some(alert) = parse_alert_lines(parser, child_lines.clone())?
{
return Ok(BlockParseResult {
node: Some(alert),
new_line_index: index,
new_node_count: node_count + 1,
extra_nodes: None,
});
}
let mut child_parser =
parser.child_with_flags(parser.flags() & !ParserFlags::ALLOW_BLOCKQUOTES);
let mut child_nodes = child_parser.parse_lines(child_lines)?;
normalize_nodes(&mut child_nodes, true);
let blank_lines = if child_nodes.is_empty() {
Some(index.saturating_sub(current))
} else {
None
};
Ok(BlockParseResult {
node: Some(Node::Blockquote {
children: child_nodes,
blank_lines,
}),
new_line_index: index,
new_node_count: node_count + 1,
extra_nodes: None,
})
}
fn parse_multiline_blockquote(
parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
node_count: usize,
) -> Result<BlockParseResult, ParseError> {
let trimmed_line = trim_start(&lines[current].text);
if !starts_with(trimmed_line, ">>> ") {
return Ok(BlockParseResult {
node: Some(Node::Text {
content: String::new(),
}),
new_line_index: current,
new_node_count: node_count,
extra_nodes: None,
});
}
let trimmed_offset = lines[current].text.len() - trimmed_line.len();
let mut child_lines = vec![Line {
text: bounded_line_text(&trimmed_line[4..]).to_owned(),
offset: lines[current].offset + trimmed_offset + 4,
}];
let mut content_len = trimmed_line[4..].len();
let mut index = current + 1;
while index < lines.len() {
child_lines.push(lines[index].clone());
content_len += lines[index].text.len() + 1;
if content_len > MAX_LINE_LENGTH * 100 {
break;
}
index += 1;
}
let mut child_parser = parser.child_with_flags(
(parser.flags() & !ParserFlags::ALLOW_MULTILINE_BLOCKQUOTES)
| ParserFlags::ALLOW_BLOCKQUOTES,
);
let child_nodes = child_parser.parse_lines(child_lines)?;
let blank_lines = if child_nodes.is_empty() {
Some(index.saturating_sub(current))
} else {
None
};
Ok(BlockParseResult {
node: Some(Node::Blockquote {
children: child_nodes,
blank_lines,
}),
new_line_index: index,
new_node_count: node_count + 1,
extra_nodes: None,
})
}
fn parse_code_block(
_parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
) -> Result<Option<CodeBlockResult>, ParseError> {
if current >= lines.len() {
return Ok(None);
}
let line = &lines[current].text;
let trimmed_line = trim_start(line);
let indent_spaces = line.len() - trimmed_line.len();
let list_indent = &line[..indent_spaces];
let mut fence_length = 0usize;
while fence_length < trimmed_line.len() && byte_at(trimmed_line, fence_length) == b'`' {
fence_length += 1;
}
if fence_length < 3 {
return Ok(None);
}
let language_part = &trimmed_line[fence_length..];
let closing_fence = "`".repeat(fence_length);
if let Some(closing_index) = language_part.find(&closing_fence) {
let inline_content = &language_part[..closing_index];
if !has_visible_content(inline_content) {
return Ok(None);
}
let trailing = &language_part[closing_index + fence_length..];
return Ok(Some(CodeBlockResult {
node: Node::CodeBlock {
language: None,
content: inline_content.to_owned(),
},
new_line_index: if trailing.is_empty() {
current + 1
} else {
current
},
extra_content: (!trailing.is_empty()).then(|| ExtraContent {
content: trailing.to_owned(),
offset: lines[current].offset + (line.len() - trailing.len()),
}),
}));
}
let treat_opening_as_language = has_valid_code_fence_language(language_part);
let language = treat_opening_as_language.then(|| trim(language_part).to_owned());
let mut temp = current + 1;
let mut line_count = 0usize;
let mut has_closing = false;
while temp < lines.len() {
if find_closing_fence(trim_start(&lines[temp].text), &closing_fence, fence_length).is_some()
{
has_closing = true;
break;
}
line_count += 1;
if line_count > 1000 {
break;
}
temp += 1;
}
if !has_closing {
return Ok(None);
}
let mut content = String::new();
if !treat_opening_as_language && !language_part.is_empty() {
content.push_str(language_part);
content.push('\n');
}
let mut index = current + 1;
while index < lines.len() {
let current_line = &lines[index].text;
let trimmed = trim_start(current_line);
if let Some(closing) = find_closing_fence(trimmed, &closing_fence, fence_length) {
let absolute_fence = current_line.find(&closing_fence).unwrap_or(0);
let prefix = &current_line[..absolute_fence];
let content_line = if indent_spaces > 0 && starts_with(prefix, list_indent) {
&prefix[indent_spaces..]
} else {
prefix
};
if !content_line.is_empty() {
content.push_str(content_line);
content.push('\n');
}
let extra_text = if !closing.trailing_text.is_empty() {
closing.trailing_text
} else if closing.backtick_count > fence_length {
&trimmed[closing.fence_index + fence_length..]
} else {
""
};
if !extra_text.is_empty() {
if !has_visible_content(&content) {
return Ok(None);
}
return Ok(Some(CodeBlockResult {
node: Node::CodeBlock { language, content },
new_line_index: index,
extra_content: Some(ExtraContent {
content: extra_text.to_owned(),
offset: lines[index].offset + (current_line.len() - extra_text.len()),
}),
}));
}
index += 1;
break;
}
let content_line = if indent_spaces > 0 && starts_with(current_line, list_indent) {
&current_line[indent_spaces..]
} else {
current_line
};
content.push_str(content_line);
content.push('\n');
if content.len() > MAX_LINE_LENGTH * 100 {
break;
}
index += 1;
}
if !has_visible_content(&content) {
return Ok(None);
}
Ok(Some(CodeBlockResult {
node: Node::CodeBlock { language, content },
new_line_index: index,
extra_content: None,
}))
}
fn find_closing_fence<'a>(
trimmed_line: &'a str,
closing_fence: &str,
fence_length: usize,
) -> Option<ClosingFence<'a>> {
let fence_index = trimmed_line.find(closing_fence)?;
let mut count = 0usize;
let mut idx = fence_index;
while idx < trimmed_line.len() && byte_at(trimmed_line, idx) == b'`' {
count += 1;
idx += 1;
}
let after = byte_at(trimmed_line, idx);
let trailing = &trimmed_line[idx..];
let only_whitespace_after = after == 0 || matches!(after, b' ' | b'\t' | b'`');
if count >= fence_length && (only_whitespace_after || trailing.contains(closing_fence)) {
return Some(ClosingFence {
fence_index,
backtick_count: count,
trailing_text: trailing,
});
}
None
}
fn parse_block_spoiler(
parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
) -> Result<BlockSpoilerResult, ParseError> {
let mut found_end = false;
let mut trailing_content: Option<ExtraContent> = None;
let mut child_lines = Vec::new();
let mut content_len = 0usize;
let mut index = current;
while index < lines.len() {
let line = &lines[index].text;
if index == current {
if let Some(start) = line.find("||") {
let child_text = &line[start + 2..];
child_lines.push(Line {
text: bounded_line_text(child_text).to_owned(),
offset: lines[index].offset + start + 2,
});
content_len += child_text.len() + 1;
}
} else if let Some(end) = line.find("||") {
let child_text = &line[..end];
child_lines.push(Line {
text: bounded_line_text(child_text).to_owned(),
offset: lines[index].offset,
});
found_end = true;
let trailing = &line[end + 2..];
if !trailing.is_empty() {
trailing_content = Some(ExtraContent {
content: trailing.to_owned(),
offset: lines[index].offset + (line.len() - trailing.len()),
});
}
index += 1;
break;
} else {
child_lines.push(lines[index].clone());
content_len += line.len() + 1;
}
if content_len > MAX_LINE_LENGTH * 10 {
break;
}
index += 1;
}
if !found_end {
let content = lines_to_text(&child_lines);
return Ok(BlockSpoilerResult {
node: Node::Text {
content: concat("||", trim_right(&content)),
},
new_line_index: index,
extra_content: None,
});
}
let mut child_parser = parser.child_with_flags(parser.flags());
let mut window = child_lines;
if !has_visible_content(&lines_to_text(&window)) {
return Ok(BlockSpoilerResult {
node: Node::Text {
content: lines_to_text(&lines[current..index]),
},
new_line_index: index,
extra_content: None,
});
}
let inner = child_parser.parse_lines(trim_line_window(&mut window))?;
Ok(BlockSpoilerResult {
node: Node::Spoiler {
children: inner,
is_block: Some(true),
},
new_line_index: if trailing_content.is_some() {
index - 1
} else {
index
},
extra_content: trailing_content,
})
}
fn parse_alert_lines(
parser: &mut MarkdownParser,
lines: Vec<Line>,
) -> Result<Option<Node>, ParseError> {
if lines.is_empty() || !starts_with(&lines[0].text, "[!") {
return Ok(None);
}
let Some(close) = lines[0].text.find(']') else {
return Ok(None);
};
let label = &lines[0].text[2..close];
let Some(alert_type) = alert_type(label) else {
return Ok(None);
};
let mut content_lines = Vec::new();
let after_label = &lines[0].text[close + 1..];
let after_trimmed = trim_start_newline_whitespace(after_label);
if !after_trimmed.is_empty() {
content_lines.push(Line {
text: bounded_line_text(after_trimmed).to_owned(),
offset: lines[0].offset + close + 1 + (after_label.len() - after_trimmed.len()),
});
}
if lines.len() > 1 {
content_lines.extend(lines[1..].iter().cloned());
}
let mut child_parser = parser.child_with_flags(
(parser.flags() & !ParserFlags::ALLOW_BLOCKQUOTES)
| ParserFlags::ALLOW_LISTS
| ParserFlags::ALLOW_HEADINGS,
);
let prepared = prepare_alert_content_lines(content_lines);
let mut children = child_parser.parse_lines(prepared)?;
children = post_process_alert_nodes(children);
Ok(Some(Node::Alert {
alert_type,
children,
}))
}
fn prepare_alert_content_lines(mut lines: Vec<Line>) -> Vec<Line> {
let mut out = Vec::new();
let mut blank_run = 0usize;
for line in lines.drain(..) {
let is_list_like = is_alert_list_like_line(trim(&line.text));
let mut prepared = line;
if is_list_like {
prepared.text = bounded_line_text(&prepared.text).to_owned();
} else {
let left_trimmed = trim_start(&prepared.text);
let leading_bytes = prepared.text.len() - left_trimmed.len();
prepared.offset += leading_bytes;
prepared.text = bounded_line_text(trim_right(left_trimmed)).to_owned();
}
if trim(&prepared.text).is_empty() {
blank_run += 1;
if blank_run > 2 {
continue;
}
prepared.text.clear();
} else {
blank_run = 0;
}
out.push(prepared);
}
trim_line_window(&mut out)
}
fn is_alert_list_like_line(trimmed_line: &str) -> bool {
if starts_with(trimmed_line, "-") {
return true;
}
let mut index = 0usize;
while index < trimmed_line.len() && byte_at(trimmed_line, index).is_ascii_digit() {
index += 1;
}
index > 0 && index < trimmed_line.len() && byte_at(trimmed_line, index) == b'.'
}
fn post_process_alert_nodes(nodes: Vec<Node>) -> Vec<Node> {
if nodes.len() <= 1 {
return nodes;
}
let mut out = Vec::new();
let mut index = 0usize;
while index < nodes.len() {
let node = nodes[index].clone();
if let Node::Text { content } = &node
&& index + 1 < nodes.len()
&& matches!(nodes[index + 1], Node::List { .. })
{
let content = replace_trailing_whitespace_with_newline(content);
if !content.is_empty() {
out.push(Node::Text { content });
}
index += 1;
continue;
}
if matches!(node, Node::List { .. }) && index + 1 < nodes.len() {
out.push(node);
if let Node::Text { content } = &nodes[index + 1] {
let content = trim(content);
if !content.is_empty() {
out.push(Node::Text {
content: concat("\n", content),
});
index += 2;
continue;
}
}
index += 1;
continue;
}
out.push(node);
index += 1;
}
out
}
fn parse_list(
parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
ordered: bool,
indent_level: usize,
depth: usize,
node_count: usize,
) -> Result<ListResult, ParseError> {
let mut items = Vec::new();
let mut index = current;
let mut new_count = node_count;
while index < lines.len() {
if new_count > MAX_AST_NODES {
break;
}
let current_line = &lines[index].text;
let trimmed_line = trim_start(current_line);
if starts_with(trimmed_line, "#") || is_blockquote_start(trimmed_line, parser.flags()) {
break;
}
if let Some(item) = match_list_item(current_line) {
let ordinal = normalise_ordinal(&items, item.ordinal, ordered);
if item.indent_level < indent_level {
break;
}
if item.indent_level == indent_level {
if item.ordered != ordered {
break;
}
let mut children = Vec::new();
if let Some(inline_item) = match_list_item(item.content) {
let mut nested_items = Vec::new();
let inline_nodes = crate::inline::parse_inline(
parser,
inline_item.content,
lines[index].offset + (current_line.len() - inline_item.content.len()),
)?;
nested_items.push(ListItem {
children: inline_nodes,
ordinal: None,
});
if let Some(nested) = try_parse_nested_list(
parser,
lines,
index + 1,
indent_level,
depth,
new_count,
)? {
if let Node::List { items, .. } = nested.node {
nested_items.extend(items);
index = nested.new_line_index;
new_count = nested.new_node_count;
} else {
index += 1;
new_count += 1;
}
} else {
index += 1;
new_count += 1;
}
children.push(Node::List {
ordered: inline_item.ordered,
items: nested_items,
});
} else {
let content_nodes = crate::inline::parse_inline(
parser,
item.content,
lines[index].offset + (current_line.len() - item.content.len()),
)?;
children.extend(content_nodes.iter().cloned());
if let Some(nested) = try_parse_nested_list(
parser,
lines,
index + 1,
indent_level,
depth,
new_count,
)? {
children.push(nested.node);
index = nested.new_line_index;
new_count = nested.new_node_count;
} else {
index += 1;
new_count += content_nodes.len();
}
}
items.push(ListItem { children, ordinal });
} else if item.indent_level == indent_level + 1 && depth < 9 {
let nested = parse_list(
parser,
lines,
index,
item.ordered,
item.indent_level,
depth + 1,
new_count,
)?;
if let Some(last) = items.last_mut() {
last.children.push(nested.node);
}
index = nested.new_line_index;
new_count = nested.new_node_count;
} else {
break;
}
} else if is_bullet_point_text(current_line) {
if let Some(last) = items.last_mut() {
last.children.push(Node::Text {
content: trim(current_line).to_owned(),
});
}
index += 1;
new_count += 1;
} else if is_list_continuation(current_line, indent_level) {
if let Some(last) = items.last_mut() {
let trimmed = trim_start(current_line);
let parsed = crate::inline::parse_inline(
parser,
trimmed,
lines[index].offset + (current_line.len() - trimmed.len()),
)?;
last.children.extend(parsed.iter().cloned());
new_count += parsed.len();
}
index += 1;
} else {
break;
}
if items.len() > MAX_AST_NODES {
break;
}
}
Ok(ListResult {
node: Node::List { ordered, items },
new_line_index: index,
new_node_count: new_count,
})
}
fn try_parse_nested_list(
parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
parent_indent: usize,
depth: usize,
node_count: usize,
) -> Result<Option<ListResult>, ParseError> {
if current >= lines.len() {
return Ok(None);
}
let trimmed = trim_start(&lines[current].text);
if starts_with(trimmed, "```")
&& let Some(result) = parse_code_block(parser, lines, current)?
{
return Ok(Some(ListResult {
node: result.node,
new_line_index: result.new_line_index,
new_node_count: node_count + 1,
}));
}
if let Some(item) = match_list_item(&lines[current].text)
&& item.indent_level > parent_indent
&& depth < 9
{
return parse_list(
parser,
lines,
current,
item.ordered,
item.indent_level,
depth + 1,
node_count,
)
.map(Some);
}
Ok(None)
}
pub(crate) fn match_list_item(line: &str) -> Option<ListMatch<'_>> {
let mut indent = 0usize;
while indent < line.len() && byte_at(line, indent) == b' ' {
indent += 1;
}
if indent > 0 && indent < 2 {
return None;
}
let indent_level = indent / 2;
if indent >= line.len() {
return None;
}
let marker = byte_at(line, indent);
if matches!(marker, b'*' | b'-') && indent + 1 < line.len() && byte_at(line, indent + 1) == b' '
{
return Some(ListMatch {
ordered: false,
indent_level,
content: &line[indent + 2..],
ordinal: None,
});
}
if marker.is_ascii_digit() {
let mut pos = indent;
while pos < line.len() && byte_at(line, pos).is_ascii_digit() {
pos += 1;
}
if pos < line.len()
&& byte_at(line, pos) == b'.'
&& pos + 1 < line.len()
&& byte_at(line, pos + 1) == b' '
{
return Some(ListMatch {
ordered: true,
indent_level,
content: &line[pos + 2..],
ordinal: line[indent..pos].parse::<usize>().ok().or(Some(1)),
});
}
}
None
}
fn parse_table(
parser: &mut MarkdownParser,
lines: &[Line],
current: usize,
) -> Result<Option<TableResult>, ParseError> {
if current + 2 >= lines.len() {
return Ok(None);
}
let header_line = trim_table_line(&lines[current]);
let align_line = trim_table_line(&lines[current + 1]);
if !header_line.text.contains('|') || !align_line.text.contains('|') {
return Ok(None);
}
let header_cells = split_table_cells(&header_line);
if header_cells.is_empty() || !cells_have_content(&header_cells) {
return Ok(None);
}
let align_cells = split_table_cells(&align_line);
let Some(alignments) = parse_alignments(&align_cells) else {
return Ok(None);
};
if header_cells.len() != alignments.len() {
return Ok(None);
}
let header_row = create_table_row(parser, &header_cells)?;
let column_count = header_cells.len();
let mut rows = Vec::new();
let mut index = current + 2;
while index < lines.len() {
let line = trim_table_line(&lines[index]);
if !line.text.contains('|') || is_table_block_break(&line.text) {
break;
}
let cells = normalize_column_count(split_table_cells(&line), column_count);
rows.push(create_table_row(parser, &cells)?);
index += 1;
}
if rows.is_empty() {
return Ok(None);
}
Ok(Some(TableResult {
node: Node::Table {
header: Box::new(header_row),
alignments,
rows,
},
new_line_index: index,
}))
}
fn trim_table_line(line: &Line) -> TableCellText {
let left = trim_start(&line.text);
let leading = line.text.len() - left.len();
TableCellText {
text: trim_right(left).to_owned(),
offset: line.offset + leading,
}
}
fn trim_table_cell(cell: &TableCellText) -> TableCellText {
let left = trim_start(&cell.text);
let leading = cell.text.len() - left.len();
TableCellText {
text: trim_right(left).to_owned(),
offset: cell.offset + leading,
}
}
fn split_table_cells(source: &TableCellText) -> Vec<TableCellText> {
let line = &source.text;
let mut start = 0usize;
let mut end = line.len();
if line.starts_with('|') {
start = 1;
}
if end > start && line.ends_with('|') {
end -= 1;
}
let mut cells = Vec::new();
if start >= end {
return cells;
}
let mut cell = String::new();
let mut cell_offset = source.offset + start;
let mut i = start;
while i < end {
if byte_at(line, i) == b'\\' && i + 1 < end && byte_at(line, i + 1) == b'|' {
cell.push('|');
i += 2;
continue;
}
if byte_at(line, i) == b'|' {
cells.push(TableCellText {
text: std::mem::take(&mut cell),
offset: cell_offset,
});
i += 1;
cell_offset = source.offset + i;
continue;
}
let ch = line[i..].chars().next().unwrap_or('\0');
cell.push(ch);
i += ch.len_utf8();
}
cells.push(TableCellText {
text: cell,
offset: cell_offset,
});
cells
}
fn parse_alignments(cells: &[TableCellText]) -> Option<Vec<TableAlignment>> {
if cells.is_empty() {
return None;
}
let mut alignments = Vec::with_capacity(cells.len());
for cell in cells {
let value = trim(&cell.text);
if value.is_empty() || !value.contains('-') {
return None;
}
if !value
.bytes()
.all(|c| matches!(c, b' ' | b':' | b'-' | b'|'))
{
return None;
}
let left = value.starts_with(':');
let right = value.ends_with(':');
alignments.push(if left && right {
TableAlignment::Center
} else if left {
TableAlignment::Left
} else if right {
TableAlignment::Right
} else {
TableAlignment::None
});
}
Some(alignments)
}
fn create_table_row(
parser: &mut MarkdownParser,
cells: &[TableCellText],
) -> Result<Node, ParseError> {
let mut out = Vec::new();
for cell in cells {
let trimmed_cell = trim_table_cell(cell);
let value = trimmed_cell.text;
let parsed = crate::inline::parse_inline(parser, &value, trimmed_cell.offset)?;
let children = if parsed.is_empty() {
vec![Node::Text { content: value }]
} else {
parsed
};
out.push(Node::TableCell { children });
}
Ok(Node::TableRow { cells: out })
}
pub(crate) fn is_block_start(line: &str, flags: u32) -> bool {
starts_with(line, "#")
|| (ParserFlags::has(flags, ParserFlags::ALLOW_SUBTEXT) && starts_with(line, "-#"))
|| (ParserFlags::has(flags, ParserFlags::ALLOW_CODE_BLOCKS) && starts_with(line, "```"))
|| (ParserFlags::has(flags, ParserFlags::ALLOW_LISTS) && match_list_item(line).is_some())
|| is_blockquote_start(line, flags)
}
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;
}
let header_line = trim_table_line(&lines[index]);
let align_line = trim_table_line(&lines[index + 1]);
if !header_line.text.contains('|') || !align_line.text.contains('|') {
return false;
}
let header_cells = split_table_cells(&header_line);
if header_cells.is_empty() || !cells_have_content(&header_cells) {
return false;
}
let Some(alignments) = parse_alignments(&split_table_cells(&align_line)) else {
return false;
};
if header_cells.len() != alignments.len() {
return false;
}
let body_line = trim_table_line(&lines[index + 2]);
body_line.text.contains('|') && !is_table_block_break(&body_line.text)
}
pub(crate) fn is_heading_start(line: &str, flags: u32) -> bool {
if !ParserFlags::has(flags, ParserFlags::ALLOW_HEADINGS) || !starts_with(line, "#") {
return false;
}
let mut level = 0usize;
while level < line.len() && level < 4 && byte_at(line, level) == b'#' {
level += 1;
}
(1..=4).contains(&level) && level < line.len() && byte_at(line, level) == b' '
}
pub(crate) fn is_blockquote_start(line: &str, flags: u32) -> bool {
(ParserFlags::has(flags, ParserFlags::ALLOW_MULTILINE_BLOCKQUOTES) && starts_with(line, ">>> "))
|| (ParserFlags::has(flags, ParserFlags::ALLOW_BLOCKQUOTES) && starts_with(line, "> "))
}
fn alert_type(label: &str) -> Option<AlertType> {
if label.eq_ignore_ascii_case("NOTE") {
Some(AlertType::Note)
} else if label.eq_ignore_ascii_case("TIP") {
Some(AlertType::Tip)
} else if label.eq_ignore_ascii_case("IMPORTANT") {
Some(AlertType::Important)
} else if label.eq_ignore_ascii_case("WARNING") {
Some(AlertType::Warning)
} else if label.eq_ignore_ascii_case("CAUTION") {
Some(AlertType::Caution)
} else {
None
}
}
fn normalise_ordinal(items: &[ListItem], ordinal: Option<usize>, ordered: bool) -> Option<usize> {
if !ordered {
return None;
}
if items.is_empty() {
return ordinal.or(Some(1));
}
let start = items[0].ordinal.or(ordinal).unwrap_or(1);
Some(start + items.len())
}
fn is_bullet_point_text(text: &str) -> bool {
if match_list_item(text).is_some() {
return false;
}
let trimmed_text = trim_start(text);
starts_with(trimmed_text, "- ") && !starts_with(text, " ")
}
fn is_list_continuation(line: &str, indent_level: usize) -> bool {
let mut spaces = 0usize;
while spaces < line.len() && byte_at(line, spaces) == b' ' {
spaces += 1;
}
spaces > indent_level * 2
}
fn cells_have_content(cells: &[TableCellText]) -> bool {
cells.iter().any(|cell| !trim(&cell.text).is_empty())
}
fn normalize_column_count(cells: Vec<TableCellText>, expected: usize) -> Vec<TableCellText> {
if cells.len() == expected {
return cells;
}
let mut out = Vec::with_capacity(expected);
for index in 0..expected {
if let Some(cell) = cells.get(index) {
out.push(cell.clone());
} else {
out.push(TableCellText {
text: String::new(),
offset: 0,
});
}
}
if cells.len() > expected && expected > 0 {
let mut combined = out[expected - 1].clone();
for extra in &cells[expected..] {
combined.text = concat3(&combined.text, "|", &extra.text);
}
out[expected - 1] = combined;
}
out
}
fn is_table_block_break(text: &str) -> bool {
if text.is_empty() {
return false;
}
let first = byte_at(text, 0);
if matches!(first, b'#' | b'>' | b'-' | b'*') {
return true;
}
if text.len() >= 2 && byte_at(text, 0) == b'-' && byte_at(text, 1) == b'#' {
return true;
}
if first.is_ascii_digit() {
for i in 1..text.len().min(4) {
if byte_at(text, i) == b'.' {
return true;
}
}
}
false
}