use crate::tui_internal::markdown_text_merge::DecodedTextMerge;
use crate::tui_internal::render::highlight::foreground_style_for_scopes;
use crate::tui_internal::render::highlight::highlight_code_to_lines;
use crate::tui_internal::render::line_utils::line_to_static;
use crate::tui_internal::style::table_separator_style;
use crate::tui_internal::terminal_hyperlinks::HyperlinkLine;
use crate::tui_internal::terminal_hyperlinks::annotate_web_urls_in_line;
use crate::tui_internal::terminal_hyperlinks::remap_wrapped_line;
use crate::tui_internal::terminal_hyperlinks::visible_lines;
use crate::tui_internal::terminal_hyperlinks::web_destination;
use crate::tui_internal::wrapping::RtOptions;
use crate::tui_internal::wrapping::adaptive_wrap_line;
use crate::tui_internal::wrapping::word_wrap_line;
use lemurclaw_core::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 streaming;
mod table_key_value;
pub(crate) use streaming::StreamingMarkdownRender;
pub(crate) use streaming::render_streaming_markdown_lines_with_width_and_cwd;
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,
}
}
}
#[derive(Clone, Debug, Default)]
struct TableCell {
lines: Vec<HyperlinkLine>,
}
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
}
}
#[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,
}
}
}
struct RenderedTableLines {
table_lines: Vec<HyperlinkLine>,
table_lines_prewrapped: bool,
spillover_lines: Vec<HyperlinkLine>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TableColumnKind {
Narrative,
TokenHeavy,
Compact,
}
#[derive(Clone, Debug)]
struct TableColumnMetrics {
max_width: usize,
header_token_width: usize,
body_token_width: usize,
kind: TableColumnKind,
}
pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, None)
}
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())
}
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> {
render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
input,
width,
cwd,
&never_hide_link_destination,
)
}
fn never_hide_link_destination(_: &str) -> bool {
false
}
pub(crate) fn render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
is_hidden_link_destination: &dyn Fn(&str) -> bool,
) -> 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, is_hidden_link_destination);
w.run();
w.text
}
#[derive(Clone, Debug)]
struct LinkState {
destination: String,
show_destination: bool,
style_label: bool,
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}"),
},
);
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}"),
});
struct Writer<'a, 'policy, 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>,
is_hidden_link_destination: &'policy dyn Fn(&str) -> bool,
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, 'policy, I> Writer<'a, 'policy, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
fn new(
input: &'a str,
iter: I,
wrap_width: Option<usize>,
cwd: Option<&Path>,
is_hidden_link_destination: &'policy dyn Fn(&str) -> bool,
) -> 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),
is_hidden_link_destination,
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, false),
Event::InlineHtml(html) => self.html(html, true),
Event::FootnoteReference(_) => {}
Event::TaskListMarker(_) => {}
}
}
fn prepare_for_event(&mut self, event: &Event<'a>) {
if !self.pending_local_link_soft_break {
return;
}
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("> ")],
None,
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;
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,
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;
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()],
None,
false,
));
self.needs_newline = true;
}
fn end_codeblock(&mut self) {
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));
}
}
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);
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(&metrics, 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());
}
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)
})
}
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)
})
}
fn compute_column_widths(
metrics: &[TableColumnMetrics],
available_width: Option<usize>,
) -> Option<Vec<usize>> {
let min_column_width = 3usize;
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 = metrics.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 floor_total: usize = floors.iter().sum();
if floor_total > max_width {
let minimums = vec![min_column_width; floors.len()];
Self::shrink_columns(&mut floors, &minimums, metrics, floor_total - max_width);
}
let total_width: usize = widths.iter().sum();
if total_width > max_width {
let remaining =
Self::shrink_columns(&mut widths, &floors, metrics, total_width - max_width);
if remaining > 0 {
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();
let mut word_count = 0usize;
for token in plain.split_whitespace() {
let token_width = token.width();
body_token_width = body_token_width.max(token_width);
long_body_token_count += usize::from(token_width >= 20);
word_count += 1;
}
if word_count > 0 {
body_token_count += word_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
}
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)
}
fn shrink_columns(
widths: &mut [usize],
floors: &[usize],
metrics: &[TableColumnMetrics],
mut amount: usize,
) -> usize {
for kind in [
TableColumnKind::TokenHeavy,
TableColumnKind::Narrative,
TableColumnKind::Compact,
] {
let slack_total = widths
.iter()
.enumerate()
.filter(|(idx, _)| metrics[*idx].kind == kind)
.map(|(idx, width)| width.saturating_sub(floors[idx]))
.sum::<usize>();
let to_remove = amount.min(slack_total);
if to_remove == 0 {
continue;
}
let mut low = 0usize;
let mut high = widths
.iter()
.enumerate()
.filter(|(idx, _)| metrics[*idx].kind == kind)
.map(|(idx, width)| width.saturating_sub(floors[idx]))
.max()
.unwrap_or( 0);
while low < high {
let cap = low + (high - low) / 2;
let removed = widths
.iter()
.enumerate()
.filter(|(idx, _)| metrics[*idx].kind == kind)
.map(|(idx, width)| width.saturating_sub(floors[idx]).saturating_sub(cap))
.sum::<usize>();
if removed > to_remove {
low = cap + 1;
} else {
high = cap;
}
}
let cap = low;
let mut removed = 0usize;
for (idx, width) in widths.iter_mut().enumerate() {
if metrics[idx].kind != kind {
continue;
}
let reduction = width.saturating_sub(floors[idx]).saturating_sub(cap);
*width -= reduction;
removed += reduction;
}
let mut remainder = to_remove - removed;
for (idx, width) in widths.iter_mut().enumerate() {
if remainder == 0 {
break;
}
if metrics[idx].kind == kind && width.saturating_sub(floors[idx]) == cap {
*width -= 1;
remainder -= 1;
}
}
amount -= to_remove;
if amount == 0 {
break;
}
}
amount
}
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
}
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(), None);
for cell in row {
out.push_span(" ".into(), None);
for (index, line) in cell.lines.iter().enumerate() {
if index > 0 {
out.push_span(" ".into(), 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( 0);
}
flush(&mut out, &mut current_text, current_destination);
}
out.push_span(" |".into(), 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
}
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( 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
}
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;
}
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;
}
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"))
}
#[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 style_label = (self.is_hidden_link_destination)(&dest_url);
if style_label {
self.push_inline_style(self.styles.link);
}
let show_destination = !style_label && should_render_link_destination(&dest_url);
self.link = Some(LinkState {
show_destination,
style_label,
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.style_label {
self.pop_inline_style();
}
if link.show_destination {
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 {
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;
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;
}
}
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( 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'\\')
)
}
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)
}
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;
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))
}
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))
}
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())
}
fn expand_local_link_path(path_text: &str) -> String {
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)
}
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()));
}
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))
}
fn normalize_local_link_path_text(path_text: &str) -> String {
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()
)
}
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('/')
}
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;
}
if cwd_text == "/" || cwd_text == "//" {
return path_text.strip_prefix('/');
}
path_text
.strip_prefix(cwd_text)
.and_then(|rest| rest.strip_prefix('/'))
}
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 {
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() {
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() {
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);
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),
None,
&never_hide_link_destination,
);
let wrapped = writer.wrap_cell(&cell, 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 W<'a> = Writer<'a, 'a, std::iter::Empty<(Event<'a>, Range<usize>)>>;
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,
}
}
#[test]
fn column_classification_narrative_by_word_count() {
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, 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, 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, 1);
assert_eq!(metrics[0].kind, TableColumnKind::TokenHeavy);
}
#[test]
fn column_classification_compact_all_short() {
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, 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, 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, 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, 3), 16);
}
#[test]
fn preferred_floor_compact_uses_body_token() {
let m = TableColumnMetrics {
max_width: 30,
header_token_width: 5,
body_token_width: 12,
kind: TableColumnKind::Compact,
};
assert_eq!(W::preferred_column_floor(&m, 3), 12);
let m2 = TableColumnMetrics {
max_width: 30,
header_token_width: 5,
body_token_width: 20,
kind: TableColumnKind::Compact,
};
assert_eq!(W::preferred_column_floor(&m2, 3), 16);
}
#[test]
fn bulk_column_shrink_matches_one_cell_at_a_time() {
fn priority(kind: TableColumnKind) -> usize {
match kind {
TableColumnKind::TokenHeavy => 0,
TableColumnKind::Narrative => 1,
TableColumnKind::Compact => 2,
}
}
fn shrink_one_at_a_time(
widths: &mut [usize],
floors: &[usize],
metrics: &[TableColumnMetrics],
mut amount: usize,
) -> usize {
while amount > 0 {
let Some(idx) = widths
.iter()
.enumerate()
.filter(|(idx, width)| **width > floors[*idx])
.min_by_key(|(idx, width)| {
(
priority(metrics[*idx].kind),
usize::MAX - width.saturating_sub(floors[*idx]),
)
})
.map(|(idx, _)| idx)
else {
break;
};
widths[idx] -= 1;
amount -= 1;
}
amount
}
for case in 0..64usize {
let metrics = (0..5)
.map(|idx| TableColumnMetrics {
max_width: 100,
header_token_width: 8,
body_token_width: 8,
kind: match (case + idx) % 3 {
0 => TableColumnKind::TokenHeavy,
1 => TableColumnKind::Narrative,
_ => TableColumnKind::Compact,
},
})
.collect::<Vec<_>>();
let floors = (0..5)
.map(|idx| 3 + (case * (idx + 1) + idx) % 14)
.collect::<Vec<_>>();
let initial = floors
.iter()
.enumerate()
.map(|(idx, floor)| floor + (case * (idx + 3) + idx * 7) % 41)
.collect::<Vec<_>>();
let slack = initial
.iter()
.zip(&floors)
.map(|(width, floor)| width - floor)
.sum::<usize>();
for amount in 0..=slack + 1 {
let mut expected = initial.clone();
let expected_remaining =
shrink_one_at_a_time(&mut expected, &floors, &metrics, amount);
let mut actual = initial.clone();
let actual_remaining = W::shrink_columns(&mut actual, &floors, &metrics, amount);
assert_eq!((actual, actual_remaining), (expected, expected_remaining));
}
}
}
#[test]
fn column_widths_bulk_shrink_large_token_heavy_column() {
let metrics = [
TableColumnMetrics {
max_width: 1_000_000,
header_token_width: 4,
body_token_width: 1_000_000,
kind: TableColumnKind::TokenHeavy,
},
TableColumnMetrics {
max_width: 8,
header_token_width: 5,
body_token_width: 8,
kind: TableColumnKind::Compact,
},
];
assert_eq!(
W::compute_column_widths(&metrics, Some(24)),
Some(vec![16, 8])
);
}
#[test]
fn spillover_detects_single_cell_row() {
let row = make_body_row(
vec![make_cell("some trailing text")],
false,
);
assert!(W::is_spillover_row(&row, None));
}
#[test]
fn spillover_keeps_single_cell_row_with_table_pipe_syntax() {
let row = make_body_row(
vec![make_cell("some sparse value")],
true,
);
assert!(!W::is_spillover_row(&row, None));
}
#[test]
fn spillover_detects_html_content() {
let row = make_body_row(
vec![
make_cell("<div>content</div>"),
make_cell(""),
make_cell(""),
],
false,
);
assert!(W::is_spillover_row(&row, None));
}
#[test]
fn spillover_detects_label_followed_by_html() {
let row = make_body_row(
vec![make_cell("HTML block:"), make_cell(""), make_cell("")],
false,
);
let next = make_body_row(
vec![make_cell("<div>x</div>"), make_cell(""), make_cell("")],
false,
);
assert!(W::is_spillover_row(&row, Some(&next)));
}
#[test]
fn spillover_detects_trailing_html_label() {
let row = make_body_row(
vec![make_cell("HTML block:"), make_cell(""), make_cell("")],
false,
);
assert!(W::is_spillover_row(&row, None));
}
#[test]
fn spillover_keeps_normal_multi_cell_row() {
let row = make_body_row(
vec![make_cell("one"), make_cell("two"), make_cell("three")],
true,
);
assert!(!W::is_spillover_row(&row, None));
}
#[test]
fn spillover_keeps_label_when_next_is_not_html() {
let row = make_body_row(
vec![make_cell("Status:"), make_cell(""), make_cell("")],
true,
);
let next = make_body_row(
vec![make_cell("ok"), make_cell(""), make_cell("")],
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).",
Some(80),
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,
Some(32),
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,
Some(20),
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,
Some(80),
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,
Some(5),
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"));
}
}