751 lines
24 KiB
Rust
751 lines
24 KiB
Rust
//! A small Markdown subset renderer for the contents of `text` nodes.
|
|
//!
|
|
//! The spec says a text node holds Markdown but does not pin down a flavour, so
|
|
//! this covers the parts that actually show up on canvases: headings, emphasis,
|
|
//! inline code, fenced code, quotes, bullet/numbered/task lists, rules, links
|
|
//! and images. Anything it does not recognise is rendered as plain text.
|
|
|
|
use eframe::egui::{self, Color32, RichText, Ui};
|
|
|
|
use crate::theme::Palette;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Model
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct Style {
|
|
pub bold: bool,
|
|
pub italic: bool,
|
|
pub code: bool,
|
|
pub strike: bool,
|
|
}
|
|
|
|
/// A run of text sharing one style, optionally a link.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Span {
|
|
pub text: String,
|
|
pub style: Style,
|
|
/// Link target (`[text](target)`), or the URL of an autolink.
|
|
pub link: Option<String>,
|
|
/// `true` for ``.
|
|
pub image: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Marker {
|
|
Bullet,
|
|
Number(u64),
|
|
Task(bool),
|
|
}
|
|
|
|
/// One cell of a table.
|
|
pub type Cell = Vec<Span>;
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Block {
|
|
Heading { level: u8, spans: Vec<Span> },
|
|
Paragraph(Vec<Span>),
|
|
List { indent: usize, marker: Marker, spans: Vec<Span> },
|
|
Quote(Vec<Span>),
|
|
Code { language: String, text: String },
|
|
Table { header: Vec<Cell>, rows: Vec<Vec<Cell>> },
|
|
Rule,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Block parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Splits Markdown source into renderable blocks.
|
|
pub fn parse(source: &str) -> Vec<Block> {
|
|
let mut blocks = Vec::new();
|
|
let mut paragraph: Vec<String> = Vec::new();
|
|
let mut quote: Vec<String> = Vec::new();
|
|
let mut lines = source.lines().peekable();
|
|
|
|
fn flush(buffer: &mut Vec<String>, blocks: &mut Vec<Block>, quoted: bool) {
|
|
if buffer.is_empty() {
|
|
return;
|
|
}
|
|
let text = buffer.join(" ");
|
|
buffer.clear();
|
|
let spans = parse_inline(&text);
|
|
blocks.push(if quoted {
|
|
Block::Quote(spans)
|
|
} else {
|
|
Block::Paragraph(spans)
|
|
});
|
|
}
|
|
|
|
while let Some(line) = lines.next() {
|
|
let trimmed = line.trim();
|
|
|
|
if let Some(fence) = fence_of(trimmed) {
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
let language = trimmed[fence.len()..].trim().to_owned();
|
|
let mut body = Vec::new();
|
|
for line in lines.by_ref() {
|
|
if line.trim().starts_with(fence) {
|
|
break;
|
|
}
|
|
body.push(line.to_owned());
|
|
}
|
|
blocks.push(Block::Code {
|
|
language,
|
|
text: body.join("\n"),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if trimmed.is_empty() {
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
continue;
|
|
}
|
|
|
|
if is_rule(trimmed) {
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
blocks.push(Block::Rule);
|
|
continue;
|
|
}
|
|
|
|
if let Some((level, rest)) = heading_of(trimmed) {
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
blocks.push(Block::Heading {
|
|
level,
|
|
spans: parse_inline(rest),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = trimmed.strip_prefix('>') {
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
quote.push(rest.trim_start().to_owned());
|
|
continue;
|
|
}
|
|
|
|
// A pipe table: a header row, a `| --- |` separator, then the body.
|
|
if trimmed.contains('|')
|
|
&& lines.peek().is_some_and(|next| is_separator_row(next))
|
|
{
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
let header = split_row(trimmed);
|
|
lines.next();
|
|
let mut rows = Vec::new();
|
|
while let Some(row) = lines.peek() {
|
|
if !row.contains('|') || row.trim().is_empty() {
|
|
break;
|
|
}
|
|
rows.push(split_row(row.trim()));
|
|
lines.next();
|
|
}
|
|
blocks.push(Block::Table { header, rows });
|
|
continue;
|
|
}
|
|
|
|
if let Some((indent, marker, rest)) = list_item_of(line) {
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
blocks.push(Block::List {
|
|
indent,
|
|
marker,
|
|
spans: parse_inline(rest),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
flush(&mut quote, &mut blocks, true);
|
|
paragraph.push(trimmed.to_owned());
|
|
}
|
|
|
|
flush(&mut paragraph, &mut blocks, false);
|
|
flush(&mut quote, &mut blocks, true);
|
|
blocks
|
|
}
|
|
|
|
fn fence_of(line: &str) -> Option<&'static str> {
|
|
if line.starts_with("```") {
|
|
Some("```")
|
|
} else if line.starts_with("~~~") {
|
|
Some("~~~")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn is_rule(line: &str) -> bool {
|
|
let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect();
|
|
compact.len() >= 3
|
|
&& (compact.chars().all(|c| c == '-')
|
|
|| compact.chars().all(|c| c == '*')
|
|
|| compact.chars().all(|c| c == '_'))
|
|
}
|
|
|
|
/// `| --- | :--: |`, the line that turns the row above it into a table header.
|
|
fn is_separator_row(line: &str) -> bool {
|
|
let line = line.trim();
|
|
if !line.contains('|') {
|
|
return false;
|
|
}
|
|
let cells = split_cells(line);
|
|
!cells.is_empty()
|
|
&& cells.iter().all(|cell| {
|
|
let body = cell.trim().trim_start_matches(':').trim_end_matches(':');
|
|
!body.is_empty() && body.chars().all(|c| c == '-')
|
|
})
|
|
}
|
|
|
|
fn split_cells(line: &str) -> Vec<&str> {
|
|
line.trim()
|
|
.trim_start_matches('|')
|
|
.trim_end_matches('|')
|
|
.split('|')
|
|
.map(str::trim)
|
|
.collect()
|
|
}
|
|
|
|
fn split_row(line: &str) -> Vec<Cell> {
|
|
split_cells(line).into_iter().map(parse_inline).collect()
|
|
}
|
|
|
|
fn heading_of(line: &str) -> Option<(u8, &str)> {
|
|
let hashes = line.chars().take_while(|c| *c == '#').count();
|
|
if (1..=6).contains(&hashes) {
|
|
let rest = &line[hashes..];
|
|
// ATX headings need a space after the hashes.
|
|
rest.strip_prefix(' ').map(|rest| (hashes as u8, rest.trim()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn list_item_of(line: &str) -> Option<(usize, Marker, &str)> {
|
|
let indent_chars = line.len() - line.trim_start().len();
|
|
let indent = indent_chars / 2;
|
|
let trimmed = line.trim_start();
|
|
|
|
let (marker, rest) = if let Some(rest) = trimmed
|
|
.strip_prefix("- ")
|
|
.or_else(|| trimmed.strip_prefix("* "))
|
|
.or_else(|| trimmed.strip_prefix("+ "))
|
|
{
|
|
let rest_trimmed = rest.trim_start();
|
|
let lowered = rest_trimmed.to_ascii_lowercase();
|
|
if let Some(task) = lowered.strip_prefix("[ ] ") {
|
|
(Marker::Task(false), &rest_trimmed[rest_trimmed.len() - task.len()..])
|
|
} else if let Some(task) = lowered
|
|
.strip_prefix("[x] ")
|
|
.or_else(|| lowered.strip_prefix("[/] "))
|
|
{
|
|
(Marker::Task(true), &rest_trimmed[rest_trimmed.len() - task.len()..])
|
|
} else {
|
|
(Marker::Bullet, rest)
|
|
}
|
|
} else {
|
|
let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
|
|
if digits.is_empty() {
|
|
return None;
|
|
}
|
|
let rest = &trimmed[digits.len()..];
|
|
let rest = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") "))?;
|
|
(Marker::Number(digits.parse().ok()?), rest)
|
|
};
|
|
|
|
Some((indent, marker, rest.trim_start()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inline parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parses emphasis, code, links and images inside one block of text.
|
|
pub fn parse_inline(text: &str) -> Vec<Span> {
|
|
let mut spans = Vec::new();
|
|
parse_inline_into(text, Style::default(), &mut spans);
|
|
spans.retain(|s| !s.text.is_empty());
|
|
spans
|
|
}
|
|
|
|
fn parse_inline_into(text: &str, style: Style, out: &mut Vec<Span>) {
|
|
let mut plain = String::new();
|
|
let mut rest = text;
|
|
|
|
macro_rules! flush {
|
|
() => {
|
|
if !plain.is_empty() {
|
|
out.push(Span {
|
|
text: std::mem::take(&mut plain),
|
|
style,
|
|
link: None,
|
|
image: false,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
while !rest.is_empty() {
|
|
// Backslash escapes.
|
|
if let Some(tail) = rest.strip_prefix('\\') {
|
|
let mut chars = tail.chars();
|
|
if let Some(escaped) = chars.next() {
|
|
plain.push(escaped);
|
|
rest = chars.as_str();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Inline code takes precedence and is never parsed further.
|
|
if let Some((inner, tail)) = delimited(rest, "`") {
|
|
flush!();
|
|
out.push(Span {
|
|
text: inner.to_owned(),
|
|
style: Style { code: true, ..style },
|
|
link: None,
|
|
image: false,
|
|
});
|
|
rest = tail;
|
|
continue;
|
|
}
|
|
|
|
if let Some((inner, tail)) = delimited(rest, "**").or_else(|| delimited(rest, "__")) {
|
|
flush!();
|
|
parse_inline_into(inner, Style { bold: true, ..style }, out);
|
|
rest = tail;
|
|
continue;
|
|
}
|
|
|
|
if let Some((inner, tail)) = delimited(rest, "~~") {
|
|
flush!();
|
|
parse_inline_into(inner, Style { strike: true, ..style }, out);
|
|
rest = tail;
|
|
continue;
|
|
}
|
|
|
|
if let Some((inner, tail)) = delimited(rest, "*").or_else(|| delimited(rest, "_")) {
|
|
flush!();
|
|
parse_inline_into(inner, Style { italic: true, ..style }, out);
|
|
rest = tail;
|
|
continue;
|
|
}
|
|
|
|
if let Some((label, target, tail, image)) = link_at(rest) {
|
|
flush!();
|
|
let mut link_spans = Vec::new();
|
|
parse_inline_into(label, style, &mut link_spans);
|
|
if link_spans.is_empty() {
|
|
link_spans.push(Span {
|
|
text: target.clone(),
|
|
style,
|
|
link: None,
|
|
image: false,
|
|
});
|
|
}
|
|
for mut span in link_spans {
|
|
span.link = Some(target.clone());
|
|
span.image = image;
|
|
out.push(span);
|
|
}
|
|
rest = tail;
|
|
continue;
|
|
}
|
|
|
|
if let Some((url, tail)) = autolink_at(rest) {
|
|
flush!();
|
|
out.push(Span {
|
|
text: url.clone(),
|
|
style,
|
|
link: Some(url),
|
|
image: false,
|
|
});
|
|
rest = tail;
|
|
continue;
|
|
}
|
|
|
|
let mut chars = rest.chars();
|
|
if let Some(c) = chars.next() {
|
|
plain.push(c);
|
|
}
|
|
rest = chars.as_str();
|
|
}
|
|
|
|
flush!();
|
|
}
|
|
|
|
/// If `text` opens with `delim`, returns the content up to the matching `delim`
|
|
/// and the remainder after it.
|
|
fn delimited<'a>(text: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
|
|
let after_open = text.strip_prefix(delim)?;
|
|
// `**bold**` must not be mistaken for the start of `*italic*`.
|
|
if delim.len() == 1 && after_open.starts_with(delim) {
|
|
return None;
|
|
}
|
|
let end = after_open.find(delim)?;
|
|
let inner = &after_open[..end];
|
|
if inner.is_empty() || inner.starts_with(char::is_whitespace) {
|
|
return None;
|
|
}
|
|
Some((inner, &after_open[end + delim.len()..]))
|
|
}
|
|
|
|
/// Parses `[label](target)` or `` at the start of `text`.
|
|
fn link_at(text: &str) -> Option<(&str, String, &str, bool)> {
|
|
let (image, body) = match text.strip_prefix('!') {
|
|
Some(body) if body.starts_with('[') => (true, body),
|
|
_ => (false, text),
|
|
};
|
|
let after_open = body.strip_prefix('[')?;
|
|
let close = after_open.find("](")?;
|
|
let label = &after_open[..close];
|
|
let after_label = &after_open[close + 2..];
|
|
let end = after_label.find(')')?;
|
|
let target = after_label[..end].trim().to_owned();
|
|
if target.is_empty() {
|
|
return None;
|
|
}
|
|
Some((label, target, &after_label[end + 1..], image))
|
|
}
|
|
|
|
/// Parses a bare `http(s)://…` URL at the start of `text`.
|
|
fn autolink_at(text: &str) -> Option<(String, &str)> {
|
|
if !(text.starts_with("http://") || text.starts_with("https://")) {
|
|
return None;
|
|
}
|
|
let end = text
|
|
.find(|c: char| c.is_whitespace() || matches!(c, ')' | ']' | '>' | '"'))
|
|
.unwrap_or(text.len());
|
|
let url = text[..end].trim_end_matches(['.', ',', ';', ':', '!']);
|
|
Some((url.to_owned(), &text[url.len()..]))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const HEADING_SCALE: [f32; 6] = [1.7, 1.45, 1.25, 1.12, 1.0, 0.95];
|
|
|
|
/// Draws the blocks and returns the target of a link the user clicked.
|
|
pub fn render(ui: &mut Ui, blocks: &[Block], palette: &Palette, base_size: f32) -> Option<String> {
|
|
let mut activated = None;
|
|
let mut table_index = 0usize;
|
|
ui.spacing_mut().item_spacing.y = base_size * 0.35;
|
|
|
|
for block in blocks {
|
|
match block {
|
|
Block::Heading { level, spans } => {
|
|
let size = base_size * HEADING_SCALE[(*level as usize - 1).min(5)];
|
|
if let Some(link) = spans_ui(ui, spans, palette, size, true) {
|
|
activated = Some(link);
|
|
}
|
|
}
|
|
Block::Paragraph(spans) => {
|
|
if let Some(link) = spans_ui(ui, spans, palette, base_size, false) {
|
|
activated = Some(link);
|
|
}
|
|
}
|
|
Block::List {
|
|
indent,
|
|
marker,
|
|
spans,
|
|
} => {
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
ui.add_space(base_size * (0.4 + 1.2 * *indent as f32));
|
|
let bullet = match marker {
|
|
Marker::Bullet => "• ".to_owned(),
|
|
Marker::Number(n) => format!("{n}. "),
|
|
Marker::Task(true) => "☑ ".to_owned(),
|
|
Marker::Task(false) => "☐ ".to_owned(),
|
|
};
|
|
ui.label(
|
|
RichText::new(bullet)
|
|
.size(base_size)
|
|
.color(palette.text_muted),
|
|
);
|
|
if let Some(link) = spans_inline(ui, spans, palette, base_size, false) {
|
|
activated = Some(link);
|
|
}
|
|
});
|
|
}
|
|
Block::Quote(spans) => {
|
|
let stripe = palette.text_muted.gamma_multiply(0.6);
|
|
egui::Frame::NONE
|
|
.inner_margin(egui::Margin {
|
|
left: (base_size * 0.7) as i8,
|
|
..Default::default()
|
|
})
|
|
.show(ui, |ui| {
|
|
let top = ui.cursor().min;
|
|
let result = spans_ui(ui, spans, palette, base_size, false);
|
|
let bottom = ui.min_rect().max.y;
|
|
let x = top.x - base_size * 0.45;
|
|
ui.painter().line_segment(
|
|
[egui::pos2(x, top.y), egui::pos2(x, bottom)],
|
|
egui::Stroke::new(2.0, stripe),
|
|
);
|
|
if let Some(link) = result {
|
|
activated = Some(link);
|
|
}
|
|
});
|
|
}
|
|
Block::Code { text, .. } => {
|
|
egui::Frame::NONE
|
|
.fill(palette.surface_muted)
|
|
.corner_radius(4.0)
|
|
.inner_margin(egui::Margin::symmetric(
|
|
(base_size * 0.5) as i8,
|
|
(base_size * 0.35) as i8,
|
|
))
|
|
.show(ui, |ui| {
|
|
ui.label(
|
|
RichText::new(text)
|
|
.monospace()
|
|
.size(base_size * 0.92)
|
|
.color(palette.text),
|
|
);
|
|
});
|
|
}
|
|
Block::Table { header, rows } => {
|
|
table_index += 1;
|
|
egui::Grid::new(("markdown-table", ui.id(), table_index))
|
|
.striped(true)
|
|
.spacing([base_size * 0.8, base_size * 0.25])
|
|
.show(ui, |ui| {
|
|
for cell in header {
|
|
ui.horizontal(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
spans_inline(ui, cell, palette, base_size, true);
|
|
});
|
|
}
|
|
ui.end_row();
|
|
for row in rows {
|
|
for cell in row {
|
|
ui.horizontal(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
if let Some(link) =
|
|
spans_inline(ui, cell, palette, base_size, false)
|
|
{
|
|
activated = Some(link);
|
|
}
|
|
});
|
|
}
|
|
ui.end_row();
|
|
}
|
|
});
|
|
}
|
|
Block::Rule => {
|
|
let stroke = egui::Stroke::new(1.0, palette.border);
|
|
let width = ui.available_width();
|
|
let (rect, _) = ui.allocate_exact_size(
|
|
egui::vec2(width, base_size * 0.6),
|
|
egui::Sense::hover(),
|
|
);
|
|
ui.painter().line_segment(
|
|
[
|
|
egui::pos2(rect.left(), rect.center().y),
|
|
egui::pos2(rect.right(), rect.center().y),
|
|
],
|
|
stroke,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
activated
|
|
}
|
|
|
|
fn spans_ui(
|
|
ui: &mut Ui,
|
|
spans: &[Span],
|
|
palette: &Palette,
|
|
size: f32,
|
|
heading: bool,
|
|
) -> Option<String> {
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
spans_inline(ui, spans, palette, size, heading)
|
|
})
|
|
.inner
|
|
}
|
|
|
|
fn spans_inline(
|
|
ui: &mut Ui,
|
|
spans: &[Span],
|
|
palette: &Palette,
|
|
size: f32,
|
|
heading: bool,
|
|
) -> Option<String> {
|
|
let mut activated = None;
|
|
for span in spans {
|
|
let mut text = RichText::new(&span.text).size(size);
|
|
if span.style.bold || heading {
|
|
text = text.strong();
|
|
}
|
|
if span.style.italic {
|
|
text = text.italics();
|
|
}
|
|
if span.style.strike {
|
|
text = text.strikethrough();
|
|
}
|
|
if span.style.code {
|
|
text = text
|
|
.monospace()
|
|
.size(size * 0.92)
|
|
.background_color(palette.surface_muted);
|
|
}
|
|
|
|
match &span.link {
|
|
Some(target) => {
|
|
let label = if span.image {
|
|
RichText::new(format!("[image] {}", span.text)).size(size)
|
|
} else {
|
|
text
|
|
}
|
|
.color(link_color(palette));
|
|
if ui.link(label).on_hover_text(target).clicked() {
|
|
activated = Some(target.clone());
|
|
}
|
|
}
|
|
None => {
|
|
ui.label(text.color(palette.text));
|
|
}
|
|
}
|
|
}
|
|
activated
|
|
}
|
|
|
|
fn link_color(palette: &Palette) -> Color32 {
|
|
palette.accent
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_headings_and_paragraphs() {
|
|
let blocks = parse("# Title\n\nSome text\nwrapped over lines.\n");
|
|
assert_eq!(blocks.len(), 2);
|
|
assert!(matches!(blocks[0], Block::Heading { level: 1, .. }));
|
|
let Block::Paragraph(spans) = &blocks[1] else {
|
|
panic!("expected a paragraph");
|
|
};
|
|
assert_eq!(spans[0].text, "Some text wrapped over lines.");
|
|
}
|
|
|
|
#[test]
|
|
fn hash_without_a_space_is_not_a_heading() {
|
|
assert!(matches!(parse("#tag")[0], Block::Paragraph(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_emphasis() {
|
|
let spans = parse_inline("a **bold** and *italic* and `code` and ~~gone~~");
|
|
let styled: Vec<_> = spans
|
|
.iter()
|
|
.map(|s| (s.text.as_str(), s.style))
|
|
.filter(|(_, style)| *style != Style::default())
|
|
.collect();
|
|
assert_eq!(styled[0].0, "bold");
|
|
assert!(styled[0].1.bold);
|
|
assert!(styled[1].1.italic);
|
|
assert!(styled[2].1.code);
|
|
assert!(styled[3].1.strike);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_links_images_and_autolinks() {
|
|
let spans = parse_inline("see [the spec](https://jsoncanvas.org) now");
|
|
let link = spans.iter().find(|s| s.link.is_some()).unwrap();
|
|
assert_eq!(link.text, "the spec");
|
|
assert_eq!(link.link.as_deref(), Some("https://jsoncanvas.org"));
|
|
|
|
let spans = parse_inline("");
|
|
assert!(spans[0].image);
|
|
assert_eq!(spans[0].link.as_deref(), Some("img/pic.png"));
|
|
|
|
let spans = parse_inline("go to https://example.com.");
|
|
let link = spans.iter().find(|s| s.link.is_some()).unwrap();
|
|
assert_eq!(link.link.as_deref(), Some("https://example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_lists_including_tasks() {
|
|
let blocks = parse("- one\n- [ ] todo\n- [x] done\n1. first\n - nested");
|
|
let markers: Vec<Marker> = blocks
|
|
.iter()
|
|
.filter_map(|b| match b {
|
|
Block::List { marker, .. } => Some(*marker),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
markers,
|
|
vec![
|
|
Marker::Bullet,
|
|
Marker::Task(false),
|
|
Marker::Task(true),
|
|
Marker::Number(1),
|
|
Marker::Bullet
|
|
]
|
|
);
|
|
let Block::List { indent, spans, .. } = &blocks[4] else {
|
|
panic!("expected a list item");
|
|
};
|
|
assert_eq!(*indent, 1);
|
|
assert_eq!(spans[0].text, "nested");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_fenced_code_and_rules() {
|
|
let blocks = parse("---\n```rust\nlet x = 1;\n```\n");
|
|
assert!(matches!(blocks[0], Block::Rule));
|
|
let Block::Code { language, text } = &blocks[1] else {
|
|
panic!("expected code");
|
|
};
|
|
assert_eq!(language, "rust");
|
|
assert_eq!(text, "let x = 1;");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_pipe_tables() {
|
|
let blocks = parse("| Key | What it does |\n| --- | :---: |\n| `a` | first |\n| b | second |\n\nafter");
|
|
let Block::Table { header, rows } = &blocks[0] else {
|
|
panic!("expected a table, got {blocks:?}");
|
|
};
|
|
assert_eq!(header.len(), 2);
|
|
assert_eq!(header[1][0].text, "What it does");
|
|
assert_eq!(rows.len(), 2);
|
|
assert!(rows[0][0][0].style.code);
|
|
assert_eq!(rows[1][1][0].text, "second");
|
|
// The paragraph after the table is still its own block.
|
|
assert!(matches!(blocks[1], Block::Paragraph(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_lone_pipe_is_not_a_table() {
|
|
assert!(matches!(parse("a | b")[0], Block::Paragraph(_)));
|
|
assert!(!is_separator_row("| not a separator |"));
|
|
assert!(is_separator_row("|---|:--:|"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_quotes() {
|
|
let blocks = parse("> quoted\n> again");
|
|
let Block::Quote(spans) = &blocks[0] else {
|
|
panic!("expected a quote");
|
|
};
|
|
assert_eq!(spans[0].text, "quoted again");
|
|
}
|
|
|
|
#[test]
|
|
fn escapes_are_honoured() {
|
|
let spans = parse_inline("literal \\*stars\\*");
|
|
assert_eq!(spans.len(), 1);
|
|
assert_eq!(spans[0].text, "literal *stars*");
|
|
}
|
|
}
|