diff --git a/AGENTS.md b/AGENTS.md index bf27617..44df423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math; `BlockData::from_markdown` adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. - Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle. diff --git a/Cargo.lock b/Cargo.lock index d330e46..29338b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1649,6 +1649,7 @@ dependencies = [ "jiff", "js-sys", "log", + "markdown", "objc", "pulldown-cmark", "qrcode", @@ -3611,6 +3612,15 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -6576,6 +6586,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 05d0232..2f3af8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ isocountry = "0.3" isolang = { version = "2.4", features = ["local_names", "list_languages"] } jiff = "0.2" log = "0.4" +markdown = "=1.0.0" pulldown-cmark = { version = "0.13", default-features = false } qrcode = { version = "0.14", default-features = false } regex = "1" diff --git a/src/documents/markdown.rs b/src/documents/markdown.rs index 3736c8f..8e7c53a 100644 --- a/src/documents/markdown.rs +++ b/src/documents/markdown.rs @@ -1,18 +1,32 @@ use std::ops::Range; use gpui::{App, FontWeight}; -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use markdown::{Constructs, ParseOptions, mdast}; use unicode_segmentation::UnicodeSegmentation; use crate::{forms::Highlight, theme::ActiveTheme}; -/// The markdown this crate reads: GitHub's tables, strikethrough and task lists, footnotes, and math between dollars. -pub(crate) fn options() -> Options { - Options::ENABLE_TABLES - | Options::ENABLE_STRIKETHROUGH - | Options::ENABLE_TASKLISTS - | Options::ENABLE_FOOTNOTES - | Options::ENABLE_MATH +/// The markdown this crate reads: GitHub's flavor, and math between dollars. +pub(crate) fn options() -> ParseOptions { + ParseOptions { + constructs: Constructs { + math_flow: true, + math_text: true, + ..Constructs::gfm() + }, + ..ParseOptions::gfm() + } +} + +/// `text` as a syntax tree. +pub(crate) fn tree(text: &str) -> mdast::Node { + markdown::to_mdast(text, &options()).expect("markdown without MDX always parses") +} + +/// The bytes of the source a parsed node spans. +pub(crate) fn span(node: &mdast::Node) -> Range { + let at = node.position().expect("a parsed node has its position"); + at.start.offset..at.end.offset } /// How a stretch of markdown prints while it is edited. @@ -47,58 +61,58 @@ pub(crate) fn edge(span: &str, mark: Mark) -> usize { } } +/// Every node under `node`, itself first, in source order. +fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) { + out.push(node); + for child in node.children().into_iter().flatten() { + walk(child, out); + } +} + /// The marks over `text`'s bytes: styles, code, links, headings and quotes, and the syntax that writes each. pub fn marks(text: &str) -> Vec<(Range, Mark)> { + let root = tree(text); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); let mut out = Vec::new(); - let mut links: Vec<(Range, Option>)> = Vec::new(); - for (event, range) in Parser::new_ext(text, options()).into_offset_iter() { - let span = &text[range.clone()]; - match event { - Event::Start(Tag::Strong | Tag::Emphasis | Tag::Strikethrough) => { - let mark = match event { - Event::Start(Tag::Strong) => Mark::Strong, - Event::Start(Tag::Emphasis) => Mark::Emphasis, - _ => Mark::Strike, - }; - let edge = edge(span, mark); - out.push((range.clone(), mark)); - out.push((range.start..range.start + edge, Mark::Syntax)); - out.push((range.end - edge..range.end, Mark::Syntax)); - } - Event::Code(_) => { - let ticks = edge(span, Mark::Code); - out.push((range.clone(), Mark::Code)); - out.push((range.start..range.start + ticks, Mark::Syntax)); - out.push((range.end - ticks..range.end, Mark::Syntax)); - } - Event::Start(Tag::Link { .. }) => links.push((range, None)), - Event::Text(_) if !links.is_empty() => { - let (_, inner) = links.last_mut().expect("inside a link"); - *inner = Some(match inner.take() { - Some(inner) => inner.start..range.end, - None => range, - }); - } - Event::End(TagEnd::Link) => { - let (whole, inner) = links.pop().expect("a link ends after it starts"); - out.push((whole.clone(), Mark::Link)); - if let Some(inner) = inner { - out.push((whole.start..inner.start, Mark::Syntax)); - out.push((inner.end..whole.end, Mark::Syntax)); + for node in nodes { + let range = span(node); + let written = &text[range.clone()]; + let styled = match node { + mdast::Node::Strong(_) => Some(Mark::Strong), + mdast::Node::Emphasis(_) => Some(Mark::Emphasis), + mdast::Node::Delete(_) => Some(Mark::Strike), + mdast::Node::InlineCode(_) => Some(Mark::Code), + _ => None, + }; + if let Some(mark) = styled { + let edge = edge(written, mark); + out.push((range.clone(), mark)); + out.push((range.start..range.start + edge, Mark::Syntax)); + out.push((range.end - edge..range.end, Mark::Syntax)); + continue; + } + match node { + mdast::Node::Link(link) => { + out.push((range.clone(), Mark::Link)); + if let (Some(first), Some(last)) = (link.children.first(), link.children.last()) { + let inner = span(first).start..span(last).end; + out.push((range.start..inner.start, Mark::Syntax)); + out.push((inner.end..range.end, Mark::Syntax)); } } - Event::Start(Tag::Heading { level, .. }) => { - let hashes = leading(span, '#'); - out.push((range.clone(), Mark::Heading(level as u8))); + mdast::Node::Heading(heading) => { + let hashes = leading(written, '#'); + out.push((range.clone(), Mark::Heading(heading.depth))); if hashes > 0 { - let spaced = hashes + leading(&span[hashes..], ' '); + let spaced = hashes + leading(&written[hashes..], ' '); out.push((range.start..range.start + spaced, Mark::Syntax)); } } - Event::Start(Tag::BlockQuote(_)) => { + mdast::Node::Blockquote(_) => { out.push((range.clone(), Mark::Quote)); let mut at = range.start; - for line in span.split_inclusive('\n') { + for line in written.split_inclusive('\n') { let marker = line.len() - line.trim_start_matches(['>', ' ']).len(); out.push((at..at + marker, Mark::Syntax)); at += line.len(); @@ -154,39 +168,32 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range, Highlight /// A link written `[text](address)` around byte `at`: its range, its text and its address. pub fn link_at(text: &str, at: usize) -> Option<(Range, String, String)> { - Parser::new_ext(text, options()) - .into_offset_iter() - .find_map(|(event, range)| match event { - Event::Start(Tag::Link { dest_url, .. }) - if range.start <= at && at <= range.end && text[range.clone()].starts_with('[') => - { - let written = &text[range.clone()]; - let label = &written[1..written.rfind("](")?]; - Some((range, label.to_string(), dest_url.to_string())) + let root = tree(text); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); + nodes.into_iter().find_map(|node| match node { + mdast::Node::Link(link) => { + let range = span(node); + let written = &text[range.clone()]; + if !(range.start <= at && at <= range.end && written.starts_with('[')) { + return None; } - _ => None, - }) + let label = &written[1..written.rfind("](")?]; + Some((range, label.to_string(), link.url.clone())) + } + _ => None, + }) } -/// The document's top-level blocks, as ranges of its source. +/// The document's top-level blocks, as ranges of its source; link definitions are none. pub fn block_ranges(text: &str) -> Vec> { - let mut depth = 0usize; - let mut out = Vec::new(); - for (event, range) in Parser::new_ext(text, options()).into_offset_iter() { - match event { - Event::Start(_) => { - if depth == 0 { - out.push(range); - } - depth += 1; - } - Event::End(_) => depth -= 1, - Event::Rule if depth == 0 => out.push(range), - _ => {} - } - } - out.into_iter() - .map(|range| { + let root = tree(text); + root.children() + .expect("the root holds the blocks") + .iter() + .filter(|block| !matches!(block, mdast::Node::Definition(_))) + .map(|block| { + let range = span(block); let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len(); range.start..range.start + kept }) diff --git a/src/documents/render.rs b/src/documents/render.rs index 078f169..c5709fa 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -5,7 +5,7 @@ use gpui::{ IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div, prelude::*, }; -use pulldown_cmark::Alignment; +use markdown::mdast::AlignKind; use super::tree::{Inline, Node, Style, parse}; use crate::{ @@ -291,10 +291,10 @@ impl Draw<'_> { .flex() .px_3() .py_1p5() - .when(aligns.get(column) == Some(&Alignment::Right), |cell| { + .when(aligns.get(column) == Some(&AlignKind::Right), |cell| { cell.justify_end() }) - .when(aligns.get(column) == Some(&Alignment::Center), |cell| { + .when(aligns.get(column) == Some(&AlignKind::Center), |cell| { cell.justify_center() }) .when(header, |cell| { diff --git a/src/documents/tree.rs b/src/documents/tree.rs index bd6c1d0..4f8fa0d 100644 --- a/src/documents/tree.rs +++ b/src/documents/tree.rs @@ -1,8 +1,8 @@ -use std::ops::Range; +use std::{collections::HashMap, ops::Range}; -use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; +use markdown::mdast::{self, AlignKind}; -use super::markdown::options; +use super::markdown::{span, tree}; /// How a stretch of inline text prints. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -40,7 +40,7 @@ pub enum Node { items: Vec<(Option, Vec)>, }, Table { - aligns: Vec, + aligns: Vec, head: Vec, rows: Vec>, }, @@ -56,183 +56,187 @@ pub enum Node { }, } -/// Parses into nodes as it goes: a stack of open containers and the inline text being built. -#[derive(Default)] -struct Builder { - stack: Vec<(Frame, Vec)>, - inline: Inline, - open: Vec<(usize, Style)>, - link: Vec<(usize, String)>, - image: Vec<(String, String)>, - code: Option<(Option, String)>, - head: Vec, - row: Vec, - rows: Vec>, -} - -enum Frame { - Quote, - List(Option, Vec<(Option, Vec)>), - Item(Option), - Table(Vec), - Footnote(String), -} - -impl Builder { - fn take_inline(&mut self) -> Inline { - std::mem::take(&mut self.inline) - } +/// A tree read against its source and its link definitions. +struct Reader<'a> { + source: &'a str, + urls: HashMap, } /// `markdown` as blocks. pub fn parse(markdown: &str) -> Vec { - let mut built = Builder::default(); - let mut out: Vec = Vec::new(); - for event in Parser::new_ext(markdown, options()) { - match event { - Event::Start(Tag::Paragraph | Tag::Heading { .. } | Tag::TableCell) => {} - Event::End(TagEnd::Paragraph) => { - let text = built.take_inline(); - if !text.text.is_empty() { - push(&mut built, &mut out, Node::Paragraph(text)); + let root = tree(markdown); + let blocks = root.children().expect("the root holds the blocks"); + let mut urls = HashMap::new(); + for block in blocks { + if let mdast::Node::Definition(definition) = block { + urls.entry(definition.identifier.clone()) + .or_insert_with(|| definition.url.clone()); + } + } + let reader = Reader { + source: markdown, + urls, + }; + let mut out = Vec::new(); + reader.blocks(blocks, &mut out); + out +} + +impl Reader<'_> { + fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec) { + for node in nodes { + self.block(node, out); + } + } + + fn nested(&self, nodes: &[mdast::Node]) -> Vec { + let mut out = Vec::new(); + self.blocks(nodes, &mut out); + out + } + + /// The address a reference names: the parser makes references to definitions alone. + fn url(&self, identifier: &str) -> String { + self.urls + .get(identifier) + .cloned() + .expect("a reference names a definition") + } + + fn block(&self, node: &mdast::Node, out: &mut Vec) { + match node { + mdast::Node::Paragraph(paragraph) => self.paragraph(node, ¶graph.children, out), + mdast::Node::Heading(heading) => { + out.push(Node::Heading(heading.depth, self.inline(&heading.children))) + } + mdast::Node::Blockquote(quote) => out.push(Node::Quote(self.nested("e.children))), + mdast::Node::Code(code) => out.push(Node::Code { + language: code.lang.clone(), + text: code.value.clone(), + }), + mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())), + mdast::Node::List(list) => { + let items = list + .children + .iter() + .map(|item| match item { + mdast::Node::ListItem(item) => (item.checked, self.nested(&item.children)), + other => unreachable!("a list holds items, not {other:?}"), + }) + .collect(); + let start = list.ordered.then(|| u64::from(list.start.unwrap_or(1))); + out.push(Node::List { start, items }); + } + mdast::Node::Table(table) => { + let mut rows = table.children.iter().map(|row| match row { + mdast::Node::TableRow(row) => row + .children + .iter() + .map(|cell| self.inline(cell.children().expect("a cell holds inlines"))) + .collect::>(), + other => unreachable!("a table holds rows, not {other:?}"), + }); + let head = rows.next().expect("a table has its head row"); + out.push(Node::Table { + aligns: table.align.clone(), + head, + rows: rows.collect(), + }); + } + mdast::Node::ThematicBreak(_) => out.push(Node::Rule), + mdast::Node::FootnoteDefinition(note) => out.push(Node::Footnote { + label: note + .label + .clone() + .unwrap_or_else(|| note.identifier.clone()), + body: self.nested(¬e.children), + }), + mdast::Node::Html(_) | mdast::Node::Definition(_) => {} + other => unreachable!("{other:?} is no block outside MDX"), + } + } + + /// A paragraph's text split around its images; `$$` math written alone stands as a block. + fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec) { + if let [mdast::Node::InlineMath(math)] = children + && self.source[span(node)].starts_with("$$") + { + return out.push(Node::Math(math.value.clone())); + } + let mut run = Inline::default(); + for child in children { + let image = match child { + mdast::Node::Image(image) => Some((image.alt.clone(), image.url.clone())), + mdast::Node::ImageReference(image) => { + Some((image.alt.clone(), self.url(&image.identifier))) } - } - Event::End(TagEnd::Heading(level)) => { - let text = built.take_inline(); - push(&mut built, &mut out, Node::Heading(depth(level), text)); - } - Event::Start(Tag::BlockQuote(_)) => built.stack.push((Frame::Quote, Vec::new())), - Event::Start(Tag::List(start)) => { - flush(&mut built, &mut out); - built - .stack - .push((Frame::List(start, Vec::new()), Vec::new())); - } - Event::Start(Tag::Item) => built.stack.push((Frame::Item(None), Vec::new())), - Event::TaskListMarker(done) => { - if let Some((Frame::Item(task), _)) = built.stack.last_mut() { - *task = Some(done); + _ => None, + }; + match image { + Some((alt, url)) => { + text_block(std::mem::take(&mut run), out); + out.push(Node::Image { alt, url }); } + None => self.inline_into(child, &mut run), } - Event::End(TagEnd::Item) => { - flush(&mut built, &mut out); - let (frame, nodes) = built.stack.pop().expect("an item was open"); - let Frame::Item(task) = frame else { - unreachable!("an item closes an item") - }; - if let Some((Frame::List(_, items), _)) = built.stack.last_mut() { - items.push((task, nodes)); - } + } + text_block(run, out); + } + + fn inline(&self, nodes: &[mdast::Node]) -> Inline { + let mut text = Inline::default(); + for node in nodes { + self.inline_into(node, &mut text); + } + text + } + + /// `nodes` into `text`; their stretch of it, styled. + fn styled_children(&self, nodes: &[mdast::Node], text: &mut Inline) -> Range { + let start = text.text.len(); + for node in nodes { + self.inline_into(node, text); + } + start..text.text.len() + } + + fn inline_into(&self, node: &mdast::Node, text: &mut Inline) { + let (children, style) = match node { + mdast::Node::Text(words) => return text.text.push_str(&words.value.replace('\n', " ")), + mdast::Node::Break(_) => return text.text.push('\n'), + mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code), + mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math), + mdast::Node::FootnoteReference(note) => { + let label = note.label.as_ref().unwrap_or(¬e.identifier); + return styled(text, &format!("[{label}]"), Style::Footnote); } - Event::End(TagEnd::List(_)) => { - let (frame, _) = built.stack.pop().expect("a list was open"); - let Frame::List(start, items) = frame else { - unreachable!("a list closes a list") - }; - push(&mut built, &mut out, Node::List { start, items }); + mdast::Node::Image(image) => return text.text.push_str(&image.alt), + mdast::Node::ImageReference(image) => return text.text.push_str(&image.alt), + mdast::Node::Html(_) => return, + mdast::Node::Strong(strong) => (&strong.children, Style::Strong), + mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis), + mdast::Node::Delete(delete) => (&delete.children, Style::Strike), + mdast::Node::Link(link) => (&link.children, Style::Link), + mdast::Node::LinkReference(link) => (&link.children, Style::Link), + other => unreachable!("{other:?} is no inline outside MDX"), + }; + let range = self.styled_children(children, text); + text.styles.push((range.clone(), style)); + match node { + mdast::Node::Link(link) => text.links.push((range, link.url.clone())), + mdast::Node::LinkReference(link) => { + text.links.push((range, self.url(&link.identifier))) } - Event::End(TagEnd::BlockQuote(_)) => { - let (_, nodes) = built.stack.pop().expect("a quote was open"); - push(&mut built, &mut out, Node::Quote(nodes)); - } - Event::Start(Tag::FootnoteDefinition(label)) => built - .stack - .push((Frame::Footnote(label.to_string()), Vec::new())), - Event::End(TagEnd::FootnoteDefinition) => { - let (frame, body) = built.stack.pop().expect("a footnote was open"); - let Frame::Footnote(label) = frame else { - unreachable!("a footnote closes a footnote") - }; - push(&mut built, &mut out, Node::Footnote { label, body }); - } - Event::Start(Tag::CodeBlock(kind)) => { - let language = match kind { - CodeBlockKind::Fenced(name) if !name.is_empty() => Some(name.to_string()), - _ => None, - }; - built.code = Some((language, String::new())); - } - Event::End(TagEnd::CodeBlock) => { - let (language, text) = built.code.take().expect("a code block was open"); - push( - &mut built, - &mut out, - Node::Code { - language, - text: text.trim_end_matches('\n').to_string(), - }, - ); - } - Event::Start(Tag::Table(aligns)) => { - built.stack.push((Frame::Table(aligns), Vec::new())) - } - Event::End(TagEnd::TableCell) => { - let cell = built.take_inline(); - built.row.push(cell); - } - Event::End(TagEnd::TableHead) => built.head = std::mem::take(&mut built.row), - Event::End(TagEnd::TableRow) => { - let row = std::mem::take(&mut built.row); - built.rows.push(row); - } - Event::End(TagEnd::Table) => { - let (frame, _) = built.stack.pop().expect("a table was open"); - let Frame::Table(aligns) = frame else { - unreachable!("a table closes a table") - }; - let (head, rows) = ( - std::mem::take(&mut built.head), - std::mem::take(&mut built.rows), - ); - push(&mut built, &mut out, Node::Table { aligns, head, rows }); - } - Event::Rule => push(&mut built, &mut out, Node::Rule), - Event::DisplayMath(math) => push(&mut built, &mut out, Node::Math(math.to_string())), - Event::Start(Tag::Image { dest_url, .. }) => { - built.image.push((String::new(), dest_url.to_string())) - } - Event::End(TagEnd::Image) => { - let (alt, url) = built.image.pop().expect("an image closes after it opens"); - push(&mut built, &mut out, Node::Image { alt, url }); - } - Event::Start(Tag::Strong) => built.open.push((built.inline.text.len(), Style::Strong)), - Event::Start(Tag::Emphasis) => { - built.open.push((built.inline.text.len(), Style::Emphasis)) - } - Event::Start(Tag::Strikethrough) => { - built.open.push((built.inline.text.len(), Style::Strike)) - } - Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) => { - let (start, style) = built.open.pop().expect("a style closes after it opens"); - let end = built.inline.text.len(); - built.inline.styles.push((start..end, style)); - } - Event::Start(Tag::Link { dest_url, .. }) => built - .link - .push((built.inline.text.len(), dest_url.to_string())), - Event::End(TagEnd::Link) => { - let (start, url) = built.link.pop().expect("a link closes after it opens"); - let range = start..built.inline.text.len(); - built.inline.styles.push((range.clone(), Style::Link)); - built.inline.links.push((range, url)); - } - Event::Text(text) => match (&mut built.code, built.image.last_mut()) { - (Some((_, code)), _) => code.push_str(&text), - (None, Some((alt, _))) => alt.push_str(&text), - (None, None) => built.inline.text.push_str(&text), - }, - Event::Code(code) => styled(&mut built.inline, &code, Style::Code), - Event::InlineMath(math) => styled(&mut built.inline, &math, Style::Math), - Event::FootnoteReference(label) => { - styled(&mut built.inline, &format!("[{label}]"), Style::Footnote) - } - Event::SoftBreak => built.inline.text.push(' '), - Event::HardBreak => built.inline.text.push('\n'), _ => {} } } - flush(&mut built, &mut out); - out +} + +/// Text left between images, unless it is blank. +fn text_block(text: Inline, out: &mut Vec) { + if !text.text.trim().is_empty() { + out.push(Node::Paragraph(text)); + } } fn styled(inline: &mut Inline, text: &str, style: Style) { @@ -241,26 +245,6 @@ fn styled(inline: &mut Inline, text: &str, style: Style) { inline.styles.push((start..inline.text.len(), style)); } -fn depth(level: HeadingLevel) -> u8 { - level as u8 -} - -/// A finished node goes into the open container, or the document. -fn push(built: &mut Builder, out: &mut Vec, node: Node) { - match built.stack.last_mut() { - Some((_, nodes)) => nodes.push(node), - None => out.push(node), - } -} - -/// Text left from a tight list item becomes its paragraph. -fn flush(built: &mut Builder, out: &mut Vec) { - if !built.inline.text.is_empty() { - let text = built.take_inline(); - push(built, out, Node::Paragraph(text)); - } -} - #[cfg(test)] mod tests { use super::*; @@ -297,21 +281,51 @@ mod tests { } #[test] - fn an_image_inside_an_image_keeps_both() { - let nodes = parse("![outer ![inner](inner.png)](outer.png)"); - let urls: Vec<&str> = nodes - .iter() - .filter_map(|node| match node { - Node::Image { url, .. } => Some(url.as_str()), - _ => None, - }) - .collect(); - assert_eq!(urls, ["inner.png", "outer.png"], "{nodes:?}"); + fn an_image_inside_an_image_is_the_outer_one() { + // markdown-rs drops the inner image from the alt. + assert_eq!( + parse("![outer ![inner](inner.png)](outer.png)"), + [Node::Image { + alt: "outer ".into(), + url: "outer.png".into() + }] + ); + } + + #[test] + fn an_image_splits_its_paragraph_and_references_resolve() { + let nodes = + parse("Before ![a][pic] after [site].\n\n[pic]: a.png\n[site]: https://ely.dev\n"); + assert!(matches!(&nodes[0], Node::Paragraph(text) if text.text == "Before ")); + assert_eq!( + nodes[1], + Node::Image { + alt: "a".into(), + url: "a.png".into() + } + ); + let Node::Paragraph(after) = &nodes[2] else { + panic!("a paragraph, not {:?}", nodes[2]) + }; + assert_eq!(after.text, " after site."); + assert_eq!(after.links, [(7..11, "https://ely.dev".to_string())]); + assert_eq!(nodes.len(), 3, "definitions draw nothing: {nodes:?}"); + } + + #[test] + fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() { + let nodes = parse("one\ntwo \nthree"); + assert!(matches!(&nodes[0], Node::Paragraph(text) if text.text == "one two\nthree")); } #[test] fn display_math_stands_alone() { assert_eq!(parse("$$c = 1$$"), [Node::Math("c = 1".into())]); + assert_eq!(parse("$$\nc = 1\n$$"), [Node::Math("c = 1".into())]); + let Node::Paragraph(inline) = &parse("so $c$ holds")[0] else { + panic!("inline math stays in its paragraph") + }; + assert_eq!(inline.styles, [(3..4, Style::Math)]); } #[test] @@ -320,7 +334,7 @@ mod tests { let Node::Table { aligns, head, rows } = &nodes[0] else { panic!("a table, not {:?}", nodes[0]) }; - assert_eq!(aligns, &[Alignment::Left, Alignment::Right]); + assert_eq!(aligns, &[AlignKind::Left, AlignKind::Right]); assert_eq!( head.iter() .map(|cell| cell.text.as_str()) @@ -329,4 +343,17 @@ mod tests { ); assert_eq!(rows[0][1].text, "0.5"); } + + #[test] + fn footnotes_number_their_references_and_hold_their_body() { + let nodes = parse("Lift[^a].\n\n[^a]: Toward white.\n"); + let Node::Paragraph(line) = &nodes[0] else { + panic!("a paragraph, not {:?}", nodes[0]) + }; + assert_eq!(line.text, "Lift[a]."); + assert_eq!(line.styles, [(4..7, Style::Footnote)]); + assert!( + matches!(&nodes[1], Node::Footnote { label, body } if label == "a" && body.len() == 1) + ); + } }