use std::sync::OnceLock;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use crate::theme;
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
fn theme() -> &'static Theme {
let themes = THEME_SET.get_or_init(ThemeSet::load_defaults);
let name = if is_light_ui() {
"base16-ocean.light"
} else {
"base16-ocean.dark"
};
themes
.themes
.get(name)
.or_else(|| themes.themes.get("base16-ocean.dark"))
.expect("syntect default theme")
}
fn is_light_ui() -> bool {
match theme::surface() {
Color::Rgb(r, g, b) => (r as u16) + (g as u16) + (b as u16) > 400,
_ => false,
}
}
fn to_ratatui_style(style: syntect::highlighting::Style) -> Style {
let fg = style.foreground;
let mut out = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b));
if style.font_style.contains(FontStyle::BOLD) {
out = out.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
out = out.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
out = out.add_modifier(Modifier::UNDERLINED);
}
out
}
fn highlight_code_line(
hl: &mut HighlightLines<'_>,
code: &str,
ps: &SyntaxSet,
) -> Vec<Span<'static>> {
let line = if code.ends_with('\n') {
code.to_string()
} else {
format!("{code}\n")
};
match hl.highlight_line(&line, ps) {
Ok(segments) => segments
.into_iter()
.map(|(style, text)| {
let text = text.trim_end_matches('\n').to_string();
Span::styled(text, to_ratatui_style(style))
})
.filter(|s| !s.content.is_empty())
.collect(),
Err(_) => vec![Span::styled(
code.to_string(),
Style::default().fg(theme::text()),
)],
}
}
fn muted_line(text: &str) -> Line<'static> {
Line::from(Span::styled(
text.to_string(),
Style::default().fg(theme::muted()),
))
}
fn path_from_diff_git(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let mut parts = rest.split_whitespace();
let _a = parts.next()?;
let b = parts.next()?;
Some(b.trim_start_matches("b/").to_string())
}
fn path_from_plus_plus_plus(line: &str) -> Option<String> {
let rest = line.strip_prefix("+++ ")?.trim();
if rest == "/dev/null" {
return None;
}
Some(rest.trim_start_matches("b/").to_string())
}
fn path_from_minus_minus_minus(line: &str) -> Option<String> {
let rest = line.strip_prefix("--- ")?.trim();
if rest == "/dev/null" {
return None;
}
Some(rest.trim_start_matches("a/").to_string())
}
fn syntax_for_path<'a>(ps: &'a SyntaxSet, path: &str) -> &'a SyntaxReference {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let mapped = match ext.as_str() {
"ts" | "tsx" | "mts" | "cts" | "jsx" => "js",
"mjs" | "cjs" => "js",
"vue" | "svelte" => "html",
"yml" => "yaml",
other => other,
};
ps.find_syntax_by_extension(mapped)
.or_else(|| {
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| ps.find_syntax_by_extension(n))
})
.unwrap_or_else(|| ps.find_syntax_plain_text())
}
struct FileHighlighters<'a> {
old: HighlightLines<'a>,
new: HighlightLines<'a>,
}
impl<'a> FileHighlighters<'a> {
fn new(syntax: &'a SyntaxReference, theme: &'a Theme) -> Self {
Self {
old: HighlightLines::new(syntax, theme),
new: HighlightLines::new(syntax, theme),
}
}
}
pub fn highlight_unified_diff(patch: &str) -> Vec<Line<'static>> {
if patch.is_empty() {
return vec![muted_line("(无文件变更)")];
}
let ps = syntax_set();
let th = theme();
let plain = ps.find_syntax_plain_text();
let mut file_hl = FileHighlighters::new(plain, th);
let mut out = Vec::new();
for raw in patch.lines() {
if raw.starts_with("diff --git ") {
if let Some(path) = path_from_diff_git(raw) {
let syn = syntax_for_path(ps, &path);
file_hl = FileHighlighters::new(syn, th);
}
out.push(muted_line(raw));
continue;
}
if raw.starts_with("--- ") {
if let Some(path) = path_from_minus_minus_minus(raw) {
let syn = syntax_for_path(ps, &path);
file_hl.old = HighlightLines::new(syn, th);
}
out.push(muted_line(raw));
continue;
}
if raw.starts_with("+++ ") {
if let Some(path) = path_from_plus_plus_plus(raw) {
let syn = syntax_for_path(ps, &path);
file_hl.new = HighlightLines::new(syn, th);
}
out.push(muted_line(raw));
continue;
}
if raw.starts_with("@@")
|| raw.starts_with("index ")
|| raw.starts_with("new file")
|| raw.starts_with("deleted file")
|| raw.starts_with("similarity")
|| raw.starts_with("rename ")
|| raw.starts_with("Binary ")
|| raw.starts_with("old mode")
|| raw.starts_with("new mode")
{
out.push(muted_line(raw));
continue;
}
let (marker, code) = match raw.chars().next() {
Some('+') => ('+', &raw[1..]),
Some('-') => ('-', &raw[1..]),
Some(' ') => (' ', &raw[1..]),
_ => {
out.push(Line::from(Span::styled(
raw.to_string(),
Style::default().fg(theme::text()),
)));
continue;
}
};
let mut spans = Vec::new();
match marker {
'+' => {
spans.push(Span::styled(
"+".to_string(),
Style::default().fg(theme::success()),
));
spans.extend(highlight_code_line(&mut file_hl.new, code, ps));
}
'-' => {
spans.push(Span::styled(
"-".to_string(),
Style::default().fg(theme::danger()),
));
spans.extend(highlight_code_line(&mut file_hl.old, code, ps));
}
_ => {
spans.push(Span::styled(
" ".to_string(),
Style::default().fg(theme::muted()),
));
let _ = highlight_code_line(&mut file_hl.old, code, ps);
spans.extend(highlight_code_line(&mut file_hl.new, code, ps));
}
}
out.push(Line::from(spans));
}
out
}
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from("");
}
let mut used = 0usize;
let mut spans = Vec::new();
for span in line.spans {
let chars: Vec<char> = span.content.chars().collect();
let len = chars.len();
if used >= max_width {
break;
}
if used + len <= max_width {
used += len;
spans.push(span);
continue;
}
let remain = max_width - used;
if remain == 1 {
spans.push(Span::styled("…".to_string(), span.style));
} else {
let head: String = chars.into_iter().take(remain - 1).collect();
spans.push(Span::styled(format!("{head}…"), span.style));
}
break;
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highlights_rust_diff_tokens() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1 +1 @@
-fn old() {}
+fn new() {}
";
let lines = highlight_unified_diff(patch);
assert!(lines.len() >= 5);
let add = lines.iter().find(|l| {
l.spans
.first()
.is_some_and(|s| s.content.as_ref() == "+")
});
assert!(add.is_some());
let add = add.unwrap();
assert!(
add.spans.len() > 2,
"expected syntax spans, got {:?}",
add.spans
);
}
#[test]
fn highlights_tsx_via_javascript_fallback() {
let ps = syntax_set();
assert_eq!(syntax_for_path(ps, "src/App.tsx").name, "JavaScript");
assert_eq!(syntax_for_path(ps, "x.ts").name, "JavaScript");
assert_eq!(syntax_for_path(ps, "README.md").name, "Markdown");
let patch = "\
diff --git a/src/App.tsx b/src/App.tsx
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1 +1 @@
-const x = 1;
+export function App() { return null; }
";
let lines = highlight_unified_diff(patch);
let add = lines
.iter()
.find(|l| l.spans.first().is_some_and(|s| s.content.as_ref() == "+"))
.expect("add line");
assert!(
add.spans.len() > 2,
"tsx should highlight via JS, got {:?}",
add.spans
);
}
}