Version 1
This commit is contained in:
+750
@@ -0,0 +1,750 @@
|
||||
//! 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*");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user