mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## Why Incoming delegated messages were rendered as plain text, leaving Markdown formatting visible and links without hyperlink metadata. ## What changed Use a dedicated history cell to render the message body as Markdown while keeping the source task attribution literal. Preserve hyperlinks and wrapping, and apply the appropriate list spacing for scrollback and the redrawable transcript. Use the same rendering for live messages, resumed history, and transcript projection. ## Testing Extend replay coverage to check matching live and replayed output, transcript rendering, hyperlink destinations, and shared source text across wrapped rows. Add a snapshot covering list spacing in scrollback and the redrawable transcript. GitOrigin-RevId: e9864c7721fa1afa2d3662ab9f57122ab696a51e
3285 lines
121 KiB
Rust
3285 lines
121 KiB
Rust
//! Low-level markdown event renderer for the TUI transcript.
|
|
//!
|
|
//! This module consumes `pulldown-cmark` events and emits styled `ratatui`
|
|
//! lines, including table layout, task-list checkboxes, Mermaid previews, wrapping, and local file-link
|
|
//! display. It is the final rendering stage used by higher-level helpers in
|
|
//! `markdown.rs`. Launch-time `tui.rendering` preferences preserve disabled features as source.
|
|
//!
|
|
//! Local file-link parsing and display policy live in [`local_links`].
|
|
//! List spacing stays a renderer policy: compact while streaming in an owned viewport, uniform
|
|
//! after source-backed consolidation, and historical spacing for native scrollback.
|
|
//!
|
|
//! ## 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.
|
|
//!
|
|
//! Markdown prose uses foregrounds from the active syntax theme's markup scopes,
|
|
//! retaining its usual modifiers and falling back when a scope has no foreground.
|
|
//! Inline code and local file paths share the active syntax theme's raw-markup foreground.
|
|
|
|
use crate::markdown_text_merge::DecodedTextMerge;
|
|
use crate::render::highlight::current_syntax_theme;
|
|
use crate::render::highlight::foreground_style_for_scopes;
|
|
use crate::render::highlight::foreground_style_for_scopes_with_theme;
|
|
use crate::render::highlight::highlight_code_to_lines;
|
|
use crate::render::line_utils::line_to_static;
|
|
use crate::style::accent_color;
|
|
use crate::style::table_separator_style;
|
|
use crate::terminal_hyperlinks::HyperlinkLine;
|
|
use crate::terminal_hyperlinks::remap_wrapped_line;
|
|
use crate::terminal_hyperlinks::visible_lines;
|
|
use crate::terminal_hyperlinks::web_destination;
|
|
use crate::width::char_width;
|
|
use crate::width::display_width;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::word_wrap_line;
|
|
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::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::text::Text;
|
|
use std::ops::Range;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
mod inline_directives;
|
|
mod list_spacing;
|
|
mod local_links;
|
|
mod math;
|
|
mod mermaid;
|
|
pub(crate) mod preferences;
|
|
mod source_tables;
|
|
mod streaming;
|
|
pub(crate) use streaming::render_with_copy_sources;
|
|
mod table_key_value;
|
|
mod task_lists;
|
|
mod web_links;
|
|
|
|
use inline_directives::InlineDirectives;
|
|
pub(crate) use inline_directives::followup_labels;
|
|
pub(crate) use list_spacing::ListSpacing;
|
|
use list_spacing::UniformList;
|
|
use local_links::is_local_path_like_link;
|
|
use local_links::render_local_link_target;
|
|
pub(crate) use streaming::StreamingMarkdownRender;
|
|
pub(crate) use streaming::render_streaming_markdown_lines_with_width_and_cwd;
|
|
#[cfg(test)]
|
|
#[path = "markdown_render/list_spacing_tests.rs"]
|
|
mod list_spacing_tests;
|
|
#[cfg(test)]
|
|
#[path = "markdown_render/task_list_tests.rs"]
|
|
mod task_list_tests;
|
|
pub(crate) use web_links::hide_web_link_destination;
|
|
use web_links::style_bare_web_urls;
|
|
|
|
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::for_theme(&crate::render::highlight::current_syntax_theme())
|
|
}
|
|
}
|
|
|
|
impl MarkdownStyles {
|
|
fn for_theme(theme: &syntect::highlighting::Theme) -> Self {
|
|
let foreground = |scopes: &[&str]| {
|
|
let mut style =
|
|
foreground_style_for_scopes_with_theme(theme, scopes).unwrap_or_default();
|
|
style.fg = style.fg.map(|foreground| {
|
|
crate::style::readable_color_on(foreground, /*background*/ None)
|
|
});
|
|
style
|
|
};
|
|
Self {
|
|
h1: foreground(&["markup.heading.1.markdown", "markup.heading.markdown"])
|
|
.bold()
|
|
.underlined(),
|
|
h2: foreground(&["markup.heading.2.markdown", "markup.heading.markdown"]).bold(),
|
|
h3: foreground(&["markup.heading.3.markdown", "markup.heading.markdown"])
|
|
.bold()
|
|
.italic(),
|
|
h4: foreground(&["markup.heading.4.markdown", "markup.heading.markdown"]).italic(),
|
|
h5: foreground(&["markup.heading.5.markdown", "markup.heading.markdown"]).italic(),
|
|
h6: foreground(&["markup.heading.6.markdown", "markup.heading.markdown"]).italic(),
|
|
code: foreground_style_for_scopes_with_theme(
|
|
theme,
|
|
&[
|
|
"markup.inline.raw.string.markdown",
|
|
"markup.raw.inline.markdown",
|
|
],
|
|
)
|
|
.unwrap_or_else(|| Style::new().fg(accent_color())),
|
|
emphasis: foreground(&["markup.italic.markdown"]).italic(),
|
|
strong: foreground(&["markup.bold.markdown"]).bold(),
|
|
strikethrough: Style::new().crossed_out(),
|
|
ordered_list_marker: Style::new()
|
|
.light_blue()
|
|
.patch(foreground(&["markup.list.numbered.markdown"])),
|
|
unordered_list_marker: foreground(&[
|
|
"markup.list.unnumbered.markdown",
|
|
"markup.list.bullet.markdown",
|
|
]),
|
|
link: Style::new()
|
|
.fg(accent_color())
|
|
.patch(foreground(&["markup.underline.link.markdown"]))
|
|
.underlined(),
|
|
blockquote: Style::new()
|
|
.green()
|
|
.patch(foreground(&["markup.quote.markdown"])),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct IndentContext {
|
|
prefix: Vec<Span<'static>>,
|
|
marker: Option<Vec<Span<'static>>>,
|
|
copy_marker: String,
|
|
is_list: bool,
|
|
}
|
|
|
|
impl IndentContext {
|
|
fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, is_list: bool) -> Self {
|
|
// Keep the canonical marker separate from depth-dependent display padding.
|
|
let copy_marker = marker
|
|
.iter()
|
|
.flatten()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
.trim_start()
|
|
.replace('•', "-");
|
|
Self {
|
|
prefix,
|
|
marker,
|
|
copy_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>,
|
|
copies: Vec<crate::markdown_copy::CopyLine>,
|
|
}
|
|
|
|
// 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()));
|
|
self.copies.push(Default::default());
|
|
}
|
|
}
|
|
|
|
fn push_annotated(
|
|
&mut self,
|
|
mut appended: HyperlinkLine,
|
|
inline: &[crate::markdown_copy::Inline],
|
|
) {
|
|
self.ensure_line();
|
|
if let Some(copy) = self.copies.last_mut() {
|
|
copy.push(
|
|
appended
|
|
.line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.len())
|
|
.sum(),
|
|
inline,
|
|
);
|
|
}
|
|
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()));
|
|
self.copies.push(Default::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,
|
|
)))
|
|
}
|
|
|
|
/// Keep destinations visible by default, including for callers that discard hyperlink metadata.
|
|
/// Semantic output paths supply their hidden-destination policy explicitly.
|
|
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> {
|
|
render_markdown_lines_with_list_spacing(
|
|
input,
|
|
width,
|
|
cwd,
|
|
is_hidden_link_destination,
|
|
ListSpacing::AfterMultiline,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn render_markdown_lines_with_list_spacing(
|
|
input: &str,
|
|
width: Option<usize>,
|
|
cwd: Option<&Path>,
|
|
is_hidden_link_destination: &dyn Fn(&str) -> bool,
|
|
list_spacing: ListSpacing,
|
|
) -> Vec<HyperlinkLine> {
|
|
let mut options = Options::empty();
|
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
|
options.insert(Options::ENABLE_TABLES);
|
|
options.set(Options::ENABLE_TASKLISTS, preferences::current().lists);
|
|
let math = math::MathMarkdown::new(input, options, width);
|
|
let parser = DecodedTextMerge::new(source_tables::preserve(
|
|
input,
|
|
math.events(Parser::new_ext(&math.markdown, options).into_offset_iter()),
|
|
));
|
|
let mut w = Writer::new(input, width, cwd, is_hidden_link_destination);
|
|
w.list_spacing = list_spacing;
|
|
// Drop the consumed parser before the rendering state, including on unwind.
|
|
let mut parser = parser;
|
|
w.run(&mut parser);
|
|
w.text
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct LinkState {
|
|
destination: String,
|
|
show_destination: bool,
|
|
style_label: bool,
|
|
has_visible_label: bool,
|
|
/// Pre-rendered display text for local file links.
|
|
///
|
|
/// When this is present, label spans are buffered until the link closes so path-like labels
|
|
/// can collapse to this canonical target without losing descriptive labels.
|
|
local_target_display: Option<String>,
|
|
local_label_spans: Vec<Span<'static>>,
|
|
local_label_inline: Vec<Vec<crate::markdown_copy::Inline>>,
|
|
}
|
|
|
|
fn should_render_link_destination(dest_url: &str) -> bool {
|
|
!is_local_path_like_link(dest_url)
|
|
}
|
|
|
|
/// 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.
|
|
/// Layout state is independent of the parser so rendering routines are compiled
|
|
/// once; event traversal remains statically dispatched over each iterator.
|
|
struct Writer<'a, 'policy> {
|
|
input: &'a str,
|
|
text: Vec<HyperlinkLine>,
|
|
styles: MarkdownStyles,
|
|
inline_styles: Vec<Style>,
|
|
copy_inline: Vec<crate::markdown_copy::Inline>,
|
|
copy_line: crate::markdown_copy::CopyLine,
|
|
quote_sources: Vec<std::sync::Arc<str>>,
|
|
code_sources: Vec<crate::markdown_copy::SourceBlock>,
|
|
transformed_code_source: Option<std::sync::Arc<str>>,
|
|
indent_stack: Vec<IndentContext>,
|
|
list_indices: Vec<Option<u64>>,
|
|
list_needs_blank_before_next_item: Vec<bool>,
|
|
list_item_start_line_counts: Vec<usize>,
|
|
list_spacing: ListSpacing,
|
|
uniform_lists: Vec<UniformList>,
|
|
link: Option<LinkState>,
|
|
needs_newline: bool,
|
|
pending_marker_line: bool,
|
|
last_task_marker_end: usize,
|
|
in_paragraph: bool,
|
|
in_code_block: bool,
|
|
code_block_lang: Option<String>,
|
|
code_block_buffer: String,
|
|
code_block_content_end: usize,
|
|
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> Writer<'a, 'policy> {
|
|
fn new(
|
|
input: &'a str,
|
|
wrap_width: Option<usize>,
|
|
cwd: Option<&Path>,
|
|
is_hidden_link_destination: &'policy dyn Fn(&str) -> bool,
|
|
) -> Self {
|
|
Self {
|
|
input,
|
|
text: Vec::new(),
|
|
styles: MarkdownStyles::default(),
|
|
inline_styles: Vec::new(),
|
|
copy_inline: Vec::new(),
|
|
copy_line: Default::default(),
|
|
quote_sources: Vec::new(),
|
|
code_sources: Vec::new(),
|
|
transformed_code_source: None,
|
|
indent_stack: Vec::new(),
|
|
list_indices: Vec::new(),
|
|
list_needs_blank_before_next_item: Vec::new(),
|
|
list_item_start_line_counts: Vec::new(),
|
|
list_spacing: ListSpacing::default(),
|
|
uniform_lists: Vec::new(),
|
|
link: None,
|
|
needs_newline: false,
|
|
pending_marker_line: false,
|
|
last_task_marker_end: 0,
|
|
in_paragraph: false,
|
|
in_code_block: false,
|
|
code_block_lang: None,
|
|
code_block_buffer: String::new(),
|
|
code_block_content_end: 0,
|
|
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<I>(&mut self, iter: &mut I)
|
|
where
|
|
I: Iterator<Item = (Event<'a>, Range<usize>)>,
|
|
{
|
|
while let Some((ev, range)) = iter.next() {
|
|
self.handle_event(ev, range, iter);
|
|
}
|
|
self.flush_current_line();
|
|
}
|
|
|
|
fn handle_event<I>(&mut self, event: Event<'a>, range: Range<usize>, iter: &mut I)
|
|
where
|
|
I: Iterator<Item = (Event<'a>, Range<usize>)>,
|
|
{
|
|
let source = self
|
|
.code_sources
|
|
.iter()
|
|
.find(|source| source.range.contains(&range.start))
|
|
.map(|source| std::sync::Arc::clone(&source.content));
|
|
let same_source = match (&source, &self.transformed_code_source) {
|
|
(Some(source), Some(current)) => std::sync::Arc::ptr_eq(source, current),
|
|
(None, None) => true,
|
|
_ => false,
|
|
};
|
|
if !same_source {
|
|
self.flush_current_line();
|
|
self.transformed_code_source = source;
|
|
}
|
|
self.prepare_for_event(&event);
|
|
let copy_inline = crate::markdown_copy::Inline::from_event(&event);
|
|
if let Some(inline) = ©_inline {
|
|
self.copy_inline.push(inline.clone());
|
|
}
|
|
let close_inline = matches!(
|
|
event,
|
|
Event::Code(_)
|
|
| Event::End(
|
|
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link
|
|
)
|
|
);
|
|
match event {
|
|
Event::Start(tag) => self.start_tag(tag, range, iter),
|
|
Event::End(tag) => self.end_tag(tag, range),
|
|
Event::Text(text) => {
|
|
if self.in_code_block {
|
|
self.code_block_content_end = range.end;
|
|
}
|
|
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.copy_line.rule = true;
|
|
self.needs_newline = true;
|
|
}
|
|
Event::Html(html) => self.html(html, /*inline*/ false),
|
|
Event::InlineHtml(html) => self.html(html, /*inline*/ true),
|
|
Event::FootnoteReference(_) => {}
|
|
Event::TaskListMarker(checked) => {
|
|
// The parser can emit a recovered empty item's marker in a later paragraph.
|
|
if range.start >= self.last_task_marker_end {
|
|
self.last_task_marker_end = range.end;
|
|
self.task_list_marker(checked);
|
|
if self.current_line_content.is_none() {
|
|
self.push_line(Line::default());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if close_inline {
|
|
self.copy_inline.pop();
|
|
}
|
|
}
|
|
|
|
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<I>(&mut self, tag: Tag<'a>, range: Range<usize>, iter: &mut I)
|
|
where
|
|
I: Iterator<Item = (Event<'a>, Range<usize>)>,
|
|
{
|
|
match tag {
|
|
Tag::Paragraph => self.start_paragraph(),
|
|
Tag::Heading { level, .. } => self.start_heading(level),
|
|
Tag::BlockQuote => {
|
|
self.start_blockquote();
|
|
let source = self.quote_sources.first().cloned().unwrap_or_else(|| {
|
|
crate::markdown_copy::quote_content(&self.input[range]).into()
|
|
});
|
|
self.quote_sources.push(source);
|
|
}
|
|
Tag::CodeBlock(kind) => {
|
|
self.code_block_content_end = range.end;
|
|
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) => {
|
|
if self.pending_marker_line
|
|
&& self.input[..range.start]
|
|
.rsplit('\n')
|
|
.next()
|
|
.is_some_and(|line| line.trim_matches(['>', ' ', '\t']).is_empty())
|
|
{
|
|
self.push_line(Line::default());
|
|
}
|
|
self.start_list(start);
|
|
}
|
|
Tag::Item => {
|
|
self.start_item();
|
|
if let Some((next, next_range)) = iter.next() {
|
|
// Recover markers consumed without an event before block content or empty items.
|
|
let content_start = if matches!(next, Event::End(TagEnd::Item)) {
|
|
range.end
|
|
} else {
|
|
next_range.start
|
|
};
|
|
if preferences::current().lists
|
|
&& let Some(prefix) = self.input.get(range.start..content_start)
|
|
&& let Some(prefix) = prefix.lines().next()
|
|
&& let Some((_, marker)) = prefix.trim().split_once(char::is_whitespace)
|
|
&& let Some(checked) = match marker.trim() {
|
|
"[ ]" => Some(false),
|
|
"[x]" | "[X]" => Some(true),
|
|
_ => None,
|
|
}
|
|
{
|
|
self.last_task_marker_end = content_start;
|
|
self.task_list_marker(checked);
|
|
if matches!(
|
|
next,
|
|
Event::Start(Tag::List(_) | Tag::HtmlBlock)
|
|
| Event::End(TagEnd::Item)
|
|
| Event::Rule
|
|
) {
|
|
self.push_line(Line::default());
|
|
}
|
|
}
|
|
self.handle_event(next, next_range, iter);
|
|
}
|
|
}
|
|
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, range: Range<usize>) {
|
|
match tag {
|
|
TagEnd::Paragraph => {
|
|
// At a streaming EOF, the parser omits a trailing hard-break event. Retain it
|
|
// before this line is committed and a later delta supplies the next line.
|
|
self.copy_line.hard_break |= self
|
|
.input
|
|
.get(..range.end)
|
|
.is_some_and(|prefix| prefix.ends_with(" \n") || prefix.ends_with(" \r\n"));
|
|
self.end_paragraph();
|
|
}
|
|
TagEnd::Heading(_) => self.end_heading(),
|
|
TagEnd::BlockQuote => {
|
|
self.flush_current_line();
|
|
self.end_blockquote();
|
|
self.quote_sources.pop();
|
|
}
|
|
TagEnd::CodeBlock => self.end_codeblock(range),
|
|
TagEnd::List(_) => self.end_list(),
|
|
TagEnd::Item => {
|
|
if self.pending_marker_line {
|
|
self.push_line(Line::default());
|
|
}
|
|
self.flush_current_line();
|
|
let start_line_count = self.list_item_start_line_counts.pop().unwrap_or_default();
|
|
if let Some(list) = self.uniform_lists.last_mut() {
|
|
let rows = &self.text[start_line_count..];
|
|
list.multiline |= rows.len() > 1
|
|
|| (rows.len() == 1
|
|
&& self.wrap_width.is_some_and(|width| rows[0].width() > width));
|
|
}
|
|
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));
|
|
let heading = content.len();
|
|
self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
|
|
self.copy_line.heading = heading;
|
|
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.collecting_local_link_label() {
|
|
let style = self.inline_styles.last().copied().unwrap_or_default();
|
|
for (index, line) in text.lines().enumerate() {
|
|
if index > 0 {
|
|
self.push_local_link_label_break();
|
|
}
|
|
self.push_local_link_label_span(Span::styled(line.to_string(), style));
|
|
}
|
|
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.collecting_local_link_label() {
|
|
self.push_local_link_label_span(Span::from(code.into_string()).style(self.styles.code));
|
|
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.collecting_local_link_label() {
|
|
let style = self.inline_styles.last().copied().unwrap_or_default();
|
|
for (index, line) in html.lines().enumerate() {
|
|
if index > 0 {
|
|
self.push_local_link_label_break();
|
|
}
|
|
self.push_local_link_label_span(Span::styled(line.to_string(), style));
|
|
}
|
|
if !inline {
|
|
self.push_local_link_label_break();
|
|
}
|
|
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.collecting_local_link_label() {
|
|
self.push_local_link_label_break();
|
|
return;
|
|
}
|
|
self.line_ends_with_local_link_target = false;
|
|
if self.in_table_cell() {
|
|
self.push_table_cell_hard_break();
|
|
return;
|
|
}
|
|
self.copy_line.hard_break = true;
|
|
self.push_line(Line::default());
|
|
}
|
|
|
|
fn soft_break(&mut self) {
|
|
if self.collecting_local_link_label() {
|
|
self.push_local_link_label_break();
|
|
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);
|
|
if self.list_spacing == ListSpacing::Uniform {
|
|
self.uniform_lists.push(UniformList::default());
|
|
}
|
|
}
|
|
|
|
fn end_list(&mut self) {
|
|
self.flush_current_line();
|
|
if let Some(list) = self.uniform_lists.pop() {
|
|
list.finish(&mut self.text);
|
|
}
|
|
self.list_indices.pop();
|
|
self.list_needs_blank_before_next_item.pop();
|
|
self.needs_newline = true;
|
|
}
|
|
|
|
fn start_item(&mut self) {
|
|
let after_multiline = self
|
|
.list_needs_blank_before_next_item
|
|
.last_mut()
|
|
.map(std::mem::take)
|
|
.unwrap_or(/*default*/ false);
|
|
self.flush_current_line();
|
|
let separate = match self.list_spacing {
|
|
ListSpacing::AfterMultiline => after_multiline,
|
|
ListSpacing::Compact => false,
|
|
ListSpacing::Uniform => self.uniform_lists.last().is_some_and(|list| list.has_item),
|
|
};
|
|
if separate {
|
|
let index = self.text.len();
|
|
self.push_blank_line();
|
|
if let Some(line) = self.text.get_mut(index) {
|
|
let mut source = line.source.clone().unwrap_or_else(|| {
|
|
crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line)
|
|
});
|
|
let mut copy = crate::markdown_copy::CopyLine::default();
|
|
copy.omit = true;
|
|
source.copy = Some(std::sync::Arc::new(copy));
|
|
line.source = Some(source);
|
|
}
|
|
if self.text.len() > index
|
|
&& let Some(list) = self.uniform_lists.last_mut()
|
|
{
|
|
list.separators.push(index);
|
|
}
|
|
}
|
|
if let Some(list) = self.uniform_lists.last_mut() {
|
|
list.has_item = true;
|
|
}
|
|
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)
|
|
+ if preferences::current().lists {
|
|
"• "
|
|
} else {
|
|
"- "
|
|
},
|
|
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();
|
|
let first_item_block = self.pending_marker_line
|
|
&& self
|
|
.indent_stack
|
|
.last()
|
|
.is_some_and(|context| context.is_list);
|
|
if !self.text.is_empty()
|
|
&& (self.list_spacing == ListSpacing::AfterMultiline || !first_item_block)
|
|
{
|
|
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, range: Range<usize>) {
|
|
// Completed Mermaid fences can replace source with a diagram; other blocks keep highlighting.
|
|
if let Some(lang) = self.code_block_lang.take() {
|
|
let code = std::mem::take(&mut self.code_block_buffer);
|
|
if !code.is_empty() {
|
|
let diagram = if preferences::current().mermaid
|
|
&& lang == "mermaid"
|
|
&& mermaid::has_closing_fence(self.input, range, self.code_block_content_end)
|
|
{
|
|
let indent =
|
|
Self::spans_display_width(&self.prefix_spans(self.pending_marker_line));
|
|
Some(mermaid::render(
|
|
&code,
|
|
self.wrap_width.map(|width| width.saturating_sub(indent)),
|
|
¤t_syntax_theme(),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
let highlighted = match diagram {
|
|
Some(diagram) => diagram,
|
|
None => highlight_code_to_lines(&code, &lang),
|
|
};
|
|
let source: std::sync::Arc<str> = code.into();
|
|
for hl_line in highlighted {
|
|
self.push_line(Line::default());
|
|
self.copy_line.code_source = Some(std::sync::Arc::clone(&source));
|
|
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 table: std::sync::Arc<[&str]> = table_state
|
|
.alignments
|
|
.iter()
|
|
.map(|alignment| match alignment {
|
|
Alignment::None => "---",
|
|
Alignment::Left => ":---",
|
|
Alignment::Center => ":---:",
|
|
Alignment::Right => "---:",
|
|
})
|
|
.collect();
|
|
let RenderedTableLines {
|
|
table_lines,
|
|
table_lines_prewrapped,
|
|
spillover_lines,
|
|
} = self.render_table_lines(table_state, &table);
|
|
let mut pending_marker_line = self.pending_marker_line;
|
|
for mut line in table_lines {
|
|
if line
|
|
.source
|
|
.as_ref()
|
|
.and_then(|source| source.copy.as_ref())
|
|
.is_none()
|
|
{
|
|
crate::markdown_copy::table::attach(
|
|
&mut line,
|
|
Some(crate::markdown_copy::table::TableLine::empty(&table)),
|
|
);
|
|
}
|
|
if table_lines_prewrapped {
|
|
self.push_prewrapped_line(line, pending_marker_line);
|
|
} else {
|
|
let table = line
|
|
.source
|
|
.as_ref()
|
|
.and_then(|source| source.copy.as_ref())
|
|
.and_then(|copy| copy.table.clone());
|
|
self.push_hyperlink_line(line);
|
|
self.copy_line.table = table;
|
|
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>) {
|
|
let span = self.style_link_label(span);
|
|
let mut annotated = HyperlinkLine::new(Line::default());
|
|
annotated.push_span(
|
|
span,
|
|
self.link.as_ref().map(|link| link.destination.as_str()),
|
|
);
|
|
if let Some(table_state) = self.table_state.as_mut()
|
|
&& let Some(cell) = table_state.current_cell.as_mut()
|
|
{
|
|
cell.push_annotated(annotated, &self.copy_inline);
|
|
}
|
|
}
|
|
|
|
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 = self.style_link_label(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 {
|
|
style_bare_web_urls(span, self.styles.link)
|
|
};
|
|
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), &self.copy_inline);
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
table: &std::sync::Arc<[&'static str]>,
|
|
) -> 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);
|
|
}
|
|
for (row_index, row) in std::iter::once(&mut header)
|
|
.chain(rows.iter_mut())
|
|
.enumerate()
|
|
{
|
|
for (column, cell) in row.iter_mut().enumerate() {
|
|
cell.ensure_line();
|
|
crate::markdown_copy::table::annotate_cell(
|
|
&mut cell.lines,
|
|
std::mem::take(&mut cell.copies),
|
|
table,
|
|
row_index,
|
|
column,
|
|
);
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
/// 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 shrunk by priority 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(
|
|
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 = display_width(token);
|
|
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 += display_width(&plain);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
display_width(&header_plain) 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)
|
|
}
|
|
|
|
/// Shrink columns in priority order, balancing slack within each priority.
|
|
///
|
|
/// Priority: TokenHeavy columns are shrunk before Narrative, then Compact.
|
|
/// Within the same kind, columns with the most slack above their floor are
|
|
/// shrunk first so similarly-shaped columns stay balanced. This produces the
|
|
/// same result as repeatedly shrinking one display cell, without repeatedly
|
|
/// scanning every column for long tokens.
|
|
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(/*default*/ 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 {
|
|
let mut line = wrapped_cells
|
|
.iter()
|
|
.find_map(|cell| cell.get(row_line))
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
line.line.style = row_style;
|
|
out.push(line);
|
|
continue;
|
|
};
|
|
let mut spans = Vec::new();
|
|
let mut copy = None;
|
|
let mut byte_offset = 0;
|
|
for (column, width) in column_widths
|
|
.iter()
|
|
.enumerate()
|
|
.take(last_visible_column + 1)
|
|
{
|
|
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
|
|
byte_offset += 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)));
|
|
}
|
|
byte_offset += left_padding;
|
|
if let Some(source) = &line.source {
|
|
crate::markdown_copy::table::append(&mut copy, source, byte_offset);
|
|
}
|
|
byte_offset += line
|
|
.line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.len())
|
|
.sum::<usize>();
|
|
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)));
|
|
byte_offset += right_padding;
|
|
}
|
|
if !is_last_column {
|
|
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
|
|
byte_offset += TABLE_CELL_PADDING;
|
|
}
|
|
if !is_last_column {
|
|
spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP)));
|
|
byte_offset += TABLE_COLUMN_GAP;
|
|
}
|
|
}
|
|
let mut out_line = HyperlinkLine::new(Line::from(spans).style(row_style));
|
|
crate::markdown_copy::table::attach(&mut out_line, copy);
|
|
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());
|
|
let mut copy = None;
|
|
let mut byte_offset = 1;
|
|
out.push_span("|".into(), /*destination*/ None);
|
|
for cell in row {
|
|
out.push_span(" ".into(), /*destination*/ None);
|
|
byte_offset += 1;
|
|
for (index, line) in cell.lines.iter().enumerate() {
|
|
if index > 0 {
|
|
out.push_span(" ".into(), /*destination*/ None);
|
|
byte_offset += 1;
|
|
}
|
|
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 (byte, ch) in text.char_indices() {
|
|
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("\\|");
|
|
byte_offset += 1;
|
|
} else {
|
|
current_text.push(ch);
|
|
}
|
|
if let Some(mut source) = line.source.clone() {
|
|
source.range = byte..byte + ch.len_utf8();
|
|
crate::markdown_copy::table::append(&mut copy, &source, byte_offset);
|
|
}
|
|
byte_offset += ch.len_utf8();
|
|
column += char_width(ch);
|
|
}
|
|
flush(&mut out, &mut current_text, current_destination);
|
|
}
|
|
out.push_span(" |".into(), /*destination*/ None);
|
|
byte_offset += 2;
|
|
}
|
|
crate::markdown_copy::table::attach(&mut out, copy);
|
|
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(source_line.clone());
|
|
} 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| display_width(span.content.as_ref()))
|
|
.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(display_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 style_link_label(&mut self, mut span: Span<'static>) -> Span<'static> {
|
|
if let Some(link) = self.link.as_mut()
|
|
&& web_destination(&link.destination).is_some()
|
|
{
|
|
link.has_visible_label |=
|
|
!span.content.trim().is_empty() && display_width(&span.content) > 0;
|
|
span.style = span.style.patch(self.styles.link);
|
|
}
|
|
span
|
|
}
|
|
|
|
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,
|
|
has_visible_label: false,
|
|
local_target_display: if is_local_path_like_link(&dest_url) {
|
|
Some(
|
|
render_local_link_target(&dest_url, self.cwd.as_deref())
|
|
.unwrap_or_else(|| dest_url.clone()),
|
|
)
|
|
} else {
|
|
None
|
|
},
|
|
local_label_spans: Vec::new(),
|
|
local_label_inline: Vec::new(),
|
|
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
|
|
|| (!link.has_visible_label && web_destination(&link.destination).is_some())
|
|
{
|
|
// 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.copy_inline);
|
|
}
|
|
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 show_label = link
|
|
.local_label_spans
|
|
.iter()
|
|
.any(|span| !span.content.trim().is_empty());
|
|
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() {
|
|
if show_label {
|
|
for (label_span, inline) in link
|
|
.local_label_spans
|
|
.into_iter()
|
|
.zip(link.local_label_inline)
|
|
{
|
|
let previous = std::mem::replace(&mut self.copy_inline, inline);
|
|
self.push_span_to_table_cell(label_span);
|
|
self.copy_inline = previous;
|
|
}
|
|
self.push_span_to_table_cell(" (".into());
|
|
}
|
|
self.copy_inline.push(crate::markdown_copy::Inline::Literal);
|
|
self.push_span_to_table_cell(span);
|
|
self.copy_inline.pop();
|
|
if show_label {
|
|
self.push_span_to_table_cell(")".into());
|
|
}
|
|
} else {
|
|
if self.pending_marker_line {
|
|
self.push_line(Line::default());
|
|
}
|
|
if show_label {
|
|
for (label_span, inline) in link
|
|
.local_label_spans
|
|
.into_iter()
|
|
.zip(link.local_label_inline)
|
|
{
|
|
let previous = std::mem::replace(&mut self.copy_inline, inline);
|
|
self.push_span(label_span);
|
|
self.copy_inline = previous;
|
|
}
|
|
self.push_span(" (".into());
|
|
}
|
|
self.copy_inline.push(crate::markdown_copy::Inline::Literal);
|
|
self.push_span(span);
|
|
self.copy_inline.pop();
|
|
if show_label {
|
|
self.push_span(")".into());
|
|
}
|
|
self.line_ends_with_local_link_target = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collecting_local_link_label(&self) -> bool {
|
|
self.link
|
|
.as_ref()
|
|
.and_then(|link| link.local_target_display.as_ref())
|
|
.is_some()
|
|
}
|
|
|
|
fn push_local_link_label_span(&mut self, span: Span<'static>) {
|
|
if let Some(link) = self.link.as_mut() {
|
|
link.local_label_spans.push(span);
|
|
link.local_label_inline.push(
|
|
self.copy_inline[..self
|
|
.copy_inline
|
|
.len()
|
|
.min(crate::markdown_copy::MAX_COPY_DEPTH)]
|
|
.to_vec(),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn push_local_link_label_break(&mut self) {
|
|
let needs_space = self
|
|
.link
|
|
.as_ref()
|
|
.and_then(|link| link.local_label_spans.last())
|
|
.and_then(|span| span.content.chars().last())
|
|
.is_some_and(|character| !character.is_whitespace());
|
|
if needs_space {
|
|
self.push_local_link_label_span(" ".into());
|
|
}
|
|
}
|
|
|
|
fn flush_current_line(&mut self) {
|
|
if let Some(mut line) = self.current_line_content.take() {
|
|
let style = self.current_line_style;
|
|
let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
|
|
source.copy = Some(std::sync::Arc::new(std::mem::take(&mut self.copy_line)));
|
|
// 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
|
|
{
|
|
line.source = Some(source);
|
|
let opts = RtOptions::new(width)
|
|
.initial_indent(self.current_initial_indent.clone().into())
|
|
.subsequent_indent(self.current_subsequent_indent.clone().into());
|
|
for wrapped in crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
|
|
std::slice::from_ref(&line),
|
|
opts,
|
|
) {
|
|
self.push_output_line(wrapped.style(style));
|
|
}
|
|
} else {
|
|
let mut spans = self.current_initial_indent.clone();
|
|
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
|
|
source.continuation_indent = self.current_subsequent_indent.clone().into();
|
|
line.source = Some(source);
|
|
let shift = Self::spans_display_width(&spans);
|
|
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 mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
|
|
let mut copy = line
|
|
.source
|
|
.as_ref()
|
|
.and_then(|source| source.copy.as_deref())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
copy.code_source = self.transformed_code_source.clone();
|
|
copy.quote_source = self.quote_sources.first().cloned();
|
|
copy.prefix = self.copy_prefix(pending_marker_line);
|
|
copy.continuation = self.copy_prefix(/*pending_marker_line*/ false);
|
|
copy.item_prefix = self.copy_prefix(/*pending_marker_line*/ true);
|
|
source.copy = Some(std::sync::Arc::new(copy));
|
|
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
|
|
source.continuation_indent = self.prefix_spans(/*pending_marker_line*/ false).into();
|
|
line.source = Some(source);
|
|
let shift = Self::spans_display_width(&spans);
|
|
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.copy_line = crate::markdown_copy::CopyLine::default();
|
|
self.copy_line.prefix = self.copy_prefix(was_pending);
|
|
self.copy_line.continuation = self.copy_prefix(/*pending_marker_line*/ false);
|
|
self.copy_line.item_prefix = self.copy_prefix(/*pending_marker_line*/ true);
|
|
self.copy_line.code = self.in_code_block;
|
|
self.copy_line.code_source = self.transformed_code_source.clone();
|
|
self.copy_line.quote_source = self.quote_sources.first().cloned();
|
|
// Heading markers are already visible; retain them as Markdown syntax.
|
|
self.copy_line
|
|
.push(line.spans.iter().map(|span| span.content.len()).sum(), &[]);
|
|
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>) {
|
|
let span = self.style_link_label(span);
|
|
if self.current_line_content.is_none() {
|
|
self.push_line(Line::default());
|
|
}
|
|
if let Some(line) = self.current_line_content.as_mut() {
|
|
self.copy_line.push(span.content.len(), &self.copy_inline);
|
|
line.push_span(
|
|
span,
|
|
self.link.as_ref().map(|link| link.destination.as_str()),
|
|
);
|
|
}
|
|
}
|
|
|
|
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() {
|
|
self.copy_line.push(
|
|
appended
|
|
.line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.len())
|
|
.sum(),
|
|
&self.copy_inline,
|
|
);
|
|
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 = self.style_link_label(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 {
|
|
style_bare_web_urls(span, self.styles.link)
|
|
};
|
|
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);
|
|
}
|
|
|
|
// Markdown needs relative container indentation, not the display's fixed-depth padding.
|
|
fn copy_prefix(&self, pending_marker_line: bool) -> String {
|
|
// Code's display padding is not a Markdown container. Keep only the innermost semantic
|
|
// containers so lazy continuation lines cannot each retain an arbitrarily deep prefix.
|
|
let containers =
|
|
&self.indent_stack[..self.indent_stack.len() - usize::from(self.in_code_block)];
|
|
let containers = &containers[containers
|
|
.len()
|
|
.saturating_sub(crate::markdown_copy::MAX_COPY_DEPTH)..];
|
|
let last_list = containers.iter().rposition(|context| context.is_list);
|
|
let mut prefix = String::new();
|
|
for (index, context) in containers.iter().enumerate() {
|
|
if context.is_list {
|
|
let marker = &context.copy_marker;
|
|
let width = marker.find('[').unwrap_or(marker.len());
|
|
if pending_marker_line && Some(index) == last_list {
|
|
prefix.push_str(marker);
|
|
} else {
|
|
prefix.extend(std::iter::repeat_n(' ', width));
|
|
}
|
|
} else {
|
|
prefix.extend(context.prefix.iter().map(|span| span.content.as_ref()));
|
|
}
|
|
}
|
|
prefix
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
#[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_lines_with_width_and_cwd(markdown, Some(22), /*cwd*/ None);
|
|
let source = rendered[0].source.as_ref().expect("blockquote source");
|
|
let styles = MarkdownStyles::default();
|
|
assert_eq!(
|
|
source.styled_range(0..source.text.len()),
|
|
Line::from(vec![
|
|
Span::styled("block quote with ", styles.blockquote),
|
|
Span::styled("content", styles.blockquote.patch(styles.strong)),
|
|
Span::styled(" that should wrap nicely", styles.blockquote),
|
|
])
|
|
);
|
|
let lines: Vec<_> = rendered.iter().map(|line| line.line.to_string()).collect();
|
|
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 = crate::terminal_palette::with_test_default_colors(
|
|
crate::terminal_probe::DefaultColors {
|
|
fg: (220, 220, 220),
|
|
bg: (20, 20, 20),
|
|
},
|
|
|| 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_annotated(Line::from("first line").into(), &[]);
|
|
cell.hard_break();
|
|
cell.push_annotated(Line::from("second line").into(), &[]);
|
|
|
|
let writer = W::new(
|
|
"",
|
|
/*wrap_width*/ Some(80),
|
|
/*cwd*/ None,
|
|
&never_hide_link_destination,
|
|
);
|
|
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()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blank_table_continuations_do_not_repeat_the_first_cell() {
|
|
let mut cell = make_cell("first");
|
|
cell.hard_break();
|
|
cell.hard_break();
|
|
cell.push_annotated(Line::from("last").into(), &[]);
|
|
cell.hard_break();
|
|
let writer = W::new(
|
|
"",
|
|
/*wrap_width*/ Some(80),
|
|
/*cwd*/ None,
|
|
&never_hide_link_destination,
|
|
);
|
|
let lines = writer.render_table_row(
|
|
&[make_cell("short"), cell],
|
|
&[5, 5],
|
|
&[Alignment::None, Alignment::None],
|
|
Style::default(),
|
|
);
|
|
let rendered: Vec<_> = lines.iter().map(|line| line.line.to_string()).collect();
|
|
insta::assert_debug_snapshot!(rendered);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Type alias for calling private associated functions on Writer.
|
|
// ---------------------------------------------------------------
|
|
type W<'a> = Writer<'a, 'a>;
|
|
|
|
/// Build a single-line `TableCell` from plain text.
|
|
fn make_cell(text: &str) -> TableCell {
|
|
let mut cell = TableCell::default();
|
|
cell.push_annotated(Line::from(text.to_string()).into(), &[]);
|
|
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 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, /*available_width*/ Some(24)),
|
|
Some(vec![16, 8])
|
|
);
|
|
}
|
|
|
|
// ===== 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 table_widths_count_halfwidth_sound_marks() {
|
|
let cell = make_cell("ガパ");
|
|
assert_eq!(W::cell_display_width(&cell), 4);
|
|
assert_eq!(W::longest_token_width("ガパtail"), 8);
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|