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>,
visual_height: Cell<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 { visual_height, .. } => visual_height.get(),
DocBlock::Mermaid { cell_height, .. } => cell_height.get(),
DocBlock::Table(t) => t.rendered_height,
}
}
}
pub fn update_text_visual_heights(blocks: &[DocBlock], content_width: u16) -> bool {
use crate::ui::markdown_view::visual_rows::line_visual_rows;
let mut changed = false;
for block in blocks {
if let DocBlock::Text {
text,
visual_height,
..
} = block
{
let new_h: u32 = text
.lines
.iter()
.map(|l| line_visual_rows(l, content_width))
.sum();
let new_h = new_h.max(crate::cast::u32_sat(text.lines.len()));
if new_h != visual_height.get() {
visual_height.set(new_h);
changed = true;
}
}
}
changed
}
pub fn update_mermaid_heights(
blocks: &[DocBlock],
cache: &crate::mermaid::MermaidCache,
max_height: u32,
) -> 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, max_height);
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),
}
}
#[allow(dead_code)]
pub fn source_line_at(blocks: &[DocBlock], visual_row: u32) -> u32 {
source_line_at_width(blocks, visual_row, 0)
}
pub fn source_line_at_width(blocks: &[DocBlock], visual_row: u32, content_width: u16) -> u32 {
use crate::ui::markdown_view::visual_rows::line_visual_rows;
let mut offset = 0u32;
for block in blocks {
let h = block.height();
if visual_row < offset + h {
let local_visual = visual_row - offset;
return match block {
DocBlock::Text {
text, source_lines, ..
} => {
let mut acc = 0u32;
let mut logical_idx = 0usize;
for (i, line) in text.lines.iter().enumerate() {
let rows = line_visual_rows(line, content_width);
if local_visual < acc + rows {
logical_idx = i;
break;
}
acc += rows;
logical_idx = i + 1;
}
source_lines.get(logical_idx).copied().unwrap_or(0)
}
DocBlock::Mermaid {
source_line,
source,
..
} => {
let local = local_visual as usize;
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_visual as usize),
};
}
offset += h;
}
0
}
#[allow(dead_code)]
pub fn logical_line_at_source(blocks: &[DocBlock], target_source: u32) -> Option<u32> {
logical_line_at_source_width(blocks, target_source, 0)
}
pub fn logical_line_at_source_width(
blocks: &[DocBlock],
target_source: u32,
content_width: u16,
) -> Option<u32> {
use crate::ui::markdown_view::visual_rows::line_visual_rows;
let mut offset = 0u32;
let mut best: Option<u32> = None;
for block in blocks {
let height = block.height();
match block {
DocBlock::Text {
text, source_lines, ..
} => {
let mut visual_in_block = 0u32;
for (i, &s) in source_lines.iter().enumerate() {
if s == target_source {
return Some(offset + visual_in_block);
}
if s <= target_source {
best = Some(offset + visual_in_block);
}
if let Some(line) = text.lines.get(i) {
visual_in_block =
visual_in_block.saturating_add(line_visual_rows(line, content_width));
}
}
}
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 == '_' || *c == ' ')
.collect();
let slug = filtered.replace(' ', "-");
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_preserve_hyphens() {
assert_eq!(heading_to_anchor("A B"), "a--b");
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);
}
#[test]
fn heading_with_inline_code_produces_correct_anchor() {
let md = "# `kg.nodes`\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, "kgnodes");
}
#[test]
fn heading_mixing_text_and_inline_code_includes_both_in_anchor() {
let md = "# Use the `Foo` API\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, "use-the-foo-api");
}
#[test]
fn heading_with_underscores_preserves_underscores_in_anchor() {
let md = "# `kg.node_stats`\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, "kgnode_stats");
}
#[test]
fn heading_with_multi_code_and_slash_produces_correct_anchor() {
let md = "# `kg.node_stats` / `kg.predicate_stats`\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, "kgnode_stats--kgpredicate_stats");
}
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();
let n = crate::cast::u32_sat(lines.len());
DocBlock::Text {
text: ratatui::text::Text::from(lines),
links: vec![],
heading_anchors: vec![],
source_lines: sources.to_vec(),
visual_height: std::cell::Cell::new(n),
}
}
#[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 update_text_visual_heights_counts_wrapped_rows() {
let long = "x".repeat(50); let blocks = vec![text_block_with_sources(
&["short", &long, "short"],
&[0, 1, 2],
)];
let changed = update_text_visual_heights(&blocks, 20);
assert!(
changed,
"visual_height should change from logical (3) to visual (5)"
);
assert_eq!(blocks[0].height(), 5, "1 + 3 + 1");
let changed_again = update_text_visual_heights(&blocks, 20);
assert!(!changed_again, "no-op on second call");
let changed_wider = update_text_visual_heights(&blocks, 80);
assert!(changed_wider);
assert_eq!(blocks[0].height(), 3, "1 + 1 + 1 at 80 cols");
}
#[test]
fn source_line_at_width_handles_wrapped_text_block() {
let long = "y".repeat(30);
let blocks = vec![text_block_with_sources(&["a", &long, "c"], &[10, 20, 30])];
update_text_visual_heights(&blocks, 10);
assert_eq!(source_line_at_width(&blocks, 0, 10), 10);
assert_eq!(source_line_at_width(&blocks, 1, 10), 20);
assert_eq!(
source_line_at_width(&blocks, 2, 10),
20,
"row 2 is wrap continuation"
);
assert_eq!(
source_line_at_width(&blocks, 3, 10),
20,
"row 3 is wrap continuation"
);
assert_eq!(source_line_at_width(&blocks, 4, 10), 30);
}
#[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));
}
}