mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## Why Path-like link labels were collapsed into their destinations, discarding the author's label text and formatting. ## What changed Preserve nonblank local link labels alongside the formatted destination as `label (target)`, including in tables. Empty or whitespace-only labels show only the destination. Keep destination formatting and fall back to the original destination when a local file URL cannot be parsed. Render file citations without a label so they continue to display only the formatted destination. ## Testing Update rendering and transcript-copy tests and snapshots to cover preserved path labels, styled labels, empty labels, malformed file URLs, tables, and Markdown escaping. GitOrigin-RevId: 2810b70f1ecdc59d7f90fa1b254b1e9069c8fc29
3244 lines
119 KiB
Rust
3244 lines
119 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.
|
|
//!
|
|
//! 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 {
|
|
Self {
|
|
h1: Style::new().bold().underlined(),
|
|
h2: Style::new().bold(),
|
|
h3: Style::new().bold().italic(),
|
|
h4: Style::new().italic(),
|
|
h5: Style::new().italic(),
|
|
h6: Style::new().italic(),
|
|
code: 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: Style::new().italic(),
|
|
strong: Style::new().bold(),
|
|
strikethrough: Style::new().crossed_out(),
|
|
ordered_list_marker: Style::new().light_blue(),
|
|
unordered_list_marker: Style::new(),
|
|
link: Style::new().fg(accent_color()).underlined(),
|
|
blockquote: Style::new().green(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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> {
|
|
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);
|
|
// 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::style::Stylize;
|
|
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");
|
|
assert_eq!(
|
|
source.styled_range(0..source.text.len()),
|
|
Line::from(vec![
|
|
"block quote with ".green(),
|
|
"content".green().bold(),
|
|
" that should wrap nicely".green(),
|
|
])
|
|
);
|
|
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"));
|
|
}
|
|
}
|