use crate::model::{Block, BlockKind, Document, Page, TableData};
use crate::plugin::{FormatPlugin, RawDoc};
fn heading(line: &str) -> Option<(u8, String)> {
let t = line.trim_start();
let hashes = t.chars().take_while(|c| *c == '#').count();
if hashes == 0 || hashes > 6 {
return None;
}
let rest = &t[hashes..];
if !(rest.starts_with(' ') || rest.starts_with('\t')) {
return None;
}
Some((hashes as u8, rest.trim().to_string()))
}
fn quote(line: &str) -> Option<String> {
let t = line.trim_start();
let rest = t.strip_prefix('>')?;
Some(rest.strip_prefix(' ').unwrap_or(rest).trim().to_string())
}
fn ulist_item(line: &str) -> Option<String> {
let t = line.trim_start();
for marker in ["- ", "* ", "+ "] {
if let Some(rest) = t.strip_prefix(marker) {
return Some(rest.trim().to_string());
}
}
None
}
fn olist_item(line: &str) -> Option<String> {
let t = line.trim_start();
let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
if digits == 0 {
return None;
}
let rest = &t[digits..];
let rest = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')'))?;
if !(rest.starts_with(' ') || rest.starts_with('\t')) {
return None;
}
Some(rest.trim().to_string())
}
fn is_table_sep(line: &str) -> bool {
let t = line.trim();
!t.is_empty()
&& t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
&& t.contains('-')
&& t.contains('|')
}
fn split_row(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = t.strip_suffix('|').unwrap_or(t);
t.split('|').map(|c| c.trim().to_string()).collect()
}
fn flush_para(blocks: &mut Vec<Block>, para: &mut Vec<String>) {
if para.is_empty() {
return;
}
let content = para.join(" ");
para.clear();
let content = content.trim();
if !content.is_empty() {
blocks.push(Block {
kind: BlockKind::Paragraph,
content: content.to_string(),
..Default::default()
});
}
}
pub fn parse(text: &str) -> Vec<Block> {
let lines: Vec<&str> = text.lines().collect();
let n = lines.len();
let mut blocks: Vec<Block> = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut i = 0usize;
while i < n {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
flush_para(&mut blocks, &mut para);
let lang = trimmed.trim_start_matches('`').trim().to_string();
let mut buf: Vec<&str> = Vec::new();
i += 1;
while i < n && !lines[i].trim_start().starts_with("```") {
buf.push(lines[i]);
i += 1;
}
if i < n {
i += 1; }
blocks.push(Block {
kind: BlockKind::Code,
content: buf.join("\n"),
lang: if lang.is_empty() { None } else { Some(lang) },
..Default::default()
});
continue;
}
if line.trim().is_empty() {
flush_para(&mut blocks, &mut para);
i += 1;
continue;
}
if let Some((level, content)) = heading(line) {
flush_para(&mut blocks, &mut para);
blocks.push(Block {
kind: BlockKind::Heading,
content,
level: Some(level),
..Default::default()
});
i += 1;
continue;
}
if let Some(content) = quote(line) {
flush_para(&mut blocks, &mut para);
blocks.push(Block {
kind: BlockKind::Quote,
content,
..Default::default()
});
i += 1;
continue;
}
if trimmed.starts_with('|') && i + 1 < n && is_table_sep(lines[i + 1]) {
flush_para(&mut blocks, &mut para);
let header = split_row(line);
let mut rows: Vec<Vec<String>> = vec![header.clone()];
i += 2;
while i < n && lines[i].trim_start().starts_with('|') {
rows.push(split_row(lines[i]));
i += 1;
}
blocks.push(Block {
kind: BlockKind::Table,
content: header.join(" | "),
table: Some(TableData { rows }),
..Default::default()
});
continue;
}
if let Some(first) = ulist_item(line) {
flush_para(&mut blocks, &mut para);
let mut items = vec![first];
i += 1;
while i < n {
match ulist_item(lines[i]) {
Some(it) => {
items.push(it);
i += 1;
}
None => break,
}
}
blocks.push(Block {
kind: BlockKind::List,
content: items.join("\n"),
ordered: Some(false),
..Default::default()
});
continue;
}
if let Some(first) = olist_item(line) {
flush_para(&mut blocks, &mut para);
let mut items = vec![first];
i += 1;
while i < n {
match olist_item(lines[i]) {
Some(it) => {
items.push(it);
i += 1;
}
None => break,
}
}
blocks.push(Block {
kind: BlockKind::List,
content: items.join("\n"),
ordered: Some(true),
..Default::default()
});
continue;
}
para.push(line.trim().to_string());
i += 1;
}
flush_para(&mut blocks, &mut para);
blocks
}
pub struct MarkdownPlugin;
impl FormatPlugin for MarkdownPlugin {
fn id(&self) -> &str {
"md"
}
fn formats(&self) -> &[&str] {
&["md"]
}
fn default_parse_chain(&self) -> Vec<&'static str> {
vec!["text.read.md"]
}
fn default_process_chain(&self) -> Vec<&'static str> {
vec![]
}
fn assemble(&self, raw: &RawDoc) -> Document {
let text = raw
.pages
.first()
.and_then(|p| p.items.first())
.map(|i| i.text.as_str())
.unwrap_or_default();
Document {
pages: vec![Page {
blocks: parse(text),
text: text.to_string(),
..Default::default()
}],
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(blocks: &[Block]) -> Vec<BlockKind> {
blocks.iter().map(|b| b.kind).collect()
}
#[test]
fn parses_headings_and_paragraphs() {
let blocks = parse("# 标题\n正文一\n正文二\n\n## 二级\n");
assert_eq!(
kinds(&blocks),
vec![BlockKind::Heading, BlockKind::Paragraph, BlockKind::Heading]
);
assert_eq!(blocks[0].content, "标题");
assert_eq!(blocks[0].level, Some(1));
assert_eq!(blocks[1].content, "正文一 正文二"); assert_eq!(blocks[2].level, Some(2));
}
#[test]
fn parses_lists_and_quote() {
let blocks = parse("- a\n- b\n\n1. x\n2. y\n\n> 引用\n");
assert_eq!(
kinds(&blocks),
vec![BlockKind::List, BlockKind::List, BlockKind::Quote]
);
assert_eq!(blocks[0].content, "a\nb");
assert_eq!(blocks[0].ordered, Some(false));
assert_eq!(blocks[1].ordered, Some(true));
assert_eq!(blocks[2].content, "引用");
}
#[test]
fn parses_code_fence_with_lang() {
let blocks = parse("```rust\nfn main() {}\n```\n");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, BlockKind::Code);
assert_eq!(blocks[0].lang.as_deref(), Some("rust"));
assert_eq!(blocks[0].content, "fn main() {}");
}
#[test]
fn parses_pipe_table() {
let blocks = parse("| a | b |\n| --- | --- |\n| 1 | 2 |\n");
assert_eq!(blocks.len(), 1);
let rows = &blocks[0].table.as_ref().unwrap().rows;
assert_eq!(rows.len(), 2); assert_eq!(rows[0], vec!["a", "b"]);
assert_eq!(rows[1], vec!["1", "2"]);
assert_eq!(blocks[0].content, "a | b");
}
#[test]
fn plain_pipe_line_without_separator_is_paragraph() {
let blocks = parse("| a | b |\nnot a table\n");
assert_eq!(kinds(&blocks), vec![BlockKind::Paragraph]);
}
#[test]
fn unmatched_fence_does_not_hang() {
let blocks = parse("```\nabc");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, BlockKind::Code);
}
}