mirror of
https://github.com/openai/codex.git
synced 2026-05-18 02:02:30 +00:00
## Why The TUI currently treats Markdown tables as ordinary wrapped text, which makes table-heavy responses hard to read and brittle across narrow panes and terminal resizes. This change teaches the TUI to render Markdown tables responsively while preserving the raw Markdown source needed to re-render streamed and finalized transcript content after width changes. The goal is to keep tables legible during streaming, after resize, and once a turn has finished, without corrupting scrollback ordering. ## What Changed - add table detection and responsive table rendering in the Markdown renderer - render standard tables with Unicode box-drawing borders when the pane is wide enough - add a vertical readability fallback for constrained or dense tables so narrow panes still show each row clearly - keep links and `<br>` content inside table cells instead of leaking text outside the table - avoid table normalization inside fenced or indented code blocks - preserve raw streamed Markdown source and keep the active table as a mutable tail until finalization - consolidate finalized streamed content into source-backed transcript cells so post-resize re-rendering stays correct - add snapshot and targeted streaming/resize regression coverage for the new table behavior ## How to Test 1. Start Codex TUI from this branch. 2. Paste this exact prompt: `This is a session to test codex, no need to do any thinking, just end different markdown tables, with columns exploring different markdown contents, like links, bold italic, code, etc. Make them different sizes, some 30+ rows, some not and intertwine them with some paragraphs with complex formatting as well.` 3. Confirm the response includes several Markdown tables mixed with richly formatted paragraphs. 4. Confirm wide-enough tables render with box-drawing borders instead of plain wrapped pipe text. 5. Resize the terminal narrower while the answer is still streaming and confirm the in-progress table stays coherent instead of duplicating headers or leaving broken scrollback behind. 6. Resize again after the turn finishes and confirm the finalized transcript re-renders cleanly at the new width. 7. In a narrow pane, verify dense tables fall back to the vertical per-row layout instead of producing unreadable wrapped columns. 8. Also verify pipe-heavy fenced code blocks still render as code, not as tables. Targeted tests: - `cargo test -p codex-tui table_readability_fallback --no-fail-fast` - `cargo test -p codex-tui markdown_render --no-fail-fast` - `cargo test -p codex-tui streaming::controller --no-fail-fast` - `cargo test -p codex-tui table_resize_lifecycle --no-fail-fast` ## Docs No developer docs update appears necessary.
2407 lines
86 KiB
Rust
2407 lines
86 KiB
Rust
//! 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<Span<'static>>,
|
||
marker: Option<Vec<Span<'static>>>,
|
||
is_list: bool,
|
||
}
|
||
|
||
impl IndentContext {
|
||
fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, 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<Line<'static>>,
|
||
}
|
||
|
||
// 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<TableCell>,
|
||
has_table_pipe_syntax: bool,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct TableState {
|
||
alignments: Vec<Alignment>,
|
||
header: Option<Vec<TableCell>>,
|
||
rows: Vec<TableBodyRow>,
|
||
current_row: Option<Vec<TableCell>>,
|
||
current_row_has_table_pipe_syntax: bool,
|
||
current_cell: Option<TableCell>,
|
||
in_header: bool,
|
||
}
|
||
|
||
impl TableState {
|
||
fn new(alignments: Vec<Alignment>) -> 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<Line<'static>>,
|
||
table_lines_prewrapped: bool,
|
||
spillover_lines: Vec<Line<'static>>,
|
||
}
|
||
|
||
/// 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<usize>) -> 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<usize>,
|
||
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<String>,
|
||
}
|
||
|
||
fn should_render_link_destination(dest_url: &str) -> bool {
|
||
!is_local_path_like_link(dest_url)
|
||
}
|
||
|
||
static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
|
||
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<Regex> =
|
||
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<Item = (Event<'a>, Range<usize>)>,
|
||
{
|
||
input: &'a str,
|
||
iter: I,
|
||
text: Text<'static>,
|
||
styles: MarkdownStyles,
|
||
inline_styles: Vec<Style>,
|
||
indent_stack: Vec<IndentContext>,
|
||
list_indices: Vec<Option<u64>>,
|
||
list_needs_blank_before_next_item: Vec<bool>,
|
||
list_item_contains_code_block: Vec<bool>,
|
||
link: Option<LinkState>,
|
||
needs_newline: bool,
|
||
pending_marker_line: bool,
|
||
in_paragraph: bool,
|
||
in_code_block: bool,
|
||
code_block_lang: Option<String>,
|
||
code_block_buffer: String,
|
||
wrap_width: Option<usize>,
|
||
cwd: Option<PathBuf>,
|
||
line_ends_with_local_link_target: bool,
|
||
pending_local_link_soft_break: bool,
|
||
current_line_content: Option<Line<'static>>,
|
||
current_initial_indent: Vec<Span<'static>>,
|
||
current_subsequent_indent: Vec<Span<'static>>,
|
||
current_line_style: Style,
|
||
current_line_in_code_block: bool,
|
||
table_state: Option<TableState>,
|
||
}
|
||
|
||
impl<'a, I> Writer<'a, I>
|
||
where
|
||
I: Iterator<Item = (Event<'a>, Range<usize>)>,
|
||
{
|
||
fn new(input: &'a str, iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
|
||
Self {
|
||
input,
|
||
iter,
|
||
text: Text::default(),
|
||
styles: MarkdownStyles::default(),
|
||
inline_styles: Vec::new(),
|
||
indent_stack: Vec::new(),
|
||
list_indices: Vec::new(),
|
||
list_needs_blank_before_next_item: Vec::new(),
|
||
list_item_contains_code_block: Vec::new(),
|
||
link: None,
|
||
needs_newline: false,
|
||
pending_marker_line: false,
|
||
in_paragraph: false,
|
||
in_code_block: false,
|
||
code_block_lang: None,
|
||
code_block_buffer: String::new(),
|
||
wrap_width,
|
||
cwd: cwd.map(Path::to_path_buf),
|
||
line_ends_with_local_link_target: false,
|
||
pending_local_link_soft_break: false,
|
||
current_line_content: None,
|
||
current_initial_indent: Vec::new(),
|
||
current_subsequent_indent: Vec::new(),
|
||
current_line_style: Style::default(),
|
||
current_line_in_code_block: false,
|
||
table_state: None,
|
||
}
|
||
}
|
||
|
||
fn run(&mut self) {
|
||
while let Some((ev, range)) = self.iter.next() {
|
||
self.handle_event(ev, range);
|
||
}
|
||
self.flush_current_line();
|
||
}
|
||
|
||
fn handle_event(&mut self, event: Event<'a>, range: Range<usize>) {
|
||
self.prepare_for_event(&event);
|
||
match event {
|
||
Event::Start(tag) => self.start_tag(tag, range),
|
||
Event::End(tag) => self.end_tag(tag),
|
||
Event::Text(text) => self.text(text),
|
||
Event::Code(code) => self.code(code),
|
||
Event::SoftBreak => self.soft_break(),
|
||
Event::HardBreak => self.hard_break(),
|
||
Event::Rule => {
|
||
self.flush_current_line();
|
||
if !self.text.lines.is_empty() {
|
||
self.push_blank_line();
|
||
}
|
||
self.push_line(Line::from("———"));
|
||
self.needs_newline = true;
|
||
}
|
||
Event::Html(html) => self.html(html, /*inline*/ false),
|
||
Event::InlineHtml(html) => self.html(html, /*inline*/ true),
|
||
Event::FootnoteReference(_) => {}
|
||
Event::TaskListMarker(_) => {}
|
||
}
|
||
}
|
||
|
||
fn prepare_for_event(&mut self, event: &Event<'a>) {
|
||
if !self.pending_local_link_soft_break {
|
||
return;
|
||
}
|
||
|
||
// Local file links render from the destination at `TagEnd::Link`, so a Markdown soft break
|
||
// immediately before a descriptive `: ...` should stay inline instead of splitting the
|
||
// list item across two lines.
|
||
if matches!(event, Event::Text(text) if text.trim_start().starts_with(':')) {
|
||
self.pending_local_link_soft_break = false;
|
||
return;
|
||
}
|
||
|
||
self.pending_local_link_soft_break = false;
|
||
self.push_line(Line::default());
|
||
}
|
||
|
||
fn start_tag(&mut self, tag: Tag<'a>, range: Range<usize>) {
|
||
match tag {
|
||
Tag::Paragraph => self.start_paragraph(),
|
||
Tag::Heading { level, .. } => self.start_heading(level),
|
||
Tag::BlockQuote => self.start_blockquote(),
|
||
Tag::CodeBlock(kind) => {
|
||
let indent = match kind {
|
||
CodeBlockKind::Fenced(_) => None,
|
||
CodeBlockKind::Indented => Some(Span::from(" ".repeat(4))),
|
||
};
|
||
let lang = match kind {
|
||
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
|
||
CodeBlockKind::Indented => None,
|
||
};
|
||
self.start_codeblock(lang, indent)
|
||
}
|
||
Tag::List(start) => self.start_list(start),
|
||
Tag::Item => self.start_item(),
|
||
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
|
||
Tag::Strong => self.push_inline_style(self.styles.strong),
|
||
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
|
||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||
Tag::Table(alignments) => self.start_table(alignments),
|
||
Tag::TableHead => self.start_table_head(),
|
||
Tag::TableRow => self.start_table_row(range),
|
||
Tag::TableCell => self.start_table_cell(),
|
||
Tag::HtmlBlock
|
||
| Tag::FootnoteDefinition(_)
|
||
| Tag::Image { .. }
|
||
| Tag::MetadataBlock(_) => {}
|
||
}
|
||
}
|
||
|
||
fn end_tag(&mut self, tag: TagEnd) {
|
||
match tag {
|
||
TagEnd::Paragraph => self.end_paragraph(),
|
||
TagEnd::Heading(_) => self.end_heading(),
|
||
TagEnd::BlockQuote => self.end_blockquote(),
|
||
TagEnd::CodeBlock => self.end_codeblock(),
|
||
TagEnd::List(_) => self.end_list(),
|
||
TagEnd::Item => {
|
||
if self.list_item_contains_code_block.pop().unwrap_or(false)
|
||
&& let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut()
|
||
{
|
||
*needs_blank = true;
|
||
}
|
||
self.indent_stack.pop();
|
||
self.pending_marker_line = false;
|
||
}
|
||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
|
||
TagEnd::Link => self.pop_link(),
|
||
TagEnd::Table => self.end_table(),
|
||
TagEnd::TableHead => self.end_table_head(),
|
||
TagEnd::TableRow => self.end_table_row(),
|
||
TagEnd::TableCell => self.end_table_cell(),
|
||
TagEnd::HtmlBlock
|
||
| TagEnd::FootnoteDefinition
|
||
| TagEnd::Image
|
||
| TagEnd::MetadataBlock(_) => {}
|
||
}
|
||
}
|
||
|
||
fn start_paragraph(&mut self) {
|
||
if self.in_table_cell() {
|
||
return;
|
||
}
|
||
if self.needs_newline {
|
||
self.push_blank_line();
|
||
}
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
self.in_paragraph = true;
|
||
}
|
||
|
||
fn end_paragraph(&mut self) {
|
||
if self.in_table_cell() {
|
||
return;
|
||
}
|
||
self.needs_newline = true;
|
||
self.in_paragraph = false;
|
||
self.pending_marker_line = false;
|
||
}
|
||
|
||
fn start_heading(&mut self, level: HeadingLevel) {
|
||
if self.in_table_cell() {
|
||
return;
|
||
}
|
||
if self.needs_newline {
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
}
|
||
let heading_style = match level {
|
||
HeadingLevel::H1 => self.styles.h1,
|
||
HeadingLevel::H2 => self.styles.h2,
|
||
HeadingLevel::H3 => self.styles.h3,
|
||
HeadingLevel::H4 => self.styles.h4,
|
||
HeadingLevel::H5 => self.styles.h5,
|
||
HeadingLevel::H6 => self.styles.h6,
|
||
};
|
||
let content = format!("{} ", "#".repeat(level as usize));
|
||
self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
|
||
self.push_inline_style(heading_style);
|
||
self.needs_newline = false;
|
||
}
|
||
|
||
fn end_heading(&mut self) {
|
||
if self.in_table_cell() {
|
||
return;
|
||
}
|
||
self.needs_newline = true;
|
||
self.pop_inline_style();
|
||
}
|
||
|
||
fn start_blockquote(&mut self) {
|
||
if self.in_table_cell() {
|
||
return;
|
||
}
|
||
if self.needs_newline {
|
||
self.push_blank_line();
|
||
self.needs_newline = false;
|
||
}
|
||
self.indent_stack.push(IndentContext::new(
|
||
vec![Span::from("> ")],
|
||
/*marker*/ None,
|
||
/*is_list*/ false,
|
||
));
|
||
}
|
||
|
||
fn end_blockquote(&mut self) {
|
||
if self.in_table_cell() {
|
||
return;
|
||
}
|
||
self.indent_stack.pop();
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn text(&mut self, text: CowStr<'a>) {
|
||
if self.suppressing_local_link_label() {
|
||
return;
|
||
}
|
||
self.line_ends_with_local_link_target = false;
|
||
if self.in_table_cell() {
|
||
self.push_text_to_table_cell(&text);
|
||
return;
|
||
}
|
||
|
||
if self.pending_marker_line {
|
||
self.push_line(Line::default());
|
||
}
|
||
self.pending_marker_line = false;
|
||
|
||
// When inside a fenced code block with a known language, accumulate
|
||
// text into the buffer for batch highlighting in end_codeblock().
|
||
// Append verbatim — pulldown-cmark text events already contain the
|
||
// original line breaks, so inserting separators would double them.
|
||
if self.in_code_block && self.code_block_lang.is_some() {
|
||
self.code_block_buffer.push_str(&text);
|
||
return;
|
||
}
|
||
|
||
if self.in_code_block && !self.needs_newline {
|
||
let has_content = self
|
||
.current_line_content
|
||
.as_ref()
|
||
.map(|line| !line.spans.is_empty())
|
||
.unwrap_or_else(|| {
|
||
self.text
|
||
.lines
|
||
.last()
|
||
.map(|line| !line.spans.is_empty())
|
||
.unwrap_or(false)
|
||
});
|
||
if has_content {
|
||
self.push_line(Line::default());
|
||
}
|
||
}
|
||
for (i, line) in text.lines().enumerate() {
|
||
if self.needs_newline {
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
}
|
||
if i > 0 {
|
||
self.push_line(Line::default());
|
||
}
|
||
let content = line.to_string();
|
||
let span = Span::styled(
|
||
content,
|
||
self.inline_styles.last().copied().unwrap_or_default(),
|
||
);
|
||
self.push_span(span);
|
||
}
|
||
self.needs_newline = false;
|
||
}
|
||
|
||
fn code(&mut self, code: CowStr<'a>) {
|
||
if self.suppressing_local_link_label() {
|
||
return;
|
||
}
|
||
self.line_ends_with_local_link_target = false;
|
||
if self.in_table_cell() {
|
||
self.push_span_to_table_cell(Span::from(code.into_string()).style(self.styles.code));
|
||
return;
|
||
}
|
||
|
||
if self.pending_marker_line {
|
||
self.push_line(Line::default());
|
||
self.pending_marker_line = false;
|
||
}
|
||
let span = Span::from(code.into_string()).style(self.styles.code);
|
||
self.push_span(span);
|
||
}
|
||
|
||
fn html(&mut self, html: CowStr<'a>, inline: bool) {
|
||
if self.suppressing_local_link_label() {
|
||
return;
|
||
}
|
||
self.line_ends_with_local_link_target = false;
|
||
if self.in_table_cell() {
|
||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||
for (i, line) in html.lines().enumerate() {
|
||
if i > 0 {
|
||
self.push_table_cell_hard_break();
|
||
}
|
||
self.push_span_to_table_cell(Span::styled(line.to_string(), style));
|
||
}
|
||
if !inline {
|
||
self.push_table_cell_hard_break();
|
||
}
|
||
return;
|
||
}
|
||
self.pending_marker_line = false;
|
||
for (i, line) in html.lines().enumerate() {
|
||
if self.needs_newline {
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
}
|
||
if i > 0 {
|
||
self.push_line(Line::default());
|
||
}
|
||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||
self.push_span(Span::styled(line.to_string(), style));
|
||
}
|
||
self.needs_newline = !inline;
|
||
}
|
||
|
||
fn hard_break(&mut self) {
|
||
if self.suppressing_local_link_label() {
|
||
return;
|
||
}
|
||
self.line_ends_with_local_link_target = false;
|
||
if self.in_table_cell() {
|
||
self.push_table_cell_hard_break();
|
||
return;
|
||
}
|
||
self.push_line(Line::default());
|
||
}
|
||
|
||
fn soft_break(&mut self) {
|
||
if self.suppressing_local_link_label() {
|
||
return;
|
||
}
|
||
if self.in_table_cell() {
|
||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||
self.push_span_to_table_cell(Span::styled(" ".to_string(), style));
|
||
return;
|
||
}
|
||
if self.line_ends_with_local_link_target {
|
||
self.pending_local_link_soft_break = true;
|
||
self.line_ends_with_local_link_target = false;
|
||
return;
|
||
}
|
||
self.line_ends_with_local_link_target = false;
|
||
self.push_line(Line::default());
|
||
}
|
||
|
||
fn start_list(&mut self, index: Option<u64>) {
|
||
if self.list_indices.is_empty() && self.needs_newline {
|
||
self.push_line(Line::default());
|
||
}
|
||
self.list_indices.push(index);
|
||
self.list_needs_blank_before_next_item.push(false);
|
||
}
|
||
|
||
fn end_list(&mut self) {
|
||
self.list_indices.pop();
|
||
self.list_needs_blank_before_next_item.pop();
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn start_item(&mut self) {
|
||
if self
|
||
.list_needs_blank_before_next_item
|
||
.last_mut()
|
||
.map(std::mem::take)
|
||
.unwrap_or(false)
|
||
{
|
||
self.push_blank_line();
|
||
}
|
||
self.pending_marker_line = true;
|
||
self.list_item_contains_code_block.push(false);
|
||
let depth = self.list_indices.len();
|
||
let is_ordered = self
|
||
.list_indices
|
||
.last()
|
||
.map(Option::is_some)
|
||
.unwrap_or(false);
|
||
let width = depth * 4 - 3;
|
||
let marker = if let Some(last_index) = self.list_indices.last_mut() {
|
||
match last_index {
|
||
None => Some(vec![Span::styled(
|
||
" ".repeat(width - 1) + "- ",
|
||
self.styles.unordered_list_marker,
|
||
)]),
|
||
Some(index) => {
|
||
*index += 1;
|
||
Some(vec![Span::styled(
|
||
format!("{:width$}. ", *index - 1),
|
||
self.styles.ordered_list_marker,
|
||
)])
|
||
}
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
let indent_prefix = if depth == 0 {
|
||
Vec::new()
|
||
} else {
|
||
let indent_len = if is_ordered { width + 2 } else { width + 1 };
|
||
vec![Span::from(" ".repeat(indent_len))]
|
||
};
|
||
self.indent_stack.push(IndentContext::new(
|
||
indent_prefix,
|
||
marker,
|
||
/*is_list*/ true,
|
||
));
|
||
self.needs_newline = false;
|
||
}
|
||
|
||
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
|
||
for item_contains_code_block in &mut self.list_item_contains_code_block {
|
||
*item_contains_code_block = true;
|
||
}
|
||
self.flush_current_line();
|
||
if !self.text.lines.is_empty() {
|
||
self.push_blank_line();
|
||
}
|
||
self.in_code_block = true;
|
||
|
||
// Extract the language token from the info string. CommonMark info
|
||
// strings can contain metadata after the language, separated by commas,
|
||
// spaces, or other delimiters (e.g. "rust,no_run", "rust title=demo").
|
||
// Take only the first token so the syntax lookup succeeds.
|
||
let lang = lang
|
||
.as_deref()
|
||
.and_then(|s| s.split([',', ' ', '\t']).next())
|
||
.filter(|s| !s.is_empty())
|
||
.map(std::string::ToString::to_string);
|
||
self.code_block_lang = lang;
|
||
self.code_block_buffer.clear();
|
||
|
||
self.indent_stack.push(IndentContext::new(
|
||
vec![indent.unwrap_or_default()],
|
||
/*marker*/ None,
|
||
/*is_list*/ false,
|
||
));
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn end_codeblock(&mut self) {
|
||
// If we buffered code for a known language, syntax-highlight it now.
|
||
if let Some(lang) = self.code_block_lang.take() {
|
||
let code = std::mem::take(&mut self.code_block_buffer);
|
||
if !code.is_empty() {
|
||
let highlighted = highlight_code_to_lines(&code, &lang);
|
||
for hl_line in highlighted {
|
||
self.push_line(Line::default());
|
||
for span in hl_line.spans {
|
||
self.push_span(span);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
self.needs_newline = true;
|
||
self.in_code_block = false;
|
||
self.indent_stack.pop();
|
||
}
|
||
|
||
fn start_table(&mut self, alignments: Vec<Alignment>) {
|
||
self.flush_current_line();
|
||
if self.needs_newline {
|
||
self.push_blank_line();
|
||
self.needs_newline = false;
|
||
}
|
||
self.table_state = Some(TableState::new(alignments));
|
||
}
|
||
|
||
fn end_table(&mut self) {
|
||
let Some(table_state) = self.table_state.take() else {
|
||
return;
|
||
};
|
||
|
||
let RenderedTableLines {
|
||
table_lines,
|
||
table_lines_prewrapped,
|
||
spillover_lines,
|
||
} = self.render_table_lines(table_state);
|
||
let mut pending_marker_line = self.pending_marker_line;
|
||
for line in table_lines {
|
||
if table_lines_prewrapped {
|
||
self.push_prewrapped_line(line, pending_marker_line);
|
||
} else {
|
||
self.push_line(line);
|
||
self.flush_current_line();
|
||
}
|
||
pending_marker_line = false;
|
||
}
|
||
self.pending_marker_line = false;
|
||
for spillover_line in spillover_lines {
|
||
self.push_line(spillover_line);
|
||
self.flush_current_line();
|
||
}
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn start_table_head(&mut self) {
|
||
if let Some(table_state) = self.table_state.as_mut() {
|
||
table_state.in_header = true;
|
||
table_state.current_row = Some(Vec::new());
|
||
}
|
||
}
|
||
|
||
fn end_table_head(&mut self) {
|
||
let Some(table_state) = self.table_state.as_mut() else {
|
||
return;
|
||
};
|
||
if let Some(current_cell) = table_state.current_cell.take() {
|
||
table_state
|
||
.current_row
|
||
.get_or_insert_with(Vec::new)
|
||
.push(current_cell);
|
||
}
|
||
if let Some(row) = table_state.current_row.take() {
|
||
table_state.header = Some(row);
|
||
}
|
||
table_state.in_header = false;
|
||
}
|
||
|
||
fn start_table_row(&mut self, source_range: Range<usize>) {
|
||
let has_table_pipe_syntax = self.has_table_row_boundary_pipe(source_range);
|
||
if let Some(table_state) = self.table_state.as_mut() {
|
||
table_state.current_row = Some(Vec::new());
|
||
table_state.current_row_has_table_pipe_syntax = has_table_pipe_syntax;
|
||
}
|
||
}
|
||
|
||
fn has_table_row_boundary_pipe(&self, source_range: Range<usize>) -> bool {
|
||
let Some(source) = self.input.get(source_range) else {
|
||
return false;
|
||
};
|
||
let source = source.trim();
|
||
source.starts_with('|') || source.ends_with('|')
|
||
}
|
||
|
||
fn end_table_row(&mut self) {
|
||
let Some(table_state) = self.table_state.as_mut() else {
|
||
return;
|
||
};
|
||
|
||
if let Some(current_cell) = table_state.current_cell.take() {
|
||
table_state
|
||
.current_row
|
||
.get_or_insert_with(Vec::new)
|
||
.push(current_cell);
|
||
}
|
||
|
||
let Some(row) = table_state.current_row.take() else {
|
||
return;
|
||
};
|
||
|
||
if table_state.in_header {
|
||
table_state.header = Some(row);
|
||
} else {
|
||
table_state.rows.push(TableBodyRow {
|
||
cells: row,
|
||
has_table_pipe_syntax: table_state.current_row_has_table_pipe_syntax,
|
||
});
|
||
}
|
||
table_state.current_row_has_table_pipe_syntax = false;
|
||
}
|
||
|
||
fn start_table_cell(&mut self) {
|
||
if let Some(table_state) = self.table_state.as_mut() {
|
||
table_state.current_cell = Some(TableCell::default());
|
||
}
|
||
}
|
||
|
||
fn end_table_cell(&mut self) {
|
||
let Some(table_state) = self.table_state.as_mut() else {
|
||
return;
|
||
};
|
||
|
||
if let Some(cell) = table_state.current_cell.take() {
|
||
table_state
|
||
.current_row
|
||
.get_or_insert_with(Vec::new)
|
||
.push(cell);
|
||
}
|
||
}
|
||
|
||
fn in_table_cell(&self) -> bool {
|
||
self.table_state
|
||
.as_ref()
|
||
.and_then(|table_state| table_state.current_cell.as_ref())
|
||
.is_some()
|
||
}
|
||
|
||
fn push_span_to_table_cell(&mut self, span: Span<'static>) {
|
||
if let Some(table_state) = self.table_state.as_mut()
|
||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||
{
|
||
cell.push_span(span);
|
||
}
|
||
}
|
||
|
||
fn push_table_cell_hard_break(&mut self) {
|
||
if let Some(table_state) = self.table_state.as_mut()
|
||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||
{
|
||
cell.hard_break();
|
||
}
|
||
}
|
||
|
||
fn push_text_to_table_cell(&mut self, text: &str) {
|
||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||
for (i, line) in text.lines().enumerate() {
|
||
if i > 0 {
|
||
self.push_table_cell_hard_break();
|
||
}
|
||
self.push_span_to_table_cell(Span::styled(line.to_string(), style));
|
||
}
|
||
}
|
||
|
||
/// Convert a completed `TableState` into styled `Line`s with Unicode
|
||
/// box-drawing borders.
|
||
///
|
||
/// Pipeline: filter spillover rows -> normalize column counts -> compute
|
||
/// column widths -> render box grid (or fall back to pipe format if the
|
||
/// minimum column widths exceed available terminal width). Spillover rows
|
||
/// are appended as plain text after the table grid.
|
||
///
|
||
/// Falls back to `render_table_pipe_fallback` (raw `| A | B |` format)
|
||
/// when `compute_column_widths` returns `None` (terminal too narrow for
|
||
/// even 3-char-wide columns).
|
||
fn render_table_lines(&self, mut table_state: TableState) -> RenderedTableLines {
|
||
let column_count = table_state.alignments.len();
|
||
if column_count == 0 {
|
||
return RenderedTableLines {
|
||
table_lines: Vec::new(),
|
||
table_lines_prewrapped: true,
|
||
spillover_lines: Vec::new(),
|
||
};
|
||
}
|
||
|
||
let mut spillover_rows: Vec<TableCell> = Vec::with_capacity(4);
|
||
let mut rows: Vec<Vec<TableCell>> = Vec::with_capacity(table_state.rows.len());
|
||
for (row_idx, row) in table_state.rows.iter().enumerate() {
|
||
let next_row = table_state.rows.get(row_idx + 1);
|
||
// pulldown-cmark accepts body rows without pipes, which can turn a following paragraph
|
||
// into a one-cell table row. For multi-column tables, treat those as spillover text
|
||
// rendered after the table.
|
||
if column_count > 1 && Self::is_spillover_row(row, next_row) {
|
||
if let Some(cell) = row.cells.first().cloned() {
|
||
spillover_rows.push(cell);
|
||
}
|
||
} else {
|
||
rows.push(row.cells.clone());
|
||
}
|
||
}
|
||
|
||
let mut header = table_state
|
||
.header
|
||
.take()
|
||
.unwrap_or_else(|| vec![TableCell::default(); column_count]);
|
||
Self::normalize_row(&mut header, column_count);
|
||
for row in &mut rows {
|
||
Self::normalize_row(row, column_count);
|
||
}
|
||
|
||
let available_width = self.available_table_width(column_count);
|
||
let widths =
|
||
self.compute_column_widths(&header, &rows, &table_state.alignments, available_width);
|
||
let spillover_lines: Vec<Line<'static>> = spillover_rows
|
||
.into_iter()
|
||
.flat_map(|spillover| spillover.lines)
|
||
.collect();
|
||
|
||
let Some(column_widths) = widths else {
|
||
return RenderedTableLines {
|
||
table_lines: self.render_table_pipe_fallback(
|
||
&header,
|
||
&rows,
|
||
&table_state.alignments,
|
||
),
|
||
table_lines_prewrapped: false,
|
||
spillover_lines,
|
||
};
|
||
};
|
||
|
||
let border_style = Style::new().dim();
|
||
let mut out = Vec::with_capacity(3 + rows.len() * 2);
|
||
out.push(self.render_border_line('┌', '┬', '┐', &column_widths, border_style));
|
||
out.extend(self.render_table_row(
|
||
&header,
|
||
&column_widths,
|
||
&table_state.alignments,
|
||
border_style,
|
||
));
|
||
out.push(self.render_border_line('├', '┼', '┤', &column_widths, border_style));
|
||
for row in &rows {
|
||
out.extend(self.render_table_row(
|
||
row,
|
||
&column_widths,
|
||
&table_state.alignments,
|
||
border_style,
|
||
));
|
||
}
|
||
out.push(self.render_border_line('└', '┴', '┘', &column_widths, border_style));
|
||
RenderedTableLines {
|
||
table_lines: out,
|
||
table_lines_prewrapped: true,
|
||
spillover_lines,
|
||
}
|
||
}
|
||
|
||
fn normalize_row(row: &mut Vec<TableCell>, column_count: usize) {
|
||
row.truncate(column_count);
|
||
row.resize(column_count, TableCell::default());
|
||
}
|
||
|
||
/// subtracts the space eaten by border characters
|
||
fn available_table_width(&self, column_count: usize) -> Option<usize> {
|
||
self.wrap_width.map(|wrap_width| {
|
||
let prefix_width =
|
||
Self::spans_display_width(&self.prefix_spans(self.pending_marker_line));
|
||
let reserved = prefix_width + 1 + (column_count * 3);
|
||
wrap_width.saturating_sub(reserved)
|
||
})
|
||
}
|
||
|
||
/// Allocate column widths for box-drawing table rendering.
|
||
///
|
||
/// Each column starts at its natural (max cell content) width, then columns
|
||
/// are iteratively shrunk one character at a time until the total fits within
|
||
/// `available_width`. Narrative columns (long prose) are shrunk before
|
||
/// Structured columns (short tokens). Returns `None` when even the minimum
|
||
/// width (3 chars per column) cannot fit.
|
||
fn compute_column_widths(
|
||
&self,
|
||
header: &[TableCell],
|
||
rows: &[Vec<TableCell>],
|
||
alignments: &[Alignment],
|
||
available_width: Option<usize>,
|
||
) -> Option<Vec<usize>> {
|
||
let min_column_width = 3usize;
|
||
let metrics = Self::collect_table_column_metrics(header, rows, alignments.len());
|
||
let mut widths: Vec<usize> = metrics
|
||
.iter()
|
||
.map(|col| col.max_width.max(min_column_width))
|
||
.collect();
|
||
|
||
let Some(max_width) = available_width else {
|
||
return Some(widths);
|
||
};
|
||
let minimum_total = alignments.len() * min_column_width;
|
||
if max_width < minimum_total {
|
||
return None;
|
||
}
|
||
|
||
let mut floors: Vec<usize> = metrics
|
||
.iter()
|
||
.map(|col| Self::preferred_column_floor(col, min_column_width))
|
||
.collect();
|
||
let mut floor_total: usize = floors.iter().sum();
|
||
if floor_total > max_width {
|
||
// Relax preferred floors (starting with narrative columns) until we can satisfy the
|
||
// width budget. We still keep hard minimums.
|
||
while floor_total > max_width {
|
||
let Some((idx, _)) = floors
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, floor)| **floor > min_column_width)
|
||
.min_by_key(|(idx, floor)| {
|
||
let kind_priority = match metrics[*idx].kind {
|
||
TableColumnKind::Narrative => 0,
|
||
TableColumnKind::Structured => 1,
|
||
};
|
||
(kind_priority, *floor)
|
||
})
|
||
else {
|
||
break;
|
||
};
|
||
|
||
floors[idx] -= 1;
|
||
floor_total -= 1;
|
||
}
|
||
}
|
||
|
||
let mut total_width: usize = widths.iter().sum();
|
||
|
||
while total_width > max_width {
|
||
let Some(idx) = Self::next_column_to_shrink(&widths, &floors, &metrics) else {
|
||
break;
|
||
};
|
||
widths[idx] -= 1;
|
||
total_width -= 1;
|
||
}
|
||
|
||
if total_width > max_width {
|
||
return None;
|
||
}
|
||
|
||
Some(widths)
|
||
}
|
||
|
||
fn collect_table_column_metrics(
|
||
header: &[TableCell],
|
||
rows: &[Vec<TableCell>],
|
||
column_count: usize,
|
||
) -> Vec<TableColumnMetrics> {
|
||
let mut metrics = Vec::with_capacity(column_count);
|
||
for column in 0..column_count {
|
||
let header_cell = &header[column];
|
||
let header_plain = header_cell.plain_text();
|
||
let header_token_width = Self::longest_token_width(&header_plain);
|
||
let mut max_width = Self::cell_display_width(header_cell);
|
||
let mut body_token_width = 0usize;
|
||
let mut total_words = 0usize;
|
||
let mut total_cells = 0usize;
|
||
let mut total_cell_width = 0usize;
|
||
|
||
for row in rows {
|
||
let cell = &row[column];
|
||
max_width = max_width.max(Self::cell_display_width(cell));
|
||
let plain = cell.plain_text();
|
||
body_token_width = body_token_width.max(Self::longest_token_width(&plain));
|
||
let word_count = plain.split_whitespace().count();
|
||
if word_count > 0 {
|
||
total_words += word_count;
|
||
total_cells += 1;
|
||
total_cell_width += plain.width();
|
||
}
|
||
}
|
||
|
||
let avg_words_per_cell = if total_cells == 0 {
|
||
header_plain.split_whitespace().count() as f64
|
||
} else {
|
||
total_words as f64 / total_cells as f64
|
||
};
|
||
let avg_cell_width = if total_cells == 0 {
|
||
header_plain.width() as f64
|
||
} else {
|
||
total_cell_width as f64 / total_cells as f64
|
||
};
|
||
let kind = if body_token_width >= 20 && avg_words_per_cell <= 2.0 {
|
||
// URL-like/token-heavy columns should resist collapse even if their
|
||
// average cell width is high.
|
||
TableColumnKind::Structured
|
||
} else if avg_words_per_cell >= 4.0 || avg_cell_width >= 28.0 {
|
||
TableColumnKind::Narrative
|
||
} else {
|
||
TableColumnKind::Structured
|
||
};
|
||
|
||
metrics.push(TableColumnMetrics {
|
||
max_width,
|
||
header_token_width,
|
||
body_token_width,
|
||
avg_words_per_cell,
|
||
avg_cell_width,
|
||
kind,
|
||
});
|
||
}
|
||
|
||
metrics
|
||
}
|
||
|
||
/// Compute the preferred minimum width for a column before the shrink loop
|
||
/// starts reducing it further.
|
||
///
|
||
/// Narrative columns floor at the header's longest token (capped at 10).
|
||
/// Structured columns floor at the larger of the header and body token widths
|
||
/// (body capped at 16). The result is clamped to `[min_column_width, max_width]`.
|
||
fn preferred_column_floor(metrics: &TableColumnMetrics, min_column_width: usize) -> usize {
|
||
let token_target = match metrics.kind {
|
||
TableColumnKind::Narrative => metrics.header_token_width.min(10),
|
||
TableColumnKind::Structured => metrics
|
||
.header_token_width
|
||
.max(metrics.body_token_width.min(16)),
|
||
};
|
||
token_target.max(min_column_width).min(metrics.max_width)
|
||
}
|
||
|
||
/// Pick the next column to shrink by one character during width allocation.
|
||
///
|
||
/// Priority: Narrative columns are shrunk before Structured. Within the same
|
||
/// kind, the column with the most slack above its floor is chosen. A guard
|
||
/// cost is added when the width would fall below the header's longest token
|
||
/// (to avoid truncating column headers).
|
||
fn next_column_to_shrink(
|
||
widths: &[usize],
|
||
floors: &[usize],
|
||
metrics: &[TableColumnMetrics],
|
||
) -> Option<usize> {
|
||
widths
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(idx, width)| **width > floors[*idx])
|
||
.min_by_key(|(idx, width)| {
|
||
let slack = width.saturating_sub(floors[*idx]);
|
||
let kind_cost = match metrics[*idx].kind {
|
||
TableColumnKind::Narrative => 0i32,
|
||
TableColumnKind::Structured => 2i32,
|
||
};
|
||
let header_guard = if **width <= metrics[*idx].header_token_width {
|
||
3i32
|
||
} else {
|
||
0i32
|
||
};
|
||
let density_guard = if metrics[*idx].avg_words_per_cell >= 4.0
|
||
|| metrics[*idx].avg_cell_width >= 24.0
|
||
{
|
||
0i32
|
||
} else {
|
||
1i32
|
||
};
|
||
(
|
||
kind_cost + header_guard + density_guard,
|
||
usize::MAX.saturating_sub(slack),
|
||
)
|
||
})
|
||
.map(|(idx, _)| idx)
|
||
}
|
||
|
||
fn render_border_line(
|
||
&self,
|
||
left: char,
|
||
sep: char,
|
||
right: char,
|
||
column_widths: &[usize],
|
||
style: Style,
|
||
) -> Line<'static> {
|
||
let mut spans = Vec::with_capacity(column_widths.len() * 2 + 1);
|
||
spans.push(Span::styled(String::from(left), style));
|
||
for (idx, width) in column_widths.iter().enumerate() {
|
||
spans.push(Span::styled("─".repeat(*width + 2), style));
|
||
if idx + 1 == column_widths.len() {
|
||
spans.push(Span::styled(String::from(right), style));
|
||
} else {
|
||
spans.push(Span::styled(String::from(sep), style));
|
||
}
|
||
}
|
||
Line::from(spans)
|
||
}
|
||
|
||
fn render_table_row(
|
||
&self,
|
||
row: &[TableCell],
|
||
column_widths: &[usize],
|
||
alignments: &[Alignment],
|
||
border_style: Style,
|
||
) -> Vec<Line<'static>> {
|
||
let wrapped_cells: Vec<Vec<Line<'static>>> = row
|
||
.iter()
|
||
.zip(column_widths)
|
||
.map(|(cell, width)| self.wrap_cell(cell, *width))
|
||
.collect();
|
||
let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
|
||
|
||
let mut out = Vec::with_capacity(row_height);
|
||
for row_line in 0..row_height {
|
||
let mut spans = Vec::new();
|
||
spans.push(Span::styled("│", border_style));
|
||
for (column, width) in column_widths.iter().enumerate() {
|
||
spans.push(Span::raw(" "));
|
||
let line = wrapped_cells[column]
|
||
.get(row_line)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let line_width = Self::line_display_width(&line);
|
||
let remaining = width.saturating_sub(line_width);
|
||
let (left_padding, right_padding) = match alignments[column] {
|
||
Alignment::Left | Alignment::None => (0, remaining),
|
||
Alignment::Center => (remaining / 2, remaining - (remaining / 2)),
|
||
Alignment::Right => (remaining, 0),
|
||
};
|
||
if left_padding > 0 {
|
||
spans.push(Span::raw(" ".repeat(left_padding)));
|
||
}
|
||
spans.extend(line.spans);
|
||
if right_padding > 0 {
|
||
spans.push(Span::raw(" ".repeat(right_padding)));
|
||
}
|
||
spans.push(Span::raw(" "));
|
||
spans.push(Span::styled("│", border_style));
|
||
}
|
||
out.push(Line::from(spans));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Render the table as raw pipe-delimited lines (`| A | B |`).
|
||
///
|
||
/// Used when `compute_column_widths` returns `None` (terminal too narrow
|
||
/// for even 3-char-wide columns). Pipe characters inside cell content are
|
||
/// escaped as `\|` so downstream parsers keep cell boundaries intact.
|
||
fn render_table_pipe_fallback(
|
||
&self,
|
||
header: &[TableCell],
|
||
rows: &[Vec<TableCell>],
|
||
alignments: &[Alignment],
|
||
) -> Vec<Line<'static>> {
|
||
let mut out = Vec::new();
|
||
out.push(Line::from(Self::row_to_pipe_string(header)));
|
||
out.push(Line::from(Self::alignments_to_pipe_delimiter(alignments)));
|
||
out.extend(
|
||
rows.iter()
|
||
.map(|row| Line::from(Self::row_to_pipe_string(row))),
|
||
);
|
||
out
|
||
}
|
||
|
||
fn row_to_pipe_string(row: &[TableCell]) -> String {
|
||
let mut out = String::new();
|
||
out.push('|');
|
||
for cell in row {
|
||
out.push(' ');
|
||
// Preserve literal `|` inside cell text in markdown fallback mode so
|
||
// downstream markdown parsers keep the cell content intact.
|
||
out.push_str(&cell.plain_text().replace('|', "\\|"));
|
||
out.push(' ');
|
||
out.push('|');
|
||
}
|
||
out
|
||
}
|
||
|
||
fn alignments_to_pipe_delimiter(alignments: &[Alignment]) -> String {
|
||
let mut out = String::new();
|
||
out.push('|');
|
||
for alignment in alignments {
|
||
let segment = match alignment {
|
||
Alignment::Left => ":---",
|
||
Alignment::Center => ":---:",
|
||
Alignment::Right => "---:",
|
||
Alignment::None => "---",
|
||
};
|
||
out.push_str(segment);
|
||
out.push('|');
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Wrap a single table cell's content to `width`, preserving rich inline
|
||
/// styling (bold, code, links) across wrapped lines.
|
||
///
|
||
/// Each logical line within the cell (separated by hard breaks) is wrapped
|
||
/// independently. Empty cells produce a single blank line so the row grid
|
||
/// stays aligned.
|
||
fn wrap_cell(&self, cell: &TableCell, width: usize) -> Vec<Line<'static>> {
|
||
if cell.lines.is_empty() {
|
||
return vec![Line::default()];
|
||
}
|
||
let mut wrapped = Vec::new();
|
||
for source_line in &cell.lines {
|
||
let rendered = word_wrap_line(source_line, RtOptions::new(width.max(1)));
|
||
if rendered.is_empty() {
|
||
wrapped.push(Line::default());
|
||
} else {
|
||
push_owned_lines(&rendered, &mut wrapped);
|
||
};
|
||
}
|
||
if wrapped.is_empty() {
|
||
wrapped.push(Line::default());
|
||
}
|
||
wrapped
|
||
}
|
||
|
||
/// Detect rows that are artifacts of pulldown-cmark's lenient table parsing.
|
||
///
|
||
/// pulldown-cmark accepts body rows without leading pipes, which can absorb a
|
||
/// trailing paragraph as a single-cell row in a multi-column table. These
|
||
/// "spillover" rows are extracted and rendered as plain text after the table
|
||
/// grid so they don't appear as malformed table content.
|
||
///
|
||
/// Heuristic: a row is spillover if its only non-empty cell is the first one
|
||
/// AND (a single-cell row lacked table pipe syntax, the content looks like
|
||
/// HTML, it's a label line followed by HTML content, or a trailing
|
||
/// HTML-intro label line).
|
||
fn is_spillover_row(row: &TableBodyRow, next_row: Option<&TableBodyRow>) -> bool {
|
||
let Some(first_text) = Self::first_non_empty_only_text(&row.cells) else {
|
||
return false;
|
||
};
|
||
|
||
if row.cells.len() == 1 && !row.has_table_pipe_syntax {
|
||
return true;
|
||
}
|
||
|
||
if Self::looks_like_html_content(&first_text) {
|
||
return true;
|
||
}
|
||
|
||
// Keep common intro + html-block spillover together:
|
||
// "HTML block:" followed by "<div ...>".
|
||
if first_text.trim_end().ends_with(':') {
|
||
if next_row
|
||
.and_then(|row| Self::first_non_empty_only_text(&row.cells))
|
||
.is_some_and(|text| Self::looks_like_html_content(&text))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
// pulldown can end the table before the corresponding HTML block line.
|
||
// In that case, treat trailing HTML-intro labels (e.g., "HTML block:")
|
||
// as spillover while keeping explicit sparse labels in real tables.
|
||
if next_row.is_none() && Self::looks_like_html_label_line(&first_text) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
fn first_non_empty_only_text(row: &[TableCell]) -> Option<String> {
|
||
let first = row.first()?.plain_text();
|
||
if first.trim().is_empty() {
|
||
return None;
|
||
}
|
||
let rest_empty = row[1..]
|
||
.iter()
|
||
.all(|cell| cell.plain_text().trim().is_empty());
|
||
rest_empty.then_some(first)
|
||
}
|
||
|
||
fn looks_like_html_content(text: &str) -> bool {
|
||
let bytes = text.as_bytes();
|
||
for (idx, &byte) in bytes.iter().enumerate() {
|
||
if byte != b'<' {
|
||
continue;
|
||
}
|
||
|
||
let mut tag_start = idx + 1;
|
||
if tag_start < bytes.len() && (bytes[tag_start] == b'/' || bytes[tag_start] == b'!') {
|
||
tag_start += 1;
|
||
}
|
||
|
||
if bytes.get(tag_start).is_some_and(u8::is_ascii_alphabetic)
|
||
&& bytes
|
||
.get(tag_start + 1..)
|
||
.is_some_and(|suffix| suffix.contains(&b'>'))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn looks_like_html_label_line(text: &str) -> bool {
|
||
let trimmed = text.trim();
|
||
if !trimmed.ends_with(':') {
|
||
return false;
|
||
}
|
||
let prefix = trimmed.trim_end_matches(':').trim();
|
||
prefix
|
||
.split_whitespace()
|
||
.any(|word| word.eq_ignore_ascii_case("html"))
|
||
}
|
||
|
||
// Width-measurement helpers inlined — called per-cell during table column
|
||
// width computation, which runs on every re-render.
|
||
|
||
#[inline]
|
||
fn spans_display_width(spans: &[Span<'_>]) -> usize {
|
||
spans.iter().map(|span| span.content.width()).sum()
|
||
}
|
||
|
||
#[inline]
|
||
fn line_display_width(line: &Line<'_>) -> usize {
|
||
Self::spans_display_width(&line.spans)
|
||
}
|
||
|
||
#[inline]
|
||
fn cell_display_width(cell: &TableCell) -> usize {
|
||
cell.lines
|
||
.iter()
|
||
.map(Self::line_display_width)
|
||
.max()
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[inline]
|
||
fn longest_token_width(text: &str) -> usize {
|
||
text.split_whitespace().map(str::width).max().unwrap_or(0)
|
||
}
|
||
|
||
fn push_inline_style(&mut self, style: Style) {
|
||
let current = self.inline_styles.last().copied().unwrap_or_default();
|
||
let merged = current.patch(style);
|
||
self.inline_styles.push(merged);
|
||
}
|
||
|
||
fn pop_inline_style(&mut self) {
|
||
self.inline_styles.pop();
|
||
}
|
||
|
||
fn push_link(&mut self, dest_url: String) {
|
||
let show_destination = should_render_link_destination(&dest_url);
|
||
self.link = Some(LinkState {
|
||
show_destination,
|
||
local_target_display: if is_local_path_like_link(&dest_url) {
|
||
render_local_link_target(&dest_url, self.cwd.as_deref())
|
||
} else {
|
||
None
|
||
},
|
||
destination: dest_url,
|
||
});
|
||
}
|
||
|
||
fn pop_link(&mut self) {
|
||
if let Some(link) = self.link.take() {
|
||
if link.show_destination {
|
||
// Link destinations are rendered as " (url)" suffixes. When parsing table cells,
|
||
// append the suffix into the active cell buffer rather than the outer paragraph
|
||
// line to avoid detached url lines.
|
||
if self.in_table_cell() {
|
||
self.push_span_to_table_cell(" (".into());
|
||
self.push_span_to_table_cell(Span::styled(link.destination, self.styles.link));
|
||
self.push_span_to_table_cell(")".into());
|
||
} else {
|
||
self.push_span(" (".into());
|
||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||
self.push_span(")".into());
|
||
}
|
||
} else if let Some(local_target_display) = link.local_target_display {
|
||
// Local file links are rendered as code-like path text so the transcript shows the
|
||
// resolved target instead of arbitrary caller-provided label text.
|
||
let style = self
|
||
.inline_styles
|
||
.last()
|
||
.copied()
|
||
.unwrap_or_default()
|
||
.patch(self.styles.code);
|
||
let span = Span::styled(local_target_display, style);
|
||
if self.in_table_cell() {
|
||
self.push_span_to_table_cell(span);
|
||
} else {
|
||
if self.pending_marker_line {
|
||
self.push_line(Line::default());
|
||
}
|
||
self.push_span(span);
|
||
self.line_ends_with_local_link_target = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn suppressing_local_link_label(&self) -> bool {
|
||
self.link
|
||
.as_ref()
|
||
.and_then(|link| link.local_target_display.as_ref())
|
||
.is_some()
|
||
}
|
||
|
||
fn flush_current_line(&mut self) {
|
||
if let Some(line) = self.current_line_content.take() {
|
||
let style = self.current_line_style;
|
||
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
|
||
if !self.current_line_in_code_block
|
||
&& let Some(width) = self.wrap_width
|
||
{
|
||
let opts = RtOptions::new(width)
|
||
.initial_indent(self.current_initial_indent.clone().into())
|
||
.subsequent_indent(self.current_subsequent_indent.clone().into());
|
||
for wrapped in adaptive_wrap_line(&line, opts) {
|
||
let owned = line_to_static(&wrapped).style(style);
|
||
self.push_output_line(owned);
|
||
}
|
||
} else {
|
||
let mut spans = self.current_initial_indent.clone();
|
||
let mut line = line;
|
||
spans.append(&mut line.spans);
|
||
self.push_output_line(Line::from_iter(spans).style(style));
|
||
}
|
||
self.current_initial_indent.clear();
|
||
self.current_subsequent_indent.clear();
|
||
self.current_line_in_code_block = false;
|
||
self.line_ends_with_local_link_target = false;
|
||
}
|
||
}
|
||
|
||
/// Push a line that has already been laid out at the correct width, skipping
|
||
/// word wrapping.
|
||
///
|
||
/// Table lines are pre-formatted with exact column widths and box-drawing
|
||
/// borders. Passing them through `word_wrap_line` would break the grid at
|
||
/// arbitrary positions. This method prepends the indent/blockquote prefix
|
||
/// and pushes directly to `self.text.lines`.
|
||
fn is_blockquote_active(&self) -> bool {
|
||
self.indent_stack
|
||
.iter()
|
||
.any(|ctx| ctx.prefix.iter().any(|p| p.content.contains('>')))
|
||
}
|
||
|
||
fn push_prewrapped_line(&mut self, line: Line<'static>, pending_marker_line: bool) {
|
||
self.flush_current_line();
|
||
let blockquote_active = self.is_blockquote_active();
|
||
let style = if blockquote_active {
|
||
self.styles.blockquote.patch(line.style)
|
||
} else {
|
||
line.style
|
||
};
|
||
|
||
let mut spans = self.prefix_spans(pending_marker_line);
|
||
spans.extend(line.spans);
|
||
self.push_output_line(Line::from(spans).style(style));
|
||
}
|
||
|
||
fn push_line(&mut self, line: Line<'static>) {
|
||
self.flush_current_line();
|
||
let blockquote_active = self.is_blockquote_active();
|
||
let style = if blockquote_active {
|
||
self.styles.blockquote
|
||
} else {
|
||
line.style
|
||
};
|
||
let was_pending = self.pending_marker_line;
|
||
|
||
self.current_initial_indent = self.prefix_spans(was_pending);
|
||
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
|
||
self.current_line_style = style;
|
||
self.current_line_content = Some(line);
|
||
self.current_line_in_code_block = self.in_code_block;
|
||
self.line_ends_with_local_link_target = false;
|
||
|
||
self.pending_marker_line = false;
|
||
}
|
||
|
||
fn push_span(&mut self, span: Span<'static>) {
|
||
if let Some(line) = self.current_line_content.as_mut() {
|
||
line.push_span(span);
|
||
} else {
|
||
self.push_line(Line::from(vec![span]));
|
||
}
|
||
}
|
||
|
||
fn push_blank_line(&mut self) {
|
||
self.flush_current_line();
|
||
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
|
||
self.push_output_line(Line::default());
|
||
} else {
|
||
self.push_line(Line::default());
|
||
self.flush_current_line();
|
||
}
|
||
}
|
||
|
||
fn push_output_line(&mut self, line: Line<'static>) {
|
||
self.text.lines.push(line);
|
||
}
|
||
|
||
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
|
||
let mut prefix: Vec<Span<'static>> = Vec::new();
|
||
let last_marker_index = if pending_marker_line {
|
||
self.indent_stack
|
||
.iter()
|
||
.enumerate()
|
||
.rev()
|
||
.find_map(|(i, ctx)| if ctx.marker.is_some() { Some(i) } else { None })
|
||
} else {
|
||
None
|
||
};
|
||
let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list);
|
||
|
||
for (i, ctx) in self.indent_stack.iter().enumerate() {
|
||
if pending_marker_line {
|
||
if Some(i) == last_marker_index
|
||
&& let Some(marker) = &ctx.marker
|
||
{
|
||
prefix.extend(marker.iter().cloned());
|
||
continue;
|
||
}
|
||
if ctx.is_list && last_marker_index.is_some_and(|idx| idx > i) {
|
||
continue;
|
||
}
|
||
} else if ctx.is_list && Some(i) != last_list_index {
|
||
continue;
|
||
}
|
||
prefix.extend(ctx.prefix.iter().cloned());
|
||
}
|
||
|
||
prefix
|
||
}
|
||
}
|
||
|
||
fn is_local_path_like_link(dest_url: &str) -> bool {
|
||
dest_url.starts_with("file://")
|
||
|| dest_url.starts_with('/')
|
||
|| dest_url.starts_with("~/")
|
||
|| dest_url.starts_with("./")
|
||
|| dest_url.starts_with("../")
|
||
|| dest_url.starts_with("\\\\")
|
||
|| matches!(
|
||
dest_url.as_bytes(),
|
||
[drive, b':', separator, ..]
|
||
if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
|
||
)
|
||
}
|
||
|
||
/// Parse a local link target into normalized path text plus an optional location suffix.
|
||
///
|
||
/// This accepts the path shapes Codex emits today: `file://` URLs, absolute and relative paths,
|
||
/// `~/...`, Windows paths, and `#L..C..` or `:line:col` suffixes.
|
||
fn render_local_link_target(dest_url: &str, cwd: Option<&Path>) -> Option<String> {
|
||
let (path_text, location_suffix) = parse_local_link_target(dest_url)?;
|
||
let mut rendered = display_local_link_path(&path_text, cwd);
|
||
if let Some(location_suffix) = location_suffix {
|
||
rendered.push_str(&location_suffix);
|
||
}
|
||
Some(rendered)
|
||
}
|
||
|
||
/// Split a local-link destination into `(normalized_path_text, location_suffix)`.
|
||
///
|
||
/// The returned path text never includes a trailing `#L..` or `:line[:col]` suffix. Path
|
||
/// normalization expands `~/...` when possible and rewrites path separators into display-stable
|
||
/// forward slashes. The suffix, when present, is returned separately in normalized markdown form.
|
||
///
|
||
/// Returns `None` only when the destination looks like a `file://` URL but cannot be parsed into a
|
||
/// local path. Plain path-like inputs always return `Some(...)` even if they are relative.
|
||
fn parse_local_link_target(dest_url: &str) -> Option<(String, Option<String>)> {
|
||
if dest_url.starts_with("file://") {
|
||
let url = Url::parse(dest_url).ok()?;
|
||
let path_text = file_url_to_local_path_text(&url)?;
|
||
let location_suffix = url
|
||
.fragment()
|
||
.and_then(normalize_hash_location_suffix_fragment);
|
||
return Some((path_text, location_suffix));
|
||
}
|
||
|
||
let mut path_text = dest_url;
|
||
let mut location_suffix = None;
|
||
// Prefer `#L..` style fragments when both forms are present so URLs like `path#L10` do not
|
||
// get misparsed as a plain path ending in `:10`.
|
||
if let Some((candidate_path, fragment)) = dest_url.rsplit_once('#')
|
||
&& let Some(normalized) = normalize_hash_location_suffix_fragment(fragment)
|
||
{
|
||
path_text = candidate_path;
|
||
location_suffix = Some(normalized);
|
||
}
|
||
if location_suffix.is_none()
|
||
&& let Some(suffix) = extract_colon_location_suffix(path_text)
|
||
{
|
||
let path_len = path_text.len().saturating_sub(suffix.len());
|
||
path_text = &path_text[..path_len];
|
||
location_suffix = Some(suffix);
|
||
}
|
||
|
||
let decoded_path_text =
|
||
urlencoding::decode(path_text).unwrap_or(std::borrow::Cow::Borrowed(path_text));
|
||
Some((expand_local_link_path(&decoded_path_text), location_suffix))
|
||
}
|
||
|
||
/// Normalize a hash fragment like `L12` or `L12C3-L14C9` into the display suffix we render.
|
||
///
|
||
/// Returns `None` for fragments that are not location references. This deliberately ignores other
|
||
/// `#...` fragments so non-location hashes stay part of the path text.
|
||
fn normalize_hash_location_suffix_fragment(fragment: &str) -> Option<String> {
|
||
HASH_LOCATION_SUFFIX_RE
|
||
.is_match(fragment)
|
||
.then(|| format!("#{fragment}"))
|
||
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
|
||
}
|
||
|
||
/// Extract a trailing `:line`, `:line:col`, or range suffix from a plain path-like string.
|
||
///
|
||
/// The suffix must occur at the end of the input; embedded colons elsewhere in the path are left
|
||
/// alone. This is what keeps Windows drive letters like `C:/...` from being misread as locations.
|
||
fn extract_colon_location_suffix(path_text: &str) -> Option<String> {
|
||
COLON_LOCATION_SUFFIX_RE
|
||
.find(path_text)
|
||
.filter(|matched| matched.end() == path_text.len())
|
||
.map(|matched| matched.as_str().to_string())
|
||
}
|
||
|
||
/// Expand home-relative paths and normalize separators for display.
|
||
///
|
||
/// If `~/...` cannot be expanded because the home directory is unavailable, the original text still
|
||
/// goes through separator normalization and is returned as-is otherwise.
|
||
fn expand_local_link_path(path_text: &str) -> String {
|
||
// Expand `~/...` eagerly so home-relative links can participate in the same normalization and
|
||
// cwd-relative shortening path as absolute links.
|
||
if let Some(rest) = path_text.strip_prefix("~/")
|
||
&& let Some(home) = home_dir()
|
||
{
|
||
return normalize_local_link_path_text(&home.join(rest).to_string_lossy());
|
||
}
|
||
|
||
normalize_local_link_path_text(path_text)
|
||
}
|
||
|
||
/// Convert a `file://` URL into the normalized local-path text used for transcript rendering.
|
||
///
|
||
/// This prefers `Url::to_file_path()` for standard file URLs. When that rejects Windows-oriented
|
||
/// encodings, we reconstruct a display path from the host/path parts so UNC paths and drive-letter
|
||
/// URLs still render sensibly.
|
||
fn file_url_to_local_path_text(url: &Url) -> Option<String> {
|
||
if let Ok(path) = url.to_file_path() {
|
||
return Some(normalize_local_link_path_text(&path.to_string_lossy()));
|
||
}
|
||
|
||
// Fall back to string reconstruction for cases `to_file_path()` rejects, especially UNC-style
|
||
// hosts and Windows drive paths encoded in URL form.
|
||
let mut path_text = url.path().to_string();
|
||
if let Some(host) = url.host_str()
|
||
&& !host.is_empty()
|
||
&& host != "localhost"
|
||
{
|
||
path_text = format!("//{host}{path_text}");
|
||
} else if matches!(
|
||
path_text.as_bytes(),
|
||
[b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic()
|
||
) {
|
||
path_text.remove(0);
|
||
}
|
||
|
||
Some(normalize_local_link_path_text(&path_text))
|
||
}
|
||
|
||
/// Normalize local-path text into the transcript display form.
|
||
///
|
||
/// Display normalization is intentionally lexical: it does not touch the filesystem, resolve
|
||
/// symlinks, or collapse `.` / `..`. It only converts separators to forward slashes and rewrites
|
||
/// UNC-style `\\\\server\\share` inputs into `//server/share` so later prefix checks operate on a
|
||
/// stable representation.
|
||
fn normalize_local_link_path_text(path_text: &str) -> String {
|
||
// Render all local link paths with forward slashes so display and prefix stripping are stable
|
||
// across mixed Windows and Unix-style inputs.
|
||
if let Some(rest) = path_text.strip_prefix("\\\\") {
|
||
format!("//{}", rest.replace('\\', "/").trim_start_matches('/'))
|
||
} else {
|
||
path_text.replace('\\', "/")
|
||
}
|
||
}
|
||
|
||
fn is_absolute_local_link_path(path_text: &str) -> bool {
|
||
path_text.starts_with('/')
|
||
|| path_text.starts_with("//")
|
||
|| matches!(
|
||
path_text.as_bytes(),
|
||
[drive, b':', b'/', ..] if drive.is_ascii_alphabetic()
|
||
)
|
||
}
|
||
|
||
/// Remove trailing separators from a local path without destroying root semantics.
|
||
///
|
||
/// Roots like `/`, `//`, and `C:/` stay intact so callers can still distinguish "the root itself"
|
||
/// from "a path under the root".
|
||
fn trim_trailing_local_path_separator(path_text: &str) -> &str {
|
||
if path_text == "/" || path_text == "//" {
|
||
return path_text;
|
||
}
|
||
if matches!(path_text.as_bytes(), [drive, b':', b'/'] if drive.is_ascii_alphabetic()) {
|
||
return path_text;
|
||
}
|
||
path_text.trim_end_matches('/')
|
||
}
|
||
|
||
/// Strip `cwd_text` from the start of `path_text` when `path_text` is strictly underneath it.
|
||
///
|
||
/// Returns the relative remainder without a leading slash. If the path equals the cwd exactly, this
|
||
/// returns `None` so callers can keep rendering the full path instead of collapsing it to an empty
|
||
/// string.
|
||
fn strip_local_path_prefix<'a>(path_text: &'a str, cwd_text: &str) -> Option<&'a str> {
|
||
let path_text = trim_trailing_local_path_separator(path_text);
|
||
let cwd_text = trim_trailing_local_path_separator(cwd_text);
|
||
if path_text == cwd_text {
|
||
return None;
|
||
}
|
||
|
||
// Treat filesystem roots specially so `/tmp/x` under `/` becomes `tmp/x` instead of being
|
||
// left unchanged by the generic prefix-stripping branch.
|
||
if cwd_text == "/" || cwd_text == "//" {
|
||
return path_text.strip_prefix('/');
|
||
}
|
||
|
||
path_text
|
||
.strip_prefix(cwd_text)
|
||
.and_then(|rest| rest.strip_prefix('/'))
|
||
}
|
||
|
||
/// Choose the visible path text for a local link after normalization.
|
||
///
|
||
/// Relative paths stay relative. Absolute paths are shortened against `cwd` only when they are
|
||
/// lexically underneath it; otherwise the absolute path is preserved. This is display logic only,
|
||
/// not filesystem canonicalization.
|
||
fn display_local_link_path(path_text: &str, cwd: Option<&Path>) -> String {
|
||
let path_text = normalize_local_link_path_text(path_text);
|
||
if !is_absolute_local_link_path(&path_text) {
|
||
return path_text;
|
||
}
|
||
|
||
if let Some(cwd) = cwd {
|
||
// Only shorten absolute paths that are under the provided session cwd; otherwise preserve
|
||
// the original absolute target for clarity.
|
||
let cwd_text = normalize_local_link_path_text(&cwd.to_string_lossy());
|
||
if let Some(stripped) = strip_local_path_prefix(&path_text, &cwd_text) {
|
||
return stripped.to_string();
|
||
}
|
||
}
|
||
|
||
path_text
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod markdown_render_tests {
|
||
include!("markdown_render_tests.rs");
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pretty_assertions::assert_eq;
|
||
use ratatui::text::Text;
|
||
|
||
fn lines_to_strings(text: &Text<'_>) -> Vec<String> {
|
||
text.lines
|
||
.iter()
|
||
.map(|l| {
|
||
l.spans
|
||
.iter()
|
||
.map(|s| s.content.clone())
|
||
.collect::<String>()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_plain_text_when_width_provided() {
|
||
let markdown = "This is a simple sentence that should wrap.";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(16));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"This is a simple".to_string(),
|
||
"sentence that".to_string(),
|
||
"should wrap.".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_list_items_preserving_indent() {
|
||
let markdown = "- first second third fourth";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(14));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec!["- first second".to_string(), " third fourth".to_string(),]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_nested_lists() {
|
||
let markdown =
|
||
"- outer item with several words to wrap\n - inner item that also needs wrapping";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(20));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"- outer item with".to_string(),
|
||
" several words to".to_string(),
|
||
" wrap".to_string(),
|
||
" - inner item".to_string(),
|
||
" that also".to_string(),
|
||
" needs wrapping".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_ordered_lists() {
|
||
let markdown = "1. ordered item contains many words for wrapping";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(18));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"1. ordered item".to_string(),
|
||
" contains many".to_string(),
|
||
" words for".to_string(),
|
||
" wrapping".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_blockquotes() {
|
||
let markdown = "> block quote with content that should wrap nicely";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(22));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"> block quote with".to_string(),
|
||
"> content that should".to_string(),
|
||
"> wrap nicely".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_blockquotes_inside_lists() {
|
||
let markdown = "- list item\n > block quote inside list that wraps";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(24));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"- list item".to_string(),
|
||
" > block quote inside".to_string(),
|
||
" > list that wraps".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_list_items_containing_blockquotes() {
|
||
let markdown = "1. item with quote\n > quoted text that should wrap";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(24));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"1. item with quote".to_string(),
|
||
" > quoted text that".to_string(),
|
||
" > should wrap".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_wrap_code_blocks() {
|
||
let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(10));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_split_long_url_like_token_without_scheme() {
|
||
let url_like =
|
||
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
|
||
let rendered = render_markdown_text_with_width(url_like, Some(24));
|
||
let lines = lines_to_strings(&rendered);
|
||
|
||
assert_eq!(
|
||
lines.iter().filter(|line| line.contains(url_like)).count(),
|
||
1,
|
||
"expected full URL-like token in one rendered line, got: {lines:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fenced_code_info_string_with_metadata_highlights() {
|
||
// CommonMark info strings like "rust,no_run" or "rust title=demo"
|
||
// contain metadata after the language token. The language must be
|
||
// extracted (first word / comma-separated token) so highlighting works.
|
||
for info in &["rust,no_run", "rust no_run", "rust title=\"demo\""] {
|
||
let markdown = format!("```{info}\nfn main() {{}}\n```\n");
|
||
let rendered = render_markdown_text(&markdown);
|
||
let has_rgb = rendered.lines.iter().any(|line| {
|
||
line.spans
|
||
.iter()
|
||
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
|
||
});
|
||
assert!(
|
||
has_rgb,
|
||
"info string \"{info}\" should still produce syntax highlighting"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn crlf_code_block_no_extra_blank_lines() {
|
||
// pulldown-cmark can split CRLF code blocks into multiple Text events.
|
||
// The buffer must concatenate them verbatim — no inserted separators.
|
||
let markdown = "```rust\r\nfn main() {}\r\n line2\r\n```\r\n";
|
||
let rendered = render_markdown_text(markdown);
|
||
let lines = lines_to_strings(&rendered);
|
||
// Should be exactly two code lines; no spurious blank line between them.
|
||
assert_eq!(
|
||
lines,
|
||
vec!["fn main() {}".to_string(), " line2".to_string()],
|
||
"CRLF code block should not produce extra blank lines: {lines:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_cell_preserves_hard_break_lines() {
|
||
let mut cell = TableCell::default();
|
||
cell.push_span("first line".into());
|
||
cell.hard_break();
|
||
cell.push_span("second line".into());
|
||
|
||
let writer = W::new("", std::iter::empty(), Some(80), /*cwd*/ None);
|
||
let wrapped = writer.wrap_cell(&cell, /*width*/ 40);
|
||
let rendered = wrapped
|
||
.iter()
|
||
.map(|line| {
|
||
line.spans
|
||
.iter()
|
||
.map(|span| span.content.clone())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
assert_eq!(
|
||
rendered,
|
||
vec!["first line".to_string(), "second line".to_string()]
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// Type alias for calling private associated functions on Writer.
|
||
// ---------------------------------------------------------------
|
||
type W<'a> = Writer<'a, std::iter::Empty<(Event<'a>, Range<usize>)>>;
|
||
|
||
/// Build a single-line `TableCell` from plain text.
|
||
fn make_cell(text: &str) -> TableCell {
|
||
let mut cell = TableCell::default();
|
||
cell.push_span(Span::raw(text.to_string()));
|
||
cell
|
||
}
|
||
|
||
fn make_body_row(cells: Vec<TableCell>, has_table_pipe_syntax: bool) -> TableBodyRow {
|
||
TableBodyRow {
|
||
cells,
|
||
has_table_pipe_syntax,
|
||
}
|
||
}
|
||
|
||
// ===== Column-metrics unit tests =====
|
||
|
||
#[test]
|
||
fn column_classification_narrative_by_word_count() {
|
||
// Col 0: short tokens (1-2 words each) → Structured
|
||
// Col 1: prose (≥4 words per cell) → Narrative
|
||
let header = vec![make_cell("ID"), make_cell("Description")];
|
||
let rows = vec![
|
||
vec![make_cell("1"), make_cell("a long description of the item")],
|
||
vec![make_cell("2"), make_cell("another verbose body cell here")],
|
||
];
|
||
let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 2);
|
||
assert_eq!(metrics[0].kind, TableColumnKind::Structured);
|
||
assert_eq!(metrics[1].kind, TableColumnKind::Narrative);
|
||
}
|
||
|
||
#[test]
|
||
fn column_classification_structured_by_url_like_token() {
|
||
// Col with short word count but ≥28 avg char width and long tokens → Structured
|
||
// (URL-like/token-heavy columns resist collapse)
|
||
let header = vec![make_cell("URL")];
|
||
let rows = vec![
|
||
vec![make_cell("https://example.com/very/long/path")],
|
||
vec![make_cell("https://another.example.org/deep")],
|
||
];
|
||
let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 1);
|
||
assert!(metrics[0].avg_cell_width >= 28.0);
|
||
assert_eq!(metrics[0].kind, TableColumnKind::Structured);
|
||
}
|
||
|
||
#[test]
|
||
fn column_classification_structured_all_short() {
|
||
// Both columns short tokens → both Structured
|
||
let header = vec![make_cell("Status"), make_cell("Count")];
|
||
let rows = vec![
|
||
vec![make_cell("ok"), make_cell("42")],
|
||
vec![make_cell("err"), make_cell("7")],
|
||
];
|
||
let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 2);
|
||
assert_eq!(metrics[0].kind, TableColumnKind::Structured);
|
||
assert_eq!(metrics[1].kind, TableColumnKind::Structured);
|
||
}
|
||
|
||
#[test]
|
||
fn preferred_floor_narrative_caps_header_at_10() {
|
||
// Narrative col: header_token_width 15 → floors at 10
|
||
let m = TableColumnMetrics {
|
||
max_width: 40,
|
||
header_token_width: 15,
|
||
body_token_width: 8,
|
||
avg_words_per_cell: 5.0,
|
||
avg_cell_width: 30.0,
|
||
kind: TableColumnKind::Narrative,
|
||
};
|
||
assert_eq!(W::preferred_column_floor(&m, /*min_column_width*/ 3), 10);
|
||
|
||
// Narrative col: header_token_width 6 → floors at 6 (below cap)
|
||
let m2 = TableColumnMetrics {
|
||
max_width: 40,
|
||
header_token_width: 6,
|
||
body_token_width: 8,
|
||
avg_words_per_cell: 5.0,
|
||
avg_cell_width: 30.0,
|
||
kind: TableColumnKind::Narrative,
|
||
};
|
||
assert_eq!(W::preferred_column_floor(&m2, /*min_column_width*/ 3), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn preferred_floor_structured_uses_body_token() {
|
||
// Structured: max(header_token_width, body_token_width.min(16))
|
||
let m = TableColumnMetrics {
|
||
max_width: 30,
|
||
header_token_width: 5,
|
||
body_token_width: 12,
|
||
avg_words_per_cell: 1.0,
|
||
avg_cell_width: 10.0,
|
||
kind: TableColumnKind::Structured,
|
||
};
|
||
// max(5, min(12, 16)) = max(5, 12) = 12
|
||
assert_eq!(W::preferred_column_floor(&m, /*min_column_width*/ 3), 12);
|
||
|
||
// Body token exceeds 16 cap → capped at 16, then max with header
|
||
let m2 = TableColumnMetrics {
|
||
max_width: 30,
|
||
header_token_width: 5,
|
||
body_token_width: 20,
|
||
avg_words_per_cell: 1.0,
|
||
avg_cell_width: 10.0,
|
||
kind: TableColumnKind::Structured,
|
||
};
|
||
// max(5, min(20, 16)) = max(5, 16) = 16
|
||
assert_eq!(W::preferred_column_floor(&m2, /*min_column_width*/ 3), 16);
|
||
}
|
||
|
||
#[test]
|
||
fn next_column_to_shrink_prefers_narrative() {
|
||
// Two columns: Narrative (col 0) and Structured (col 1), both with slack.
|
||
// Narrative should be shrunk first.
|
||
let widths = [20usize, 20];
|
||
let floors = [8usize, 8];
|
||
let metrics = [
|
||
TableColumnMetrics {
|
||
max_width: 30,
|
||
header_token_width: 8,
|
||
body_token_width: 6,
|
||
avg_words_per_cell: 5.0,
|
||
avg_cell_width: 30.0,
|
||
kind: TableColumnKind::Narrative,
|
||
},
|
||
TableColumnMetrics {
|
||
max_width: 30,
|
||
header_token_width: 8,
|
||
body_token_width: 6,
|
||
avg_words_per_cell: 1.0,
|
||
avg_cell_width: 10.0,
|
||
kind: TableColumnKind::Structured,
|
||
},
|
||
];
|
||
let idx = W::next_column_to_shrink(&widths, &floors, &metrics);
|
||
assert_eq!(idx, Some(0), "Narrative column should be shrunk first");
|
||
}
|
||
|
||
// ===== Spillover-detection unit tests =====
|
||
|
||
#[test]
|
||
fn spillover_detects_single_cell_row() {
|
||
let row = make_body_row(
|
||
vec![make_cell("some trailing text")],
|
||
/*has_table_pipe_syntax*/ false,
|
||
);
|
||
assert!(W::is_spillover_row(&row, /*next_row*/ None));
|
||
}
|
||
|
||
#[test]
|
||
fn spillover_keeps_single_cell_row_with_table_pipe_syntax() {
|
||
let row = make_body_row(
|
||
vec![make_cell("some sparse value")],
|
||
/*has_table_pipe_syntax*/ true,
|
||
);
|
||
assert!(!W::is_spillover_row(&row, /*next_row*/ None));
|
||
}
|
||
|
||
#[test]
|
||
fn spillover_detects_html_content() {
|
||
// 3-cell row where only cell 0 has HTML content
|
||
let row = make_body_row(
|
||
vec![
|
||
make_cell("<div>content</div>"),
|
||
make_cell(""),
|
||
make_cell(""),
|
||
],
|
||
/*has_table_pipe_syntax*/ false,
|
||
);
|
||
assert!(W::is_spillover_row(&row, /*next_row*/ None));
|
||
}
|
||
|
||
#[test]
|
||
fn spillover_detects_label_followed_by_html() {
|
||
// cell 0 = "HTML block:" and next_row cell 0 = "<div>x</div>"
|
||
let row = make_body_row(
|
||
vec![make_cell("HTML block:"), make_cell(""), make_cell("")],
|
||
/*has_table_pipe_syntax*/ false,
|
||
);
|
||
let next = make_body_row(
|
||
vec![make_cell("<div>x</div>"), make_cell(""), make_cell("")],
|
||
/*has_table_pipe_syntax*/ false,
|
||
);
|
||
assert!(W::is_spillover_row(&row, Some(&next)));
|
||
}
|
||
|
||
#[test]
|
||
fn spillover_detects_trailing_html_label() {
|
||
// "HTML block:" with no next_row → trailing HTML label spillover
|
||
let row = make_body_row(
|
||
vec![make_cell("HTML block:"), make_cell(""), make_cell("")],
|
||
/*has_table_pipe_syntax*/ false,
|
||
);
|
||
assert!(W::is_spillover_row(&row, /*next_row*/ None));
|
||
}
|
||
|
||
#[test]
|
||
fn spillover_keeps_normal_multi_cell_row() {
|
||
// 3 cells all non-empty → not spillover
|
||
let row = make_body_row(
|
||
vec![make_cell("one"), make_cell("two"), make_cell("three")],
|
||
/*has_table_pipe_syntax*/ true,
|
||
);
|
||
assert!(!W::is_spillover_row(&row, /*next_row*/ None));
|
||
}
|
||
|
||
#[test]
|
||
fn spillover_keeps_label_when_next_is_not_html() {
|
||
// cell 0 = "Status:" and next_row cell 0 = "ok" → not spillover (not HTML)
|
||
let row = make_body_row(
|
||
vec![make_cell("Status:"), make_cell(""), make_cell("")],
|
||
/*has_table_pipe_syntax*/ true,
|
||
);
|
||
let next = make_body_row(
|
||
vec![make_cell("ok"), make_cell(""), make_cell("")],
|
||
/*has_table_pipe_syntax*/ true,
|
||
);
|
||
assert!(!W::is_spillover_row(&row, Some(&next)));
|
||
}
|
||
}
|