use std::collections::HashSet;
use std::path::Path;
const RESET: &str = "\x1b[0m";
const DIM: &str = "\x1b[2m";
pub struct RenderedMarkdown {
pub text: String,
pub map: Vec<(usize, usize)>,
}
pub fn render_with_map(source: &str, width: usize) -> RenderedMarkdown {
use pulldown_cmark::{Event, Parser, Tag};
let skin = termimad::MadSkin::default();
let render_width = width.max(20);
fn is_top_level_block(tag: &Tag) -> bool {
matches!(
tag,
Tag::Paragraph
| Tag::Heading { .. }
| Tag::BlockQuote(_)
| Tag::CodeBlock(_)
| Tag::List(_)
| Tag::HtmlBlock
| Tag::Table(_)
| Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::MetadataBlock(_)
)
}
let mut blocks: Vec<std::ops::Range<usize>> = Vec::new();
let mut depth: i32 = 0;
let mut current_start: Option<usize> = None;
for (event, range) in Parser::new(source).into_offset_iter() {
match event {
Event::Start(tag) => {
if depth == 0 && is_top_level_block(&tag) {
current_start = Some(range.start);
}
depth += 1;
}
Event::End(_) => {
depth -= 1;
if depth == 0 {
if let Some(start) = current_start.take() {
blocks.push(start..range.end);
}
}
}
Event::Rule if depth == 0 => {
blocks.push(range);
}
_ => {}
}
}
let mut text = String::new();
let mut map: Vec<(usize, usize)> = Vec::with_capacity(blocks.len());
let mut rendered_row: usize = 0;
for range in &blocks {
let source_line = source[..range.start].matches('\n').count() + 1;
let chunk_src = &source[range.clone()];
let chunk_out = skin.text(chunk_src, Some(render_width)).to_string();
map.push((rendered_row, source_line));
let row_count = if chunk_out.is_empty() {
0
} else if chunk_out.ends_with('\n') {
chunk_out.matches('\n').count()
} else {
chunk_out.matches('\n').count() + 1
};
text.push_str(&chunk_out);
if !chunk_out.is_empty() && !chunk_out.ends_with('\n') {
text.push('\n');
}
rendered_row = rendered_row.saturating_add(row_count);
}
RenderedMarkdown { text, map }
}
pub fn render_to_string(source: &str, width: usize) -> String {
render_with_map(source, width).text
}
pub struct RenderedMarkdownWithGutter {
pub text: String,
pub map: Vec<(usize, usize)>,
pub gutter_width: usize,
}
pub fn render_with_gutter(
source: &str,
term_w: usize,
line_no_width: usize,
show_numbers: bool,
show_grid: bool,
use_color: bool,
) -> RenderedMarkdownWithGutter {
let gutter_width = (if show_numbers { line_no_width + 1 } else { 0 })
+ (if show_grid { 2 } else { 0 });
if gutter_width == 0 {
let r = render_with_map(source, term_w);
return RenderedMarkdownWithGutter {
text: r.text,
map: r.map,
gutter_width: 0,
};
}
let body_width = term_w.saturating_sub(gutter_width).max(20);
let rendered = render_with_map(source, body_width);
let block_starts: HashSet<usize> = rendered.map.iter().map(|(r, _)| *r).collect();
let map = rendered.map.clone();
let rows: Vec<&str> = rendered.text.split('\n').collect();
let mut out = String::with_capacity(rendered.text.len() + rows.len() * gutter_width);
let last = rows.len().saturating_sub(1);
for (idx, row) in rows.iter().enumerate() {
if show_numbers {
if block_starts.contains(&idx) {
let src_line = source_line_for_rendered(&map, idx);
let label = format!("{:>width$}", src_line, width = line_no_width);
if use_color {
out.push_str(DIM);
out.push_str(&label);
out.push_str(RESET);
} else {
out.push_str(&label);
}
out.push(' ');
} else {
for _ in 0..(line_no_width + 1) {
out.push(' ');
}
}
}
if show_grid {
out.push_str("│ ");
}
out.push_str(row);
if idx < last {
out.push('\n');
}
}
RenderedMarkdownWithGutter {
text: out,
map,
gutter_width,
}
}
pub fn rendered_row_for_source(map: &[(usize, usize)], source_line: usize) -> usize {
if map.is_empty() {
return 0;
}
let mut lo = 0usize;
let mut hi = map.len();
let mut best: usize = 0;
while lo < hi {
let mid = (lo + hi) / 2;
if map[mid].1 <= source_line {
best = map[mid].0;
lo = mid + 1;
} else {
hi = mid;
}
}
best
}
pub fn source_line_for_rendered(map: &[(usize, usize)], rendered_row: usize) -> usize {
if map.is_empty() {
return 1;
}
let mut lo = 0usize;
let mut hi = map.len();
let mut best: usize = map[0].1;
while lo < hi {
let mid = (lo + hi) / 2;
if map[mid].0 <= rendered_row {
best = map[mid].1;
lo = mid + 1;
} else {
hi = mid;
}
}
best
}
pub fn is_markdown_path(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("md" | "markdown" | "mdown" | "mkd")
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn renders_basic_markdown() {
let out = render_to_string("# Hello\n\nThis is **bold**.\n", 80);
assert!(out.contains("\x1b["), "expected ANSI in: {:?}", out);
assert!(out.contains("Hello"));
assert!(out.contains("bold"));
}
#[test]
fn render_handles_empty_input() {
let out = render_to_string("", 80);
let _ = out;
}
#[test]
fn render_with_map_handles_inline_tags_in_blocks() {
let src = "First **bold** paragraph.\n\n\
Second *emph* paragraph with `code`.\n\n\
Third paragraph plain.\n";
let r = render_with_map(src, 80);
assert_eq!(
r.map.len(),
3,
"expected 3 paragraph blocks, got map: {:?}",
r.map
);
assert!(r.text.contains("First"), "missing first: {:?}", r.text);
assert!(r.text.contains("Second"), "missing second: {:?}", r.text);
assert!(r.text.contains("Third"), "missing third: {:?}", r.text);
}
#[test]
fn render_with_map_distinct_blocks() {
let src = "# Title\n\nA paragraph.\n\n- list item\n- another\n";
let r = render_with_map(src, 80);
assert!(r.map.len() >= 3, "expected ≥3 blocks, got map: {:?}", r.map);
for w in r.map.windows(2) {
assert!(w[0].0 <= w[1].0, "rendered rows not monotonic: {:?}", r.map);
}
for w in r.map.windows(2) {
assert!(w[0].1 <= w[1].1, "source lines not monotonic: {:?}", r.map);
}
}
#[test]
fn render_with_map_first_block_at_row_zero() {
let src = "# Heading\n";
let r = render_with_map(src, 80);
assert!(!r.map.is_empty());
assert_eq!(r.map[0].0, 0, "first block must start at rendered row 0");
assert_eq!(r.map[0].1, 1, "first block must start at source line 1");
}
#[test]
fn rendered_row_for_source_lookup() {
let map = vec![(0, 1), (5, 10), (12, 20), (30, 50)];
assert_eq!(rendered_row_for_source(&map, 1), 0);
assert_eq!(rendered_row_for_source(&map, 9), 0);
assert_eq!(rendered_row_for_source(&map, 10), 5);
assert_eq!(rendered_row_for_source(&map, 15), 5);
assert_eq!(rendered_row_for_source(&map, 20), 12);
assert_eq!(rendered_row_for_source(&map, 1000), 30);
assert_eq!(rendered_row_for_source(&map, 0), 0);
}
#[test]
fn source_line_for_rendered_lookup() {
let map = vec![(0, 1), (5, 10), (12, 20), (30, 50)];
assert_eq!(source_line_for_rendered(&map, 0), 1);
assert_eq!(source_line_for_rendered(&map, 4), 1);
assert_eq!(source_line_for_rendered(&map, 5), 10);
assert_eq!(source_line_for_rendered(&map, 12), 20);
assert_eq!(source_line_for_rendered(&map, 100), 50);
}
#[test]
fn lookup_helpers_handle_empty_map() {
let map: Vec<(usize, usize)> = vec![];
assert_eq!(rendered_row_for_source(&map, 42), 0);
assert_eq!(source_line_for_rendered(&map, 42), 1);
}
#[test]
fn detects_md_extension() {
assert!(is_markdown_path(&PathBuf::from("README.md")));
assert!(is_markdown_path(&PathBuf::from("notes.markdown")));
assert!(is_markdown_path(&PathBuf::from("doc.MD")));
assert!(is_markdown_path(&PathBuf::from("file.mkd")));
assert!(is_markdown_path(&PathBuf::from("file.mdown")));
}
#[test]
fn rejects_non_md_extensions() {
assert!(!is_markdown_path(&PathBuf::from("main.rs")));
assert!(!is_markdown_path(&PathBuf::from("README")));
assert!(!is_markdown_path(&PathBuf::from("config.toml")));
}
#[test]
fn render_with_gutter_off_when_neither_flag() {
let src = "# Title\n\nA paragraph.\n";
let plain = render_with_map(src, 80).text;
let r = render_with_gutter(src, 80, 4, false, false, false);
assert_eq!(r.gutter_width, 0);
assert_eq!(r.text, plain);
}
#[test]
fn render_with_gutter_width_accounting() {
let r = render_with_gutter("# Title\n", 80, 4, true, true, false);
assert_eq!(r.gutter_width, 7);
let r = render_with_gutter("# Title\n", 80, 4, true, false, false);
assert_eq!(r.gutter_width, 5);
let r = render_with_gutter("# Title\n", 80, 4, false, true, false);
assert_eq!(r.gutter_width, 2);
}
#[test]
fn render_with_gutter_prefixes_block_starts() {
let src = "# Title\n\nA paragraph.\n\n- Item one\n- Item two\n";
let r = render_with_gutter(src, 80, 4, true, false, false);
let starts: std::collections::HashSet<usize> =
r.map.iter().map(|(rr, _)| *rr).collect();
for (idx, row) in r.text.split('\n').enumerate() {
let prefix: String = row.chars().take(5).collect();
if starts.contains(&idx) {
assert!(
prefix.trim().chars().any(|c| c.is_ascii_digit()),
"block-start row {} missing line number; prefix={:?}",
idx, prefix
);
} else {
assert!(
prefix.trim().is_empty(),
"continuation row {} has unexpected number; prefix={:?}",
idx, prefix
);
}
}
}
#[test]
fn render_with_gutter_grid_repeats_on_every_row() {
let src = "# Title\n\nA paragraph.\n";
let r = render_with_gutter(src, 80, 4, true, true, false);
for (idx, row) in r.text.split('\n').enumerate() {
assert!(
row.contains('│'),
"row {} missing grid bar: {:?}",
idx, row
);
}
}
}