claw-code/rust/crates/rusty-claude-cli/src/markdown.rs

458 lines
17 KiB
Rust

//! Markdown → ratatui `Line` renderer with syntect syntax highlighting.
//!
//! Syntect assets (`SyntaxSet`, `ThemeSet`) are loaded once via `once_cell::Lazy`.
use once_cell::sync::Lazy;
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
/// Loaded once on first access (~10-50ms), cached for process lifetime.
pub static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
pub static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
#[derive(Clone)]
pub struct MarkdownRenderer {
code_theme_name: String,
theme: crate::theme::TuiTheme,
}
impl MarkdownRenderer {
pub fn new(theme: crate::theme::TuiTheme) -> Self {
let code_theme_name = theme.syntax_theme.clone();
Self {
code_theme_name,
theme,
}
}
pub fn set_code_theme(&mut self, name: &str) {
self.code_theme_name = name.to_string();
}
pub fn set_theme(&mut self, theme: crate::theme::TuiTheme) {
self.code_theme_name = theme.syntax_theme.clone();
self.theme = theme;
}
/// Render a markdown string into ratatui Lines at the given terminal width.
pub fn render(&self, markdown: &str, width: u16) -> Vec<Line<'static>> {
let parser = Parser::new(markdown);
let events: Vec<Event> = parser.collect();
self.render_events(&events, width)
}
fn render_events(&self, events: &[Event], width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut style_stack: Vec<Style> = Vec::new();
let mut in_code_block = false;
let mut code_block_lang: Option<String> = None;
let mut code_block_content = String::new();
let mut list_depth: usize = 0;
for event in events {
match event {
Event::Start(tag) => match tag {
Tag::Heading { .. } => {
flush_line(&mut lines, &mut current_spans);
style_stack.push(
Style::default()
.fg(self.theme.conversation_user.to_color())
.add_modifier(Modifier::BOLD),
);
}
Tag::Paragraph => {
style_stack.push(Style::default());
}
Tag::CodeBlock(kind) => {
flush_line(&mut lines, &mut current_spans);
in_code_block = true;
code_block_content.clear();
code_block_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
let s = lang.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
_ => None,
};
}
Tag::List(_) => {
list_depth += 1;
}
Tag::Item => {
let indent = " ".repeat(list_depth.saturating_sub(1));
current_spans.push(Span::raw(format!("{indent}")));
}
Tag::BlockQuote(_) => {
current_spans
.push(Span::styled("", Style::default().fg(self.theme.conversation_dim.to_color())));
style_stack.push(Style::default().fg(self.theme.conversation_dim.to_color()));
}
Tag::Emphasis => {
style_stack.push(Style::default().add_modifier(Modifier::ITALIC));
}
Tag::Strong => {
style_stack.push(Style::default().add_modifier(Modifier::BOLD));
}
Tag::Link { .. } => {
style_stack.push(
Style::default()
.fg(self.theme.conversation_user.to_color())
.add_modifier(Modifier::UNDERLINED),
);
}
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Heading(_) => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(""));
style_stack.pop();
}
TagEnd::Paragraph => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(""));
style_stack.pop();
}
TagEnd::CodeBlock => {
let code_lines =
self.render_code_block(&code_block_content, code_block_lang.as_deref());
lines.extend(code_lines);
in_code_block = false;
code_block_lang = None;
}
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
}
TagEnd::Item => {
flush_line(&mut lines, &mut current_spans);
}
TagEnd::BlockQuote(_) => {
flush_line(&mut lines, &mut current_spans);
style_stack.pop();
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => {
style_stack.pop();
}
_ => {}
},
Event::Text(text) => {
if in_code_block {
code_block_content.push_str(&text);
} else {
let style = style_stack.last().copied().unwrap_or_default();
current_spans.push(Span::styled(text.to_string(), style));
}
}
Event::Code(code) => {
let style = Style::default().fg(self.theme.conversation_system.to_color()).bg(self.theme.code_bg.to_color());
current_spans.push(Span::styled(format!(" {code} "), style));
}
Event::SoftBreak | Event::HardBreak => {
if in_code_block {
code_block_content.push('\n');
} else {
flush_line(&mut lines, &mut current_spans);
}
}
Event::Rule => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(Span::styled(
"".repeat(width as usize),
Style::default().fg(self.theme.conversation_dim.to_color()),
)));
}
_ => {}
}
}
flush_line(&mut lines, &mut current_spans);
lines
}
fn render_code_block(&self, code: &str, language: Option<&str>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let syntax = language
.and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
// Theme lookup with fallback chain — never panic on missing theme
let theme = THEME_SET
.themes
.get(&self.code_theme_name)
.or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
.or_else(|| THEME_SET.themes.values().next())
.expect("syntect has no themes");
let lang_label = language.unwrap_or("text");
lines.push(Line::from(Span::styled(
format!("╭─ {lang_label}"),
Style::default().fg(self.theme.code_language_label.to_color()),
)));
// syntect 5.x: HighlightLines::new() returns directly (not Result)
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
for line in code.lines() {
match highlighter.highlight_line(line, &SYNTAX_SET) {
Ok(ranges) => {
let mut line_spans: Vec<Span<'static>> = vec![Span::styled(
"",
Style::default().fg(self.theme.code_border.to_color()),
)];
for (style, text) in ranges {
let fg = style.foreground;
line_spans.push(Span::styled(
text.to_string(),
Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)),
));
}
lines.push(Line::from(line_spans));
}
Err(_) => {
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(self.theme.code_border.to_color())),
Span::raw(line.to_string()),
]));
}
}
}
lines.push(Line::from(Span::styled(
"╰─",
Style::default().fg(self.theme.code_border.to_color()),
)));
lines
}
}
/// Flush accumulated spans into a new line.
fn flush_line(lines: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>) {
if !spans.is_empty() {
lines.push(Line::from(spans.clone()));
spans.clear();
}
}
/// Heuristic: does this text look like it contains markdown formatting?
pub fn looks_like_markdown(text: &str) -> bool {
let lines: Vec<&str> = text.lines().collect();
let has_header = lines.iter().any(|l| l.starts_with('#'));
let has_code_block = text.contains("```");
let has_list = lines
.iter()
.any(|l| l.starts_with("- ") || l.starts_with("* "));
let has_bold = text.contains("**");
let has_inline_code = text.matches('`').count() >= 2;
let multi_line = lines.len() > 3;
has_code_block || (has_header && multi_line) || (has_list && multi_line) || (has_bold && has_inline_code)
}
/// Render a unified diff with color-coded lines.
pub fn render_diff(diff: &str, theme: &crate::theme::TuiTheme) -> Vec<Line<'static>> {
diff.lines()
.map(|raw_line| {
let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") {
(raw_line.to_string(), theme.conversation_text.to_color())
} else if raw_line.starts_with("@@") {
(raw_line.to_string(), theme.conversation_user.to_color())
} else if raw_line.starts_with('+') {
(raw_line.to_string(), theme.agent_done.to_color())
} else if raw_line.starts_with('-') {
(raw_line.to_string(), theme.conversation_error.to_color())
} else {
(raw_line.to_string(), theme.conversation_dim.to_color())
};
Line::from(Span::styled(text, Style::default().fg(color)))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_theme() -> crate::theme::TuiTheme {
crate::theme::TuiTheme::builtin("default").unwrap()
}
// --- Markdown rendering ---
#[test]
fn test_plain_paragraph() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("Just a paragraph.", 80);
assert_eq!(lines.len(), 2); // text + blank
}
#[test]
fn test_h1_rendering() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("# Hello", 80);
assert!(lines[0]
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Cyan)));
}
#[test]
fn test_rust_code_block() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("```rust\nfn main() {\n println!(\"hi\");\n}\n```", 80);
// At minimum: top border + at least 1 code line + bottom border
assert!(lines.len() >= 3);
// Should contain code border characters
let text: String = lines.iter().map(|l| {
l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()
}).collect();
assert!(text.contains("╭─") && text.contains("╰─"));
}
#[test]
fn test_python_code_block() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("```python\ndef hello():\n pass\n```", 80);
assert!(lines.len() >= 3);
let text: String = lines.iter().map(|l| {
l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()
}).collect();
assert!(text.contains("python"));
}
#[test]
fn test_inline_code() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("Use `git status` to check", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_bold_and_italic() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("**bold** and *italic*", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_nested_list() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("- item 1\n- item 2\n - nested", 80);
// At least 2 list items rendered (nested may be part of item 2)
assert!(lines.len() >= 2);
}
#[test]
fn test_blockquote() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("> quoted\n> text", 80);
// 2 blockquote lines + possible blank lines
assert!(lines.len() >= 2);
}
#[test]
fn test_horizontal_rule() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("---", 80);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_empty_input() {
let r = MarkdownRenderer::new(test_theme());
let lines = r.render("", 80);
assert!(lines.is_empty());
}
#[test]
fn test_mixed_content() {
let r = MarkdownRenderer::new(test_theme());
let md = "# Title\n\nSome text with `code`.\n\n```rust\nfn main() {}\n```\n\n- list item";
let lines = r.render(md, 80);
// Header + blank + text + blank + code block (3+) + blank + list item + blanks
assert!(lines.len() >= 5);
}
#[test]
fn test_theme_fallback() {
let mut r = MarkdownRenderer::new(test_theme());
r.set_code_theme("nonexistent_theme");
// Should not panic — falls back gracefully
let lines = r.render("```rust\nfn main() {}\n```", 80);
assert!(lines.len() >= 3);
}
// --- looks_like_markdown ---
#[test]
fn test_markdown_detection_code_block() {
assert!(looks_like_markdown(
"some text\n```rust\ncode\n```\nmore text"
));
}
#[test]
fn test_markdown_detection_header() {
assert!(looks_like_markdown(
"# Title\n\nParagraph one.\n\nParagraph two."
));
}
#[test]
fn test_markdown_detection_list() {
assert!(looks_like_markdown(
"Items:\n\n- one\n- two\n- three"
));
}
#[test]
fn test_markdown_detection_plain() {
assert!(!looks_like_markdown(
"Just a simple response without any formatting."
));
}
#[test]
fn test_markdown_detection_short() {
assert!(!looks_like_markdown("Short"));
}
// --- Diff renderer ---
#[test]
fn test_diff_additions_green() {
let theme = test_theme();
let lines = render_diff("+new line", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.agent_done.to_color()));
}
#[test]
fn test_diff_deletions_red() {
let theme = test_theme();
let lines = render_diff("-old line", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_error.to_color()));
}
#[test]
fn test_diff_hunk_header() {
let theme = test_theme();
let lines = render_diff("@@ -1,3 +1,4 @@", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_user.to_color()));
}
#[test]
fn test_diff_file_headers() {
let theme = test_theme();
let lines = render_diff("--- a/file.rs\n+++ b/file.rs", &theme);
assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_text.to_color()));
assert_eq!(lines[1].spans[0].style.fg, Some(theme.conversation_text.to_color()));
}
}