use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use std::sync::OnceLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Style as SynStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
const REMOVED_BG: Color = Color::Rgb(55, 24, 24);
const ADDED_BG: Color = Color::Rgb(24, 46, 24);
fn max_line_width(raw: &str) -> usize {
raw.lines()
.map(|l| l.chars().count().saturating_sub(1))
.max()
.unwrap_or(0)
}
const TAB_WIDTH: usize = 4;
fn printable(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for line in raw.lines() {
let line = line.strip_suffix('\r').unwrap_or(line);
let mut col = 0usize;
for (i, c) in line.chars().enumerate() {
let before = out.len();
match c {
'\t' => {
let pad = if i == 0 {
1
} else {
TAB_WIDTH - col % TAB_WIDTH
};
out.extend(std::iter::repeat_n(' ', pad));
}
c if c.is_ascii_control() => {
out.push('^');
out.push(char::from(c as u8 ^ 0x40));
}
c if c.is_control() => out.push(char::REPLACEMENT_CHARACTER),
c => out.push(c),
}
if i > 0 {
col += out[before..].chars().count();
}
}
out.push('\n');
}
out
}
pub type Blob = Box<dyn FnOnce() -> Option<String> + Send>;
#[derive(Default)]
pub struct DiffContext {
pub old: Option<Blob>,
pub new: Option<Blob>,
}
const MAX_PRIME: usize = 1 << 20;
struct Side {
hl: HighlightLines<'static>,
blob: Option<Blob>,
lines: Option<Vec<String>>,
fed: usize,
}
impl Side {
fn new(syntax: &'static SyntaxReference, blob: Option<Blob>) -> Self {
Side {
hl: HighlightLines::new(syntax, theme()),
blob,
lines: None,
fed: 0,
}
}
fn line(&mut self, ps: &SyntaxSet, code: &str) -> Vec<Span<'static>> {
self.fed += 1;
hl_spans(&mut self.hl, ps, code)
}
fn prime(&mut self, ps: &SyntaxSet, start: usize) {
if start <= self.fed + 1 {
return;
}
if self.lines.is_none() {
let Some(blob) = self.blob.take() else {
return;
};
let Some(text) = blob().filter(|t| t.len() <= MAX_PRIME) else {
return;
};
self.lines = Some(text.lines().map(str::to_string).collect());
}
let Side { hl, lines, fed, .. } = self;
let Some(lines) = lines.as_ref() else {
return;
};
let upto = (start - 1).min(lines.len());
if upto <= *fed {
return;
}
for line in &lines[*fed..upto] {
let _ = hl.highlight_line(&format!("{line}\n"), ps);
}
*fed = upto;
}
}
struct FileHl {
old: Side,
new: Side,
}
type NumLine = (usize, Vec<Span<'static>>);
enum DiffRow {
Header(Line<'static>),
Pair {
lnum: Option<usize>,
lspans: Vec<Span<'static>>,
lbg: Option<Color>,
rnum: Option<usize>,
rspans: Vec<Span<'static>>,
rbg: Option<Color>,
},
}
#[derive(Default)]
pub struct RenderedDiff {
rows: Vec<DiffRow>,
gutter_w: usize,
max_line: usize,
}
impl RenderedDiff {
pub fn max_line(&self) -> usize {
self.max_line
}
pub fn cell_width(&self, width: u16) -> u16 {
let avail = (width as usize).saturating_sub(2 * self.gutter_w + 5);
(avail / 2).max(1) as u16
}
}
pub fn prepare_diff(raw: &str, mut ctx: DiffContext) -> RenderedDiff {
let ps = syntaxes();
let raw = &printable(raw);
let mut rows: Vec<DiffRow> = Vec::new();
let mut hl: Option<FileHl> = None;
let mut in_patch = false;
let mut old_ln = 0usize;
let mut new_ln = 0usize;
let mut rem: Vec<NumLine> = Vec::new();
let mut add: Vec<NumLine> = Vec::new();
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
flush_pairs(&mut rows, &mut rem, &mut add);
in_patch = true;
let syntax = syntax_for(ps, rest.rsplit(" b/").next().unwrap_or(""));
let (old, new) = if std::ptr::eq(syntax, ps.find_syntax_plain_text()) {
(None, None)
} else {
(ctx.old.take(), ctx.new.take())
};
hl = Some(FileHl {
old: Side::new(syntax, old),
new: Side::new(syntax, new),
});
rows.push(DiffRow::Header(plain(
line,
dim().add_modifier(Modifier::BOLD),
)));
continue;
}
if !in_patch {
if line.is_empty() {
continue;
}
rows.push(DiffRow::Header(plain(line, Style::default())));
continue;
}
if line.starts_with("@@") {
flush_pairs(&mut rows, &mut rem, &mut add);
if let Some((a, _, c, _)) = parse_hunk(line) {
old_ln = a;
new_ln = c;
if let Some(h) = hl.as_mut() {
h.old.prime(ps, a);
h.new.prime(ps, c);
}
}
rows.push(DiffRow::Header(plain(
line,
Style::default().fg(Color::Cyan),
)));
continue;
}
if is_meta(line) {
flush_pairs(&mut rows, &mut rem, &mut add);
rows.push(DiffRow::Header(plain(line, dim())));
continue;
}
if let Some(code) = line.strip_prefix('-') {
let spans = hl_old(&mut hl, ps, code);
rem.push((old_ln, spans));
old_ln += 1;
} else if let Some(code) = line.strip_prefix('+') {
let spans = hl_new(&mut hl, ps, code);
add.push((new_ln, spans));
new_ln += 1;
} else if let Some(code) = line.strip_prefix(' ') {
flush_pairs(&mut rows, &mut rem, &mut add);
let l = hl_old(&mut hl, ps, code);
let r = hl_new(&mut hl, ps, code);
rows.push(DiffRow::Pair {
lnum: Some(old_ln),
lspans: l,
lbg: None,
rnum: Some(new_ln),
rspans: r,
rbg: None,
});
old_ln += 1;
new_ln += 1;
} else {
flush_pairs(&mut rows, &mut rem, &mut add);
rows.push(DiffRow::Header(plain(line, dim())));
}
}
flush_pairs(&mut rows, &mut rem, &mut add);
RenderedDiff {
rows,
gutter_w: gutter_width(raw),
max_line: max_line_width(raw),
}
}
fn flush_pairs(rows: &mut Vec<DiffRow>, rem: &mut Vec<NumLine>, add: &mut Vec<NumLine>) {
let n = rem.len().max(add.len());
for i in 0..n {
let (lnum, lspans, lbg) = match rem.get(i) {
Some((num, s)) => (Some(*num), s.clone(), Some(REMOVED_BG)),
None => (None, Vec::new(), None),
};
let (rnum, rspans, rbg) = match add.get(i) {
Some((num, s)) => (Some(*num), s.clone(), Some(ADDED_BG)),
None => (None, Vec::new(), None),
};
rows.push(DiffRow::Pair {
lnum,
lspans,
lbg,
rnum,
rspans,
rbg,
});
}
rem.clear();
add.clear();
}
pub fn render_prepared(d: &RenderedDiff, width: u16, hscroll: u16) -> Text<'static> {
let g = d.gutter_w;
let inner = width as usize;
let avail = inner.saturating_sub(2 * g + 5);
let lw = (avail / 2).max(1);
let rw = avail.saturating_sub(lw).max(1);
let hs = hscroll as usize;
let lines: Vec<Line<'static>> = d
.rows
.iter()
.map(|row| match row {
DiffRow::Header(l) => l.clone(),
DiffRow::Pair {
lnum,
lspans,
lbg,
rnum,
rspans,
rbg,
} => {
let mut spans = gutter(*lnum, g, *lbg);
spans.extend(fit_cell(lspans.clone(), lw, *lbg, hs));
spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
spans.extend(gutter(*rnum, g, *rbg));
spans.extend(fit_cell(rspans.clone(), rw, *rbg, hs));
Line::from(spans)
}
})
.collect();
Text::from(lines)
}
fn gutter(num: Option<usize>, g: usize, bg: Option<Color>) -> Vec<Span<'static>> {
let text = match num {
Some(n) => format!("{n:>g$} "),
None => " ".repeat(g + 1),
};
let mut style = Style::default().fg(Color::DarkGray);
if let Some(bg) = bg {
style = style.bg(bg);
}
vec![Span::styled(text, style)]
}
fn gutter_width(raw: &str) -> usize {
let mut max = 1usize;
for line in raw.lines() {
if line.starts_with("@@")
&& let Some((a, b, c, d)) = parse_hunk(line)
{
max = max.max(a + b).max(c + d);
}
}
digits(max).max(3)
}
fn digits(mut n: usize) -> usize {
let mut d = 1;
while n >= 10 {
n /= 10;
d += 1;
}
d
}
fn parse_hunk(line: &str) -> Option<(usize, usize, usize, usize)> {
let body = line.strip_prefix("@@ ")?.split(" @@").next()?; let mut parts = body.split(' ');
let (a, b) = parse_pair(parts.next()?.strip_prefix('-')?)?;
let (c, d) = parse_pair(parts.next()?.strip_prefix('+')?)?;
Some((a, b, c, d))
}
fn parse_pair(s: &str) -> Option<(usize, usize)> {
let mut it = s.split(',');
let start = it.next()?.parse().ok()?;
let count = it.next().and_then(|x| x.parse().ok()).unwrap_or(1);
Some((start, count))
}
fn fit_cell(
spans: Vec<Span<'static>>,
width: usize,
bg: Option<Color>,
skip: usize,
) -> Vec<Span<'static>> {
let mut out = Vec::new();
let mut skipped = 0usize; let mut used = 0usize; for sp in spans {
if used >= width {
break;
}
let chars: Vec<char> = sp.content.chars().collect();
let mut start = 0usize;
if skipped < skip {
let drop = (skip - skipped).min(chars.len());
start = drop;
skipped += drop;
if start >= chars.len() {
continue; }
}
let take = (chars.len() - start).min(width - used);
let text: String = chars[start..start + take].iter().collect();
let mut style = sp.style;
if let Some(bg) = bg {
style = style.bg(bg);
}
out.push(Span::styled(text, style));
used += take;
}
if used < width {
let mut style = Style::default();
if let Some(bg) = bg {
style = style.bg(bg);
}
out.push(Span::styled(" ".repeat(width - used), style));
}
out
}
fn hl_old(hl: &mut Option<FileHl>, ps: &SyntaxSet, code: &str) -> Vec<Span<'static>> {
match hl {
Some(h) => h.old.line(ps, code),
None => vec![Span::raw(code.to_string())],
}
}
fn hl_new(hl: &mut Option<FileHl>, ps: &SyntaxSet, code: &str) -> Vec<Span<'static>> {
match hl {
Some(h) => h.new.line(ps, code),
None => vec![Span::raw(code.to_string())],
}
}
fn hl_spans(h: &mut HighlightLines, ps: &SyntaxSet, code: &str) -> Vec<Span<'static>> {
let with_nl = format!("{code}\n");
match h.highlight_line(&with_nl, ps) {
Ok(ranges) => ranges
.into_iter()
.filter_map(|(sty, text)| {
let text = text.trim_end_matches('\n');
(!text.is_empty()).then(|| Span::styled(text.to_string(), syn_to_ratatui(sty)))
})
.collect(),
Err(_) => vec![Span::raw(code.to_string())],
}
}
fn is_meta(line: &str) -> bool {
line.starts_with("index ")
|| line.starts_with("--- ")
|| line.starts_with("+++ ")
|| line.starts_with("new file")
|| line.starts_with("deleted file")
|| line.starts_with("similarity ")
|| line.starts_with("rename ")
|| line.starts_with("\\ No newline")
}
fn syntax_for<'a>(ps: &'a SyntaxSet, path: &str) -> &'a SyntaxReference {
path.rsplit('.')
.next()
.filter(|ext| *ext != path)
.and_then(|ext| ps.find_syntax_by_extension(ext))
.unwrap_or_else(|| ps.find_syntax_plain_text())
}
fn plain(text: &str, style: Style) -> Line<'static> {
Line::from(Span::styled(text.to_string(), style))
}
fn dim() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
fn syn_to_ratatui(s: SynStyle) -> Style {
let fg = s.foreground;
let mut style = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b));
if s.font_style.contains(FontStyle::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
style
}
fn syntaxes() -> &'static SyntaxSet {
static SET: OnceLock<SyntaxSet> = OnceLock::new();
SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
fn theme() -> &'static Theme {
static THEME: OnceLock<Theme> = OnceLock::new();
THEME.get_or_init(|| {
let mut ts = ThemeSet::load_defaults();
ts.themes
.remove("base16-eighties.dark")
.or_else(|| ts.themes.remove("base16-ocean.dark"))
.unwrap_or_else(|| ts.themes.values().next().cloned().unwrap_or_default())
})
}
#[cfg(test)]
mod tests {
use super::*;
const FILE: &str = "import os\n\"\"\"\ndoc a\ndoc b\n\"\"\"\n\nx = os.getcwd()\ny = 1\n";
const WHOLE: &str = "diff --git a/x.py b/x.py\n@@ -1,8 +1,8 @@\n import os\n \"\"\"\n doc a\n doc b\n \"\"\"\n \n-x = os.getcwd()\n+x = os.getpid()\n y = 1\n";
const TAIL: &str = "diff --git a/x.py b/x.py\n@@ -5,4 +5,4 @@\n \"\"\"\n \n-x = os.getcwd()\n+x = os.getpid()\n y = 1\n";
fn ctx() -> DiffContext {
DiffContext {
old: Some(Box::new(|| Some(FILE.to_string()))),
new: Some(Box::new(|| Some(FILE.to_string()))),
}
}
fn spans_of(d: &RenderedDiff, needle: &str) -> Vec<(String, Style)> {
let text = render_prepared(d, 120, 0);
text.lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains(needle)))
.map(|l| {
l.spans
.iter()
.filter(|s| !s.content.trim().is_empty())
.map(|s| (s.content.to_string(), s.style))
.collect()
})
.unwrap_or_default()
}
#[test]
fn a_hunk_is_coloured_as_if_the_file_was_read_from_its_first_line() {
let whole = spans_of(&prepare_diff(WHOLE, DiffContext::default()), "y = 1");
let tail = spans_of(&prepare_diff(TAIL, ctx()), "y = 1");
let blind = spans_of(&prepare_diff(TAIL, DiffContext::default()), "y = 1");
assert_eq!(
tail, whole,
"a hunk given the lines above it must colour like the whole file"
);
assert_ne!(
blind, whole,
"without them the closing \"\"\" reads as an opening one - if this \
passes, the comparison above proves nothing"
);
}
#[test]
fn a_diff_never_hands_the_terminal_a_control_character() {
let raw = "diff --git a/x.txt b/x.txt\n@@ -0,0 +1,3 @@\n+\tindented\n+mid\rline\n+bell\x07end\r\n";
let text = render_prepared(&prepare_diff(raw, DiffContext::default()), 120, 0);
for line in &text.lines {
let row: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!row.contains(char::is_control),
"a control character moves the terminal's cursor and leaves stale cells: {row:?}"
);
}
let rows: Vec<String> = text
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
rows.iter().any(|r| r.contains(" indented")),
"a tab at the start of a line expands to the first tab stop: {rows:#?}"
);
}
}