1use std::collections::HashMap;
8use std::ops::Range;
9
10use ratatui::style::{Style, Stylize};
11use ratatui::text::{Line, Span};
12use similar::{ChangeTag, TextDiff};
13
14use crate::Theme;
15use crate::highlight::{Language, highlight};
16
17#[derive(Clone, Copy, PartialEq, Eq)]
19enum Kind {
20 Other,
22 File,
24 Hunk,
26 Same(usize, usize),
28 Removed(usize),
30 Added(usize),
32}
33
34pub fn unified_lines(text: &str, theme: &Theme) -> Option<Vec<Line<'static>>> {
37 if !text.lines().any(|line| header(line).is_some()) {
38 return None;
39 }
40 let lines: Vec<(Kind, String)> = classify(text);
41 let width = lines
42 .iter()
43 .map(|(kind, _)| match kind {
44 Kind::Same(old, new) => (*old).max(*new),
45 Kind::Removed(at) | Kind::Added(at) => *at,
46 _ => 0,
47 })
48 .max()
49 .unwrap_or_default()
50 .to_string()
51 .len();
52 let mut words = changed_words(&lines);
53 let mut code = highlighted(&lines);
54 let drawn = lines.iter().enumerate().map(|(at, (kind, text))| {
55 let number = |at: Option<usize>| at.map_or(" ".repeat(width), |at| format!("{at:>width$}"));
56 let gutter =
57 |old, new, sign| Span::from(format!(" {} {} {sign} ", number(old), number(new)));
58 let spans = code
59 .remove(&at)
60 .unwrap_or_else(|| vec![Span::from(text.clone())]);
61 let mut marked =
62 |shade| overlay(spans.clone(), &words.remove(&at).unwrap_or_default(), shade);
63 match *kind {
64 Kind::Other => Line::from(format!(" {text}")).dim(),
65 Kind::File => Line::from(format!(" {text}")).bold(),
66 Kind::Hunk => Line::from(format!(" {text}")).dim(),
67 Kind::Same(old, new) => {
68 Line::from([vec![gutter(Some(old), Some(new), ' ').dim()], spans].concat())
69 }
70 Kind::Removed(old) => {
71 let shade = theme.changed_word(theme.removed_word);
72 let gutter = gutter(Some(old), None, '-').red();
73 Line::from([vec![gutter], marked(shade)].concat()).bg(theme.removed)
74 }
75 Kind::Added(new) => {
76 let shade = theme.changed_word(theme.added_word);
77 let gutter = gutter(None, Some(new), '+').green();
78 Line::from([vec![gutter], marked(shade)].concat()).bg(theme.added)
79 }
80 }
81 });
82 Some(drawn.collect())
83}
84
85fn classify(text: &str) -> Vec<(Kind, String)> {
89 let (mut old, mut new) = (0, 0);
90 let (mut old_left, mut new_left) = (0usize, 0usize);
91 text.lines()
92 .map(|line| {
93 let line = line.replace('\t', " ");
94 let body = || line.get(1..).unwrap_or_default().to_owned();
95 if old_left + new_left > 0 {
96 let kind = match line.chars().next() {
97 Some('-') => {
98 old_left = old_left.saturating_sub(1);
99 old += 1;
100 Kind::Removed(old - 1)
101 }
102 Some('+') => {
103 new_left = new_left.saturating_sub(1);
104 new += 1;
105 Kind::Added(new - 1)
106 }
107 Some('\\') => return (Kind::Other, line),
108 _ => {
109 old_left = old_left.saturating_sub(1);
110 new_left = new_left.saturating_sub(1);
111 (old, new) = (old + 1, new + 1);
112 Kind::Same(old - 1, new - 1)
113 }
114 };
115 return (kind, body());
116 }
117 match header(&line) {
118 Some(((old_start, old_count), (new_start, new_count))) => {
119 (old, new) = (old_start, new_start);
120 (old_left, new_left) = (old_count, new_count);
121 (Kind::Hunk, line)
122 }
123 None if line.starts_with("--- ") || line.starts_with("+++ ") => (Kind::File, line),
124 None => (Kind::Other, line),
125 }
126 })
127 .collect()
128}
129
130fn header(line: &str) -> Option<((usize, usize), (usize, usize))> {
133 let rest = line.strip_prefix("@@ -")?;
134 let (ranges, _) = rest.split_once(" @@")?;
135 let (old, new) = ranges.split_once(" +")?;
136 let range = |range: &str| -> Option<(usize, usize)> {
137 match range.split_once(',') {
138 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
139 None => Some((range.parse().ok()?, 1)),
140 }
141 };
142 Some((range(old)?, range(new)?))
143}
144
145fn highlighted(lines: &[(Kind, String)]) -> HashMap<usize, Vec<Span<'static>>> {
150 let mut spans = HashMap::new();
151 let files = lines.iter().filter(|(kind, _)| *kind == Kind::File);
152 let mut paths = files.filter_map(|(_, line)| line.get(4..)).map(str::trim);
153 let Some(path) = paths.rfind(|path| *path != "/dev/null") else {
154 return spans;
155 };
156 let hunks = lines
157 .iter()
158 .enumerate()
159 .collect::<Vec<_>>()
160 .chunk_by(|_, (_, (kind, _))| *kind != Kind::Hunk)
161 .map(<[_]>::to_vec)
162 .collect::<Vec<_>>();
163 let old = |kind: &Kind| matches!(kind, Kind::Same(..) | Kind::Removed(_));
164 let new = |kind: &Kind| matches!(kind, Kind::Same(..) | Kind::Added(_));
165 for hunk in &hunks {
166 for side in [&old as &dyn Fn(&Kind) -> bool, &new] {
167 let side: Vec<(usize, &String)> = hunk
168 .iter()
169 .filter(|(_, (kind, _))| side(kind))
170 .map(|(at, (_, text))| (*at, text))
171 .collect();
172 let code: Vec<&str> = side.iter().map(|(_, text)| text.as_str()).collect();
173 let Some(drawn) = highlight(&code.join("\n"), Language::File(path)) else {
174 return spans;
175 };
176 for ((at, _), line) in side.iter().zip(drawn) {
177 spans.entry(*at).or_insert(line);
178 }
179 }
180 }
181 spans
182}
183
184fn changed_words(lines: &[(Kind, String)]) -> HashMap<usize, Vec<Range<usize>>> {
189 let mut words = HashMap::new();
190 let removed = |at: &usize| matches!(lines.get(*at), Some((Kind::Removed(_), _)));
191 let added = |at: &usize| matches!(lines.get(*at), Some((Kind::Added(_), _)));
192 let mut at = 0;
193 while at < lines.len() {
194 if !removed(&at) {
195 at += 1;
196 continue;
197 }
198 let removed_end = (at..lines.len())
199 .find(|at| !removed(at))
200 .unwrap_or(lines.len());
201 let added_end = (removed_end..lines.len())
202 .find(|at| !added(at))
203 .unwrap_or(lines.len());
204 for (old, new) in (at..removed_end).zip(removed_end..added_end) {
205 let (Some((_, before)), Some((_, after))) = (lines.get(old), lines.get(new)) else {
206 continue;
207 };
208 let diff = TextDiff::from_words(before.as_str(), after.as_str());
209 if diff.ratio() < 0.3 {
210 continue;
211 }
212 let (mut was, mut is) = (Vec::new(), Vec::new());
213 let (mut was_at, mut is_at) = (0, 0);
214 for change in diff.iter_all_changes() {
215 let length = change.value().len();
216 match change.tag() {
217 ChangeTag::Equal => {
218 was_at += length;
219 is_at += length;
220 }
221 ChangeTag::Delete => {
222 was.push(was_at..was_at + length);
223 was_at += length;
224 }
225 ChangeTag::Insert => {
226 is.push(is_at..is_at + length);
227 is_at += length;
228 }
229 }
230 }
231 words.insert(old, was);
232 words.insert(new, is);
233 }
234 at = added_end.max(at + 1);
235 }
236 words
237}
238
239fn overlay(spans: Vec<Span<'static>>, ranges: &[Range<usize>], shade: Style) -> Vec<Span<'static>> {
242 if ranges.is_empty() {
243 return spans;
244 }
245 let mut laid = Vec::new();
246 let mut start = 0;
247 for span in spans {
248 let text = span.content.as_ref();
249 let end = start + text.len();
250 let mut cuts = vec![0, text.len()];
251 let inside = ranges.iter().flat_map(|range| [range.start, range.end]);
252 cuts.extend(
253 inside
254 .filter(|at| start < *at && *at < end)
255 .map(|at| at - start),
256 );
257 cuts.sort_unstable();
258 cuts.dedup();
259 for pair in cuts.windows(2) {
260 let [from, to] = pair else { continue };
261 let Some(piece) = text.get(*from..*to) else {
262 continue;
263 };
264 let at = start + from;
265 let marked = ranges.iter().any(|range| range.contains(&at));
266 let style = if marked {
267 span.style.patch(shade)
268 } else {
269 span.style
270 };
271 laid.push(Span::styled(piece.to_owned(), style));
272 }
273 start = end;
274 }
275 laid
276}
277
278#[cfg(test)]
279mod tests;