pub mod highlight;
pub mod math;
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,
pub source_line: u32,
pub row_source_lines: Vec<u32>,
}
#[derive(Debug)]
pub enum DocBlock {
Text {
text: Text<'static>,
links: Vec<LinkInfo>,
heading_anchors: Vec<HeadingAnchor>,
source_lines: Vec<u32>,
},
Mermaid {
id: MermaidBlockId,
source: String,
cell_height: Cell<u32>,
source_line: u32,
},
Table(TableBlock),
}
impl DocBlock {
pub fn height(&self) -> u32 {
match self {
DocBlock::Text { text, .. } => crate::cast::u32_sat(text.lines.len()),
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
}
fn table_row_source_line(t: &TableBlock, local: usize) -> u32 {
let header_idx: usize = 1; let first_body_idx: usize = 3; let last_body_idx: usize = first_body_idx + t.rows.len();
match local {
i if i < header_idx => t.row_source_lines.first().copied().unwrap_or(t.source_line),
i if i == header_idx => t.row_source_lines.first().copied().unwrap_or(t.source_line),
i if i < first_body_idx => t.row_source_lines.first().copied().unwrap_or(t.source_line),
i if i < last_body_idx => {
let body_index = i - first_body_idx;
t.row_source_lines
.get(1 + body_index)
.copied()
.unwrap_or(t.source_line)
}
_ => t.row_source_lines.last().copied().unwrap_or(t.source_line),
}
}
pub fn source_line_at(blocks: &[DocBlock], logical_line: u32) -> u32 {
let mut offset = 0u32;
for block in blocks {
let h = block.height();
if logical_line < offset + h {
let local = (logical_line - offset) as usize;
return match block {
DocBlock::Text { source_lines, .. } => {
source_lines.get(local).copied().unwrap_or(0)
}
DocBlock::Mermaid {
source_line,
source,
..
} => {
if local == 0 {
*source_line
} else {
let content_count = crate::cast::u32_sat(source.lines().count());
let content_offset =
(crate::cast::u32_sat(local) - 1).min(content_count.saturating_sub(1));
*source_line + 1 + content_offset
}
}
DocBlock::Table(t) => table_row_source_line(t, local),
};
}
offset += h;
}
0
}
pub fn logical_line_at_source(blocks: &[DocBlock], target_source: u32) -> Option<u32> {
let mut offset = 0u32;
let mut best: Option<u32> = None;
for block in blocks {
let height = block.height();
match block {
DocBlock::Text { source_lines, .. } => {
for (i, &s) in source_lines.iter().enumerate() {
if s == target_source {
return Some(offset + crate::cast::u32_sat(i));
}
if s <= target_source {
best = Some(offset + crate::cast::u32_sat(i));
}
}
}
DocBlock::Mermaid {
source_line,
source,
..
} => {
let content_count = crate::cast::u32_sat(source.lines().count());
let block_end_source = *source_line + 1 + content_count;
if target_source >= *source_line && target_source < block_end_source {
let local = target_source - *source_line;
return Some(offset + local.min(height.saturating_sub(1)));
}
if *source_line <= target_source {
best = Some(offset);
}
}
DocBlock::Table(t) => {
for (row_idx, &s) in t.row_source_lines.iter().enumerate() {
let rendered_row = if row_idx == 0 {
1u32 } else {
3 + crate::cast::u32_sat(row_idx - 1) };
if rendered_row >= height {
break;
}
if s == target_source {
return Some(offset + rendered_row);
}
if s <= target_source {
best = Some(offset + rendered_row);
} else {
break;
}
}
}
}
offset += height;
}
best
}
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_or("", |(_, t)| t)
.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,
);
}
}
fn text_block_with_sources(content: &[&str], sources: &[u32]) -> DocBlock {
let lines: Vec<ratatui::text::Line<'static>> = content
.iter()
.map(|s| ratatui::text::Line::from(ratatui::text::Span::raw(s.to_string())))
.collect();
DocBlock::Text {
text: ratatui::text::Text::from(lines),
links: vec![],
heading_anchors: vec![],
source_lines: sources.to_vec(),
}
}
#[test]
fn logical_line_at_source_finds_text_line() {
let block = text_block_with_sources(&["a", "b", "c"], &[0, 1, 2]);
assert_eq!(logical_line_at_source(&[block], 1), Some(1));
}
#[test]
fn logical_line_at_source_across_blocks() {
let b1 = text_block_with_sources(&["a", "b"], &[0, 1]);
let b2 = text_block_with_sources(&["d", "e", "f"], &[3, 4, 5]);
assert_eq!(logical_line_at_source(&[b1, b2], 4), Some(3));
}
#[test]
fn logical_line_at_source_table_header() {
use crate::markdown::{TableBlock, TableBlockId};
let block = DocBlock::Table(TableBlock {
id: TableBlockId(0),
headers: vec![vec![ratatui::text::Span::raw("H")]],
rows: vec![
vec![vec![ratatui::text::Span::raw("a")]],
vec![vec![ratatui::text::Span::raw("b")]],
],
alignments: vec![pulldown_cmark::Alignment::None],
natural_widths: vec![1],
rendered_height: 6,
source_line: 5,
row_source_lines: vec![5, 7, 8],
});
assert_eq!(logical_line_at_source(&[block], 5), Some(1));
}
#[test]
fn logical_line_at_source_table_body() {
use crate::markdown::{TableBlock, TableBlockId};
let block = DocBlock::Table(TableBlock {
id: TableBlockId(1),
headers: vec![vec![ratatui::text::Span::raw("H")]],
rows: vec![
vec![vec![ratatui::text::Span::raw("a")]],
vec![vec![ratatui::text::Span::raw("b")]],
],
alignments: vec![pulldown_cmark::Alignment::None],
natural_widths: vec![1],
rendered_height: 6,
source_line: 5,
row_source_lines: vec![5, 7, 8],
});
assert_eq!(logical_line_at_source(&[block], 7), Some(3));
}
#[test]
fn logical_line_at_source_mermaid_inside() {
use std::cell::Cell;
let block = DocBlock::Mermaid {
id: crate::markdown::MermaidBlockId(0),
source: "a\nb\nc".to_string(),
cell_height: Cell::new(4),
source_line: 10,
};
assert_eq!(logical_line_at_source(&[block], 12), Some(2));
}
#[test]
fn logical_line_at_source_overshoot_falls_back_to_last_line() {
let block = text_block_with_sources(&["x", "y", "z"], &[0, 1, 2]);
assert_eq!(logical_line_at_source(&[block], 99), Some(2));
}
#[test]
fn logical_line_at_source_non_monotonic_text_block() {
let block = text_block_with_sources(&["a", "b", "c"], &[165, 160, 167]);
assert_eq!(logical_line_at_source(&[block], 163), Some(1));
}
#[test]
fn logical_line_at_source_duplicate_source_line_returns_first() {
let block = text_block_with_sources(
&["heading", "para1", "para2", "blank-after-list"],
&[306, 307, 308, 306],
);
assert_eq!(logical_line_at_source(&[block], 306), Some(0));
}
#[test]
fn logical_line_at_source_duplicate_across_blocks_returns_first() {
let b1 = text_block_with_sources(&["real content"], &[306]);
let b2 = text_block_with_sources(&["other", "dip-artifact"], &[310, 306]);
assert_eq!(logical_line_at_source(&[b1, b2], 306), Some(0));
}
#[test]
fn logical_line_at_source_target_beyond_any_block_is_none() {
assert_eq!(logical_line_at_source(&[], 5), None);
}
#[test]
fn logical_line_at_source_target_inside_joined_paragraph() {
for target in [5u32, 6, 7] {
let block = text_block_with_sources(&["joined paragraph"], &[5]);
assert_eq!(
logical_line_at_source(&[block], target),
Some(0),
"target source line {target} should land on the joined paragraph's rendered line 0",
);
}
}
#[test]
fn logical_line_at_source_between_blocks_lands_on_previous_last_line() {
let b1 = text_block_with_sources(&["a", "b"], &[0, 1]);
let b2 = text_block_with_sources(&["c", "d"], &[10, 11]);
assert_eq!(logical_line_at_source(&[b1, b2], 5), Some(1));
}
}