//! Low-level markdown event renderer for the TUI transcript. //! //! This module consumes `pulldown-cmark` events and emits styled `ratatui` //! lines, including table layout, width-aware wrapping, and local file-link //! display. It is the final rendering stage used by higher-level helpers in //! `markdown.rs`. //! //! This renderer intentionally treats local file links differently from normal web links. For //! local paths, the displayed text comes from the destination, not the markdown label, so //! transcripts show the real file target (including normalized location suffixes) and can shorten //! absolute paths relative to a known working directory. //! //! ## Table rendering pipeline //! //! When the parser emits `Tag::Table` .. `TagEnd::Table`, the writer //! accumulates header and body rows into a `TableState`, then hands it to //! `render_table_lines` which runs this pipeline: //! //! 1. **Filter spillover rows** -- heuristic extraction of rows that are //! artifacts of pulldown-cmark's lenient parsing. //! 2. **Normalize column counts** -- pad or truncate so every row matches the //! alignment count. //! 3. **Compute column widths** -- allocate widths with Narrative/Structured //! priority and iterative shrinking. //! 4. **Render box grid** -- Unicode borders (`┌───┬───┐`) or fallback to pipe //! format when the minimum cannot fit. //! 5. **Append spillover** -- extracted spillover rows rendered as plain text //! after the table. //! //! ## Width allocation //! //! Columns are classified as Narrative (long prose, >= 4 avg words or >= 28 //! avg char width) or Structured (short tokens). The shrink loop removes //! one character at a time, preferring Narrative columns, until the total //! fits the available width. A guard cost penalises shrinking below a //! column's header token width. When even 3-char-wide columns cannot fit, //! the table falls back to pipe-delimited format. use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::render::line_utils::push_owned_lines; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; use codex_utils_string::normalize_markdown_hash_location_suffix; use dirs::home_dir; use pulldown_cmark::Alignment; use pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; use pulldown_cmark::HeadingLevel; use pulldown_cmark::Options; use pulldown_cmark::Parser; use pulldown_cmark::Tag; use pulldown_cmark::TagEnd; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; use regex_lite::Regex; use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; use unicode_width::UnicodeWidthStr; use url::Url; struct MarkdownStyles { h1: Style, h2: Style, h3: Style, h4: Style, h5: Style, h6: Style, code: Style, emphasis: Style, strong: Style, strikethrough: Style, ordered_list_marker: Style, unordered_list_marker: Style, link: Style, blockquote: Style, } impl Default for MarkdownStyles { fn default() -> Self { Self { h1: Style::new().bold().underlined(), h2: Style::new().bold(), h3: Style::new().bold().italic(), h4: Style::new().italic(), h5: Style::new().italic(), h6: Style::new().italic(), code: Style::new().cyan(), emphasis: Style::new().italic(), strong: Style::new().bold(), strikethrough: Style::new().crossed_out(), ordered_list_marker: Style::new().light_blue(), unordered_list_marker: Style::new(), link: Style::new().cyan().underlined(), blockquote: Style::new().green(), } } } #[derive(Clone, Debug)] struct IndentContext { prefix: Vec>, marker: Option>>, is_list: bool, } impl IndentContext { fn new(prefix: Vec>, marker: Option>>, is_list: bool) -> Self { Self { prefix, marker, is_list, } } } /// Styled content of a single cell in the table being parsed. /// /// A cell can contain multiple lines (hard breaks inside the cell) and rich inline spans (bold, /// code, links). The `plain_text()` projection is used for column-width measurement; the styled /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { lines: Vec>, } // TableCell mutators inlined — called per-span during table event parsing. impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { self.lines.push(Line::default()); } } #[inline] fn push_span(&mut self, span: Span<'static>) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { line.push_span(span); } } #[inline] fn hard_break(&mut self) { self.lines.push(Line::default()); } fn plain_text(&self) -> String { use std::fmt::Write; let mut buf = String::new(); for (i, line) in self.lines.iter().enumerate() { if i > 0 { buf.push(' '); } for span in &line.spans { let _ = write!(buf, "{}", span.content); } } buf } } /// Accumulates pulldown-cmark table events into a structured representation. /// /// `TableState` is created on `Tag::Table` and consumed on `TagEnd::Table`. Between those events, /// the Writer delegates cell content (text, code, html, breaks) into the `current_cell`, which is /// flushed into `current_row` on `TagEnd::TableCell`, then into `header`/`rows` on row/head end /// events. #[derive(Debug)] struct TableBodyRow { cells: Vec, has_table_pipe_syntax: bool, } #[derive(Debug)] struct TableState { alignments: Vec, header: Option>, rows: Vec, current_row: Option>, current_row_has_table_pipe_syntax: bool, current_cell: Option, in_header: bool, } impl TableState { fn new(alignments: Vec) -> Self { Self { alignments, header: None, rows: Vec::new(), current_row: None, current_row_has_table_pipe_syntax: false, current_cell: None, in_header: false, } } } /// Rendered table output split by wrapping behavior. /// /// `table_lines` are either prewrapped grid rows (box rendering) or pipe /// fallback rows that should still pass through normal wrapping. /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { table_lines: Vec>, table_lines_prewrapped: bool, spillover_lines: Vec>, } /// Classification of a table column for width-allocation priority. /// /// Narrative columns (long prose, many words per cell) are shrunk first when the table exceeds /// available width. Structured columns (short tokens like dates, status words, numbers) are /// preserved as long as possible to keep their content on a single line. /// /// The heuristic is simple: >= 4 average words per cell OR >= 28 average character width → /// Narrative. Everything else → Structured. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TableColumnKind { /// Long-form prose content (>= 4 avg words/cell or >= 28 avg char width). Narrative, /// Short, token-like content that should resist wrapping. Structured, } /// Per-column statistics used to drive the width-allocation algorithm. /// /// Collected in a single pass over the header and body rows before any /// shrinking decisions are made. #[derive(Clone, Debug)] struct TableColumnMetrics { /// Widest cell content (display width) across header and all body rows. max_width: usize, /// Display width of the longest whitespace-delimited token in the header. header_token_width: usize, /// Display width of the longest whitespace-delimited token across body rows. body_token_width: usize, /// Average number of whitespace-delimited words per non-empty body cell. avg_words_per_cell: f64, /// Average display width of non-empty body cells. avg_cell_width: f64, /// Classification derived from `avg_words_per_cell` and `avg_cell_width`. kind: TableColumnKind, } /// Render markdown with default wrapping behavior. /// /// Use this when the caller does not have a concrete render width yet (for /// example, snapshot tests or contexts that intentionally defer wrapping). If /// a viewport width is known, prefer [`render_markdown_text_with_width`] so /// table fallback and line wrapping decisions match the visible terminal. pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, /*width*/ None) } /// Render markdown constrained to a known terminal width. /// /// The renderer preserves table structure when possible and falls back to /// pipe-table output when a box table cannot fit the available width. Passing /// `None` keeps intrinsic line widths and disables width-driven wrapping in the /// markdown writer. Local file links render relative to the current process /// working directory. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let cwd = std::env::current_dir().ok(); render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref()) } /// Render markdown with an explicit working directory for local file links. /// /// The `cwd` parameter controls how absolute local targets are shortened before display. Passing /// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even /// when rendering happens away from the process cwd. pub(crate) fn render_markdown_text_with_width_and_cwd( input: &str, width: Option, cwd: Option<&Path>, ) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); let parser = Parser::new_ext(input, options).into_offset_iter(); let mut w = Writer::new(input, parser, width, cwd); w.run(); w.text } #[derive(Clone, Debug)] struct LinkState { destination: String, show_destination: bool, /// Pre-rendered display text for local file links. /// /// When this is present, the markdown label is intentionally suppressed so the rendered /// transcript always reflects the real target path. local_target_display: Option, } fn should_render_link_destination(dest_url: &str) -> bool { !is_local_path_like_link(dest_url) } static COLON_LOCATION_SUFFIX_RE: LazyLock = LazyLock::new( || match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") { Ok(regex) => regex, Err(error) => panic!("invalid location suffix regex: {error}"), }, ); // Covered by load_location_suffix_regexes. static HASH_LOCATION_SUFFIX_RE: LazyLock = LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") { Ok(regex) => regex, Err(error) => panic!("invalid hash location regex: {error}"), }); /// Stateful pulldown-cmark event consumer that builds styled `ratatui` output. /// /// Tracks inline style nesting, indent/blockquote context, list numbering, /// and an optional `TableState` for accumulating table events. The /// `wrap_width` field enables width-aware line wrapping and table column /// allocation; when `None`, lines keep their intrinsic width. struct Writer<'a, I> where I: Iterator, Range)>, { input: &'a str, iter: I, text: Text<'static>, styles: MarkdownStyles, inline_styles: Vec