parse.rs raw

//! CommonMark source → [`Block`]s, dressed from the theme's tokens.
//!
//! pulldown-cmark answers "what does this text mean"; everything here is the
//! translation of that answer into the framework's own vocabulary — a
//! [`RichText`] per block, with a [`TextSpan`] per inline run. The spans are
//! byte ranges into the flattened prose, which is exactly the coordinate
//! system `Paragraph` hit-tests and wraps in, so a link that breaks across a
//! line needs nothing said about it here.

use guiduck_core::text::{Paragraph, RichText, TextSpan};
use guiduck_scene::geom::Point;
use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd};

use crate::block::{Block, BlockKind, CellAlign, ImageContent, Table, TableCell};
use crate::tokens::MarkdownTokens;

/// Parse `source` into blocks dressed by `tokens`.
///
/// The blocks are rebuilt whenever either input changes: the tokens decide
/// font sizes, so a theme change is a re-layout of the document either way, and
/// one construction path means the dressing cannot drift from the parse.
pub fn parse(source: &str, tokens: &MarkdownTokens) -> Vec<Block> {
    let mut builder = Builder::new(tokens);
    let mut options = Options::empty();
    // Strikethrough the span vocabulary already carries; tables the block model
    // now does.
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TABLES);
    for event in Parser::new_ext(source, options) {
        builder.event(event);
    }
    builder.finish()
}

struct Builder<'a> {
    tokens: &'a MarkdownTokens,
    blocks: Vec<Block>,

    /// The block being accumulated, if any.
    kind: Option<BlockKind>,
    /// The marker of the list item being accumulated.
    marker: Option<String>,
    /// The flattened prose of the current block, and the runs over it. Byte
    /// offsets into `text` are the only coordinate anything downstream uses.
    text: String,
    spans: Vec<(std::ops::Range<usize>, TextSpan)>,

    /// Open inline runs, by the byte they started at. A stack per kind, so
    /// `**a *b* c**` closes the inner one first without either knowing about
    /// the other.
    emphasis: Vec<usize>,
    strong: Vec<usize>,
    strikethrough: Vec<usize>,
    links: Vec<(usize, String)>,

    /// Enclosing block quotes.
    quote_depth: u8,
    /// Enclosing lists: `Some(n)` is the next number of an ordered one, `None`
    /// a bulleted one.
    lists: Vec<Option<u64>>,
    /// Whether `Event::Text` is a code block's contents rather than prose.
    in_code: bool,
    /// The table being accumulated, if inside one. Its cells reuse the same
    /// inline machinery every block's prose does; only where a finished run of
    /// text *goes* differs.
    table: Option<TableBuild>,

    /// Images seen in the current block: the byte range their alt text occupies
    /// in `text`, and the `src`. A paragraph that is *exactly* one image
    /// becomes a block image; anywhere else, the alt text stands in the prose.
    images: Vec<(std::ops::Range<usize>, String)>,
    /// The alt-text start and `src` while inside an image tag.
    image: Option<(usize, String)>,
}

/// A table under construction: the alignments, the rows finished so far, and
/// the row in progress.
struct TableBuild {
    aligns: Vec<CellAlign>,
    rows: Vec<Vec<TableCell>>,
    current: Vec<TableCell>,
}

impl<'a> Builder<'a> {
    fn new(tokens: &'a MarkdownTokens) -> Self {
        Self {
            tokens,
            blocks: Vec::new(),
            kind: None,
            marker: None,
            text: String::new(),
            spans: Vec::new(),
            emphasis: Vec::new(),
            strong: Vec::new(),
            strikethrough: Vec::new(),
            links: Vec::new(),
            quote_depth: 0,
            lists: Vec::new(),
            in_code: false,
            table: None,
            images: Vec::new(),
            image: None,
        }
    }

    fn event(&mut self, event: Event<'_>) {
        match event {
            Event::Start(Tag::Paragraph) => {
                // A list item's own first paragraph is the item, not a block
                // under it — which is the whole difference between a tight and
                // a loose list, and it is no difference at all once the marker
                // is already ours.
                if self.kind == Some(BlockKind::ListItem) && self.text.is_empty() {
                    return;
                }
                self.flush();
                self.kind = Some(BlockKind::Paragraph);
            }
            Event::End(TagEnd::Paragraph) => self.flush(),

            Event::Start(Tag::Heading { level, .. }) => {
                self.flush();
                self.kind = Some(BlockKind::Heading(heading_level(level)));
            }
            Event::End(TagEnd::Heading(_)) => self.flush(),

            Event::Start(Tag::CodeBlock(_)) => {
                self.flush();
                self.kind = Some(BlockKind::Code);
                self.in_code = true;
            }
            Event::End(TagEnd::CodeBlock) => {
                self.in_code = false;
                // The fence's own trailing newline is the fence, not a blank
                // last line of the program.
                while self.text.ends_with('\n') {
                    self.text.pop();
                }
                self.flush();
            }

            Event::Start(Tag::BlockQuote(_)) => {
                self.flush();
                self.quote_depth += 1;
            }
            Event::End(TagEnd::BlockQuote(_)) => {
                self.flush();
                self.quote_depth = self.quote_depth.saturating_sub(1);
            }

            Event::Start(Tag::List(start)) => {
                self.flush();
                self.lists.push(start);
            }
            Event::End(TagEnd::List(_)) => {
                self.flush();
                self.lists.pop();
            }
            Event::Start(Tag::Item) => {
                self.flush();
                self.marker = Some(self.next_marker());
                self.kind = Some(BlockKind::ListItem);
            }
            Event::End(TagEnd::Item) => self.flush(),

            Event::Rule => {
                self.flush();
                let block = Block::new(BlockKind::Rule, self.quote_depth, self.lists.len(), None);
                self.blocks.push(block);
            }

            Event::Start(Tag::Table(aligns)) => {
                self.flush();
                self.table = Some(TableBuild {
                    aligns: aligns.iter().map(map_align).collect(),
                    rows: Vec::new(),
                    current: Vec::new(),
                });
            }
            Event::End(TagEnd::Table) => self.finish_table(),
            // A head or a body row: both accumulate cells into `current`, which
            // is closed into a row when the row ends.
            Event::Start(Tag::TableHead | Tag::TableRow) => self.start_cell_text(),
            Event::End(TagEnd::TableHead | TagEnd::TableRow) => {
                if let Some(table) = &mut self.table {
                    let row = std::mem::take(&mut table.current);
                    table.rows.push(row);
                }
            }
            Event::Start(Tag::TableCell) => self.start_cell_text(),
            Event::End(TagEnd::TableCell) => {
                let text = std::mem::take(&mut self.text);
                let spans = std::mem::take(&mut self.spans);
                let body = self.prose_paragraph(text, spans);
                if let Some(table) = &mut self.table {
                    table.current.push(TableCell {
                        body,
                        origin: Point::ORIGIN,
                    });
                }
            }

            Event::Start(Tag::Emphasis) => self.emphasis.push(self.text.len()),
            Event::End(TagEnd::Emphasis) => {
                if let Some(start) = self.emphasis.pop() {
                    self.close(start, TextSpan::new().italic());
                }
            }
            Event::Start(Tag::Strong) => self.strong.push(self.text.len()),
            Event::End(TagEnd::Strong) => {
                if let Some(start) = self.strong.pop() {
                    self.close(start, TextSpan::new().bold());
                }
            }
            Event::Start(Tag::Strikethrough) => self.strikethrough.push(self.text.len()),
            Event::End(TagEnd::Strikethrough) => {
                if let Some(start) = self.strikethrough.pop() {
                    self.close(start, TextSpan::new().strikethrough());
                }
            }
            Event::Start(Tag::Link { dest_url, .. }) => {
                self.links.push((self.text.len(), dest_url.into_string()));
            }
            Event::End(TagEnd::Link) => {
                if let Some((start, target)) = self.links.pop() {
                    // The span carries the *target*, not a color: what a link
                    // looks like is `link-color`/`link-underline-width`, which
                    // `Paragraph` puts under whatever else the span says.
                    self.close(start, TextSpan::new().link(target));
                }
            }

            Event::Start(Tag::Image { dest_url, .. }) => {
                self.image = Some((self.text.len(), dest_url.into_string()));
            }
            Event::End(TagEnd::Image) => {
                // The alt text flowed into `text` between the tags, so its range
                // is the start we remembered to here. Kept in the prose, so a
                // mid-sentence image degrades to its alt text; lifted out only
                // when the image is the paragraph's whole content.
                if let Some((start, src)) = self.image.take() {
                    self.images.push((start..self.text.len(), src));
                }
            }

            Event::Text(text) => self.push_str(&text),
            Event::Code(code) => {
                let start = self.text.len();
                self.push_str(&code);
                self.close(start, self.code_span());
            }
            Event::SoftBreak => self.push_str(if self.in_code { "\n" } else { " " }),
            Event::HardBreak => self.push_str("\n"),

            // HTML, footnotes and task markers are not in this widget's
            // vocabulary; see the crate docs for which are deferred and why.
            // Dropping the event renders the document without them rather than
            // refusing to render it.
            _ => {}
        }
    }

    fn finish(mut self) -> Vec<Block> {
        self.flush();
        self.blocks
    }

    fn push_str(&mut self, text: &str) {
        self.text.push_str(text);
    }

    /// A run that ran from `start` to here.
    fn close(&mut self, start: usize, span: TextSpan) {
        let end = self.text.len();
        if start < end {
            self.spans.push((start..end, span));
        }
    }

    /// Begin a fresh run of inline text, for the next cell (or the first cell
    /// of a row). The inline stacks are per-cell, and a well-formed table
    /// closes each run it opens inside the cell, so clearing the text is all it
    /// takes to isolate one cell from the next.
    fn start_cell_text(&mut self) {
        self.text.clear();
        self.spans.clear();
    }

    /// A cell's prose, in the document's body font — the same construction a
    /// paragraph block gets, minus the heading and code special-casing a cell
    /// never needs.
    fn prose_paragraph(
        &self,
        text: String,
        spans: Vec<(std::ops::Range<usize>, TextSpan)>,
    ) -> Paragraph {
        let mut content = RichText::new(text);
        for (range, span) in spans {
            content = content.span(range, span);
        }
        let mut body = Paragraph::default();
        body.set_content(content);
        body.set_link_style(self.tokens.link.clone());
        body.set_font_size(self.tokens.font_size as f32);
        body.set_brush(self.tokens.color.clone());
        body.set_family(Some(self.tokens.font_family.clone()));
        body
    }

    /// Close the current table into a block, padding ragged rows to the column
    /// count so every `rows[r][c]` is a real cell.
    fn finish_table(&mut self) {
        let Some(mut build) = self.table.take() else {
            return;
        };
        let columns = build.aligns.len();
        for row in &mut build.rows {
            while row.len() < columns {
                row.push(TableCell {
                    body: self.prose_paragraph(String::new(), Vec::new()),
                    origin: Point::ORIGIN,
                });
            }
            row.truncate(columns);
        }
        // A table with no body is still a table: the header alone is content.
        let table = Table {
            aligns: build.aligns,
            rows: build.rows,
            header_rows: 1,
            col_edges: Vec::new(),
            row_edges: Vec::new(),
        };
        let mut block = Block::new(BlockKind::Table, self.quote_depth, self.lists.len(), None);
        block.table = Some(table);
        self.blocks.push(block);
    }

    fn code_span(&self) -> TextSpan {
        TextSpan::new()
            .family(self.tokens.code_font_family.clone())
            .color(self.tokens.code_color.clone())
            .font_size(self.tokens.code_font_size as f32)
            .background(self.tokens.code_background.clone())
    }

    /// The marker of the item now starting, and the counter moved on.
    fn next_marker(&mut self) -> String {
        match self.lists.last_mut() {
            Some(Some(number)) => {
                let marker = format!("{number}.");
                *number += 1;
                marker
            }
            _ => self.tokens.bullet.clone(),
        }
    }

    /// Turn everything accumulated into a block, if there is anything to turn.
    fn flush(&mut self) {
        let Some(kind) = self.kind.take() else {
            // A `(rule …)` or a stray end tag: nothing was accumulating.
            self.text.clear();
            self.spans.clear();
            self.marker = None;
            self.images.clear();
            return;
        };
        let text = std::mem::take(&mut self.text);
        let spans = std::mem::take(&mut self.spans);
        let marker = self.marker.take();
        let images = std::mem::take(&mut self.images);

        // A paragraph that is exactly one image — its alt text spanning the
        // whole prose — is a block image, resolved by the widget later. Checked
        // before the empty-text return so `![](src)` (an image with no alt) is
        // still a block, not nothing.
        if kind == BlockKind::Paragraph && images.len() == 1 && images[0].0 == (0..text.len()) {
            let (alt_range, src) = &images[0];
            let alt = self.prose_paragraph(text[alt_range.clone()].to_owned(), Vec::new());
            let mut block = Block::new(BlockKind::Image, self.quote_depth, self.lists.len(), None);
            block.image = Some(ImageContent {
                src: src.clone(),
                alt,
                graphic: None,
                deferred: false,
            });
            self.blocks.push(block);
            return;
        }

        // An item with no text is still an item: its marker is content.
        if text.is_empty() && kind != BlockKind::ListItem {
            return;
        }

        let mut content = RichText::new(text);
        // The heading's weight goes on first, under the inline runs, so
        // `# a *b*` is bold-italic rather than bold-then-not.
        if matches!(kind, BlockKind::Heading(_)) && self.tokens.heading_weight != 400.0 {
            let whole = 0..content.text.len();
            content = content.span(
                whole,
                TextSpan::new().weight(self.tokens.heading_weight as f32),
            );
        }
        for (range, span) in spans {
            content = content.span(range, span);
        }

        let mut body = Paragraph::default();
        body.set_content(content);
        body.set_link_style(self.tokens.link.clone());
        match kind {
            BlockKind::Heading(level) => {
                body.set_font_size(self.tokens.heading_font_size(level) as f32);
                body.set_brush(self.tokens.heading_color.clone());
                body.set_family(Some(self.tokens.font_family.clone()));
            }
            BlockKind::Code => {
                body.set_font_size(self.tokens.code_font_size as f32);
                body.set_brush(self.tokens.code_color.clone());
                body.set_family(Some(self.tokens.code_font_family.clone()));
            }
            // A table and an image never flush a body (a table's cells and an
            // image's block are built directly), so their arms here are
            // unreachable; they take the body styling all the same rather than
            // being special-cased.
            BlockKind::Paragraph
            | BlockKind::ListItem
            | BlockKind::Rule
            | BlockKind::Table
            | BlockKind::Image => {
                body.set_font_size(self.tokens.font_size as f32);
                body.set_brush(self.tokens.color.clone());
                body.set_family(Some(self.tokens.font_family.clone()));
            }
        }

        let mut block = Block::new(kind, self.quote_depth, self.lists.len(), Some(body));
        if let Some(marker) = marker {
            let mut paragraph = Paragraph::default();
            paragraph.set_content(RichText::new(marker));
            paragraph.set_font_size(self.tokens.font_size as f32);
            paragraph.set_brush(self.tokens.color.clone());
            paragraph.set_family(Some(self.tokens.font_family.clone()));
            block.marker = Some(paragraph);
        }
        self.blocks.push(block);
    }
}

/// A column's delimiter-row alignment. `None` (no colons) and `Left` are both
/// start-aligned; there is no widget-side difference between them.
fn map_align(align: &Alignment) -> CellAlign {
    match align {
        Alignment::Right => CellAlign::End,
        Alignment::Center => CellAlign::Center,
        Alignment::None | Alignment::Left => CellAlign::Start,
    }
}

/// `HeadingLevel` is an enum of the six levels; this is the number a reader
/// wrote.
fn heading_level(level: HeadingLevel) -> u8 {
    match level {
        HeadingLevel::H1 => 1,
        HeadingLevel::H2 => 2,
        HeadingLevel::H3 => 3,
        HeadingLevel::H4 => 4,
        HeadingLevel::H5 => 5,
        HeadingLevel::H6 => 6,
    }
}