pub mod renderer;
use std::cell::Cell;
use ratatui::text::{Span, Text};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkInfo {
pub line: u32,
pub col_start: u16,
pub col_end: u16,
pub url: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct HeadingAnchor {
pub anchor: String,
pub line: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MermaidBlockId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TableBlockId(pub u64);
pub type CellSpans = Vec<Span<'static>>;
#[derive(Debug, Clone)]
pub struct TableBlock {
pub id: TableBlockId,
pub headers: Vec<CellSpans>,
pub rows: Vec<Vec<CellSpans>>,
pub alignments: Vec<pulldown_cmark::Alignment>,
pub natural_widths: Vec<usize>,
pub rendered_height: u32,
}
#[derive(Debug)]
pub enum DocBlock {
Text {
text: Text<'static>,
links: Vec<LinkInfo>,
heading_anchors: Vec<HeadingAnchor>,
},
Mermaid {
id: MermaidBlockId,
source: String,
cell_height: Cell<u32>,
},
Table(TableBlock),
}
impl DocBlock {
pub fn height(&self) -> u32 {
match self {
DocBlock::Text { text, .. } => text.lines.len() as u32,
DocBlock::Mermaid { cell_height, .. } => cell_height.get(),
DocBlock::Table(t) => t.rendered_height,
}
}
}
pub fn update_mermaid_heights(blocks: &[DocBlock], cache: &crate::mermaid::MermaidCache) {
for block in blocks {
if let DocBlock::Mermaid {
id,
source,
cell_height,
} = block
{
cell_height.set(cache.height(id, source));
}
}
}
pub fn heading_to_anchor(text: &str) -> String {
let lower = text.to_lowercase();
let filtered: String = lower
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ')
.collect();
let hyphenated = filtered.replace(' ', "-");
let mut slug = String::with_capacity(hyphenated.len());
let mut prev_hyphen = false;
for ch in hyphenated.chars() {
if ch == '-' {
if !prev_hyphen {
slug.push(ch);
}
prev_hyphen = true;
} else {
slug.push(ch);
prev_hyphen = false;
}
}
slug.trim_matches('-').to_string()
}
pub fn cell_display_width(spans: &[Span<'static>]) -> usize {
spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum()
}
pub fn cell_to_string(spans: &[Span<'static>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::renderer::render_markdown;
use crate::theme::Palette;
fn palette() -> Palette {
Palette::from_theme(crate::theme::Theme::Default)
}
#[test]
fn anchor_plain_words() {
assert_eq!(
heading_to_anchor("Installation Guide"),
"installation-guide"
);
}
#[test]
fn anchor_apostrophe_stripped() {
assert_eq!(heading_to_anchor("What's New?"), "whats-new");
}
#[test]
fn anchor_dot_stripped() {
assert_eq!(heading_to_anchor("API v2.0"), "api-v20");
}
#[test]
fn anchor_already_lowercase() {
assert_eq!(heading_to_anchor("hello world"), "hello-world");
}
#[test]
fn anchor_consecutive_spaces_collapse() {
assert_eq!(heading_to_anchor("A B"), "a-b");
}
#[test]
fn anchor_empty() {
assert_eq!(heading_to_anchor(""), "");
}
#[test]
fn link_info_internal_anchor() {
let md = "[Installation](#installation)\n";
let blocks = render_markdown(md, &palette());
let link = match &blocks[0] {
DocBlock::Text { links, .. } => links.first().expect("link expected"),
_ => panic!("expected Text block"),
};
assert_eq!(link.url, "#installation");
assert_eq!(link.text, "Installation");
assert_eq!(link.line, 0);
assert_eq!(link.col_start, 0);
assert_eq!(link.col_end, 12);
}
#[test]
fn link_info_external_url_preserved() {
let md = "[Rust](https://rust-lang.org)\n";
let blocks = render_markdown(md, &palette());
let link = match &blocks[0] {
DocBlock::Text { links, .. } => links.first().expect("link expected"),
_ => panic!("expected Text block"),
};
assert_eq!(link.url, "https://rust-lang.org");
}
#[test]
fn heading_anchor_collected() {
let md = "# Installation Guide\n\nsome text\n";
let blocks = render_markdown(md, &palette());
let anchor = match &blocks[0] {
DocBlock::Text {
heading_anchors, ..
} => heading_anchors.first().expect("anchor expected"),
_ => panic!("expected Text block"),
};
assert_eq!(anchor.anchor, "installation-guide");
assert_eq!(anchor.line, 0);
}
}