use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar;
use crate::theme;
pub fn char_width(ch: char) -> usize {
ch.width().unwrap_or(0).max(1)
}
fn first_char(s: &str) -> char {
s.chars().next().unwrap_or(' ')
}
pub fn str_width(s: &str) -> usize {
s.chars().map(char_width).sum()
}
pub fn cut_at(widths: impl Iterator<Item = usize>, width: usize) -> usize {
let room = width.saturating_sub(1);
let mut used = 0;
let mut n = 0;
for cw in widths {
if used + cw > room {
break;
}
used += cw;
n += 1;
}
n
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Align {
Left,
Center,
Right,
}
pub const COL_SEP: &str = " │ ";
pub fn column_widths(rows: &[Vec<usize>], cols: usize) -> Vec<usize> {
let mut widths = vec![0usize; cols];
for row in rows {
for (i, w) in row.iter().enumerate().take(cols) {
widths[i] = widths[i].max(*w);
}
}
widths
}
pub fn pad_for(content: usize, width: usize, align: Align) -> (usize, usize) {
let pad = width.saturating_sub(content);
match align {
Align::Right => (pad, 0),
Align::Center => (pad / 2, pad - pad / 2),
Align::Left => (0, pad),
}
}
pub const MIN_COL: usize = 2;
pub fn fit_widths(widths: &[usize], total: usize) -> Vec<usize> {
let mut w = widths.to_vec();
if w.is_empty() {
return w;
}
let seps = COL_SEP.chars().count() * (w.len() - 1);
let budget = total.saturating_sub(seps);
let floor = MIN_COL * w.len();
if budget <= floor {
return vec![MIN_COL; w.len()];
}
while w.iter().sum::<usize>() > budget {
let (i, _) = w
.iter()
.enumerate()
.max_by_key(|(i, v)| (**v, std::cmp::Reverse(*i)))
.unwrap();
w[i] -= 1;
}
w
}
pub fn truncate(text: &str, width: usize) -> String {
if str_width(text) <= width {
return text.to_string();
}
let n = cut_at(text.chars().map(char_width), width);
let mut out: String = text.chars().take(n).collect();
out.push('…');
out
}
pub fn table_rule(widths: &[usize]) -> String {
widths
.iter()
.map(|w| "─".repeat(*w))
.collect::<Vec<_>>()
.join("─┼─")
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cell {
pub ch: char,
pub style: Style,
pub src: usize,
}
#[derive(Clone, Debug, Default)]
pub struct RLine {
pub cells: Vec<Cell>,
pub src_len: usize,
}
impl RLine {
pub fn raw(src: &str) -> RLine {
let cells = src
.chars()
.enumerate()
.map(|(i, ch)| Cell {
ch,
style: theme::PLAIN,
src: i,
})
.collect();
RLine {
cells,
src_len: src.chars().count(),
}
}
pub fn one_row(&self) -> Seg {
Seg {
cells: self.cells.clone(),
indent: 0,
end_src: self.src_len,
}
}
pub fn to_line(&self, selection: Option<(usize, usize)>) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut text = String::new();
let mut current: Option<Style> = None;
for cell in &self.cells {
let mut style = cell.style;
if let Some((a, b)) = selection {
if cell.src >= a && cell.src < b {
style = style.add_modifier(Modifier::REVERSED);
}
}
if current != Some(style) {
if let Some(s) = current {
spans.push(Span::styled(std::mem::take(&mut text), s));
}
current = Some(style);
}
text.push(cell.ch);
}
if let Some(s) = current {
spans.push(Span::styled(text, s));
}
if spans.is_empty() {
if let Some((a, b)) = selection {
if a < b {
spans.push(Span::styled(
" ".to_string(),
Style::new().add_modifier(Modifier::REVERSED),
));
}
}
}
Line::from(spans)
}
}
#[derive(Clone, Debug, Default)]
pub struct Seg {
pub cells: Vec<Cell>,
pub indent: usize,
pub end_src: usize,
}
impl Seg {
pub fn owns_src(&self, col: usize) -> bool {
col < self.end_src
}
pub fn display_to_source(&self, col: usize) -> usize {
let col = col.saturating_sub(self.indent);
let mut x = 0;
for c in &self.cells {
let w = char_width(c.ch);
if col < x + w {
return c.src;
}
x += w;
}
self.end_src
}
pub fn source_to_display(&self, col: usize) -> usize {
let mut x = 0;
for c in &self.cells {
if c.src >= col {
return self.indent + x;
}
x += char_width(c.ch);
}
self.indent + x
}
pub fn to_line(&self, selection: Option<(usize, usize)>) -> Line<'static> {
let inner = RLine {
cells: self.cells.clone(),
src_len: self.end_src,
}
.to_line(selection);
if self.indent == 0 {
return inner;
}
let mut spans = vec![Span::raw(" ".repeat(self.indent))];
spans.extend(inner.spans);
Line::from(spans)
}
}
pub fn wrap_rline(line: &RLine, width: usize) -> Vec<Seg> {
if width == 0 {
return vec![line.one_row()];
}
let indent = hanging_indent(&line.cells).min(width / 2);
let chars: Vec<char> = line.cells.iter().map(|c| c.ch).collect();
wrap_breaks(&chars, width, width - indent)
.into_iter()
.enumerate()
.map(|(i, (s, e))| Seg {
cells: line.cells[s..e].to_vec(),
indent: if i == 0 { 0 } else { indent },
end_src: line.cells.get(e).map_or(line.src_len, |c| c.src),
})
.collect()
}
fn hanging_indent(cells: &[Cell]) -> usize {
let ch = |i: usize| cells.get(i).map(|c: &Cell| c.ch);
let mut i = 0;
let mut w = 0;
let space = |i: &mut usize, w: &mut usize| {
while matches!(ch(*i), Some(' ') | Some('\t')) {
*w += 1;
*i += 1;
}
};
space(&mut i, &mut w);
let bar = first_char(theme::QUOTE_BAR);
while ch(i) == Some(bar) {
w += char_width(bar);
i += 1;
space(&mut i, &mut w);
}
let markers = [
first_char(theme::BULLET),
first_char(theme::CHECKED),
first_char(theme::UNCHECKED),
];
let mut j = i;
match ch(j) {
Some(c) if markers.contains(&c) => j += 1,
Some('-') | Some('*') | Some('+') => j += 1,
Some(c) if c.is_ascii_digit() => {
while matches!(ch(j), Some(c) if c.is_ascii_digit()) {
j += 1;
}
if !matches!(ch(j), Some('.') | Some(')')) {
return w;
}
j += 1;
}
_ => return w,
}
if ch(j) != Some(' ') {
return w;
}
let marker: usize = cells[i..j].iter().map(|c| char_width(c.ch)).sum();
w + marker + 1
}
pub fn wrap_breaks(chars: &[char], first: usize, rest: usize) -> Vec<(usize, usize)> {
if chars.is_empty() {
return vec![(0, 0)];
}
let mut out = Vec::new();
let mut start = 0;
let mut avail = first.max(1);
while start < chars.len() {
let mut x = 0;
let mut fit = start;
while fit < chars.len() {
let w = char_width(chars[fit]);
if x + w > avail {
break;
}
x += w;
fit += 1;
}
if fit >= chars.len() {
out.push((start, chars.len()));
break;
}
let brk = chars[start..=fit]
.iter()
.rposition(|c| *c == ' ')
.map(|p| start + p)
.filter(|p| *p > start);
let (end, next) = match brk {
Some(p) => (p, p + 1), None => {
let e = fit.max(start + 1); (e, e)
}
};
out.push((start, end));
start = next;
avail = rest.max(1);
}
if out.is_empty() {
out.push((0, chars.len()));
}
out
}
struct Builder<'a> {
src: &'a [char],
cells: Vec<Cell>,
}
impl<'a> Builder<'a> {
fn keep(&mut self, i: usize, style: Style) {
self.cells.push(Cell {
ch: self.src[i],
style,
src: i,
});
}
fn sub(&mut self, text: &str, style: Style, src: usize) {
for ch in text.chars() {
self.cells.push(Cell { ch, style, src });
}
}
}
pub fn style_inline(src: &str) -> Vec<Cell> {
let chars: Vec<char> = src.chars().collect();
let mut b = Builder {
src: &chars,
cells: Vec::with_capacity(chars.len()),
};
inline(&mut b, 0, theme::PLAIN);
b.cells
}
pub fn style_line(src: &str) -> RLine {
let chars: Vec<char> = src.chars().collect();
let src_len = chars.len();
let mut b = Builder {
src: &chars,
cells: Vec::with_capacity(src_len),
};
let mut i = 0;
if is_fence(src) {
for (idx, _) in chars.iter().enumerate() {
b.keep(idx, theme::code());
}
return RLine {
cells: b.cells,
src_len,
};
}
let trimmed = src.trim();
if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-') {
for (idx, _) in chars.iter().enumerate() {
b.sub("─", theme::marker(), idx);
}
return RLine {
cells: b.cells,
src_len,
};
}
if trimmed.starts_with('|') && trimmed.len() > 1 {
let rule = trimmed
.chars()
.all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'));
for (idx, ch) in chars.iter().enumerate() {
let style = if rule || *ch == '|' {
theme::marker()
} else {
theme::PLAIN
};
b.keep(idx, style);
}
return RLine {
cells: b.cells,
src_len,
};
}
let mut base = theme::PLAIN;
loop {
let mut j = i;
while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t') {
j += 1;
}
if j < chars.len() && chars[j] == '>' {
for k in i..j {
b.keep(k, theme::marker());
}
b.sub(theme::QUOTE_BAR, theme::marker(), j);
i = j + 1;
if i < chars.len() && chars[i] == ' ' {
b.keep(i, theme::marker());
i += 1;
}
base = theme::quote();
} else {
break;
}
}
while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
b.keep(i, base);
i += 1;
}
if i < chars.len() && chars[i] == '#' {
let mut h = i;
while h < chars.len() && chars[h] == '#' && h - i < 6 {
h += 1;
}
if h < chars.len() && chars[h] == ' ' {
base = theme::heading(h - i);
for k in i..=h {
b.sub("", base, k); }
i = h + 1;
inline(&mut b, i, base);
return RLine {
cells: b.cells,
src_len,
};
}
}
if let Some((marker, style, width)) = list_marker(&chars, i) {
b.sub(marker, style, i);
b.sub(" ", style, i + 1);
for k in i..i + width {
if k >= i + 2 {
b.sub("", style, k);
}
}
i += width;
if marker == theme::CHECKED {
base = base.patch(theme::done_text());
}
}
inline(&mut b, i, base);
RLine {
cells: b.cells,
src_len,
}
}
fn list_marker(chars: &[char], i: usize) -> Option<(&'static str, Style, usize)> {
let at = |k: usize| chars.get(k).copied();
let bullet = matches!(at(i), Some('-') | Some('*') | Some('+'));
if !bullet || at(i + 1) != Some(' ') {
return None;
}
if at(i + 2) == Some('[') && at(i + 4) == Some(']') && at(i + 5) == Some(' ') {
return match at(i + 3) {
Some(' ') => Some((theme::UNCHECKED, theme::marker(), 6)),
Some('x') | Some('X') => Some((theme::CHECKED, theme::done(), 6)),
_ => Some((theme::BULLET, theme::marker(), 2)),
};
}
Some((theme::BULLET, theme::marker(), 2))
}
fn inline(b: &mut Builder, mut i: usize, base: Style) {
while i < b.src.len() {
i = span_at(b, i, base).unwrap_or_else(|| {
b.keep(i, base);
i + 1
});
}
}
fn span_at(b: &mut Builder, i: usize, base: Style) -> Option<usize> {
let c = b.src[i];
if c == '[' && b.src.get(i + 1) == Some(&'[') && links::enabled() {
if let Some(w) = wikilink_at(b.src, i) {
let style = wiki_style(base, &w.target);
return Some(delimited(
b,
w.start,
w.label_start,
w.label_end,
w.end,
style,
));
}
}
if c == '`' {
let end = find(b.src, i + 1, '`')?;
return Some(delimited(b, i, i + 1, end, end + 1, theme::inline_code()));
}
if c == '[' {
if let Some(close) = find(b.src, i + 1, ']') {
if b.src.get(close + 1) == Some(&'(') {
if let Some(paren) = find(b.src, close + 2, ')') {
let style = base.patch(theme::link());
b.sub("", style, i);
for k in i + 1..close {
b.keep(k, style);
}
for k in close..=paren {
b.sub("", style, k);
}
return Some(paren + 1);
}
}
}
}
if let Some(end) = url_at(b.src, i) {
let style = base.patch(theme::link());
for k in i..end {
b.keep(k, style);
}
return Some(end);
}
if c == '#' && tags::enabled() {
if let Some(end) = tag_at(b.src, i) {
let style = base.patch(theme::tag());
for k in i..end {
b.keep(k, style);
}
return Some(end);
}
}
for (m, style) in [
('*', base.add_modifier(Modifier::BOLD)),
('~', base.add_modifier(Modifier::CROSSED_OUT)),
('=', theme::highlight()),
] {
if c == m && b.src.get(i + 1) == Some(&m) {
if let Some(end) = find_pair(b.src, i + 2, m) {
return Some(delimited(b, i, i + 2, end, end + 2, style));
}
}
}
if (c == '*' || c == '_') && b.src.get(i + 1) != Some(&c) {
let end = find(b.src, i + 1, c)?;
return Some(delimited(
b,
i,
i + 1,
end,
end + 1,
base.add_modifier(Modifier::ITALIC),
));
}
None
}
fn delimited(
b: &mut Builder,
open: usize,
body_start: usize,
body_end: usize,
close_end: usize,
style: Style,
) -> usize {
for k in open..body_start {
b.sub("", style, k);
}
for k in body_start..body_end {
b.keep(k, style);
}
for k in body_end..close_end {
b.sub("", style, k);
}
close_end
}
pub fn link_at(line: &str, col: usize) -> Option<LinkTarget> {
let src: Vec<char> = line.chars().collect();
let mut i = 0;
while i < src.len() {
if src[i] == '`' {
if let Some(end) = find(&src, i + 1, '`') {
i = end + 1;
continue;
}
}
if src[i] == '[' && src.get(i + 1) == Some(&'[') && links::enabled() {
if let Some(w) = wikilink_at(&src, i) {
if (w.start..w.end).contains(&col) {
return Some(LinkTarget::Wiki(w.target));
}
i = w.end;
continue;
}
}
if src[i] == '[' && (i == 0 || src[i - 1] != '!') {
if let Some(close) = find(&src, i + 1, ']') {
if src.get(close + 1) == Some(&'(') {
if let Some(paren) = find(&src, close + 2, ')') {
if (i..=paren).contains(&col) {
let url: String = src[close + 2..paren].iter().collect();
return (!url.trim().is_empty())
.then(|| LinkTarget::Url(url.trim().to_string()));
}
i = paren + 1;
continue;
}
}
}
}
if let Some(end) = url_at(&src, i) {
if (i..end).contains(&col) {
return Some(LinkTarget::Url(src[i..end].iter().collect()));
}
i = end.max(i + 1);
continue;
}
if src[i] == '#' && tags::enabled() {
if let Some(end) = tag_at(&src, i) {
if (i..end).contains(&col) {
return Some(LinkTarget::Tag(src[i + 1..end].iter().collect()));
}
i = end;
continue;
}
}
i += 1;
}
None
}
pub fn url_at(src: &[char], i: usize) -> Option<usize> {
let rest: String = src[i..].iter().take(8).collect();
let starts = (rest.starts_with("http://") || rest.starts_with("https://"))
&& (i == 0 || !src[i - 1].is_alphanumeric());
if !starts {
return None;
}
let mut end = i;
while end < src.len() && !src[end].is_whitespace() {
end += 1;
}
while end > i && matches!(src[end - 1], '.' | ',' | ')' | ']' | '!' | '?') {
end -= 1;
}
Some(end)
}
fn find(src: &[char], from: usize, ch: char) -> Option<usize> {
(from..src.len()).find(|&k| src[k] == ch)
}
pub(crate) fn find_pair(src: &[char], from: usize, ch: char) -> Option<usize> {
(from..src.len().saturating_sub(1)).find(|&k| src[k] == ch && src[k + 1] == ch)
}
pub fn tag_at(src: &[char], i: usize) -> Option<usize> {
if src.get(i) != Some(&'#') || !tag_boundary(i.checked_sub(1).map(|k| src[k])) {
return None;
}
if i >= 2 && src[i - 1] == '[' && src[i - 2] == '[' {
return None;
}
if !src.get(i + 1).is_some_and(|c| c.is_alphabetic()) {
return None;
}
let mut end = i + 2;
while end < src.len() && is_tag_char(src[end]) {
end += 1;
}
Some(end)
}
pub fn tag_boundary(prev: Option<char>) -> bool {
match prev {
None => true,
Some(c) => c.is_whitespace() || matches!(c, '(' | '[' | '{' | '"' | '\''),
}
}
fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '-' | '_' | '/')
}
pub fn tags_in(line: &str) -> Vec<(usize, usize)> {
let src: Vec<char> = line.chars().collect();
let mut found = Vec::new();
let mut i = 0;
while i < src.len() {
match src[i] {
'`' => {
if let Some(end) = find(&src, i + 1, '`') {
i = end + 1;
continue;
}
}
'[' => {
if src.get(i + 1) == Some(&'[') && links::enabled() {
if let Some(w) = wikilink_at(&src, i) {
i = w.end;
continue;
}
}
if let Some(close) = find(&src, i + 1, ']') {
if src.get(close + 1) == Some(&'(') {
if let Some(paren) = find(&src, close + 2, ')') {
i = paren + 1;
continue;
}
}
}
}
'#' => {
if let Some(end) = tag_at(&src, i) {
found.push((i, end));
i = end;
continue;
}
}
_ if url_at(&src, i).is_some() => {
while i < src.len() && !src[i].is_whitespace() {
i += 1;
}
continue;
}
_ => {}
}
i += 1;
}
found
}
pub fn tag_key(tag: &str) -> String {
tag.trim().trim_start_matches('#').to_ascii_lowercase()
}
pub mod tags {
use std::sync::RwLock;
static ON: RwLock<bool> = RwLock::new(true);
pub fn set_enabled(on: bool) {
if let Ok(mut w) = ON.write() {
*w = on;
}
}
pub fn enabled() -> bool {
ON.read().map(|b| *b).unwrap_or(true)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Wikilink {
pub start: usize,
pub end: usize,
pub target: String,
pub label_start: usize,
pub label_end: usize,
}
pub fn wikilink_at(src: &[char], i: usize) -> Option<Wikilink> {
if src.get(i) != Some(&'[') || src.get(i + 1) != Some(&'[') {
return None;
}
if i > 0 && matches!(src[i - 1], '\\' | '!') {
return None;
}
let body_start = i + 2;
let mut k = body_start;
let close = loop {
match src.get(k) {
Some(']') if src.get(k + 1) == Some(&']') => break k,
Some('[') | Some(']') | Some('\n') | None => return None,
Some(_) => k += 1,
}
};
if src[body_start..close].iter().all(|c| c.is_whitespace()) {
return None;
}
if src[body_start] == '#' {
return None;
}
let pipe = (body_start..close).find(|&k| src[k] == '|');
let (target_end, label) = match pipe {
Some(p) if src[p + 1..close].iter().all(|c| c.is_whitespace()) => (p, (body_start, p)),
Some(p) => (p, (p + 1, close)),
None => (close, (body_start, close)),
};
let raw: String = src[body_start..target_end].iter().collect();
let target = raw.split('#').next().unwrap_or("").trim().to_string();
if target.is_empty() {
return None;
}
Some(Wikilink {
start: i,
end: close + 2,
target,
label_start: label.0,
label_end: label.1,
})
}
pub fn wikilinks(line: &str) -> Vec<Wikilink> {
let src: Vec<char> = line.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < src.len() {
match wikilink_at(&src, i) {
Some(w) => {
i = w.end;
out.push(w);
}
None => i += 1,
}
}
out
}
pub fn link_key(target: &str) -> String {
let t = target.split('#').next().unwrap_or("").trim().to_lowercase();
let t = t.replace('\\', "/");
t.strip_suffix(".md").unwrap_or(&t).trim().to_string()
}
pub const WIKI_SCHEME: &str = "wikilink:";
pub const NOTE_SCHEME: &str = "note:";
pub const URL_SCHEME: &str = "url:";
pub const TAG_SCHEME: &str = "tag:";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LinkTarget {
Url(String),
Wiki(String),
Note(String),
Tag(String),
}
impl LinkTarget {
pub fn href(&self) -> String {
match self {
LinkTarget::Url(u)
if u.starts_with(NOTE_SCHEME)
|| u.starts_with(URL_SCHEME)
|| u.starts_with(TAG_SCHEME) =>
{
format!("{URL_SCHEME}{u}")
}
LinkTarget::Url(u) => u.clone(),
LinkTarget::Wiki(t) => format!("{WIKI_SCHEME}{t}"),
LinkTarget::Note(p) => format!("{NOTE_SCHEME}{p}"),
LinkTarget::Tag(t) => format!("{TAG_SCHEME}{t}"),
}
}
pub fn parse(href: &str) -> LinkTarget {
if let Some(u) = href.strip_prefix(URL_SCHEME) {
return LinkTarget::Url(u.to_string());
}
if let Some(t) = href.strip_prefix(WIKI_SCHEME) {
return LinkTarget::Wiki(t.to_string());
}
if let Some(t) = href.strip_prefix(TAG_SCHEME) {
return LinkTarget::Tag(t.to_string());
}
match href.strip_prefix(NOTE_SCHEME) {
Some(p) => LinkTarget::Note(p.to_string()),
None => LinkTarget::Url(href.to_string()),
}
}
}
pub mod links {
use std::collections::HashSet;
use std::sync::RwLock;
static KNOWN: RwLock<Option<HashSet<String>>> = RwLock::new(None);
static ON: RwLock<bool> = RwLock::new(true);
pub fn set_known(keys: HashSet<String>) {
if let Ok(mut w) = KNOWN.write() {
*w = Some(keys);
}
}
pub fn set_enabled(on: bool) {
if let Ok(mut w) = ON.write() {
*w = on;
}
}
pub fn enabled() -> bool {
ON.read().map(|b| *b).unwrap_or(true)
}
pub fn resolves(target: &str) -> bool {
let key = super::link_key(target);
match KNOWN.read() {
Ok(k) => match &*k {
Some(set) => set.contains(&key),
None => true,
},
Err(_) => true,
}
}
#[cfg(test)]
pub fn forget() {
if let Ok(mut w) = KNOWN.write() {
*w = None;
}
}
}
pub fn wiki_style(base: Style, target: &str) -> Style {
let base = base.patch(theme::link());
if links::resolves(target) {
base
} else {
base.patch(theme::grey())
}
}
pub fn mermaid_style(role: crate::mermaid::Role) -> Style {
use crate::mermaid::Role;
match role {
Role::Line => theme::marker(),
Role::Node => theme::PLAIN,
Role::Label => theme::grey(),
Role::Bright => theme::bright(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BlockKind {
Fence,
Mermaid,
Rule,
Table,
Image,
FrontMatter,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
pub start: usize,
pub end: usize,
}
impl Block {
pub fn contains(&self, row: usize) -> bool {
row >= self.start && row <= self.end
}
}
pub(crate) fn is_fence(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("```") || t.starts_with("~~~")
}
fn fence_info(line: &str) -> &str {
line.trim_start().trim_start_matches(['`', '~']).trim()
}
pub fn is_rule(line: &str) -> bool {
let t = line.trim();
t.chars().count() >= 3
&& (t.chars().all(|c| c == '-')
|| t.chars().all(|c| c == '*')
|| t.chars().all(|c| c == '_'))
}
fn is_table_rule(line: &str) -> bool {
let t = line.trim();
t.starts_with('|')
&& t.contains('-')
&& t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
}
fn is_table_row(line: &str) -> bool {
line.trim().starts_with('|') && line.trim().chars().count() > 1
}
pub fn image_line(line: &str) -> Option<(String, String)> {
let t = line.trim();
if let Some(found) = embed_line(t) {
return Some(found);
}
let rest = t.strip_prefix("?;
let alt = &rest[..close];
let url = rest[close + 2..].strip_suffix(')')?;
if alt.contains(']') || url.contains(')') || url.is_empty() {
return None;
}
Some((alt.to_string(), url.to_string()))
}
pub fn embed_line(line: &str) -> Option<(String, String)> {
let t = line.trim();
let body = t.strip_prefix("![[")?.strip_suffix("]]")?;
if body.contains('[') || body.contains(']') || body.contains('\n') {
return None;
}
let (url, alt) = match body.split_once('|') {
Some((u, a)) => (u.trim(), a.trim()),
None => (body.trim(), ""),
};
if url.is_empty() || !is_image_path(url) {
return None;
}
let alt = if alt.chars().all(|c| c.is_ascii_digit()) {
""
} else {
alt
};
Some((alt.to_string(), url.to_string()))
}
pub fn is_image_path(path: &str) -> bool {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
matches!(
ext.as_deref(),
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff")
)
}
pub fn blocks(lines: &[String]) -> Vec<Block> {
blocks_from(lines, 0)
}
pub fn blocks_from(lines: &[String], from: usize) -> Vec<Block> {
let mut out = Vec::new();
let mut i = from;
while i < lines.len() {
if is_fence(&lines[i]) {
let mut j = i + 1;
while j < lines.len() && !is_fence(&lines[j]) {
j += 1;
}
let end = j.min(lines.len() - 1);
let kind = if crate::mermaid::is_mermaid(fence_info(&lines[i])) {
BlockKind::Mermaid
} else {
BlockKind::Fence
};
out.push(Block {
kind,
start: i,
end,
});
i = end + 1;
continue;
}
if is_table_row(&lines[i]) && lines.get(i + 1).is_some_and(|l| is_table_rule(l)) {
let mut j = i;
while j < lines.len() && is_table_row(&lines[j]) {
j += 1;
}
out.push(Block {
kind: BlockKind::Table,
start: i,
end: j - 1,
});
i = j;
continue;
}
if is_rule(&lines[i]) {
out.push(Block {
kind: BlockKind::Rule,
start: i,
end: i,
});
} else if image_line(&lines[i]).is_some() {
out.push(Block {
kind: BlockKind::Image,
start: i,
end: i,
});
}
i += 1;
}
out
}
pub fn block_at(blocks: &[Block], row: usize) -> Option<&Block> {
blocks.iter().find(|b| b.contains(row))
}
pub fn style_block_line(lines: &[String], block: &Block, row: usize, width: usize) -> RLine {
let src = lines.get(row).map(String::as_str).unwrap_or("");
match block.kind {
BlockKind::Fence => fence_line(src, row == block.start || row == block.end),
BlockKind::Mermaid => {
mermaid_line(&lines[block.start..=block.end], row - block.start, width)
}
BlockKind::Rule => rule_line(src, width),
BlockKind::Image => image_fallback_line(src),
BlockKind::FrontMatter => front_matter_line(src),
BlockKind::Table => table_line(&lines[block.start..=block.end], row - block.start, width),
}
}
fn at(text: &str, style: Style, src: usize) -> Vec<Cell> {
text.chars().map(|ch| Cell { ch, style, src }).collect()
}
fn done(cells: Vec<Cell>, src: &str) -> RLine {
RLine {
cells,
src_len: src.chars().count(),
}
}
fn fence_line(src: &str, cap: bool) -> RLine {
if !cap {
let cells = src
.chars()
.enumerate()
.map(|(i, ch)| Cell {
ch,
style: theme::code(),
src: i,
})
.collect();
return done(cells, src);
}
let cells = src
.chars()
.enumerate()
.skip_while(|(_, ch)| *ch == '`' || *ch == '~' || ch.is_whitespace())
.map(|(i, ch)| Cell {
ch,
style: theme::marker(),
src: i,
})
.collect();
done(cells, src)
}
fn mermaid_line(rows: &[String], row: usize, width: usize) -> RLine {
let src = rows.get(row).map(String::as_str).unwrap_or("");
if rows.len() > 2 {
let body = rows[1..rows.len() - 1].join("\n");
if let Some(line) =
rendered_memo(&body, width).and_then(|d| diagram_line(&d, rows.len(), row, src))
{
return line;
}
}
fence_line(src, row == 0 || row + 1 == rows.len())
}
type MermaidMemo = (String, usize, Option<std::rc::Rc<crate::mermaid::Rendered>>);
thread_local! {
static MERMAID_MEMO: std::cell::RefCell<Option<MermaidMemo>> =
const { std::cell::RefCell::new(None) };
}
fn rendered_memo(body: &str, width: usize) -> Option<std::rc::Rc<crate::mermaid::Rendered>> {
MERMAID_MEMO.with(|memo| {
let mut memo = memo.borrow_mut();
if let Some((b, w, d)) = memo.as_ref() {
if b == body && *w == width {
return d.clone();
}
}
let d = crate::mermaid::render(body, width).map(std::rc::Rc::new);
*memo = Some((body.to_string(), width, d.clone()));
d
})
}
fn diagram_line(d: &crate::mermaid::Rendered, rows: usize, row: usize, src: &str) -> Option<RLine> {
if d.height() > rows {
return None;
}
let top = (rows - d.height()) / 2;
let drawn = row.checked_sub(top).and_then(|i| d.rows.get(i));
let cells = drawn
.into_iter()
.flatten()
.flat_map(|run| {
let style = mermaid_style(run.role);
run.text.chars().map(move |ch| Cell { ch, style, src: 0 })
})
.collect();
Some(done(cells, src))
}
fn front_matter_line(src: &str) -> RLine {
let cells = src
.chars()
.enumerate()
.map(|(i, ch)| Cell {
ch,
style: theme::marker(),
src: i,
})
.collect();
done(cells, src)
}
fn rule_line(src: &str, width: usize) -> RLine {
let len = src.chars().count();
let n = width.max(len).max(1);
let cells = (0..n)
.map(|i| Cell {
ch: '─',
style: theme::marker(),
src: i.min(len),
})
.collect();
done(cells, src)
}
fn image_fallback_line(src: &str) -> RLine {
let len = src.chars().count();
let (alt, url) = image_line(src).unwrap_or_default();
let label = if alt.is_empty() {
format!("🖼 {url}")
} else {
format!("🖼 {alt} ({url})")
};
let cells = label
.chars()
.enumerate()
.map(|(i, ch)| Cell {
ch,
style: theme::marker(),
src: i.min(len),
})
.collect();
done(cells, src)
}
struct TCell {
start: usize,
text: String,
}
fn split_row(src: &str) -> (Vec<TCell>, Vec<usize>) {
let chars: Vec<char> = src.chars().collect();
let pipes: Vec<usize> = chars
.iter()
.enumerate()
.filter(|(_, c)| **c == '|')
.map(|(i, _)| i)
.collect();
let mut cells = Vec::new();
for w in pipes.windows(2) {
let (a, b) = (w[0] + 1, w[1]);
let mut start = a;
while start < b && chars[start].is_whitespace() {
start += 1;
}
let mut end = b;
while end > start && chars[end - 1].is_whitespace() {
end -= 1;
}
cells.push(TCell {
start,
text: chars[start..end].iter().collect(),
});
}
(cells, pipes)
}
fn align_of(spec: &str) -> Align {
let t = spec.trim();
match (t.starts_with(':'), t.ends_with(':')) {
(true, true) => Align::Center,
(false, true) => Align::Right,
_ => Align::Left,
}
}
fn styled_cell(text: &str, base: Style) -> Vec<Cell> {
let chars: Vec<char> = text.chars().collect();
let mut b = Builder {
src: &chars,
cells: Vec::with_capacity(chars.len()),
};
inline(&mut b, 0, base);
b.cells
}
fn cells_width(cells: &[Cell]) -> usize {
cells.iter().map(|c| char_width(c.ch)).sum()
}
fn truncate_cells(mut cells: Vec<Cell>, width: usize) -> Vec<Cell> {
if cells_width(&cells) <= width {
return cells;
}
let n = cut_at(cells.iter().map(|c| char_width(c.ch)), width);
let cut = Cell {
ch: '…',
..cells[n]
};
cells.truncate(n);
cells.push(cut);
cells
}
struct TableLayout {
parsed: Vec<(Vec<TCell>, Vec<usize>)>,
rule_row: Option<usize>,
aligns: Vec<Align>,
widths: Vec<usize>,
}
fn table_layout(rows: &[String], width: usize) -> TableLayout {
let parsed: Vec<(Vec<TCell>, Vec<usize>)> = rows.iter().map(|r| split_row(r)).collect();
let rule_row = rows.iter().position(|r| is_table_rule(r));
let aligns: Vec<Align> = rule_row
.map(|i| parsed[i].0.iter().map(|c| align_of(&c.text)).collect())
.unwrap_or_default();
let cols = parsed.iter().map(|(c, _)| c.len()).max().unwrap_or(0);
let measured: Vec<Vec<usize>> = parsed
.iter()
.enumerate()
.filter(|(i, _)| Some(*i) != rule_row)
.map(|(_, (c, _))| {
c.iter()
.map(|c| cells_width(&styled_cell(&c.text, theme::PLAIN)))
.collect()
})
.collect();
let widths = fit_widths(&column_widths(&measured, cols), width);
TableLayout {
parsed,
rule_row,
aligns,
widths,
}
}
type TableMemo = (Vec<String>, usize, std::rc::Rc<TableLayout>);
thread_local! {
static TABLE_MEMO: std::cell::RefCell<Option<TableMemo>> =
const { std::cell::RefCell::new(None) };
}
fn layout_memo(rows: &[String], width: usize) -> std::rc::Rc<TableLayout> {
TABLE_MEMO.with(|memo| {
let mut memo = memo.borrow_mut();
if let Some((r, w, l)) = memo.as_ref() {
if r == rows && *w == width {
return l.clone();
}
}
let l = std::rc::Rc::new(table_layout(rows, width));
*memo = Some((rows.to_vec(), width, l.clone()));
l
})
}
fn table_line(rows: &[String], row: usize, width: usize) -> RLine {
table_row(&layout_memo(rows, width), rows, row)
}
fn table_row(l: &TableLayout, rows: &[String], row: usize) -> RLine {
let TableLayout {
parsed,
rule_row,
aligns,
widths,
} = l;
let rule_row = *rule_row;
let src = rows.get(row).map(String::as_str).unwrap_or("");
if Some(row) == rule_row {
let len = src.chars().count();
let cells = table_rule(widths)
.chars()
.enumerate()
.map(|(i, ch)| Cell {
ch,
style: theme::marker(),
src: i.min(len),
})
.collect();
return done(cells, src);
}
let head = rule_row.is_some_and(|r| row < r);
let body = if head {
theme::PLAIN.add_modifier(Modifier::BOLD)
} else {
theme::PLAIN
};
let (row_cells, pipes) = &parsed[row];
let mut cells: Vec<Cell> = Vec::new();
for (ci, w) in widths.iter().enumerate() {
if ci > 0 {
let pipe = pipes.get(ci).copied().unwrap_or(0);
cells.extend(at(COL_SEP, theme::marker(), pipe));
}
let empty = TCell {
start: pipes.last().copied().unwrap_or(0),
text: String::new(),
};
let cell = row_cells.get(ci).unwrap_or(&empty);
let align = aligns.get(ci).copied().unwrap_or(Align::Left);
let styled = truncate_cells(styled_cell(&cell.text, body), *w);
let (left, right) = pad_for(cells_width(&styled), *w, align);
cells.extend(at(&" ".repeat(left), body, cell.start));
cells.extend(styled.into_iter().map(|c| Cell {
src: cell.start + c.src,
..c
}));
let after = cell.start + cell.text.chars().count();
cells.extend(at(&" ".repeat(right), body, after));
}
done(cells, src)
}
#[cfg(test)]
mod tests {
use super::*;
fn text(l: &RLine) -> String {
l.cells.iter().map(|c| c.ch).collect()
}
#[test]
fn heading_marker_is_hidden_and_styled() {
let l = style_line("## Title");
assert_eq!(text(&l), "Title");
assert!(l.cells[0].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(l.one_row().display_to_source(0), 3);
assert_eq!(l.one_row().display_to_source(4), 7);
assert_eq!(l.one_row().display_to_source(99), 8);
assert_eq!(l.one_row().source_to_display(3), 0);
}
#[test]
fn checkboxes_and_bullets() {
let done = style_line("- [x] ship it");
assert_eq!(text(&done), "✓ ship it");
assert_eq!(done.one_row().display_to_source(2), 6);
let todo = style_line("- [ ] later");
assert_eq!(text(&todo), "☐ later");
let bullet = style_line("- plain");
assert_eq!(text(&bullet), "• plain");
assert_eq!(bullet.one_row().display_to_source(2), 2);
}
#[test]
fn inline_markers_are_hidden() {
let l = style_line("a **b** c *d* `e` ==f== ~~g~~");
assert_eq!(text(&l), "a b c d e f g");
assert_eq!(l.one_row().display_to_source(2), 4); }
#[test]
fn links_show_only_the_text() {
let l = style_line("see [docs](http://x.y) now");
assert_eq!(text(&l), "see docs now");
assert_eq!(l.one_row().display_to_source(4), 5);
assert!(l.cells[4].style.fg == theme::link().fg);
}
#[test]
fn table_rows_keep_their_characters_and_dim_the_pipes() {
let l = style_line("| a | b |");
assert_eq!(text(&l), "| a | b |");
assert_eq!(l.cells[0].style, theme::marker());
assert_eq!(l.cells[2].style, theme::PLAIN);
assert_eq!(l.one_row().display_to_source(4), 4);
let sep = style_line("| --- | ---: |");
assert!(sep.cells.iter().all(|c| c.style == theme::marker()));
}
#[test]
fn inline_code_in_a_table_cell_is_styled_and_kept() {
let rows: Vec<String> = ["| a | `foo` |", "| --- | --- |", "| 1 | x `bar` y |"]
.iter()
.map(|s| s.to_string())
.collect();
let head = table_line(&rows, 0, 80);
let t = text(&head);
assert!(t.contains("foo"), "{t}");
assert!(!t.contains('`'), "{t}");
let f = head.cells.iter().find(|c| c.ch == 'f').unwrap();
assert_eq!(f.style.fg, theme::inline_code().fg);
assert_eq!(f.src, rows[0].find('f').unwrap());
let body = table_line(&rows, 2, 80);
let t = text(&body);
assert!(t.contains("x bar y"), "{t}");
let x = body.cells.iter().find(|c| c.ch == 'x').unwrap();
assert_eq!(x.style, theme::PLAIN);
let b = body.cells.iter().find(|c| c.ch == 'b').unwrap();
assert_eq!(b.style.fg, theme::inline_code().fg);
let l = style_line("say `foo` now");
assert_eq!(text(&l), "say foo now");
assert_eq!(l.cells[4].style.fg, theme::inline_code().fg);
assert_eq!(l.cells[4].style.bg, None);
}
#[test]
fn highlight_body_is_reversed_out_of_the_page() {
let l = style_line("a ==wow== b");
assert_eq!(text(&l), "a wow b");
let w = l.cells.iter().find(|c| c.ch == 'w').unwrap();
assert_eq!(w.style.bg, theme::highlight().bg);
}
#[test]
fn bare_urls_are_styled_as_links() {
let l = style_line("see https://x.y/z. ok");
assert_eq!(text(&l), "see https://x.y/z. ok");
let c = l.cells[4];
assert_eq!(c.style.fg, theme::link().fg);
assert_eq!(l.cells[17].style.fg, None);
}
#[test]
fn link_at_finds_the_url_under_a_source_column() {
let url = |u: &str| Some(LinkTarget::Url(u.to_string()));
let line = "see [docs](http://x.y) now";
assert_eq!(link_at(line, 3), None);
assert_eq!(link_at(line, 4), url("http://x.y"));
assert_eq!(link_at(line, 6), url("http://x.y"));
assert_eq!(link_at(line, 21), url("http://x.y"));
assert_eq!(link_at(line, 22), None);
let bare = "see https://x.y/z. ok";
assert_eq!(link_at(bare, 4), url("https://x.y/z"));
assert_eq!(link_at(bare, 16), url("https://x.y/z"));
assert_eq!(link_at(bare, 17), None);
assert_eq!(link_at("", 8), None);
assert_eq!(
link_at("[a](http://a) and [b](http://b)", 20),
url("http://b")
);
assert_eq!(link_at("plain text", 2), None);
assert_eq!(link_at("[empty]()", 2), None);
}
#[test]
fn done_tasks_are_struck_through() {
let l = style_line("- [x] ship it");
let s = l.cells.iter().find(|c| c.ch == 's').unwrap().style;
assert!(s.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn quotes_get_a_bar() {
let l = style_line("> hi");
assert_eq!(text(&l), "▌ hi");
assert_eq!(l.one_row().display_to_source(0), 0);
assert_eq!(l.one_row().display_to_source(2), 2);
}
#[test]
fn raw_line_maps_one_to_one() {
let l = RLine::raw("## Title");
assert_eq!(text(&l), "## Title");
for i in 0..8 {
assert_eq!(l.one_row().display_to_source(i), i);
assert_eq!(l.one_row().source_to_display(i), i);
}
}
#[test]
fn wide_characters_take_two_display_columns() {
let l = style_line("**漢字** x");
assert_eq!(text(&l), "漢字 x");
assert_eq!(l.one_row().display_to_source(0), 2);
assert_eq!(l.one_row().display_to_source(1), 2);
assert_eq!(l.one_row().display_to_source(2), 3);
assert_eq!(l.one_row().source_to_display(3), 2);
assert_eq!(l.one_row().display_to_source(5), 7);
}
fn buf(s: &str) -> Vec<String> {
s.lines().map(String::from).collect()
}
#[test]
fn an_obsidian_embed_alone_on_a_line_is_an_image() {
assert_eq!(
embed_line("![[attachments/hero.jpg]]"),
Some((String::new(), "attachments/hero.jpg".into()))
);
assert_eq!(
image_line(" ![[a.png|a cat]] "),
Some(("a cat".into(), "a.png".into()))
);
assert_eq!(
image_line("![[a.png|300]]"),
Some((String::new(), "a.png".into()))
);
assert_eq!(embed_line("![[plan]]"), None);
assert_eq!(embed_line("![[a.png]] tail"), None);
assert_eq!(embed_line("![[]]"), None);
assert_eq!(embed_line("[[a.png]]"), None);
assert!(blocks(&["![[a.png]]".to_string()])
.iter()
.any(|b| b.kind == BlockKind::Image));
}
#[test]
fn block_spans_cover_fences_tables_rules_and_images() {
let lines = buf("intro\n```rust\nlet x = 1;\n```\n\n---\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n\n");
let bs = blocks(&lines);
assert_eq!(
bs,
vec![
Block {
kind: BlockKind::Fence,
start: 1,
end: 3
},
Block {
kind: BlockKind::Rule,
start: 5,
end: 5
},
Block {
kind: BlockKind::Table,
start: 7,
end: 9
},
Block {
kind: BlockKind::Image,
start: 11,
end: 11
},
]
);
assert_eq!(block_at(&bs, 2).unwrap().kind, BlockKind::Fence);
assert!(block_at(&bs, 0).is_none());
assert!(block_at(&bs, 4).is_none());
}
#[test]
fn a_fence_swallows_what_looks_like_other_blocks() {
let lines = buf("```\n---\n| a | b |\n| --- | --- |\n```\n");
let bs = blocks(&lines);
assert_eq!(bs.len(), 1);
assert_eq!(bs[0].kind, BlockKind::Fence);
assert_eq!((bs[0].start, bs[0].end), (0, 4));
}
#[test]
fn an_unclosed_fence_runs_to_the_end_of_the_buffer() {
let lines = buf("```\nlet x = 1;\nmore\n");
let bs = blocks(&lines);
assert_eq!((bs[0].start, bs[0].end), (0, 2));
}
#[test]
fn pipes_without_a_separator_row_are_not_a_table() {
assert!(blocks(&buf("| a | b |\ntext\n")).is_empty());
assert!(blocks(&buf("see  here\n")).is_empty());
assert_eq!(
image_line("  "),
Some(("a cat".into(), "x/cat.png".into()))
);
assert_eq!(
image_line(""),
Some((String::new(), "p.png".into()))
);
}
#[test]
fn front_matter_is_one_block_and_the_markdown_scan_starts_below_it() {
let lines = buf("---\ntags: a\n---\n\n# Title\n\n---\n");
let bs = blocks_from(&lines, 3);
assert_eq!(
bs,
vec![Block {
kind: BlockKind::Rule,
start: 6,
end: 6,
}]
);
}
#[test]
fn blocks_from_reports_absolute_line_numbers() {
let lines = buf("---\na: b\n---\n```\ncode\n```\n");
let bs = blocks_from(&lines, 3);
assert_eq!(bs.len(), 1);
assert_eq!((bs[0].start, bs[0].end), (3, 5));
assert_eq!(blocks_from(&lines, 0), blocks(&lines));
}
#[test]
fn a_front_matter_fence_is_drawn_as_typed_not_as_a_horizontal_rule() {
let lines = buf("---\ntags: work\n---\n");
let block = Block {
kind: BlockKind::FrontMatter,
start: 0,
end: 2,
};
let l = style_block_line(&lines, &block, 0, 80);
assert_eq!(text(&l), "---");
let l = style_block_line(&lines, &block, 1, 80);
assert_eq!(text(&l), "tags: work");
assert_eq!(l.one_row().display_to_source(5), 5);
assert!(l.cells.iter().all(|c| c.style == theme::marker()));
}
fn drawn(rows: &[&str]) -> crate::mermaid::Rendered {
use crate::mermaid::{Role, Run};
crate::mermaid::Rendered::new(
rows.iter()
.map(|r| vec![Run::new(*r, Role::Node)])
.collect(),
)
}
#[test]
fn a_mermaid_fence_is_its_own_block_kind() {
let lines = buf("```mermaid\nflowchart LR\n A --> B\n```\n");
assert_eq!(
blocks(&lines),
vec![Block {
kind: BlockKind::Mermaid,
start: 0,
end: 3,
}]
);
assert_eq!(
blocks(&buf("```Mermaid {theme: dark}\nx\n```\n"))[0].kind,
BlockKind::Mermaid
);
assert_eq!(
blocks(&buf("```mermaidjs\nx\n```\n"))[0].kind,
BlockKind::Fence
);
}
#[test]
fn the_editor_draws_a_diagram_that_fits_its_fence() {
let d = drawn(&["╭───╮", "│ A │", "╰───╯"]);
let row = |r| text(&diagram_line(&d, 5, r, " A --> B").unwrap());
assert_eq!(row(0), "");
assert_eq!(row(1), "╭───╮");
assert_eq!(row(2), "│ A │");
assert_eq!(row(3), "╰───╯");
assert_eq!(row(4), "");
}
#[test]
fn a_diagram_taller_than_its_fence_falls_back_to_the_fence() {
let d = drawn(&["a", "b", "c", "d"]);
assert!(diagram_line(&d, 3, 0, "```mermaid").is_none());
let lines = buf("```mermaid\ngantt\n title Ship it\n```\n");
let block = Block {
kind: BlockKind::Mermaid,
start: 0,
end: 3,
};
assert_eq!(text(&style_block_line(&lines, &block, 1, 80)), "gantt");
assert_eq!(
text(&style_block_line(&lines, &block, 2, 80)),
" title Ship it"
);
}
#[test]
fn every_source_line_of_a_mermaid_block_is_exactly_one_display_line() {
let lines = buf("```mermaid\nflowchart LR\n A --> B\n B --> C\n```\n");
let bs = blocks(&lines);
let block = &bs[0];
for row in block.start..=block.end {
let l = style_block_line(&lines, block, row, 60);
assert_eq!(l.src_len, lines[row].chars().count());
}
}
#[test]
fn a_click_on_a_drawn_diagram_lands_on_its_own_source_line() {
let d = drawn(&["│ A │"]);
let l = diagram_line(&d, 1, 0, " A --> B").unwrap();
assert!(l.cells.iter().all(|c| c.src == 0));
assert_eq!(l.one_row().display_to_source(3), 0);
assert_eq!(l.src_len, " A --> B".chars().count());
}
#[test]
fn rules_take_any_of_the_three_markers() {
assert!(is_rule("---"));
assert!(is_rule(" *** "));
assert!(is_rule("___"));
assert!(!is_rule("--"));
assert!(!is_rule("- item"));
}
#[test]
fn a_rule_is_drawn_across_the_page_and_clicks_land_on_it() {
let l = rule_line("---", 10);
assert_eq!(text(&l), "──────────");
assert_eq!(l.one_row().display_to_source(0), 0);
assert_eq!(l.one_row().display_to_source(9), 3);
}
#[test]
fn a_fence_hides_its_backticks_and_colours_its_body() {
let open = fence_line("```rust", true);
assert_eq!(text(&open), "rust");
assert_eq!(open.cells[0].style, theme::marker());
assert_eq!(open.cells[0].src, 3);
assert_eq!(open.one_row().display_to_source(0), 3);
assert_eq!(text(&fence_line("```", true)), "");
assert_eq!(text(&fence_line("~~~", true)), "");
assert_eq!(fence_line("```", true).one_row().display_to_source(0), 3);
assert_eq!(
fence_line("let x = 1;", false).cells[0].style,
theme::code()
);
assert_eq!(text(&fence_line("let x = 1;", false)), "let x = 1;");
}
#[test]
fn tables_are_laid_out_in_aligned_columns() {
let rows = buf("| a | bbbb |\n| --- | ---: |\n| 1 | 2 |");
assert_eq!(text(&table_line(&rows, 0, 80)), "a │ bbbb");
assert_eq!(text(&table_line(&rows, 1, 80)), "──┼─────");
assert_eq!(text(&table_line(&rows, 2, 80)), "1 │ 2"); assert!(table_line(&rows, 0, 80).cells[0]
.style
.add_modifier
.contains(Modifier::BOLD));
assert!(!table_line(&rows, 2, 80).cells[0]
.style
.add_modifier
.contains(Modifier::BOLD));
let r = crate::render::render("| a | bbbb |\n| --- | ---: |\n| 1 | 2 |\n");
let drawn: Vec<String> = r
.lines
.iter()
.map(|l| l.cells.iter().map(|c| c.ch).collect::<String>())
.filter(|t| !t.trim().is_empty())
.collect();
assert_eq!(drawn, vec!["a │ bbbb", "──┼─────", "1 │ 2"]);
}
#[test]
fn a_wide_table_is_squeezed_into_the_page_width() {
let rows = buf("| a | bbbbbbbbbbbbbbbbbbbb |\n| --- | --- |\n| 1 | 2 |");
for r in 0..3 {
let l = table_line(&rows, r, 16);
assert!(str_width(&text(&l)) <= 16, "{:?}", text(&l));
}
assert_eq!(text(&table_line(&rows, 0, 16)), "a │ bbbbbbbbbbb…");
assert_eq!(text(&table_line(&rows, 2, 16)), "1 │ 2 ");
}
#[test]
fn clicking_a_laid_out_table_maps_back_into_the_source_row() {
let rows = buf("| a | bbbb |\n| --- | ---: |\n| 1 | 2 |");
let l = table_line(&rows, 0, 80);
assert_eq!(l.one_row().display_to_source(0), 2);
assert_eq!(l.one_row().display_to_source(2), 4);
assert_eq!(l.one_row().display_to_source(4), 6);
let body = table_line(&rows, 2, 80);
assert_eq!(body.one_row().display_to_source(4), 6);
}
#[test]
fn an_image_line_falls_back_to_a_labelled_row() {
let l = image_fallback_line("");
assert_eq!(text(&l), "🖼 a cat (cat.png)");
assert!(l.one_row().display_to_source(99) <= 17);
}
#[test]
fn selection_reverses_only_the_selected_cells() {
let l = style_line("hello");
let line = l.to_line(Some((1, 3)));
let rev: String = line
.spans
.iter()
.filter(|s| s.style.add_modifier.contains(Modifier::REVERSED))
.map(|s| s.content.to_string())
.collect();
assert_eq!(rev, "el");
}
fn colours() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn a_wikilink_shows_its_target_as_the_text() {
let _turn = colours();
let l = style_line("see [[note]] now");
assert_eq!(text(&l), "see note now");
let row = l.one_row();
assert_eq!(row.display_to_source(4), 6); assert_eq!(l.cells[4].style.fg, theme::link().fg);
}
#[test]
fn a_piped_wikilink_shows_only_its_label() {
let src = "[[stories/story-matrix|the matrix]]";
let l = style_line(src);
assert_eq!(text(&l), "the matrix");
assert_eq!(l.one_row().display_to_source(0), src.find("the").unwrap());
let w = wikilink_at(&src.chars().collect::<Vec<_>>(), 0).unwrap();
assert_eq!(w.target, "stories/story-matrix");
}
#[test]
fn a_pipe_with_no_label_after_it_draws_the_target_and_not_the_pipe() {
assert_eq!(text(&style_line("[[note|]]")), "note");
assert_eq!(text(&style_line("[[note| ]]")), "note");
let chars: Vec<char> = "[[note|]]".chars().collect();
let w = wikilink_at(&chars, 0).unwrap();
assert_eq!((w.label_start, w.label_end), (2, 6));
}
#[test]
fn a_heading_suffix_is_shown_but_is_not_part_of_the_target() {
let l = style_line("[[note#Method]]");
assert_eq!(text(&l), "note#Method");
let chars: Vec<char> = "[[note#Method]]".chars().collect();
assert_eq!(wikilink_at(&chars, 0).unwrap().target, "note");
}
#[test]
fn unmatched_or_escaped_brackets_stay_literal_text() {
for src in [
"[[unclosed",
"[[a] b]]",
"\\[[escaped]]",
"![[embed.png]]",
"[[ ]]",
"[[#heading]]",
"[x]",
] {
assert_eq!(text(&style_line(src)), src, "{src}");
}
}
#[test]
fn two_wikilinks_on_one_line_are_both_links() {
let _turn = colours();
let l = style_line("[[a]] and [[b|bee]]");
assert_eq!(text(&l), "a and bee");
assert_eq!(l.cells[0].style.fg, theme::link().fg);
assert_eq!(l.cells[6].style.fg, theme::link().fg);
assert_eq!(l.cells[1].style.fg, None);
}
#[test]
fn an_unresolved_wikilink_is_grey_and_still_underlined() {
let _turn = colours();
let mut known = std::collections::HashSet::new();
known.insert("real".to_string());
links::set_known(known);
let ok = style_line("[[real]]");
assert_eq!(ok.cells[0].style.fg, theme::link().fg);
let broken = style_line("[[gone]]");
assert_eq!(broken.cells[0].style.fg, theme::grey().fg);
assert!(broken.cells[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED));
assert_eq!(
style_line("[[Real.md]]").cells[0].style.fg,
theme::link().fg
);
links::forget();
assert_eq!(style_line("[[gone]]").cells[0].style.fg, theme::link().fg);
}
#[test]
fn link_at_tells_a_wikilink_from_a_url() {
let line = "see [[note|label]] and [d](http://x.y)";
let wiki = Some(LinkTarget::Wiki("note".to_string()));
assert_eq!(link_at(line, 3), None);
assert_eq!(link_at(line, 4), wiki); assert_eq!(link_at(line, 12), wiki); assert_eq!(link_at(line, 17), wiki); assert_eq!(link_at(line, 18), None);
assert_eq!(
link_at(line, 24),
Some(LinkTarget::Url("http://x.y".to_string()))
);
assert_eq!(link_at(line, 99), None);
}
#[test]
fn a_tag_is_a_hash_on_a_word_boundary_then_a_letter() {
let ends = |line: &str| -> Vec<String> {
let chars: Vec<char> = line.chars().collect();
tags_in(line)
.into_iter()
.map(|(s, e)| chars[s..e].iter().collect())
.collect()
};
assert_eq!(ends("a #work note"), vec!["#work"]);
assert_eq!(ends("#top of line"), vec!["#top"]);
assert_eq!(ends("(#paren) \"#quoted\""), vec!["#paren", "#quoted"]);
assert_eq!(ends("#a-b_c/d9 tail"), vec!["#a-b_c/d9"]);
assert_eq!(ends("#done."), vec!["#done"]);
assert!(ends("# Heading").is_empty());
assert!(ends("## Heading").is_empty());
assert!(ends("#1 and # and #").is_empty());
assert!(ends("a#b c&#d").is_empty());
}
#[test]
fn a_tag_inside_code_a_link_or_a_url_is_not_one() {
assert!(tags_in("`#code` [t](http://x.y/#frag)").is_empty());
assert!(tags_in("https://x.y/p#frag").is_empty());
assert!(tags_in("see `a #b` c").is_empty());
assert_eq!(tags_in("`x` #tag https://a.b#c"), vec![(4, 8)]);
}
#[test]
fn a_hash_inside_a_wikilink_is_a_heading_and_not_a_tag() {
assert!(tags_in("[[#heading]] [[note#part|alias]]").is_empty());
assert_eq!(tags_in("[[note]] #tag"), vec![(9, 13)]);
assert_eq!(link_at("[[#heading]]", 3), None);
let l = style_line("[[#heading]]");
assert!(l.cells.iter().all(|c| c.style.fg != theme::tag().fg));
}
#[test]
fn a_tag_is_drawn_in_the_accent_and_kept_whole() {
tags::set_enabled(true);
let l = style_line("note #work here");
assert_eq!(text(&l), "note #work here");
assert_eq!(l.cells[5].style.fg, theme::tag().fg);
assert_eq!(l.cells[9].style.fg, theme::tag().fg);
assert_eq!(l.cells[10].style, theme::PLAIN);
let h = style_line("# Title #work");
assert_eq!(text(&h), "Title #work");
assert_eq!(h.cells[6].style.fg, theme::tag().fg);
let u = style_line("https://x.y/#frag");
assert_eq!(u.cells[12].style.fg, theme::link().fg);
}
#[test]
fn link_at_finds_a_tag_and_steps_over_code() {
tags::set_enabled(true);
let line = "see `#no` and #yes now";
assert_eq!(link_at(line, 5), None);
assert_eq!(link_at(line, 14), Some(LinkTarget::Tag("yes".to_string())));
assert_eq!(link_at(line, 17), Some(LinkTarget::Tag("yes".to_string())));
assert_eq!(link_at(line, 18), None);
}
#[test]
fn tag_key_drops_the_hash_and_the_case() {
assert_eq!(tag_key("#Work/Q3"), "work/q3");
assert_eq!(tag_key(" work "), "work");
}
#[test]
fn a_tag_href_round_trips_through_the_scheme() {
let t = LinkTarget::Tag("work".to_string());
assert_eq!(t.href(), "tag:work");
assert_eq!(LinkTarget::parse(&t.href()), t);
let url = LinkTarget::Url("tag:x".to_string());
assert_eq!(LinkTarget::parse(&url.href()), url);
}
#[test]
fn every_wikilink_on_a_line_is_found_once_and_in_order() {
let found = wikilinks("see [[a]] and [[b|bee]] and [[unclosed");
let targets: Vec<&str> = found.iter().map(|w| w.target.as_str()).collect();
assert_eq!(targets, vec!["a", "b"]);
assert_eq!(
&"see [[a]] and [[b|bee]] and [[unclosed"[found[0].start..found[0].end],
"[[a]]"
);
assert!(wikilinks("nothing here at all").is_empty());
}
#[test]
fn a_wikilink_href_round_trips_through_the_scheme() {
let w = LinkTarget::Wiki("a/b".to_string());
assert_eq!(w.href(), "wikilink:a/b");
assert_eq!(LinkTarget::parse(&w.href()), w);
let n = LinkTarget::Note("/vault/meta.md".to_string());
assert_eq!(n.href(), "note:/vault/meta.md");
assert_eq!(LinkTarget::parse(&n.href()), n);
assert_eq!(
LinkTarget::parse("https://x.y"),
LinkTarget::Url("https://x.y".to_string())
);
}
#[test]
fn a_url_that_spells_out_the_apps_own_scheme_stays_a_url() {
for u in ["note:/etc/passwd", "url:note:/etc/passwd", "url:x"] {
let url = LinkTarget::Url(u.to_string());
assert_eq!(LinkTarget::parse(&url.href()), url, "{u}");
}
assert_eq!(
LinkTarget::parse("note:/vault/meta.md"),
LinkTarget::Note("/vault/meta.md".to_string())
);
}
#[test]
fn link_key_drops_the_heading_the_extension_and_the_case() {
assert_eq!(
link_key("Stories/Story-Matrix.md#Method"),
link_key("stories/story-matrix")
);
assert_eq!(link_key(" A\\B "), "a/b");
}
}