Files
codex/codex-rs/tui/src/markdown_render.rs
T
Felipe Coury 4ca2e436e5 fix(tui): linkify complete bare URLs with tildes (#27088)
## Background

Bare URLs containing `~` in their path are currently only clickable up
to the tilde in the interactive TUI. For example, Codex renders the
visible text for:


`https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf`

but the OSC 8 destination stops at `https://www.cs.tufts.edu/`. This
makes Cmd-click open the wrong location even though the terminal
recognizes the complete URL outside Codex.

Fixes #26774.

## Root Cause

The URL scanner already accepts `~`. The truncation happens earlier:
with strikethrough parsing enabled, `pulldown-cmark` splits this URL
into adjacent decoded `Event::Text` values around the tilde. The
Markdown renderer annotated each text event independently, so only the
first event still looked like a complete URL with a supported scheme.

The renderer now merges adjacent decoded text events before URL
annotation. It preserves the combined source range while retaining
parser-decoded contents, which avoids regressing entities such as
`&`.

## Changes

- Add a small iterator that merges adjacent decoded Markdown text events
and their source ranges.
- Apply it at the Markdown renderer boundary before hyperlink detection.
- Add regression coverage for the reported URL in prose, wrapped table
output, and entity-decoded URLs.

## How to Test

1. Run Codex with `just c`.
2. Ask the assistant to output this exact bare URL with no Markdown link
syntax:

`https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf`
3. Hold Cmd and hover or click the URL.
4. Confirm the complete URL, including the suffix after `~`, is one
destination.
5. Repeat with the URL inside a Markdown table and confirm wrapped
portions retain the same complete destination.

Targeted tests:

- `just test -p codex-tui url_with_tilde`
- `just test -p codex-tui merged_text_events_preserve_entity_decoding`

The full `codex-tui` test run was also executed. Its only failures were
the two existing Guardian feature-flag tests:

-
`app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
-
`app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`
2026-06-08 17:02:36 -07:00

2772 lines
100 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 content-aware
//! priority and iterative shrinking.
//! 4. **Choose presentation** -- render theme-accented row-separated columns
//! while values remain scannable, otherwise transpose body rows
//! into key/value records separated by muted rules.
//! 5. **Append spillover** -- extracted spillover rows rendered as plain text
//! after the table.
//!
//! ## Width allocation
//!
//! Columns are classified as Narrative (long prose), TokenHeavy (paths, URLs,
//! or hashes), or Compact (short values such as counts and status labels).
//! Token-heavy columns give up excess width before narrative columns so an
//! oversized path does not collapse readable prose; compact values are
//! preserved last. When compact values split, token-heavy values collapse into
//! unusably short chunks, expansive cells form tall narrow strips across enough
//! body rows, or even 3-char-wide columns cannot fit, body rows render as
//! key/value records.
use crate::markdown_text_merge::DecodedTextMerge;
use crate::render::highlight::foreground_style_for_scopes;
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::style::table_separator_style;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::annotate_web_urls_in_line;
use crate::terminal_hyperlinks::remap_wrapped_line;
use crate::terminal_hyperlinks::visible_lines;
use crate::terminal_hyperlinks::web_destination;
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::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use url::Url;
mod table_key_value;
const TABLE_COLUMN_GAP: usize = 2;
const TABLE_CELL_PADDING: usize = 1;
const TABLE_HEADER_SEPARATOR_CHAR: char = '━';
const TABLE_BODY_SEPARATOR_CHAR: char = '─';
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<HyperlinkLine>,
}
// 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(HyperlinkLine::new(Line::default()));
}
}
#[inline]
fn push_span(&mut self, span: Span<'static>) {
self.ensure_line();
if let Some(line) = self.lines.last_mut() {
line.line.push_span(span);
}
}
fn push_annotated(&mut self, mut appended: HyperlinkLine) {
self.ensure_line();
if let Some(line) = self.lines.last_mut() {
let shift = line.width();
line.line.spans.append(&mut appended.line.spans);
line.hyperlinks
.extend(appended.hyperlinks.into_iter().map(|mut link| {
link.columns = link.columns.start + shift..link.columns.end + shift;
link
}));
}
}
#[inline]
fn hard_break(&mut self) {
self.lines.push(HyperlinkLine::new(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.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 prewrapped aligned rows or key/value records, except
/// header-only tables may retain pipe fallback rows for normal wrapping.
/// `spillover_lines` are prose rows extracted from parser artifacts and should
/// be routed through normal wrapping.
struct RenderedTableLines {
table_lines: Vec<HyperlinkLine>,
table_lines_prewrapped: bool,
spillover_lines: Vec<HyperlinkLine>,
}
/// Classification of a table column for width-allocation priority.
///
/// Token-heavy columns such as paths and URLs are allowed to wrap before prose becomes unreadable.
/// Compact columns such as counts or status words resist wrapping so their values stay scannable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TableColumnKind {
/// Long-form prose content (>= 4 avg words/cell or >= 28 avg char width).
Narrative,
/// Content dominated by long tokens, such as paths, URLs, and hashes.
TokenHeavy,
/// Short values, such as counts and status labels, that should resist wrapping.
Compact,
}
/// 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,
/// Classification derived from body token density and average cell content.
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 columnar table structure while values remain
/// scannable and falls back to key/value records when body rows cannot fit
/// readably. 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> {
Text::from(visible_lines(render_markdown_lines_with_width_and_cwd(
input, width, cwd,
)))
}
pub(crate) fn render_markdown_lines_with_width_and_cwd(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> Vec<HyperlinkLine> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = DecodedTextMerge::new(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: Vec<HyperlinkLine>,
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_start_line_counts: Vec<usize>,
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<HyperlinkLine>,
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: Vec::new(),
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_start_line_counts: 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.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 => {
self.flush_current_line();
let start_line_count = self.list_item_start_line_counts.pop().unwrap_or_default();
if self.text.len().saturating_sub(start_line_count) > 1
&& 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.line.spans.is_empty())
.unwrap_or_else(|| {
self.text
.last()
.map(|line| !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 style = self.inline_styles.last().copied().unwrap_or_default();
self.push_text_spans(&content, style);
}
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.flush_current_line();
self.list_item_start_line_counts.push(self.text.len());
self.pending_marker_line = true;
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>>) {
self.flush_current_line();
if !self.text.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_hyperlink_line(line);
self.flush_current_line();
}
pending_marker_line = false;
}
self.pending_marker_line = false;
for spillover_line in spillover_lines {
self.push_hyperlink_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_text_spans_to_table_cell(line, style);
}
}
fn push_text_spans_to_table_cell(&mut self, text: &str, style: Style) {
let span = Span::styled(text.to_string(), style);
let destination = self
.link
.as_ref()
.and_then(|link| web_destination(&link.destination));
let mut annotated = if let Some(destination) = destination {
let mut annotated = HyperlinkLine::new(Line::default());
annotated.push_span(span, Some(&destination));
annotated
} else if self.link.is_some() || self.in_code_block {
HyperlinkLine::new(Line::from(span))
} else {
annotate_web_urls_in_line(Line::from(span))
};
if let Some(table_state) = self.table_state.as_mut()
&& let Some(cell) = table_state.current_cell.as_mut()
{
cell.push_annotated(std::mem::take(&mut annotated));
}
}
/// Convert a completed `TableState` into styled table `Line`s.
///
/// Pipeline: filter spillover rows -> normalize column counts -> compute
/// column widths -> render aligned rows or key/value records when values
/// systemically lose token readability or expansive cells become tall
/// narrow strips. Spillover rows are appended as plain text after the
/// table.
///
/// Falls back to key/value records when body rows cannot fit in the aligned
/// grid; header-only tables retain raw pipe output because they contain no
/// records to transpose.
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 metrics = Self::collect_table_column_metrics(&header, &rows, 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<HyperlinkLine> = spillover_rows
.into_iter()
.flat_map(|spillover| spillover.lines)
.collect();
let header_style =
foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"])
.unwrap_or(self.styles.strong)
.bold();
let separator_style = table_separator_style();
let Some(column_widths) = widths else {
if !rows.is_empty() {
return RenderedTableLines {
table_lines: table_key_value::render_records(
&header,
&rows,
&metrics,
self.available_record_width(),
header_style,
separator_style,
),
table_lines_prewrapped: true,
spillover_lines,
};
}
return RenderedTableLines {
table_lines: self.render_table_pipe_fallback(
&header,
&rows,
&table_state.alignments,
),
table_lines_prewrapped: false,
spillover_lines,
};
};
if table_key_value::should_render_records(&rows, &column_widths, &metrics) {
return RenderedTableLines {
table_lines: table_key_value::render_records(
&header,
&rows,
&metrics,
self.available_record_width(),
header_style,
separator_style,
),
table_lines_prewrapped: true,
spillover_lines,
};
}
let mut out = Vec::with_capacity(2 + rows.len() * 2);
out.extend(self.render_table_row(
&header,
&column_widths,
&table_state.alignments,
header_style,
));
out.push(Self::render_table_separator(
&column_widths,
TABLE_HEADER_SEPARATOR_CHAR,
separator_style,
));
for (row_idx, row) in rows.iter().enumerate() {
out.extend(self.render_table_row(
row,
&column_widths,
&table_state.alignments,
Style::default(),
));
if row_idx + 1 < rows.len() {
out.push(Self::render_table_separator(
&column_widths,
TABLE_BODY_SEPARATOR_CHAR,
separator_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());
}
/// Subtract horizontal gutters and per-cell padding from the content budget.
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
+ (column_count.saturating_sub(1) * TABLE_COLUMN_GAP)
+ (column_count * TABLE_CELL_PADDING * 2);
wrap_width.saturating_sub(reserved)
})
}
/// Return the full content budget for record fallback rendering.
fn available_record_width(&self) -> Option<usize> {
self.wrap_width.map(|wrap_width| {
let prefix_width =
Self::spans_display_width(&self.prefix_spans(self.pending_marker_line));
wrap_width.saturating_sub(prefix_width)
})
}
/// Allocate column widths for aligned, row-separated 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`. Token-heavy columns surrender excess width before
/// narrative prose; compact columns are preserved last. 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 in wrapping priority order until the hard width budget fits.
while floor_total > max_width {
let Some((idx, _)) = floors
.iter()
.enumerate()
.filter(|(_, floor)| **floor > min_column_width)
.min_by_key(|(idx, floor)| {
(
Self::column_shrink_priority(metrics[*idx].kind),
usize::MAX.saturating_sub(**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 body_token_count = 0usize;
let mut long_body_token_count = 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 {
body_token_count += word_count;
long_body_token_count += plain
.split_whitespace()
.filter(|token| token.width() >= 20)
.count();
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 long_body_token_count > 0
&& long_body_token_count >= body_token_count.saturating_sub(long_body_token_count)
{
TableColumnKind::TokenHeavy
} else if avg_words_per_cell >= 4.0 || avg_cell_width >= 28.0 {
TableColumnKind::Narrative
} else {
TableColumnKind::Compact
};
metrics.push(TableColumnMetrics {
max_width,
header_token_width,
body_token_width,
kind,
});
}
metrics
}
/// Compute the preferred minimum width for a column before the shrink loop
/// starts reducing it further.
///
/// Narrative and token-heavy columns retain a readable 16-cell soft floor.
/// Compact 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 | TableColumnKind::TokenHeavy => 16,
TableColumnKind::Compact => 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: TokenHeavy columns are shrunk before Narrative, then Compact.
/// Within the same kind, the column with the most slack above its floor is
/// chosen so similarly-shaped columns stay balanced.
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]);
(
Self::column_shrink_priority(metrics[*idx].kind),
usize::MAX.saturating_sub(slack),
)
})
.map(|(idx, _)| idx)
}
fn column_shrink_priority(kind: TableColumnKind) -> usize {
match kind {
TableColumnKind::TokenHeavy => 0,
TableColumnKind::Narrative => 1,
TableColumnKind::Compact => 2,
}
}
fn render_table_separator(
column_widths: &[usize],
separator_char: char,
style: Style,
) -> HyperlinkLine {
let segment_char = separator_char.to_string();
let gap = " ".repeat(TABLE_COLUMN_GAP);
let text = column_widths
.iter()
.map(|width| segment_char.repeat(*width + (TABLE_CELL_PADDING * 2)))
.collect::<Vec<_>>()
.join(&gap);
HyperlinkLine::new(Line::from(Span::styled(text, style)))
}
fn render_table_row(
&self,
row: &[TableCell],
column_widths: &[usize],
alignments: &[Alignment],
row_style: Style,
) -> Vec<HyperlinkLine> {
let wrapped_cells: Vec<Vec<HyperlinkLine>> = 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 Some(last_visible_column) = wrapped_cells.iter().rposition(|lines| {
lines
.get(row_line)
.is_some_and(|line| Self::line_display_width(&line.line) > 0)
}) else {
out.push(HyperlinkLine::new(Line::default().style(row_style)));
continue;
};
let mut spans = Vec::new();
for (column, width) in column_widths
.iter()
.enumerate()
.take(last_visible_column + 1)
{
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
let mut line = wrapped_cells[column]
.get(row_line)
.cloned()
.unwrap_or_default();
let line_width = Self::line_display_width(&line.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.append(&mut line.line.spans);
let is_last_column = column == last_visible_column;
if right_padding > 0 && !is_last_column {
spans.push(Span::raw(" ".repeat(right_padding)));
}
if !is_last_column {
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
}
if !is_last_column {
spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP)));
}
}
let mut out_line = HyperlinkLine::new(Line::from(spans).style(row_style));
let mut column_start = 0usize;
for (column, width) in column_widths
.iter()
.enumerate()
.take(last_visible_column + 1)
{
column_start += TABLE_CELL_PADDING;
if let Some(line) = wrapped_cells[column].get(row_line) {
let remaining = width.saturating_sub(Self::line_display_width(&line.line));
let left_padding = match alignments[column] {
Alignment::Left | Alignment::None => 0,
Alignment::Center => remaining / 2,
Alignment::Right => remaining,
};
out_line
.hyperlinks
.extend(line.hyperlinks.iter().cloned().map(|mut link| {
link.columns = link.columns.start + column_start + left_padding
..link.columns.end + column_start + left_padding;
link
}));
}
column_start += *width + TABLE_CELL_PADDING + TABLE_COLUMN_GAP;
}
out.push(out_line);
}
out
}
/// Render a header-only table as raw pipe-delimited lines (`| A | B |`).
///
/// Used when `compute_column_widths` returns `None` and there are no body
/// records to transpose. 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<HyperlinkLine> {
let mut out = Vec::new();
out.push(Self::row_to_pipe_line(header));
out.push(HyperlinkLine::new(Line::from(
Self::alignments_to_pipe_delimiter(alignments),
)));
out.extend(rows.iter().map(|row| Self::row_to_pipe_line(row)));
out
}
fn row_to_pipe_line(row: &[TableCell]) -> HyperlinkLine {
let mut out = HyperlinkLine::new(Line::default());
out.push_span("|".into(), /*destination*/ None);
for cell in row {
out.push_span(" ".into(), /*destination*/ None);
for (index, line) in cell.lines.iter().enumerate() {
if index > 0 {
out.push_span(" ".into(), /*destination*/ None);
}
let text = line
.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let mut column = 0usize;
let mut current_destination = None;
let mut current_text = String::new();
let flush = |out: &mut HyperlinkLine,
current_text: &mut String,
destination: Option<&str>| {
if !current_text.is_empty() {
out.push_span(Span::raw(std::mem::take(current_text)), destination);
}
};
for ch in text.chars() {
let destination = line
.hyperlinks
.iter()
.find(|link| link.columns.contains(&column))
.map(|link| link.destination.as_str());
if destination != current_destination {
flush(&mut out, &mut current_text, current_destination);
current_destination = destination;
}
if ch == '|' {
current_text.push_str("\\|");
} else {
current_text.push(ch);
}
column += UnicodeWidthChar::width(ch).unwrap_or(/*default*/ 0);
}
flush(&mut out, &mut current_text, current_destination);
}
out.push_span(" |".into(), /*destination*/ None);
}
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<HyperlinkLine> {
if cell.lines.is_empty() {
return vec![HyperlinkLine::new(Line::default())];
}
let mut wrapped = Vec::new();
for source_line in &cell.lines {
let rendered =
word_wrap_line(&source_line.line, RtOptions::new(width.max(/*other*/ 1)))
.into_iter()
.map(|line| line_to_static(&line))
.collect::<Vec<_>>();
if rendered.is_empty() {
wrapped.push(HyperlinkLine::new(Line::default()));
} else {
wrapped.extend(remap_wrapped_line(source_line, rendered));
};
}
if wrapped.is_empty() {
wrapped.push(HyperlinkLine::new(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(|line| Self::line_display_width(&line.line))
.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());
let mut destination = HyperlinkLine::new(Line::default());
destination.push_span(
Span::styled(link.destination.clone(), self.styles.link),
web_destination(&link.destination).as_deref(),
);
if let Some(table_state) = self.table_state.as_mut()
&& let Some(cell) = table_state.current_cell.as_mut()
{
cell.push_annotated(destination);
}
self.push_span_to_table_cell(")".into());
} else {
self.push_span(" (".into());
let mut destination = HyperlinkLine::new(Line::default());
destination.push_span(
Span::styled(link.destination.clone(), self.styles.link),
web_destination(&link.destination).as_deref(),
);
self.push_annotated(destination);
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(mut 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());
let wrapped = adaptive_wrap_line(&line.line, opts)
.into_iter()
.map(|wrapped| line_to_static(&wrapped))
.collect();
for wrapped in remap_wrapped_line(&line, wrapped) {
self.push_output_line(wrapped.style(style));
}
} else {
let mut spans = self.current_initial_indent.clone();
let shift = spans.iter().map(|span| span.content.width()).sum::<usize>();
spans.append(&mut line.line.spans);
for hyperlink in &mut line.hyperlinks {
hyperlink.columns =
hyperlink.columns.start + shift..hyperlink.columns.end + shift;
}
line.line = Line::from_iter(spans);
self.push_output_line(line.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 separators.
/// Passing them through `word_wrap_line` would break the layout at
/// arbitrary positions. This method prepends the indent/blockquote prefix
/// and pushes directly to `self.text`.
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, mut line: HyperlinkLine, 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.line.style)
} else {
line.line.style
};
let mut spans = self.prefix_spans(pending_marker_line);
let shift = spans.iter().map(|span| span.content.width()).sum::<usize>();
spans.append(&mut line.line.spans);
for hyperlink in &mut line.hyperlinks {
hyperlink.columns = hyperlink.columns.start + shift..hyperlink.columns.end + shift;
}
line.line = Line::from(spans);
self.push_output_line(line.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(HyperlinkLine::new(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_hyperlink_line(&mut self, line: HyperlinkLine) {
let hyperlinks = line.hyperlinks;
self.push_line(line.line);
if let Some(current) = self.current_line_content.as_mut() {
current.hyperlinks = hyperlinks;
}
}
fn push_span(&mut self, span: Span<'static>) {
if let Some(line) = self.current_line_content.as_mut() {
line.line.push_span(span);
} else {
self.push_line(Line::from(vec![span]));
}
}
fn push_annotated(&mut self, mut appended: HyperlinkLine) {
if self.current_line_content.is_none() {
self.push_line(Line::default());
}
if let Some(line) = self.current_line_content.as_mut() {
let shift = line.width();
line.line.spans.append(&mut appended.line.spans);
line.hyperlinks
.extend(appended.hyperlinks.into_iter().map(|mut link| {
link.columns = link.columns.start + shift..link.columns.end + shift;
link
}));
}
}
fn push_text_spans(&mut self, text: &str, style: Style) {
let span = Span::styled(text.to_string(), style);
let destination = self
.link
.as_ref()
.and_then(|link| web_destination(&link.destination));
let annotated = if let Some(destination) = destination {
let mut annotated = HyperlinkLine::new(Line::default());
annotated.push_span(span, Some(&destination));
annotated
} else if self.link.is_some() || self.in_code_block {
HyperlinkLine::new(Line::from(span))
} else {
annotate_web_urls_in_line(Line::from(span))
};
self.push_annotated(annotated);
}
fn push_blank_line(&mut self) {
self.flush_current_line();
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
self.push_output_line(HyperlinkLine::new(Line::default()));
} else {
self.push_line(Line::default());
self.flush_current_line();
}
}
fn push_output_line(&mut self, line: HyperlinkLine) {
self.text.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.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) -> Compact
// 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::Compact);
assert_eq!(metrics[1].kind, TableColumnKind::Narrative);
}
#[test]
fn column_classification_token_heavy_by_url_like_tokens() {
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_eq!(metrics[0].kind, TableColumnKind::TokenHeavy);
}
#[test]
fn column_classification_token_heavy_for_local_path_lists() {
let header = vec![make_cell("Files")];
let rows = vec![
vec![make_cell(
"codex-rs/core/src/next_prompt_suggestion.rs:1, codex-rs/core/src/next_prompt_suggestion_tests.rs:1",
)],
vec![make_cell(
"codex-rs/core/src/context/next_prompt_suggestion.rs:1, codex-rs/core/src/context/contextual_user_message_tests.rs:1",
)],
];
let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 1);
assert_eq!(metrics[0].kind, TableColumnKind::TokenHeavy);
}
#[test]
fn column_classification_compact_all_short() {
// Both columns short tokens -> both Compact
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::Compact);
assert_eq!(metrics[1].kind, TableColumnKind::Compact);
}
#[test]
fn preferred_floor_narrative_retains_readable_width() {
let m = TableColumnMetrics {
max_width: 40,
header_token_width: 15,
body_token_width: 8,
kind: TableColumnKind::Narrative,
};
assert_eq!(W::preferred_column_floor(&m, /*min_column_width*/ 3), 16);
let m2 = TableColumnMetrics {
max_width: 12,
header_token_width: 6,
body_token_width: 8,
kind: TableColumnKind::Narrative,
};
assert_eq!(W::preferred_column_floor(&m2, /*min_column_width*/ 3), 12);
}
#[test]
fn preferred_floor_token_heavy_retains_readable_width() {
let m = TableColumnMetrics {
max_width: 80,
header_token_width: 5,
body_token_width: 60,
kind: TableColumnKind::TokenHeavy,
};
assert_eq!(W::preferred_column_floor(&m, /*min_column_width*/ 3), 16);
}
#[test]
fn preferred_floor_compact_uses_body_token() {
// Compact: max(header_token_width, body_token_width.min(16))
let m = TableColumnMetrics {
max_width: 30,
header_token_width: 5,
body_token_width: 12,
kind: TableColumnKind::Compact,
};
// 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,
kind: TableColumnKind::Compact,
};
// 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_token_heavy_then_narrative() {
let widths = [20usize, 20, 20];
let floors = [8usize, 8, 8];
let metrics = [
TableColumnMetrics {
max_width: 30,
header_token_width: 8,
body_token_width: 6,
kind: TableColumnKind::Narrative,
},
TableColumnMetrics {
max_width: 30,
header_token_width: 8,
body_token_width: 28,
kind: TableColumnKind::TokenHeavy,
},
TableColumnMetrics {
max_width: 30,
header_token_width: 8,
body_token_width: 6,
kind: TableColumnKind::Compact,
},
];
let idx = W::next_column_to_shrink(&widths, &floors, &metrics);
assert_eq!(idx, Some(1), "token-heavy column should shrink first");
let widths = [20usize, 8, 20];
let idx = W::next_column_to_shrink(&widths, &floors, &metrics);
assert_eq!(
idx,
Some(0),
"narrative column should shrink before compact"
);
}
// ===== 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)));
}
#[test]
fn annotates_explicit_web_link_label_and_visible_destination() {
let lines = render_markdown_lines_with_width_and_cwd(
"See [docs](https://example.com/reference).",
/*width*/ Some(80),
/*cwd*/ None,
);
let links = lines
.iter()
.flat_map(|line| line.hyperlinks.iter())
.collect::<Vec<_>>();
assert_eq!(links.len(), 2);
assert!(
links
.iter()
.all(|link| link.destination == "https://example.com/reference")
);
}
#[test]
fn wrapped_table_url_fragments_keep_complete_web_destination() {
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
let markdown = format!("| Item | URL |\n| --- | --- |\n| report | {destination} |\n");
let lines = render_markdown_lines_with_width_and_cwd(
&markdown,
/*width*/ Some(32),
/*cwd*/ None,
);
let linked_rows = lines
.iter()
.filter(|line| !line.hyperlinks.is_empty())
.collect::<Vec<_>>();
assert!(
linked_rows.len() > 1,
"expected a URL wrapped across table rows"
);
assert!(linked_rows.iter().all(|line| {
line.hyperlinks
.iter()
.all(|link| link.destination == destination)
}));
}
#[test]
fn key_value_table_keeps_web_annotations() {
let destination = "https://example.com/a/very/long/path";
let markdown = format!(
"| c1 | c2 | c3 | c4 | c5 | c6 |\n| --- | --- | --- | --- | --- | --- |\n| {destination} | 2 | 3 | 4 | 5 | 6 |\n"
);
let lines = render_markdown_lines_with_width_and_cwd(
&markdown,
/*width*/ Some(20),
/*cwd*/ None,
);
let destinations = lines
.iter()
.flat_map(|line| line.hyperlinks.iter().map(|link| link.destination.as_str()))
.collect::<Vec<_>>();
assert!(!destinations.is_empty());
assert!(destinations.iter().all(|link| *link == destination));
}
#[test]
fn does_not_annotate_code_or_non_web_markdown_links() {
let markdown = "`https://example.com/inline`\n\n```text\nhttps://example.com/block\n```\n\n[mail](mailto:test@example.com)\n\n[https://example.com/label](mailto:test@example.com)\n\n| Target |\n| --- |\n| [https://example.com/table-label](mailto:test@example.com) |";
let lines = render_markdown_lines_with_width_and_cwd(
markdown,
/*width*/ Some(80),
/*cwd*/ None,
);
assert!(lines.iter().all(|line| line.hyperlinks.is_empty()));
}
#[test]
fn pipe_table_fallback_keeps_web_annotations() {
let destination = "https://example.com/a/long/path";
let target = "https://target.example/path";
let code_url = "https://code.example/not-a-link";
let markdown = format!(
"| URL | Code | Label |\n| --- | --- | --- |\n| {destination} | `{code_url}` | [https://shown.example]({target}) |\n"
);
let lines = render_markdown_lines_with_width_and_cwd(
&markdown,
/*width*/ Some(5),
/*cwd*/ None,
);
let destinations = lines
.iter()
.flat_map(|line| line.hyperlinks.iter().map(|link| link.destination.as_str()))
.collect::<Vec<_>>();
assert!(destinations.contains(&destination));
assert!(destinations.contains(&target));
assert!(!destinations.contains(&code_url));
assert!(!destinations.contains(&"https://shown.example"));
}
}