use super::code_blocks::CodeBlockState;
use super::links::{
extract_hidden_location_suffix, label_segments_have_location_suffix,
should_render_link_destination,
};
use super::tables::{TableBuffer, render_table};
use super::{LIST_INDENT_WIDTH, MarkdownLine};
use crate::ui::theme::ThemeStyles;
use anstyle::Style;
use pulldown_cmark::{CodeBlockKind, HeadingLevel, Tag, TagEnd};
use regex::Regex;
use std::cmp::max;
use std::sync::LazyLock;
use vtcode_commons::parse_editor_target;
static NON_WHITESPACE_TOKEN_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\S+").expect("valid transcript token regex"));
static QUOTED_PATH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"`(?:file://|~/|/|\./|\.\./|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^`]+`|"(?:file://|~/|/|\./|\.\./|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^"]+"|'(?:file://|~/|/|\./|\.\./|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^']+'"#,
)
.expect("valid quoted transcript path regex")
});
const COMMON_FILE_EXTENSIONS: &[&str] = &[
"rs", "toml", "md", "json", "yaml", "yml", "js", "jsx", "ts", "tsx", "py", "go", "java", "kt",
"swift", "c", "h", "cpp", "hpp", "cc", "m", "mm", "sh", "zsh", "bash", "fish", "ps1", "rb",
"php", "sql", "html", "css", "scss", "sass", "less", "xml", "ini", "cfg", "conf", "env",
"lock", "txt",
];
const COMMON_FILE_NAMES: &[&str] = &["Makefile", "Dockerfile"];
#[derive(Clone, Debug)]
struct FileLinkMatch {
start: usize,
end: usize,
target: String,
}
#[derive(Clone, Debug)]
pub(crate) struct ListState {
pub(crate) kind: ListKind,
pub(crate) depth: usize,
pub(crate) continuation: String,
}
#[derive(Clone, Debug)]
pub(crate) enum ListKind {
Unordered,
Ordered { next: usize },
}
#[derive(Clone, Debug)]
pub(crate) struct LinkState {
pub(crate) destination: String,
pub(crate) show_destination: bool,
pub(crate) hidden_location_suffix: Option<String>,
pub(crate) label_start_segment_idx: usize,
}
pub(crate) struct MarkdownContext<'a> {
pub(crate) style_stack: &'a mut Vec<Style>,
pub(crate) blockquote_depth: &'a mut usize,
pub(crate) list_stack: &'a mut Vec<ListState>,
pub(crate) list_continuation_prefix: &'a mut String,
pub(crate) pending_list_prefix: &'a mut Option<String>,
pub(crate) lines: &'a mut Vec<MarkdownLine>,
pub(crate) current_line: &'a mut MarkdownLine,
pub(crate) theme_styles: &'a ThemeStyles,
pub(crate) base_style: Style,
pub(crate) code_block: &'a mut Option<CodeBlockState>,
pub(crate) active_table: &'a mut Option<TableBuffer>,
pub(crate) link_state: &'a mut Option<LinkState>,
}
impl MarkdownContext<'_> {
fn current_style(&self) -> Style {
self.style_stack.last().copied().unwrap_or(self.base_style)
}
fn push_style(&mut self, modifier: impl FnOnce(Style) -> Style) {
self.style_stack.push(modifier(self.current_style()));
}
fn pop_style(&mut self) {
self.style_stack.pop();
}
pub(crate) fn flush_line(&mut self) {
flush_current_line(
self.lines,
self.current_line,
*self.blockquote_depth,
self.list_continuation_prefix,
self.pending_list_prefix,
self.base_style,
);
}
fn flush_paragraph(&mut self) {
self.flush_line();
push_blank_line(self.lines);
}
pub(crate) fn ensure_prefix(&mut self) {
ensure_prefix(
self.current_line,
*self.blockquote_depth,
self.list_continuation_prefix,
self.pending_list_prefix,
self.base_style,
);
}
fn refresh_list_continuation_prefix(&mut self) {
rebuild_list_continuation_prefix(self.list_stack, self.list_continuation_prefix);
}
fn set_pending_list_continuation(&mut self) {
if let Some(state) = self.list_stack.last() {
self.pending_list_prefix.replace(state.continuation.clone());
}
}
pub(crate) fn active_link_target(&self) -> Option<String> {
self.link_state
.as_ref()
.map(|link| link.destination.clone())
}
}
pub(crate) fn handle_start_tag(tag: &Tag<'_>, ctx: &mut MarkdownContext<'_>) {
match tag {
Tag::Paragraph => {}
Tag::Heading { level, .. } => {
let style = heading_style(*level, ctx.theme_styles, ctx.base_style);
ctx.style_stack.push(style);
ctx.ensure_prefix();
}
Tag::BlockQuote(_) => *ctx.blockquote_depth += 1,
Tag::List(start) => {
let depth = ctx.list_stack.len();
let kind = start
.map(|v| ListKind::Ordered {
next: max(1, v as usize),
})
.unwrap_or(ListKind::Unordered);
ctx.list_stack.push(ListState {
kind,
depth,
continuation: String::new(),
});
ctx.refresh_list_continuation_prefix();
}
Tag::Item => {
if let Some(state) = ctx.list_stack.last_mut() {
let indent = " ".repeat(state.depth * LIST_INDENT_WIDTH);
match &mut state.kind {
ListKind::Unordered => {
let bullet_char = match state.depth % 3 {
0 => "•",
1 => "◦",
_ => "▪",
};
let bullet = format!("{indent}{bullet_char} ");
state.continuation = format!("{indent} ");
*ctx.pending_list_prefix = Some(bullet);
}
ListKind::Ordered { next } => {
let bullet = format!("{indent}{}. ", *next);
let width = bullet.len().saturating_sub(indent.len());
state.continuation = format!("{indent}{}", " ".repeat(width));
*ctx.pending_list_prefix = Some(bullet);
*next += 1;
}
}
ctx.refresh_list_continuation_prefix();
}
}
Tag::Emphasis => ctx.push_style(Style::italic),
Tag::Strong => {
let theme_styles = ctx.theme_styles;
let base_style = ctx.base_style;
ctx.push_style(|style| strong_style(style, theme_styles, base_style));
}
Tag::Strikethrough => ctx.push_style(Style::strikethrough),
Tag::Superscript | Tag::Subscript => ctx.push_style(Style::italic),
Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. } => {
let show_destination = should_render_link_destination(dest_url);
let label_start_segment_idx = ctx.current_line.segments.len();
*ctx.link_state = Some(LinkState {
destination: dest_url.to_string(),
show_destination,
hidden_location_suffix: extract_hidden_location_suffix(dest_url),
label_start_segment_idx,
});
ctx.push_style(Style::underline);
}
Tag::CodeBlock(kind) => {
let language = match kind {
CodeBlockKind::Fenced(info) => info
.split_whitespace()
.next()
.filter(|lang| !lang.is_empty())
.map(|lang| lang.to_string()),
CodeBlockKind::Indented => None,
};
*ctx.code_block = Some(CodeBlockState {
language,
buffer: String::new(),
});
}
Tag::Table(_) => {
ctx.flush_paragraph();
*ctx.active_table = Some(TableBuffer::default());
}
Tag::TableRow => {
if let Some(table) = ctx.active_table.as_mut() {
table.current_row.clear();
} else {
ctx.flush_line();
}
}
Tag::TableHead => {
if let Some(table) = ctx.active_table.as_mut() {
table.in_head = true;
table.current_row.clear();
}
}
Tag::TableCell => {
if ctx.active_table.is_none() {
ctx.ensure_prefix();
} else {
ctx.current_line.segments.clear();
}
}
Tag::FootnoteDefinition(_)
| Tag::HtmlBlock
| Tag::MetadataBlock(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition => {}
}
}
pub(crate) fn handle_end_tag(tag: TagEnd, ctx: &mut MarkdownContext<'_>) {
match tag {
TagEnd::Paragraph => ctx.flush_paragraph(),
TagEnd::Heading(_) => {
ctx.flush_line();
ctx.pop_style();
push_blank_line(ctx.lines);
}
TagEnd::BlockQuote(_) => {
ctx.flush_line();
*ctx.blockquote_depth = ctx.blockquote_depth.saturating_sub(1);
}
TagEnd::List(_) => {
ctx.flush_line();
if ctx.list_stack.pop().is_some() {
ctx.refresh_list_continuation_prefix();
if ctx.list_stack.is_empty() {
ctx.pending_list_prefix.take();
} else {
ctx.set_pending_list_continuation();
}
}
push_blank_line(ctx.lines);
}
TagEnd::Item => {
ctx.flush_line();
ctx.set_pending_list_continuation();
}
TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Superscript
| TagEnd::Subscript => {
ctx.pop_style();
}
TagEnd::Link | TagEnd::Image => {
if let Some(link) = ctx.link_state.take() {
if link.show_destination {
ctx.current_line.push_segment_with_link(
ctx.current_style(),
" (",
Some(link.destination.clone()),
);
ctx.current_line.push_segment_with_link(
ctx.current_style(),
&link.destination,
Some(link.destination.clone()),
);
ctx.current_line.push_segment_with_link(
ctx.current_style(),
")",
Some(link.destination.clone()),
);
} else if let Some(location_suffix) = link.hidden_location_suffix.as_deref() {
let label_segments = ctx
.current_line
.segments
.get(link.label_start_segment_idx..)
.unwrap_or(&[]);
if !label_segments_have_location_suffix(label_segments) {
ctx.current_line.push_segment_with_link(
ctx.current_style(),
location_suffix,
Some(link.destination.clone()),
);
}
}
}
ctx.pop_style();
}
TagEnd::CodeBlock => {}
TagEnd::Table => {
if let Some(mut table) = ctx.active_table.take() {
if !table.current_row.is_empty() {
table.rows.push(std::mem::take(&mut table.current_row));
}
let rendered = render_table(&table, ctx.base_style);
ctx.lines.extend(rendered);
}
push_blank_line(ctx.lines);
}
TagEnd::TableRow => {
if let Some(table) = ctx.active_table.as_mut() {
if table.in_head {
table.headers = std::mem::take(&mut table.current_row);
} else {
table.rows.push(std::mem::take(&mut table.current_row));
}
} else {
ctx.flush_line();
}
}
TagEnd::TableCell => {
if let Some(table) = ctx.active_table.as_mut() {
table.current_row.push(std::mem::take(ctx.current_line));
}
}
TagEnd::TableHead => {
if let Some(table) = ctx.active_table.as_mut() {
if !table.current_row.is_empty() {
table.headers = std::mem::take(&mut table.current_row);
}
table.in_head = false;
}
}
TagEnd::FootnoteDefinition
| TagEnd::HtmlBlock
| TagEnd::MetadataBlock(_)
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition => {}
}
}
pub(crate) fn append_text(text: &str, ctx: &mut MarkdownContext<'_>) {
let style = ctx.current_style();
let link_target = ctx.active_link_target();
let mut start = 0usize;
let mut chars = text.char_indices().peekable();
while let Some((idx, ch)) = chars.next() {
if ch == '\n' {
let segment = &text[start..idx];
if !segment.is_empty() {
append_text_segment(segment, ctx, style, link_target.clone());
}
ctx.lines.push(std::mem::take(ctx.current_line));
start = idx + 1;
while chars.peek().is_some_and(|&(_, c)| c == '\n') {
let Some((_, c)) = chars.next() else {
break;
};
start += c.len_utf8();
}
}
}
if start < text.len() {
let remaining = &text[start..];
if !remaining.is_empty() {
append_text_segment(remaining, ctx, style, link_target);
}
}
}
fn detect_file_link_matches(text: &str) -> Vec<FileLinkMatch> {
let mut matches = Vec::new();
for quoted_match in QUOTED_PATH_PATTERN.find_iter(text) {
if let Some(link_match) =
build_file_link_match(text, quoted_match.start(), quoted_match.end())
{
matches.push(link_match);
}
}
for token_match in NON_WHITESPACE_TOKEN_PATTERN.find_iter(text) {
if matches.iter().any(|existing| {
token_match.start() < existing.end && token_match.end() > existing.start
}) {
continue;
}
if let Some(link_match) =
build_file_link_match(text, token_match.start(), token_match.end())
{
matches.push(link_match);
}
}
matches.sort_by_key(|link_match| link_match.start);
matches.dedup_by(|left, right| left.start == right.start && left.end == right.end);
matches
}
fn build_file_link_match(
text: &str,
token_start: usize,
token_end: usize,
) -> Option<FileLinkMatch> {
let token = &text[token_start..token_end];
let (trimmed_start, trimmed_end) = trim_transcript_token_bounds(token);
if trimmed_start >= trimmed_end {
return None;
}
let start = token_start + trimmed_start;
let end = token_start + trimmed_end;
let candidate = text[start..end].trim();
if candidate.is_empty() {
return None;
}
let parsed = parse_editor_target(candidate)?;
let stripped = parsed.path().to_string_lossy();
if stripped.is_empty() || !looks_like_markdown_path(&stripped) {
return None;
}
let target = parsed.canonical_string();
Some(FileLinkMatch { start, end, target })
}
fn trim_transcript_token_bounds(token: &str) -> (usize, usize) {
let mut start = 0usize;
let mut end = token.len();
while start < end {
let Some(ch) = token[start..end].chars().next() else {
break;
};
if matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`') {
start += ch.len_utf8();
} else {
break;
}
}
while start < end {
let Some(ch) = token[start..end].chars().next_back() else {
break;
};
if matches!(
ch,
')' | ']' | '}' | '>' | '"' | '\'' | '`' | ',' | ';' | '.' | '!' | '?'
) {
if ch == ')' && location_paren_suffix_start(&token[start..end]).is_some() {
break;
}
end -= ch.len_utf8();
} else {
break;
}
}
(start, end)
}
fn looks_like_markdown_path(token: &str) -> bool {
let token = token.trim();
if token.is_empty() {
return false;
}
if token.starts_with("http://") || token.starts_with("https://") {
return false;
}
if token.contains("://") && !token.starts_with("file://") {
return false;
}
if token.starts_with("./")
|| token.starts_with("../")
|| token.starts_with('/')
|| token.starts_with("~/")
|| token.starts_with("file://")
{
return true;
}
if token.len() >= 3
&& token.as_bytes()[0].is_ascii_alphabetic()
&& token.as_bytes()[1] == b':'
&& matches!(token.as_bytes()[2], b'\\' | b'/')
{
return true;
}
if token.contains('/') || token.contains('\\') {
return true;
}
if token.starts_with('.') && token.len() > 1 {
return true;
}
if COMMON_FILE_NAMES.contains(&token) {
return true;
}
if let Some((_, ext)) = token.rsplit_once('.')
&& !ext.is_empty()
&& ext.len() <= 12
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
{
let ext_lower = ext.to_ascii_lowercase();
return COMMON_FILE_EXTENSIONS
.iter()
.any(|candidate| *candidate == ext_lower);
}
false
}
fn location_paren_suffix_start(token: &str) -> Option<usize> {
let paren_start = token.rfind('(')?;
let inner = token[paren_start + 1..].strip_suffix(')')?;
let valid = !inner.is_empty()
&& !inner.starts_with(',')
&& !inner.ends_with(',')
&& !inner.contains(",,")
&& inner.chars().all(|c| c.is_ascii_digit() || c == ',');
valid.then_some(paren_start)
}
fn append_text_segment(
segment: &str,
ctx: &mut MarkdownContext<'_>,
style: Style,
link_target: Option<String>,
) {
if segment.is_empty() {
return;
}
ctx.ensure_prefix();
if let Some(target) = link_target {
ctx.current_line
.push_segment_with_link(style, segment, Some(target));
return;
}
let matches = detect_file_link_matches(segment);
if matches.is_empty() {
ctx.current_line
.push_segment_with_link(style, segment, None);
return;
}
let link_style = file_link_style(style, ctx.theme_styles, ctx.base_style);
let mut cursor = 0usize;
for link_match in matches {
if link_match.start > cursor {
ctx.current_line.push_segment_with_link(
style,
&segment[cursor..link_match.start],
None,
);
}
if link_match.end > link_match.start {
ctx.current_line.push_segment_with_link(
link_style,
&segment[link_match.start..link_match.end],
Some(link_match.target),
);
}
cursor = link_match.end;
}
if cursor < segment.len() {
ctx.current_line
.push_segment_with_link(style, &segment[cursor..], None);
}
}
pub(crate) fn flush_current_line(
lines: &mut Vec<MarkdownLine>,
current_line: &mut MarkdownLine,
blockquote_depth: usize,
list_continuation_prefix: &str,
pending_list_prefix: &mut Option<String>,
base_style: Style,
) {
if current_line.segments.is_empty() && pending_list_prefix.is_some() {
ensure_prefix(
current_line,
blockquote_depth,
list_continuation_prefix,
pending_list_prefix,
base_style,
);
}
if !current_line.segments.is_empty() {
lines.push(std::mem::take(current_line));
}
}
pub(crate) fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
if lines
.last()
.map(|line| line.segments.is_empty())
.unwrap_or(false)
{
return;
}
lines.push(MarkdownLine::default());
}
pub(crate) fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
while lines
.last()
.map(|line| line.segments.is_empty())
.unwrap_or(false)
{
lines.pop();
}
}
pub(crate) fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
let mut style = base_style.bold();
if should_apply_markdown_accent(base_style, theme_styles)
&& let Some(color) = choose_markdown_accent(
base_style,
&[
theme_styles.secondary,
theme_styles.primary,
theme_styles.tool_detail,
theme_styles.status,
],
)
{
style = style.fg_color(Some(color));
}
style
}
fn file_link_style(current: Style, theme_styles: &ThemeStyles, base_style: Style) -> Style {
let mut style = current;
let base_fg = base_style.get_fg_color();
let current_fg = style.get_fg_color();
if (current_fg.is_none() || current_fg == base_fg)
&& let Some(color) = choose_markdown_accent(
base_style,
&[
theme_styles.primary,
theme_styles.secondary,
theme_styles.status,
theme_styles.tool_detail,
],
)
{
style = style.fg_color(Some(color));
}
style.underline()
}
fn ensure_prefix(
current_line: &mut MarkdownLine,
blockquote_depth: usize,
list_continuation_prefix: &str,
pending_list_prefix: &mut Option<String>,
base_style: Style,
) {
if !current_line.segments.is_empty() {
return;
}
for _ in 0..blockquote_depth {
current_line.push_segment(base_style.dimmed().italic(), "│ ");
}
if let Some(prefix) = pending_list_prefix.take() {
current_line.push_segment(base_style, &prefix);
} else if !list_continuation_prefix.is_empty() {
current_line.push_segment(base_style, list_continuation_prefix);
}
}
fn heading_style(_level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
markdown_bold_accent_style(base_style.bold(), theme_styles, base_style)
}
fn strong_style(current: Style, theme_styles: &ThemeStyles, base_style: Style) -> Style {
markdown_bold_accent_style(current.bold(), theme_styles, base_style)
}
fn markdown_bold_accent_style(
mut style: Style,
theme_styles: &ThemeStyles,
base_style: Style,
) -> Style {
if should_apply_markdown_accent(base_style, theme_styles)
&& let Some(color) = choose_markdown_accent(
base_style,
&[
theme_styles.primary,
theme_styles.secondary,
theme_styles.status,
theme_styles.tool,
],
)
{
style = style.fg_color(Some(color));
}
style
}
fn should_apply_markdown_accent(base_style: Style, theme_styles: &ThemeStyles) -> bool {
base_style == theme_styles.response
}
fn choose_markdown_accent(base_style: Style, candidates: &[Style]) -> Option<anstyle::Color> {
let base_fg = base_style.get_fg_color();
candidates.iter().find_map(|candidate| {
candidate
.get_fg_color()
.filter(|color| base_fg != Some(*color))
})
}
fn rebuild_list_continuation_prefix(
list_stack: &[ListState],
list_continuation_prefix: &mut String,
) {
list_continuation_prefix.clear();
for state in list_stack {
list_continuation_prefix.push_str(&state.continuation);
}
}