use crate::render::rich::RichLine;
use super::{find_close, list_marker, parse, parse_inline, Block, Marker, MdStyles};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum CellAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct TableBlock {
pub align: Vec<CellAlign>,
pub header: Vec<RichLine>,
pub rows: Vec<Vec<RichLine>>,
}
impl TableBlock {
pub fn new(align: Vec<CellAlign>, header: Vec<RichLine>, rows: Vec<Vec<RichLine>>) -> Self {
let n = align.len();
let fit = |mut cells: Vec<RichLine>| -> Vec<RichLine> {
cells.truncate(n);
while cells.len() < n {
cells.push(RichLine::new());
}
cells
};
TableBlock {
align,
header: fit(header),
rows: rows.into_iter().map(fit).collect(),
}
}
pub fn columns(&self) -> usize {
self.align.len()
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ImageBlock {
pub alt: String,
pub src: String,
}
impl ImageBlock {
pub fn new(alt: impl Into<String>, src: impl Into<String>) -> Self {
ImageBlock {
alt: alt.into(),
src: src.into(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct TaskBlock {
pub checked: bool,
pub depth: u8,
pub content: RichLine,
}
impl TaskBlock {
pub fn new(checked: bool, depth: u8, content: RichLine) -> Self {
TaskBlock {
checked,
depth,
content,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum DocBlock {
Core(Block),
Table(TableBlock),
Image(ImageBlock),
Task(TaskBlock),
}
pub fn parse_doc(src: &str, styles: &MdStyles) -> Vec<DocBlock> {
let lines: Vec<&str> = src.lines().collect();
let mut out: Vec<DocBlock> = Vec::new();
let mut core: Vec<&str> = Vec::new();
let mut in_fence = false;
let mut i = 0;
let flush = |core: &mut Vec<&str>, out: &mut Vec<DocBlock>, styles: &MdStyles| {
if core.is_empty() {
return;
}
let seg = core.join("\n");
out.extend(parse(&seg, styles).into_iter().map(DocBlock::Core));
core.clear();
};
while i < lines.len() {
let raw = lines[i];
let trimmed = raw.trim_end().trim_start();
if in_fence {
core.push(raw);
if trimmed.starts_with("```") {
in_fence = false;
}
i += 1;
continue;
}
match doc_line_class(raw) {
DocLineClass::FenceOpen => {
in_fence = true;
core.push(raw);
i += 1;
}
DocLineClass::Image => {
let (alt, src_ref) = image_line(trimmed).expect("classified image line");
flush(&mut core, &mut out, styles);
out.push(DocBlock::Image(ImageBlock::new(alt, src_ref)));
i += 1;
}
DocLineClass::Task => {
let line = raw.trim_end();
let indent = line.len() - line.trim_start().len();
let (checked, rest) = task_item(trimmed).expect("classified task line");
flush(&mut core, &mut out, styles);
out.push(DocBlock::Task(TaskBlock::new(
checked,
(indent / 2).min(8) as u8,
parse_inline(rest, styles, styles.base),
)));
i += 1;
}
DocLineClass::PipeText
if lines
.get(i + 1)
.is_some_and(|next| table_opens(trimmed, next)) =>
{
flush(&mut core, &mut out, styles);
let align = delimiter_alignments(lines[i + 1].trim_end().trim_start())
.expect("table_opens verified the delimiter");
let header = split_row_cells(trimmed)
.into_iter()
.map(|c| parse_inline(&c, styles, styles.base))
.collect();
i += 2; let mut rows = Vec::new();
while i < lines.len() && doc_line_class(lines[i]) == DocLineClass::PipeText {
rows.push(
split_row_cells(lines[i].trim_end().trim_start())
.into_iter()
.map(|c| parse_inline(&c, styles, styles.base))
.collect(),
);
i += 1;
}
out.push(DocBlock::Table(TableBlock::new(align, header, rows)));
}
_ => {
core.push(raw);
i += 1;
}
}
}
flush(&mut core, &mut out, styles);
out
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(super) enum DocLineClass {
FenceOpen,
Image,
Task,
Boundary,
PipeText,
ParaText,
}
pub(super) fn doc_line_class(raw: &str) -> DocLineClass {
let trimmed = raw.trim_end().trim_start();
match super::stream::line_class(raw) {
super::stream::LineClass::FenceOpen => DocLineClass::FenceOpen,
super::stream::LineClass::Boundary => {
if task_item(trimmed).is_some() {
DocLineClass::Task
} else {
DocLineClass::Boundary
}
}
super::stream::LineClass::ParaText => {
if image_line(trimmed).is_some() {
DocLineClass::Image
} else if has_unescaped_pipe(trimmed) {
DocLineClass::PipeText
} else {
DocLineClass::ParaText
}
}
}
}
pub(super) fn table_opens(header_trimmed: &str, next_raw: &str) -> bool {
match delimiter_alignments(next_raw.trim_end().trim_start()) {
Some(align) => split_row_cells(header_trimmed).len() == align.len(),
None => false,
}
}
fn has_unescaped_pipe(line: &str) -> bool {
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'\\' => i += 2,
b'|' => return true,
_ => i += 1,
}
}
false
}
pub(super) fn split_row_cells(trimmed: &str) -> Vec<String> {
let inner = trimmed.strip_prefix('|').unwrap_or(trimmed);
let b = inner.as_bytes();
let mut cells = Vec::new();
let mut cur = String::new();
let mut i = 0;
let mut ended_on_boundary = false;
while i < b.len() {
match b[i] {
b'\\' if b.get(i + 1) == Some(&b'|') => {
cur.push('|');
i += 2;
ended_on_boundary = false;
}
b'\\' if i + 1 < b.len() => {
cur.push('\\');
let start = i + 1;
i = next_char_boundary(inner, start);
cur.push_str(&inner[start..i]);
ended_on_boundary = false;
}
b'|' => {
cells.push(std::mem::take(&mut cur));
i += 1;
ended_on_boundary = true;
}
_ => {
let start = i;
i = next_char_boundary(inner, start);
cur.push_str(&inner[start..i]);
ended_on_boundary = false;
}
}
}
if !(cur.is_empty() && ended_on_boundary) {
cells.push(cur);
}
for c in &mut cells {
let t = c.trim();
if t.len() != c.len() {
*c = t.to_string();
}
}
cells
}
fn next_char_boundary(s: &str, start: usize) -> usize {
s[start..]
.char_indices()
.nth(1)
.map(|(o, _)| start + o)
.unwrap_or(s.len())
}
pub(super) fn delimiter_alignments(trimmed: &str) -> Option<Vec<CellAlign>> {
if !has_unescaped_pipe(trimmed) {
return None;
}
let cells = split_row_cells(trimmed);
if cells.is_empty() {
return None;
}
let mut out = Vec::with_capacity(cells.len());
for cell in &cells {
let c = cell.as_str();
let left = c.starts_with(':');
let right = c.ends_with(':') && c.len() > 1;
let dashes = &c[usize::from(left)..c.len() - usize::from(right)];
if dashes.is_empty() || !dashes.bytes().all(|b| b == b'-') {
return None;
}
out.push(match (left, right) {
(true, true) => CellAlign::Center,
(false, true) => CellAlign::Right,
_ => CellAlign::Left,
});
}
Some(out)
}
pub(super) fn image_line(trimmed: &str) -> Option<(String, String)> {
if !trimmed.starts_with("![") {
return None;
}
let b = trimmed.as_bytes();
let alt_end = find_close(b, 2, b"]")?;
if b.get(alt_end + 1) != Some(&b'(') {
return None;
}
let src_end = find_close(b, alt_end + 2, b")")?;
if src_end + 1 != b.len() {
return None; }
let src = &trimmed[alt_end + 2..src_end];
if src.is_empty() {
return None;
}
Some((trimmed[2..alt_end].to_string(), src.to_string()))
}
pub(super) fn task_item(trimmed: &str) -> Option<(bool, &str)> {
let (marker, rest) = list_marker(trimmed)?;
if marker != Marker::Bullet {
return None;
}
let checked = if rest.starts_with("[ ]") {
false
} else if rest.starts_with("[x]") || rest.starts_with("[X]") {
true
} else {
return None;
};
match rest.as_bytes().get(3) {
None => Some((checked, "")),
Some(b' ') => Some((checked, rest[4..].trim_start())),
Some(_) => None, }
}
#[cfg(test)]
#[path = "md_doc_tests.rs"]
mod tests;