feat(02-02): build core markdown renderer with inline and block elements

- Create src/renderer.rs with public render_markdown(input, width) -> Vec<Line<'static>>
- Implement RenderState with style stack, blockquote depth, list counters, table two-pass collection
- Handle all inline elements: headings H1-H6 (CGA colors + H1/H2 decorators), bold, italic, inline code
- Handle block elements: paragraphs, lists (ordered/unordered/nested with •/◦/▪), blockquotes with │ prefix, horizontal rules, images as [IMAGE: alt] placeholders
- Stub emit_code_block and emit_table delegate to full implementations (Task 2)
- Add mod highlighter, renderer, vault declarations to main.rs (wiring for Plan 03)
- cargo check passes clean (19 dead_code warnings expected — modules unwired until Plan 03)
This commit is contained in:
2026-02-28 22:17:28 +01:00
parent af0fe03f12
commit 9e6f79c233
2 changed files with 716 additions and 0 deletions
+3
View File
@@ -1,7 +1,10 @@
mod app;
mod config;
mod highlighter;
mod renderer;
mod signals;
mod terminal;
mod vault;
fn main() {
// ── PRE-TERMINAL PHASE ────────────────────────────────────────────────────
+713
View File
@@ -0,0 +1,713 @@
//! Markdown-to-styled-lines renderer for bbs-md.
//!
//! Converts a markdown string into `Vec<Line<'static>>` using pulldown-cmark
//! event processing, CGA-themed styling, syntax-highlighted code blocks, and
//! box-drawing table grids.
//!
//! # Public API
//!
//! - `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>`
use pulldown_cmark::{
Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream,
};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
// ── RenderState ───────────────────────────────────────────────────────────────
/// Internal mutable state threaded through the event handler.
struct RenderState {
/// Accumulated output lines.
lines: Vec<Line<'static>>,
/// Spans for the current (in-progress) line.
current_spans: Vec<Span<'static>>,
/// Style stack — push on block/inline Start, pop on End.
style_stack: Vec<Style>,
/// True while inside a fenced or indented code block.
in_code_block: bool,
/// Language token for the current code block (empty = no language).
code_lang: String,
/// Accumulated raw text inside a code block.
code_buf: String,
/// List nesting: `None` = unordered, `Some(n)` = ordered starting at n.
list_counters: Vec<Option<u64>>,
/// Nesting depth of blockquotes (0 = not in blockquote).
blockquote_depth: u32,
/// True when we are currently inside at least one blockquote.
in_blockquote: bool,
/// True while collecting alt text for an image.
in_image: bool,
/// Accumulated image alt text.
image_alt: String,
// ── Table state ──────────────────────────────────────────────────────────
/// True while inside a table element.
in_table: bool,
/// Column alignment from the `Table` tag.
table_alignments: Vec<Alignment>,
/// All rows collected (rows[0] = header row).
table_rows: Vec<Vec<String>>,
/// Text accumulated for the current table cell.
current_cell: String,
/// True while inside the `<thead>` section.
in_table_head: bool,
// ── Width ────────────────────────────────────────────────────────────────
/// Terminal width; used for horizontal rules and code block sizing.
width: u16,
}
impl RenderState {
fn new(width: u16) -> Self {
RenderState {
lines: Vec::new(),
current_spans: Vec::new(),
style_stack: Vec::new(),
in_code_block: false,
code_lang: String::new(),
code_buf: String::new(),
list_counters: Vec::new(),
blockquote_depth: 0,
in_blockquote: false,
in_image: false,
image_alt: String::new(),
in_table: false,
table_alignments: Vec::new(),
table_rows: Vec::new(),
current_cell: String::new(),
in_table_head: false,
width,
}
}
// ── Style helpers ─────────────────────────────────────────────────────────
/// Return the topmost style from the stack, or `Style::default()` if empty.
fn current_style(&self) -> Style {
self.style_stack.last().copied().unwrap_or_default()
}
/// Push a new style derived from the current top by adding a modifier.
fn push_modifier(&mut self, modifier: Modifier) {
let new_style = self.current_style().add_modifier(modifier);
self.style_stack.push(new_style);
}
/// Push a completely new foreground-colored style.
fn push_fg(&mut self, color: Color) {
let new_style = Style::default().fg(color);
self.style_stack.push(new_style);
}
/// Push a new style with a foreground color and modifier.
fn push_fg_mod(&mut self, color: Color, modifier: Modifier) {
let new_style = Style::default().fg(color).add_modifier(modifier);
self.style_stack.push(new_style);
}
// ── Line flushing ─────────────────────────────────────────────────────────
/// Flush `current_spans` into a `Line`, optionally prepending blockquote borders.
///
/// If inside a blockquote, the content spans are colored gray and preceded
/// by a yellow `│ ` border for each nesting level.
fn flush_line(&mut self) {
let mut spans = std::mem::take(&mut self.current_spans);
if self.in_blockquote && self.blockquote_depth > 0 {
// Re-color content spans to Gray
for span in spans.iter_mut() {
span.style = span.style.fg(Color::Gray);
}
// Prepend the blockquote border(s)
let border_str = "".repeat(self.blockquote_depth as usize);
let border_span =
Span::styled(border_str, Style::default().fg(Color::Yellow));
let mut prefixed = vec![border_span];
prefixed.extend(spans);
spans = prefixed;
}
self.lines.push(Line::from(spans));
}
/// Push a blank (empty) line.
fn push_blank(&mut self) {
self.lines.push(Line::default());
}
// ── Heading handling ──────────────────────────────────────────────────────
fn start_heading(&mut self, level: HeadingLevel) {
let style = match level {
HeadingLevel::H1 => {
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD)
}
HeadingLevel::H2 => {
Style::default().fg(Color::LightYellow).add_modifier(Modifier::BOLD)
}
HeadingLevel::H3 => {
Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD)
}
HeadingLevel::H4 => Style::default().fg(Color::Green),
HeadingLevel::H5 => Style::default().fg(Color::Cyan),
HeadingLevel::H6 => Style::default().fg(Color::DarkGray),
};
self.style_stack.push(style);
}
fn end_heading(&mut self, level: HeadingLevel) {
// Flush the heading text
self.flush_line();
// Pop the heading style
self.style_stack.pop();
// Emit decorator for H1 and H2
let w = self.width as usize;
match level {
HeadingLevel::H1 => {
self.lines.push(Line::from(Span::styled(
"".repeat(w),
Style::default().fg(Color::LightCyan),
)));
}
HeadingLevel::H2 => {
self.lines.push(Line::from(Span::styled(
"".repeat(w),
Style::default().fg(Color::LightYellow),
)));
}
_ => {}
}
// Always add a blank line after any heading
self.push_blank();
}
// ── Code block stub ───────────────────────────────────────────────────────
// (will be replaced in Task 2 with full border + highlighting)
fn emit_code_block_stub(&mut self) {
let code = std::mem::take(&mut self.code_buf);
let lang = std::mem::take(&mut self.code_lang);
let width = self.width;
emit_code_block(&code, &lang, width, &mut self.lines);
}
// ── Table stub ────────────────────────────────────────────────────────────
// (will be replaced in Task 2 with full box-drawing grid)
fn emit_table_stub(&mut self) {
let alignments = std::mem::take(&mut self.table_alignments);
let rows = std::mem::take(&mut self.table_rows);
emit_table(&alignments, &rows, &mut self.lines);
}
// ── Finish ────────────────────────────────────────────────────────────────
fn finish(mut self) -> Vec<Line<'static>> {
// Flush any trailing spans that were not terminated with a paragraph end
if !self.current_spans.is_empty() {
self.flush_line();
}
self.lines
}
}
// ── Event dispatcher ──────────────────────────────────────────────────────────
fn handle_event(state: &mut RenderState, event: Event) {
match event {
// ── Headings ──────────────────────────────────────────────────────────
Event::Start(Tag::Heading { level, .. }) => {
state.start_heading(level);
}
Event::End(TagEnd::Heading(level)) => {
state.end_heading(level);
}
// ── Paragraphs ────────────────────────────────────────────────────────
Event::Start(Tag::Paragraph) => {
// Nothing — spans accumulate until End
}
Event::End(TagEnd::Paragraph) => {
state.flush_line();
state.push_blank();
}
// ── Inline formatting ─────────────────────────────────────────────────
Event::Start(Tag::Strong) => {
state.push_modifier(Modifier::BOLD);
}
Event::End(TagEnd::Strong) => {
state.style_stack.pop();
}
Event::Start(Tag::Emphasis) => {
state.push_modifier(Modifier::ITALIC);
}
Event::End(TagEnd::Emphasis) => {
state.style_stack.pop();
}
// ── Inline code ───────────────────────────────────────────────────────
Event::Code(text) => {
// Inline code: LightCyan color, no background color available in 16-color
let span = Span::styled(
text.to_string(),
Style::default().fg(Color::LightCyan),
);
state.current_spans.push(span);
}
// ── Text ──────────────────────────────────────────────────────────────
Event::Text(text) => {
if state.in_code_block {
state.code_buf.push_str(&text);
} else if state.in_image {
state.image_alt.push_str(&text);
} else if state.in_table {
state.current_cell.push_str(&text);
} else {
let style = state.current_style();
state.current_spans.push(Span::styled(text.to_string(), style));
}
}
// ── Code blocks ───────────────────────────────────────────────────────
Event::Start(Tag::CodeBlock(kind)) => {
state.code_lang = match kind {
CodeBlockKind::Fenced(lang) => lang.to_string(),
CodeBlockKind::Indented => String::new(),
};
state.in_code_block = true;
state.code_buf.clear();
}
Event::End(TagEnd::CodeBlock) => {
state.in_code_block = false;
state.emit_code_block_stub();
}
// ── Lists ─────────────────────────────────────────────────────────────
Event::Start(Tag::List(start)) => {
state.list_counters.push(start);
}
Event::End(TagEnd::List(_)) => {
state.list_counters.pop();
// Blank line after top-level list only
if state.list_counters.is_empty() {
state.push_blank();
}
}
Event::Start(Tag::Item) => {
let depth = state.list_counters.len();
let indent = " ".repeat(depth.saturating_sub(1));
let bullet_str = match state.list_counters.last_mut() {
Some(Some(n)) => {
let s = format!("{}{}. ", indent, n);
*n += 1;
s
}
Some(None) => {
let bullet = match depth {
1 => "",
2 => "",
_ => "",
};
format!("{}{}", indent, bullet)
}
None => format!("{}", indent),
};
state.current_spans.push(Span::styled(
bullet_str,
Style::default().fg(Color::Cyan),
));
}
Event::End(TagEnd::Item) => {
state.flush_line();
}
// ── Blockquotes ───────────────────────────────────────────────────────
Event::Start(Tag::BlockQuote(_)) => {
if state.blockquote_depth == 0 {
state.push_blank();
}
state.blockquote_depth += 1;
state.in_blockquote = true;
}
Event::End(TagEnd::BlockQuote(_)) => {
if state.blockquote_depth > 0 {
state.blockquote_depth -= 1;
}
if state.blockquote_depth == 0 {
state.in_blockquote = false;
state.push_blank();
}
}
// ── Horizontal rules ──────────────────────────────────────────────────
Event::Rule => {
let w = state.width as usize;
state.lines.push(Line::from(Span::styled(
"".repeat(w),
Style::default().fg(Color::DarkGray),
)));
state.push_blank();
}
// ── Images ────────────────────────────────────────────────────────────
Event::Start(Tag::Image { .. }) => {
state.in_image = true;
state.image_alt.clear();
}
Event::End(TagEnd::Image) => {
let alt = std::mem::take(&mut state.image_alt);
let placeholder = format!("[IMAGE: {}]", alt);
state.current_spans.push(Span::styled(
placeholder,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
));
state.in_image = false;
}
// ── Links ─────────────────────────────────────────────────────────────
Event::Start(Tag::Link { .. }) => {
// Phase 2: pass through — link text renders as normal styled text.
// Phase 3 will make links interactive.
}
Event::End(TagEnd::Link) => {}
// ── Tables ────────────────────────────────────────────────────────────
Event::Start(Tag::Table(alignments)) => {
state.table_alignments = alignments;
state.table_rows.clear();
state.in_table = true;
}
Event::End(TagEnd::Table) => {
state.in_table = false;
state.emit_table_stub();
}
Event::Start(Tag::TableHead) => {
state.in_table_head = true;
// Start a new row for the header
state.table_rows.push(Vec::new());
}
Event::End(TagEnd::TableHead) => {
state.in_table_head = false;
}
Event::Start(Tag::TableRow) => {
if !state.in_table_head {
state.table_rows.push(Vec::new());
}
}
Event::End(TagEnd::TableRow) => {}
Event::Start(Tag::TableCell) => {
state.current_cell.clear();
}
Event::End(TagEnd::TableCell) => {
let cell = std::mem::take(&mut state.current_cell);
if let Some(row) = state.table_rows.last_mut() {
row.push(cell);
}
}
// ── Breaks ────────────────────────────────────────────────────────────
Event::SoftBreak => {
state.current_spans.push(Span::raw(" "));
}
Event::HardBreak => {
state.flush_line();
}
// ── Strikethrough (enabled but treated as plain text for now) ─────────
Event::Start(Tag::Strikethrough) => {
state.push_modifier(Modifier::CROSSED_OUT);
}
Event::End(TagEnd::Strikethrough) => {
state.style_stack.pop();
}
// ── Everything else ────────────────────────────────────────────────────
_ => {}
}
}
// ── Code block emitter ────────────────────────────────────────────────────────
/// Emit a fenced code block with rounded box-drawing borders and syntax
/// highlighting via `crate::highlighter::highlight_code()`.
///
/// Layout:
/// ```text
/// ╭─ rust ─────────────────────╮
/// │ let x = 1; │
/// ╰────────────────────────────╯
/// ```
fn emit_code_block(
code_buf: &str,
lang: &str,
width: u16,
lines: &mut Vec<Line<'static>>,
) {
let highlighted = crate::highlighter::highlight_code(code_buf, lang);
// Compute box width: at least lang.len() + 6, capped at terminal width
let max_content_width = highlighted.iter()
.map(|l| l.spans.iter().map(|s| s.content.len()).sum::<usize>())
.max()
.unwrap_or(0);
let min_width_for_lang = if lang.is_empty() { 4 } else { lang.len() + 6 };
let box_width = (max_content_width + 4)
.max(min_width_for_lang)
.min(width as usize);
// ── Top border ───────────────────────────────────────────────────────────
// ╭─ rust ───────╮ or ╭──────────╮
let top_line = if lang.is_empty() {
// No language label
let fill = "".repeat(box_width.saturating_sub(2));
Line::from(vec![
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(fill, Style::default().fg(Color::DarkGray)),
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
])
} else {
// With language label: ╭─ {lang} ─...─╮
let label = format!("{} ", lang);
let used = label.len() + 2; // ╭ + label + ╮
let fill_len = box_width.saturating_sub(used);
let fill = "".repeat(fill_len);
Line::from(vec![
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(lang.to_string(), Style::default().fg(Color::Yellow)),
Span::styled(format!(" {}", fill), Style::default().fg(Color::DarkGray)),
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
])
};
lines.push(Line::default()); // blank before
lines.push(top_line);
// ── Content lines ─────────────────────────────────────────────────────────
// │ {highlighted spans} {padding} │
if highlighted.is_empty() {
// Empty code block
let inner_width = box_width.saturating_sub(4); // "│ " + " │"
let padding = " ".repeat(inner_width);
lines.push(Line::from(vec![
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(padding, Style::default().fg(Color::Gray)),
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
]));
} else {
for hl_line in highlighted {
let content_len: usize = hl_line.spans.iter().map(|s| s.content.len()).sum();
let inner_width = box_width.saturating_sub(4);
let pad_len = inner_width.saturating_sub(content_len);
let padding = " ".repeat(pad_len);
let mut row_spans = vec![
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
];
for span in hl_line.spans {
row_spans.push(span);
}
row_spans.push(Span::styled(padding, Style::default()));
row_spans.push(Span::styled("".to_string(), Style::default().fg(Color::DarkGray)));
lines.push(Line::from(row_spans));
}
}
// ── Bottom border ─────────────────────────────────────────────────────────
let fill = "".repeat(box_width.saturating_sub(2));
lines.push(Line::from(vec![
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(fill, Style::default().fg(Color::DarkGray)),
Span::styled("".to_string(), Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::default()); // blank after
}
// ── Table emitter ─────────────────────────────────────────────────────────────
/// Emit a GFM table with full box-drawing grid.
///
/// Layout:
/// ```text
/// ┌──────┬──────┐
/// │ Col1 │ Col2 │
/// ├──────┼──────┤
/// │ data │ data │
/// └──────┴──────┘
/// ```
fn emit_table(
alignments: &[Alignment],
rows: &[Vec<String>],
lines: &mut Vec<Line<'static>>,
) {
if rows.is_empty() {
return;
}
let n_cols = alignments.len().max(
rows.iter().map(|r| r.len()).max().unwrap_or(0)
);
if n_cols == 0 {
return;
}
// ── Compute column widths ─────────────────────────────────────────────────
// Minimum 5 chars; each cell gets +2 for surrounding spaces
let mut col_widths: Vec<usize> = vec![5; n_cols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < n_cols {
col_widths[i] = col_widths[i].max(cell.len() + 2);
}
}
}
// ── Border builder ────────────────────────────────────────────────────────
let build_border = |left: char, mid: char, right: char, fill: char| -> String {
let mut s = String::new();
s.push(left);
for (i, &w) in col_widths.iter().enumerate() {
s.extend(std::iter::repeat(fill).take(w));
if i + 1 < n_cols {
s.push(mid);
}
}
s.push(right);
s
};
let border_style = Style::default().fg(Color::Cyan);
// ── Top border ────────────────────────────────────────────────────────────
let top = build_border('┌', '┬', '┐', '─');
lines.push(Line::from(Span::styled(top, border_style)));
// ── Header row ────────────────────────────────────────────────────────────
let header_style = Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD);
emit_table_row(
&rows[0],
&col_widths,
alignments,
header_style,
border_style,
lines,
);
// ── Header separator ──────────────────────────────────────────────────────
let sep = build_border('├', '┼', '┤', '─');
lines.push(Line::from(Span::styled(sep, border_style)));
// ── Data rows ─────────────────────────────────────────────────────────────
let data_style = Style::default();
for row in rows.iter().skip(1) {
emit_table_row(row, &col_widths, alignments, data_style, border_style, lines);
}
// ── Bottom border ─────────────────────────────────────────────────────────
let bottom = build_border('└', '┴', '┘', '─');
lines.push(Line::from(Span::styled(bottom, border_style)));
lines.push(Line::default()); // spacing after table
}
/// Emit a single table data row (header or body) as a `Line`.
fn emit_table_row(
cells: &[String],
col_widths: &[usize],
alignments: &[Alignment],
cell_style: Style,
border_style: Style,
lines: &mut Vec<Line<'static>>,
) {
let n_cols = col_widths.len();
let mut spans: Vec<Span<'static>> = Vec::new();
for col in 0..n_cols {
spans.push(Span::styled("".to_string(), border_style));
let cell_text = cells.get(col).map(|s| s.as_str()).unwrap_or("");
let width = col_widths[col];
let alignment = alignments.get(col).copied().unwrap_or(Alignment::None);
let padded = pad_cell(cell_text, width, alignment);
spans.push(Span::styled(padded, cell_style));
}
spans.push(Span::styled("".to_string(), border_style));
lines.push(Line::from(spans));
}
/// Pad a cell string to `width` according to the column alignment.
///
/// - `Left` / `None`: one leading space, right-pad to width
/// - `Right`: left-pad to width, one trailing space
/// - `Center`: center the text within width
fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// width includes the surrounding single-space padding on each side
let content_width = width.saturating_sub(2);
let text_len = text.len();
match alignment {
Alignment::Right => {
// Right-align: spaces on left, 1 trailing space
let pad = content_width.saturating_sub(text_len);
format!("{}{} ", " ".repeat(pad + 1), text)
}
Alignment::Center => {
let pad = content_width.saturating_sub(text_len);
let left_pad = pad / 2 + 1;
let right_pad = pad - (pad / 2) + 1;
format!(
"{}{}{}",
" ".repeat(left_pad),
text,
" ".repeat(right_pad)
)
}
// Alignment::Left and Alignment::None
_ => {
let pad = content_width.saturating_sub(text_len);
format!(" {}{}", text, " ".repeat(pad + 1))
}
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Convert a markdown string into a list of styled ratatui lines.
///
/// The `width` parameter controls the width used for:
/// - Horizontal rule `─` characters (full-width)
/// - Code block border sizing
///
/// All output spans are `'static` (owned strings) — no lifetime dependency on
/// the input `&str`. The result can be stored in `App` state and rendered
/// indefinitely via `Paragraph::new(lines).scroll((offset, 0))`.
///
/// # Panics
///
/// Panics if `crate::highlighter::init_highlighter()` has not been called before
/// the first code block is encountered.
pub fn render_markdown(input: &str, width: u16) -> Vec<Line<'static>> {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_GFM);
let parser = TextMergeStream::new(Parser::new_ext(input, opts));
let mut state = RenderState::new(width);
for event in parser {
handle_event(&mut state, event);
}
state.finish()
}