use std::collections::HashMap;
use std::ops::Range;
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span};
use similar::{ChangeTag, TextDiff};
use crate::Theme;
use crate::highlight::{Language, highlight};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Other,
File,
Hunk,
Same(usize, usize),
Removed(usize),
Added(usize),
}
pub fn unified_lines(text: &str, theme: &Theme) -> Option<Vec<Line<'static>>> {
if !text.lines().any(|line| header(line).is_some()) {
return None;
}
let lines: Vec<(Kind, String)> = classify(text);
let width = lines
.iter()
.map(|(kind, _)| match kind {
Kind::Same(old, new) => (*old).max(*new),
Kind::Removed(at) | Kind::Added(at) => *at,
_ => 0,
})
.max()
.unwrap_or_default()
.to_string()
.len();
let mut words = changed_words(&lines);
let mut code = highlighted(&lines);
let drawn = lines.iter().enumerate().map(|(at, (kind, text))| {
let number = |at: Option<usize>| at.map_or(" ".repeat(width), |at| format!("{at:>width$}"));
let gutter =
|old, new, sign| Span::from(format!(" {} {} {sign} ", number(old), number(new)));
let spans = code
.remove(&at)
.unwrap_or_else(|| vec![Span::from(text.clone())]);
let mut marked =
|shade| overlay(spans.clone(), &words.remove(&at).unwrap_or_default(), shade);
match *kind {
Kind::Other => Line::from(format!(" {text}")).dim(),
Kind::File => Line::from(format!(" {text}")).bold(),
Kind::Hunk => Line::from(format!(" {text}")).dim(),
Kind::Same(old, new) => {
Line::from([vec![gutter(Some(old), Some(new), ' ').dim()], spans].concat())
}
Kind::Removed(old) => {
let shade = theme.changed_word(theme.removed_word);
let gutter = gutter(Some(old), None, '-').red();
Line::from([vec![gutter], marked(shade)].concat()).bg(theme.removed)
}
Kind::Added(new) => {
let shade = theme.changed_word(theme.added_word);
let gutter = gutter(None, Some(new), '+').green();
Line::from([vec![gutter], marked(shade)].concat()).bg(theme.added)
}
}
});
Some(drawn.collect())
}
fn classify(text: &str) -> Vec<(Kind, String)> {
let (mut old, mut new) = (0, 0);
let (mut old_left, mut new_left) = (0usize, 0usize);
text.lines()
.map(|line| {
let line = line.replace('\t', " ");
let body = || line.get(1..).unwrap_or_default().to_owned();
if old_left + new_left > 0 {
let kind = match line.chars().next() {
Some('-') => {
old_left = old_left.saturating_sub(1);
old += 1;
Kind::Removed(old - 1)
}
Some('+') => {
new_left = new_left.saturating_sub(1);
new += 1;
Kind::Added(new - 1)
}
Some('\\') => return (Kind::Other, line),
_ => {
old_left = old_left.saturating_sub(1);
new_left = new_left.saturating_sub(1);
(old, new) = (old + 1, new + 1);
Kind::Same(old - 1, new - 1)
}
};
return (kind, body());
}
match header(&line) {
Some(((old_start, old_count), (new_start, new_count))) => {
(old, new) = (old_start, new_start);
(old_left, new_left) = (old_count, new_count);
(Kind::Hunk, line)
}
None if line.starts_with("--- ") || line.starts_with("+++ ") => (Kind::File, line),
None => (Kind::Other, line),
}
})
.collect()
}
fn header(line: &str) -> Option<((usize, usize), (usize, usize))> {
let rest = line.strip_prefix("@@ -")?;
let (ranges, _) = rest.split_once(" @@")?;
let (old, new) = ranges.split_once(" +")?;
let range = |range: &str| -> Option<(usize, usize)> {
match range.split_once(',') {
Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
None => Some((range.parse().ok()?, 1)),
}
};
Some((range(old)?, range(new)?))
}
fn highlighted(lines: &[(Kind, String)]) -> HashMap<usize, Vec<Span<'static>>> {
let mut spans = HashMap::new();
let files = lines.iter().filter(|(kind, _)| *kind == Kind::File);
let mut paths = files.filter_map(|(_, line)| line.get(4..)).map(str::trim);
let Some(path) = paths.rfind(|path| *path != "/dev/null") else {
return spans;
};
let hunks = lines
.iter()
.enumerate()
.collect::<Vec<_>>()
.chunk_by(|_, (_, (kind, _))| *kind != Kind::Hunk)
.map(<[_]>::to_vec)
.collect::<Vec<_>>();
let old = |kind: &Kind| matches!(kind, Kind::Same(..) | Kind::Removed(_));
let new = |kind: &Kind| matches!(kind, Kind::Same(..) | Kind::Added(_));
for hunk in &hunks {
for side in [&old as &dyn Fn(&Kind) -> bool, &new] {
let side: Vec<(usize, &String)> = hunk
.iter()
.filter(|(_, (kind, _))| side(kind))
.map(|(at, (_, text))| (*at, text))
.collect();
let code: Vec<&str> = side.iter().map(|(_, text)| text.as_str()).collect();
let Some(drawn) = highlight(&code.join("\n"), Language::File(path)) else {
return spans;
};
for ((at, _), line) in side.iter().zip(drawn) {
spans.entry(*at).or_insert(line);
}
}
}
spans
}
fn changed_words(lines: &[(Kind, String)]) -> HashMap<usize, Vec<Range<usize>>> {
let mut words = HashMap::new();
let removed = |at: &usize| matches!(lines.get(*at), Some((Kind::Removed(_), _)));
let added = |at: &usize| matches!(lines.get(*at), Some((Kind::Added(_), _)));
let mut at = 0;
while at < lines.len() {
if !removed(&at) {
at += 1;
continue;
}
let removed_end = (at..lines.len())
.find(|at| !removed(at))
.unwrap_or(lines.len());
let added_end = (removed_end..lines.len())
.find(|at| !added(at))
.unwrap_or(lines.len());
for (old, new) in (at..removed_end).zip(removed_end..added_end) {
let (Some((_, before)), Some((_, after))) = (lines.get(old), lines.get(new)) else {
continue;
};
let diff = TextDiff::from_words(before.as_str(), after.as_str());
if diff.ratio() < 0.3 {
continue;
}
let (mut was, mut is) = (Vec::new(), Vec::new());
let (mut was_at, mut is_at) = (0, 0);
for change in diff.iter_all_changes() {
let length = change.value().len();
match change.tag() {
ChangeTag::Equal => {
was_at += length;
is_at += length;
}
ChangeTag::Delete => {
was.push(was_at..was_at + length);
was_at += length;
}
ChangeTag::Insert => {
is.push(is_at..is_at + length);
is_at += length;
}
}
}
words.insert(old, was);
words.insert(new, is);
}
at = added_end.max(at + 1);
}
words
}
fn overlay(spans: Vec<Span<'static>>, ranges: &[Range<usize>], shade: Style) -> Vec<Span<'static>> {
if ranges.is_empty() {
return spans;
}
let mut laid = Vec::new();
let mut start = 0;
for span in spans {
let text = span.content.as_ref();
let end = start + text.len();
let mut cuts = vec![0, text.len()];
let inside = ranges.iter().flat_map(|range| [range.start, range.end]);
cuts.extend(
inside
.filter(|at| start < *at && *at < end)
.map(|at| at - start),
);
cuts.sort_unstable();
cuts.dedup();
for pair in cuts.windows(2) {
let [from, to] = pair else { continue };
let Some(piece) = text.get(*from..*to) else {
continue;
};
let at = start + from;
let marked = ranges.iter().any(|range| range.contains(&at));
let style = if marked {
span.style.patch(shade)
} else {
span.style
};
laid.push(Span::styled(piece.to_owned(), style));
}
start = end;
}
laid
}
#[cfg(test)]
mod tests;