#[cfg(test)]
use std::cell::Cell;
use std::cell::RefCell;
use std::sync::OnceLock;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, HighlightState, Theme, ThemeSet};
use syntect::parsing::{ParseState as SyntectParseState, SyntaxSet};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::palette;
use crate::tui::osc8;
use crate::tui::ui_text::CopyLineSeparator;
#[cfg(test)]
thread_local! {
static PARSE_INVOCATIONS: Cell<u64> = const { Cell::new(0) };
}
#[cfg(test)]
#[must_use]
pub fn parse_invocation_count() -> u64 {
PARSE_INVOCATIONS.with(|c| c.get())
}
#[cfg(test)]
pub fn reset_parse_invocation_count() {
PARSE_INVOCATIONS.with(|c| c.set(0));
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Block {
Heading { level: usize, text: String },
HeadingRule,
HorizontalRule,
ListItem { bullet: String, text: String },
Quote { depth: usize, text: String },
Code {
line: String,
language: Option<String>,
block_id: usize,
},
TableRow(Vec<String>),
TableSeparator,
Paragraph { text: String },
Blank,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedMarkdown {
blocks: Vec<Block>,
}
#[derive(Debug, Clone)]
pub struct RenderedMarkdownLine {
pub line: Line<'static>,
pub links: Vec<osc8::LineLink>,
pub is_code: bool,
pub copy_prefix_width: usize,
pub copy_separator_after: CopyLineSeparator,
}
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
static COLOR_DEPTH: OnceLock<palette::ColorDepth> = OnceLock::new();
static PALETTE_MODE: OnceLock<palette::PaletteMode> = OnceLock::new();
fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
fn theme_set() -> &'static ThemeSet {
THEME_SET.get_or_init(ThemeSet::load_defaults)
}
fn syntax_color_depth() -> palette::ColorDepth {
*COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
}
pub(crate) fn detected_palette_mode() -> palette::PaletteMode {
*PALETTE_MODE.get_or_init(palette::PaletteMode::detect)
}
#[must_use]
pub fn parse(content: &str) -> ParsedMarkdown {
#[cfg(test)]
PARSE_INVOCATIONS.with(|c| c.set(c.get() + 1));
STREAM_PARSE_MEMO.with(|memo| {
let mut memo = memo.borrow_mut();
let state = memo.get_or_insert_with(ParseState::default);
if !state.can_resume_from(content) {
*state = ParseState::default();
}
state.commit_complete_lines(content);
let parsed = state.snapshot(content);
if state.consumed > MAX_MEMOIZED_PREFIX_BYTES {
*state = ParseState::default();
}
parsed
})
}
const MAX_MEMOIZED_PREFIX_BYTES: usize = 1024 * 1024;
thread_local! {
static STREAM_PARSE_MEMO: RefCell<Option<ParseState>> = const { RefCell::new(None) };
}
#[derive(Debug, Clone, Default)]
pub struct ParseState {
blocks: Vec<Block>,
prefix: String,
consumed: usize,
open_fence_len: Option<usize>,
code_language: Option<String>,
code_block_id: usize,
}
impl ParseState {
fn commit_complete_lines(&mut self, content: &str) {
let Some(rest) = content.get(self.consumed..) else {
return;
};
let Some(last_newline) = rest.rfind('\n') else {
return;
};
let complete = &rest[..=last_newline];
for raw_line in complete.lines() {
push_parsed_line(
raw_line,
&mut self.blocks,
&mut self.open_fence_len,
&mut self.code_language,
&mut self.code_block_id,
);
}
self.prefix.push_str(complete);
self.consumed += complete.len();
}
fn snapshot(&self, content: &str) -> ParsedMarkdown {
let tail = content.get(self.consumed..).unwrap_or_default();
if tail.is_empty() {
return ParsedMarkdown {
blocks: self.blocks.clone(),
};
}
let mut blocks = self.blocks.clone();
let mut open_fence_len = self.open_fence_len;
let mut code_language = self.code_language.clone();
let mut code_block_id = self.code_block_id;
for raw_line in tail.lines() {
push_parsed_line(
raw_line,
&mut blocks,
&mut open_fence_len,
&mut code_language,
&mut code_block_id,
);
}
ParsedMarkdown { blocks }
}
fn can_resume_from(&self, content: &str) -> bool {
content.len() >= self.consumed
&& content.is_char_boundary(self.consumed)
&& self.committed_prefix_matches(content)
}
fn can_resume_verified_append(&self, content: &str) -> bool {
content.len() >= self.consumed && content.is_char_boundary(self.consumed)
}
fn committed_prefix_matches(&self, content: &str) -> bool {
self.prefix == content[..self.consumed]
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct MarkdownRenderWork {
pub classified_lines: u64,
pub stable_blocks_rendered: u64,
pub tail_blocks_rendered: u64,
pub invalidations: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct IncrementalRenderKey {
width: u16,
base_style: Style,
palette_mode: palette::PaletteMode,
}
#[derive(Debug, Clone)]
struct IncrementalCodeHighlighter {
block_id: usize,
language: Option<String>,
state: Option<(HighlightState, SyntectParseState)>,
}
#[derive(Debug, Default)]
pub(crate) struct IncrementalMarkdownRenderCache {
parser: ParseState,
key: Option<IncrementalRenderKey>,
source_len: usize,
stable_rendered_line_count: usize,
code_highlighter: Option<IncrementalCodeHighlighter>,
work: MarkdownRenderWork,
}
pub(crate) struct IncrementalMarkdownRenderDelta {
pub replace_from: usize,
pub lines: Vec<RenderedMarkdownLine>,
}
impl IncrementalMarkdownRenderCache {
#[cfg(test)]
#[must_use]
pub(crate) fn work(&self) -> MarkdownRenderWork {
self.work
}
#[cfg(test)]
#[must_use]
pub(crate) fn retained_source_bytes(&self) -> usize {
self.parser.prefix.len()
}
pub(crate) fn update(
&mut self,
content: &str,
width: u16,
base_style: Style,
palette_mode: palette::PaletteMode,
verified_append: bool,
) -> IncrementalMarkdownRenderDelta {
let key = IncrementalRenderKey {
width: width.max(1),
base_style,
palette_mode,
};
let can_resume = self.key == Some(key)
&& verified_append
&& content.len() >= self.source_len
&& self.parser.can_resume_verified_append(content);
let replace_from = if can_resume {
self.stable_rendered_line_count
} else {
self.reset_for_invalidation();
self.work.invalidations = self.work.invalidations.saturating_add(1);
0
};
self.key = Some(key);
let before_consumed = self.parser.consumed;
self.parser.commit_complete_lines(content);
self.work.classified_lines = self.work.classified_lines.saturating_add(
content[before_consumed..self.parser.consumed]
.lines()
.count() as u64,
);
self.source_len = content.len();
self.parser.prefix.clear();
let stable_end = stable_block_prefix_len(&self.parser.blocks);
let mut lines = self.render_stable_prefix(stable_end, key);
self.stable_rendered_line_count =
self.stable_rendered_line_count.saturating_add(lines.len());
let mut tail_blocks = self.parser.blocks.clone();
tail_blocks.extend(self.parser.snapshot_tail(content));
if !tail_blocks.is_empty() {
self.work.tail_blocks_rendered = self
.work
.tail_blocks_rendered
.saturating_add(tail_blocks.len() as u64);
let mut tail_highlighter = self.code_highlighter.clone();
lines.extend(render_incremental_blocks(
&tail_blocks,
key,
&mut tail_highlighter,
));
}
if lines.is_empty() && self.stable_rendered_line_count == 0 {
lines.push(empty_rendered_markdown_line());
}
IncrementalMarkdownRenderDelta {
replace_from,
lines,
}
}
fn render_stable_prefix(
&mut self,
end: usize,
key: IncrementalRenderKey,
) -> Vec<RenderedMarkdownLine> {
if end == 0 {
return Vec::new();
}
self.work.stable_blocks_rendered =
self.work.stable_blocks_rendered.saturating_add(end as u64);
let lines =
render_incremental_blocks(&self.parser.blocks[..end], key, &mut self.code_highlighter);
self.parser.blocks.drain(..end);
lines
}
fn reset_for_invalidation(&mut self) {
self.parser = ParseState::default();
self.key = None;
self.source_len = 0;
self.stable_rendered_line_count = 0;
self.code_highlighter = None;
}
}
impl ParseState {
fn snapshot_tail(&self, content: &str) -> Vec<Block> {
let tail = content.get(self.consumed..).unwrap_or_default();
let mut blocks = Vec::new();
let mut open_fence_len = self.open_fence_len;
let mut code_language = self.code_language.clone();
let mut code_block_id = self.code_block_id;
for raw_line in tail.lines() {
push_parsed_line(
raw_line,
&mut blocks,
&mut open_fence_len,
&mut code_language,
&mut code_block_id,
);
}
blocks
}
}
fn stable_block_prefix_len(blocks: &[Block]) -> usize {
let Some(last_non_table) = blocks
.iter()
.rposition(|block| !matches!(block, Block::TableRow(_) | Block::TableSeparator))
else {
return 0;
};
if last_non_table + 1 == blocks.len() {
blocks.len()
} else {
last_non_table + 1
}
}
fn push_parsed_line(
raw_line: &str,
blocks: &mut Vec<Block>,
open_fence_len: &mut Option<usize>,
code_language: &mut Option<String>,
code_block_id: &mut usize,
) {
let trimmed = raw_line.trim_start();
let fence_len = trimmed.chars().take_while(|c| *c == '`').count();
if fence_len >= 3 {
match *open_fence_len {
Some(open) if fence_len >= open && trimmed[fence_len..].trim().is_empty() => {
*open_fence_len = None;
*code_language = None;
}
Some(_) => {
blocks.push(Block::Code {
line: raw_line.to_string(),
language: code_language.clone(),
block_id: *code_block_id,
});
}
None => {
*open_fence_len = Some(fence_len);
*code_block_id = code_block_id.saturating_add(1);
*code_language = normalized_fence_language(&trimmed[fence_len..]);
}
}
return;
}
if open_fence_len.is_some() {
blocks.push(Block::Code {
line: raw_line.to_string(),
language: code_language.clone(),
block_id: *code_block_id,
});
return;
}
if let Some((depth, text)) = parse_blockquote(trimmed) {
blocks.push(Block::Quote {
depth,
text: text.to_string(),
});
return;
}
if let Some((level, text)) = parse_heading(trimmed) {
blocks.push(Block::Heading {
level,
text: text.to_string(),
});
if level == 1 {
blocks.push(Block::HeadingRule);
}
return;
}
if let Some((bullet, text)) = parse_list_item(trimmed) {
blocks.push(Block::ListItem {
bullet,
text: text.to_string(),
});
return;
}
if is_horizontal_rule(trimmed) {
blocks.push(Block::HorizontalRule);
return;
}
match parse_table_row(trimmed) {
Some(cells) => {
blocks.push(Block::TableRow(cells));
return;
}
None if trimmed.starts_with('|') => {
blocks.push(Block::TableSeparator);
return;
}
None => {}
}
if trimmed.is_empty() {
blocks.push(Block::Blank);
return;
}
blocks.push(Block::Paragraph {
text: raw_line.to_string(),
});
}
#[must_use]
pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) -> Vec<Line<'static>> {
render_parsed_tagged_with_palette(parsed, width, base_style, detected_palette_mode())
.into_iter()
.map(|line| line.line)
.collect()
}
#[cfg(test)]
#[must_use]
pub fn render_parsed_tagged(
parsed: &ParsedMarkdown,
width: u16,
base_style: Style,
) -> Vec<RenderedMarkdownLine> {
render_parsed_tagged_with_palette(parsed, width, base_style, detected_palette_mode())
}
#[must_use]
pub(crate) fn render_parsed_tagged_with_palette(
parsed: &ParsedMarkdown,
width: u16,
base_style: Style,
palette_mode: palette::PaletteMode,
) -> Vec<RenderedMarkdownLine> {
let width = width.max(1) as usize;
let mut out: Vec<RenderedMarkdownLine> = Vec::with_capacity(parsed.blocks.len());
let mut i = 0;
while i < parsed.blocks.len() {
if matches!(
&parsed.blocks[i],
Block::TableRow(_) | Block::TableSeparator
) {
let start = i;
while i < parsed.blocks.len()
&& matches!(
&parsed.blocks[i],
Block::TableRow(_) | Block::TableSeparator
)
{
i += 1;
}
out.extend(
render_table_group(&parsed.blocks[start..i], width, base_style)
.into_iter()
.map(|line| RenderedMarkdownLine {
line,
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
}),
);
continue;
}
if let Block::Code {
language, block_id, ..
} = &parsed.blocks[i]
{
let start = i;
while i < parsed.blocks.len()
&& matches!(
&parsed.blocks[i],
Block::Code {
block_id: candidate,
..
} if candidate == block_id
)
{
i += 1;
}
let source_lines = parsed.blocks[start..i]
.iter()
.filter_map(|block| match block {
Block::Code { line, .. } => Some(line.as_str()),
_ => None,
})
.collect::<Vec<_>>();
let highlighted =
highlight_code_block(language.as_deref(), &source_lines, base_style, palette_mode);
for spans in highlighted {
out.extend(render_wrapped_code_spans_tagged(spans, width));
}
continue;
}
match &parsed.blocks[i] {
Block::Heading { text, .. } => {
let style = Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD);
out.extend(render_wrapped_line_tagged(text, width, style, false, false));
}
Block::HeadingRule => {
out.push(RenderedMarkdownLine {
line: Line::from(Span::styled(
"─".repeat(width.min(40)),
Style::default().fg(palette::TEXT_DIM),
)),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
Block::HorizontalRule => {
out.push(RenderedMarkdownLine {
line: Line::from(Span::styled(
"─".repeat(width.min(60)),
Style::default().fg(palette::TEXT_DIM),
)),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
Block::ListItem { bullet, text } => {
let bullet_style = Style::default().fg(palette::WHALE_INFO);
out.extend(render_list_line_tagged(
bullet,
text,
width,
bullet_style,
base_style,
));
}
Block::Code { .. } => unreachable!(),
Block::Quote { depth, text } => {
let rail_style = Style::default().fg(palette::WHALE_INFO);
let text_style = Style::default().fg(palette::TEXT_DIM);
out.extend(render_quote_line_tagged(
text, *depth, width, rail_style, text_style,
));
}
Block::Paragraph { text } => {
let link_style = Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::UNDERLINED);
out.extend(render_line_with_links_tagged(
text, width, base_style, link_style,
));
}
Block::Blank => {
out.push(RenderedMarkdownLine {
line: Line::from(""),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
Block::TableRow(_) | Block::TableSeparator => unreachable!(),
}
i += 1;
}
if out.is_empty() {
out.push(RenderedMarkdownLine {
line: Line::from(""),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
out
}
fn empty_rendered_markdown_line() -> RenderedMarkdownLine {
RenderedMarkdownLine {
line: Line::from(""),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
}
}
fn render_incremental_blocks(
blocks: &[Block],
key: IncrementalRenderKey,
code_highlighter: &mut Option<IncrementalCodeHighlighter>,
) -> Vec<RenderedMarkdownLine> {
let mut out = Vec::new();
let mut index = 0;
while index < blocks.len() {
if let Block::Code {
line,
language,
block_id,
} = &blocks[index]
{
let spans = highlight_incremental_code_line(
*block_id,
language.as_deref(),
line,
key.base_style,
key.palette_mode,
code_highlighter,
);
out.extend(render_wrapped_code_spans_tagged(
spans,
usize::from(key.width),
));
index += 1;
continue;
}
let start = index;
while index < blocks.len() && !matches!(blocks[index], Block::Code { .. }) {
index += 1;
}
out.extend(render_parsed_tagged_with_palette(
&ParsedMarkdown {
blocks: blocks[start..index].to_vec(),
},
key.width,
key.base_style,
key.palette_mode,
));
}
out
}
fn highlight_incremental_code_line(
block_id: usize,
language: Option<&str>,
line: &str,
base_style: Style,
palette_mode: palette::PaletteMode,
cache: &mut Option<IncrementalCodeHighlighter>,
) -> Vec<Span<'static>> {
let language_owned = language.map(str::to_owned);
let needs_reset = cache
.as_ref()
.is_none_or(|current| current.block_id != block_id || current.language != language_owned);
if needs_reset {
let state = language
.and_then(find_code_syntax)
.map(|syntax| HighlightLines::new(syntax, selected_syntax_theme(palette_mode)).state());
*cache = Some(IncrementalCodeHighlighter {
block_id,
language: language_owned,
state,
});
}
let plain_style = base_style.fg(palette::TEXT_TOOL_OUTPUT);
let Some(current) = cache.as_mut() else {
return vec![Span::styled(line.to_string(), plain_style)];
};
let Some((highlight_state, parse_state)) = current.state.take() else {
return vec![Span::styled(line.to_string(), plain_style)];
};
let mut highlighter = HighlightLines::from_state(
selected_syntax_theme(palette_mode),
highlight_state,
parse_state,
);
let highlighted = match highlighter.highlight_line(line, syntax_set()) {
Ok(ranges) if !ranges.is_empty() => ranges
.into_iter()
.map(|(style, text)| {
Span::styled(
text.to_string(),
syntax_style_to_ratatui(style, base_style, palette_mode),
)
})
.collect(),
_ => vec![Span::styled(line.to_string(), plain_style)],
};
current.state = Some(highlighter.state());
highlighted
}
#[must_use]
pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
let parsed = parse(content);
render_parsed(&parsed, width, base_style)
}
#[cfg(test)]
#[must_use]
pub fn render_markdown_tagged(
content: &str,
width: u16,
base_style: Style,
) -> Vec<RenderedMarkdownLine> {
let parsed = parse(content);
render_parsed_tagged(&parsed, width, base_style)
}
#[must_use]
pub(crate) fn render_markdown_tagged_with_palette(
content: &str,
width: u16,
base_style: Style,
palette_mode: palette::PaletteMode,
) -> Vec<RenderedMarkdownLine> {
let parsed = parse(content);
render_parsed_tagged_with_palette(&parsed, width, base_style, palette_mode)
}
#[must_use]
pub fn render_plain_text(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let mut lines = Vec::new();
for raw_line in content.split('\n') {
if raw_line.is_empty() {
lines.push(Line::from(""));
} else {
lines.extend(wrap_plain_line(raw_line, width, base_style));
}
}
if lines.is_empty() {
lines.push(Line::from(""));
}
lines
}
fn wrap_plain_line(line: &str, width: usize, style: Style) -> Vec<Line<'static>> {
if width == 0 || line.is_empty() {
return vec![Line::from("")];
}
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
let mut last_break_pos = None;
for grapheme in line.graphemes(true) {
loop {
let grapheme_width = markdown_grapheme_width(grapheme, current_width);
if current_width + grapheme_width <= width || current.is_empty() {
break;
}
if let Some(pos) = last_break_pos {
if pos == current.len() {
chunks.push(std::mem::take(&mut current));
current_width = 0;
last_break_pos = None;
break;
}
if current[..pos].chars().any(|c| !c.is_whitespace()) {
let tail = current.split_off(pos);
chunks.push(std::mem::take(&mut current));
current = tail;
current_width = plain_display_width(¤t);
last_break_pos = last_plain_break_pos(¤t);
continue;
}
}
chunks.push(std::mem::take(&mut current));
current_width = 0;
last_break_pos = None;
break;
}
let grapheme_width = markdown_grapheme_width(grapheme, current_width);
current.push_str(grapheme);
current_width += grapheme_width;
if grapheme.chars().all(char::is_whitespace) {
last_break_pos = Some(current.len());
}
}
if !current.is_empty() {
chunks.push(current);
}
if chunks.is_empty() {
return vec![Line::from("")];
}
chunks
.into_iter()
.map(|chunk| Line::from(vec![Span::styled(chunk, style)]))
.collect()
}
fn plain_display_width(text: &str) -> usize {
let mut width = 0usize;
for grapheme in text.graphemes(true) {
width += markdown_grapheme_width(grapheme, width);
}
width
}
fn last_plain_break_pos(text: &str) -> Option<usize> {
text.char_indices()
.rev()
.find_map(|(idx, ch)| ch.is_whitespace().then_some(idx + ch.len_utf8()))
}
fn parse_heading(line: &str) -> Option<(usize, &str)> {
let trimmed = line.trim_start();
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
if hashes == 0 {
return None;
}
let text = trimmed[hashes..].trim();
if text.is_empty() {
None
} else {
Some((hashes, text))
}
}
fn parse_list_item(line: &str) -> Option<(String, &str)> {
let trimmed = line.trim_start();
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
return Some(("-".to_string(), trimmed[2..].trim()));
}
let bytes = trimmed.as_bytes();
let mut idx = 0;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == 0 || idx >= bytes.len() || bytes[idx] != b'.' {
return None;
}
let rest = &trimmed[idx + 1..];
if !rest.starts_with(' ') {
return None;
}
Some((format!("{}.", &trimmed[..idx]), rest.trim_start()))
}
const MAX_QUOTE_DEPTH: usize = 4;
fn parse_blockquote(line: &str) -> Option<(usize, &str)> {
let trimmed = line.trim_start();
if !trimmed.starts_with('>') {
return None;
}
let mut rest = trimmed;
let mut depth = 0usize;
while rest.starts_with('>') {
depth = depth.saturating_add(1);
rest = rest[1..].trim_start_matches([' ', '\t']);
}
Some((depth.clamp(1, MAX_QUOTE_DEPTH), rest.trim()))
}
fn normalized_fence_language(info: &str) -> Option<String> {
let token = info
.trim()
.split(|ch: char| ch.is_whitespace() || ch == ',')
.next()
.unwrap_or("")
.trim_matches(['{', '}', '.'])
.to_ascii_lowercase();
if token.is_empty() || matches!(token.as_str(), "text" | "txt" | "plain" | "plaintext") {
return None;
}
let normalized = match token.as_str() {
"rs" => "rust",
"js" | "jsx" | "node" => "javascript",
"ts" | "tsx" => "typescript",
"py" => "python",
"rb" => "ruby",
"sh" | "shell" | "zsh" => "bash",
"yml" => "yaml",
"md" => "markdown",
other => other,
};
Some(normalized.to_string())
}
fn selected_syntax_theme(mode: palette::PaletteMode) -> &'static Theme {
let themes = theme_set();
let preferred = match mode {
palette::PaletteMode::Dark | palette::PaletteMode::Grayscale => "base16-ocean.dark",
palette::PaletteMode::Light => "InspiredGitHub",
palette::PaletteMode::SolarizedLight => "Solarized (light)",
};
themes
.themes
.get(preferred)
.or_else(|| themes.themes.values().next())
.expect("syntect ships at least one default theme")
}
fn syntax_style_to_ratatui(
style: syntect::highlighting::Style,
base_style: Style,
palette_mode: palette::PaletteMode,
) -> Style {
let fg = syntax_rgb_to_terminal_color(
style.foreground.r,
style.foreground.g,
style.foreground.b,
palette_mode,
syntax_color_depth(),
);
let mut modifiers = Modifier::empty();
if style.font_style.contains(FontStyle::BOLD) {
modifiers |= Modifier::BOLD;
}
if style.font_style.contains(FontStyle::ITALIC) {
modifiers |= Modifier::ITALIC;
}
if style.font_style.contains(FontStyle::UNDERLINE) {
modifiers |= Modifier::UNDERLINED;
}
base_style.fg(fg).add_modifier(modifiers)
}
fn syntax_rgb_to_terminal_color(
r: u8,
g: u8,
b: u8,
mode: palette::PaletteMode,
depth: palette::ColorDepth,
) -> Color {
let (r, g, b) = if mode == palette::PaletteMode::Grayscale {
let luma =
((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 1000) as u8;
let readable = luma.clamp(96, 232);
(readable, readable, readable)
} else {
(r, g, b)
};
let mut color = Color::Rgb(r, g, b);
if matches!(
color,
reserved if reserved == palette::WHALE_HUMAN
|| reserved == palette::WHALE_LIVE
|| reserved == palette::WHALE_ACTION
|| reserved == palette::WHALE_ERROR
) {
color = Color::Rgb(r, g, b.saturating_add(1));
}
let color = palette::adapt_color(color, depth);
let reserved = [
palette::WHALE_HUMAN,
palette::WHALE_LIVE,
palette::WHALE_ACTION,
palette::WHALE_ERROR,
]
.map(|semantic| palette::adapt_color(semantic, depth));
if !reserved.contains(&color) {
return color;
}
for delta in [17_u8, 34, 51, 68, 85, 102, 119, 136] {
let candidate = palette::adapt_color(
Color::Rgb(
r.wrapping_add(delta),
g.wrapping_add(delta / 2),
b.wrapping_add(delta / 3),
),
depth,
);
if !reserved.contains(&candidate) {
return candidate;
}
}
Color::Reset
}
fn highlight_code_block(
language: Option<&str>,
lines: &[&str],
base_style: Style,
palette_mode: palette::PaletteMode,
) -> Vec<Vec<Span<'static>>> {
let plain_style = base_style.fg(palette::TEXT_TOOL_OUTPUT);
let Some(language) = language else {
return lines
.iter()
.map(|line| vec![Span::styled((*line).to_string(), plain_style)])
.collect();
};
let syntaxes = syntax_set();
let Some(syntax) = find_code_syntax(language) else {
return lines
.iter()
.map(|line| vec![Span::styled((*line).to_string(), plain_style)])
.collect();
};
let mut highlighter = HighlightLines::new(syntax, selected_syntax_theme(palette_mode));
lines
.iter()
.map(|line| match highlighter.highlight_line(line, syntaxes) {
Ok(ranges) if !ranges.is_empty() => ranges
.into_iter()
.map(|(style, text)| {
Span::styled(
text.to_string(),
syntax_style_to_ratatui(style, base_style, palette_mode),
)
})
.collect(),
_ => vec![Span::styled((*line).to_string(), plain_style)],
})
.collect()
}
fn find_code_syntax(language: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
let syntaxes = syntax_set();
syntaxes
.find_syntax_by_token(language)
.or_else(|| syntaxes.find_syntax_by_extension(language))
.or_else(|| {
syntaxes
.syntaxes()
.iter()
.find(|syntax| syntax.name.eq_ignore_ascii_case(language))
})
}
fn render_wrapped_code_spans_tagged(
spans: Vec<Span<'static>>,
width: usize,
) -> Vec<RenderedMarkdownLine> {
let prefix = " ";
let prefix_width = prefix.width();
let available = width.saturating_sub(prefix_width).max(1);
let mut rows: Vec<Vec<(String, Style)>> = vec![Vec::new()];
let mut current_width = 0usize;
for span in spans {
for grapheme in span.content.graphemes(true) {
let grapheme_width = markdown_grapheme_width(grapheme, current_width);
if current_width + grapheme_width > available && current_width > 0 {
rows.push(Vec::new());
current_width = 0;
}
let row = rows.last_mut().expect("code rows are never empty");
if let Some((text, style)) = row.last_mut()
&& *style == span.style
{
text.push_str(grapheme);
} else {
row.push((grapheme.to_string(), span.style));
}
current_width += markdown_grapheme_width(grapheme, current_width);
}
}
let last_index = rows.len().saturating_sub(1);
rows.into_iter()
.enumerate()
.map(|(idx, row)| {
let mut rendered = vec![Span::raw(prefix)];
rendered.extend(
row.into_iter()
.map(|(text, style)| Span::styled(text, style)),
);
RenderedMarkdownLine {
line: Line::from(rendered),
links: Vec::new(),
is_code: true,
copy_prefix_width: prefix_width,
copy_separator_after: if idx == last_index {
CopyLineSeparator::Newline
} else {
CopyLineSeparator::None
},
}
})
.collect()
}
fn render_wrapped_line_tagged(
line: &str,
width: usize,
style: Style,
indent_code: bool,
is_code: bool,
) -> Vec<RenderedMarkdownLine> {
let prefix = if indent_code { " " } else { "" };
let prefix_width = prefix.width();
let available = width.saturating_sub(prefix_width).max(1);
let wrapped = if indent_code {
wrap_code_line(line, available)
} else {
wrap_text(line, available)
};
let mut out = Vec::new();
let last_index = wrapped.len().saturating_sub(1);
for (idx, chunk) in wrapped.into_iter().enumerate() {
let line = if idx == 0 {
Line::from(vec![Span::raw(prefix), Span::styled(chunk, style)])
} else {
Line::from(vec![
Span::raw(" ".repeat(prefix_width)),
Span::styled(chunk, style),
])
};
let copy_separator_after = if idx == last_index {
CopyLineSeparator::Newline
} else if is_code {
CopyLineSeparator::None
} else {
CopyLineSeparator::Space
};
out.push(RenderedMarkdownLine {
line,
links: Vec::new(),
is_code,
copy_prefix_width: if indent_code { prefix_width } else { 0 },
copy_separator_after,
});
}
out
}
fn render_list_line_tagged(
bullet: &str,
text: &str,
width: usize,
bullet_style: Style,
text_style: Style,
) -> Vec<RenderedMarkdownLine> {
let bullet_prefix = format!("{bullet} ");
let bullet_width = bullet_prefix.width();
let available = width.saturating_sub(bullet_width).max(1);
let wrapped = render_line_with_links_tagged(text, available, text_style, link_style());
let mut out = Vec::new();
for (idx, rendered) in wrapped.into_iter().enumerate() {
let links = rendered
.links
.iter()
.map(|link| link.shifted(bullet_width))
.collect();
if idx == 0 {
let mut spans = vec![Span::styled(bullet_prefix.clone(), bullet_style)];
spans.extend(rendered.line.spans);
out.push(RenderedMarkdownLine {
line: Line::from(spans),
links,
is_code: false,
copy_prefix_width: 0,
copy_separator_after: rendered.copy_separator_after,
});
} else {
let mut spans = vec![Span::raw(" ".repeat(bullet_width))];
spans.extend(rendered.line.spans);
out.push(RenderedMarkdownLine {
line: Line::from(spans),
links,
is_code: false,
copy_prefix_width: bullet_width,
copy_separator_after: rendered.copy_separator_after,
});
}
}
out
}
fn render_quote_line_tagged(
text: &str,
depth: usize,
width: usize,
rail_style: Style,
text_style: Style,
) -> Vec<RenderedMarkdownLine> {
let depth = depth.clamp(1, MAX_QUOTE_DEPTH);
let rail = "│ ".repeat(depth);
let rail_width = rail.width();
let available = width.saturating_sub(rail_width).max(1);
let wrapped = render_line_with_links_tagged(text, available, text_style, link_style());
let mut out = Vec::new();
for (idx, rendered) in wrapped.into_iter().enumerate() {
let links = rendered
.links
.iter()
.map(|link| link.shifted(rail_width))
.collect();
let mut spans = if idx == 0 {
(0..depth).map(|_| Span::styled("│ ", rail_style)).collect()
} else {
vec![Span::raw(" ".repeat(rail_width))]
};
spans.extend(rendered.line.spans);
out.push(RenderedMarkdownLine {
line: Line::from(spans),
links,
is_code: false,
copy_prefix_width: if idx == 0 { 0 } else { rail_width },
copy_separator_after: rendered.copy_separator_after,
});
}
out
}
#[cfg(test)]
fn render_line_with_links(
line: &str,
width: usize,
base_style: Style,
link_style: Style,
) -> Vec<Line<'static>> {
render_line_with_links_tagged(line, width, base_style, link_style)
.into_iter()
.map(|rendered| rendered.line)
.collect()
}
fn render_line_with_links_tagged(
line: &str,
width: usize,
base_style: Style,
link_style: Style,
) -> Vec<RenderedMarkdownLine> {
if line.trim().is_empty() {
return vec![RenderedMarkdownLine {
line: Line::from(""),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
}];
}
let tokens = parse_inline_spans(line, base_style, link_style);
let mut words: Vec<InlineToken> = Vec::new();
for token in tokens {
let mut first = true;
for part in token.text.split(' ') {
if !first {
words.push(InlineToken::new(
" ".to_string(),
token.style,
token.link_url.clone(),
));
}
if !part.is_empty() {
words.push(InlineToken::new(
part.to_string(),
token.style,
token.link_url.clone(),
));
}
first = false;
}
}
let mut lines: Vec<RenderedMarkdownLine> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut current_links: Vec<osc8::LineLink> = Vec::new();
let mut current_width = 0usize;
for word in words {
let ww = word.text.width();
if word.text == " " {
if !current_spans.is_empty() && current_width < width {
current_spans.push(word.span_for(" ".to_string()));
record_inline_link(&mut current_links, &word, current_width, 1);
current_width += 1;
}
continue;
}
if ww > width && width > 0 {
if !current_spans.is_empty() {
push_inline_line(
&mut lines,
&mut current_spans,
&mut current_links,
CopyLineSeparator::Space,
);
current_width = 0;
}
let mut chunk = String::new();
let mut chunk_w = 0usize;
for grapheme in word.text.graphemes(true) {
let grapheme_width = grapheme.width();
if chunk_w + grapheme_width > width && chunk_w > 0 {
let chunk = std::mem::take(&mut chunk);
let mut links = Vec::new();
record_inline_link(&mut links, &word, 0, chunk_w);
lines.push(RenderedMarkdownLine {
line: Line::from(vec![word.span_for(chunk)]),
links,
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::None,
});
chunk_w = 0;
}
chunk.push_str(grapheme);
chunk_w += grapheme_width;
}
if !chunk.is_empty() {
record_inline_link(&mut current_links, &word, 0, chunk_w);
current_spans.push(word.span_for(chunk));
current_width = chunk_w;
}
continue;
}
if current_width > 0 && current_width + ww > width {
push_inline_line(
&mut lines,
&mut current_spans,
&mut current_links,
CopyLineSeparator::Space,
);
current_width = 0;
}
record_inline_link(&mut current_links, &word, current_width, ww);
current_spans.push(word.into_span());
current_width += ww;
}
if !current_spans.is_empty() {
push_inline_line(
&mut lines,
&mut current_spans,
&mut current_links,
CopyLineSeparator::Newline,
);
} else if let Some(last) = lines.last_mut() {
last.copy_separator_after = CopyLineSeparator::Newline;
}
if lines.is_empty() {
lines.push(RenderedMarkdownLine {
line: Line::from(""),
links: Vec::new(),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
lines
}
fn push_inline_line(
lines: &mut Vec<RenderedMarkdownLine>,
spans: &mut Vec<Span<'static>>,
links: &mut Vec<osc8::LineLink>,
copy_separator_after: CopyLineSeparator,
) {
if let Some(last) = spans.last()
&& last.content.as_ref() == " "
{
spans.pop();
}
let visible_width = spans
.iter()
.map(|span| span.content.as_ref().width())
.sum::<usize>();
links.retain(|link| link.col_start < visible_width);
for link in links.iter_mut() {
link.col_end = link.col_end.min(visible_width.saturating_sub(1));
}
lines.push(RenderedMarkdownLine {
line: Line::from(std::mem::take(spans)),
links: std::mem::take(links),
is_code: false,
copy_prefix_width: 0,
copy_separator_after,
});
}
fn record_inline_link(
links: &mut Vec<osc8::LineLink>,
token: &InlineToken,
col_start: usize,
width: usize,
) {
let Some(target) = token.link_url.as_ref() else {
return;
};
if width == 0 {
return;
}
let col_end = col_start.saturating_add(width).saturating_sub(1);
if let Some(last) = links.last_mut()
&& last.target == *target
&& last.col_end.saturating_add(1) == col_start
{
last.col_end = col_end;
return;
}
links.push(osc8::LineLink {
col_start,
col_end,
target: target.clone(),
});
}
#[derive(Clone)]
struct InlineToken {
text: String,
style: Style,
link_url: Option<String>,
}
impl InlineToken {
fn new(text: String, style: Style, link_url: Option<String>) -> Self {
Self {
text,
style,
link_url,
}
}
fn span_for(&self, text: String) -> Span<'static> {
Span::styled(text, self.style)
}
fn into_span(self) -> Span<'static> {
Span::styled(self.text, self.style)
}
}
fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<InlineToken> {
let bold_style = base_style.add_modifier(Modifier::BOLD);
let italic_style = base_style.add_modifier(Modifier::ITALIC);
let code_style = base_style
.add_modifier(Modifier::ITALIC)
.bg(palette::SURFACE_ELEVATED);
let strike_style = base_style.add_modifier(Modifier::CROSSED_OUT);
let mut out = Vec::new();
let mut rest = line;
while !rest.is_empty() {
if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) {
let inner = &rest[2..2 + end];
out.push(InlineToken::new(inner.to_string(), bold_style, None));
rest = &rest[2 + end + 2..];
continue;
}
if let Some(end) = rest.strip_prefix("__").and_then(|s| s.find("__")) {
let inner = &rest[2..2 + end];
out.push(InlineToken::new(inner.to_string(), bold_style, None));
rest = &rest[2 + end + 2..];
continue;
}
if rest.starts_with('*')
&& !rest.starts_with("**")
&& let Some(end) = rest[1..].find('*')
{
let inner = &rest[1..1 + end];
let after = &rest[1 + end + 1..];
if !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
out.push(InlineToken::new(inner.to_string(), italic_style, None));
rest = after;
continue;
}
}
if rest.starts_with('_')
&& !rest.starts_with("__")
&& let Some(end) = rest[1..].find('_')
{
let inner = &rest[1..1 + end];
let after = &rest[1 + end + 1..];
if !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
out.push(InlineToken::new(inner.to_string(), italic_style, None));
rest = after;
continue;
}
}
if let Some(end) = rest.strip_prefix('`').and_then(|s| s.find('`')) {
let inner = &rest[1..1 + end];
out.push(InlineToken::new(inner.to_string(), code_style, None));
rest = &rest[1 + end + 1..];
continue;
}
if let Some(end) = rest.strip_prefix("~~").and_then(|s| s.find("~~")) {
let inner = &rest[2..2 + end];
out.push(InlineToken::new(inner.to_string(), strike_style, None));
rest = &rest[2 + end + 2..];
continue;
}
if rest.starts_with('[')
&& let Some(bracket_end) = rest.find(']')
{
let text = &rest[1..bracket_end];
let after_bracket = &rest[bracket_end + 1..];
if after_bracket.starts_with('(')
&& let Some(paren_end) = after_bracket.find(')')
{
let url = &after_bracket[1..paren_end];
out.push(InlineToken::new(
text.to_string(),
link_style,
normalized_link_target(url),
));
rest = &after_bracket[paren_end + 1..];
continue;
}
}
if rest.starts_with("http://") || rest.starts_with("https://") {
let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let token = &rest[..token_end];
let url_end = trailing_url_end(token);
let url = &token[..url_end];
out.push(InlineToken::new(
url.to_string(),
link_style,
normalized_http_link_target(url),
));
if url_end < token_end {
out.push(InlineToken::new(
token[url_end..].to_string(),
base_style,
None,
));
}
rest = &rest[token_end..];
continue;
}
let next = find_next_marker(rest).max(rest.chars().next().map_or(1, |c| c.len_utf8()));
out.push(InlineToken::new(rest[..next].to_string(), base_style, None));
rest = &rest[next..];
}
out
}
fn normalized_link_target(target: &str) -> Option<String> {
normalized_http_link_target(target).or_else(|| normalized_file_link_target(target))
}
fn normalized_file_link_target(target: &str) -> Option<String> {
let path = match target.get(..7) {
Some(prefix) if prefix.eq_ignore_ascii_case("file://") => &target[7..],
_ => target,
};
if !path.starts_with('/') || path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) {
return None;
}
Some(format!("file://{path}"))
}
fn normalized_http_link_target(target: &str) -> Option<String> {
let (scheme, rest) = if target
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
{
("https://", &target[8..])
} else if target
.get(..7)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
{
("http://", &target[7..])
} else {
return None;
};
if rest.is_empty()
|| rest.chars().any(|ch| ch.is_whitespace() || ch.is_control())
|| rest.split(['/', '?', '#']).next().is_none_or(str::is_empty)
{
return None;
}
Some(format!("{scheme}{rest}"))
}
fn trailing_url_end(candidate: &str) -> usize {
let mut end = candidate.len();
while end > 0 {
let remaining = &candidate[..end];
let Some(ch) = remaining.chars().next_back() else {
break;
};
let trim = matches!(ch, ',' | '.' | ';' | '!' | '\'' | '"')
|| matches!(ch, ')' | ']' | '}' | '>')
&& has_unmatched_closing_delimiter(remaining, ch);
if !trim {
break;
}
end -= ch.len_utf8();
}
end
}
fn has_unmatched_closing_delimiter(candidate: &str, closing: char) -> bool {
let opening = match closing {
')' => '(',
']' => '[',
'}' => '{',
'>' => '<',
_ => return false,
};
candidate.chars().filter(|ch| *ch == closing).count()
> candidate.chars().filter(|ch| *ch == opening).count()
}
fn find_next_marker(s: &str) -> usize {
let mut i = 0;
let bytes = s.as_bytes();
while i < bytes.len() {
let ch_len = s[i..].chars().next().map_or(1, |c| c.len_utf8());
let slice = &s[i..];
if slice.starts_with("**")
|| slice.starts_with("__")
|| slice.starts_with("~~")
|| slice.starts_with('`')
|| slice.starts_with('[')
|| (slice.starts_with('*') && !slice.starts_with("**"))
|| (slice.starts_with('_') && !slice.starts_with("__"))
|| slice.starts_with("http://")
|| slice.starts_with("https://")
{
return i;
}
i += ch_len;
}
s.len()
}
fn is_horizontal_rule(line: &str) -> bool {
let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect();
(stripped.chars().all(|c| c == '-')
|| stripped.chars().all(|c| c == '*')
|| stripped.chars().all(|c| c == '_'))
&& stripped.len() >= 3
}
fn parse_table_row(line: &str) -> Option<Vec<String>> {
if !line.starts_with('|') {
return None;
}
let inner = line.trim_matches('|');
let cells = split_table_cells(inner);
if cells
.iter()
.all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
{
return None;
}
Some(cells)
}
fn split_table_cells(inner: &str) -> Vec<String> {
let mut cells = Vec::new();
let mut current = String::new();
let mut in_code = false;
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if matches!(chars.peek(), Some('|')) {
current.push('|');
let _ = chars.next();
} else {
current.push(ch);
}
}
'`' => {
in_code = !in_code;
current.push(ch);
}
'|' if !in_code => {
cells.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
cells.push(current.trim().to_string());
cells
}
fn wrap_cell_text(cell: &str, col_width: usize) -> Vec<String> {
if cell.is_empty() || cell.width() <= col_width {
return vec![cell.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_w = 0usize;
for word in cell.split_whitespace() {
let word_w = word.width();
if current_w == 0 {
if word_w > col_width {
push_word_breaking_graphemes(
word,
col_width,
&mut current,
&mut current_w,
&mut lines,
);
} else {
current.push_str(word);
current_w = word_w;
}
} else if current_w + 1 + word_w <= col_width {
current.push(' ');
current.push_str(word);
current_w += 1 + word_w;
} else {
lines.push(std::mem::take(&mut current));
current_w = 0;
if word_w > col_width {
push_word_breaking_graphemes(
word,
col_width,
&mut current,
&mut current_w,
&mut lines,
);
} else {
current.push_str(word);
current_w = word_w;
}
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec<Line<'static>> {
if cells.is_empty() {
return vec![Line::from("")];
}
let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();
let col_width = col_width.max(4);
let sep_style = Style::default().fg(palette::TEXT_DIM);
let wrapped: Vec<Vec<String>> = cells.iter().map(|c| wrap_cell_text(c, col_width)).collect();
let row_height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(row_height);
for row in 0..row_height {
let mut spans: Vec<Span> = vec![Span::styled("│ ".to_string(), sep_style)];
for (i, cell_segments) in wrapped.iter().enumerate() {
let segment = cell_segments.get(row).map(String::as_str).unwrap_or("");
let cell_spans = parse_inline_spans(segment, base_style, link_style());
let cell_width: usize = cell_spans.iter().map(|token| token.text.width()).sum();
let pad = col_width.saturating_sub(cell_width);
for token in cell_spans {
spans.push(token.into_span());
}
spans.push(Span::raw(" ".repeat(pad)));
if i + 1 < cells.len() {
spans.push(Span::styled(" │ ".to_string(), sep_style));
} else {
spans.push(Span::styled(" │".to_string(), sep_style));
}
}
lines.push(Line::from(spans));
}
lines
}
fn table_col_width(num_cols: usize, term_width: usize) -> usize {
let col_width = (term_width.saturating_sub(3 * num_cols + 1)) / num_cols;
col_width.max(4)
}
fn render_table_border(
num_cols: usize,
col_width: usize,
sep_style: Style,
left: &str,
mid: &str,
right: &str,
) -> Line<'static> {
let fill = "\u{2500}".repeat(col_width);
let mut s = String::new();
s.push_str(left);
for i in 0..num_cols {
s.push_str(&fill);
if i + 1 < num_cols {
s.push_str(mid);
} else {
s.push_str(right);
}
}
Line::from(Span::styled(s, sep_style))
}
fn render_table_group(blocks: &[Block], width: usize, base_style: Style) -> Vec<Line<'static>> {
let sep_style = Style::default().fg(palette::TEXT_DIM);
let num_cols = blocks
.iter()
.filter_map(|b| match b {
Block::TableRow(cells) => Some(cells.len()),
_ => None,
})
.max()
.unwrap_or(1);
let col_width = table_col_width(num_cols, width);
let mut lines = Vec::new();
lines.push(render_table_border(
num_cols,
col_width,
sep_style,
"\u{250C}\u{2500}",
"\u{2500}\u{252C}\u{2500}",
"\u{2500}\u{2510}",
));
let mid_border = || {
render_table_border(
num_cols,
col_width,
sep_style,
"\u{251C}\u{2500}",
"\u{2500}\u{253C}\u{2500}",
"\u{2500}\u{2524}",
)
};
for i in 0..blocks.len() {
match &blocks[i] {
Block::TableRow(cells) => {
lines.extend(render_table_row(cells, width, base_style));
if i + 1 < blocks.len() && matches!(&blocks[i + 1], Block::TableRow(_)) {
lines.push(mid_border());
}
}
Block::TableSeparator => {
lines.push(mid_border());
}
_ => {}
}
}
lines.push(render_table_border(
num_cols,
col_width,
sep_style,
"\u{2514}\u{2500}",
"\u{2500}\u{2534}\u{2500}",
"\u{2500}\u{2518}",
));
lines
}
fn link_style() -> Style {
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::UNDERLINED)
}
fn markdown_grapheme_width(grapheme: &str, col: usize) -> usize {
if grapheme == "\t" {
return 8 - (col % 8); }
if let Some(ch) = grapheme.chars().next()
&& ch.len_utf8() == grapheme.len()
{
return match ch {
'\u{2460}'..='\u{24FF}' | '\u{2776}'..='\u{2793}' | '\u{3248}'..='\u{324F}' => 2,
_ => ch.width().unwrap_or(1),
};
}
if grapheme.contains('\u{20e3}') {
return 2;
}
grapheme.width()
}
fn wrap_code_line(line: &str, width: usize) -> Vec<String> {
if width == 0 || line.is_empty() {
return vec![line.to_string()];
}
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for grapheme in line.graphemes(true) {
let grapheme_width = markdown_grapheme_width(grapheme, current_width);
if current_width + grapheme_width > width && !current.is_empty() {
chunks.push(current);
current = String::new();
current_width = 0;
}
current.push_str(grapheme);
current_width += grapheme_width;
}
chunks.push(current);
chunks
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = word.width();
if word_width > width {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
push_word_breaking_graphemes(word, width, &mut current, &mut current_width, &mut lines);
continue;
}
let additional = if current.is_empty() {
word_width
} else {
word_width + 1
};
if current_width + additional > width && !current.is_empty() {
lines.push(current);
current = word.to_string();
current_width = word_width;
} else {
if !current.is_empty() {
current.push(' ');
current_width += 1;
}
current.push_str(word);
current_width += word_width;
}
}
if current.is_empty() {
lines.push(String::new());
} else {
lines.push(current);
}
lines
}
fn push_word_breaking_graphemes(
word: &str,
width: usize,
current: &mut String,
current_width: &mut usize,
lines: &mut Vec<String>,
) {
for grapheme in word.graphemes(true) {
let grapheme_width = grapheme.width();
if *current_width + grapheme_width > width && *current_width > 0 {
lines.push(std::mem::take(current));
*current_width = 0;
}
current.push_str(grapheme);
*current_width += grapheme_width;
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
fn visible_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect()
}
fn rendered_fingerprint(lines: &[RenderedMarkdownLine]) -> Vec<String> {
lines
.iter()
.map(|line| {
format!(
"{:?}|{:?}|{}|{}|{:?}",
line.line,
line.links,
line.is_code,
line.copy_prefix_width,
line.copy_separator_after
)
})
.collect()
}
fn update_incremental_render(
cache: &mut IncrementalMarkdownRenderCache,
rendered: &mut Vec<RenderedMarkdownLine>,
source: &str,
width: u16,
palette_mode: palette::PaletteMode,
verified_append: bool,
) {
update_incremental_render_with_style(
cache,
rendered,
source,
width,
Style::default(),
palette_mode,
verified_append,
);
}
fn update_incremental_render_with_style(
cache: &mut IncrementalMarkdownRenderCache,
rendered: &mut Vec<RenderedMarkdownLine>,
source: &str,
width: u16,
base_style: Style,
palette_mode: palette::PaletteMode,
verified_append: bool,
) {
let delta = cache.update(source, width, base_style, palette_mode, verified_append);
rendered.truncate(delta.replace_from);
rendered.extend(delta.lines);
}
#[test]
fn incremental_render_is_exact_and_linear_for_unicode_fences_and_tables() {
let mut cache = IncrementalMarkdownRenderCache::default();
let mut rendered = Vec::new();
let mut source = String::new();
let chunks = 80usize;
for index in 0..chunks {
source.push_str(&format!(
"## 段落 {index}\nUnicode e\u{301} 世界 🚀\n```rust\nlet 値_{index}: usize = {index}; // 注釈\n```\n| key | value |\n|---|---|\n| {index} | 世界 |\n\n"
));
update_incremental_render(
&mut cache,
&mut rendered,
&source,
96,
palette::PaletteMode::Dark,
index > 0,
);
let cold = render_markdown_tagged_with_palette(
&source,
96,
Style::default(),
palette::PaletteMode::Dark,
);
assert_eq!(
rendered_fingerprint(&rendered),
rendered_fingerprint(&cold),
"incremental output diverged after chunk {index}"
);
}
let work = cache.work();
let parsed = reference_parse(&source);
assert_eq!(work.classified_lines as usize, source.lines().count());
assert_eq!(work.stable_blocks_rendered as usize, parsed.blocks.len());
assert_eq!(work.tail_blocks_rendered, 0);
assert_eq!(work.invalidations, 1);
}
#[test]
fn incremental_render_invalidates_on_mutation_width_theme_and_style() {
let mut cache = IncrementalMarkdownRenderCache::default();
let mut rendered = Vec::new();
let mut source = "alpha\n```rust\nlet value = 1;\n```\n".to_string();
update_incremental_render(
&mut cache,
&mut rendered,
&source,
80,
palette::PaletteMode::Dark,
false,
);
source.push_str("tail 世界\n");
update_incremental_render(
&mut cache,
&mut rendered,
&source,
80,
palette::PaletteMode::Dark,
true,
);
source.replace_range(..5, "ALPHA");
update_incremental_render(
&mut cache,
&mut rendered,
&source,
80,
palette::PaletteMode::Dark,
false,
);
let mutated = render_markdown_tagged_with_palette(
&source,
80,
Style::default(),
palette::PaletteMode::Dark,
);
assert_eq!(
rendered_fingerprint(&rendered),
rendered_fingerprint(&mutated)
);
update_incremental_render(
&mut cache,
&mut rendered,
&source,
37,
palette::PaletteMode::Dark,
true,
);
update_incremental_render(
&mut cache,
&mut rendered,
&source,
37,
palette::PaletteMode::Light,
true,
);
let changed_style = Style::default().add_modifier(Modifier::ITALIC);
update_incremental_render_with_style(
&mut cache,
&mut rendered,
&source,
37,
changed_style,
palette::PaletteMode::Light,
true,
);
let rethemed = render_markdown_tagged_with_palette(
&source,
37,
changed_style,
palette::PaletteMode::Light,
);
assert_eq!(
rendered_fingerprint(&rendered),
rendered_fingerprint(&rethemed)
);
assert_eq!(cache.work().invalidations, 5);
}
#[test]
fn incremental_render_drops_committed_source_without_a_large_answer_cliff() {
let line = format!("{}\n", "x".repeat(16 * 1024));
let mut source = String::new();
let mut cache = IncrementalMarkdownRenderCache::default();
let mut rendered = Vec::new();
for index in 0..80 {
source.push_str(&line);
update_incremental_render(
&mut cache,
&mut rendered,
&source,
u16::MAX,
palette::PaletteMode::Dark,
index > 0,
);
assert_eq!(cache.retained_source_bytes(), 0);
}
assert!(source.len() > 1024 * 1024);
assert_eq!(cache.retained_source_bytes(), 0);
assert_eq!(cache.work().classified_lines, 80);
assert_eq!(cache.work().stable_blocks_rendered, 80);
assert_eq!(cache.work().invalidations, 1);
}
#[test]
fn underscores_inside_identifiers_render_as_literal_text() {
let cases = [
"crate codewhale_tui handles approvals",
"see foo_bar_baz for details",
"look at *not_emphasised*tail",
];
for source in cases {
let parsed = parse(source);
let rendered: String = render_parsed(&parsed, 80, Style::default())
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect();
for token in source.split_whitespace().filter(|t| t.contains('_')) {
assert!(
rendered.contains(token),
"identifier {token:?} must survive markdown rendering of {source:?}; got {rendered:?}"
);
}
}
}
#[test]
fn render_markdown_matches_parse_then_render() {
let source = "# Title\n\nA paragraph with a https://example.com link.\n\n- one\n- two\n```\ncode\n```";
let direct = render_markdown(source, 80, Style::default())
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<String>();
let parsed = parse(source);
let two_step = render_parsed(&parsed, 80, Style::default())
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<String>();
assert_eq!(direct, two_step);
}
#[test]
fn render_plain_text_preserves_literal_markdown_and_spacing() {
let source = " # heading\n- item\n \nhello world\n";
let lines = render_plain_text(source, 80, Style::default());
assert_eq!(
visible_lines(&lines),
vec![" # heading", "- item", " ", "hello world", ""]
);
}
#[test]
fn render_plain_text_wraps_without_collapsing_spaces() {
let source = "alpha beta gamma";
let lines = render_plain_text(source, 12, Style::default());
for width in rendered_widths(&lines) {
assert!(width <= 12, "rendered width {width} exceeds budget");
}
let combined = visible_lines(&lines).join("");
assert_eq!(combined, source);
}
#[test]
fn render_plain_text_breaks_overlong_words() {
let source = "x".repeat(40);
let lines = render_plain_text(&source, 9, Style::default());
for width in rendered_widths(&lines) {
assert!(width <= 9, "rendered width {width} exceeds budget");
}
let combined = visible_lines(&lines).join("");
assert_eq!(combined, source);
}
#[test]
fn parse_is_width_independent() {
let source = "Hello\n\n## Heading\n- list\n";
let a = parse(source);
let b = parse(source);
assert_eq!(a, b);
}
#[test]
fn render_parsed_word_wrap_changes_with_width() {
let parsed = parse("alpha beta gamma delta epsilon zeta");
let wide = render_parsed(&parsed, 80, Style::default());
let narrow = render_parsed(&parsed, 10, Style::default());
assert!(
narrow.len() > wide.len(),
"narrow should produce more lines"
);
}
#[test]
fn parse_invocations_increment() {
reset_parse_invocation_count();
let _ = parse("hello\n");
let _ = parse("world\n");
assert_eq!(parse_invocation_count(), 2);
}
#[test]
fn render_parsed_does_not_call_parse() {
let parsed = parse("multiline\nsource\nwith several\nlines\n");
reset_parse_invocation_count();
let _ = render_parsed(&parsed, 80, Style::default());
let _ = render_parsed(&parsed, 40, Style::default());
let _ = render_parsed(&parsed, 20, Style::default());
assert_eq!(
parse_invocation_count(),
0,
"render_parsed must not call parse"
);
}
fn streaming_corpus() -> Vec<&'static str> {
vec![
"# Title\n\nSome prose that wraps.\n\n- alpha\n- beta\n",
"text\n```rust\nlet x = 1;\nlet y = 2;\n```\nafter\n",
"```\nunterminated fence never closes\nstill inside\n",
"| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\ndone\n",
"1. one\n2. two\n * nested\n\n## Sub\n\n***\n",
"混合 CJK 内容\n\n```python\nprint(\"中文\")\n```\n尾部\n",
"crlf lines\r\nsecond\r\n\r\n```go\nfmt.Println()\r\n```\r\n",
"no trailing newline at all",
"",
]
}
#[test]
fn incremental_parse_matches_a_full_reparse_at_every_prefix() {
for source in streaming_corpus() {
for end in 0..=source.len() {
if !source.is_char_boundary(end) {
continue;
}
let prefix = &source[..end];
let streamed = parse(prefix);
let mut cold = ParseState::default();
cold.commit_complete_lines(prefix);
let reference = cold.snapshot(prefix);
assert_eq!(
streamed, reference,
"prefix {end} of {source:?} diverged from a full re-parse"
);
}
}
}
#[test]
fn streaming_does_not_reclassify_committed_lines() {
let chunk = "a line of prose\n";
let chunks = 400;
let mut content = String::new();
let mut state = ParseState::default();
let mut total_committed = 0usize;
for _ in 0..chunks {
content.push_str(chunk);
assert!(
state.can_resume_from(&content),
"an append-only stream must always be resumable"
);
let before = state.blocks.len();
state.commit_complete_lines(&content);
total_committed += state.blocks.len() - before;
}
assert_eq!(
total_committed, chunks,
"each line must be classified exactly once across the whole stream"
);
assert_eq!(state.blocks.len(), chunks);
}
#[test]
fn a_changed_prefix_is_not_resumable() {
let mut state = ParseState::default();
state.commit_complete_lines("first line\nsecond line\n");
assert!(state.can_resume_from("first line\nsecond line\nthird\n"));
assert!(!state.can_resume_from("FIRST line\nsecond line\nthird\n"));
assert!(!state.can_resume_from("first line\n"));
assert!(!state.can_resume_from("something else\n"));
}
#[test]
fn interleaved_sources_do_not_contaminate_each_other() {
let a = "# Alpha\n\nalpha body\n";
let b = "```rust\nlet b = 1;\n```\n";
for _ in 0..5 {
assert_eq!(parse(a), reference_parse(a));
assert_eq!(parse(b), reference_parse(b));
}
}
fn reference_parse(content: &str) -> ParsedMarkdown {
let mut cold = ParseState::default();
cold.commit_complete_lines(content);
cold.snapshot(content)
}
#[test]
fn fenced_code_block_collected_in_parse() {
let parsed = parse("text\n```rust\ncode line one\ncode line two\n```\nmore\n");
let blocks = &parsed.blocks;
let code_lines: Vec<_> = blocks
.iter()
.filter_map(|b| match b {
Block::Code {
line,
language,
block_id,
} => Some((line.as_str(), language.as_deref(), *block_id)),
_ => None,
})
.collect();
assert_eq!(
code_lines,
vec![
("code line one", Some("rust"), 1),
("code line two", Some("rust"), 1),
]
);
}
#[test]
fn adjacent_code_fences_keep_distinct_highlighter_state() {
let parsed = parse("```rust\n/* open\n```\n```rust\nlet x = 1;\n```\n");
let ids = parsed
.blocks
.iter()
.filter_map(|block| match block {
Block::Code { block_id, .. } => Some(*block_id),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn rust_fence_renders_multiple_syntax_foregrounds_without_reserved_rgb() {
let rendered = render_markdown_tagged(
"```rust\nfn main() {\n let answer: u32 = 42; // comment\n}\n```",
100,
Style::default(),
);
let colors = rendered
.iter()
.flat_map(|line| line.line.spans.iter())
.filter_map(|span| span.style.fg)
.collect::<std::collections::HashSet<_>>();
assert!(colors.len() > 1, "expected syntax colors, got: {colors:?}");
for color in colors {
assert_ne!(color, palette::WHALE_HUMAN);
assert_ne!(color, palette::WHALE_LIVE);
assert_ne!(color, palette::WHALE_ACTION);
assert_ne!(color, palette::WHALE_ERROR);
}
}
#[test]
fn syntax_colors_use_existing_depth_quantizer_and_grayscale_path() {
assert!(matches!(
syntax_rgb_to_terminal_color(
120,
80,
200,
palette::PaletteMode::Dark,
palette::ColorDepth::Ansi256,
),
Color::Indexed(_)
));
assert!(matches!(
syntax_rgb_to_terminal_color(
120,
80,
200,
palette::PaletteMode::Dark,
palette::ColorDepth::Ansi16,
),
Color::Black
| Color::Red
| Color::Green
| Color::Yellow
| Color::Blue
| Color::Magenta
| Color::Cyan
| Color::Gray
| Color::DarkGray
| Color::LightRed
| Color::LightGreen
| Color::LightYellow
| Color::LightBlue
| Color::LightMagenta
| Color::LightCyan
| Color::White
));
let gray = syntax_rgb_to_terminal_color(
120,
80,
200,
palette::PaletteMode::Grayscale,
palette::ColorDepth::TrueColor,
);
assert!(matches!(gray, Color::Rgb(r, g, b) if r == g && g == b));
}
#[test]
fn syntax_assets_are_lazy_singletons_and_explicit_modes_select_themes() {
assert!(std::ptr::eq(syntax_set(), syntax_set()));
assert!(std::ptr::eq(theme_set(), theme_set()));
assert!(!std::ptr::eq(
selected_syntax_theme(palette::PaletteMode::Dark),
selected_syntax_theme(palette::PaletteMode::Light),
));
}
#[test]
fn depth_quantization_cannot_reintroduce_reserved_semantic_colors() {
let reserved = [
palette::WHALE_HUMAN,
palette::WHALE_LIVE,
palette::WHALE_ACTION,
palette::WHALE_ERROR,
];
for depth in [
palette::ColorDepth::TrueColor,
palette::ColorDepth::Ansi256,
palette::ColorDepth::Ansi16,
] {
let reserved_at_depth = reserved.map(|color| palette::adapt_color(color, depth));
for semantic in reserved {
let Color::Rgb(r, g, b) = semantic else {
panic!("reserved syntax guard expects RGB semantic colors");
};
let syntax =
syntax_rgb_to_terminal_color(r, g, b, palette::PaletteMode::Dark, depth);
assert!(
!reserved_at_depth.contains(&syntax),
"{syntax:?} reintroduced a reserved color at {depth:?}"
);
}
}
}
#[test]
fn code_block_indentation_is_preserved_in_render() {
let md = "```\nfn main() {\n println!(\"hi\");\n}\n```\n";
let lines = render_markdown(md, 80, Style::default());
let text: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect();
let indented = text
.iter()
.find(|t| t.contains("println"))
.expect("should find println line");
assert!(
indented.starts_with(" "),
"expected 6+ leading spaces (2 block prefix + 4 indent), got: {indented:?}"
);
}
#[test]
fn wrap_code_line_preserves_leading_whitespace() {
assert_eq!(wrap_code_line(" let x = 1;", 80), vec![" let x = 1;"]);
let chunks = wrap_code_line(" abcdefgh", 8);
assert_eq!(chunks[0], " abcd", "first chunk keeps leading spaces");
assert_eq!(chunks[1], "efgh");
assert_eq!(wrap_code_line("", 80), vec![""]);
}
#[test]
fn wrap_code_line_tab_counts_toward_width() {
let chunks = wrap_code_line("\txy", 10);
assert_eq!(chunks, vec!["\txy"], "tab + 2 chars fits in width 10");
let chunks = wrap_code_line("\txy", 9);
assert_eq!(chunks[0], "\tx", "tab + first char fits exactly");
assert_eq!(chunks[1], "y", "second char wraps");
let chunks = wrap_code_line("\tx", 8);
assert_eq!(chunks[0], "\t");
assert_eq!(chunks[1], "x");
}
#[test]
fn markdown_grapheme_width_uses_tab_stop_and_string_width() {
assert_eq!(markdown_grapheme_width("\t", 0), 8);
assert_eq!(markdown_grapheme_width("\t", 4), 4);
assert_eq!(markdown_grapheme_width("\t", 8), 8);
assert_eq!(markdown_grapheme_width("a", 0), 1);
assert_eq!(markdown_grapheme_width("1\u{fe0f}\u{20e3}", 0), 2);
}
#[test]
fn ordered_and_unordered_list_items_parse() {
let parsed = parse("- alpha\n* beta\n1. gamma\n");
let items: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::ListItem { bullet, text } => Some((bullet.as_str(), text.as_str())),
_ => None,
})
.collect();
assert_eq!(items, vec![("-", "alpha"), ("-", "beta"), ("1.", "gamma")]);
}
#[test]
fn blockquote_lines_parse_with_depth() {
let parsed =
parse("> hello\n>\n>> nested\n> > spaced\n>no-space\n>\t tabbed\nlone > arrow\n");
let quotes: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::Quote { depth, text } => Some((*depth, text.as_str())),
_ => None,
})
.collect();
assert_eq!(
quotes,
vec![
(1, "hello"),
(1, ""),
(2, "nested"),
(2, "spaced"),
(1, "no-space"),
(1, "tabbed"),
]
);
assert_eq!(parsed.blocks.len(), 7);
}
#[test]
fn code_fence_contains_quote_lines_untouched() {
let parsed = parse("```\n> not a quote\n\n> but this is\n```\n");
let code: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::Code { line, .. } => Some(line.as_str()),
_ => None,
})
.collect();
assert_eq!(code, vec!["> not a quote", "", "> but this is"]);
assert!(
parsed
.blocks
.iter()
.all(|b| !matches!(b, Block::Quote { .. })),
"lines inside a fence must stay code, never quotes"
);
}
#[test]
fn four_backtick_fence_keeps_shorter_fence_and_quotes_as_code() {
let parsed = parse("````\n```\n> still code\n`````\n> now a quote\n");
let code: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::Code { line, .. } => Some(line.as_str()),
_ => None,
})
.collect();
assert_eq!(code, vec!["```", "> still code"]);
let quotes: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::Quote { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(quotes, vec!["now a quote"]);
}
#[test]
fn longer_fence_closes_shorter_opener() {
let parsed = parse("```\ncode\n````\nplain\n");
let code: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::Code { line, .. } => Some(line.as_str()),
_ => None,
})
.collect();
assert_eq!(code, vec!["code"]);
let paragraphs: Vec<_> = parsed
.blocks
.iter()
.filter_map(|b| match b {
Block::Paragraph { text } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(paragraphs, vec!["plain"]);
}
#[test]
fn backticks_with_info_do_not_close_an_open_fence() {
let parsed = parse("```\n```rust\n> still code\n```\n");
let code: Vec<_> = parsed
.blocks
.iter()
.filter_map(|block| match block {
Block::Code { line, .. } => Some(line.as_str()),
_ => None,
})
.collect();
assert_eq!(code, vec!["```rust", "> still code"]);
assert!(
parsed
.blocks
.iter()
.all(|block| !matches!(block, Block::Quote { .. }))
);
}
#[test]
fn blockquote_renders_rail_and_inline_formatting() {
let rendered = render_markdown_tagged(
"> **bold** `code` and see https://example.com",
80,
Style::default(),
);
assert_eq!(
tagged_visible(&rendered),
vec!["│ bold code and see https://example.com"]
);
let spans = &rendered[0].line.spans;
assert_eq!(spans[0].content, "│ ");
assert!(
spans
.iter()
.any(|span| span.content == "bold"
&& span.style.add_modifier.contains(Modifier::BOLD)),
"inline bold must survive inside a quote"
);
assert!(
spans
.iter()
.any(|span| span.content == "code"
&& span.style.bg == Some(palette::SURFACE_ELEVATED)),
"inline code is styled distinctly from plain text"
);
assert_eq!(rendered[0].copy_prefix_width, 0);
assert_eq!(
rendered[0].links,
vec![osc8::LineLink {
col_start: 20,
col_end: 38,
target: "https://example.com".to_string(),
}]
);
}
#[test]
fn nested_blockquote_renders_multiple_rails_capped() {
let rendered =
render_markdown_tagged(">>> deep\n>>>>>>>>>> too deep\n", 80, Style::default());
assert_eq!(
tagged_visible(&rendered),
vec!["│ │ │ deep", "│ │ │ │ too deep"]
);
assert_eq!(
rendered[0]
.line
.spans
.iter()
.take(3)
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
vec!["│ ", "│ ", "│ "],
"each rail must remain independently discoverable by selection copy"
);
}
#[test]
fn blockquote_wraps_with_continuation_rail_indent() {
let source = "> alpha beta gamma delta epsilon zeta";
let rendered = render_markdown_tagged(source, 12, Style::default());
let visible = tagged_visible(&rendered);
assert!(visible.len() > 1, "fixture must wrap: {visible:?}");
assert!(visible[0].starts_with("│ "), "first row starts with rail");
assert_eq!(rendered[0].copy_prefix_width, 0);
for row in rendered.iter().skip(1) {
assert_eq!(row.copy_prefix_width, 2);
}
for row in &visible[1..] {
assert!(
row.starts_with(" "),
"continuation rows keep the rail width indent: {row:?}"
);
assert!(
!row.starts_with('│'),
"rail appears only on the first row: {row:?}"
);
}
for width in rendered.iter().map(|row| {
row.line
.spans
.iter()
.map(|span| span.content.as_ref().width())
.sum::<usize>()
}) {
assert!(width <= 12, "rendered width {width} exceeds budget");
}
let combined = visible
.iter()
.map(|row| row.trim_start_matches('│').trim_start())
.collect::<Vec<_>>()
.join(" ");
assert_eq!(combined, &source[2..]);
}
fn tagged_visible(lines: &[RenderedMarkdownLine]) -> Vec<String> {
lines
.iter()
.map(|rendered| {
rendered
.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect()
}
#[test]
fn http_links_keep_visible_text_and_out_of_band_metadata() {
let source = "see https://example.com for details";
let rendered = render_markdown_tagged(source, 80, Style::default());
assert_eq!(tagged_visible(&rendered), vec![source]);
assert!(
rendered
.iter()
.flat_map(|line| &line.line.spans)
.all(|span| { !span.content.contains('\x1b') && !span.content.contains("]8;;") }),
"escape payloads must never enter visible spans"
);
assert_eq!(
rendered[0].links,
vec![osc8::LineLink {
col_start: 4,
col_end: 22,
target: "https://example.com".to_string(),
}]
);
}
#[test]
fn bare_http_links_exclude_surrounding_punctuation_from_target() {
let source = "see (https://example.com/path).";
let rendered = render_markdown_tagged(source, 80, Style::default());
assert_eq!(tagged_visible(&rendered), vec![source]);
assert_eq!(rendered[0].links.len(), 1);
let link = &rendered[0].links[0];
assert_eq!(link.target, "https://example.com/path");
assert_eq!(link.col_start, 5);
assert_eq!(link.col_end, 28);
}
#[test]
fn bare_http_links_preserve_balanced_parentheses_in_target() {
let url = "https://en.wikipedia.org/wiki/Function_(mathematics)";
let source = format!("see {url}.");
let rendered = render_markdown_tagged(&source, 100, Style::default());
assert_eq!(tagged_visible(&rendered), vec![source]);
assert_eq!(rendered[0].links.len(), 1);
assert_eq!(rendered[0].links[0].target, url);
}
#[test]
fn wrapped_url_chunks_keep_visible_label_and_full_target() {
let url = "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
let rendered = render_markdown_tagged(url, 34, Style::default());
let visible = tagged_visible(&rendered);
assert!(visible.len() > 1, "fixture must wrap: {visible:?}");
assert_eq!(visible.concat(), url);
for (line, text) in rendered.iter().zip(&visible) {
assert_eq!(line.links.len(), 1, "each wrapped chunk is linked");
assert_eq!(line.links[0].target, url);
assert_eq!(line.links[0].col_start, 0);
assert_eq!(line.links[0].col_end, text.width().saturating_sub(1));
assert!(!text.contains('\x1b') && !text.contains("]8;;"));
}
}
#[test]
fn named_link_shows_only_label_and_keeps_target_in_metadata() {
let rendered = render_markdown_tagged(
"read [the docs](https://example.com/guide) now",
80,
Style::default(),
);
assert_eq!(tagged_visible(&rendered), vec!["read the docs now"]);
assert_eq!(
rendered[0].links,
vec![osc8::LineLink {
col_start: 5,
col_end: 12,
target: "https://example.com/guide".to_string(),
}]
);
}
#[test]
fn named_links_reject_non_web_schemes_and_normalize_http_scheme() {
let unsafe_link = render_markdown_tagged("[run](javascript:alert)", 80, Style::default());
assert_eq!(tagged_visible(&unsafe_link), vec!["run"]);
assert!(unsafe_link.iter().all(|line| line.links.is_empty()));
let web_link =
render_markdown_tagged("[docs](HTTPS://example.com/guide)", 80, Style::default());
assert_eq!(tagged_visible(&web_link), vec!["docs"]);
assert_eq!(web_link[0].links[0].target, "https://example.com/guide");
}
#[test]
fn named_links_target_absolute_paths_with_the_file_scheme() {
let rendered = render_markdown_tagged(
"edit [main.rs](/repo/src/main.rs) now",
80,
Style::default(),
);
assert_eq!(tagged_visible(&rendered), vec!["edit main.rs now"]);
assert_eq!(
rendered[0].links,
vec![osc8::LineLink {
col_start: 5,
col_end: 11,
target: "file:///repo/src/main.rs".to_string(),
}]
);
let explicit =
render_markdown_tagged("[main.rs](FILE:///repo/src/main.rs)", 80, Style::default());
assert_eq!(explicit[0].links[0].target, "file:///repo/src/main.rs");
}
#[test]
fn named_links_reject_relative_paths_and_smuggled_control_bytes() {
let relative = render_markdown_tagged("[main.rs](src/main.rs)", 80, Style::default());
assert_eq!(tagged_visible(&relative), vec!["main.rs"]);
assert!(relative.iter().all(|line| line.links.is_empty()));
let hostile = render_markdown_tagged(
"[log](/tmp/a\x07b\x1b]8;;https://evil.test\x1b\\c)",
80,
Style::default(),
);
assert!(
hostile.iter().all(|line| line.links.is_empty()),
"control bytes must not reach a link target: {hostile:?}"
);
let host = render_markdown_tagged("[share](file://evil.test/etc)", 80, Style::default());
assert!(host.iter().all(|line| line.links.is_empty()));
}
#[test]
fn bare_paths_in_prose_are_never_linkified() {
let rendered = render_markdown_tagged(
"the fix landed in /repo/src/main.rs today",
80,
Style::default(),
);
assert!(rendered.iter().all(|line| line.links.is_empty()));
}
#[test]
fn table_separator_row_is_kept() {
let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.88+ |\n";
let parsed = parse(src);
let blocks: Vec<_> = parsed.blocks.iter().collect();
let table_rows: Vec<_> = blocks
.iter()
.filter(|b| matches!(b, Block::TableRow(_)))
.collect();
assert_eq!(table_rows.len(), 2, "expected 2 table rows: {blocks:?}");
let separators: Vec<_> = blocks
.iter()
.filter(|b| matches!(b, Block::TableSeparator))
.collect();
assert_eq!(
separators.len(),
1,
"expected 1 table separator: {blocks:?}"
);
}
#[test]
fn bold_markers_stripped_in_render() {
let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n";
let lines = render_markdown(src, 80, Style::default());
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!text.contains("**"),
"bold markers leaked into output: {text:?}"
);
assert!(text.contains("Rust"), "bold content missing: {text:?}");
}
#[test]
fn table_renders_with_box_drawing_borders() {
let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n";
let lines = render_markdown(src, 60, Style::default());
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(text.contains('│'), "table pipe separator missing: {text:?}");
assert!(
!text.contains("|---|"),
"raw separator row leaked: {text:?}"
);
assert!(
text.contains('\u{250C}'),
"top-left corner missing: {text:?}"
);
assert!(
text.contains('\u{2510}'),
"top-right corner missing: {text:?}"
);
assert!(
text.contains('\u{2514}'),
"bottom-left corner missing: {text:?}"
);
assert!(
text.contains('\u{2518}'),
"bottom-right corner missing: {text:?}"
);
assert!(
text.contains('\u{251C}'),
"middle-left junction missing: {text:?}"
);
assert!(
text.contains('\u{2524}'),
"middle-right junction missing: {text:?}"
);
}
#[test]
fn table_pipes_inside_inline_code_stay_in_the_cell() {
let src = "| Check | Result |\n\
|---|---|\n\
| `strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"` | 0 matches |\n";
let parsed = parse(src);
let rows: Vec<&Vec<String>> = parsed
.blocks
.iter()
.filter_map(|block| match block {
Block::TableRow(cells) => Some(cells),
_ => None,
})
.collect();
assert_eq!(rows.len(), 2, "expected header + data row: {rows:?}");
assert_eq!(
rows[1],
&vec![
"`strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"`".to_string(),
"0 matches".to_string(),
]
);
let rendered_lines = visible_lines(&render_markdown(src, 200, Style::default()));
let rendered = rendered_lines.join("\n");
assert!(
rendered.contains("grep -c"),
"inline-code command was lost: {rendered}"
);
let data_line = rendered_lines
.iter()
.find(|line| line.contains("strings ~/.cargo/bin/codewhale-tui"))
.expect("data row should render");
assert_eq!(
data_line.matches('│').count(),
3,
"two-column table row should have left, middle, and right separators: {data_line:?}"
);
}
#[test]
fn table_cell_wider_than_column_wraps_instead_of_truncating() {
let src = "| Feature | How to verify |\n\
|---|---|\n\
| Workspace-local commands | Drop a .deepseek/commands/foo.md in any project, run deepseek from there, type /foo — should dispatch |\n";
let lines = render_markdown(src, 80, Style::default());
let combined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!combined.contains('…'),
"table cell was truncated with `…` instead of wrapping; got: {combined:?}"
);
assert!(
combined.contains("type /foo"),
"tail of long cell was lost; got: {combined:?}"
);
assert!(
combined.contains("Workspace-local commands"),
"short cell content lost; got: {combined:?}"
);
}
#[test]
fn wrapped_table_row_preserves_column_separators() {
let src = "| A | B |\n\
|---|---|\n\
| short | this is a very very long second cell that absolutely must wrap to a new visual line because it cannot fit in the column allocated to it at this terminal width |\n";
let lines = render_markdown(src, 60, Style::default());
let rendered: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect();
let body_lines: Vec<&String> = rendered.iter().filter(|s| s.starts_with('│')).collect();
assert!(
body_lines.len() >= 3,
"expected at least header + multi-line data row (3+ body lines), got {}: {:?}",
body_lines.len(),
body_lines
);
for line in &body_lines {
assert!(
line.matches('│').count() >= 3,
"every wrapped table line should have N+1 column separators \
for N columns; got fewer in: {line:?}"
);
}
let combined: String = rendered.join("\n");
for fragment in ["this is a very very long", "must wrap", "terminal width"] {
assert!(
combined.contains(fragment),
"fragment {fragment:?} missing from wrapped output:\n{combined}"
);
}
}
fn rendered_widths(rendered: &[Line<'static>]) -> Vec<usize> {
rendered
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref().width())
.sum::<usize>()
})
.collect()
}
fn render_paragraph_for_test(text: &str, width: usize) -> Vec<Line<'static>> {
render_line_with_links(text, width, Style::default(), Style::default())
}
#[test]
fn paragraph_wrap_breaks_overlong_word_at_width_40() {
let long = "a".repeat(200);
let rendered = render_paragraph_for_test(&long, 40);
for w in rendered_widths(&rendered) {
assert!(w <= 40, "rendered width {w} exceeds 40-col window");
}
let combined: String = rendered
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect();
assert_eq!(combined.matches('a').count(), 200);
}
#[test]
fn paragraph_wrap_breaks_no_whitespace_cjk_at_width_40() {
let long = "界".repeat(300);
let rendered = render_paragraph_for_test(&long, 40);
for w in rendered_widths(&rendered) {
assert!(w <= 40, "rendered width {w} exceeds 40-col window");
}
let combined: String = rendered
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect();
assert_eq!(combined.chars().filter(|&ch| ch == '界').count(), 300);
assert!(
rendered.len() >= 15,
"300 double-width chars should wrap into many rows, got {}",
rendered.len()
);
}
#[test]
fn paragraph_wrap_breaks_overlong_word_at_widths_60_80_120() {
let long = format!("https://example.com/{}", "p".repeat(180));
for &width in &[60usize, 80, 120] {
let rendered = render_paragraph_for_test(&long, width);
for w in rendered_widths(&rendered) {
assert!(
w <= width,
"width={width}: rendered line width {w} exceeds budget"
);
}
assert!(rendered.len() >= 2, "width={width}: expected wrap");
}
}
#[test]
fn paragraph_wrap_keeps_short_words_unbroken() {
let text = "the quick brown fox jumps over the lazy dog and then it stops moving";
let rendered = render_paragraph_for_test(text, 40);
for line in &rendered {
let s: String = line.spans.iter().map(|s| s.content.to_string()).collect();
let first = s.split_whitespace().next().unwrap_or("");
assert!(
[
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then",
"it", "stops", "moving"
]
.contains(&first),
"line {s:?} appears to start with a partial word"
);
}
}
#[test]
fn paragraph_wrap_mixed_short_and_overlong_word() {
let long = "x".repeat(150);
let text = format!("intro {long} tail words go here");
let rendered = render_paragraph_for_test(&text, 80);
for w in rendered_widths(&rendered) {
assert!(w <= 80, "rendered width {w} exceeds 80-col window");
}
let combined: String = rendered
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect();
for fragment in ["intro", "tail", "words", "go", "here"] {
assert!(
combined.contains(fragment),
"fragment {fragment:?} missing from wrapped output:\n{combined}"
);
}
assert_eq!(combined.matches('x').count(), 150);
}
#[test]
fn wrap_text_breaks_overlong_word_for_code_blocks() {
for &width in &[40usize, 80] {
let long = "z".repeat(200);
let lines = wrap_text(&long, width);
for line in &lines {
assert!(
line.width() <= width,
"wrap_text line {line:?} exceeds {width}"
);
}
let combined: String = lines.join("");
assert_eq!(combined.matches('z').count(), 200);
}
}
#[test]
fn wrap_cell_text_already_handled_long_words_remains_correct() {
let long = "y".repeat(120);
let segments = wrap_cell_text(&long, 30);
for seg in &segments {
assert!(seg.width() <= 30, "segment {seg:?} exceeds col 30");
}
let combined: String = segments.join("");
assert_eq!(combined.matches('y').count(), 120);
}
#[test]
fn paragraph_wrap_handles_zero_width_gracefully() {
let rendered = render_paragraph_for_test("hello world", 0);
let _ = rendered;
}
fn rendered_text(rendered: &[Line<'static>]) -> String {
rendered
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect()
}
fn assert_rendered_widths_fit(rendered: &[Line<'static>], width: usize, label: &str) {
for line_width in rendered_widths(rendered) {
assert!(
line_width <= width,
"{label} width={width}: rendered line width {line_width} exceeds budget"
);
}
}
#[test]
fn paragraph_wrap_keeps_unicode_runs_within_qa_widths() {
let cases = [
("cjk", "界".repeat(300)),
("emoji", "😀".repeat(200)),
("mixed-cjk-emoji", "界😀世🚀".repeat(90)),
];
for (label, text) in cases {
for &width in &[80usize, 100, 120] {
let rendered = render_paragraph_for_test(&text, width);
assert_rendered_widths_fit(&rendered, width, label);
assert_eq!(
rendered_text(&rendered),
text,
"{label} width={width}: content changed while wrapping"
);
let min_lines = text.width().div_ceil(width);
assert!(
rendered.len() >= min_lines,
"{label} width={width}: expected at least {min_lines} lines, got {}",
rendered.len()
);
}
}
}
#[test]
fn paragraph_wrap_preserves_mixed_unicode_and_ascii_fragments() {
let cjk = "这是一个测试字符串".repeat(10); let emoji = "🚀".repeat(12);
let text = format!("Note: {cjk} done {emoji}");
for &width in &[80usize, 100, 120] {
let rendered = render_paragraph_for_test(&text, width);
assert_rendered_widths_fit(&rendered, width, "mixed unicode/ascii");
let visible = visible_lines(&rendered).join("\n");
for fragment in ["Note:", "测试", "done"] {
assert!(
visible.contains(fragment),
"width={width}: fragment {fragment:?} missing from output:\n{visible}"
);
}
assert_eq!(
visible.matches('🚀').count(),
12,
"width={width}: emoji content lost"
);
}
}
#[test]
fn lower_level_wrap_text_keeps_unicode_runs_within_qa_widths() {
let cases = [
("cjk", "中".repeat(140)),
("emoji", "😀".repeat(110)),
("combining", "e\u{301}".repeat(140)),
];
for (label, input) in cases {
for &width in &[80usize, 100, 120] {
let lines = wrap_text(&input, width);
for line in &lines {
assert!(
line.width() <= width,
"{label} width={width}: wrap_text line {line:?} exceeds budget"
);
}
let combined: String = lines.join("");
assert_eq!(
combined, input,
"{label} width={width}: wrap_text changed content"
);
}
}
}
#[test]
fn table_render_keeps_cjk_cells_within_qa_widths() {
let cjk = "界".repeat(80);
let src = format!("| Name | Value |\n|---|---|\n| CJK | {cjk} |\n");
for &width in &[80usize, 100, 120] {
let rendered = render_markdown(&src, width as u16, Style::default());
assert_rendered_widths_fit(&rendered, width, "table cjk");
assert_eq!(
rendered_text(&rendered).matches('界').count(),
80,
"width={width}: CJK table cell content lost"
);
}
}
#[test]
fn paragraph_wrap_keeps_cjk_transcript_within_narrow_widths() {
let text = "实时输出结果显示正常".repeat(6); for &width in &[20usize, 40] {
let rendered = render_paragraph_for_test(&text, width);
assert_rendered_widths_fit(&rendered, width, "narrow cjk transcript");
assert_eq!(
rendered_text(&rendered),
text,
"width={width}: CJK transcript content changed while wrapping"
);
}
}
}