pub mod highlight;
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) -> bool {
let mut changed = false;
for block in blocks {
if let DocBlock::Mermaid {
id,
source,
cell_height,
} = block
{
let new_h = cache.height(id, source);
if new_h != cell_height.get() {
cell_height.set(new_h);
changed = true;
}
}
}
changed
}
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, Theme};
fn palette() -> Palette {
Palette::from_theme(Theme::Default)
}
fn theme() -> 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(), theme());
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(), theme());
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(), theme());
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);
}
fn absolute_anchor_positions(blocks: &[DocBlock]) -> Vec<(String, u32)> {
let mut result = Vec::new();
let mut offset = 0u32;
for block in blocks {
if let DocBlock::Text {
heading_anchors, ..
} = block
{
for ha in heading_anchors {
result.push((ha.anchor.clone(), offset + ha.line));
}
}
offset += block.height();
}
result
}
fn actual_heading_lines(blocks: &[DocBlock]) -> Vec<(String, u32)> {
let mut result = Vec::new();
let mut abs_line = 0u32;
for block in blocks {
if let DocBlock::Text { text, .. } = block {
for line in &text.lines {
let content: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
for prefix in &["█ ", "▌ ", "▎ "] {
if content.contains(prefix) {
let text_after_prefix = content
.split_once(prefix)
.map(|(_, t)| t)
.unwrap_or("")
.trim();
if !text_after_prefix.is_empty() {
let anchor = heading_to_anchor(text_after_prefix);
result.push((anchor, abs_line));
}
break;
}
}
abs_line += 1;
}
} else {
abs_line += block.height();
}
}
result
}
#[test]
fn visual_row_wrapping_maps_to_correct_logical_line() {
use crate::ui::markdown_view::visual_row_to_logical_line;
let long_para: String = "word ".repeat(30); let md = format!(
"# Title\n\n{long_para}\n\n- [Section A](#section-a)\n- [Section B](#section-b)\n\n## Section A\n\nText.\n\n## Section B\n\nMore.\n",
);
let blocks = render_markdown(&md, &palette(), theme());
let content_width: u16 = 80;
let logical_line_for_section_a = visual_row_to_logical_line(&blocks, 0, 5, content_width);
assert_eq!(
logical_line_for_section_a, 4,
"visual row 5 should map to logical line 4 (Section A), \
not naive row 5 (Section B); naive formula is off by 1 wrap row"
);
let logical_line_for_section_b = visual_row_to_logical_line(&blocks, 0, 6, content_width);
assert_eq!(
logical_line_for_section_b, 5,
"visual row 6 should map to logical line 5 (Section B)"
);
}
#[test]
fn anchor_positions_match_actual_heading_lines_after_special_blocks() {
let md = concat!(
"# Title\n\n",
"- [Section A](#section-a)\n",
"- [Section B](#section-b)\n",
"- [Section C](#section-c)\n",
"- [Section D](#section-d)\n\n",
"## Section A\n\n",
"Some text.\n\n",
"```mermaid\n",
"graph LR\n",
" A-->B\n",
"```\n\n",
"## Section B\n\n",
"More text.\n\n",
"| Col1 | Col2 |\n",
"|------|------|\n",
"| a | b |\n\n",
"## Section C\n\n",
"Some code:\n\n",
"```rust\n",
"let x = 1;\n",
"```\n\n",
"## Section D\n\n",
"Final text.\n",
);
let blocks = render_markdown(md, &palette(), theme());
let recorded = absolute_anchor_positions(&blocks);
let actual = actual_heading_lines(&blocks);
for (anchor, recorded_line) in &recorded {
let found = actual
.iter()
.find(|(a, _)| a == anchor)
.unwrap_or_else(|| panic!("no heading found for anchor '{anchor}'"));
assert_eq!(
*recorded_line, found.1,
"anchor '{anchor}': recorded line {recorded_line} != actual heading line {}",
found.1,
);
}
}
}