use crate::config::TableStyle;
use crate::theme;
use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
#[derive(Clone, Debug, PartialEq)]
pub struct PCell {
pub ch: char,
pub style: Style,
pub link: Option<usize>,
pub src: Option<(usize, usize)>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ImageSpec {
pub alt: String,
pub url: String,
pub width: Option<u32>,
}
#[derive(Clone, Debug, Default)]
pub struct PLine {
pub cells: Vec<PCell>,
pub checkbox: Option<usize>,
pub image: Option<usize>,
pub src_line: Option<usize>,
pub wide: bool,
pub hang: usize,
}
pub fn wrap_pline(line: &PLine, width: usize) -> Vec<Vec<PCell>> {
let width = width.max(1);
if line.hang == 0 {
return wrap_pcells(&line.cells, width);
}
let rest = width.saturating_sub(line.hang).max(4);
wrap_hang(&line.cells, width, rest)
.into_iter()
.enumerate()
.map(|(i, row)| {
if i == 0 {
row
} else {
let mut cells = str_cells(&" ".repeat(line.hang), theme::PLAIN);
cells.extend(row);
cells
}
})
.collect()
}
pub fn to_line(cells: &[PCell]) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut text = String::new();
let mut current: Option<Style> = None;
for cell in cells {
if current != Some(cell.style) {
if let Some(s) = current {
spans.push(Span::styled(std::mem::take(&mut text), s));
}
current = Some(cell.style);
}
text.push(cell.ch);
}
if let Some(s) = current {
spans.push(Span::styled(text, s));
}
Line::from(spans)
}
impl PLine {
#[cfg(test)]
pub fn text(&self) -> String {
self.cells.iter().map(|c| c.ch).collect()
}
}
#[derive(Clone, Debug, Default)]
pub struct Rendered {
pub lines: Vec<PLine>,
pub urls: Vec<String>,
pub images: Vec<ImageSpec>,
}
impl Rendered {
#[cfg(test)]
pub fn url(&self, i: usize) -> Option<&str> {
self.urls.get(i).map(String::as_str)
}
}
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_TABLES
| Options::ENABLE_MATH
| Options::ENABLE_FOOTNOTES
}
#[cfg(test)]
pub fn render(markdown: &str) -> Rendered {
render_wide(markdown, usize::MAX)
}
#[cfg(test)]
pub fn render_wide(markdown: &str, width: usize) -> Rendered {
render_page(markdown, width, TableStyle::default())
}
#[cfg(test)]
pub fn render_page(markdown: &str, width: usize, tables: TableStyle) -> Rendered {
render_page_at(markdown, 0, width, tables)
}
pub fn render_page_at(
markdown: &str,
first_line: usize,
width: usize,
tables: TableStyle,
) -> Rendered {
let markdown = &blank_block_ids(markdown);
let mut r = Ren::new(markdown, first_line, width, tables);
let (stripped, cuts) = crate::md::strip_comments(markdown);
let (rewritten, notes) = inline_footnotes(&stripped);
r.src = rewritten.clone();
r.cuts = cuts;
r.tail_start = rewritten.len() - notes.iter().map(|n| n.tail_len).sum::<usize>();
r.inline_notes = notes;
r.run(&rewritten);
r.finish()
}
pub fn blank_block_ids(markdown: &str) -> String {
let mut out = String::with_capacity(markdown.len());
let mut fenced = false;
for raw in markdown.split_inclusive('\n') {
let line = raw.trim_end_matches('\n').trim_end_matches('\r');
if crate::md::is_fence(line) {
fenced = !fenced;
}
match (fenced, crate::md::block_id_at(line)) {
(false, Some((col, _))) => {
let byte = line.char_indices().nth(col).map_or(line.len(), |(b, _)| b);
out.push_str(&line[..byte]);
out.extend(std::iter::repeat_n(' ', line.len() - byte));
out.push_str(&raw[line.len()..]);
}
_ => out.push_str(raw),
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InlineNote {
label: String,
ordinal: usize,
orig: usize,
def: usize,
len: usize,
tail_len: usize,
}
fn inline_footnotes(markdown: &str) -> (String, Vec<InlineNote>) {
let mut out = String::with_capacity(markdown.len());
let mut notes: Vec<InlineNote> = Vec::new();
let mut bodies: Vec<&str> = Vec::new();
let lines: Vec<&str> = markdown
.split_inclusive('\n')
.map(|raw| raw.trim_end_matches('\n').trim_end_matches('\r'))
.collect();
let counts = crate::md::footnote_counts(&lines);
for (row, raw) in markdown.split_inclusive('\n').enumerate() {
let line = lines[row];
let mut ordinal = counts[row];
let refs = if counts[row + 1] == ordinal || !line.contains("^[") {
Vec::new()
} else {
crate::md::footnote_refs(line)
};
if refs.is_empty() {
out.push_str(raw);
continue;
}
let bytes: Vec<usize> = line
.char_indices()
.map(|(b, _)| b)
.chain(std::iter::once(line.len()))
.collect();
let mut at = 0;
for r in refs {
if !r.inline {
ordinal += 1;
continue;
}
let (start, end) = (bytes[r.start], bytes[r.end]);
let (b0, b1) = (bytes[r.body.0], bytes[r.body.1]);
out.push_str(&line[at..start]);
let orig = out.len() + 2;
let mut label = format!("~{ordinal}");
while label.len() < b1 - b0 {
label.push('~');
}
out.push_str("[^");
out.push_str(&label);
out.push(']');
notes.push(InlineNote {
label,
ordinal,
orig,
def: 0,
len: b1 - b0,
tail_len: 0,
});
bodies.push(&line[b0..b1]);
ordinal += 1;
at = end;
}
out.push_str(&raw[at..]);
}
if notes.is_empty() {
return (out, notes);
}
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
let body_end = out.len();
let mut tail_len = out.len();
for (n, body) in notes.iter_mut().zip(bodies) {
out.push_str("\n[^");
out.push_str(&n.label);
out.push_str("]: ");
n.def = out.len();
out.push_str(body);
out.push('\n');
n.tail_len = out.len() - tail_len;
tail_len = out.len();
}
debug_assert_eq!(
body_end + notes.iter().map(|n| n.tail_len).sum::<usize>(),
out.len()
);
(out, notes)
}
pub fn append_mentions(r: &mut Rendered, mentions: &[crate::mentions::Mention], width: usize) {
if mentions.is_empty() {
return;
}
let dim = theme::marker();
r.lines.push(PLine::default());
r.lines.push(PLine {
cells: str_cells(
&"─".repeat(if width == usize::MAX { 40 } else { width }),
dim,
),
..Default::default()
});
let namew = mentions
.iter()
.map(|m| crate::md::str_width(&m.name))
.max()
.unwrap_or(0)
.min(MAX_NAME_COLS)
.min(width.saturating_sub(2));
let linked: Vec<&crate::mentions::Mention> = mentions.iter().filter(|m| m.linked).collect();
let unlinked: Vec<&crate::mentions::Mention> = mentions.iter().filter(|m| !m.linked).collect();
if !linked.is_empty() {
let count = match linked.len() {
1 => "1 note links here".to_string(),
n => format!("{n} notes link here"),
};
r.lines.push(PLine {
cells: str_cells(&count, dim),
..Default::default()
});
for m in &linked {
append_mention_row(r, m, namew, width);
}
}
if !unlinked.is_empty() {
if !linked.is_empty() {
r.lines.push(PLine::default());
}
let count = match unlinked.len() {
1 => "mentioned in 1 note".to_string(),
n => format!("mentioned in {n} notes"),
};
r.lines.push(PLine {
cells: str_cells(&count, dim),
..Default::default()
});
let shown = unlinked.len().min(crate::mentions::MAX_UNLINKED_ROWS);
for m in &unlinked[..shown] {
append_mention_row(r, m, namew, width);
}
if unlinked.len() > shown {
r.lines.push(PLine {
cells: str_cells(&format!(" {} more", unlinked.len() - shown), dim),
..Default::default()
});
}
}
}
fn append_mention_row(r: &mut Rendered, m: &crate::mentions::Mention, namew: usize, width: usize) {
let dim = theme::marker();
{
let idx = r.urls.len();
r.urls
.push(crate::md::LinkTarget::Note(m.path.to_string_lossy().into_owned()).href());
let mut cells = str_cells(" ", dim);
let mut name = truncate_cells(&str_cells(&m.name, theme::link()), namew);
for c in &mut name {
c.link = Some(idx);
}
let pad = namew.saturating_sub(cells_width(&name));
cells.extend(name);
cells.extend(str_cells(&" ".repeat(pad), dim));
let tail = if m.count > 1 {
format!(" ×{}", m.count)
} else {
String::new()
};
let room = width
.saturating_sub(cells_width(&cells) + 2 + crate::md::str_width(&tail))
.min(MAX_EXCERPT_COLS);
if room >= 12 && !m.excerpt.is_empty() {
cells.extend(str_cells(" ", dim));
cells.extend(excerpt_cells(&m.excerpt, m.link, dim, room, !m.linked));
}
cells.extend(str_cells(&tail, dim));
r.lines.push(PLine {
cells: truncate_cells(&cells, width),
..Default::default()
});
}
}
pub const PROPERTIES_HREF: &str = "catcher:properties";
const MAX_EXCERPT_COLS: usize = 80;
const MAX_NAME_COLS: usize = 28;
pub fn prepend_properties(
r: &mut Rendered,
content: &str,
width: usize,
today: (i32, u32, u32),
mode: crate::config::Properties,
) {
use crate::config::Properties;
let props = crate::md::front_matter_properties(content);
if props.is_empty() || mode == Properties::Hide {
return;
}
let border = theme::border();
let width = if width == usize::MAX {
40
} else {
width.max(8)
};
let toggle = r.urls.len();
r.urls.push(PROPERTIES_HREF.to_string());
let linked = |mut cells: Vec<PCell>| {
for c in &mut cells {
c.link = Some(toggle);
}
cells
};
if mode == Properties::Line {
let n = props.len();
let label = if n == 1 {
"1 property".to_string()
} else {
format!("{n} properties")
};
let mut cells = linked(str_cells(theme::FOLDED, theme::fold()));
cells.extend(linked(str_cells(&label, theme::marker())));
let mut lines = vec![
PLine {
cells,
..Default::default()
},
PLine::default(),
];
lines.append(&mut r.lines);
r.lines = lines;
return;
}
let inner = width - 4;
let keyw = props
.iter()
.map(|p| crate::md::str_width(&p.key))
.max()
.unwrap_or(0)
.min(inner / 2);
let mut lines: Vec<PLine> = Vec::with_capacity(props.len() + 3);
let mut top = str_cells("┌ ", border);
top.extend(str_cells("properties", theme::grey()));
top.push(cell(' ', border));
let tail = str_cells(" hide ┐", border);
let fill = width.saturating_sub(cells_width(&top) + cells_width(&tail));
top.extend(str_cells(&"─".repeat(fill), border));
top.extend(tail);
lines.push(PLine {
cells: linked(top),
..Default::default()
});
for p in &props {
let mut cells = str_cells("│ ", border);
let key = truncate_cells(&str_cells(&p.key, theme::grey()), keyw);
let pad = keyw - cells_width(&key);
cells.extend(key);
cells.extend(str_cells(&" ".repeat(pad + 2), theme::PLAIN));
let room = inner.saturating_sub(keyw + 2);
let value = property_cells(r, p, today);
cells.extend(truncate_cells(&value, room));
let rest = width.saturating_sub(cells_width(&cells) + 1);
cells.extend(str_cells(&" ".repeat(rest), theme::PLAIN));
cells.push(cell('│', border));
lines.push(PLine {
cells,
src_line: Some(p.line),
..Default::default()
});
}
let mut bottom = str_cells("└", border);
bottom.extend(str_cells(&"─".repeat(width - 2), border));
bottom.push(cell('┘', border));
lines.push(PLine {
cells: bottom,
..Default::default()
});
lines.push(PLine::default());
lines.append(&mut r.lines);
r.lines = lines;
}
fn property_cells(r: &mut Rendered, p: &crate::md::Property, today: (i32, u32, u32)) -> Vec<PCell> {
let mut cells = Vec::new();
if p.key == "tags" {
let tags: Vec<String> = p
.values
.iter()
.flat_map(|v| v.split(|c: char| c == ',' || c.is_whitespace()))
.map(|t| crate::md::tag_key(t.trim_matches(|c| c == '"' || c == '\'')))
.filter(|t| !t.is_empty())
.collect();
for (i, tag) in tags.iter().enumerate() {
if i > 0 {
cells.push(cell(' ', theme::PLAIN));
}
let idx = r.urls.len();
r.urls.push(crate::md::LinkTarget::Tag(tag.clone()).href());
let mut run = str_cells(&format!("#{tag}"), theme::tag());
for c in &mut run {
c.link = Some(idx);
}
cells.extend(run);
}
return cells;
}
for (i, v) in p.values.iter().enumerate() {
if i > 0 {
cells.extend(str_cells(" · ", theme::grey()));
}
cells.extend(str_cells(v, theme::PLAIN));
if let Some(date) = crate::dates::parse_iso(v) {
let hint = crate::dates::relative(date, today);
cells.extend(str_cells(&format!(" {hint}"), theme::marker()));
}
}
cells
}
fn cell(ch: char, style: Style) -> PCell {
PCell {
ch,
style,
link: None,
src: None,
}
}
pub fn apply_folds(
r: &mut Rendered,
visible: &crate::fold::Visible,
folded: &[usize],
width: usize,
) {
if visible.is_plain() {
return;
}
let lines = std::mem::take(&mut r.lines);
let mut out: Vec<PLine> = Vec::with_capacity(lines.len());
for mut line in lines {
match line.src_line {
Some(l) if visible.is_hidden(l) => continue,
Some(l) if folded.contains(&l) && !line.cells.is_empty() => {
let first = out.last().is_none_or(|p| p.src_line != Some(l));
let edge = line.cells.iter().any(|c| c.ch == '╰');
if first && line.cells.iter().any(|c| c.ch == '╭') {
mark_folded_card(&mut line, l, visible.hidden_under(l));
} else if first && !edge {
mark_folded(&mut line, l, visible.hidden_under(l), width);
}
}
None if !line.cells.is_empty()
&& out
.last()
.is_some_and(|p| p.src_line.is_some_and(|l| folded.contains(&l))) =>
{
continue
}
None if line.cells.is_empty() && out.last().is_some_and(|p| p.cells.is_empty()) => {
continue
}
_ => {}
}
out.push(line);
}
while out.last().is_some_and(|l| l.cells.is_empty()) {
out.pop();
}
r.lines = out;
}
fn mark_folded_card(line: &mut PLine, src: usize, hidden: usize) {
let Some(open) = line.cells.iter().position(|c| c.ch == '╭') else {
return;
};
let Some(close) = line.cells.iter().rposition(|c| c.ch == '╮') else {
return;
};
let at = open + 3;
let marker = |ch: char| PCell {
ch,
style: theme::fold(),
link: None,
src: Some((src, 0)),
};
let mut close = close;
if line.cells.get(at).is_some_and(|c| c.ch == '▾') {
line.cells[at] = marker('▸');
} else if close > at + 2 && line.cells[close - 2..close].iter().all(|c| c.ch == '─') {
line.cells.splice(close - 2..close, []);
line.cells.splice(at..at, [marker('▸'), marker(' ')]);
} else {
return;
}
let label = crate::md::fold_count(hidden);
let dashes = line.cells[..close]
.iter()
.rev()
.take_while(|c| c.ch == '─')
.count();
let need = crate::md::str_width(&label) + 3;
if dashes <= need {
return;
}
let style = line.cells[close].style;
let mut tail = str_cells(" ", style);
tail.extend(str_cells(&label, theme::marker()));
tail.extend(str_cells(" ─", style));
close = line
.cells
.iter()
.rposition(|c| c.ch == '╮')
.unwrap_or(close);
line.cells.splice(close - need..close, tail);
}
fn mark_folded(line: &mut PLine, src: usize, hidden: usize, width: usize) {
let marker: Vec<PCell> = theme::FOLDED
.chars()
.map(|ch| PCell {
ch,
style: theme::fold(),
link: None,
src: Some((src, 0)),
})
.collect();
line.cells.splice(0..0, marker);
let label = match hidden {
1 => "1 line folded".to_string(),
n => format!("{n} lines folded"),
};
let used = cells_width(&line.cells);
let need = crate::md::str_width(&label) + 2;
if width == usize::MAX || used + need > width {
return;
}
line.cells.extend(str_cells(
&" ".repeat(width - used - need + 2),
theme::PLAIN,
));
line.cells.extend(str_cells(&label, theme::marker()));
}
fn excerpt_cells(
excerpt: &str,
link: (usize, usize),
base: Style,
room: usize,
plain_span: bool,
) -> Vec<PCell> {
let cells: Vec<PCell> = crate::md::style_inline(excerpt)
.into_iter()
.map(|c| PCell {
ch: c.ch,
style: if plain_span && c.src >= link.0 && c.src < link.1 {
c.style
} else {
base.patch(c.style)
},
link: None,
src: Some((0, c.src)),
})
.collect();
if cells_width(&cells) <= room {
return strip_src(cells);
}
let first = cells
.iter()
.position(|c| c.src.is_some_and(|s| s.1 >= link.0));
let last = cells
.iter()
.rposition(|c| c.src.is_some_and(|s| s.1 < link.1));
let (Some(first), Some(last)) = (first, last) else {
return strip_src(truncate_cells(&cells, room));
};
let before = cells_width(&cells[..first]);
let linkw = cells_width(&cells[first..=last]);
if before + linkw < room {
return strip_src(truncate_cells(&cells, room));
}
let lead = room.saturating_sub(linkw + 2) / 3;
let mut skip = first;
let mut skipped = 0;
while skip > 0 && skipped + crate::md::char_width(cells[skip - 1].ch) <= lead {
skip -= 1;
skipped += crate::md::char_width(cells[skip].ch);
}
let mut out = str_cells("…", base);
out.extend(truncate_cells(&cells[skip..], room.saturating_sub(1)));
strip_src(out)
}
fn strip_src(mut cells: Vec<PCell>) -> Vec<PCell> {
for c in &mut cells {
c.src = None;
}
cells
}
enum Sink {
Page,
Table,
}
#[derive(Default)]
struct Table {
aligns: Vec<Alignment>,
rows: Vec<Vec<Vec<PCell>>>,
in_head: bool,
row: Vec<Vec<PCell>>,
}
#[derive(Clone, Copy, Debug)]
enum Deco {
Rail,
Card {
style: Style,
w: usize,
title: Option<usize>,
},
}
struct Ren {
src: String,
out: Rendered,
cells: Vec<PCell>,
cell_buf: Vec<PCell>,
sink: Sink,
styles: Vec<Style>,
link: Option<usize>,
rails: usize,
boxed: bool,
box_w: usize,
box_style: Style,
quotes: Vec<Deco>,
quote_fresh: bool,
quote_blank: bool,
hang: usize,
list_depth: usize,
list_numbers: Vec<Option<u64>>,
in_code_block: bool,
mermaid: Option<(String, usize)>,
table: Option<Table>,
tables: TableStyle,
width: usize,
orig: String,
cuts: Vec<(usize, usize)>,
line_starts: Vec<usize>,
first_line: usize,
src_line: Option<usize>,
pending_checkbox: Option<usize>,
footnote: Option<String>,
inline_notes: Vec<InlineNote>,
tail_start: usize,
done_item: bool,
item_fresh: bool,
image_alt: Option<(String, String)>,
wiki_until: usize,
html: Vec<String>,
in_comment: bool,
}
impl Ren {
fn new(markdown: &str, first_line: usize, width: usize, tables: TableStyle) -> Ren {
let mut line_starts = vec![0usize];
for (i, b) in markdown.bytes().enumerate() {
if b == b'\n' {
line_starts.push(i + 1);
}
}
Ren {
src: markdown.to_string(),
out: Rendered::default(),
cells: Vec::new(),
cell_buf: Vec::new(),
sink: Sink::Page,
styles: vec![Style::default()],
link: None,
rails: 0,
boxed: false,
box_w: 0,
box_style: Style::new(),
quotes: Vec::new(),
quote_fresh: false,
quote_blank: false,
hang: 0,
list_depth: 0,
list_numbers: Vec::new(),
in_code_block: false,
mermaid: None,
table: None,
tables,
width,
orig: markdown.to_string(),
cuts: Vec::new(),
line_starts,
first_line,
src_line: None,
pending_checkbox: None,
footnote: None,
inline_notes: Vec::new(),
tail_start: usize::MAX,
done_item: false,
item_fresh: false,
image_alt: None,
wiki_until: 0,
html: Vec::new(),
in_comment: false,
}
}
fn style(&self) -> Style {
*self.styles.last().unwrap()
}
fn buf(&mut self) -> &mut Vec<PCell> {
match self.sink {
Sink::Page => &mut self.cells,
Sink::Table => &mut self.cell_buf,
}
}
fn push(&mut self, text: &str, style: Style, link: Option<usize>) {
self.push_at(text, style, link, None);
}
fn push_at(&mut self, text: &str, style: Style, link: Option<usize>, off: Option<usize>) {
let mut off = off;
let mut cells: Vec<PCell> = Vec::with_capacity(text.len());
for ch in text.chars() {
cells.push(PCell {
ch,
style,
link,
src: off.map(|o| self.pos_of(o)),
});
if let Some(o) = off.as_mut() {
*o += ch.len_utf8();
}
}
self.buf().extend(cells);
}
fn orig_offset(&self, offset: usize) -> usize {
offset
+ self
.cuts
.iter()
.take_while(|(at, _)| *at <= offset)
.map(|(_, n)| n)
.sum::<usize>()
}
fn remap(&self, offset: usize) -> usize {
if offset < self.tail_start {
return offset;
}
let note = self
.inline_notes
.iter()
.find(|n| offset <= n.def + n.len)
.or(self.inline_notes.last());
match note {
Some(n) => n.orig + offset.saturating_sub(n.def).min(n.len),
None => offset,
}
}
fn title_marks(&self, title: &str) -> String {
let mut out = String::with_capacity(title.len());
let mut rest = title;
while let Some(i) = rest.find("[^") {
let Some(len) = rest[i + 2..].find(']') else {
break;
};
let label = &rest[i + 2..i + 2 + len];
if label.is_empty() || label.contains(' ') {
out.push_str(&rest[..i + 2]);
rest = &rest[i + 2..];
continue;
}
out.push_str(&rest[..i]);
out.push_str(&self.footnote_mark(label));
rest = &rest[i + 3 + len..];
}
out.push_str(rest);
out
}
fn footnote_mark(&self, label: &str) -> String {
match self.inline_notes.iter().find(|n| n.label == label) {
Some(n) => crate::md::superscript(&n.ordinal.to_string()),
None => crate::md::superscript(label),
}
}
fn line_of(&self, offset: usize) -> usize {
let offset = self.orig_offset(self.remap(offset));
match self.line_starts.binary_search(&offset) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
fn pos_of(&self, offset: usize) -> (usize, usize) {
let offset = self.remap(offset);
let line = self.line_of(offset);
let start = self.line_starts.get(line).copied().unwrap_or(0);
let offset = self.orig_offset(offset).min(self.orig.len());
let col = self
.orig
.get(start..offset)
.map_or(0, |s| s.chars().count());
(self.first_line + line, col)
}
fn flush(&mut self) {
if self.cells.is_empty() {
return;
}
let cells = std::mem::take(&mut self.cells);
let checkbox = self.pending_checkbox.take();
let src_line = self.src_line;
let hang = std::mem::take(&mut self.hang);
self.emit_wrapped(cells, checkbox, None, src_line, hang);
}
fn inner_width(&self) -> usize {
let taken = if self.quotes.is_empty() {
self.rails * 2 + if self.boxed { 4 } else { 0 }
} else {
self.taken()
};
if self.width == usize::MAX {
usize::MAX
} else {
self.width.saturating_sub(taken).max(8)
}
}
fn rule_width(&self) -> usize {
match self.inner_width() {
usize::MAX => 40,
w => w,
}
}
fn taken(&self) -> usize {
self.quotes
.iter()
.map(|d| match d {
Deco::Rail => 2,
Deco::Card { .. } => 4,
})
.sum()
}
fn box_width(&self) -> usize {
if self.width == usize::MAX {
80
} else {
self.width.max(8)
}
}
fn emit_wrapped(
&mut self,
cells: Vec<PCell>,
checkbox: Option<usize>,
image: Option<usize>,
src_line: Option<usize>,
hang: usize,
) {
if self.rails == 0 && !self.boxed {
self.emit_line(PLine {
cells,
checkbox,
image,
src_line,
wide: false,
hang,
});
return;
}
let avail = self.inner_width();
let rest = avail.saturating_sub(hang).max(4);
for (i, row) in wrap_hang(&cells, avail, rest).into_iter().enumerate() {
let mut cells = if i == 0 {
Vec::new()
} else {
str_cells(&" ".repeat(hang), theme::PLAIN)
};
cells.extend(row);
self.emit_line(PLine {
cells,
checkbox: if i == 0 { checkbox } else { None },
image: if i == 0 { image } else { None },
src_line,
wide: false,
hang: 0,
});
}
}
fn emit_line(&mut self, mut line: PLine) {
if !line.wide && !self.quotes.is_empty() {
let mut cells = Vec::new();
let mut edges: Vec<(Style, usize)> = Vec::new();
let mut x = 0;
for deco in &self.quotes {
match *deco {
Deco::Rail => cells.extend(str_cells(
&format!("{} ", theme::QUOTE_BAR),
theme::marker(),
)),
Deco::Card { style, w, .. } => {
cells.extend(str_cells("│ ", style));
edges.push((style, x + w));
}
}
x += 2;
}
cells.extend(line.cells);
for (style, end) in edges.into_iter().rev() {
let used = cells_width(&cells);
if used + 2 > end {
break;
}
cells.extend(str_cells(&" ".repeat(end - used - 2), theme::PLAIN));
cells.extend(str_cells(" │", style));
}
line.cells = cells;
} else if !line.wide && (self.rails > 0 || self.boxed) {
let mut cells = Vec::new();
if self.boxed {
cells.extend(str_cells("│ ", self.box_style));
}
for _ in 0..self.rails {
cells.extend(str_cells(
&format!("{} ", theme::QUOTE_BAR),
theme::marker(),
));
}
cells.extend(line.cells);
if self.boxed {
let pad = self.box_w.saturating_sub(cells_width(&cells) + 2);
cells.extend(str_cells(&" ".repeat(pad), theme::PLAIN));
cells.extend(str_cells(" │", self.box_style));
}
line.cells = cells;
}
self.quote_fresh = false;
self.quote_blank = false;
self.out.lines.push(line);
}
fn blank(&mut self) {
self.flush();
if self.rails > 0 || self.boxed {
if self.quote_fresh || self.quote_blank {
return;
}
self.emit_line(PLine::default());
self.quote_blank = true;
return;
}
if !self
.out
.lines
.last()
.map(|l| l.cells.is_empty())
.unwrap_or(true)
{
self.out.lines.push(PLine::default());
}
}
fn open_box(&mut self, kind: &str, title: &str, marker: Option<char>) {
let w = self.box_width().saturating_sub(self.taken());
let style = theme::callout(kind);
if !self.boxed {
self.box_w = w;
self.box_style = style;
}
let mut cells = str_cells("╭─ ", style);
if marker.is_some() {
cells.extend(str_cells(theme::UNFOLDED, style));
}
if let Some(g) = crate::md::callout_glyph(kind) {
cells.extend(str_cells(&format!("{g} "), style));
}
cells.extend(str_cells(kind, style));
if !title.is_empty() {
cells.extend(str_cells(" · ", style));
let title = self.title_marks(title);
cells.extend(str_cells(&title, style.add_modifier(Modifier::BOLD)));
}
cells.push(PCell {
ch: ' ',
style,
link: None,
src: None,
});
let cells = truncate_cells(&cells, w.saturating_sub(1));
let mut row = cells;
let dashes = w.saturating_sub(cells_width(&row) + 1);
row.extend(str_cells(&"─".repeat(dashes), style));
row.extend(str_cells("╮", style));
let fresh = self.quote_fresh;
self.emit_line(PLine {
cells: row,
checkbox: None,
image: None,
src_line: self.src_line,
wide: false,
hang: 0,
});
self.quote_fresh = fresh;
self.quotes.push(Deco::Card {
style,
w,
title: self.src_line,
});
}
fn close_box(&mut self) {
let Some(Deco::Card { style, w, title }) = self.quotes.pop() else {
return;
};
let row = format!("╰{}╯", "─".repeat(w.saturating_sub(2)));
self.emit_line(PLine {
cells: str_cells(&row, style),
checkbox: None,
image: None,
src_line: title,
wide: false,
hang: 0,
});
}
fn indent(&self) -> String {
" ".repeat(self.list_depth.saturating_sub(1))
}
fn task_box(&mut self, glyph: &'static str, style: Style, src_line: usize, space: bool) {
let ordered = matches!(self.list_numbers.last(), Some(Some(_)));
if !ordered {
self.cells.clear();
let indent = self.indent();
self.hang = crate::md::str_width(&indent);
self.push(&indent, style, None);
}
let mark = if space {
format!("{glyph} ")
} else {
glyph.to_string()
};
self.hang += crate::md::str_width(glyph) + 1;
self.push(&mark, style, None);
self.pending_checkbox = Some(src_line);
if crate::md::struck(glyph) {
self.styles.push(self.style().patch(theme::done_text()));
self.done_item = true;
}
}
fn wikilink_here(&self, off: usize) -> Option<(usize, String, usize, usize)> {
if !crate::md::links::enabled() || !self.src[off..].starts_with("[[") {
return None;
}
let before = &self.src[..off];
if before.ends_with('\\') || before.ends_with('!') {
return None;
}
let line_end = self.src[off..]
.find('\n')
.map_or(self.src.len(), |n| off + n);
let chars: Vec<char> = self.src[off..line_end].chars().collect();
let w = crate::md::wikilink_at(&chars, 0)?;
let byte_at = byte_offsets(&chars, off);
Some((
byte_at[w.end],
w.full_target(),
byte_at[w.label_start],
byte_at[w.label_end],
))
}
fn embed_here(&self, off: usize) -> Option<(String, String, Option<u32>, usize)> {
if !crate::md::links::enabled() || !self.src[off..].starts_with("![[") {
return None;
}
let line_start = self.src[..off].rfind('\n').map_or(0, |n| n + 1);
let line_end = self.src[off..]
.find('\n')
.map_or(self.src.len(), |n| off + n);
let line = &self.src[line_start..line_end];
if line.trim_start().len() != line_end - off {
return None; }
let (alt, url, width) = crate::md::embed_line(line)?;
Some((alt, url, width, line_end))
}
fn attachment_embed_here(&self, off: usize) -> Option<(String, Option<String>, usize)> {
if !crate::md::links::enabled() || !self.src[off..].starts_with("![[") {
return None;
}
let line_start = self.src[..off].rfind('\n').map_or(0, |n| n + 1);
let line_end = self.src[off..]
.find('\n')
.map_or(self.src.len(), |n| off + n);
let line = &self.src[line_start..line_end];
if line.trim_start().len() != line_end - off {
return None; }
let (name, label) = crate::md::attachment_embed_line(line)?;
Some((name, label, line_end))
}
fn note_embed_here(&self, off: usize) -> Option<(crate::md::NoteEmbed, usize)> {
if !crate::md::links::enabled() || !self.src[off..].starts_with("![[") {
return None;
}
let line_start = self.src[..off].rfind('\n').map_or(0, |n| n + 1);
let line_end = self.src[off..]
.find('\n')
.map_or(self.src.len(), |n| off + n);
let line = &self.src[line_start..line_end];
if line.trim_start().len() != line_end - off {
return None; }
let embed = crate::md::note_embed_line(line)?;
Some((embed, line_end))
}
fn note_link_here(&self, off: usize) -> Option<(usize, String, usize, usize, bool)> {
if !crate::md::links::enabled() || !self.src[off..].starts_with("![[") {
return None;
}
let line_end = self.src[off..]
.find('\n')
.map_or(self.src.len(), |n| off + n);
let chars: Vec<char> = self.src[off..line_end].chars().collect();
let w = crate::md::wikilink_at(&chars, 1)?;
let byte_at = byte_offsets(&chars, off);
Some((
byte_at[w.end],
w.target,
byte_at[w.label_start],
byte_at[w.label_end],
w.label_start != w.start + 2,
))
}
fn emit_attachment_card(&mut self, name: &str, label: Option<&str>) {
self.flush();
let style = crate::md::embed_style();
let idx = self.out.urls.len();
self.out
.urls
.push(crate::md::LinkTarget::File(name.to_string()).href());
let rail = format!("{} ", theme::QUOTE_BAR);
self.push(&rail, style, None);
match crate::md::attachment_card(name, label) {
(text, true) => self.push(&text, style.add_modifier(Modifier::BOLD), Some(idx)),
(text, false) => self.push(&text, theme::grey(), Some(idx)),
}
self.flush();
}
fn emit_embed_card(&mut self, embed: &crate::md::NoteEmbed) {
self.flush();
let card = crate::md::embed_card(embed);
let style = crate::md::embed_style();
let idx = self.out.urls.len();
self.out
.urls
.push(crate::md::LinkTarget::Wiki(embed.target.clone()).href());
let rail = format!("{} ", theme::QUOTE_BAR);
self.push(&rail, style, None);
match card.found {
crate::md::embeds::Found::Missing => self.push(
&format!("{} (no such note)", card.head()),
theme::grey(),
Some(idx),
),
_ => self.push(&card.head(), style.add_modifier(Modifier::BOLD), Some(idx)),
}
self.flush();
for line in &card.lines {
self.push(&rail, style, None);
let cells = crate::md::style_inline(line).into_iter().map(|c| PCell {
ch: c.ch,
style: c.style,
link: None,
src: None,
});
self.buf().extend(cells);
self.flush();
}
if card.more > 0 {
self.push(&rail, style, None);
self.push(&crate::md::more_lines(card.more), theme::marker(), None);
self.flush();
}
}
fn emit_image(&mut self, alt: String, url: String, width: Option<u32>) {
self.flush();
let idx = self.out.images.len();
self.out.images.push(ImageSpec {
alt: alt.clone(),
url: url.clone(),
width,
});
let label = if alt.is_empty() {
format!("🖼 {url}")
} else {
format!("🖼 {alt} ({url})")
};
self.push(&label, theme::marker(), None);
let cells = std::mem::take(&mut self.cells);
let src_line = self.src_line;
self.emit_wrapped(cells, None, Some(idx), src_line, 0);
}
fn emit_text(&mut self, text: &str, off: Option<usize>) {
let base = self.style();
let link = self.link;
let chars: Vec<char> = text.chars().collect();
let byte_at = byte_offsets(&chars, 0);
let at = |i: usize| off.map(|o| o + byte_at[i]);
let mut i = 0;
let mut run = String::new();
let mut run_start = 0usize;
while i < chars.len() {
if chars[i] == '=' && chars.get(i + 1) == Some(&'=') {
if let Some(end) = crate::md::find_pair(&chars, i + 2, '=') {
self.push_at(&std::mem::take(&mut run), base, link, at(run_start));
let body: String = chars[i + 2..end].iter().collect();
self.push_at(&body, base.patch(theme::highlight()), link, at(i + 2));
i = end + 2;
run_start = i;
continue;
}
}
if let Some(end) = crate::md::url_at(&chars, i).filter(|_| link.is_none()) {
let url: String = chars[i..end].iter().collect();
self.push_at(&std::mem::take(&mut run), base, None, at(run_start));
let idx = self.out.urls.len();
self.out
.urls
.push(crate::md::LinkTarget::Url(url.clone()).href());
self.push_at(&url, base.patch(theme::link()), Some(idx), at(i));
i = end;
run_start = i;
continue;
}
if link.is_none() && chars[i] == '#' && crate::md::tags::enabled() {
let prev = match i {
0 => at(0).and_then(|o| self.src[..o].chars().next_back()),
_ => Some(chars[i - 1]),
};
if crate::md::tag_boundary(prev) {
if let Some(end) = crate::md::tag_at(&chars, i) {
let name: String = chars[i + 1..end].iter().collect();
self.push_at(&std::mem::take(&mut run), base, None, at(run_start));
let idx = self.out.urls.len();
self.out.urls.push(crate::md::LinkTarget::Tag(name).href());
let shown: String = chars[i..end].iter().collect();
self.push_at(&shown, base.patch(theme::tag()), Some(idx), at(i));
i = end;
run_start = i;
continue;
}
}
}
if run.is_empty() {
run_start = i;
}
run.push(chars[i]);
i += 1;
}
self.push_at(&run, base, link, at(run_start));
}
fn emit_html(&mut self, html: &str, off: usize) {
let chars: Vec<char> = html.chars().collect();
let byte_at = byte_offsets(&chars, off);
let mut i = 0;
while i < chars.len() {
if self.in_comment {
match (i..chars.len()).find(|&k| crate::md::html_comment_close_at(&chars, k)) {
Some(k) => {
self.in_comment = false;
i = k + 3;
}
None => return,
}
continue;
}
if chars[i] == '<' {
if let Some(end) = crate::md::html_comment_end(&chars, i) {
i = end;
continue;
}
if chars[i + 1..].starts_with(&['!', '-', '-']) {
self.in_comment = true;
return;
}
if let Some(tag) = crate::md::html_tag_at(&chars, i) {
self.html_tag(&tag);
i = tag.end;
continue;
}
}
if chars[i] == '\n' {
self.flush();
i += 1;
continue;
}
let end = (i..chars.len())
.find(|&k| chars[k] == '<' || chars[k] == '\n')
.unwrap_or(chars.len());
let run: String = chars[i..end].iter().collect();
let run = run.trim_end_matches('\r');
match self.script() {
Some(sup) => self.push_script(run, sup, byte_at[i]),
None => self.emit_text(run, Some(byte_at[i])),
}
i = end;
}
}
fn html_tag(&mut self, tag: &crate::md::HtmlTag) {
if tag.name == "br" {
if matches!(self.sink, Sink::Table) {
self.push(" ", self.style(), self.link);
} else {
self.flush();
}
return;
}
if tag.closing {
if let Some(at) = self.html.iter().rposition(|n| *n == tag.name) {
while self.html.len() > at {
self.html.pop();
self.styles.pop();
}
}
return;
}
if tag.opens() {
if let Some(style) = crate::md::html_style(&tag.name, self.style()) {
self.styles.push(style);
self.html.push(tag.name.clone());
}
}
}
fn close_html(&mut self) {
while self.html.pop().is_some() {
self.styles.pop();
}
}
fn script(&self) -> Option<bool> {
self.html.iter().rev().find_map(|n| match n.as_str() {
"sup" => Some(true),
"sub" => Some(false),
_ => None,
})
}
fn push_script(&mut self, text: &str, sup: bool, off: usize) {
let style = self.style();
let link = self.link;
let mut at = off;
for c in text.chars() {
let shown = if sup {
crate::md::sup_char(c)
} else {
crate::md::sub_char(c)
};
self.push_at(&shown.unwrap_or(c).to_string(), style, link, Some(at));
at += c.len_utf8();
}
}
fn emit_mermaid(&mut self, src: &str, off: usize) {
match crate::mermaid::render(src, self.inner_width()) {
Some(d) => self.emit_diagram(&d),
None => self.emit_fence_label(src, off),
}
}
fn emit_diagram(&mut self, d: &crate::mermaid::Rendered) {
for row in &d.rows {
let mut cells: Vec<PCell> = Vec::new();
for run in row {
cells.extend(str_cells(&run.text, crate::md::mermaid_style(run.role)));
}
let wide = cells_width(&cells) > self.width;
self.emit_line(PLine {
cells,
checkbox: None,
image: None,
src_line: self.src_line,
wide,
hang: 0,
});
}
}
fn emit_fence_label(&mut self, src: &str, off: usize) {
let label = match crate::mermaid::kind_word(src) {
Some(word) => format!("◇ mermaid · {word}"),
None => "◇ mermaid".to_string(),
};
self.push(&label, theme::marker(), None);
self.flush();
let mut off = off;
for raw in src.split_inclusive('\n') {
let l = raw.trim_end_matches('\n').trim_end_matches('\r');
self.push(" ", theme::code(), None);
self.push_at(l, theme::code(), None, Some(off));
off += raw.len();
let cells = std::mem::take(&mut self.cells);
let src_line = self.src_line;
self.emit_wrapped(cells, None, None, src_line, 2);
}
}
fn run(&mut self, markdown: &str) {
for (event, range) in Parser::new_ext(markdown, options()).into_offset_iter() {
let src_line = self.first_line + self.line_of(range.start);
if self.cells.is_empty() && matches!(self.sink, Sink::Page) {
self.src_line = Some(src_line);
}
self.event(event, src_line, range);
}
self.flush();
}
fn event(&mut self, event: Event<'_>, src_line: usize, range: std::ops::Range<usize>) {
if range.start < self.wiki_until && emits_cells(&event) {
return;
}
let fresh = std::mem::take(&mut self.item_fresh);
match event {
Event::Start(Tag::Heading { level, .. }) => {
self.blank();
self.src_line = Some(src_line);
self.styles.push(theme::heading(level as usize));
}
Event::End(TagEnd::Heading(_)) => {
self.close_html();
self.styles.pop();
self.flush();
}
Event::Start(Tag::Paragraph) => {
if self.list_depth == 0 && self.table.is_none() {
self.blank();
self.src_line = Some(src_line);
}
if let Some(mark) = self.footnote.take() {
self.push(&format!("{mark} "), theme::state(), None);
}
self.item_fresh = fresh;
}
Event::End(TagEnd::Paragraph) => {
self.close_html();
self.flush();
}
Event::Start(Tag::BlockQuote(_)) => {
self.blank();
self.src_line = Some(src_line);
match callout_at(&self.src, range.start) {
Some((kind, title, marker, end)) => {
self.open_box(&kind, &title, marker);
self.boxed = true;
self.wiki_until = self.wiki_until.max(end);
}
None => {
self.rails += 1;
self.quotes.push(Deco::Rail);
}
}
self.quote_fresh = true;
self.styles.push(theme::quote());
}
Event::End(TagEnd::BlockQuote(_)) => {
self.styles.pop();
self.flush();
match self.quotes.last() {
Some(Deco::Rail) => {
self.rails -= 1;
self.quotes.pop();
}
Some(Deco::Card { .. }) => {
self.boxed = self.quotes[..self.quotes.len() - 1]
.iter()
.any(|d| matches!(d, Deco::Card { .. }));
self.close_box();
}
None => {}
}
self.quote_fresh = false;
self.quote_blank = false;
}
Event::Start(Tag::List(start)) => {
if self.list_depth == 0 {
self.blank();
}
self.list_depth += 1;
self.list_numbers.push(start);
}
Event::End(TagEnd::List(_)) => {
self.list_depth = self.list_depth.saturating_sub(1);
self.list_numbers.pop();
self.flush();
}
Event::Start(Tag::Item) => {
self.flush();
self.src_line = Some(src_line);
let marker = match self.list_numbers.last_mut() {
Some(Some(n)) => {
let m = format!("{n}.");
*n += 1;
m
}
_ => theme::bullet(self.list_depth).to_string(),
};
let text = format!("{}{marker} ", self.indent());
self.hang = crate::md::str_width(&text);
self.push(&text, theme::marker(), None);
self.item_fresh = true;
}
Event::End(TagEnd::Item) => {
self.close_html();
if self.done_item {
self.styles.pop();
self.done_item = false;
}
self.flush()
}
Event::TaskListMarker(done) => {
let (mark, style) = if done {
(theme::CHECKED, theme::done())
} else {
(theme::UNCHECKED, theme::marker())
};
self.task_box(mark, style, src_line, true);
}
Event::Start(Tag::CodeBlock(kind)) => {
self.blank();
self.src_line = Some(src_line);
self.in_code_block = true;
if matches!(&kind, CodeBlockKind::Fenced(info) if crate::mermaid::is_mermaid(info))
{
self.mermaid = Some((String::new(), 0));
}
}
Event::End(TagEnd::CodeBlock) => {
if let Some((src, off)) = self.mermaid.take() {
self.emit_mermaid(&src, off);
}
self.in_code_block = false;
self.flush();
}
Event::Start(Tag::Emphasis) => self
.styles
.push(self.style().add_modifier(Modifier::ITALIC)),
Event::Start(Tag::Strong) => {
self.styles.push(self.style().add_modifier(Modifier::BOLD))
}
Event::Start(Tag::Strikethrough) => self
.styles
.push(self.style().add_modifier(Modifier::CROSSED_OUT)),
Event::End(TagEnd::Emphasis)
| Event::End(TagEnd::Strong)
| Event::End(TagEnd::Strikethrough) => {
self.styles.pop();
}
Event::Start(Tag::Link { dest_url, .. }) => {
let idx = self.out.urls.len();
self.out
.urls
.push(crate::md::LinkTarget::from_href(&dest_url).href());
self.link = Some(idx);
self.styles.push(self.style().patch(theme::link()));
}
Event::End(TagEnd::Link) => {
self.styles.pop();
self.link = None;
}
Event::Start(Tag::Image { dest_url, .. }) => {
self.image_alt = Some((String::new(), dest_url.into_string()));
}
Event::End(TagEnd::Image) => {
if let Some((alt, url)) = self.image_alt.take() {
self.emit_image(alt, url, None);
}
}
Event::Start(Tag::Table(aligns)) => {
self.blank();
self.src_line = Some(src_line);
self.table = Some(Table {
aligns,
..Table::default()
});
}
Event::End(TagEnd::Table) => self.emit_table(),
Event::Start(Tag::TableHead) => {
if let Some(t) = self.table.as_mut() {
t.in_head = true;
}
}
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
if let Some(t) = self.table.as_mut() {
let row = std::mem::take(&mut t.row);
t.rows.push(row);
t.in_head = false;
}
}
Event::Start(Tag::TableRow) => {}
Event::Start(Tag::TableCell) => {
self.cell_buf.clear();
self.sink = Sink::Table;
if self.table.as_ref().is_some_and(|t| t.in_head) {
self.styles.push(self.style().add_modifier(Modifier::BOLD));
}
}
Event::End(TagEnd::TableCell) => {
self.close_html();
if self.table.as_ref().is_some_and(|t| t.in_head) {
self.styles.pop();
}
self.sink = Sink::Page;
let cell = std::mem::take(&mut self.cell_buf);
if let Some(t) = self.table.as_mut() {
t.row.push(cell);
}
}
Event::Code(code) => {
let style = self.style().patch(theme::inline_code());
let link = self.link;
let ticks = self.src[range.clone()]
.chars()
.take_while(|c| *c == '`')
.count();
self.push_at(&code.into_string(), style, link, Some(range.start + ticks));
}
Event::InlineMath(text) => {
let style = self.style().patch(theme::math());
let link = self.link;
self.push_at(&text, style, link, Some(range.start + 1));
}
Event::DisplayMath(text) => {
self.blank();
self.src_line = Some(src_line);
let width = self.rule_width();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
let w = crate::md::str_width(line);
self.push(&" ".repeat(width.saturating_sub(w) / 2), theme::PLAIN, None);
self.push(line, theme::math(), None);
self.flush();
}
}
Event::FootnoteReference(label) => {
let mark = self.footnote_mark(&label);
self.push(&mark, theme::state(), None);
}
Event::Start(Tag::FootnoteDefinition(label)) => {
self.blank();
self.src_line = Some(src_line);
self.footnote = Some(self.footnote_mark(&label));
}
Event::End(TagEnd::FootnoteDefinition) => {
self.footnote = None;
self.flush();
}
Event::Text(text) => {
if let Some((alt, _)) = self.image_alt.as_mut() {
alt.push_str(&text);
} else if let Some((buf, off)) = self.mermaid.as_mut() {
if buf.is_empty() {
*off = range.start;
}
buf.push_str(&text);
} else if let Some(sup) = self.script() {
self.push_script(&text, sup, range.start);
} else if self.in_code_block {
let mut off = range.start;
for raw in text.split_inclusive('\n') {
let l = raw.trim_end_matches('\n').trim_end_matches('\r');
self.push(" ", theme::code(), None);
self.push_at(l, theme::code(), None, Some(off));
off += raw.len();
let cells = std::mem::take(&mut self.cells);
let src_line = self.src_line;
self.emit_wrapped(cells, None, None, src_line, 2);
}
} else if let Some((glyph, style)) = fresh
.then(|| other_task(&self.src[range.start..]))
.flatten()
{
self.task_box(glyph, style, src_line, false);
self.wiki_until = range.start + 3;
} else if let Some((alt, url, width, end)) = self.embed_here(range.start) {
self.emit_image(alt, url, width);
self.wiki_until = end;
} else if let Some((name, label, end)) = self.attachment_embed_here(range.start) {
self.emit_attachment_card(&name, label.as_deref());
self.wiki_until = end;
} else if let Some((embed, end)) = self.note_embed_here(range.start) {
self.emit_embed_card(&embed);
self.wiki_until = end;
} else if let Some((end, target, ls, le, aliased)) =
self.note_link_here(range.start)
{
let idx = self.out.urls.len();
self.out
.urls
.push(crate::md::LinkTarget::wiki(target.clone()).href());
let style = crate::md::wiki_style(self.style(), &target);
let label = &self.src[ls..le];
match label.find('#').filter(|_| !aliased) {
Some(h) => {
let (note, heading) =
(label[..h].to_string(), label[h + 1..].to_string());
self.push_at(¬e, style, Some(idx), Some(ls));
self.push(" › ", style, Some(idx));
self.push_at(&heading, style, Some(idx), Some(ls + h + 1));
}
None => {
let label = label.to_string();
self.push_at(&label, style, Some(idx), Some(ls));
}
}
self.wiki_until = end;
} else if let Some((end, target, ls, le)) = self.wikilink_here(range.start) {
let label = self.src[ls..le].to_string();
let idx = self.out.urls.len();
self.out
.urls
.push(crate::md::LinkTarget::wiki(target.clone()).href());
let style = crate::md::wiki_style(self.style(), &target);
let shown_hash = (crate::md::split_fragment(&target).1.is_some()
&& ls == range.start + 2)
.then(|| label.find('#'))
.flatten();
match shown_hash {
Some(h) => {
self.push_at(&label[..h], style, Some(idx), Some(ls));
let sep = if h == 0 { "› " } else { " › " };
self.push(sep, style, Some(idx));
self.push_at(&label[h + 1..], style, Some(idx), Some(ls + h + 1));
}
None => self.push_at(&label, style, Some(idx), Some(ls)),
}
self.wiki_until = end;
} else {
self.emit_text(&text, Some(range.start));
}
}
Event::SoftBreak => {
if matches!(self.sink, Sink::Table) {
self.push(" ", self.style(), self.link);
} else {
self.flush();
self.src_line = Some(src_line);
}
}
Event::HardBreak => self.flush(),
Event::InlineHtml(html) => self.emit_html(&html, range.start),
Event::Start(Tag::HtmlBlock) => {
self.blank();
self.src_line = Some(src_line);
}
Event::Html(html) => {
if self.cells.is_empty() {
self.src_line = Some(src_line);
}
self.emit_html(&html, range.start);
}
Event::End(TagEnd::HtmlBlock) => {
self.close_html();
self.in_comment = false;
self.flush();
}
Event::Rule => {
self.blank();
self.push(&"─".repeat(self.rule_width()), theme::marker(), None);
self.flush();
}
_ => {}
}
}
fn emit_table(&mut self) {
let Some(t) = self.table.take() else { return };
if t.rows.is_empty() {
return;
}
let page = self.width;
self.width = self.inner_width();
self.emit_table_in(&t);
self.width = page;
}
fn emit_table_in(&mut self, t: &Table) {
let cols = t.rows.iter().map(|r| r.len()).max().unwrap_or(0);
let measured: Vec<Vec<usize>> = t
.rows
.iter()
.map(|r| r.iter().map(|c| cells_width(c)).collect())
.collect();
let natural = crate::md::column_widths(&measured, cols);
let seps = crate::md::COL_SEP.chars().count() * cols.saturating_sub(1);
let fits = natural.iter().sum::<usize>() + seps <= self.width;
match self.table_shape(cols, seps, fits) {
Shape::Grid { wrap } => {
let widths = crate::md::fit_widths(&natural, self.width);
self.emit_grid(t, cols, &widths, wrap, false);
}
Shape::Scroll => {
let widths = self.scroll_widths(&natural);
self.emit_grid(t, cols, &widths, true, true);
}
Shape::Cards => self.emit_cards(t, cols),
}
}
fn table_shape(&self, cols: usize, seps: usize, fits: bool) -> Shape {
let grid_possible = self.width >= cols * crate::md::MIN_COL + seps;
match self.tables {
TableStyle::Fit => Shape::Grid { wrap: false },
TableStyle::Wrap if grid_possible => Shape::Grid { wrap: !fits },
TableStyle::Wrap => Shape::Cards,
TableStyle::Cards => Shape::Cards,
TableStyle::Scroll => Shape::Scroll,
TableStyle::Auto if fits => Shape::Grid { wrap: false },
TableStyle::Auto => Shape::Scroll,
}
}
fn scroll_widths(&self, natural: &[usize]) -> Vec<usize> {
const FLOOR: usize = 12;
let cap = (self.width / 3).clamp(FLOOR, 44);
natural.iter().map(|w| (*w).min(cap).max(1)).collect()
}
fn emit_grid(&mut self, t: &Table, cols: usize, widths: &[usize], wrap: bool, wide: bool) {
let src_line = self.src_line;
for (ri, row) in t.rows.iter().enumerate() {
let empty: Vec<PCell> = Vec::new();
let parts: Vec<Vec<Vec<PCell>>> = (0..cols)
.map(|ci| {
let cell = row.get(ci).unwrap_or(&empty);
let w = widths.get(ci).copied().unwrap_or(0);
if wrap {
wrap_pcells(cell, w.max(1))
} else {
vec![truncate_cells(cell, w)]
}
})
.collect();
let height = parts.iter().map(|p| p.len()).max().unwrap_or(1);
for line in 0..height {
let mut cells: Vec<PCell> = Vec::new();
for (ci, w) in widths.iter().enumerate().take(cols) {
if ci > 0 {
cells.extend(str_cells(crate::md::COL_SEP, theme::marker()));
}
let blank: Vec<PCell> = Vec::new();
let part = parts[ci].get(line).unwrap_or(&blank);
let align = align_of(t.aligns.get(ci).copied().unwrap_or(Alignment::None));
let (left, right) = crate::md::pad_for(cells_width(part), *w, align);
cells.extend(str_cells(&" ".repeat(left), theme::PLAIN));
cells.extend(part.iter().cloned());
cells.extend(str_cells(&" ".repeat(right), theme::PLAIN));
}
self.emit_line(PLine {
cells,
checkbox: None,
image: None,
src_line,
wide,
hang: 0,
});
}
let last = ri + 1 == t.rows.len();
if ri == 0 || !last {
let rule = crate::md::table_rule(widths);
self.emit_line(PLine {
cells: str_cells(&rule, theme::marker()),
checkbox: None,
image: None,
src_line,
wide,
hang: 0,
});
}
}
}
fn emit_cards(&mut self, t: &Table, cols: usize) {
let src_line = self.src_line;
let empty: Vec<PCell> = Vec::new();
let head: Vec<String> = (0..cols)
.map(|ci| {
t.rows
.first()
.and_then(|r| r.get(ci))
.map(|c| c.iter().map(|p| p.ch).collect::<String>())
.unwrap_or_default()
.trim()
.to_string()
})
.collect();
let labelw = head
.iter()
.skip(1)
.map(|h| crate::md::str_width(h))
.max()
.unwrap_or(0);
let mut made: Vec<Vec<PCell>> = Vec::new();
let push = |cells: Vec<PCell>, made: &mut Vec<Vec<PCell>>| made.push(cells);
for (ri, row) in t.rows.iter().enumerate().skip(1) {
if ri > 1 {
push(Vec::new(), &mut made);
}
let mut title = str_cells(&format!("{} ", theme::QUOTE_BAR), theme::state());
let first = truncate_cells(row.first().unwrap_or(&empty), self.width.saturating_sub(2));
title.extend(first.iter().map(|c| {
let mut c = c.clone();
c.style = c.style.patch(theme::heading(3)).fg(theme::palette().accent);
c
}));
push(title, &mut made);
for ci in 1..cols {
let value = row.get(ci).unwrap_or(&empty);
if value.iter().all(|c| c.ch.is_whitespace()) {
continue;
}
let label = head.get(ci).cloned().unwrap_or_default();
let pad = labelw.saturating_sub(crate::md::str_width(&label));
let indent = 2 + labelw + 2;
let avail = self.width.saturating_sub(indent).max(8);
for (i, part) in wrap_pcells(value, avail).into_iter().enumerate() {
let mut cells = if i == 0 {
let mut c = str_cells(" ", theme::PLAIN);
c.extend(str_cells(&label, theme::marker()));
c.extend(str_cells(&" ".repeat(pad + 2), theme::PLAIN));
c
} else {
str_cells(&" ".repeat(indent), theme::PLAIN)
};
cells.extend(part);
push(cells, &mut made);
}
}
}
for cells in made {
self.emit_line(PLine {
cells,
checkbox: None,
image: None,
src_line,
wide: false,
hang: 0,
});
}
}
fn finish(mut self) -> Rendered {
self.flush();
self.out
}
}
enum Shape {
Grid {
wrap: bool,
},
Scroll,
Cards,
}
pub fn wrap_pcells(cells: &[PCell], width: usize) -> Vec<Vec<PCell>> {
if width == 0 {
return vec![cells.to_vec()];
}
wrap_hang(cells, width, width)
}
fn wrap_hang(cells: &[PCell], first: usize, rest: usize) -> Vec<Vec<PCell>> {
if cells_width(cells) <= first {
return vec![cells.to_vec()];
}
let chars: Vec<char> = cells.iter().map(|c| c.ch).collect();
crate::md::wrap_breaks(&chars, first, rest)
.into_iter()
.map(|(s, e)| cells[s..e].to_vec())
.collect()
}
fn callout_at(src: &str, start: usize) -> Option<(String, String, Option<char>, usize)> {
let rest = src.get(start..)?;
let line_end = rest.find('\n').map_or(rest.len(), |i| i + 1);
let line = &rest[..line_end];
let chars: Vec<char> = line.trim_start_matches(['>', ' ', '\t']).chars().collect();
let (kind, after, title) = crate::md::callout_title(&chars, 0)?;
let marker = chars.get(after).copied().filter(|c| matches!(c, '-' | '+'));
let title: String = chars[title..].iter().collect();
Some((kind, title.trim().to_string(), marker, start + line_end))
}
fn align_of(a: Alignment) -> crate::md::Align {
match a {
Alignment::Right => crate::md::Align::Right,
Alignment::Center => crate::md::Align::Center,
_ => crate::md::Align::Left,
}
}
fn str_cells(s: &str, style: Style) -> Vec<PCell> {
s.chars()
.map(|ch| PCell {
ch,
style,
link: None,
src: None,
})
.collect()
}
fn truncate_cells(cells: &[PCell], width: usize) -> Vec<PCell> {
if cells_width(cells) <= width {
return cells.to_vec();
}
let n = crate::md::cut_at(cells.iter().map(|c| crate::md::char_width(c.ch)), width);
let mut out = cells[..n].to_vec();
let style = out.last().map(|c| c.style).unwrap_or(theme::PLAIN);
out.push(PCell {
ch: '…',
style,
link: None,
src: None,
});
out
}
pub fn cells_width(cells: &[PCell]) -> usize {
cells.iter().map(|c| crate::md::char_width(c.ch)).sum()
}
fn other_task(src: &str) -> Option<(&'static str, Style)> {
let rest = src.strip_prefix('[')?;
let c = rest.chars().next()?;
if matches!(c, ' ' | 'x' | 'X') || !rest[c.len_utf8()..].starts_with("] ") {
return None;
}
crate::md::task_state(c)
}
fn emits_cells(event: &Event<'_>) -> bool {
matches!(
event,
Event::Text(_)
| Event::Code(_)
| Event::Html(_)
| Event::InlineHtml(_)
| Event::InlineMath(_)
| Event::DisplayMath(_)
| Event::FootnoteReference(_)
)
}
fn byte_offsets(chars: &[char], base: usize) -> Vec<usize> {
let mut v = Vec::with_capacity(chars.len() + 1);
let mut b = base;
for ch in chars {
v.push(b);
b += ch.len_utf8();
}
v.push(b);
v
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(r: &Rendered) -> String {
r.lines
.iter()
.map(|l| l.text())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn renders_without_panic() {
let md = "# Title\n\nSome **bold** and *italic* and `code`.\n\n- one\n- [ ] task\n- [x] done\n\n> quote\n\n```\nlet x = 1;\n```\n\n---\n";
let r = render(md);
assert!(r.lines.len() > 5);
let flat = flat(&r);
assert!(flat.contains("Title"));
assert!(flat.contains("bold"));
assert!(flat.contains("let x = 1;"));
}
#[test]
fn code_block_offsets_survive_crlf() {
for md in [
"# T\n\n```\nlet x = 1;\nlet y = 2;\n```\n\ntail\n",
"# T\r\n\r\n```\r\nlet x = 1;\r\nlet y = 2;\r\n```\r\n\r\ntail\r\n",
] {
let src_lines: Vec<&str> = md.lines().collect();
for line in &render(md).lines {
for c in &line.cells {
if let Some((l, col)) = c.src {
let at = src_lines[l].chars().nth(col);
assert_eq!(at, Some(c.ch), "{md:?} at ({l},{col})");
}
}
}
}
}
#[test]
fn a_flowchart_fence_reaches_the_page_as_rows_and_not_as_code() {
let md = "```mermaid\nflowchart LR\n A[Start] --> B[End]\n```\n";
let flat = flat(&render_wide(md, 60));
assert!(!flat.contains("◇ mermaid"), "{flat}");
assert!(flat.contains("Start") && flat.contains("End"), "{flat}");
assert!(flat.contains('╭'), "{flat}");
}
fn page_of(d: &crate::mermaid::Rendered, width: usize, boxed: bool) -> Rendered {
let mut r = Ren::new("", 0, width, TableStyle::default());
if boxed {
r.boxed = true;
r.box_w = width;
}
r.emit_diagram(d);
r.finish()
}
#[test]
fn a_mermaid_fence_is_drawn_as_a_picture_in_the_reading_view() {
use crate::mermaid::{Rendered as Diagram, Role, Run};
let d = Diagram::new(vec![
vec![Run::new("╭───────╮", Role::Line)],
vec![
Run::new("│ ", Role::Line),
Run::new("Start", Role::Node),
Run::new(" │", Role::Line),
],
]);
let page = page_of(&d, 40, false);
assert_eq!(flat(&page), "╭───────╮\n│ Start │");
let start = page.lines[1].cells.iter().find(|c| c.ch == 'S').unwrap();
assert_eq!(start.style, theme::PLAIN);
assert_eq!(page.lines[0].cells[0].style, theme::marker());
assert!(page
.lines
.iter()
.all(|l| l.cells.iter().all(|c| c.style != theme::state())));
assert!(page.lines.iter().all(|l| !l.wide));
}
#[test]
fn a_mermaid_fence_catcher_cannot_draw_keeps_its_source_under_a_label() {
let r = render_wide("```mermaid\ngantt\n title Ship it\n```\n", 40);
let flat = flat(&r);
assert!(flat.contains("◇ mermaid"), "{flat}");
assert!(flat.contains(" gantt"), "{flat}");
assert!(flat.contains(" title Ship it"), "{flat}");
assert!(!flat.contains('╭'));
let label = r.lines.iter().find(|l| l.text().contains('◇')).unwrap();
assert!(label.cells.iter().all(|c| c.style == theme::marker()));
}
#[test]
fn the_label_names_the_diagram_kind_and_not_just_mermaid() {
assert!(flat(&render_wide("```mermaid\ngantt\n```\n", 40)).contains("◇ mermaid · gantt"));
let commented = "```mermaid\n%% mine\nclassDiagram\n```\n";
assert!(flat(&render_wide(commented, 40)).contains("◇ mermaid · classDiagram"));
let empty = flat(&render_wide("```mermaid\n\n```\n", 40));
assert!(empty.contains("◇ mermaid"));
assert!(!empty.contains('·'));
}
#[test]
fn a_diagram_wider_than_the_page_is_marked_wide_so_the_page_pans() {
use crate::mermaid::{Rendered as Diagram, Role, Run};
let d = Diagram::new(vec![
vec![Run::new("─".repeat(60), Role::Line)],
vec![Run::new("short", Role::Node)],
]);
let page = page_of(&d, 20, false);
assert!(page.lines[0].wide);
assert_eq!(cells_width(&page.lines[0].cells), 60);
assert!(!page.lines[1].wide);
}
#[test]
fn a_diagram_inside_a_callout_keeps_its_rail() {
use crate::mermaid::{Rendered as Diagram, Role, Run};
let d = Diagram::new(vec![vec![Run::new("A ─▶ B", Role::Line)]]);
let page = page_of(&d, 20, true);
assert_eq!(flat(&page), "│ A ─▶ B │");
let wide = Diagram::new(vec![vec![Run::new("─".repeat(40), Role::Line)]]);
assert_eq!(flat(&page_of(&wide, 20, true)), "─".repeat(40));
}
#[test]
fn a_fence_that_only_looks_like_mermaid_is_still_code() {
let flat = flat(&render_wide("```mermaidjs\ngraph TD\n```\n", 40));
assert!(!flat.contains('◇'), "{flat}");
assert!(flat.contains(" graph TD"), "{flat}");
}
#[test]
fn an_undrawn_diagram_keeps_the_source_columns_a_click_needs() {
let md = "# T\n\n```mermaid\ngantt\n title Ship it\n```\n";
let src_lines: Vec<&str> = md.lines().collect();
for line in &render_wide(md, 40).lines {
for c in &line.cells {
if let Some((l, col)) = c.src {
assert_eq!(src_lines[l].chars().nth(col), Some(c.ch), "at ({l},{col})");
}
}
}
}
const WIDE: &str = "| a | bbbbbbbbbbbbbbbbbbbb |\n| --- | --- |\n| 1 | 2 |\n";
const JOB_LOG: &str = concat!(
"| date | company | title | comp | location | path | doc | status |\n",
"|---|---|---|---|---|---|---|---|\n",
"| 2026-08-25 | Harrison Consulting | Director of Product | $220K/yr | ",
"Seattle, WA | LinkedIn Easy Apply | doc | applied |\n",
);
#[test]
fn a_wide_table_is_squeezed_into_the_page_width() {
let r = render_page(WIDE, 16, TableStyle::Fit);
for l in &r.lines {
assert!(cells_width(&l.cells) <= 16);
}
let head: String = r.lines[0].cells.iter().map(|c| c.ch).collect();
assert_eq!(head, "a │ bbbbbbbbbbb…");
}
#[test]
fn a_line_is_either_inside_the_page_or_marked_wide() {
for width in [24usize, 40, 80, 100] {
for style in [
TableStyle::Auto,
TableStyle::Scroll,
TableStyle::Wrap,
TableStyle::Cards,
] {
for l in &render_page(JOB_LOG, width, style).lines {
assert!(
l.wide || cells_width(&l.cells) <= width,
"{style:?} at {width}: {:?}",
l.text()
);
}
}
}
}
#[test]
fn a_scrolling_table_keeps_its_columns_and_cuts_nothing() {
let r = render_page(JOB_LOG, 60, TableStyle::Scroll);
let table: Vec<&PLine> = r.lines.iter().filter(|l| l.wide).collect();
assert!(!table.is_empty());
let text: String = table.iter().map(|l| l.text()).collect();
assert!(!text.contains('…'), "{text}");
assert!(text.contains("Harrison"), "{text}");
assert!(text.contains("applied"), "{text}");
assert!(table.iter().any(|l| cells_width(&l.cells) > 60));
let widths: Vec<usize> = table.iter().map(|l| cells_width(&l.cells)).collect();
assert!(widths.windows(2).all(|w| w[0] == w[1]), "{widths:?}");
}
#[test]
fn one_runaway_column_cannot_push_the_others_off_the_far_side() {
let md = concat!(
"| a | b |\n|---|---|\n",
"| short | https://example.com/an/extremely/long/url/that/goes/on/and/on/forever |\n",
);
let r = render_page(md, 60, TableStyle::Scroll);
for l in r.lines.iter().filter(|l| l.wide) {
assert!(cells_width(&l.cells) <= 60 + 60 / 3, "{:?}", l.text());
}
}
#[test]
fn wrapping_keeps_every_word_a_squeezed_grid_would_have_cut() {
let r = render_page(WIDE, 16, TableStyle::Wrap);
let text: String = r.lines.iter().map(|l| l.text()).collect();
assert!(text.contains("bbbbbbbb"), "{text:?}");
assert!(!text.contains('…'), "{text:?}");
}
#[test]
fn cards_label_every_value_and_truncate_nothing() {
let md = concat!(
"| date | company | status |\n|---|---|---|\n",
"| 2026-08-25 | Harrison Consulting | applied |\n",
);
let r = render_page(md, 30, TableStyle::Cards);
let text: String = r.lines.iter().map(|l| format!("{}\n", l.text())).collect();
assert!(text.contains("2026-08-25"), "{text}");
assert!(text.contains("company"), "{text}");
assert!(text.contains("Harrison Consulting"), "{text}");
assert!(text.contains("status"), "{text}");
assert!(text.contains("applied"), "{text}");
assert!(!text.contains("▌ date"), "{text}");
assert!(!text.contains('…'), "{text}");
}
#[test]
fn auto_leaves_a_table_that_fits_alone_and_scrolls_one_that_does_not() {
let narrow = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let grid: String = render_page(narrow, 80, TableStyle::Auto)
.lines
.iter()
.map(|l| format!("{}\n", l.text()))
.collect();
assert!(grid.contains('┼'), "{grid}");
assert!(!grid.contains('▌'), "{grid}");
let r = render_page(JOB_LOG, 60, TableStyle::Auto);
assert!(r.lines.iter().any(|l| l.wide));
let text: String = r.lines.iter().map(|l| l.text()).collect();
assert!(!text.contains('…'), "{text}");
}
#[test]
fn tables_get_aligned_columns_and_a_head_rule() {
let md = "| a | bbbb |\n| --- | ---: |\n| 1 | 2 |\n";
let r = render(md);
let rows: Vec<String> = r
.lines
.iter()
.map(|l| l.text())
.filter(|t| !t.trim().is_empty())
.collect();
assert_eq!(rows[0], "a │ bbbb");
assert_eq!(rows[1], "──┼─────");
assert_eq!(rows[2], "1 │ 2"); assert!(r.lines[0].cells[0]
.style
.add_modifier
.contains(Modifier::BOLD));
}
#[test]
fn table_columns_are_measured_in_display_columns() {
let r = render("| 漢字 | b |\n| --- | --- |\n| x | y |\n");
let rows: Vec<&PLine> = r
.lines
.iter()
.filter(|l| !l.text().trim().is_empty())
.collect();
let widths: Vec<usize> = rows.iter().map(|l| cells_width(&l.cells)).collect();
assert!(widths.windows(2).all(|w| w[0] == w[1]), "{widths:?}");
}
#[test]
fn ordered_lists_keep_their_numbers() {
let r = render("3. three\n4. four\n - sub\n5. five\n\n- plain\n");
let f = flat(&r);
assert!(f.contains("3. three\n4. four\n ◦ sub\n5. five"), "{f}");
assert!(f.contains("• plain"), "{f}");
}
#[test]
fn bullet_glyph_follows_nesting_depth() {
let r = render("- one\n - two\n - three\n - four\n - two again\n- one again\n");
let f = flat(&r);
assert!(
f.contains("• one\n ◦ two\n ▪ three\n • four\n ◦ two again\n• one again"),
"{f}"
);
}
#[test]
fn nested_task_items_keep_their_boxes() {
let r = render("- [ ] top\n - [x] nested\n - plain\n");
let f = flat(&r);
assert!(f.contains("☐ top\n ✓ nested\n ◦ plain"), "{f}");
}
#[test]
fn wrapped_list_item_hangs_under_its_text() {
let r = render_wide("1. alpha beta gamma delta epsilon zeta\n", 20);
let item = r
.lines
.iter()
.find(|l| l.text().starts_with("1. "))
.unwrap();
assert_eq!(item.hang, 3);
let rows: Vec<String> = wrap_pline(item, 20)
.iter()
.map(|c| c.iter().map(|x| x.ch).collect())
.collect();
assert!(rows.len() > 1, "{rows:?}");
assert!(rows[0].starts_with("1. alpha"), "{rows:?}");
assert!(
rows[1].starts_with(" ") && !rows[1].starts_with(" "),
"{rows:?}"
);
}
#[test]
fn rule_spans_the_page() {
let r = render_wide("a\n\n---\n\nb\n", 60);
assert!(
r.lines.iter().any(|l| l.text() == "─".repeat(60)),
"{}",
flat(&r)
);
}
#[test]
fn every_quoted_line_gets_its_bar() {
let r = render("> first line\n> second line\n\nafter\n");
let quoted: Vec<String> = r
.lines
.iter()
.map(|l| l.text())
.filter(|t| t.contains("line"))
.collect();
assert_eq!(quoted, vec!["▌ first line", "▌ second line"]);
assert!(r.lines.iter().any(|l| l.text() == "after"));
}
#[test]
fn the_rail_runs_down_blank_and_wrapped_rows_alike() {
let md = "> one two three four five six seven eight nine ten\n>\n> - alpha beta gamma delta epsilon zeta eta\n\nafter\n";
let r = render_wide(md, 24);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let quoted: Vec<&String> = rows.iter().filter(|t| t.starts_with("▌")).collect();
assert!(quoted.len() >= 5, "{rows:?}");
assert!(
quoted.iter().any(|t| t.trim() == "▌"),
"blank row keeps its bar: {rows:?}"
);
for t in "ed {
assert!(crate::md::str_width(t) <= 24, "{t:?}");
}
let bullet = rows.iter().position(|t| t.contains("• alpha")).unwrap();
assert!(
rows[bullet + 1].starts_with("▌ "),
"{:?}",
rows[bullet + 1]
);
assert!(rows.iter().any(|t| t == "after"));
let body = r
.lines
.iter()
.find(|l| l.text().contains("one two"))
.unwrap();
let word = body.cells.iter().find(|c| c.ch == 'o').unwrap();
assert_eq!(word.style, Style::default());
}
#[test]
fn footnotes_and_maths_render_on_the_page() {
let r = render_wide(
"Tea[^1] and $x$.\n\n$$\nE = mc^2\n$$\n\n[^1]: Tickles\n",
20,
);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(rows.iter().any(|t| t == "Tea¹ and x."), "{rows:?}");
assert!(
rows.iter()
.any(|t| t.trim() == "E = mc^2" && t.starts_with(" ")),
"{rows:?}"
);
assert!(rows.iter().any(|t| t == "¹ Tickles"), "{rows:?}");
assert!(
rows.iter()
.all(|t| !t.contains("[^1]") && !t.contains("$$")),
"{rows:?}"
);
}
#[test]
fn inline_footnotes_render_numbered_with_their_definitions() {
let md = "Tea[^1] and^[Milk too] then^[Sugar].\n\n[^1]: Black\n";
let r = render_wide(md, 40);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(rows.iter().any(|t| t == "Tea¹ and² then³."), "{rows:?}");
assert!(rows.iter().any(|t| t == "¹ Black"), "{rows:?}");
assert!(rows.iter().any(|t| t == "² Milk too"), "{rows:?}");
assert!(rows.iter().any(|t| t == "³ Sugar"), "{rows:?}");
assert!(
rows.iter().all(|t| !t.contains('~') && !t.contains("^[")),
"{rows:?}"
);
let at = |s: &str| rows.iter().position(|t| t == s).unwrap();
assert!(at("¹ Black") < at("² Milk too") && at("² Milk too") < at("³ Sugar"));
}
#[test]
fn an_inline_footnote_maps_back_to_the_line_it_was_written_on() {
let md = "Tea[^1] and^[Milk too] then^[Sugar].\n\n[^1]: Black\n";
let r = render_page_at(md, 5, 40, TableStyle::default());
let first = r
.lines
.iter()
.find(|l| l.text().starts_with("Tea"))
.unwrap();
let t = first.cells.iter().find(|c| c.ch == 't').unwrap();
assert_eq!(t.src, Some((5, 23)));
let milk = r.lines.iter().find(|l| l.text() == "² Milk too").unwrap();
assert_eq!(milk.src_line, Some(5));
let m = milk.cells.iter().find(|c| c.ch == 'M').unwrap();
assert_eq!(m.src, Some((5, 13)));
let sugar = r.lines.iter().find(|l| l.text() == "³ Sugar").unwrap();
assert_eq!(sugar.src_line, Some(5));
let black = r.lines.iter().find(|l| l.text() == "¹ Black").unwrap();
assert_eq!(black.src_line, Some(7));
}
#[test]
fn escaped_and_commented_footnotes_are_not_numbered() {
let r = render("\\^[lit] B^[two]\n");
assert_eq!(r.lines[0].text(), "^[lit] B¹");
let r = render("%% ^[hidden] %%\nA^[one]\n");
assert_eq!(r.lines[0].text(), "A¹");
}
#[test]
fn a_footnote_in_a_callout_title_is_a_superscript() {
let r = render_wide("> [!note] T^[c1]\n> body\n", 40);
let top = r.lines[0].text();
assert!(top.contains("T¹"), "{top:?}");
assert!(!top.contains("[^"), "{top:?}");
}
#[test]
fn inline_footnotes_count_with_word_labels_and_skip_fences() {
let r = render("x[^note] y^[z]\n\n[^note]: def\n");
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(rows.iter().any(|t| t == "x^note y²"), "{rows:?}");
assert!(rows.iter().any(|t| t == "² z"), "{rows:?}");
assert!(rows.iter().any(|t| t == "^note def"), "{rows:?}");
let r = render("```\n^[not a note]\n```\n");
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(rows.iter().any(|t| t.contains("^[not a note]")), "{rows:?}");
assert!(rows.iter().all(|t| !t.contains('¹')), "{rows:?}");
let (out, notes) = inline_footnotes("plain [^1]\n\n[^1]: x\n");
assert_eq!(out, "plain [^1]\n\n[^1]: x\n");
assert!(notes.is_empty());
}
#[test]
fn the_rewrite_keeps_the_line_and_names_the_text_it_moved() {
let (out, notes) = inline_footnotes("a^[bc] d");
assert_eq!(out, "a[^~1] d\n\n[^~1]: bc\n");
assert_eq!(notes.len(), 1);
let n = ¬es[0];
assert_eq!(
(n.label.as_str(), n.ordinal, n.orig, n.len),
("~1", 1, 3, 2)
);
assert_eq!(&out[n.def..n.def + n.len], "bc");
assert_eq!(n.tail_len, "\n[^~1]: bc\n".len());
let (out, _) = inline_footnotes("^[four] tail\n");
assert_eq!(out, "[^~1~~] tail\n\n[^~1~~]: four\n");
}
#[test]
fn a_callout_becomes_a_box_the_width_of_the_page() {
let md = "> [!summary] TL;DR\n> **Situation:**\n>\n> - At Airstream, during the MY22 launch, the connected vehicle platform.\n\nafter\n";
let w = 40;
let r = render_wide(md, w);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let top = rows.iter().find(|t| t.starts_with('╭')).expect("top edge");
let bottom = rows
.iter()
.find(|t| t.starts_with('╰'))
.expect("bottom edge");
assert_eq!(crate::md::str_width(top), w, "{top:?}");
assert_eq!(crate::md::str_width(bottom), w, "{bottom:?}");
assert!(top.ends_with('╮') && bottom.ends_with('╯'));
assert!(top.contains("≡ summary · TL;DR"), "{top:?}");
let ti = rows.iter().position(|t| t.starts_with('╭')).unwrap();
let bi = rows.iter().position(|t| t.starts_with('╰')).unwrap();
assert!(bi > ti + 2);
for t in &rows[ti + 1..bi] {
assert!(t.starts_with('│') && t.ends_with('│'), "{t:?}");
assert_eq!(crate::md::str_width(t), w, "{t:?}");
}
assert!(rows.iter().all(|t| !t.contains("[!summary]")), "{rows:?}");
assert!(rows.iter().any(|t| t.contains("Situation:")));
assert!(rows[ti + 1..bi]
.iter()
.any(|t| t.trim_matches(|c| c == '│' || c == ' ').is_empty()));
assert!(
rows[ti + 1..bi]
.iter()
.filter(|t| t.contains("Airstream") || t.contains("platform"))
.count()
>= 2
);
let sit = r
.lines
.iter()
.find(|l| l.text().contains("Situation"))
.unwrap();
assert_eq!(
sit.cells.iter().find(|c| c.ch == 'S').unwrap().src,
Some((1, 4))
);
assert!(rows.iter().any(|t| t == "after"));
}
#[test]
fn a_callout_without_a_title_and_with_a_fold_marker_still_boxes() {
let r = render_wide("> [!tip]- \n> body\n", 30);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let top = rows.iter().find(|t| t.starts_with('╭')).unwrap();
assert!(top.contains("✓ tip ─"), "{top:?}");
assert!(!top.contains('·'));
assert!(rows.iter().any(|t| t.starts_with("│ body")));
}
#[test]
fn a_callout_inside_a_callout_is_a_nested_card() {
let md = "> [!note] Outer\n> a\n>\n> > [!tip] Inner\n> > b\n>\n> c\n";
let w = 40;
let r = render_wide(md, w);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let outer = rows
.iter()
.position(|t| t.starts_with("╭─ i note · Outer"))
.expect("outer top");
let inner = rows
.iter()
.position(|t| t.starts_with("│ ╭─ ✓ tip · Inner"))
.expect("inner top");
assert!(inner > outer);
assert!(rows[inner].ends_with("╮ │"), "{:?}", rows[inner]);
let b = rows.iter().find(|t| t.contains(" b ")).expect("inner body");
assert!(b.starts_with("│ │ b") && b.ends_with("│ │"), "{b:?}");
let inner_close = rows
.iter()
.position(|t| t.starts_with("│ ╰"))
.expect("inner bottom");
assert!(
inner_close > inner && rows[inner_close].ends_with("╯ │"),
"{:?}",
rows[inner_close]
);
let c = rows
.iter()
.position(|t| t.starts_with("│ c"))
.expect("outer body after");
assert!(c > inner_close);
let outer_close = rows.iter().rposition(|t| t.starts_with('╰')).unwrap();
assert!(outer_close > c);
for t in &rows[outer..=outer_close] {
assert_eq!(crate::md::str_width(t), w, "{t:?}");
}
assert_eq!(r.lines[inner_close].src_line, Some(3));
assert_eq!(r.lines[outer_close].src_line, Some(0));
}
#[test]
fn a_foldable_callout_shows_its_state_in_the_reading_view() {
let md = "> [!tip]- Go\n> a\n> b\n\nafter\n";
let open = folded(md, &[], 30);
let rows: Vec<String> = open.lines.iter().map(|l| l.text()).collect();
assert!(
rows.iter().any(|t| t.starts_with("╭─ ▾ ✓ tip · Go")),
"{rows:?}"
);
let r = folded(md, &[0], 30);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let top = rows.iter().position(|t| t.starts_with('╭')).expect("top");
assert_eq!(rows[top], "╭─ ▸ ✓ tip · Go ─── 2 lines ─╮");
assert!(rows[top + 1].starts_with('╰'), "{rows:?}");
assert!(
rows.iter()
.all(|t| !t.contains(" a ") && !t.contains(" b ")),
"{rows:?}"
);
assert!(rows.iter().any(|t| t == "after"));
let r = folded("> [!tip] Go\n> a\n", &[0], 30);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let top = rows.iter().find(|t| t.starts_with('╭')).unwrap();
assert_eq!(top, "╭─ ▸ ✓ tip · Go ──── 1 line ─╮");
}
#[test]
fn a_folded_callout_with_a_paragraph_break_closes_right_under_its_title() {
let md = "> [!note]- O\n> a\n>\n> b\n\nafter\n";
let r = folded(md, &[0], 40);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let top = rows.iter().position(|t| t.starts_with('╭')).expect("top");
assert!(rows[top + 1].starts_with('╰'), "{rows:?}");
for t in &rows {
assert!(crate::md::str_width(t) <= 40, "{t:?}");
}
let md = "> [!note] O\n> a\n>\n> > [!tip] I\n> > b\n>\n> d\n";
let r = folded(md, &[0], 40);
let rows: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(rows[1].starts_with('╰'), "{rows:?}");
assert_eq!(rows.len(), 2, "{rows:?}");
}
#[test]
fn highlight_gets_the_highlight_style() {
let r = render("a ==wow== b");
let line = r.lines.iter().find(|l| l.text().contains("wow")).unwrap();
assert_eq!(line.text(), "a wow b");
let cell = line.cells.iter().find(|c| c.ch == 'w').unwrap();
assert_eq!(cell.style.bg, theme::highlight().bg);
}
#[test]
fn kbd_reads_as_inline_code_and_its_tags_vanish() {
let r = render("Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy.");
assert_eq!(r.lines[0].text(), "Press Ctrl+C to copy.");
let line = &r.lines[0];
let t = line.cells.iter().find(|c| c.ch == 't').unwrap();
assert_eq!(t.style.fg, theme::inline_code().fg);
assert_eq!(t.src, Some((0, 12)));
let plus = line.cells.iter().find(|c| c.ch == '+').unwrap();
assert_eq!(plus.style.fg, None);
let r = render("| a | b |\n|---|---|\n| <kbd>x</kbd> | y |\n");
let f = flat(&r);
assert!(f.contains('x') && !f.contains('<'), "{f:?}");
}
#[test]
fn sub_and_sup_take_their_unicode_forms() {
let r = render("H<sub>2</sub>O is x<sup>2</sup> or x<sup>(n+1)</sup>");
assert_eq!(r.lines[0].text(), "H₂O is x² or x⁽n⁺¹⁾");
let two = r.lines[0].cells.iter().find(|c| c.ch == '₂').unwrap();
assert_eq!(two.src, Some((0, 6)));
let o = r.lines[0].cells.iter().find(|c| c.ch == 'O').unwrap();
assert_eq!(o.src, Some((0, 13)));
}
#[test]
fn u_underlines_and_mark_highlights_in_the_page() {
let r = render("<u>under</u> and <mark>lit</mark> and **<u>both</u>**");
assert_eq!(r.lines[0].text(), "under and lit and both");
let cells = &r.lines[0].cells;
let u = cells.iter().find(|c| c.ch == 'r').unwrap();
assert!(u.style.add_modifier.contains(Modifier::UNDERLINED));
let m = cells.iter().find(|c| c.ch == 'l').unwrap();
assert_eq!(m.style.bg, theme::highlight().bg);
let b = cells.iter().find(|c| c.ch == 'h').unwrap();
assert!(b.style.add_modifier.contains(Modifier::UNDERLINED));
assert!(b.style.add_modifier.contains(Modifier::BOLD));
let a = cells.iter().find(|c| c.ch == 'a').unwrap();
assert_eq!(a.style, Style::default());
}
#[test]
fn br_breaks_the_line_the_way_a_hard_break_does() {
let r = render("one<br>two<br/>three");
assert_eq!(texts(&r), vec!["one", "two", "three"]);
let r = render("| a |\n|---|\n| x<br>y |\n");
assert!(flat(&r).contains("x y"), "{:?}", flat(&r));
}
#[test]
fn comments_are_dropped_inline_and_as_blocks() {
let md =
"a <!-- hush --> b\n\n<!-- block\nstill hidden -->\n\nafter <!-- multi\nline --> end\n";
let r = render(md);
let f = flat(&r);
assert!(
!f.contains("hush") && !f.contains("hidden") && !f.contains("line"),
"{f:?}"
);
assert!(f.contains("a b"), "{f:?}");
assert!(f.contains("after end"), "{f:?}");
assert!(!f.contains("<!--") && !f.contains("-->"), "{f:?}");
}
#[test]
fn unknown_tags_are_stripped_and_their_text_kept() {
let r = render(
"<div class=\"x\">\nhello <b>there</b>\n</div>\n\n<span>after</span> <i>x</i>\n",
);
let t = texts(&r);
assert!(t.iter().any(|l| l == "hello there"), "{t:?}");
assert!(t.iter().any(|l| l == "after x"), "{t:?}");
assert!(t.iter().all(|l| !l.contains('<')), "{t:?}");
let hello = r.lines.iter().find(|l| l.text() == "hello there").unwrap();
assert_eq!(hello.src_line, Some(1));
assert_eq!(hello.cells[0].src, Some((1, 0)));
}
#[test]
fn an_unclosed_tag_does_not_leak_past_its_paragraph() {
let r = render("<u>open\n\nnext\n\n- <mark>item\n- other\n");
let next = r.lines.iter().find(|l| l.text() == "next").unwrap();
assert!(!next.cells[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED));
let other = r.lines.iter().find(|l| l.text().contains("other")).unwrap();
let o = other.cells.iter().find(|c| c.ch == 'h').unwrap();
assert_eq!(o.style.bg, None);
let r = render("a < b and c > d");
assert_eq!(r.lines[0].text(), "a < b and c > d");
}
#[test]
fn checkboxes_render_and_remember_their_source_line() {
let r = render("# t\n\n- [ ] todo\n- [x] done\n");
let todo = r.lines.iter().find(|l| l.text().contains("todo")).unwrap();
assert_eq!(todo.text(), "☐ todo");
assert_eq!(todo.checkbox, Some(2));
let done = r.lines.iter().find(|l| l.text().contains("done")).unwrap();
assert_eq!(done.text(), "✓ done");
assert_eq!(done.checkbox, Some(3));
assert!(done.cells[0].style.fg == theme::done().fg);
}
#[test]
fn numbered_tasks_keep_their_number_in_front_of_the_box() {
let r = render("1. [ ] todo\n2. [x] done\n");
let todo = r.lines.iter().find(|l| l.text().contains("todo")).unwrap();
assert_eq!(todo.text(), "1. ☐ todo");
assert_eq!(todo.checkbox, Some(0));
let done = r.lines.iter().find(|l| l.text().contains("done")).unwrap();
assert_eq!(done.text(), "2. ✓ done");
assert_eq!(done.checkbox, Some(1));
let d = done.cells.iter().find(|c| c.ch == 'd').unwrap();
assert!(d.style.add_modifier.contains(Modifier::CROSSED_OUT));
assert_eq!(todo.hang, 5);
let plain = render("- [ ] todo\n");
assert_eq!(plain.lines[0].hang, 2);
}
#[test]
fn the_other_task_states_render_as_glyphs_with_a_clickable_box() {
let r = render(
"- [/] going\n- [-] dropped\n- [>] later\n- [?] maybe **x**\n- [z] plain\n1. [/] first\n\n- [/] loose\n\n- b\n",
);
let line = |s: &str| r.lines.iter().find(|l| l.text().contains(s)).unwrap();
let going = line("going");
assert_eq!(going.text(), format!("{} going", theme::IN_PROGRESS));
assert_eq!(going.checkbox, Some(0));
assert_eq!(going.cells[0].style.fg, theme::done().fg);
assert_eq!(going.cells[2].src, Some((0, 6)));
let dropped = line("dropped");
assert_eq!(dropped.text(), format!("{} dropped", theme::CANCELLED));
assert_eq!(dropped.checkbox, Some(1));
assert_eq!(dropped.cells[0].style.fg, theme::marker().fg);
let d = dropped.cells.iter().find(|c| c.ch == 'd').unwrap();
assert!(d.style.add_modifier.contains(Modifier::CROSSED_OUT));
let later = line("later");
assert_eq!(later.text(), format!("{} later", theme::FORWARDED));
assert_eq!(later.cells[0].style.fg, theme::forwarded().fg);
let struck = |l: &PLine| {
l.cells
.iter()
.any(|c| c.style.add_modifier.contains(Modifier::CROSSED_OUT))
};
assert!(!struck(later));
let maybe = line("maybe");
assert_eq!(maybe.text(), format!("{} maybe x", theme::QUESTION));
let x = maybe.cells.last().unwrap();
assert!(x.style.add_modifier.contains(Modifier::BOLD));
let plain = line("plain");
assert_eq!(plain.text(), format!("{} [z] plain", theme::BULLET));
assert_eq!(plain.checkbox, None);
let first = line("first");
assert_eq!(first.text(), format!("1. {} first", theme::IN_PROGRESS));
assert_eq!(first.checkbox, Some(5));
let loose = line("loose");
assert_eq!(loose.text(), format!("{} loose", theme::IN_PROGRESS));
assert_eq!(loose.checkbox, Some(7));
let bullet = format!("{} b", theme::BULLET);
assert!(r.lines.iter().any(|l| l.text() == bullet));
assert_eq!(loose.hang, 2);
}
fn body_of(content: &str) -> Rendered {
let (skip, first) = crate::notes::front_matter_range(content)
.map_or((0, 0), |r| (r.end, content[..r.end].lines().count()));
render_page_at(&content[skip..], first, usize::MAX, TableStyle::default())
}
#[test]
fn the_properties_box_folds_to_a_line_or_to_nothing() {
use crate::config::Properties;
let content = "---\ntitle: Launch\ndue: 2026-10-10\ntags: [work]\n---\n# Title\n";
let mut r = body_of(content);
prepend_properties(&mut r, content, 40, (2026, 9, 5), Properties::Box);
let top = &r.lines[0];
assert!(top.text().ends_with(" hide ┐"), "{:?}", top.text());
assert_eq!(top.text().chars().count(), 40);
let link = top.cells[0].link.expect("edge is clickable");
assert_eq!(r.url(link), Some(PROPERTIES_HREF));
assert!(top.cells.iter().all(|c| c.link == Some(link)));
let mut r = body_of(content);
prepend_properties(&mut r, content, 40, (2026, 9, 5), Properties::Line);
assert_eq!(r.lines[0].text(), "▸ 3 properties");
assert_eq!(r.lines[1].text(), "");
assert_eq!(r.lines[2].text(), "Title");
let link = r.lines[0].cells[0].link.expect("line is clickable");
assert_eq!(r.url(link), Some(PROPERTIES_HREF));
let mut r = body_of(content);
let before = r.lines.len();
prepend_properties(&mut r, content, 40, (2026, 9, 5), Properties::Hide);
assert_eq!(r.lines.len(), before);
assert_eq!(r.lines[0].text(), "Title");
}
#[test]
fn front_matter_is_drawn_as_a_box_of_properties_above_the_body() {
let content = "---\ntitle: Launch\ndue: 2026-10-10\ntags: [work, q3]\naliases:\n - launch\n - go live\n---\n# Title\n";
let mut r = body_of(content);
prepend_properties(
&mut r,
content,
60,
(2026, 9, 5),
crate::config::Properties::Box,
);
let page: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(page[0].starts_with("┌ properties ─"));
assert!(page[0].ends_with('┐'));
assert_eq!(page[0].chars().count(), 60);
assert!(page[1].starts_with("│ title Launch"));
assert!(page[1].ends_with('│'));
assert!(page[2].contains("due 2026-10-10 in 5 weeks"));
assert!(page[3].contains("tags #work #q3"));
assert!(page[4].contains("aliases launch · go live"));
assert!(page[5].starts_with('└') && page[5].ends_with('┘'));
assert_eq!(page[6], "");
assert!(page[7].contains("Title"));
assert_eq!(r.lines[0].src_line, None);
assert_eq!(r.lines[1].src_line, Some(1));
assert_eq!(r.lines[4].src_line, Some(4));
let tag = r.lines[3].cells.iter().find(|c| c.ch == '#').unwrap();
assert_eq!(
crate::md::LinkTarget::parse(r.url(tag.link.unwrap()).unwrap()),
crate::md::LinkTarget::Tag("work".to_string())
);
assert_eq!(tag.style.fg, theme::tag().fg);
let mut plain = body_of("# Title\n");
prepend_properties(
&mut plain,
"# Title\n",
60,
(2026, 9, 5),
crate::config::Properties::Box,
);
assert!(plain.lines[0].text().contains("Title"));
}
#[test]
fn a_page_rendered_from_a_slice_still_reports_file_line_numbers() {
let r = render_page_at("# Title\n\nprose\n", 4, usize::MAX, TableStyle::default());
let title = r.lines.iter().find(|l| l.text().contains("Title")).unwrap();
assert_eq!(title.src_line, Some(4));
assert_eq!(title.cells[0].src, Some((4, 2)));
let prose = r.lines.iter().find(|l| l.text().contains("prose")).unwrap();
assert_eq!(prose.src_line, Some(6));
assert_eq!(prose.cells[0].src, Some((6, 0)));
}
#[test]
fn the_reading_view_renders_the_body_and_never_the_front_matter() {
let r = body_of("---\ntype: log\ntags: work\n---\n\n# Title\n\nprose\n");
let page: String = r
.lines
.iter()
.map(|l| l.text())
.collect::<Vec<_>>()
.join("\n");
assert!(!page.contains("type: log"));
assert!(!page.contains("tags"));
assert!(!page.contains("---"));
assert!(page.contains("Title"));
assert!(page.contains("prose"));
let plain = body_of("# Title\n");
assert_eq!(plain.lines[0].src_line, Some(0));
}
#[test]
fn a_checkbox_under_front_matter_still_maps_to_its_own_source_line() {
let r = body_of("---\ntype: log\n---\n\n- [ ] todo\n- [x] done\n");
let todo = r.lines.iter().find(|l| l.text().contains("todo")).unwrap();
assert_eq!(todo.checkbox, Some(4));
let done = r.lines.iter().find(|l| l.text().contains("done")).unwrap();
assert_eq!(done.checkbox, Some(5));
assert_eq!(done.cells[2].src, Some((5, 6)));
}
#[test]
fn links_and_bare_urls_are_recorded() {
let r = render("see [docs](http://x.y) and https://z.example/p now");
let line = r.lines.iter().find(|l| l.text().contains("docs")).unwrap();
let docs = line.cells.iter().find(|c| c.ch == 'd').unwrap();
assert_eq!(r.url(docs.link.unwrap()), Some("http://x.y"));
let bare = line
.cells
.iter()
.find(|c| c.link.map(|i| r.urls[i].starts_with("https://z")) == Some(true))
.unwrap();
assert_eq!(r.url(bare.link.unwrap()), Some("https://z.example/p"));
assert!(line.text().contains("https://z.example/p"));
}
#[test]
fn a_tag_is_drawn_in_the_accent_and_records_a_tag_target() {
crate::md::tags::set_enabled(true);
let r = render("see #work now\n");
assert_eq!(flat(&r).trim(), "see #work now");
let cell = r.lines[0].cells.iter().find(|c| c.link.is_some()).unwrap();
assert_eq!(cell.ch, '#');
assert_eq!(cell.style.fg, theme::tag().fg);
assert_eq!(r.url(cell.link.unwrap()), Some("tag:work"));
}
#[test]
fn a_tag_in_code_a_heading_marker_or_a_url_is_not_recorded() {
crate::md::tags::set_enabled(true);
let r = render("# Title\n\n`#code` and https://x.y/#frag and x#y\n\n```\n#fence\n```\n");
assert!(
r.urls.iter().all(|u| !u.starts_with("tag:")),
"{:?}",
r.urls
);
let r = render("`x`#glued\n");
assert!(r.urls.is_empty());
let r = render("`x` #free\n");
assert_eq!(r.urls, vec!["tag:free"]);
}
#[test]
fn a_wikilink_renders_as_its_text_and_records_a_wikilink_target() {
let r = render("see [[note]] now\n");
assert_eq!(flat(&r).trim(), "see note now");
let cell = r.lines[0].cells.iter().find(|c| c.link.is_some()).unwrap();
assert_eq!(r.url(cell.link.unwrap()), Some("wikilink:note"));
let r = render("[[stories/story-matrix#Method|the matrix]]\n");
assert_eq!(flat(&r).trim(), "the matrix");
assert_eq!(r.url(0), Some("wikilink:stories/story-matrix#Method"));
}
#[test]
fn a_heading_link_reads_note_chevron_heading_and_keeps_its_source_columns() {
let r = render("see [[note#Method]] now\n");
assert_eq!(flat(&r).trim(), "see note › Method now");
assert_eq!(r.url(0), Some("wikilink:note#Method"));
let cells = &r.lines[0].cells;
let n = cells
.iter()
.position(|c| c.ch == 'n' && c.link.is_some())
.unwrap();
assert_eq!(cells[n].src, Some((0, 6)));
let chevron = cells.iter().find(|c| c.ch == '›').unwrap();
assert_eq!(chevron.src, None);
assert!(chevron.link.is_some());
let m = cells.iter().position(|c| c.ch == 'M').unwrap();
assert_eq!(cells[m].src, Some((0, 11)));
assert_eq!(flat(&render("[[note#^abc]]\n")).trim(), "note › ^abc");
let r = render("[[#Method]]\n");
assert_eq!(flat(&r).trim(), "› Method");
assert_eq!(r.url(0), Some("wikilink:#Method"));
}
#[test]
fn a_trailing_block_id_is_not_drawn_on_the_page() {
let r = render("a paragraph ^abc\n\n- item ^def-1\n\n# Title ^h1\n");
let text = flat(&r);
assert!(!text.contains('^'), "{text}");
assert!(text.contains("a paragraph"));
assert!(text.contains("Title"));
assert_eq!(blank_block_ids("x ^abc\ny\n"), "x \ny\n");
assert_eq!(blank_block_ids("^abc\n"), " \n");
assert_eq!(blank_block_ids("```\nx ^abc\n```\n"), "```\nx ^abc\n```\n");
let r = render("```\ncode ^abc\n```\n");
assert!(flat(&r).contains("code ^abc"));
let r = render("first\n\nsecond ^abc\n");
let line = r
.lines
.iter()
.find(|l| l.text().contains("second"))
.unwrap();
assert_eq!(line.src_line, Some(2));
}
#[test]
fn brackets_pulldown_hands_back_one_at_a_time_are_not_drawn_twice() {
let r = render("see [[note]] and [[a|b]] here\n");
let text = flat(&r);
assert_eq!(text.trim(), "see note and b here");
assert!(!text.contains(']'), "{text}");
assert_eq!(text.matches("here").count(), 1);
}
#[test]
fn nothing_else_pulldown_finds_inside_a_wikilink_is_drawn_after_it_either() {
let r = render("[[a `b` c]] tail\n");
assert_eq!(flat(&r).trim(), "a `b` c tail");
}
#[test]
fn a_href_in_the_note_can_never_claim_the_scheme_the_app_uses_for_a_file() {
let r = render("[report](note:/etc/passwd)\n");
assert_eq!(
crate::md::LinkTarget::parse(r.url(0).unwrap()),
crate::md::LinkTarget::Url("note:/etc/passwd".to_string())
);
let r = render("<https://x.y/a>\n");
assert_eq!(
crate::md::LinkTarget::parse(r.url(0).unwrap()),
crate::md::LinkTarget::Url("https://x.y/a".to_string())
);
}
#[test]
fn a_wikilink_in_a_table_cell_is_still_a_link() {
let r = render_wide("| a | b |\n| - | - |\n| [[note]] | x |\n", 40);
let row = r
.lines
.iter()
.find(|l| l.text().contains("note"))
.expect("the cell is drawn");
assert!(row.cells.iter().any(|c| c.link.is_some()));
assert!(!row.text().contains("[["), "{}", row.text());
assert_eq!(r.urls.iter().filter(|u| *u == "wikilink:note").count(), 1);
}
#[test]
fn a_wikilink_in_a_list_item_is_still_a_link() {
let r = render("- see [[note]]\n- and [[other]]\n");
let linked: Vec<String> = r
.lines
.iter()
.filter(|l| l.cells.iter().any(|c| c.link.is_some()))
.map(|l| l.text())
.collect();
assert_eq!(linked.len(), 2, "{linked:?}");
assert_eq!(r.urls, vec!["wikilink:note", "wikilink:other"]);
}
#[test]
fn an_escaped_or_embedded_wikilink_is_left_as_text() {
let r = render("\\[[x]] and ![[y.png]]\n");
let text = flat(&r);
assert!(text.contains("[[x]]"), "{text}");
assert!(text.contains("[[y.png]]"), "{text}");
assert!(r.urls.is_empty(), "{:?}", r.urls);
}
#[test]
fn a_wikilink_cell_remembers_the_source_column_of_its_label() {
let r = render("see [[note|label]] now\n");
let cell = r.lines[0].cells.iter().find(|c| c.link.is_some()).unwrap();
assert_eq!(cell.ch, 'l');
assert_eq!(cell.src, Some((0, "see [[note|".len())));
}
#[test]
fn an_attachment_embed_is_a_card_for_the_desktop() {
let r = render("![[report.pdf|the report]]\n");
let line = r.lines.iter().find(|l| l.text().contains("📎")).unwrap();
assert_eq!(line.text(), "▌ 📎 the report (no such file)");
assert_eq!(
crate::md::LinkTarget::parse(r.url(0).unwrap()),
crate::md::LinkTarget::File("report.pdf".into())
);
let r = render("see [[board.canvas]] now\n");
assert_eq!(
crate::md::LinkTarget::parse(r.url(0).unwrap()),
crate::md::LinkTarget::File("board.canvas".into())
);
}
#[test]
fn an_obsidian_embed_is_an_image_in_the_reading_view() {
let r = render("before\n\n![[attachments/hero.jpg|the hero]]\n\nafter\n");
let line = r.lines.iter().find(|l| l.image.is_some()).unwrap();
assert_eq!(
r.images[line.image.unwrap()],
ImageSpec {
alt: "the hero".into(),
url: "attachments/hero.jpg".into(),
width: None,
}
);
assert_eq!(line.text(), "🖼 the hero (attachments/hero.jpg)");
let all: String = r
.lines
.iter()
.map(|l| l.text())
.collect::<Vec<_>>()
.join("|");
assert!(!all.contains("]]") && !all.contains("![["), "{all}");
assert!(all.contains("after"));
let r = render("![[plan]]\n");
assert!(r.lines.iter().all(|l| l.image.is_none()));
assert!(r
.lines
.iter()
.any(|l| l.text().to_lowercase().contains("▌ plan")));
}
#[test]
fn a_note_embed_is_a_card_in_the_reading_view() {
let _turn = crate::md::embeds::turn();
let dir = crate::testutil::tmpdir("render", "embed-card");
crate::testutil::write(
&dir,
"plan.md",
"# Plan\n\nFirst **line**.\n\n## Goals\n- ship it\n- test it\n- doc it\n- more\n\n## Later\nNothing.\n",
);
crate::md::embeds::install_dir(&dir);
let r = render("before\n\n![[plan#Goals]]\n\nafter\n");
let texts: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
let at = texts
.iter()
.position(|t| t == "▌ Plan › Goals")
.expect("a title row");
assert_eq!(
&texts[at..at + 5],
&[
"▌ Plan › Goals",
"▌ - ship it",
"▌ - test it",
"▌ - doc it",
"▌ 1 more line"
]
);
let title = &r.lines[at];
assert_eq!(title.cells[0].style.fg, theme::callout("note").fg);
let linked = title.cells.iter().find(|c| c.link.is_some()).unwrap();
assert_eq!(r.url(linked.link.unwrap()), Some("wikilink:plan"));
assert!(linked.style.add_modifier.contains(Modifier::BOLD));
let r = render("![[plan]]\n");
let texts: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert_eq!(texts[0], "▌ Plan");
assert_eq!(texts[1], "▌ First line.");
assert!(r.lines[1].cells[8]
.style
.add_modifier
.contains(Modifier::BOLD));
assert!(texts.iter().all(|t| !t.contains("]]")), "{texts:?}");
assert_eq!(texts.last().unwrap(), "▌ 5 more lines");
let r = render("![[gone]]\n");
assert_eq!(r.lines[0].text(), "▌ gone (no such note)");
assert_eq!(r.lines[0].cells[2].style.fg, theme::grey().fg);
assert_eq!(r.lines.len(), 1);
crate::md::embeds::forget();
}
#[test]
fn an_embedded_note_in_a_sentence_is_a_link_in_the_reading_view() {
let r = render("see ![[plan#Goals]] and ![[plan|the plan]] now\n");
assert_eq!(flat(&r).trim(), "see plan › Goals and the plan now");
assert_eq!(r.urls, vec!["wikilink:plan", "wikilink:plan"]);
let cell = r.lines[0].cells.iter().find(|c| c.link.is_some()).unwrap();
assert_eq!(cell.ch, 'p');
assert_eq!(cell.src, Some((0, "see ![[".len())));
let r = render("see ![[y.png]] now\n");
assert!(flat(&r).contains("![[y.png]]"));
assert!(r.urls.is_empty());
}
#[test]
fn images_become_their_own_line() {
let r = render("\n");
let line = r.lines.iter().find(|l| l.image.is_some()).unwrap();
assert_eq!(line.text(), "🖼 a cat (cat.png)");
assert_eq!(
r.images[line.image.unwrap()],
ImageSpec {
alt: "a cat".into(),
url: "cat.png".into(),
width: None,
}
);
}
fn mention(name: &str, excerpt: &str, count: usize) -> crate::mentions::Mention {
let link = crate::md::wikilinks(excerpt)
.first()
.map(|w| (w.start, w.end))
.unwrap_or((0, 0));
crate::mentions::Mention {
path: std::path::PathBuf::from(format!("/vault/{name}.md")),
name: name.to_string(),
excerpt: excerpt.to_string(),
link,
count,
linked: true,
}
}
fn unlinked(name: &str, excerpt: &str, word: &str) -> crate::mentions::Mention {
let at = excerpt
.find(word)
.map(|b| excerpt[..b].chars().count())
.unwrap_or(0);
let mut m = mention(name, excerpt, 1);
m.link = (at, at + word.chars().count());
m.linked = false;
m
}
#[test]
fn unlinked_mentions_are_a_second_section_with_the_matched_words_undimmed() {
let mut r = render("# Spec\n");
append_mentions(
&mut r,
&[
mention("meta", "about [[spec]]", 1),
unlinked("plan", "the spec says so", "spec"),
],
60,
);
let text: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(text.iter().any(|t| t == "1 note links here"));
assert!(text.iter().any(|t| t == "mentioned in 1 note"), "{text:?}");
let row = footer_row(&r, "plan");
let p = row.cells.iter().find(|c| c.ch == 'p').unwrap();
assert_eq!(r.url(p.link.unwrap()), Some("note:/vault/plan.md"));
let t = row.cells.iter().find(|c| c.ch == 't').unwrap();
assert_eq!(t.style, theme::marker());
let s = row
.cells
.iter()
.find(|c| c.ch == 's' && c.link.is_none())
.unwrap();
assert_eq!(s.style, theme::PLAIN);
let mut r = render("# Spec\n");
append_mentions(&mut r, &[unlinked("plan", "the spec says so", "spec")], 60);
let text: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(!text.iter().any(|t| t.contains("link here")));
assert!(text.iter().any(|t| t == "mentioned in 1 note"));
}
#[test]
fn the_unlinked_section_stops_at_twenty_rows_and_counts_the_rest() {
let mut r = render("# Spec\n");
let rows: Vec<_> = (0..25)
.map(|i| unlinked(&format!("n{i:02}"), "the spec", "spec"))
.collect();
append_mentions(&mut r, &rows, 60);
let text: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(text.iter().any(|t| t == "mentioned in 25 notes"));
assert!(text.iter().any(|t| t.starts_with(" n19")));
assert!(!text.iter().any(|t| t.starts_with(" n20")));
assert_eq!(text.last().unwrap(), " 5 more");
}
fn footer_row<'a>(r: &'a Rendered, name: &str) -> &'a PLine {
r.lines
.iter()
.find(|l| l.text().starts_with(&format!(" {name}")))
.unwrap()
}
#[test]
fn no_mentions_means_no_footer_line_at_all() {
let mut r = render("# Spec\n\nbody\n");
let before = r.lines.len();
append_mentions(&mut r, &[], 60);
assert_eq!(r.lines.len(), before);
assert!(!r.lines.iter().any(|l| l.text().contains("link here")));
}
#[test]
fn the_footer_names_each_note_once_and_counts_the_rest() {
let mut r = render("# Spec\n");
append_mentions(
&mut r,
&[
mention("meta-os-control", "…see [[spec]] for the", 3),
mention("ford-mvp", "…pulled from [[spec]]", 1),
],
60,
);
let text: Vec<String> = r.lines.iter().map(|l| l.text()).collect();
assert!(text.iter().any(|t| t == "2 notes link here"));
let first = text.iter().find(|t| t.contains("meta-os-control")).unwrap();
assert!(first.contains("…see spec for the"));
assert!(first.ends_with(" ×3"));
let second = text.iter().find(|t| t.contains("ford-mvp")).unwrap();
assert!(!second.contains('×'));
let mut one = render("# Spec\n");
append_mentions(&mut one, &[mention("meta", "x", 1)], 60);
assert!(one.lines.iter().any(|l| l.text() == "1 note links here"));
}
#[test]
fn every_footer_row_is_a_link_to_the_note_that_mentions_this_one() {
let mut r = render("# Spec\n");
append_mentions(&mut r, &[mention("meta", "about [[spec]]", 1)], 60);
let row = r.lines.iter().find(|l| l.text().contains("meta")).unwrap();
let link = row
.cells
.iter()
.find(|c| c.ch == 'm')
.unwrap()
.link
.unwrap();
assert_eq!(r.url(link), Some("note:/vault/meta.md"));
assert_eq!(
crate::md::LinkTarget::parse(r.url(link).unwrap()),
crate::md::LinkTarget::Note("/vault/meta.md".to_string())
);
assert!(row
.cells
.iter()
.filter(|c| c.link.is_some())
.all(|c| "meta".contains(c.ch)));
}
fn folded(md: &str, heads: &[usize], width: usize) -> Rendered {
let lines: Vec<String> = md.lines().map(String::from).collect();
let blocks = crate::md::blocks(&lines);
let visible = crate::fold::Visible::new(&lines, &blocks, heads);
let mut r = render_wide(md, width);
apply_folds(&mut r, &visible, heads, width);
r
}
fn texts(r: &Rendered) -> Vec<String> {
r.lines.iter().map(PLine::text).collect()
}
#[test]
fn a_folded_section_loses_its_rows_and_the_heading_says_how_many() {
let md = "# Title\nintro\n## One\na\nb\n## Two\nc\n";
let r = folded(md, &[2], 40);
let t = texts(&r);
assert!(t.iter().all(|l| l != "a" && l != "b"), "{t:?}");
let head = r.lines.iter().find(|l| l.src_line == Some(2)).unwrap();
let text = head.text();
assert!(text.starts_with("▸ One"), "{text:?}");
assert!(text.ends_with("2 lines folded"), "{text:?}");
assert_eq!(cells_width(&head.cells), 40);
assert_eq!(head.cells[0].src, Some((2, 0)));
assert_eq!(head.cells[0].style, theme::fold());
let one = r.lines.iter().position(|l| l.src_line == Some(2)).unwrap();
assert!(r.lines[one + 1].cells.is_empty());
assert_eq!(r.lines[one + 2].src_line, Some(5));
assert!(!r.lines[one + 2].text().starts_with('▸'));
let plain = folded(md, &[], 40);
assert_eq!(texts(&plain), texts(&render_wide(md, 40)));
}
#[test]
fn a_fold_at_the_end_of_the_page_leaves_no_blank_behind() {
let md = "# Title\n## Last\nx\ny\n";
let r = folded(md, &[1], 40);
assert!(!r.lines.last().unwrap().cells.is_empty());
assert_eq!(r.lines.last().unwrap().src_line, Some(1));
assert!(folded("# T\n## L\nx\n", &[1], 40).lines[2]
.text()
.ends_with("1 line folded"));
let tight = folded("# T\n## Last\nx\n", &[1], 12);
assert_eq!(tight.lines[2].text(), "▸ Last");
}
#[test]
fn everything_a_section_holds_goes_with_it() {
let md = "# T\n## One\n- [ ] task\n```rust\nlet x = 1;\n```\n| a | b |\n| - | - |\n| 1 | 2 |\n> quoted\n\n```mermaid\ngraph LR\nA-->B\n```\n## Two\nend\n";
let r = folded(md, &[1], 40);
for l in &r.lines {
assert!(
l.src_line.is_none_or(|s| s == 0 || s == 1 || s >= 15),
"row from a hidden line: {:?} {:?}",
l.src_line,
l.text()
);
}
let all = texts(&r).join("\n");
for gone in ["task", "let x", "│", "quoted", "pic", "A", "-->"] {
assert!(!all.contains(gone), "{gone:?} in {all:?}");
}
assert!(all.contains("end"));
assert!(r.lines.iter().all(|l| l.image.is_none()));
assert!(r.lines.iter().all(|l| l.checkbox.is_none()));
}
#[test]
fn a_footer_row_carries_no_source_position_so_a_click_cannot_land_in_the_note() {
let mut r = render("# Spec\n");
let before = r.lines.len();
append_mentions(&mut r, &[mention("meta", "about [[spec]]", 1)], 60);
for line in &r.lines[before..] {
assert_eq!(line.src_line, None);
assert_eq!(line.checkbox, None);
assert!(!line.wide);
assert!(line.cells.iter().all(|c| c.src.is_none()));
}
}
#[test]
fn the_footer_never_makes_a_page_wider_than_the_page() {
let mut r = render_wide("# Spec\n", 30);
append_mentions(
&mut r,
&[mention(
"a-note-with-a-very-long-name-indeed",
"a sentence far longer than the page could ever hold, on and on",
12,
)],
30,
);
assert!(r.lines.iter().all(|l| cells_width(&l.cells) <= 30));
}
#[test]
fn the_footer_names_a_note_by_its_file_not_its_first_line() {
let mut r = render("# Spec\n");
let mut m = mention("meta-os-control", "about [[spec]]", 1);
m.path = std::path::PathBuf::from("/vault/deep/meta-os-control.md");
append_mentions(&mut r, &[m], 60);
let row = footer_row(&r, "meta-os-control");
let name: String = row
.cells
.iter()
.filter(|c| c.link.is_some())
.map(|c| c.ch)
.collect();
assert_eq!(name, "meta-os-control");
assert!(row
.cells
.iter()
.filter(|c| c.link.is_some())
.all(|c| c.style == theme::link()));
}
#[test]
fn the_excerpt_is_styled_rather_than_shown_as_raw_markdown() {
let mut r = render("# Spec\n");
append_mentions(
&mut r,
&[mention(
"meta",
"**Projects:** [[spec|the spec]] and `code`",
1,
)],
80,
);
let row = footer_row(&r, "meta");
let text = row.text();
assert!(!text.contains("**"), "{text}");
assert!(!text.contains("[["), "{text}");
assert!(!text.contains('`'), "{text}");
assert!(text.contains("Projects: the spec and code"), "{text}");
let p = row.cells.iter().find(|c| c.ch == 'P').unwrap();
assert!(p.style.add_modifier.contains(Modifier::BOLD));
let t = row.cells.iter().find(|c| c.ch == 't').unwrap();
assert!(t.style.add_modifier.contains(Modifier::UNDERLINED));
assert!(row.cells.iter().all(|c| c.src.is_none()));
}
#[test]
fn a_long_excerpt_is_cut_around_the_link_so_the_link_stays_on_screen() {
let mut r = render("# Spec\n");
let before = "word ".repeat(30);
let after = " tail".repeat(30);
let excerpt = format!("{before}[[spec]]{after}");
append_mentions(&mut r, &[mention("meta", &excerpt, 1)], 60);
let row = footer_row(&r, "meta");
let text = row.text();
assert!(text.contains("spec"), "{text}");
assert!(text.starts_with(" meta …"), "{text}");
assert!(cells_width(&row.cells) <= 60);
let mut r = render("# Spec\n");
let excerpt = format!("[[spec]]{after}");
append_mentions(&mut r, &[mention("meta", &excerpt, 1)], 60);
assert!(footer_row(&r, "meta").text().contains(" spec tail"));
}
#[test]
fn a_narrow_page_keeps_the_titles_and_drops_the_excerpts() {
let mut r = render_wide("# Spec\n", 18);
append_mentions(&mut r, &[mention("meta", "about the spec", 1)], 18);
let row = r.lines.iter().find(|l| l.text().contains("meta")).unwrap();
assert!(!row.text().contains("about"));
}
#[test]
fn inline_comments_vanish_from_the_page_and_offsets_still_point_home() {
let md = "see %% not this %% [[spec]] and `%% code %%` here\n";
let r = render(md);
let t = flat(&r);
assert!(!t.contains("not this"), "{t}");
assert!(t.contains("see spec and %% code %% here"), "{t}");
let src_lines: Vec<&str> = md.lines().collect();
for line in &r.lines {
for c in &line.cells {
if let Some((l, col)) = c.src {
assert_eq!(src_lines[l].chars().nth(col), Some(c.ch), "({l},{col})");
}
}
}
}
#[test]
fn a_block_comment_leaves_no_trace_and_the_lines_below_keep_their_numbers() {
let md = "a\n\n%%\n# hidden\n\n- [ ] not a task\n%%\nb\n\n- [ ] task\n";
let r = render(md);
let t = texts(&r);
assert!(
t.iter()
.all(|l| !l.contains("hidden") && !l.contains("not a")),
"{t:?}"
);
assert_eq!(t, texts(&render("a\n\nb\n\n- [ ] task\n")));
let b = r.lines.iter().find(|l| l.text() == "b").unwrap();
assert_eq!(b.src_line, Some(7));
let task = r.lines.iter().find(|l| l.text().contains("task")).unwrap();
assert_eq!(task.src_line, Some(9));
assert_eq!(task.checkbox, Some(9));
}
#[test]
fn a_line_that_is_only_a_comment_does_not_split_its_paragraph() {
let r = render("a\n%% note %%\nb\n");
assert_eq!(texts(&r), texts(&render("a\nb\n")));
assert_eq!(texts(&r), ["a", "b"]);
let b = r.lines.iter().find(|l| l.text() == "b").unwrap();
assert_eq!(b.src_line, Some(2));
}
#[test]
fn an_unclosed_comment_marker_and_a_commented_code_line_are_literal() {
let r = render("a %% b\n\n```\n%%\nx\n%%\n```\n");
let t = flat(&r);
assert!(t.contains("a %% b"), "{t}");
assert!(t.contains("x"), "{t}");
}
}