Skip to main content

rig_tui/
diff.rs

1//! A unified diff a tool returned, drawn in the transcript: each line
2//! with its numbers in the old and the new file, in the colors of the
3//! file's language ([`highlight`](crate::highlight)), removed lines on red
4//! and added ones on green ([`Theme`]), and the words a changed line
5//! changed on a stronger shade of each.
6
7use 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/// What a line of the diff is.
18#[derive(Clone, Copy, PartialEq, Eq)]
19enum Kind {
20    /// Text around the diff, such as a tool's summary.
21    Other,
22    /// A `---` or `+++` line naming a file.
23    File,
24    /// A hunk's `@@` header.
25    Hunk,
26    /// A line the same in both files, with its numbers in each.
27    Same(usize, usize),
28    /// A removed line, with its number in the old file.
29    Removed(usize),
30    /// An added line, with its number in the new file.
31    Added(usize),
32}
33
34/// `text` drawn as a unified diff when it holds a hunk header, else
35/// `None`, in `theme`'s colors.
36pub 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
85/// Each line of the diff `text` with what it is, tabs made spaces and
86/// the sign of a hunk's line taken off. A hunk's lines are counted by its
87/// header, so a removed line starting with `--` is not taken for a file.
88fn 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
130/// A hunk header's start and count in the old file and in the new, as in
131/// `@@ -12,3 +12,4 @@`; a count left out is 1.
132fn 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
145/// Each code line's spans in the colors of the file's language, by the
146/// line's index: the hunk's old side (same and removed lines) and its new
147/// side (same and added) highlighted apart. None when the diff names no
148/// file, or one whose language has no grammar.
149fn 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
184/// The bytes of each removed line followed by an added one, and of that
185/// added line, that changed between them, by the line's index. Removed
186/// and added lines pair up in order; a pair with too little in common is
187/// left unmarked.
188fn 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
239/// `spans` with `shade` laid over the bytes `ranges` of their joined
240/// text, each span split where a range starts or ends inside it.
241fn 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;