mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
feat(tui): render markdown tables with Unicode box-drawing borders
Add proper table rendering to the markdown renderer using Unicode box-drawing characters (┌─┬┐│├─┼┤└─┴┘) instead of raw pipe syntax. Supports column alignment, cell wrapping at narrow widths, tables inside blockquotes, escaped pipes, and styled inline content.
This commit is contained in:
committed by
Felipe Coury
parent
54dbbb839e
commit
ff310bb9e0
@@ -211,6 +211,7 @@ mod voice {
|
||||
|
||||
mod wrapping;
|
||||
|
||||
mod table_detect;
|
||||
#[cfg(test)]
|
||||
pub mod test_backend;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
|
||||
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;
|
||||
@@ -20,6 +23,7 @@ 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;
|
||||
@@ -27,6 +31,7 @@ use regex_lite::Regex;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
struct MarkdownStyles {
|
||||
@@ -86,6 +91,113 @@ impl IndentContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
impl TableCell {
|
||||
fn ensure_line(&mut self) {
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(Line::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_span(&mut self, span: Span<'static>) {
|
||||
self.ensure_line();
|
||||
if let Some(line) = self.lines.last_mut() {
|
||||
line.push_span(span);
|
||||
}
|
||||
}
|
||||
|
||||
fn hard_break(&mut self) {
|
||||
self.lines.push(Line::default());
|
||||
}
|
||||
|
||||
fn plain_text(&self) -> String {
|
||||
self.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 TableState {
|
||||
alignments: Vec<Alignment>,
|
||||
header: Option<Vec<TableCell>>,
|
||||
rows: Vec<Vec<TableCell>>,
|
||||
current_row: Option<Vec<TableCell>>,
|
||||
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_cell: None,
|
||||
in_header: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
pub fn render_markdown_text(input: &str) -> Text<'static> {
|
||||
render_markdown_text_with_width(input, /*width*/ None)
|
||||
}
|
||||
@@ -108,6 +220,7 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||||
) -> Text<'static> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
let parser = Parser::new_ext(input, options);
|
||||
let mut w = Writer::new(parser, width, cwd);
|
||||
w.run();
|
||||
@@ -170,6 +283,7 @@ where
|
||||
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>
|
||||
@@ -200,6 +314,7 @@ where
|
||||
current_subsequent_indent: Vec::new(),
|
||||
current_line_style: Style::default(),
|
||||
current_line_in_code_block: false,
|
||||
table_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,12 +388,12 @@ where
|
||||
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(),
|
||||
Tag::TableCell => self.start_table_cell(),
|
||||
Tag::HtmlBlock
|
||||
| Tag::FootnoteDefinition(_)
|
||||
| Tag::Table(_)
|
||||
| Tag::TableHead
|
||||
| Tag::TableRow
|
||||
| Tag::TableCell
|
||||
| Tag::Image { .. }
|
||||
| Tag::MetadataBlock(_) => {}
|
||||
}
|
||||
@@ -297,18 +412,21 @@ where
|
||||
}
|
||||
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::Table
|
||||
| TagEnd::TableHead
|
||||
| TagEnd::TableRow
|
||||
| TagEnd::TableCell
|
||||
| TagEnd::Image
|
||||
| TagEnd::MetadataBlock(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_paragraph(&mut self) {
|
||||
if self.in_table_cell() {
|
||||
return;
|
||||
}
|
||||
if self.needs_newline {
|
||||
self.push_blank_line();
|
||||
}
|
||||
@@ -318,12 +436,18 @@ where
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -343,11 +467,17 @@ where
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -360,6 +490,9 @@ where
|
||||
}
|
||||
|
||||
fn end_blockquote(&mut self) {
|
||||
if self.in_table_cell() {
|
||||
return;
|
||||
}
|
||||
self.indent_stack.pop();
|
||||
self.needs_newline = true;
|
||||
}
|
||||
@@ -369,6 +502,11 @@ where
|
||||
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());
|
||||
}
|
||||
@@ -422,6 +560,11 @@ where
|
||||
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;
|
||||
@@ -435,6 +578,19 @@ where
|
||||
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 {
|
||||
@@ -455,6 +611,10 @@ where
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -462,6 +622,11 @@ where
|
||||
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;
|
||||
@@ -570,6 +735,593 @@ where
|
||||
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 table_lines = self.render_table_lines(table_state);
|
||||
let mut pending_marker_line = self.pending_marker_line;
|
||||
for line in table_lines {
|
||||
self.push_prewrapped_line(line, pending_marker_line);
|
||||
pending_marker_line = false;
|
||||
}
|
||||
self.pending_marker_line = false;
|
||||
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) {
|
||||
if let Some(table_state) = self.table_state.as_mut() {
|
||||
table_state.current_row = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
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(row);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// The pipeline is: filter spillover rows -> normalize column counts -> compute column widths
|
||||
/// -> render box grid (or fall back to pipe format if widths can't fit). Spillover rows are
|
||||
/// appended after the table grid.
|
||||
fn render_table_lines(&self, mut table_state: TableState) -> Vec<Line<'static>> {
|
||||
let column_count = table_state.alignments.len();
|
||||
if column_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut spillover_rows: Vec<TableCell> = Vec::new();
|
||||
let mut rows: Vec<Vec<TableCell>> = Vec::new();
|
||||
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.first().cloned() {
|
||||
spillover_rows.push(cell);
|
||||
}
|
||||
} else {
|
||||
rows.push(row.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 Some(column_widths) = widths else {
|
||||
let mut fallback =
|
||||
self.render_table_pipe_fallback(&header, &rows, &table_state.alignments);
|
||||
for spillover in spillover_rows {
|
||||
fallback.extend(spillover.lines);
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
let border_style = Style::new().dim();
|
||||
let mut out = Vec::new();
|
||||
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));
|
||||
for spillover in spillover_rows {
|
||||
out.extend(spillover.lines);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_row(row: &mut Vec<TableCell>, column_count: usize) {
|
||||
if row.len() > column_count {
|
||||
row.truncate(column_count);
|
||||
}
|
||||
if row.len() < 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)
|
||||
})
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(left.to_string(), 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(right.to_string(), style));
|
||||
} else {
|
||||
spans.push(Span::styled(sep.to_string(), 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).max(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("│".to_string(), 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("│".to_string(), border_style));
|
||||
}
|
||||
out.push(Line::from(spans));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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(' ');
|
||||
out.push_str(&cell.plain_text());
|
||||
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
|
||||
}
|
||||
|
||||
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 rather than real
|
||||
/// table data. These "spillover" rows -- typically trailing paragraphs absorbed into the table
|
||||
/// because they lack leading pipes -- are extracted and rendered as plain text after the table
|
||||
/// grid.
|
||||
fn is_spillover_row(row: &[TableCell], next_row: Option<&Vec<TableCell>>) -> bool {
|
||||
let Some(first_text) = Self::first_non_empty_only_text(row) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if row.len() == 1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if Self::looks_like_html_content(&first_text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep common intro + html-block spillover together:
|
||||
// "HTML block:" followed by "<div ...>".
|
||||
first_text.trim_end().ends_with(':')
|
||||
&& (next_row
|
||||
.and_then(|row| Self::first_non_empty_only_text(row))
|
||||
.is_some_and(|text| Self::looks_like_html_content(&text))
|
||||
|| Self::looks_like_label_line(&first_text))
|
||||
}
|
||||
|
||||
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 {
|
||||
text.contains('<') && text.contains('>')
|
||||
}
|
||||
|
||||
fn looks_like_label_line(text: &str) -> bool {
|
||||
text.trim_end().ends_with(':') && text.split_whitespace().count() <= 3
|
||||
}
|
||||
|
||||
fn spans_display_width(spans: &[Span<'_>]) -> usize {
|
||||
spans.iter().map(|span| span.content.width()).sum()
|
||||
}
|
||||
|
||||
fn line_display_width(line: &Line<'_>) -> usize {
|
||||
line.spans.iter().map(|span| span.content.width()).sum()
|
||||
}
|
||||
|
||||
fn cell_display_width(cell: &TableCell) -> usize {
|
||||
cell.lines
|
||||
.iter()
|
||||
.map(Self::line_display_width)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -596,13 +1348,16 @@ where
|
||||
fn pop_link(&mut self) {
|
||||
if let Some(link) = self.link.take() {
|
||||
if link.show_destination {
|
||||
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 {
|
||||
if self.pending_marker_line {
|
||||
self.push_line(Line::default());
|
||||
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
|
||||
@@ -611,8 +1366,16 @@ where
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.patch(self.styles.code);
|
||||
self.push_span(Span::styled(local_target_display, style));
|
||||
self.line_ends_with_local_link_target = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,6 +1414,30 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// Like push_line, but skips word wrapping.
|
||||
//
|
||||
// It just prepends the indent prefix in case blockquote is active and pushed directly to the
|
||||
// output.
|
||||
//
|
||||
// Table lines are already laid out with the exact column widths. We don't want word wrapping
|
||||
// to break the box-drawing borders.
|
||||
fn push_prewrapped_line(&mut self, line: Line<'static>, pending_marker_line: bool) {
|
||||
self.flush_current_line();
|
||||
let blockquote_active = self
|
||||
.indent_stack
|
||||
.iter()
|
||||
.any(|ctx| ctx.prefix.iter().any(|p| p.content.contains('>')));
|
||||
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.text.lines.push(Line::from(spans).style(style));
|
||||
}
|
||||
|
||||
fn push_line(&mut self, line: Line<'static>) {
|
||||
self.flush_current_line();
|
||||
let blockquote_active = self
|
||||
|
||||
@@ -1368,3 +1368,222 @@ fn code_block_preserves_trailing_blank_lines() {
|
||||
"trailing blank line inside code fence was lost: {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_renders_unicode_box() {
|
||||
let md = "| A | B |\n|---|---|\n| 1 | 2 |\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"┌─────┬─────┐".to_string(),
|
||||
"│ A │ B │".to_string(),
|
||||
"├─────┼─────┤".to_string(),
|
||||
"│ 1 │ 2 │".to_string(),
|
||||
"└─────┴─────┘".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_alignment_respects_markers() {
|
||||
let md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
assert_eq!(lines[1], "│ Left │ Center │ Right │");
|
||||
assert_eq!(lines[3], "│ a │ b │ c │");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_wraps_cell_content_when_width_is_narrow() {
|
||||
let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n";
|
||||
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(30));
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
assert!(lines[0].starts_with('┌') && lines[0].ends_with('┐'));
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|line| line.contains("Enable very verbose"))
|
||||
&& lines.iter().any(|line| line.contains("logging output")),
|
||||
"expected wrapped row content: {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_inside_blockquote_has_quote_prefix() {
|
||||
let md = "> | A | B |\n> |---|---|\n> | 1 | 2 |\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
assert!(lines.iter().all(|line| line.starts_with("> ")));
|
||||
assert!(lines.iter().any(|line| line.contains("┌─────┬─────┐")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_pipes_render_in_table_cells() {
|
||||
let md = "| Col |\n| --- |\n| a \\| b |\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
assert!(lines.iter().any(|line| line.contains("a | b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_falls_back_to_pipe_rendering_if_it_cannot_fit() {
|
||||
let md = "| c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10 |\n|---|---|---|---|---|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|
||||
10 |\n";
|
||||
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(20));
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
assert!(lines.first().is_some_and(|line| line.starts_with('|')));
|
||||
assert!(!lines.iter().any(|line| line.contains('┌')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_keeps_sparse_rows_with_empty_trailing_cells() {
|
||||
let md = "| A | B | C |\n|---|---|---|\n| a | | |\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|line| line.contains("│ a") && line.ends_with('│')),
|
||||
"expected sparse row to remain inside table grid: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
!lines.iter().any(|line| line == "a"),
|
||||
"did not expect sparse row content to spill outside the table: {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_keeps_sparse_sentence_row_inside_grid() {
|
||||
let md = "| A | B | C |\n|---|---|---|\n| This is done. | | |\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|line| line.contains("│ This is done.") && line.ends_with('│')),
|
||||
"expected sparse sentence row to remain inside table grid: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
!lines.iter().any(|line| line.trim() == "This is done."),
|
||||
"did not expect sparse sentence row to spill outside table: {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_preserves_structured_leading_columns_when_last_column_is_long() {
|
||||
let md = "| Milestone | Planned Date | Outcome | Retrospective Summary |\n|---|---|---|---|\n| Canary rollout | 2026-01-10 | Completed | Canary
|
||||
traffic was held at 5% longer than planned due to latency regressions tied to cold cache behavior; after pre-warming and query plan hints, p95
|
||||
returned to baseline and rollout resumed safely. |\n| Full region cutover | 2026-01-24 | Completed | Cutover succeeded with no customer-visible
|
||||
downtime, though internal dashboards lagged for approximately 18 minutes because ingestion workers autoscaled slower than forecast under burst load.
|
||||
|\n";
|
||||
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(160));
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("Milestone")),
|
||||
"expected first structured header to remain readable: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("Planned Date")),
|
||||
"expected date header to remain readable: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("2026-01-10")),
|
||||
"expected date values to avoid forced mid-token wraps: {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_preserves_status_column_with_long_notes() {
|
||||
let md = "| Service | Status | Notes |\n|---|---|---|\n| Auth API | Stable | Handles login and token refresh with no major incidents in the last
|
||||
30 days. |\n| Billing Worker | Monitoring | Throughput is good, but we still see occasional retry storms when upstream settlement providers return
|
||||
partial failures. |\n| Search Indexer | Tuning | Performance improved after shard balancing, yet memory usage remains elevated during full rebuild
|
||||
windows. |\n";
|
||||
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(150));
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("Status")),
|
||||
"expected status header to remain readable: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("Monitoring")),
|
||||
"expected status values to avoid mid-word wraps: {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_keeps_long_body_rows_inside_grid_instead_of_spilling_raw_pipe_rows() {
|
||||
let md = "| Milestone | Planned Date | Outcome | Retrospective Summary |\n|---|---|---|---|\n| Canary rollout | 2026-01-10 | Completed | Canary
|
||||
traffic was held at 5% longer than planned due to latency regressions tied to cold cache behavior; after pre-warming and query plan hints, p95
|
||||
returned to baseline and rollout resumed safely. |\n| Full region cutover | 2026-01-24 | Completed | Cutover succeeded with no customer-visible
|
||||
downtime, though internal dashboards lagged for approximately 18 minutes because ingestion workers autoscaled slower than forecast under burst load.
|
||||
|\n| Legacy decommission | 2026-02-07 | In progress | Most workloads have been drained, but final decommission is blocked by one compliance export
|
||||
task that still depends on a deprecated storage path and requires legal sign-off before removal. |\n";
|
||||
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(200));
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
lines.iter().any(|line| line.starts_with('┌'))
|
||||
&& lines.iter().any(|line| line.starts_with('└')),
|
||||
"expected boxed table output: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("│ Canary rollout")),
|
||||
"expected first body row to stay inside table grid: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
!lines
|
||||
.iter()
|
||||
.any(|line| line.trim_start().starts_with("| Canary rollout |")),
|
||||
"did not expect raw pipe-form body rows outside table: {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,12 @@ Image: alt text
|
||||
———
|
||||
|
||||
Table below (alignment test):
|
||||
| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| a | b | c |
|
||||
|
||||
┌──────┬────────┬───────┐
|
||||
│ Left │ Center │ Right │
|
||||
├──────┼────────┼───────┤
|
||||
│ a │ b │ c │
|
||||
└──────┴────────┴───────┘
|
||||
Inline HTML: <sup>sup</sup> and <sub>sub</sub>.
|
||||
HTML block:
|
||||
<div style="border:1px solid #ccc;padding:2px">inline block</div>
|
||||
|
||||
139
codex-rs/tui/src/table_detect.rs
Normal file
139
codex-rs/tui/src/table_detect.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! Shared pipe-table detection helpers.
|
||||
//!
|
||||
//! Both the streaming controller (`streaming/controller.rs`) and the
|
||||
//! markdown-fence unwrapper (`markdown.rs`) need to identify pipe-table
|
||||
//! structure in raw markdown source. This module provides the canonical
|
||||
//! implementations so fixes only need to happen in one place.
|
||||
|
||||
/// Split a pipe-delimited line into trimmed segments.
|
||||
///
|
||||
/// Returns `None` if the line is empty or has fewer than two segments.
|
||||
/// Leading/trailing pipes are stripped before splitting.
|
||||
pub(crate) fn parse_table_segments(line: &str) -> Option<Vec<&str>> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut content = trimmed;
|
||||
if let Some(without_leading) = content.strip_prefix('|') {
|
||||
content = without_leading;
|
||||
}
|
||||
if let Some(without_trailing) = content.strip_suffix('|') {
|
||||
content = without_trailing;
|
||||
}
|
||||
|
||||
let segments: Vec<&str> = content.split('|').map(str::trim).collect();
|
||||
(segments.len() >= 2).then_some(segments)
|
||||
}
|
||||
|
||||
/// Whether `line` looks like a table header row (has pipe-separated
|
||||
/// segments with at least one non-empty cell).
|
||||
pub(crate) fn is_table_header_line(line: &str) -> bool {
|
||||
parse_table_segments(line).is_some_and(|segments| segments.iter().any(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// Whether a single segment matches the `---`, `:---`, `---:`, or `:---:`
|
||||
/// alignment-colon syntax used in markdown table delimiter rows.
|
||||
pub(crate) fn is_table_delimiter_segment(segment: &str) -> bool {
|
||||
let trimmed = segment.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let without_leading = trimmed.strip_prefix(':').unwrap_or(trimmed);
|
||||
let without_ends = without_leading.strip_suffix(':').unwrap_or(without_leading);
|
||||
without_ends.len() >= 3 && without_ends.chars().all(|c| c == '-')
|
||||
}
|
||||
|
||||
/// Whether `line` is a valid table delimiter row (every segment passes
|
||||
/// [`is_table_delimiter_segment`]).
|
||||
pub(crate) fn is_table_delimiter_line(line: &str) -> bool {
|
||||
parse_table_segments(line)
|
||||
.is_some_and(|segments| segments.into_iter().all(is_table_delimiter_segment))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_table_segments_basic() {
|
||||
assert_eq!(
|
||||
parse_table_segments("| A | B | C |"),
|
||||
Some(vec!["A", "B", "C"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_table_segments_no_outer_pipes() {
|
||||
assert_eq!(parse_table_segments("A | B | C"), Some(vec!["A", "B", "C"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_table_segments_no_leading_pipe() {
|
||||
assert_eq!(
|
||||
parse_table_segments("A | B | C |"),
|
||||
Some(vec!["A", "B", "C"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_table_segments_no_trailing_pipe() {
|
||||
assert_eq!(
|
||||
parse_table_segments("| A | B | C"),
|
||||
Some(vec!["A", "B", "C"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_table_segments_single_segment_returns_none() {
|
||||
assert_eq!(parse_table_segments("| only |"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_table_segments_empty_returns_none() {
|
||||
assert_eq!(parse_table_segments(""), None);
|
||||
assert_eq!(parse_table_segments(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_table_delimiter_segment_valid() {
|
||||
assert!(is_table_delimiter_segment("---"));
|
||||
assert!(is_table_delimiter_segment(":---"));
|
||||
assert!(is_table_delimiter_segment("---:"));
|
||||
assert!(is_table_delimiter_segment(":---:"));
|
||||
assert!(is_table_delimiter_segment(":-------:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_table_delimiter_segment_invalid() {
|
||||
assert!(!is_table_delimiter_segment(""));
|
||||
assert!(!is_table_delimiter_segment("--"));
|
||||
assert!(!is_table_delimiter_segment("abc"));
|
||||
assert!(!is_table_delimiter_segment(":--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_table_delimiter_line_valid() {
|
||||
assert!(is_table_delimiter_line("| --- | --- |"));
|
||||
assert!(is_table_delimiter_line("|:---:|---:|"));
|
||||
assert!(is_table_delimiter_line("--- | --- | ---"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_table_delimiter_line_invalid() {
|
||||
assert!(!is_table_delimiter_line("| A | B |"));
|
||||
assert!(!is_table_delimiter_line("| -- | -- |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_table_header_line_valid() {
|
||||
assert!(is_table_header_line("| A | B |"));
|
||||
assert!(is_table_header_line("Name | Value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_table_header_line_all_empty_segments() {
|
||||
assert!(!is_table_header_line("| | |"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user