use std::cell::Cell;
use std::collections::HashMap;
use std::ops::Range;
use twig::{Alignment, ContainerOrigin, DirectiveForm, Editor, FlatNode, Kind, QueryMatch};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::style::{Baseline, Role, Style};
#[derive(Clone)]
pub struct Glyph {
pub ch: char,
pub style: Style,
pub src: usize,
pub stop: bool,
}
#[derive(Clone)]
pub struct VRow {
pub glyphs: Vec<Glyph>,
pub end_src: usize,
pub decoration: bool,
pub code: bool,
pub code_lang: Option<String>,
pub directive: bool,
pub directive_label: Option<String>,
pub media: Option<MediaMark>,
pub task: Option<bool>,
pub leaf_directive: Option<DirectiveMark>,
pub heading: Option<u8>,
pub boundary: Option<Boundary>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Boundary {
pub above: BlockClass,
pub below: BlockClass,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockClass {
Paragraph,
Heading,
List,
ListItem,
Quote,
Code,
Table,
Media,
Directive,
Rule,
Footnote,
Other,
}
impl BlockClass {
pub fn from_node_kind(kind: &Kind) -> BlockClass {
match kind {
Kind::Para => BlockClass::Paragraph,
Kind::Heading => BlockClass::Heading,
Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
Kind::BlockQuote => BlockClass::Quote,
Kind::CodeBlock => BlockClass::Code,
Kind::Table => BlockClass::Table,
Kind::Image => BlockClass::Media,
Kind::Container => BlockClass::Directive,
Kind::ThematicBreak => BlockClass::Rule,
Kind::Footnote => BlockClass::Footnote,
_ => BlockClass::Other,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectiveMark {
pub name: String,
pub attrs: Vec<(String, Option<String>)>,
pub label: String,
pub rows: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaKind {
Image,
Video,
Audio,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaStop {
Before,
After,
}
impl MediaKind {
fn sigil(self) -> char {
match self {
MediaKind::Image => '🖼',
MediaKind::Video => '🎬',
MediaKind::Audio => '🔊',
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaMark {
pub kind: MediaKind,
pub destination: String,
pub sources: Vec<MediaSource>,
pub alt: String,
pub poster: String,
pub rows: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaSource {
pub media: String,
pub srcset: String,
pub mime: String,
}
#[derive(Default)]
pub struct VisualMap {
pub rows: Vec<VRow>,
pub content_start: usize,
stops: Vec<usize>,
pub tables: Vec<TableInfo>,
pub code_blocks: Vec<CodeBlockInfo>,
pub media: Vec<MediaInfo>,
pub directives: Vec<DirectiveInfo>,
}
impl VisualMap {
pub fn num_rows(&self) -> usize {
self.rows.len()
}
pub fn row_width(&self, row: usize) -> usize {
self.rows.get(row).map_or(0, |r| r.width())
}
pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
let mut best: Option<(usize, usize, usize)> = None; for (r, row) in self.rows.iter().enumerate() {
if row.decoration {
continue;
}
let cand = row
.glyphs
.iter()
.enumerate()
.find(|(_, g)| g.stop && g.src >= off)
.map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
.or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
if let Some(c) = cand {
if best.is_none_or(|b| c.0 <= b.0) {
best = Some(c);
}
}
if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
&& first.src > b.0
{
break;
}
}
match best {
Some((_, r, c)) => (r, c),
None => {
let r = self.last_stop_row();
(r, self.row_width(r))
}
}
}
pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
if self.rows.is_empty() {
return (0, 0);
}
let first = self.pos_of_offset(range.start).0;
let mut last = first;
for (r, row) in self.rows.iter().enumerate().skip(first) {
if row.decoration {
continue;
}
let open = row
.glyphs
.iter()
.find(|g| g.stop)
.map_or(row.end_src, |g| g.src);
if open >= range.end {
break;
}
last = r;
}
(first, last)
}
pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
let r = self.rows.get(row)?;
self.task_box_at_glyph(row, r.glyph_at_col(col)?)
}
pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
let r = self.rows.get(row)?;
r.task?;
let g = r.glyphs.get(glyph)?;
(g.style.role == Role::ListMarker).then_some(g.src)
}
pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
let Some(r) = self.rows.get(row) else {
return self.stops.last().copied().unwrap_or(0);
};
match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
Some(g) if !g.stop => self.nearest_stop(g.src),
Some(g) => g.src,
None if r.decoration => self.nearest_stop(r.end_src),
None => r.end_src,
}
}
pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
for m in &self.media {
let Some(row) = self.rows.get(m.rows_span.start) else {
continue;
};
let Some(start) = row
.glyphs
.iter()
.find(|g| g.style.role == Role::Image)
.map(|g| g.src)
else {
continue;
};
if off == start {
return Some((MediaStop::Before, start..row.end_src));
}
if off == row.end_src {
return Some((MediaStop::After, start..row.end_src));
}
}
None
}
pub fn snap_to_stop(&self, off: usize) -> usize {
self.nearest_stop(off)
}
fn nearest_stop(&self, off: usize) -> usize {
let i = self.stops.partition_point(|&s| s < off);
let after = self.stops.get(i).copied();
let before = i.checked_sub(1).map(|j| self.stops[j]);
match (before, after) {
(Some(b), Some(a)) if off - b <= a - off => b,
(_, Some(a)) => a,
(Some(b), None) => b,
(None, None) => off,
}
}
pub fn row_is_navigable(&self, row: usize) -> bool {
self.rows.get(row).is_some_and(|r| !r.decoration)
}
pub fn row_start(&self, row: usize) -> Option<usize> {
let r = self.rows.get(row).filter(|r| !r.decoration)?;
Some(
r.glyphs
.iter()
.find(|g| g.stop)
.map_or(r.end_src, |g| g.src),
)
}
fn last_stop_row(&self) -> usize {
(0..self.rows.len())
.rev()
.find(|&r| self.row_is_navigable(r))
.unwrap_or(0)
}
pub fn navigable_above(&self, row: usize) -> Option<usize> {
(0..row.min(self.rows.len()))
.rev()
.find(|&r| self.row_is_navigable(r))
}
pub fn navigable_below(&self, row: usize) -> Option<usize> {
((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
}
pub fn stop_before(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s < off);
i.checked_sub(1).map(|i| self.stops[i])
}
pub fn stop_after(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s <= off);
self.stops.get(i).copied()
}
pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s < off);
self.stops.get(i).copied()
}
pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s <= off);
i.checked_sub(1).map(|i| self.stops[i])
}
pub fn is_stop(&self, off: usize) -> bool {
self.stops.binary_search(&off).is_ok()
}
pub fn visible_text(&self, from: usize, to: usize) -> String {
let from = self.nearest_stop(from);
let mut items: Vec<(usize, Option<char>)> = self
.rows
.iter()
.filter(|r| !r.decoration)
.flat_map(|r| r.glyphs.iter())
.filter(|g| g.stop && g.src >= from && g.src < to)
.map(|g| (g.src, Some(g.ch)))
.collect();
let mut boundaries: Vec<usize> = self
.rows
.iter()
.filter(|r| r.decoration)
.map(|r| r.end_src)
.filter(|&src| src >= from && src < to)
.collect();
boundaries.sort_unstable();
boundaries.dedup();
items.extend(boundaries.into_iter().map(|src| (src, None)));
items.sort_by_key(|&(src, _)| src);
items
.into_iter()
.map(|(_, ch)| ch.unwrap_or('\n'))
.collect()
}
}
fn collect_stops(rows: &[VRow]) -> Vec<usize> {
let mut stops: Vec<usize> = rows
.iter()
.filter(|r| !r.decoration)
.flat_map(|r| {
r.glyphs
.iter()
.filter(|g| g.stop)
.map(|g| g.src)
.chain(std::iter::once(r.end_src))
})
.collect();
stops.sort_unstable();
stops.dedup();
stops
}
fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
let mut blocks = Vec::new();
let mut start: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
match (row.code, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
blocks.push(CodeBlockInfo {
rows_span: s..i,
lang: rows[s].code_lang.clone(),
});
start = None;
}
_ => {}
}
}
if let Some(s) = start {
blocks.push(CodeBlockInfo {
rows_span: s..rows.len(),
lang: rows[s].code_lang.clone(),
});
}
blocks
}
fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
node.attrs
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| v.clone())
}
fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
rows.iter()
.enumerate()
.filter_map(|(i, row)| {
row.media.as_ref().map(|m| MediaInfo {
rows_span: i..i + m.rows.max(1),
kind: m.kind,
destination: m.destination.clone(),
sources: m.sources.clone(),
alt: m.alt.clone(),
poster: m.poster.clone(),
})
})
.collect()
}
fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
rows.iter()
.enumerate()
.filter_map(|(i, row)| {
row.leaf_directive.as_ref().map(|m| DirectiveInfo {
rows_span: i..i + m.rows.max(1),
name: m.name.clone(),
attrs: m.attrs.clone(),
label: m.label.clone(),
})
})
.collect()
}
pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
let rest = source.get(block_start..)?;
let line_len = rest.find('\n').unwrap_or(rest.len());
let line = &rest[..line_len];
let indent = line.len() - line.trim_start().len();
if indent > 3 {
return None;
}
let fence = line[indent..].chars().next()?;
if fence != '`' && fence != '~' {
return None; }
let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
let info_start = block_start + indent + fence_len;
Some(info_start..block_start + line_len)
}
pub fn code_language(source: &str, block_start: usize) -> Option<String> {
let span = code_info_span(source, block_start)?;
let text = source.get(span)?.trim();
(!text.is_empty()).then(|| text.to_string())
}
const UNWRAPPED_RULE_WIDTH: usize = 40;
pub fn build(
nodes: &[FlatNode],
source: &str,
wrap: Option<usize>,
preserve_soft: bool,
media_rows: &HashMap<String, usize>,
reveal: Option<Range<usize>>,
) -> VisualMap {
let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
return VisualMap::default();
};
let top = top_level(nodes, doc);
let mut b = Builder {
nodes,
source,
wrap: wrap.map(|w| w.max(8)),
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
};
b.top_blocks(&top);
b.emit_trailing_blank_lines(top.last().map_or(BlockClass::Paragraph, |&i| {
BlockClass::from_node_kind(&nodes[i].kind)
}));
let content_start = top.first().map_or(0, |&i| nodes[i].span.start);
let stops = collect_stops(&b.rows);
let code_blocks = code_block_spans(&b.rows);
let media = media_spans(&b.rows);
let directives = directive_spans(&b.rows);
VisualMap {
rows: b.rows,
content_start,
stops,
tables: b.tables,
code_blocks,
media,
directives,
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_cached(
top: &[QueryMatch],
source: &str,
wrap: Option<usize>,
preserve_soft: bool,
media_rows: &HashMap<String, usize>,
reveal: Option<Range<usize>>,
cache: &mut BlockCache,
mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
) -> VisualMap {
let wrap = wrap.map(|w| w.max(8));
if cache.wrap != Some(wrap) {
cache.entries.clear();
cache.wrap = Some(wrap);
}
cache.generation = cache.generation.wrapping_add(1);
let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
let mut b = Builder {
nodes: &[],
source,
wrap,
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
};
let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
let mut all_shift_safe = true;
for (i, block) in blocks.iter().enumerate() {
let start = block.span.start;
let before_sep = b.rows.len();
if i > 0 {
b.emit_separators_before(
start,
&[],
true,
Boundary {
above: BlockClass::from_node_kind(&blocks[i - 1].kind),
below: BlockClass::from_node_kind(&block.kind),
},
);
}
let sep_rows = b.rows.len() - before_sep;
let after_sep = b.rows.len();
let bytes = block_bytes(source, &block.span);
let hash = block_hash(bytes);
let rkey = reveal_key(&reveal, &block.span);
if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
let delta = start as isize - hit.built_start as isize;
for row in &hit.rows {
b.rows.push(shift_row(row, delta));
}
b.last_off = (hit.last_off as isize + delta) as usize;
} else {
let subtree = fetch_subtree(block.node_id);
if !subtree.is_empty() {
let mut sub = Builder {
nodes: &subtree,
source,
wrap,
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
};
sub.block(0, &[], &[]);
let last_off = sub.last_off;
if sub.tables.is_empty() {
if rows_within(&sub.rows, &block.span) {
cache.store(hash, bytes, start, sub.rows.clone(), last_off, rkey);
}
b.rows.extend(sub.rows);
} else {
let base = b.rows.len();
for t in &mut sub.tables {
t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
}
b.rows.extend(sub.rows);
b.tables.extend(sub.tables);
}
b.last_off = last_off;
}
}
let content_rows = b.rows.len() - after_sep;
all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
layout_blocks.push(BlockLayout {
span: block.span.clone(),
kind: block.kind.clone(),
sep_rows,
content_rows,
});
}
let before_trailing = b.rows.len();
b.emit_trailing_blank_lines(blocks.last().map_or(BlockClass::Paragraph, |m| {
BlockClass::from_node_kind(&m.kind)
}));
let trailing_rows = b.rows.len() - before_trailing;
let g = cache.generation;
cache.entries.retain(|_, bucket| {
bucket.retain(|e| e.generation == g);
!bucket.is_empty()
});
cache.layout = Layout {
blocks: layout_blocks,
trailing_rows,
built_len: source.len(),
has_tables: !b.tables.is_empty(),
all_shift_safe,
reveal: reveal.clone(),
};
let content_start = blocks.first().map_or(0, |m| m.span.start);
let stops = collect_stops(&b.rows);
let code_blocks = code_block_spans(&b.rows);
let media = media_spans(&b.rows);
let directives = directive_spans(&b.rows);
VisualMap {
rows: b.rows,
content_start,
stops,
tables: b.tables,
code_blocks,
media,
directives,
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_spliced(
prev: VisualMap,
source: &str,
wrap: Option<usize>,
preserve_soft: bool,
top: &[QueryMatch],
dirty: Range<usize>,
media_rows: &HashMap<String, usize>,
reveal: Option<Range<usize>>,
cache: &mut BlockCache,
mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
) -> Option<VisualMap> {
let wrap = wrap.map(|w| w.max(8));
if cache.wrap != Some(wrap) {
return None;
}
if cache.layout.reveal != reveal {
return None;
}
let prev_layout = std::mem::take(&mut cache.layout);
if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
return None;
}
let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
return None;
}
let delta = source.len() as isize - prev_layout.built_len as isize;
let k = blocks
.iter()
.position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
if m.kind != pl.kind {
return None;
}
if i == k {
continue;
}
let want = if i < k {
pl.span.clone()
} else {
(pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
};
if m.span != want {
return None;
}
}
let pk_start = prev_layout.blocks[k].span.start;
let pk_end = prev_layout.blocks[k].span.end;
let pk_sep = prev_layout.blocks[k].sep_rows;
let pk_content = prev_layout.blocks[k].content_rows;
if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
{
return None;
}
let subtree = fetch_subtree(blocks[k].node_id);
if subtree.is_empty() {
return None;
}
let mut sub = Builder {
nodes: &subtree,
source,
wrap,
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
};
sub.block(0, &[], &[]);
if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
return None;
}
let new_content = sub.rows;
let new_content_len = new_content.len();
let new_stops = collect_stops(&new_content);
let content_start_row: usize = prev_layout.blocks[..k]
.iter()
.map(|pl| pl.sep_rows + pl.content_rows)
.sum::<usize>()
+ pk_sep;
let content_end_row = content_start_row + pk_content;
let mut rows = prev.rows;
let mut suffix = rows.split_off(content_end_row);
rows.truncate(content_start_row);
for row in &mut suffix {
shift_row_in_place(row, delta);
}
rows.reserve(new_content_len + suffix.len());
rows.extend(new_content);
rows.extend(suffix);
let p1 = prev.stops.partition_point(|&s| s < pk_start);
let p2 = prev.stops.partition_point(|&s| s <= pk_end);
let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
stops.extend_from_slice(&prev.stops[..p1]);
stops.extend(new_stops);
for &s in &prev.stops[p2..] {
stops.push((s as isize + delta) as usize);
}
let mut new_blocks = prev_layout.blocks;
for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
pl.span = m.span.clone();
}
new_blocks[k].content_rows = new_content_len;
cache.layout = Layout {
blocks: new_blocks,
trailing_rows: prev_layout.trailing_rows,
built_len: source.len(),
has_tables: false,
all_shift_safe: true,
reveal,
};
let code_blocks = code_block_spans(&rows);
let media = media_spans(&rows);
let directives = directive_spans(&rows);
Some(VisualMap {
rows,
content_start: blocks[0].span.start,
stops,
tables: Vec::new(),
code_blocks,
media,
directives,
})
}
#[derive(Default)]
pub struct BlockCache {
wrap: Option<Option<usize>>,
generation: u64,
entries: HashMap<u64, Vec<CachedBlock>>,
layout: Layout,
}
#[derive(Default)]
struct Layout {
blocks: Vec<BlockLayout>,
trailing_rows: usize,
built_len: usize,
has_tables: bool,
all_shift_safe: bool,
reveal: Option<Range<usize>>,
}
struct BlockLayout {
span: Range<usize>,
kind: Kind,
sep_rows: usize,
content_rows: usize,
}
struct CachedBlock {
bytes: Box<[u8]>,
built_start: usize,
rows: Vec<VRow>,
last_off: usize,
reveal: Option<Range<usize>>,
generation: u64,
}
fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
let r = reveal.as_ref()?;
(span.start <= r.end && r.start <= span.end).then(|| {
let start = r.start.max(span.start) - span.start;
let end = r.end.min(span.end) - span.start;
start..end
})
}
impl BlockCache {
fn reuse(
&mut self,
hash: u64,
bytes: &[u8],
reveal: &Option<Range<usize>>,
) -> Option<&CachedBlock> {
let g = self.generation;
let bucket = self.entries.get_mut(&hash)?;
let e = bucket
.iter_mut()
.find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
e.generation = g;
Some(&*e)
}
fn store(
&mut self,
hash: u64,
bytes: &[u8],
built_start: usize,
rows: Vec<VRow>,
last_off: usize,
reveal: Option<Range<usize>>,
) {
let g = self.generation;
let bucket = self.entries.entry(hash).or_default();
if let Some(e) = bucket
.iter_mut()
.find(|e| &*e.bytes == bytes && e.reveal == reveal)
{
e.built_start = built_start;
e.rows = rows;
e.last_off = last_off;
e.generation = g;
} else {
bucket.push(CachedBlock {
bytes: bytes.into(),
built_start,
rows,
last_off,
reveal,
generation: g,
});
}
}
}
fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
let bytes = source.as_bytes();
let start = span.start.min(bytes.len());
&bytes[start..span.end.clamp(start, bytes.len())]
}
fn block_hash(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &x in bytes {
h ^= x as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn shift_row(row: &VRow, delta: isize) -> VRow {
let shift = |off: usize| (off as isize + delta) as usize;
VRow {
glyphs: row
.glyphs
.iter()
.map(|g| Glyph {
ch: g.ch,
style: g.style,
src: shift(g.src),
stop: g.stop,
})
.collect(),
end_src: shift(row.end_src),
decoration: row.decoration,
code: row.code,
code_lang: row.code_lang.clone(),
directive: row.directive,
directive_label: row.directive_label.clone(),
media: row.media.clone(),
task: row.task,
leaf_directive: row.leaf_directive.clone(),
heading: row.heading,
boundary: row.boundary,
}
}
fn shift_row_in_place(row: &mut VRow, delta: isize) {
for g in &mut row.glyphs {
g.src = (g.src as isize + delta) as usize;
}
row.end_src = (row.end_src as isize + delta) as usize;
}
fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
rows.iter().all(|r| {
r.end_src >= span.start
&& r.end_src <= span.end
&& r.glyphs
.iter()
.all(|g| g.src >= span.start && g.src <= span.end)
})
}
fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut child = nodes[doc].first_child;
while let Some(cid) = child {
let n = &nodes[cid.0 as usize];
if n.kind != Kind::Metadata {
out.push(cid.0 as usize);
}
child = n.next_sibling;
}
out.extend(
nodes
.iter()
.enumerate()
.filter(|(_, n)| n.kind == Kind::Footnote && n.parent.is_none())
.map(|(i, _)| i),
);
out.sort_by_key(|&i| nodes[i].span.start);
out
}
pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
let mut top = editor.child_spans(None).unwrap_or_default();
let notes = footnote_definitions(editor);
if notes.is_empty() {
return top;
}
top.extend(notes);
top.sort_by_key(|m| m.span.start);
top
}
pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
let Ok(mut doc) = editor.document() else {
return Vec::new();
};
doc.definitions()
.unwrap_or_default()
.into_iter()
.filter(|m| m.kind == Kind::Footnote)
.collect()
}
pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
let rest = source.get(start..)?.strip_prefix("[^")?;
let end = rest.find("]:")?;
Some(&rest[..end])
}
pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
let rest = source.get(span.clone())?.strip_prefix("[^")?;
let marker = rest.find("]:")?;
let after_marker = span.start + 2 + marker + 2;
let raw = source.get(after_marker..span.end)?;
let start = after_marker + (raw.len() - raw.trim_start().len());
Some(start..start + raw.trim().len())
}
pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
let rest = source.get(span)?.strip_prefix("[^")?;
let end = rest.find(']')?;
Some(&rest[..end])
}
fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
let end = span.end.min(source.len());
let Some(line) = source.get(span.start..end) else {
return span.start;
};
let line = line.split('\n').next().unwrap_or("");
let hashes = line.len() - line.trim_start_matches('#').len();
if hashes == 0 {
return span.start;
}
let after = &line[hashes..];
span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
}
struct Builder<'a> {
nodes: &'a [FlatNode],
source: &'a str,
wrap: Option<usize>,
rows: Vec<VRow>,
tables: Vec<TableInfo>,
last_off: usize,
media_rows: &'a HashMap<String, usize>,
break_glyph: Cell<char>,
preserve_soft: bool,
reveal: Option<Range<usize>>,
}
impl Builder<'_> {
fn revealed(&self, span: &Range<usize>) -> bool {
self.reveal
.as_ref()
.is_some_and(|r| span.start <= r.end && r.start <= span.end)
}
fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
let node = &self.nodes[id];
let content = node.content_span.clone()?;
let span = node.span.clone();
if content.start < span.start || content.end > span.end {
return None;
}
let (open, close) = (span.start..content.start, content.end..span.end);
let multiline =
|r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
if multiline(&open) || multiline(&close) {
return None;
}
(!open.is_empty() || !close.is_empty()).then_some((open, close))
}
fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
let Some(text) = self.source.get(range.clone()) else {
return;
};
push_text(out, text, range.start, base.role(Role::Delimiter));
}
fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
let show = self
.revealed(&self.nodes[id].span)
.then(|| self.delims(id))
.flatten();
if let Some((open, _)) = &show {
self.push_delim(out, open, style);
}
self.recurse(id, style, out);
if let Some((_, close)) = &show {
self.push_delim(out, close, style);
}
}
fn children(&self, id: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut c = self.nodes[id].first_child;
while let Some(cid) = c {
out.push(cid.0 as usize);
c = self.nodes[cid.0 as usize].next_sibling;
}
out
}
fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
let kids: Vec<usize> = self
.children(id)
.into_iter()
.filter(|&c| self.nodes[c].kind != Kind::Metadata)
.collect();
let mut above: Option<BlockClass> = None;
for (i, child) in kids.into_iter().enumerate() {
let below = BlockClass::from_node_kind(&self.nodes[child].kind);
if let Some(above) = above {
self.emit_separators_before(
self.nodes[child].span.start,
pc,
!tight,
Boundary { above, below },
);
}
let first = if i == 0 { pf } else { pc };
self.block(child, first, pc);
above = Some(below);
}
}
fn top_blocks(&mut self, ids: &[usize]) {
for (i, &child) in ids.iter().enumerate() {
let below = BlockClass::from_node_kind(&self.nodes[child].kind);
if i > 0 {
let above = BlockClass::from_node_kind(&self.nodes[ids[i - 1]].kind);
self.emit_separators_before(
self.nodes[child].span.start,
&[],
true,
Boundary { above, below },
);
}
self.block(child, &[], &[]);
}
}
fn emit_separators_before(
&mut self,
next_start: usize,
pc: &[Glyph],
synthetic: bool,
boundary: Boundary,
) {
let mut offs = self.blank_rows_between(self.last_off, next_start);
if offs.is_empty() {
if !synthetic {
return;
}
offs.push(self.blank_line_offset(self.last_off, next_start));
}
let last = offs.len() - 1;
for (k, end_src) in offs.into_iter().enumerate() {
let drawn = !self.preserve_soft && (k == 0 || k == last);
self.rows.push(VRow {
glyphs: pc.to_vec(),
end_src,
decoration: drawn,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
boundary: drawn.then_some(boundary),
});
}
}
fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
let node = &self.nodes[id];
match node.kind.as_str() {
"doc" | "section" => self.blocks(id, pf, pc, false),
"heading" => {
if let Some((m, kind)) = self.media_only(id) {
self.block_media(m, kind, id, pf);
return;
}
let level = node.level.unwrap_or(1);
let style = heading_style(level);
let mut glyphs = Vec::new();
if let Some((open, close)) =
self.revealed(&node.span).then(|| self.delims(id)).flatten()
{
self.push_delim(&mut glyphs, &open, style);
glyphs.extend(self.inline_children_with_trailing(id, style));
self.push_delim(&mut glyphs, &close, style);
} else {
glyphs = self.inline_children_with_trailing(id, style);
}
let home = heading_content_start(self.source, &node.span);
let first = self.rows.len();
self.emit_wrapped(glyphs, home, pf, pc);
for row in &mut self.rows[first..] {
row.heading = Some(level.min(255) as u8);
}
}
"block_quote" => {
let (start, end) = (node.span.start, node.span.end);
let gutter = synth("│ ", Role::QuoteGutter, start);
let f = concat(pf, &gutter);
let c = concat(pc, &gutter);
if self.children(id).is_empty() {
self.push_row_at(f, end.min(self.source.len()));
} else {
self.blocks(id, &f, &c, false);
self.emit_quote_trailing_lines(&c, end);
}
}
"container"
if container_is_directive(node)
&& node.directive_form == Some(DirectiveForm::Leaf) =>
{
self.block_directive(id, pf);
}
"container" if container_is_directive(node) => {
let label = directive_attr_label(&node.attrs);
let start_row = self.rows.len();
self.blocks(id, pf, pc, false);
for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
row.directive = true;
if i == 0 {
row.directive_label = label.clone();
}
}
}
"bullet_list" | "ordered_list" | "task_list" => {
let ordered = node.kind == Kind::OrderedList;
let mut item_no = 0usize;
let kids = self.children(id);
for (i, child) in kids.iter().copied().enumerate() {
let kind = &self.nodes[child].kind;
if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
let start = self.nodes[child].span.start;
item_no += 1;
let checked = self.nodes[child].checked;
let marker = match (checked, ordered) {
(Some(true), _) => "☑ ".to_string(),
(Some(false), _) => "☐ ".to_string(),
(None, true) => format!("{item_no}. "),
(None, false) => "• ".to_string(),
};
let bullet = synth(&marker, Role::ListMarker, start);
let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
let first_row = self.rows.len();
self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
row.task = Some(c);
}
} else {
if i > 0 {
self.emit_separators_before(
self.nodes[child].span.start,
pc,
true,
Boundary {
above: BlockClass::from_node_kind(
&self.nodes[kids[i - 1]].kind,
),
below: BlockClass::from_node_kind(&self.nodes[child].kind),
},
);
}
self.block(child, pc, pc);
}
}
}
"list_item" | "task_list_item" => {
if self.children(id).is_empty() {
let home = self.nodes[id].span.end.min(self.source.len());
self.push_row_at(pf.to_vec(), home);
} else {
self.blocks(id, pf, pc, true);
}
}
"footnote" => {
let (start, end) = (node.span.start, node.span.end);
let source = self.source;
let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
let indent = " ".repeat(text_width(&marker));
let f = concat(pf, &synth(&marker, Role::ListMarker, start));
let c = concat(pc, &synth(&indent, Role::Body, start));
if self.children(id).is_empty() {
self.push_row_at(f, end.min(source.len()));
} else {
self.blocks(id, &f, &c, false);
}
}
"table" => self.table(id, pf, pc),
"code_block" => {
let style = Style::default().role(Role::Code);
let text = node.text.clone().unwrap_or_default();
let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect();
let offs = node
.content_span
.as_ref()
.and_then(|c| self.code_line_offsets(c, &lines));
let lang = code_language(self.source, node.span.start);
for (i, raw) in lines.iter().enumerate() {
let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
let mut glyphs: Vec<Glyph> = pf.to_vec();
push_text(&mut glyphs, raw, at, style);
self.push_row_at(glyphs, at + raw.len());
if let Some(row) = self.rows.last_mut() {
row.code = true;
if i == 0 {
row.code_lang = lang.clone();
}
}
}
self.last_off = node.span.end;
}
"thematic_break" => {
let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
let w = full.saturating_sub(prefix_width(pf)).max(4);
let mut glyphs = pf.to_vec();
for _ in 0..w {
glyphs.push(Glyph {
ch: '─',
style: Style::default().role(Role::Rule),
src: node.span.start,
stop: true,
});
}
self.push_row(glyphs, node.span.start);
}
"image" => self.block_media(id, MediaKind::Image, id, pf),
"container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
let kind = match element_tag(node) {
Some("audio") => MediaKind::Audio,
_ => MediaKind::Video,
};
self.block_media(id, kind, id, pf);
}
_ => {
let kids = self.children(id);
if let Some((m, kind)) = self.media_only(id) {
self.block_media(m, kind, id, pf);
return;
}
let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
if inline || kids.is_empty() {
let glyphs = self.inline_children_with_trailing(id, Style::default());
if !glyphs.is_empty() {
self.emit_wrapped(glyphs, node.span.start, pf, pc);
}
} else {
self.blocks(id, pf, pc, false);
}
}
}
}
fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
let node_end = self.nodes[id].span.end;
let row_ids: Vec<usize> = self
.children(id)
.into_iter()
.filter(|&c| self.nodes[c].kind == Kind::Row)
.collect();
if row_ids.is_empty() {
return;
}
let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
let heads: Vec<bool> = row_ids
.iter()
.map(|&r| self.nodes[r].head.unwrap_or(false))
.collect();
let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
if cols == 0 {
return;
}
let mut widths = vec![0usize; cols];
for row in &grid {
for (c, cell) in row.iter().enumerate() {
widths[c] = widths[c].max(cell_width(&cell.glyphs));
}
}
if let Some(w) = self.wrap {
fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
}
let rows_start = self.rows.len();
let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
for (ri, row) in grid.iter().enumerate() {
self.push_table_row(row, &widths, pc);
let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
if ends_head {
let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
}
}
self.push_rule(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
self.tables.push(TableInfo {
rows_span: rows_start..self.rows.len(),
end_src: node_end,
prefix: pc.to_vec(),
grid: grid
.into_iter()
.zip(heads)
.map(|(cells, head)| TableRow { head, cells })
.collect(),
});
self.last_off = node_end;
}
fn row_cells(&self, row: usize) -> Vec<TableCell> {
self.break_glyph.set('\n');
let cells = self
.children(row)
.into_iter()
.filter(|&c| self.nodes[c].kind == Kind::Cell)
.enumerate()
.map(|(col, c)| {
let n = &self.nodes[c];
let style = if n.head.unwrap_or(false) {
Style::default().bold()
} else {
Style::default()
};
let span = n.content_span.clone().unwrap_or_else(|| {
let off = empty_cell_offset(
&self.source[n.span.start.min(self.source.len())
..n.span.end.min(self.source.len())],
n.span.start,
col,
);
off..off
});
TableCell {
glyphs: self.inline_children(c, style),
start: span.start,
end: span.end,
align: n.alignment.unwrap_or(Alignment::Default),
}
})
.collect();
self.break_glyph.set(' ');
cells
}
fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
let glyphs = concat(prefix, &synth(text, Role::Rule, src));
self.rows.push(VRow {
glyphs,
end_src: src,
decoration: true,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
boundary: None,
});
}
fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
let fallback = cells.last().map(|c| c.end).unwrap_or(0);
let laid: Vec<Vec<Vec<Glyph>>> = cells
.iter()
.enumerate()
.map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
.collect();
let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
for j in 0..height {
let mut glyphs = prefix.to_vec();
for (ci, &w) in widths.iter().enumerate() {
let cell = cells.get(ci);
let line = laid.get(ci).and_then(|l| l.get(j));
let at = line
.and_then(|l| l.first().map(|g| g.src))
.or_else(|| cell.map(|c| c.start))
.unwrap_or(fallback);
glyphs.extend(synth("│", Role::Rule, at));
match (cell, line) {
(Some(cell), Some(line)) => {
let pad = w.saturating_sub(glyphs_width(line));
let (lead, trail) = match cell.align {
Alignment::Right => (pad, 0),
Alignment::Center => (pad / 2, pad - pad / 2),
Alignment::Left | Alignment::Default => (0, pad),
};
let last = laid[ci].len() == j + 1;
let end = match last {
true => cell.end,
false => line
.last()
.map(|g| g.src + g.ch.len_utf8())
.unwrap_or(cell.end),
};
glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
glyphs.extend(line.iter().cloned());
glyphs.push(Glyph {
ch: ' ',
style: Style::default(),
src: end,
stop: true,
});
glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
}
_ => {
let at = cell.map(|c| c.end).unwrap_or(fallback);
glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
}
}
}
glyphs.extend(synth("│", Role::Rule, fallback));
let end_src = glyphs
.iter()
.rev()
.find(|g| g.stop)
.map_or(fallback, |g| g.src);
self.rows.push(VRow {
glyphs,
end_src,
decoration: false,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
boundary: None,
});
}
}
fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
let node = &self.nodes[img];
let start = node.span.start;
let end = node.span.end;
let destination = match kind {
MediaKind::Image => node.destination.clone().unwrap_or_default(),
MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
};
let poster = match kind {
MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
MediaKind::Image | MediaKind::Audio => String::new(),
};
let sources = match kind {
MediaKind::Image => self.media_sources(wrapper),
MediaKind::Video | MediaKind::Audio => self.media_sources(img),
};
let alt = self.image_alt(img);
let sigil = kind.sigil();
let label = if alt.is_empty() {
let named = if destination.is_empty() {
sources
.first()
.map(|s| s.srcset.as_str())
.unwrap_or_default()
} else {
&destination
};
format!("{sigil} {}", media_label(named))
} else {
format!("{sigil} {alt}")
};
let style = Style::default().role(Role::Image);
let mut glyphs = pf.to_vec();
for ch in label.chars() {
glyphs.push(Glyph {
ch,
style,
src: start,
stop: true,
});
}
let rows = self
.media_rows
.get(&destination)
.copied()
.unwrap_or(1)
.max(1);
self.push_row_at(glyphs, end);
if let Some(row) = self.rows.last_mut() {
row.media = Some(MediaMark {
kind,
destination,
sources,
alt,
poster,
rows,
});
}
for _ in 1..rows {
self.rows.push(VRow {
glyphs: Vec::new(),
end_src: end,
decoration: true,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
boundary: None,
});
}
self.last_off = end;
}
fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
let mut out = Vec::new();
self.collect_sources(wrapper, &mut out);
out
}
fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
for c in self.children(id) {
let node = &self.nodes[c];
if node.name.as_deref() == Some("source") {
let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
if let Some(srcset) = url {
out.push(MediaSource {
media: attr_of(node, "media").unwrap_or_default(),
srcset,
mime: attr_of(node, "type").unwrap_or_default(),
});
}
}
self.collect_sources(c, out);
}
}
fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
let mut found = None;
let mut count = 0usize;
let mut has_text = false;
self.scan_visual(id, &mut found, &mut count, &mut has_text);
(count == 1 && !has_text).then(|| found.unwrap())
}
fn scan_visual(
&self,
id: usize,
found: &mut Option<(usize, MediaKind)>,
count: &mut usize,
has_text: &mut bool,
) {
for c in self.children(id) {
let node = &self.nodes[c];
match node.kind.as_str() {
"image" => {
*found = Some((c, MediaKind::Image));
*count += 1;
}
"container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
let kind = match element_tag(node) {
Some("audio") => MediaKind::Audio,
_ => MediaKind::Video,
};
*found = Some((c, kind));
*count += 1;
}
"str" | "smart_punctuation" | "verbatim" | "inline_math" => {
if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
*has_text = true;
}
}
"soft_break" | "hard_break" | "non_breaking_space" => {}
_ => self.scan_visual(c, found, count, has_text),
}
}
}
fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
let node = &self.nodes[id];
let (start, end) = (node.span.start, node.span.end);
let name = node.name.clone().unwrap_or_default();
let attrs = node.attrs.clone();
let label = self.image_alt(id); let shown = if label.is_empty() { &name } else { &label };
let style = Style::default().role(Role::Image);
let mut glyphs = pf.to_vec();
for ch in format!("⧉ {shown}").chars() {
glyphs.push(Glyph {
ch,
style,
src: start,
stop: true,
});
}
self.push_row_at(glyphs, end);
if let Some(row) = self.rows.last_mut() {
row.directive = true;
row.leaf_directive = Some(DirectiveMark {
name,
attrs,
label,
rows: 1,
});
}
self.last_off = end;
}
fn image_alt(&self, id: usize) -> String {
let mut out = String::new();
self.collect_text(id, &mut out);
out
}
fn collect_text(&self, id: usize, out: &mut String) {
for c in self.children(id) {
if let Some(t) = &self.nodes[c].text {
out.push_str(t);
}
self.collect_text(c, out);
}
}
fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
let mut out = Vec::new();
for c in self.children(id) {
self.inline(c, base, &mut out);
}
out
}
fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
let mut out = self.inline_children(id, base);
out.extend(self.trailing_ws_glyphs(id, base));
out
}
fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
let node = &self.nodes[id];
let Some(content) = &node.content_span else {
return Vec::new();
};
let (from, to) = (content.end, node.span.end);
let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
return Vec::new();
};
if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
return Vec::new();
}
slice
.bytes()
.enumerate()
.map(|(i, _)| Glyph {
ch: ' ',
style,
src: from + i,
stop: true,
})
.collect()
}
fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
let node = &self.nodes[id];
match node.kind.as_str() {
"str" | "smart_punctuation" => push_escaped_text(
out,
node.text.as_deref().unwrap_or(""),
node.span.clone(),
self.source,
base,
),
"soft_break" | "hard_break" | "non_breaking_space" => {
let src = if node.span.start != 0 {
node.span.start
} else {
out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
};
let ch = if node.kind == Kind::HardBreak {
self.break_glyph.get()
} else if node.kind == Kind::SoftBreak
&& self.preserve_soft
&& self.break_glyph.get() == ' '
{
'\n'
} else {
' '
};
out.push(Glyph {
ch,
style: base,
src,
stop: true,
});
}
"raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
out.push(Glyph {
ch: '\n',
style: base,
src: node.span.start,
stop: true,
});
}
"emph" => self.inline_delimited(id, base.italic(), out),
"strong" => self.inline_delimited(id, base.bold(), out),
"mark" => self.inline_delimited(id, base.role(Role::Mark), out),
"insert" => self.inline_delimited(id, base.underline(), out),
"delete" => self.inline_delimited(id, base.strikethrough(), out),
"superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
"subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
"verbatim" | "inline_math" => {
let at = node
.content_span
.as_ref()
.map_or(node.span.start + 1, |c| c.start);
let style = base.role(Role::Code);
let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
if let Some((open, _)) = &show {
self.push_delim(out, open, style);
}
push_text(out, node.text.as_deref().unwrap_or(""), at, style);
if let Some((_, close)) = &show {
self.push_delim(out, close, style);
}
}
"container" if container_is_directive(node) && !self.children(id).is_empty() => {
self.recurse(id, base, out)
}
"container" if container_is_directive(node) && node.attrs.is_empty() => {
let span = node.span.clone();
push_text(
out,
self.source.get(span.clone()).unwrap_or(""),
span.start,
base,
);
}
"container" if container_is_directive(node) => {
let start = node.span.start;
let name = node.name.clone().unwrap_or_default();
let shown = match directive_attr_label(&node.attrs) {
Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
Some(attrs) => format!("⧉ {attrs}"),
None => format!("⧉ {name}"),
};
let style = base.role(Role::Image);
for (i, ch) in shown.chars().enumerate() {
out.push(Glyph {
ch,
style,
src: start,
stop: i == 0,
});
}
}
"footnote_reference" => {
let style = base.role(Role::Link);
if self.revealed(&node.span) {
self.push_delim(out, &node.span, style);
return;
}
let style = style.baseline(Baseline::Super);
let (label, at) = match &node.content_span {
Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
};
out.push(Glyph {
ch: '[',
style,
src: node.span.start,
stop: false,
});
push_text(out, label, at, style);
out.push(Glyph {
ch: ']',
style,
src: node.span.end.saturating_sub(1),
stop: false,
});
}
"link" | "url" | "email" => {
let style = base.role(Role::Link);
if self.children(id).is_empty() {
push_text(
out,
node.destination
.as_deref()
.or(node.text.as_deref())
.unwrap_or("link"),
node.span.start,
style,
);
} else {
self.inline_delimited(id, style, out);
}
}
_ => {
if self.children(id).is_empty() {
if let Some(t) = &node.text {
push_text(out, t, node.span.start, base);
}
} else {
self.recurse(id, base, out);
}
}
}
}
fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
for c in self.children(id) {
self.inline(c, style, out);
}
}
fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
if !glyphs.iter().any(|g| g.ch == '\n') {
self.emit_line(glyphs, block_start, pf, pc, None);
return;
}
let mut run: Vec<Glyph> = Vec::new();
let mut first = true;
for g in glyphs {
if g.ch == '\n' {
let lead = if first { pf } else { pc };
self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
first = false;
} else {
run.push(g);
}
}
if !run.is_empty() {
let lead = if first { pf } else { pc };
self.emit_line(run, block_start, lead, pc, None);
}
}
fn emit_line(
&mut self,
glyphs: Vec<Glyph>,
block_start: usize,
pf: &[Glyph],
pc: &[Glyph],
end: Option<usize>,
) {
let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
Some(e) => b.push_row_at(row, e),
None => b.push_row(row, block_start),
};
let Some(width) = self.wrap else {
let row = if glyphs.is_empty() {
pf.to_vec()
} else {
concat(pf, &glyphs)
};
push_last(self, row);
return;
};
let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
let mut word: Vec<Glyph> = Vec::new();
for g in glyphs {
if g.ch == ' ' {
words.push((std::mem::take(&mut word), Some(g)));
} else {
word.push(g);
}
}
if !word.is_empty() {
words.push((word, None));
}
if words.is_empty() {
push_last(self, pf.to_vec());
return;
}
let mut line: Vec<Glyph> = Vec::new();
let mut used = 0usize;
let mut first = true;
for (w, space) in words {
let avail = width
.saturating_sub(prefix_width(if first { pf } else { pc }))
.max(1);
let cells = glyphs_width(&w);
if used > 0 && used + cells > avail {
let row = concat(if first { pf } else { pc }, &line);
self.push_row(row, block_start);
line = Vec::new();
used = 0;
first = false;
}
used += cells;
line.extend(w);
if let Some(sp) = space {
used += 1;
line.push(sp);
}
}
let row = concat(if first { pf } else { pc }, &line);
push_last(self, row);
}
fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
let mut src_lines: Vec<(usize, &str)> = Vec::new();
let mut at = content.start;
for l in self.source.get(content.start..content.end)?.split('\n') {
src_lines.push((at, l));
at += l.len() + 1;
}
if src_lines.len() != lines.len() {
return None;
}
Some(
lines
.iter()
.zip(&src_lines)
.map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
.collect(),
)
}
fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
let end_src = glyphs
.last()
.map(|g| {
let at = g.src.min(self.source.len());
at + self.source[at..].chars().next().map_or(0, char::len_utf8)
})
.unwrap_or(fallback);
self.push_row_at(glyphs, end_src);
}
fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
self.last_off = end_src;
self.rows.push(VRow {
glyphs,
end_src,
decoration: false,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
boundary: None,
});
}
fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
let end = end.min(self.source.len());
let mut at = self.rows.last().map_or(0, |r| r.end_src);
while at < end {
let Some(k) = self.source[at..end].find('\n') else {
break;
};
let line_start = at + k + 1;
let line_end = self.source[line_start..end]
.find('\n')
.map_or(end, |i| line_start + i);
self.push_row_at(pc.to_vec(), line_end);
at = line_end;
}
}
fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
let after_nl = self.source[prev_end..]
.find('\n')
.map_or(prev_end, |p| prev_end + p + 1);
after_nl.min(next_start.saturating_sub(1)).max(prev_end)
}
fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
if next_start <= prev_end {
return Vec::new();
}
let gap = &self.source[prev_end..next_start];
let Some(nl) = gap.find('\n') else {
return Vec::new();
};
let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
let mut offs = Vec::new();
let mut start = prev_end + nl + 1;
while start < next_line_start {
offs.push(start);
match self.source[start..next_start].find('\n') {
Some(k) => start += k + 1,
None => break,
}
}
offs
}
fn emit_trailing_blank_lines(&mut self, above: BlockClass) {
let last_end = self.rows.last().map_or(0, |r| r.end_src);
if last_end >= self.source.len() {
return;
}
let extra = self.source[last_end..].matches('\n').count();
if extra < 2 {
return;
}
for k in 1..=extra {
self.rows.push(VRow {
glyphs: Vec::new(),
end_src: last_end + k,
decoration: !self.preserve_soft && k == 1,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
above,
below: BlockClass::Paragraph,
}),
});
}
}
}
pub fn text_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
struct Cluster {
glyph: usize,
col: usize,
cells: usize,
}
fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
let text: String = glyphs.iter().map(|g| g.ch).collect();
let mut out = Vec::new();
let (mut glyph, mut col) = (0, 0);
for cluster in text.graphemes(true) {
let cells = text_width(cluster);
out.push(Cluster { glyph, col, cells });
glyph += cluster.chars().count();
col += cells;
}
out
}
fn glyphs_width(glyphs: &[Glyph]) -> usize {
clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
}
fn cell_width(glyphs: &[Glyph]) -> usize {
glyphs
.split(|g| g.ch == '\n')
.map(glyphs_width)
.max()
.unwrap_or(0)
}
fn is_br(text: Option<&str>) -> bool {
let Some(t) = text else { return false };
matches!(
t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
"<br>" | "<br/>"
)
}
impl VRow {
fn width(&self) -> usize {
glyphs_width(&self.glyphs)
}
fn col_of_glyph(&self, i: usize) -> usize {
clusters(&self.glyphs)
.iter()
.rev()
.find(|c| c.glyph <= i)
.map_or(0, |c| c.col)
}
fn glyph_at_col(&self, col: usize) -> Option<usize> {
clusters(&self.glyphs)
.into_iter()
.find(|c| col < c.col + c.cells)
.map(|c| c.glyph)
}
}
fn empty_cell_offset(row_src: &str, row_start: usize, col: usize) -> usize {
let bytes = row_src.as_bytes();
let mut pipes = Vec::new();
for (i, &b) in bytes.iter().enumerate() {
if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
pipes.push(i);
}
}
match (pipes.get(col).copied(), pipes.get(col + 1).copied()) {
(Some(open), Some(close)) => {
let lo = open + 1; let hi = close.saturating_sub(1); let inside = if hi < lo {
lo
} else {
(open + 2).clamp(lo, hi)
};
row_start + inside
}
(Some(open), None) => row_start + open + 1,
_ => row_start,
}
}
#[derive(Clone)]
pub struct TableCell {
pub glyphs: Vec<Glyph>,
pub start: usize,
pub end: usize,
pub align: Alignment,
}
#[derive(Clone)]
pub struct TableRow {
pub head: bool,
pub cells: Vec<TableCell>,
}
#[derive(Clone)]
pub struct TableInfo {
pub rows_span: Range<usize>,
pub end_src: usize,
pub prefix: Vec<Glyph>,
pub grid: Vec<TableRow>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodeBlockInfo {
pub rows_span: Range<usize>,
pub lang: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaInfo {
pub rows_span: Range<usize>,
pub kind: MediaKind,
pub destination: String,
pub sources: Vec<MediaSource>,
pub alt: String,
pub poster: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectiveInfo {
pub rows_span: Range<usize>,
pub name: String,
pub attrs: Vec<(String, Option<String>)>,
pub label: String,
}
impl DirectiveInfo {
pub fn attr(&self, key: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| v.as_deref())
}
}
impl MediaInfo {
pub fn resolve(&self, scheme: ColorScheme) -> &str {
if let Some(url) = self
.sources
.iter()
.find(|s| media_matches(&s.media, scheme))
.and_then(|s| first_srcset_url(&s.srcset))
{
return url;
}
if self.destination.is_empty()
&& let Some(url) = self
.sources
.iter()
.find_map(|s| first_srcset_url(&s.srcset))
{
return url;
}
&self.destination
}
pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
match self.kind {
MediaKind::Image => Some(self.resolve(scheme)),
MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
MediaKind::Video | MediaKind::Audio => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorScheme {
Light,
Dark,
}
fn media_matches(media: &str, scheme: ColorScheme) -> bool {
let media = media.trim();
if media.is_empty() {
return true;
}
let lower = media.to_ascii_lowercase();
let Some(after) = lower
.split_once("prefers-color-scheme")
.map(|(_, rest)| rest)
else {
return false;
};
let value = after.trim_start_matches([':', ' ', '\t']);
let wanted = match scheme {
ColorScheme::Light => "light",
ColorScheme::Dark => "dark",
};
value.starts_with(wanted)
}
fn first_srcset_url(srcset: &str) -> Option<&str> {
let first = srcset.split(',').next()?.trim();
first.split_whitespace().next().filter(|u| !u.is_empty())
}
const MIN_COL_WIDTH: usize = 3;
fn fit_widths(widths: &mut [usize], avail: usize) {
let budget = avail.saturating_sub(3 * widths.len() + 1);
while widths.iter().sum::<usize>() > budget {
let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
return;
};
*w -= 1;
}
}
fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
if glyphs.iter().any(|g| g.ch == '\n') {
return glyphs
.split(|g| g.ch == '\n')
.flat_map(|seg| wrap_segment(seg, width))
.collect();
}
wrap_segment(glyphs, width)
}
fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
let width = width.max(1);
let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
let mut word: Vec<Glyph> = Vec::new();
for g in glyphs {
if g.ch == ' ' {
words.push((std::mem::take(&mut word), Some(g.clone())));
} else {
word.push(g.clone());
}
}
if !word.is_empty() {
words.push((word, None));
}
let mut lines: Vec<Vec<Glyph>> = Vec::new();
let mut line: Vec<Glyph> = Vec::new();
let mut used = 0usize;
let mut gap: Option<Glyph> = None;
for (word, space) in words {
for chunk in hard_break(&word, width) {
let sep = gap.is_some() as usize;
let cells = glyphs_width(chunk);
if !line.is_empty() && used + sep + cells > width {
lines.push(std::mem::take(&mut line));
used = 0;
gap = None; }
if let Some(sp) = gap.take() {
line.push(sp);
used += 1;
}
line.extend_from_slice(chunk);
used += cells;
}
gap = space;
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
let mut out = Vec::new();
if word.is_empty() {
return out;
}
let (mut start, mut used) = (0usize, 0usize);
for c in clusters(word) {
if used > 0 && used + c.cells > width {
out.push(&word[start..c.glyph]);
start = c.glyph;
used = 0;
}
used += c.cells;
}
out.push(&word[start..]);
out
}
fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
let mut s = String::new();
s.push(left);
for (i, w) in widths.iter().enumerate() {
if i > 0 {
s.push(mid);
}
for _ in 0..w + 2 {
s.push('─');
}
}
s.push(right);
s
}
fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
for (gi, cluster) in text.grapheme_indices(true) {
for (ci, ch) in cluster.char_indices() {
out.push(Glyph {
ch,
style,
src: base_src + gi + ci,
stop: ci == 0,
});
}
}
}
fn push_escaped_text(
out: &mut Vec<Glyph>,
text: &str,
span: Range<usize>,
source: &str,
style: Style,
) {
let end = span.end.min(source.len());
let src = source.get(span.start..end).unwrap_or("");
if src.len() == text.len() {
push_text(out, text, span.start, style);
return;
}
let sb = src.as_bytes();
let mut si = 0usize;
for (_, cluster) in text.grapheme_indices(true) {
for (ci, ch) in cluster.char_indices() {
while si < sb.len() && !src[si..].starts_with(ch) {
si += src[si..].chars().next().map_or(1, char::len_utf8);
}
out.push(Glyph {
ch,
style,
src: span.start + si.min(src.len()),
stop: ci == 0,
});
si += src[si..]
.chars()
.next()
.map_or(ch.len_utf8(), char::len_utf8);
}
}
}
fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
let style = Style::default().role(role);
text.chars()
.map(|ch| Glyph {
ch,
style,
src,
stop: false,
})
.collect()
}
fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
let mut v = a.to_vec();
v.extend_from_slice(b);
v
}
fn prefix_width(prefix: &[Glyph]) -> usize {
glyphs_width(prefix)
}
fn media_label(dest: &str) -> String {
if dest.is_empty() {
return "image".to_string();
}
if dest.starts_with("data:") {
return "data:…".to_string();
}
let clean = dest.split(['?', '#']).next().unwrap_or(dest);
let tail = clean
.trim_end_matches('/')
.rsplit(['/', '\\'])
.next()
.unwrap_or(clean);
if tail.is_empty() {
dest.to_string()
} else {
tail.to_string()
}
}
fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for (k, v) in attrs {
if k == "class" {
if let Some(v) = v
&& !v.is_empty()
{
parts.push(v.clone());
}
} else if v.as_deref().unwrap_or("").is_empty() {
parts.push(k.clone());
}
}
(!parts.is_empty()).then(|| parts.join(" "))
}
fn heading_style(level: u32) -> Style {
Style::default().role(Role::Heading(level.min(255) as u8))
}
pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
node.origin == Some(ContainerOrigin::Directive)
}
pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
(node.origin == Some(ContainerOrigin::Element))
.then_some(node.name.as_deref())
.flatten()
}
pub(crate) fn is_inline(node: &FlatNode) -> bool {
if node.kind == Kind::Container {
return container_is_directive(node) && node.directive_form == Some(DirectiveForm::Text);
}
is_inline_kind(&node.kind)
}
pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
matches!(
kind,
Kind::Str
| Kind::SoftBreak
| Kind::HardBreak
| Kind::NonBreakingSpace
| Kind::Emph
| Kind::Strong
| Kind::Mark
| Kind::Insert
| Kind::Delete
| Kind::Verbatim
| Kind::InlineMath
| Kind::DisplayMath
| Kind::Url
| Kind::Email
| Kind::Link
| Kind::Image
| Kind::SmartPunctuation
| Kind::Superscript
| Kind::Subscript
| Kind::FootnoteReference
)
}
#[cfg(test)]
pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
assert_eq!(
ra.glyphs.len(),
rb.glyphs.len(),
"row {i} glyph count ({ctx})"
);
for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
assert_eq!(
(ga.ch, ga.src, ga.stop, ga.style),
(gb.ch, gb.src, gb.stop, gb.style),
"row {i} glyph {j} ({ctx})"
);
}
}
assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
assert_eq!(a.stops, b.stops, "stops ({ctx})");
assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
}
assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
assert_eq!(a.media, b.media, "images ({ctx})");
}
#[cfg(test)]
mod tests {
use super::*;
use twig::{Editor, Format, NodeId};
fn map(src: &str) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn map_djot(src: &str) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Djot).unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.filter(|g| g.ch == ch)
.map(|g| g.style.baseline)
.collect()
}
fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
build_t(&ed.nodes().unwrap(), src, wrap)
}
fn map_directives(src: &str) -> VisualMap {
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
twig::MarkdownExtensions {
directives: true,
..Default::default()
},
)
.unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
}
fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
build(nodes, src, wrap, false, &HashMap::new(), None)
}
fn rendered(m: &VisualMap) -> String {
m.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn render_both(
ed: &mut Editor,
src: &str,
wrap: Option<usize>,
cache: &mut BlockCache,
) -> (VisualMap, VisualMap) {
let all = ed.nodes().unwrap();
let media_rows = HashMap::new();
let plain = build(&all, src, wrap, false, &media_rows, None);
let top = top_blocks(ed);
let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
ed.subtree(NodeId(id)).unwrap_or_default()
});
(plain, cached)
}
#[test]
fn a_glyph_never_lands_inside_the_character_before_it() {
let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
let vmap = map(src);
for (r, row) in vmap.rows.iter().enumerate() {
assert!(
src.is_char_boundary(row.end_src.min(src.len())),
"row {r} ends at {} — inside a character",
row.end_src
);
for g in &row.glyphs {
assert!(
src.is_char_boundary(g.src.min(src.len())),
"row {r} has {:?} at {}, which is inside a character",
g.ch,
g.src
);
}
}
let text: String = vmap
.rows
.iter()
.flat_map(|r| r.glyphs.iter().map(|g| g.ch))
.collect();
assert!(text.contains("[…]"), "the elision should render: {text:?}");
let close = vmap
.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.find(|g| g.ch == ']')
.expect("a closing bracket");
assert_eq!(
src[close.src..].chars().next(),
Some(']'),
"the bracket glyph should stand on the source's own `]`"
);
}
#[test]
fn build_cached_matches_build() {
let docs = [
"# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
"## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
"para one\n\n```\ncode\nlines\n```\n\nafter code\n",
"| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
"line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
"> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
"intro\n\n\n\nbetween\n\n\n\nend\n",
"- text item\n- \n- more text\n",
"A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
"note[^a]\n\n[^a]: body **bold**\n wrapped on\n three lines\n\nafter\n",
"# Title\n\nThe quick brown fox.\n\nA tail with no newline",
"A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
];
for wrap in [None, Some(80usize), Some(20)] {
for src in docs {
let ctx = format!("wrap={wrap:?} src={src:?}");
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let mut cache = BlockCache::default();
let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
let at = (src.len() / 2..=src.len())
.find(|&i| src.is_char_boundary(i))
.unwrap();
ed.edit_range(at, at, "Z").unwrap();
let src2 = ed.source_str().unwrap();
let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
ed.edit_range(at, at + 1, "").unwrap();
let src3 = ed.source_str().unwrap();
let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
}
}
}
#[test]
fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
let src = "A claim[^1] worth checking.\n\n# A heading with a reference[^1] in it\n\n[^1]: The first note.\n[^note]: A note with a word for a label.";
let mut ed = Editor::new_str(src, Format::Djot).unwrap();
let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
assert_maps_eq(&plain, &cached, "a definition ending the file");
let text = rendered(&cached);
assert!(
text.ends_with("[note] A note with a word for a label."),
"the last definition should render itself: {text:?}"
);
assert_eq!(
text.matches("A heading with a reference").count(),
1,
"the heading should render exactly once: {text:?}"
);
let mut cache = BlockCache::default();
let first = "first para\n\n# A heading\n\nlast para with no newline";
let mut ed = Editor::new_str(first, Format::Djot).unwrap();
let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
assert!(rendered(&warm).ends_with("last para with no newline"));
let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
let mut ed = Editor::new_str(second, Format::Djot).unwrap();
let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
assert_maps_eq(&plain, &cached, "edited last block, warm cache");
let text = rendered(&cached);
assert!(
text.ends_with("DIFFERENT text without a newline"),
"the warm cache served the pre-edit rows: {text:?}"
);
}
#[test]
fn resolves_markup_to_plain_text() {
let text = rendered(&map("# Title\n\na **bold** word\n"));
assert!(!text.contains('#'), "heading marker shown: {text:?}");
assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
assert!(text.contains("Title") && text.contains("bold word"));
}
#[test]
fn every_glyph_points_at_its_source_byte() {
let src = "a **bold** c\n";
let m = map(src);
for row in &m.rows {
for g in &row.glyphs {
if g.src < src.len()
&& src.is_char_boundary(g.src)
&& let Some(sc) = src[g.src..].chars().next()
&& sc == g.ch
{
continue;
}
panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
}
}
}
#[test]
fn offset_and_position_round_trip_on_visible_text() {
let m = map("hello world\n");
let (r, c) = m.pos_of_offset(6); assert_eq!(m.offset_of_pos(r, c), 6);
}
#[test]
fn unwrapped_mode_emits_one_row_per_paragraph() {
let long = "one two three four five six seven eight nine ten eleven twelve\n";
let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
assert!(wrapped.num_rows() > 1, "narrow column should wrap");
assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
assert_eq!(text.trim_end(), long.trim_end());
}
fn line_texts(m: &VisualMap) -> Vec<String> {
m.rows
.iter()
.map(|r| {
r.glyphs
.iter()
.map(|g| g.ch)
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn preserve_lays_each_soft_break_on_its_own_row() {
let src = "one two\nthree four\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let folded = build_t(&ed.nodes().unwrap(), src, None);
assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
assert_eq!(
line_texts(&folded),
vec!["one two three four"],
"break folded to a space"
);
let kept = map_preserve(src, None);
assert_eq!(
line_texts(&kept),
vec!["one two", "three four"],
"preserve: a row per line"
);
}
#[test]
fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
let src = "one two\nthree four\n";
let m = map_preserve(src, None);
assert!(
!m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
"the break glyph is dropped"
);
assert_eq!(
m.rows[0].end_src, 7,
"the first row ends at the newline byte"
);
assert!(m.is_stop(7), "the newline offset is a caret stop");
let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
assert!(
offs.windows(2).all(|w| w[0] < w[1]),
"offsets not unique: {offs:?}"
);
}
#[test]
fn preserved_lines_wrap_independently() {
let src = "alpha beta gamma\ndelta epsilon\n";
let m = map_preserve(src, Some(12));
assert_eq!(
line_texts(&m),
vec!["alpha beta", "gamma", "delta", "epsilon"],
"each source line wraps on its own"
);
}
#[test]
fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
let m = map("A\n\n\n\nB\n");
let text: Vec<String> = m
.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
assert!(
offs.windows(2).all(|w| w[0] < w[1]),
"offsets not unique: {offs:?}"
);
}
#[test]
fn a_tight_block_boundary_still_gets_one_separator() {
let m = map("# H\ntext\n");
let text: Vec<String> = m
.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
}
#[test]
fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
let m = map("a\\*b\n");
let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
}
#[test]
fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
let m = map("\\# hi\n");
let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
assert_eq!(text, "# hi");
assert_eq!(
m.rows[0].glyphs[0].src, 1,
"the # is at source byte 1, past the \\"
);
}
#[test]
fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
assert_eq!(rendered(&map("- a\n - b\n")), "• a\n • b");
}
#[test]
fn a_loose_nested_list_keeps_its_real_blank_line() {
assert_eq!(rendered(&map("- a\n\n - b\n")), "• a\n \n • b");
}
#[test]
fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
let src = format!("{fm}# leaf\n\nA line.\n");
let m = map(&src);
let text = rendered(&m);
assert!(
!text.contains("config"),
"frontmatter body leaked: {text:?}"
);
assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"leaf"
);
assert_eq!(
m.content_start,
fm.len(),
"floor should be the first real block"
);
}
#[test]
fn a_document_without_frontmatter_has_a_zero_floor() {
let m = map("# leaf\n\nbody\n");
assert_eq!(m.content_start, 0);
}
#[test]
fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
let m = map("hello \n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"hello "
);
assert_eq!(
m.rows[0].end_src, 6,
"the row now ends past the trailing space"
);
assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
let m = map("hello \n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"hello "
);
assert_eq!(m.pos_of_offset(7), (0, 7));
}
#[test]
fn a_headings_trailing_space_is_a_caret_stop_too() {
let m = map("# hi \n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"hi "
);
assert_eq!(m.pos_of_offset(5), (0, 3));
}
#[test]
fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
let text = rendered(&map(TABLE));
assert!(
text.contains("│ Pear │ 3 │"),
"cell padding disturbed:\n{text}"
);
}
#[test]
fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
let fm = "---\ntitle: n\n---\n";
let m = map(&format!("{fm}# Hi\n\nbody\n"));
let below = m.num_rows() + 5;
let off = m.offset_of_pos(below, 0);
assert!(
m.is_stop(off),
"offset {off} from a below-content click is not a stop"
);
assert_eq!(
off,
m.stops.last().copied().unwrap(),
"should be the document's last stop"
);
assert!(
off > fm.len(),
"must not fall onto the hidden frontmatter floor"
);
}
#[test]
fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
for src in [
"hello \n",
"# A heading here \n\nbody text goes on \n",
"---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
] {
let m = map(src);
for row in 0..m.num_rows() + 3 {
for col in 0..30 {
let off = m.offset_of_pos(row, col);
assert!(
m.is_stop(off),
"row {row} col {col} → {off} is not a stop in {src:?}"
);
}
}
}
}
const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
#[test]
fn a_table_renders_as_an_aligned_grid() {
let text = rendered(&map(TABLE));
assert_eq!(
text,
"┌──────┬─────┐\n\
│ Name │ Qty │\n\
├──────┼─────┤\n\
│ Pear │ 3 │\n\
│ Fig │ 12 │\n\
└──────┴─────┘",
"got:\n{text}"
);
}
#[test]
fn table_columns_honour_their_alignment() {
let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
assert!(text.contains("│ x │ y │"), "centred column: {text:?}");
}
#[test]
fn table_borders_are_decoration_the_caret_never_lands_on() {
let m = map(TABLE);
for r in [0, 2, 5] {
assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
assert!(
!m.rows[r].glyphs.iter().any(|g| g.stop),
"row {r} has a stop"
);
}
let header = &m.rows[1];
assert!(!header.decoration);
for g in &header.glyphs {
if g.ch == '│' {
assert!(!g.stop, "a border is not a caret stop");
}
}
let stops: String = header
.glyphs
.iter()
.filter(|g| g.stop)
.map(|g| g.ch)
.collect();
assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
}
#[test]
fn a_cell_maps_to_its_own_source_text() {
let m = map(TABLE);
let pear = TABLE.find("Pear").unwrap();
let (r, c) = m.pos_of_offset(pear);
assert_eq!(m.rows[r].glyphs[c].ch, 'P');
assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
}
#[test]
fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
let src = "| Ingredient | Notes |\n|---|---:|\n\
| flour milled coarse | sift it twice |\n| salt | a pinch |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(30));
let text = rendered(&m);
assert_eq!(
text,
"┌──────────────┬─────────────┐\n\
│ Ingredient │ Notes │\n\
├──────────────┼─────────────┤\n\
│ flour milled │ sift it │\n\
│ coarse │ twice │\n\
│ salt │ a pinch │\n\
└──────────────┴─────────────┘",
"got:\n{text}"
);
for (r, row) in m.rows.iter().enumerate() {
assert!(
row.glyphs.len() <= 30,
"row {r} overflows: {}",
row.glyphs.len()
);
}
}
#[test]
fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(20));
for (r, row) in m.rows.iter().enumerate() {
assert!(
row.glyphs.len() <= 20,
"row {r} overflows: {}",
row.glyphs.len()
);
}
let word = "antidisestablishmentarianism";
let at = src.find(word).unwrap();
for (i, ch) in word.char_indices() {
assert!(
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.any(|g| g.stop && g.src == at + i && g.ch == ch),
"{ch:?} at {} was lost to the break",
at + i
);
}
}
#[test]
fn a_code_block_maps_each_line_to_its_own_source_text() {
let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
let m = map(src);
for row in &m.rows {
for g in row.glyphs.iter().filter(|g| g.stop) {
assert_eq!(
src[g.src..].chars().next(),
Some(g.ch),
"glyph {:?} at {} isn't the source byte it claims",
g.ch,
g.src
);
}
}
}
#[test]
fn an_indented_code_block_maps_past_its_stripped_indent() {
let src = " indented\n code\n";
let m = map(src);
let stops: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
.collect();
assert_eq!(
stops[0],
('i', 4),
"first line should start past the indent"
);
assert!(
stops.contains(&('c', 17)),
"second line misplaced: {stops:?}"
);
}
#[test]
fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
let src = "```rust\nrust\n```\n";
let m = map(src);
let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
assert_eq!(first.src, 8, "matched the info string, not the code");
}
#[test]
fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
let m = map(src);
assert_eq!(m.code_blocks.len(), 1, "one code block");
let span = m.code_blocks[0].rows_span.clone();
let rows: Vec<String> = m.rows[span.clone()]
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
assert!(!rendered(&m).contains('▏'), "gutter still drawn");
assert!(
m.rows[span].iter().all(|r| r.code),
"every row in the span is flagged code"
);
}
#[test]
fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
let m = map_directives(src);
let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
assert!(!content_rows.is_empty(), "some row is flagged directive");
let after_rows: Vec<usize> = (0..m.rows.len())
.filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
.collect();
assert!(
after_rows.iter().all(|&i| !m.rows[i].directive),
"content outside the fence isn't tinted"
);
let labels: Vec<&str> = content_rows
.iter()
.filter_map(|&i| m.rows[i].directive_label.as_deref())
.collect();
assert_eq!(
labels,
vec!["public family"],
"only the first row carries the label"
);
assert_eq!(
rendered(&m)
.lines()
.filter(|l| !l.is_empty())
.collect::<Vec<_>>(),
vec!["hello", "world", "after"],
"fence markers don't leak into the rendered text"
);
}
#[test]
fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
let src = ":::vis{public family}\nhello\n:::\n";
let m = map_directives(src);
let label = m.rows.iter().find_map(|r| r.directive_label.clone());
assert_eq!(label.as_deref(), Some("public family"));
}
#[test]
fn a_text_directive_keeps_its_paragraph_visible() {
let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
let m = map_directives(src);
assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
let stops: usize = m
.rows
.iter()
.map(|r| r.glyphs.iter().filter(|g| g.stop).count())
.sum();
assert_eq!(stops, "Text with HTML inline.".chars().count());
assert!(m.rows.iter().all(|r| !r.directive));
}
#[test]
fn a_text_directives_label_maps_to_its_true_source_bytes() {
let src = "x :abbr[HTML]{title=\"y\"} z\n";
let m = map_directives(src);
let stops: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| (g.ch, g.src))
.collect();
assert_eq!(
stops,
[
('x', 0),
(' ', 1),
('H', 8),
('T', 9),
('M', 10),
('L', 11),
(' ', 24),
('z', 25)
]
);
}
#[test]
fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
let m = map_directives(src);
for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
let at = src[g.src..].chars().next();
assert_eq!(
at,
Some(g.ch),
"glyph {:?} claims byte {}, which is {at:?}",
g.ch,
g.src
);
}
assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
}
#[test]
fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
let src = "x :abbr[a *b* c] y\n";
let m = map_directives(src);
let b = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.find(|g| g.ch == 'b')
.expect("the emphasised char");
assert!(b.style.italic, "the label's *b* lost its emphasis");
assert_eq!(b.src, 11, "the label's *b* lost its source byte");
}
#[test]
fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
let m = map_directives(src);
assert_eq!(
rendered(&m).trim_end(),
src.trim_end(),
"prose was eaten: {src:?}"
);
}
}
#[test]
fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
let src = "a :word b\n";
let m = map_directives(src);
let stops: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| (g.ch, g.src))
.collect();
assert_eq!(
stops,
"a :word b"
.chars()
.enumerate()
.map(|(i, c)| (c, i))
.collect::<Vec<_>>()
);
}
#[test]
fn an_attribute_bearing_text_directive_draws_a_chip() {
for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
let m = map_directives(src);
assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
}
let m = map_directives("a :foo{title=\"x\"} b\n");
assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
}
#[test]
fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
let src = "a :vis{.family} b\n";
let m = map_directives(src);
let stops: Vec<usize> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| g.src)
.collect();
assert_eq!(stops, [0, 1, 2, 15, 16]);
}
#[test]
fn a_paragraph_holding_only_a_chip_is_still_navigable() {
let m = map_directives(":vis{.family}\n");
assert!(
m.row_is_navigable(0),
"a chip-only paragraph has no caret home"
);
assert_eq!(
m.offset_of_pos(0, 0),
0,
"its caret home isn't the directive's start"
);
}
#[test]
fn a_ratio_or_a_clock_time_is_never_a_directive() {
let src = "ratio 3:4 and 10:30\n";
assert_eq!(
rendered(&map_directives(src)).trim_end(),
"ratio 3:4 and 10:30"
);
}
#[test]
fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
let m = map_directives(src);
let row = m
.rows
.iter()
.position(|r| r.leaf_directive.is_some())
.expect("a placeholder row");
assert_eq!(
m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
"⧉ embed"
);
assert!(
m.rows[row].glyphs.iter().any(|g| g.stop),
"the caret can land on it"
);
assert!(
m.rows[row].directive,
"a frontend frames it like the container form"
);
assert_eq!(m.directives.len(), 1);
let info = &m.directives[0];
assert_eq!(info.name, "embed");
assert_eq!(info.rows_span, row..row + 1);
assert_eq!(info.attr("src"), Some("demo.html"));
assert_eq!(info.attr("height"), Some("400"));
assert_eq!(info.attr("nope"), None);
assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
}
#[test]
fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
assert_eq!(m.directives[0].label, "Audience demo");
let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
assert_eq!(rendered("ed).trim_end(), "│ ⧉ embed");
assert_eq!(quoted.directives[0].name, "embed");
}
#[test]
fn a_container_directive_is_still_a_panel_not_a_placeholder() {
let m = map_directives(":::note{.warning}\nBody\n:::\n");
assert!(
m.directives.is_empty(),
"a container publishes no placeholder"
);
assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
assert_eq!(rendered(&m).trim_end(), "Body");
assert!(
m.rows
.iter()
.any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
);
}
fn doc_built(src: &str) -> crate::Doc {
let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
doc.build_visual(80);
doc
}
fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
twig::MarkdownExtensions {
directives: true,
html_elements: true,
..Default::default()
},
)
.unwrap();
ed.nodes()
.unwrap()
.iter()
.filter(|n| n.kind == Kind::Container)
.map(|n| {
(
n.name.clone().unwrap_or_default(),
container_is_directive(n),
n.directive_form,
)
})
.collect()
}
#[test]
fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
for (src, name, want) in [
(":::note{.a}\nbody\n:::\n", "note", true),
("::embed{src=x}\n", "embed", true),
("a :vis[hi]{.b} b\n", "vis", true),
("<div class=\"x\">\nhi\n</div>\n", "div", false),
("<video src=\"v.mp4\" controls></video>\n", "video", false),
("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
("<figure>\n\nhi\n\n</figure>\n", "figure", false),
(
"<video src=\"http://x.test/v.mp4\" controls></video>\n",
"video",
false,
),
(
"<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
"source",
false,
),
] {
let found = containers(src);
let hit = found.iter().find(|(n, ..)| n == name);
let Some((_, is_directive, form)) = hit else {
panic!("no `{name}` container in {src:?} — found {found:?}");
};
assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
}
let div = containers("<div class=\"x\">\nhi\n</div>\n");
let note = containers(":::note{.a}\nbody\n:::\n");
assert_eq!(
div[0].2, note[0].2,
"if these ever differ, `directive_form` became usable and this rule can go"
);
}
#[test]
fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
for (src, ctx) in [
("> ::embed{src=\"x\"}\n", "quoted"),
("- ::embed{src=\"x\"}\n", "listed"),
(">> ::embed{src=\"x\"}\n", "twice quoted"),
] {
let m = map_directives(src);
assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
assert_eq!(m.directives[0].name, "embed", "{ctx}");
}
}
#[test]
fn a_video_is_still_media_and_not_a_directive() {
let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
assert!(
doc.vmap.rows.iter().all(|r| !r.directive),
"the video drew directive chrome"
);
}
#[test]
fn a_directive_needs_the_extension_flag() {
let src = ":::vis{.public}\nhello\n:::\n";
let m = map(src);
assert!(m.rows.iter().all(|r| !r.directive));
assert!(rendered(&m).contains(":::vis{.public}"));
}
#[test]
fn a_footnote_reference_keeps_its_paragraph_visible() {
let src = "A claim[^1] and more.\n";
let m = map(src);
assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
assert!(!rendered(&m).contains('^'));
}
#[test]
fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
let m = map("A claim[^1] and more.\n");
assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
}
#[test]
fn a_footnote_reference_keeps_the_link_role_it_had() {
let m = map("A claim[^1].\n");
let label = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.find(|g| g.ch == '1')
.unwrap();
assert_eq!(label.style.role, Role::Link);
assert_eq!(label.style.baseline, Baseline::Super);
}
#[test]
fn a_superscript_and_a_subscript_sit_off_the_baseline() {
let m = map_djot("H~2~O and x^2^\n");
assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
}
#[test]
fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
let m = map_djot("# Heading x^2^\n");
let two = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.find(|g| g.ch == '2')
.unwrap();
assert_eq!(two.style.baseline, Baseline::Super);
assert_eq!(two.style.role, Role::Heading(1), "still heading text");
}
#[test]
fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
let src = "see[^note] here\n";
let m = map(src);
let stops: Vec<usize> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| g.src)
.collect();
for off in 5..9 {
assert!(
stops.contains(&off),
"label byte {off} isn't a caret stop: {stops:?}"
);
}
for off in [3usize, 4, 9] {
assert!(
!stops.contains(&off),
"delimiter byte {off} is a caret stop: {stops:?}"
);
}
}
#[test]
fn a_task_item_draws_its_box_where_the_bullet_would_be() {
let m = map("- [ ] todo\n- [x] done\n- plain\n");
assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
assert_eq!(ticks, [Some(false), Some(true), None]);
}
#[test]
fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
let m = map_at(
"- [x] a much longer task that has to wrap somewhere\n",
Some(20),
);
assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
assert_eq!(m.rows[0].task, Some(true));
assert!(
m.rows[1..].iter().all(|r| r.task.is_none()),
"only the first row"
);
assert!(
rendered(&m)
.lines()
.nth(1)
.is_some_and(|l| l.starts_with(" "))
);
}
#[test]
fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
let m = map("- see [1] below\n");
assert_eq!(rendered(&m), "• see [1] below");
assert_eq!(m.rows[0].task, None);
}
#[test]
fn a_footnote_definition_renders_where_it_was_written() {
let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
let m = map(src);
let text = rendered(&m);
assert!(
text.contains("The note body."),
"the note body is invisible: {text:?}"
);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
}
#[test]
fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
let src = "x[^a].\n\n[^a]: body\n";
let m = map(src);
let body: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop && g.src >= 14)
.map(|g| (g.ch, g.src))
.collect();
assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
}
#[test]
fn an_empty_footnote_definition_still_shows_its_marker() {
let src = "x[^1]\n\n[^1]:\n";
let m = map(src);
assert!(
rendered(&m).contains("[1] "),
"no marker row: {:?}",
rendered(&m)
);
}
#[test]
fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
let m = map_at(src, Some(24));
let text = rendered(&m);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines[1].trim_end(), "[src] one two three four");
assert!(
lines[2].starts_with(" "),
"body doesn't hang: {:?}",
lines[2]
);
assert_eq!(lines[2].trim(), "five six seven");
}
#[test]
fn a_code_block_leaves_exactly_one_blank_row_below_it() {
let src = "para\n\n```\ncode\n```\n\nafter\n";
let m = map(src);
let code_end = m.code_blocks[0].rows_span.end;
let after = m
.rows
.iter()
.position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
.unwrap();
assert_eq!(
after - code_end,
1,
"exactly one row between code and 'after'"
);
}
#[test]
fn a_fenced_block_publishes_its_language_on_its_code_block() {
assert_eq!(
map("```rust\nlet x = 1;\n```\n").code_blocks[0]
.lang
.as_deref(),
Some("rust")
);
assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
assert_eq!(map(" indented\n").code_blocks[0].lang, None);
}
#[test]
fn inline_code_is_not_a_code_block() {
let m = map("a `snippet` b\n");
assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
assert!(
m.rows.iter().all(|r| !r.code),
"inline code flagged a code row"
);
}
#[test]
fn caret_steps_over_hidden_delimiters() {
let m = map("a **bold** c\n");
let (r, c) = m.pos_of_offset(7);
assert_eq!(m.offset_of_pos(r, c + 1), 10);
}
#[test]
fn a_table_is_published_structurally_beside_its_picture() {
let m = map(TABLE);
let t = &m.tables[0];
let cell = |r: usize, c: usize| -> String {
t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
};
assert_eq!(t.grid.len(), 3, "head + two body rows");
assert_eq!(
(cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
("Name".into(), "Qty".into(), "Pear".into(), "12".into())
);
assert_eq!(
t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
[true, false, false]
);
assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
}
#[test]
fn a_block_media_is_published_structurally_beside_its_placeholder() {
let m = map("intro\n\n\n\nend\n");
assert_eq!(m.media.len(), 1, "one block image");
let img = &m.media[0];
assert_eq!(img.destination, "img/cat.png");
assert_eq!(img.alt, "a cat");
let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
assert_eq!(
img.rows_span.end - img.rows_span.start,
1,
"one placeholder row"
);
assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
assert!(m.rows[img.rows_span.start].media.is_some());
}
#[test]
fn an_image_without_alt_labels_itself_with_its_filename() {
let m = map("\n");
let row = &m.rows[m.media[0].rows_span.start];
assert_eq!(
row.glyphs.iter().map(|g| g.ch).collect::<String>(),
"🖼 beach.jpg"
);
assert_eq!(m.media[0].alt, "");
}
#[test]
fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
let src = "\n";
let m = map(src);
let img = &m.rows[m.media[0].rows_span.start];
let start = 0; let end = "".len();
assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
assert_eq!(img.end_src, end, "the row ends past the image");
assert_eq!(m.stops.first(), Some(&start));
assert!(m.stops.contains(&end), "a stop sits after the image");
assert!(!m.stops.iter().any(|&s| s > start && s < end));
}
#[test]
fn an_inline_image_amid_text_is_not_a_block_media() {
let m = map("see  here\n");
assert!(m.media.is_empty(), "not a block image");
assert!(
rendered(&m).contains("a cat"),
"alt text still renders inline"
);
}
fn doc_media(src: &str) -> Vec<MediaInfo> {
let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
doc.build_visual(80);
doc.vmap.media.clone()
}
#[test]
fn a_video_block_is_media_with_its_src_poster_and_kind() {
let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
assert_eq!(m.len(), 1, "the video is one block media");
assert_eq!(m[0].kind, MediaKind::Video);
assert_eq!(m[0].destination, "clip.mp4");
assert_eq!(m[0].poster, "still.png");
}
#[test]
fn a_single_line_video_is_a_block_too() {
let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
assert_eq!(m.len(), 1, "single-line <video> is a block");
assert_eq!(m[0].kind, MediaKind::Video);
assert_eq!(m[0].destination, "clip.mp4");
}
#[test]
fn a_single_line_picture_is_a_block_with_its_alternatives() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
<img src=\"l.svg\" alt=\"banner\"></picture>\n";
let m = doc_media(src);
assert_eq!(m.len(), 1);
assert_eq!(m[0].kind, MediaKind::Image);
assert_eq!(m[0].destination, "l.svg");
assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
}
#[test]
fn an_audio_block_is_media_with_no_poster() {
let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
assert_eq!(m.len(), 1);
assert_eq!(m[0].kind, MediaKind::Audio);
assert_eq!(m[0].destination, "take.mp3");
assert!(m[0].poster.is_empty(), "audio has no poster frame");
}
#[test]
fn a_videos_source_children_are_its_candidates_typed_by_mime() {
let src = "<video controls>\n\
<source src=\"a.webm\" type=\"video/webm\">\n\
<source src=\"a.mp4\" type=\"video/mp4\">\n\
fallback\n\
</video>\n";
let m = doc_media(src);
assert_eq!(m.len(), 1);
assert!(
m[0].destination.is_empty(),
"no src attribute on the element"
);
assert_eq!(m[0].sources.len(), 2);
assert_eq!(m[0].sources[0].srcset, "a.webm");
assert_eq!(m[0].sources[0].mime, "video/webm");
assert_eq!(m[0].sources[1].srcset, "a.mp4");
assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
}
#[test]
fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
doc.build_visual(80);
let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
let text: String = row.glyphs.iter().map(|g| g.ch).collect();
assert!(
text.starts_with('🎬'),
"video sigil, not the image one: {text:?}"
);
assert!(row.media.is_some(), "the mark rides the placeholder row");
}
#[test]
fn a_picture_block_carries_its_source_alternatives() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
let images = doc_media(src);
assert_eq!(images.len(), 1, "the picture is one block image");
let img = &images[0];
assert_eq!(img.destination, "light.svg", "fallback is the <img>");
assert_eq!(img.alt, "banner");
assert_eq!(
img.sources,
vec![MediaSource {
media: "(prefers-color-scheme: dark)".into(),
srcset: "dark.svg".into(),
mime: String::new(),
}],
);
}
#[test]
fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
let images = doc_media(src);
assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
assert_eq!(images[0].destination, "l.svg");
assert_eq!(images[0].sources.len(), 1);
assert_eq!(images[0].sources[0].srcset, "d.svg");
}
#[test]
fn a_plain_image_has_no_media_sources() {
let images = doc_media("\n");
assert_eq!(images.len(), 1);
assert!(
images[0].sources.is_empty(),
"no <picture>, no alternatives"
);
}
#[test]
fn resolve_picks_the_source_matching_the_scheme() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
let images = doc_media(src);
let img = &images[0];
assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
}
#[test]
fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
let plain = doc_media("\n");
assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
let m = doc_media(
"<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
);
assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
assert_eq!(
m[0].resolve(ColorScheme::Dark),
"f.svg",
"no dark source → <img>"
);
}
#[test]
fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
assert_eq!(first_srcset_url(" solo.svg "), Some("solo.svg"));
assert_eq!(first_srcset_url(""), None);
assert!(media_matches("", ColorScheme::Light));
assert!(media_matches(
"(prefers-color-scheme:dark)",
ColorScheme::Dark
));
assert!(!media_matches(
"(prefers-color-scheme: dark)",
ColorScheme::Light
));
}
#[test]
fn a_block_media_carries_its_list_prefix() {
let m = map("- \n");
let row = &m.rows[m.media[0].rows_span.start];
let text: String = row.glyphs.iter().map(|g| g.ch).collect();
assert!(
text.starts_with("• "),
"the list marker prefixes the image row: {text:?}"
);
assert!(text.contains("🖼 alt"));
}
#[test]
fn the_structural_table_spans_exactly_its_drawn_rows() {
let m = map(&format!("before\n\n{TABLE}\nafter\n"));
let t = &m.tables[0];
let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
assert!(
row_text(t.rows_span.start).starts_with('┌'),
"opens on the top border"
);
assert!(
row_text(t.rows_span.end - 1).starts_with('└'),
"closes on the bottom border"
);
assert!(
!row_text(t.rows_span.start - 1).contains('┌'),
"the row before the span is not the table's"
);
assert_eq!(
row_text(t.rows_span.end),
"",
"the span ends before the gap row"
);
}
#[test]
fn a_nested_tables_structure_carries_the_block_prefix() {
let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
let t = &m.tables[0];
let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
let drawn: String = m.rows[t.rows_span.start]
.glyphs
.iter()
.map(|g| g.ch)
.collect();
assert!(
drawn.starts_with(&prefix),
"picture and structure disagree: {drawn:?}"
);
}
#[test]
fn a_top_level_table_carries_no_prefix() {
assert!(map(TABLE).tables[0].prefix.is_empty());
}
#[test]
fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
let src = "| Name |\n|------|\n| alpha beta gamma |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(12));
let drawn = rendered(&m);
let cell: String = m.tables[0].grid[1].cells[0]
.glyphs
.iter()
.map(|g| g.ch)
.collect();
assert_eq!(
cell, "alpha beta gamma",
"structure must not carry the wrap"
);
assert!(
drawn.lines().count() > 5,
"the picture should have wrapped, else this proves nothing:\n{drawn}"
);
}
#[test]
fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
for src in [
"| A | B |\n|---|---|\n| 你好 | y |\n",
"| A | B |\n|---|---|\n| a👨👩👧b | y |\n",
"| A | 漢字 |\n|---|---|\n| x | y |\n",
] {
let m = map(src);
let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"ragged grid {widths:?} for {src:?}:\n{}",
rendered(&m)
);
}
}
#[test]
fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
let src = "| A |\n|---|\n| 👨👩👧👨👩👧 |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(8));
let boundaries: Vec<usize> = src
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(src.len()))
.collect();
for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
assert!(
boundaries.contains(&off),
"stop at {off} is inside a character:\n{}",
rendered(&m)
);
}
}
#[test]
fn a_wrapped_cell_keeps_every_line_inside_its_column() {
let src = "| A |\n|---|\n| 你好世界漢字 |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(14));
for r in &m.rows {
assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
}
}
#[test]
fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
let glyphs = |s: &str| {
let mut out = Vec::new();
push_text(&mut out, s, 0, Style::default());
out
};
let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
let w = glyphs("你好世");
let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
assert_eq!(pieces, ["你好", "世"]);
let w = glyphs("你好");
let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
assert_eq!(pieces, ["你", "好"]);
assert!(hard_break(&[], 4).is_empty());
}
#[test]
fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
let m = map("- item\n- \n");
assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"• ",
"the empty item draws just its bullet",
);
assert!(
m.is_stop(m.rows[1].end_src),
"the empty item's caret home is not a stop"
);
assert_eq!(
m.pos_of_offset(m.rows[1].end_src),
(1, 2),
"caret sits after '• '"
);
}
#[test]
fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
let m = map(src);
let body = src.find("[title]").unwrap();
let end = src.find("\n\n[^3]").unwrap();
let (first, last) = m.row_range_for(body..end);
assert_eq!(
first, last,
"a one-block note is one row, not a span onto the next"
);
assert_ne!(
m.pos_of_offset(end - 1).0,
last,
"the forward snap still leaves the note's row — that is the whole point",
);
let plain = src.find("bare text").unwrap();
let plain_end = src.find("\n\n[^2]").unwrap();
let (pf, pl) = m.row_range_for(plain..plain_end);
assert_eq!(pf, pl);
assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
}
#[test]
fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
let src = "> one\n>\n> two\n\nafter\n";
let m = map(src);
let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
assert_eq!((first, last), (0, 2));
let (f, l) = m.row_range_for(0..1);
assert_eq!((f, l), (0, 0));
}
#[test]
fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
let m = map("a\n\n> \n\nb\n");
assert_eq!(
m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ ",
"the empty quote draws just its gutter",
);
assert!(
m.rows[2]
.glyphs
.iter()
.all(|g| g.style.role == Role::QuoteGutter)
);
assert!(
!m.rows[2].decoration,
"it is a line text can go on, not a drawn gap"
);
assert!(
m.is_stop(m.rows[2].end_src),
"the empty quote's caret home is not a stop"
);
assert_eq!(
m.pos_of_offset(m.rows[2].end_src),
(2, 2),
"caret sits after '│ '"
);
let m = map("> \n");
assert_eq!(m.num_rows(), 1);
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ "
);
}
#[test]
fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
let m = map("> a\n>\n> \n");
assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
for (i, row) in m.rows.iter().enumerate() {
let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
assert!(
!row.decoration,
"row {i} is a line to type on, not a drawn gap"
);
assert!(m.is_stop(row.end_src), "row {i} has no caret home");
}
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ a"
);
assert!(m.rows[0].end_src < m.rows[1].end_src);
assert!(m.rows[1].end_src < m.rows[2].end_src);
let m = map("> a\n\nb\n");
assert_eq!(m.num_rows(), 3);
assert_eq!(
m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
"b"
);
assert!(
!m.rows[1]
.glyphs
.iter()
.any(|g| g.style.role == Role::QuoteGutter)
);
let m = map("> > a\n>\n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ │ a"
);
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ "
);
let m = map("> > a\n> >\n");
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ │ "
);
let m = map("> a\n>\n> b\n");
assert_eq!(m.num_rows(), 3);
assert!(
m.rows[1].decoration,
"the gap between two quoted blocks is still a gap"
);
}
#[test]
fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
let m = map("1. item\n2. \n");
assert_eq!(m.num_rows(), 2);
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"2. "
);
assert!(m.is_stop(m.rows[1].end_src));
assert_eq!(
m.pos_of_offset(m.rows[1].end_src),
(1, 3),
"caret sits after '2. '"
);
}
#[test]
fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
let m = map("# \n");
assert_eq!(m.num_rows(), 1);
assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
}
#[test]
fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
let m = map("# \n");
assert_eq!(
m.rows[0].heading,
Some(1),
"the empty heading knows its level"
);
let m = map_at(
"## a heading long enough to wrap over two rows\n\nbody\n",
Some(20),
);
let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
assert!(
heads.iter().filter(|h| **h == Some(2)).count() >= 2,
"got {heads:?}"
);
assert_eq!(
m.rows.last().and_then(|r| r.heading),
None,
"the paragraph under it is not a heading",
);
}
#[test]
fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
let m = map("text\n\n# \n\n");
let end = m.rows.last().expect("a trailing blank row").end_src;
assert_eq!(end, 10, "the trailing rows must end at their real offsets");
assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
assert!(
m.rows[3..].iter().all(|r| r.end_src > 8),
"rows below own later offsets"
);
}
fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
m.rows
.iter()
.filter_map(|r| r.boundary)
.map(|b| (b.above, b.below))
.collect()
}
#[test]
fn a_boundary_says_which_blocks_it_divides() {
use BlockClass::*;
let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
assert_eq!(
boundaries(&m),
vec![
(Paragraph, Paragraph),
(Paragraph, Heading),
(Heading, Paragraph),
(Paragraph, Quote),
(Quote, Code),
(Code, Paragraph),
],
"each gap names the pair it falls between, in document order"
);
}
#[test]
fn the_trailing_gap_closes_the_last_block() {
let m = map("# Head\n\n\n");
assert_eq!(
boundaries(&m),
vec![(BlockClass::Heading, BlockClass::Paragraph)]
);
}
#[test]
fn only_the_drawn_gap_rows_carry_a_boundary() {
let m = map("one\n\ntwo\n");
for row in &m.rows {
assert_eq!(
row.boundary.is_some(),
row.decoration,
"a boundary is exactly a drawn gap row: {:?}",
row.glyphs.iter().map(|g| g.ch).collect::<String>()
);
}
}
#[test]
fn preserve_flow_labels_no_boundary() {
let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
assert!(boundaries(&m).is_empty());
}
#[test]
fn a_list_draws_no_boundary_between_its_items() {
for src in ["- one\n- two\n", "- one\n\n- two\n"] {
let m = map(src);
assert!(
boundaries(&m).is_empty(),
"no gap row inside the list of {src:?}"
);
}
let m = map("- one\n- two\n\npara\n");
assert_eq!(
boundaries(&m),
vec![(BlockClass::List, BlockClass::Paragraph)]
);
}
#[test]
fn a_nested_boundary_names_the_blocks_inside_the_container() {
let m = map("> one\n>\n> two\n");
assert_eq!(
boundaries(&m),
vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
);
}
#[test]
fn the_incremental_walk_labels_boundaries_like_the_full_one() {
let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let mut cache = BlockCache::default();
let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
assert_maps_eq(&full, &cached, "boundary labelling");
assert!(
!boundaries(&full).is_empty(),
"the fixture has boundaries to compare"
);
}
#[test]
fn every_caret_stop_opens_a_cluster_of_its_row() {
let src = "# 標題\n\na **bold** e\u{0301}mo👨👩👧ji `x` 你好\n\n\
- 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
| A | 值 |\n|---|---|\n| 你好 | 👩🚀 |\n";
let m = map(src);
for (r, row) in m.rows.iter().enumerate() {
let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
for (i, g) in row.glyphs.iter().enumerate() {
assert!(
!g.stop || openers.contains(&i),
"row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
so it is drawn at another glyph's column",
g.ch
);
}
}
}
}