use crate::fix::{has_footnote_definition, has_footnote_reference};
use crate::scan::{is_ws, js_trim};
fn starts_block(line: &str) -> bool {
let t = line.trim_start_matches(is_ws);
let mut chars = t.chars();
match chars.next() {
Some('#') => {
let count = t.chars().take_while(|c| *c == '#').count();
(1..=6).contains(&count)
&& t[count..]
.chars()
.next()
.is_some_and(|c| c == ' ' || is_ws(c))
}
Some('>') => true, Some('<' | '!' | '?') => true, Some('`') => true, _ => false,
}
}
fn lex_tokens(markdown: &str) -> Vec<&str> {
let mut tokens: Vec<&str> = Vec::new();
let lines: Vec<&str> = markdown.split('\n').collect();
let mut token_start: Option<usize> = None; let mut token_line_start = 0usize; let mut in_fence = false;
fn flush<'a>(
tokens: &mut Vec<&'a str>,
token_start: &mut Option<usize>,
markdown: &'a str,
start_byte: usize,
end_byte: usize,
) {
if let Some(start) = *token_start {
tokens.push(&markdown[start..end_byte]);
}
*token_start = if start_byte < end_byte {
Some(start_byte)
} else {
None
};
}
let mut byte_offset = 0usize;
for (line_idx, line) in lines.iter().enumerate() {
let line_len = line.len() + 1; let is_blank = line.trim().is_empty();
if in_fence {
if js_trim(line).starts_with("```") {
in_fence = false;
}
byte_offset += line_len;
continue;
}
if is_blank {
flush(
&mut tokens,
&mut token_start,
markdown,
byte_offset,
byte_offset,
);
token_line_start = line_idx + 1;
byte_offset += line_len;
continue;
}
let starts_fence = js_trim(line).starts_with("```");
if starts_fence {
flush(
&mut tokens,
&mut token_start,
markdown,
byte_offset,
byte_offset,
);
token_line_start = line_idx;
token_start = Some(byte_offset);
in_fence = true;
byte_offset += line_len;
continue;
}
let is_boundary = starts_block(line);
if is_boundary && token_start.is_some() && line_idx > token_line_start {
flush(
&mut tokens,
&mut token_start,
markdown,
byte_offset,
byte_offset,
);
token_line_start = line_idx;
token_start = Some(byte_offset);
byte_offset += line_len;
continue;
}
if token_start.is_none() {
token_start = Some(byte_offset);
token_line_start = line_idx;
}
byte_offset += line_len;
}
if let Some(start) = token_start {
tokens.push(&markdown[start..]);
}
tokens
}
fn starts_with_double_dollar(str: &str) -> bool {
let t = str.trim_start_matches(is_ws);
t.starts_with("$$")
}
fn ends_with_double_dollar(str: &str) -> bool {
let t = str.trim_end_matches(is_ws);
t.ends_with("$$")
}
fn count_double_dollars(str: &str) -> usize {
let bytes = str.as_bytes();
let mut count = 0;
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'$' && bytes[i + 1] == b'$' {
count += 1;
i += 2;
} else {
i += 1;
}
}
count
}
pub fn parse_markdown_into_blocks(markdown: &str) -> Vec<String> {
if has_footnote_reference(markdown) || has_footnote_definition(markdown) {
return vec![markdown.to_string()];
}
let tokens = lex_tokens(markdown);
let mut merged: Vec<String> = Vec::new();
let mut html_stack: Vec<String> = Vec::new();
for token in tokens {
let current = token;
let merged_len = merged.len();
if !html_stack.is_empty() {
merged[merged_len - 1].push('\n');
merged[merged_len - 1].push_str(current);
if let Some(closing) = memchr::memmem::find(current.as_bytes(), b"</") {
if let Some(rest) = current[closing + 2..].split_whitespace().next() {
let tag = rest.trim_end_matches(['>', '/', '\n']).to_string();
if let Some(top) = html_stack.last() {
if *top == tag {
html_stack.pop();
}
}
}
}
continue;
}
if current.trim_start_matches(is_ws).starts_with('<') {
let after = current.trim_start_matches(is_ws);
let after = &after[1..];
let tag: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let next_after_tag = after[tag.len()..].chars().next();
let ok_boundary = next_after_tag == Some('>') || next_after_tag.is_some_and(is_ws);
if !tag.is_empty() && ok_boundary {
let has_closing = current.contains(&format!("</{}>", tag));
if !has_closing {
html_stack.push(tag);
}
}
}
let trimmed = current.trim();
if trimmed == "$$" && merged_len > 0 {
let previous = &merged[merged_len - 1];
if starts_with_double_dollar(previous) && count_double_dollars(previous) % 2 == 1 {
merged[merged_len - 1] = format!("{}{}", previous, current);
continue;
}
}
if merged_len > 0 && ends_with_double_dollar(current) {
let previous = &merged[merged_len - 1];
let prev_dollar_count = count_double_dollars(previous);
let curr_dollar_count = count_double_dollars(current);
if starts_with_double_dollar(previous)
&& prev_dollar_count % 2 == 1
&& !starts_with_double_dollar(current)
&& curr_dollar_count == 1
{
merged[merged_len - 1] = format!("{}{}", previous, current);
continue;
}
}
merged.push(current.to_string());
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_on_blank_lines() {
let blocks = parse_markdown_into_blocks("# Title\n\nParagraph one\n\nParagraph two");
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0], "# Title\n");
assert_eq!(blocks[1], "Paragraph one\n");
assert_eq!(blocks[2], "Paragraph two");
}
#[test]
fn code_fence_is_one_block() {
let blocks = parse_markdown_into_blocks("before\n\n```js\ncode\nmore\n```\n\nafter");
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1], "```js\ncode\nmore\n```\n");
}
#[test]
fn unclosed_fence_extends_to_end() {
let blocks = parse_markdown_into_blocks("before\n\n```js\ncode\nmore");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[1], "```js\ncode\nmore");
}
#[test]
fn unclosed_html_merges() {
let blocks = parse_markdown_into_blocks("<div>\n\ncontent\n\n</div>\n\nafter");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0], "<div>\n\ncontent\n\n</div>\n");
}
#[test]
fn math_merge() {
let blocks = parse_markdown_into_blocks("text\n\n$$\nE = mc^2\n$$\n\nafter");
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1], "$$\nE = mc^2\n$$\n");
}
#[test]
fn footnote_collapses_document() {
let blocks = parse_markdown_into_blocks("Text [^1]\n\n[^1]: note");
assert_eq!(blocks.len(), 1);
}
#[test]
fn list_items_group() {
let blocks = parse_markdown_into_blocks("- a\n- b\n- c\n\nafter");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0], "- a\n- b\n- c\n");
}
}