use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use crate::terminal::palette::ColorCapabilities;
pub(super) fn looks_like_diff(body: &str) -> bool {
body.lines().any(|line| {
line.starts_with("diff --git ")
|| line.starts_with("@@ ")
|| line.starts_with("*** Begin Patch")
})
}
pub(super) fn render_diff(body: &str, capabilities: ColorCapabilities) -> Vec<Line<'static>> {
let mut language = None;
body.lines()
.map(|line| {
if let Some(path) = line.strip_prefix("+++ ") {
language = language_for_path(path.trim_start_matches("b/"));
} else if let Some(path) = patch_path(line) {
language = language_for_path(path);
}
render_diff_line(line, language, capabilities)
})
.collect()
}
fn render_diff_line(
line: &str,
language: Option<Language>,
capabilities: ColorCapabilities,
) -> Line<'static> {
let inserted = Style::default().fg(capabilities.best_color((80, 200, 120)));
let deleted = Style::default().fg(capabilities.best_color((235, 100, 100)));
let metadata = Style::default().fg(capabilities.best_color((100, 175, 230)));
if line.starts_with("@@") || line.starts_with("diff --git") || line.starts_with("*** ") {
return Line::from(Span::styled(line.to_string(), metadata));
}
if let Some(code) = line.strip_prefix('+').filter(|_| !line.starts_with("+++")) {
return code_line("+", code, language, inserted);
}
if let Some(code) = line.strip_prefix('-').filter(|_| !line.starts_with("---")) {
return code_line("-", code, language, deleted);
}
Line::from(line.to_string())
}
fn code_line(prefix: &str, code: &str, language: Option<Language>, base: Style) -> Line<'static> {
let mut spans = vec![Span::styled(prefix.to_string(), base)];
let Some(language) = language else {
spans.push(Span::styled(code.to_string(), base));
return Line::from(spans);
};
let keyword_style = base.add_modifier(Modifier::BOLD);
let mut token_start = 0usize;
for (index, ch) in code.char_indices() {
if ch.is_ascii_alphanumeric() || ch == '_' {
continue;
}
push_token(
&mut spans,
&code[token_start..index],
language,
base,
keyword_style,
);
spans.push(Span::styled(ch.to_string(), base));
token_start = index + ch.len_utf8();
}
push_token(
&mut spans,
&code[token_start..],
language,
base,
keyword_style,
);
Line::from(spans)
}
fn push_token(
spans: &mut Vec<Span<'static>>,
token: &str,
language: Language,
base: Style,
keyword: Style,
) {
if token.is_empty() {
return;
}
spans.push(Span::styled(
token.to_string(),
if language.is_keyword(token) {
keyword
} else {
base
},
));
}
fn patch_path(line: &str) -> Option<&str> {
line.strip_prefix("*** Add File: ")
.or_else(|| line.strip_prefix("*** Update File: "))
.or_else(|| line.strip_prefix("*** Delete File: "))
}
#[derive(Clone, Copy)]
enum Language {
Rust,
JavaScript,
Python,
Shell,
}
impl Language {
fn is_keyword(self, token: &str) -> bool {
match self {
Self::Rust => matches!(
token,
"as" | "async"
| "await"
| "const"
| "enum"
| "fn"
| "impl"
| "let"
| "match"
| "mod"
| "pub"
| "self"
| "struct"
| "trait"
| "use"
| "where"
),
Self::JavaScript => matches!(
token,
"async"
| "await"
| "class"
| "const"
| "export"
| "function"
| "import"
| "let"
| "return"
| "var"
),
Self::Python => matches!(
token,
"async" | "await" | "class" | "def" | "from" | "import" | "return" | "with"
),
Self::Shell => matches!(
token,
"case" | "do" | "done" | "esac" | "fi" | "for" | "if" | "then"
),
}
}
}
fn language_for_path(path: &str) -> Option<Language> {
let extension = path.rsplit_once('.').map(|(_, extension)| extension)?;
match extension {
"rs" => Some(Language::Rust),
"js" | "jsx" | "mjs" | "ts" | "tsx" => Some(Language::JavaScript),
"py" => Some(Language::Python),
"bash" | "sh" | "zsh" => Some(Language::Shell),
_ => None,
}
}
#[cfg(test)]
mod tests {
use ratatui::style::Color;
use super::*;
#[test]
fn rust_addition_highlights_keywords_without_protocol_context() {
let lines = render_diff(
"*** Update File: src/lib.rs\n+pub fn answer() -> u8 { 42 }",
ColorCapabilities {
level: crate::terminal::palette::ColorLevel::TrueColor,
color_enabled: true,
},
);
assert_eq!(lines[1].spans[1].content, "pub");
assert!(lines[1].spans[1]
.style
.add_modifier
.contains(Modifier::BOLD));
assert_eq!(lines[1].spans[0].style.fg, Some(Color::Rgb(80, 200, 120)));
}
#[test]
fn detects_unified_and_apply_patch_shapes() {
assert!(looks_like_diff("diff --git a/a b/a\n@@ -1 +1 @@"));
assert!(looks_like_diff("*** Begin Patch\n*** Add File: x"));
assert!(!looks_like_diff("ordinary tool output"));
}
}