Skip to main content

declutter/
diff.rs

1use std::ops::Range;
2
3use similar::{Algorithm, DiffTag, capture_diff_slices, group_diff_ops};
4
5use crate::highlight::LineClasses;
6use crate::project::{LayerMode, Projection};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum RowKind {
10    Context,
11    Removed,
12    Added,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Row {
17    pub kind: RowKind,
18    /// One-based line number in the original old file.
19    pub old_line: Option<usize>,
20    /// One-based line number in the original new file.
21    pub new_line: Option<usize>,
22    pub text: String,
23    /// Byte ranges of `text` that differ from the paired line on the other side.
24    pub emphasis: Vec<Range<usize>>,
25    pub syntax: LineClasses,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Hunk {
30    pub rows: Vec<Row>,
31}
32
33impl Hunk {
34    pub fn changed(&self) -> impl Iterator<Item = &Row> {
35        self.rows.iter().filter(|row| row.kind != RowKind::Context)
36    }
37
38    pub fn header(&self) -> String {
39        let old = self.rows.iter().find_map(|row| row.old_line);
40        let new = self.rows.iter().find_map(|row| row.new_line);
41        let fmt = |line: Option<usize>| line.map_or("0".to_string(), |n| n.to_string());
42        format!("@@ -{} +{} @@", fmt(old), fmt(new))
43    }
44}
45
46/// Diffs two projections line by line and groups the result into hunks with
47/// `context` unchanged lines around each change.
48pub fn diff(old: &Projection, new: &Projection, context: usize) -> Vec<Hunk> {
49    let ops = capture_diff_slices(Algorithm::Histogram, &old.lines, &new.lines);
50    let old_row = |i: usize, kind| Row {
51        kind,
52        old_line: Some(old.orig[i] + 1),
53        new_line: None,
54        text: old.lines[i].clone(),
55        emphasis: Vec::new(),
56        syntax: old.syntax.get(i).cloned().unwrap_or_default(),
57    };
58    let new_row = |j: usize| Row {
59        kind: RowKind::Added,
60        old_line: None,
61        new_line: Some(new.orig[j] + 1),
62        text: new.lines[j].clone(),
63        emphasis: Vec::new(),
64        syntax: new.syntax.get(j).cloned().unwrap_or_default(),
65    };
66
67    group_diff_ops(ops, context)
68        .into_iter()
69        .map(|group| {
70            let mut rows = Vec::new();
71            for op in group {
72                let (tag, old_range, new_range) = op.as_tag_tuple();
73                match tag {
74                    DiffTag::Equal => {
75                        for (i, j) in old_range.zip(new_range) {
76                            rows.push(Row {
77                                new_line: Some(new.orig[j] + 1),
78                                ..old_row(i, RowKind::Context)
79                            });
80                        }
81                    }
82                    DiffTag::Delete => rows.extend(old_range.map(|i| old_row(i, RowKind::Removed))),
83                    DiffTag::Insert => rows.extend(new_range.map(new_row)),
84                    DiffTag::Replace => {
85                        let removed_at = rows.len();
86                        let pairs = old_range.len().min(new_range.len());
87                        rows.extend(old_range.map(|i| old_row(i, RowKind::Removed)));
88                        let added_at = rows.len();
89                        rows.extend(new_range.map(new_row));
90                        // Lines replaced one-for-one are usually edits of each other.
91                        for k in 0..pairs {
92                            let (old_text, new_text) =
93                                (&rows[removed_at + k].text, &rows[added_at + k].text);
94                            if let Some((old_marks, new_marks)) = inline_changes(old_text, new_text)
95                            {
96                                rows[removed_at + k].emphasis = old_marks;
97                                rows[added_at + k].emphasis = new_marks;
98                            }
99                        }
100                    }
101                }
102            }
103            Hunk { rows }
104        })
105        .collect()
106}
107
108/// Byte ranges within a line.
109pub type Marks = Vec<Range<usize>>;
110
111/// The parts of two versions of a line that differ, compared token by token, or `None`
112/// when the lines have too little in common for highlighting the difference to help.
113pub fn inline_changes(old: &str, new: &str) -> Option<(Marks, Marks)> {
114    let (old_tokens, new_tokens) = (tokens(old), tokens(new));
115    let old_text: Vec<&str> = old_tokens.iter().map(|range| &old[range.clone()]).collect();
116    let new_text: Vec<&str> = new_tokens.iter().map(|range| &new[range.clone()]).collect();
117
118    let mut old_marks: Vec<Range<usize>> = Vec::new();
119    let mut new_marks: Vec<Range<usize>> = Vec::new();
120    for op in capture_diff_slices(Algorithm::Myers, &old_text, &new_text) {
121        let (tag, old_range, new_range) = op.as_tag_tuple();
122        if tag == DiffTag::Equal {
123            continue;
124        }
125        if let Some(span) = span_of(&old_tokens, old_range) {
126            push_merged(&mut old_marks, span);
127        }
128        if let Some(span) = span_of(&new_tokens, new_range) {
129            push_merged(&mut new_marks, span);
130        }
131    }
132
133    let changed = |marks: &[Range<usize>], line: &str| {
134        let line = line.trim();
135        let bytes: usize = marks.iter().map(|m| m.len()).sum();
136        line.is_empty() || bytes * 10 > line.len() * 6
137    };
138    if changed(&old_marks, old) && changed(&new_marks, new) {
139        return None;
140    }
141    Some((old_marks, new_marks))
142}
143
144/// Splits a line into identifier/number runs, whitespace runs and single punctuation.
145pub(crate) fn tokens(line: &str) -> Vec<Range<usize>> {
146    let class = |c: char| {
147        if c.is_alphanumeric() || c == '_' {
148            0
149        } else if c.is_whitespace() {
150            1
151        } else {
152            2
153        }
154    };
155    let mut out: Vec<Range<usize>> = Vec::new();
156    let mut previous = None;
157    for (i, c) in line.char_indices() {
158        let kind = class(c);
159        match out.last_mut() {
160            Some(last) if previous == Some(kind) && kind != 2 => last.end = i + c.len_utf8(),
161            _ => out.push(i..i + c.len_utf8()),
162        }
163        previous = Some(kind);
164    }
165    out
166}
167
168fn span_of(tokens: &[Range<usize>], range: Range<usize>) -> Option<Range<usize>> {
169    if range.is_empty() {
170        return None;
171    }
172    Some(tokens[range.start].start..tokens[range.end - 1].end)
173}
174
175fn push_merged(marks: &mut Vec<Range<usize>>, span: Range<usize>) {
176    match marks.last_mut() {
177        Some(last) if last.end >= span.start => last.end = last.end.max(span.end),
178        _ => marks.push(span),
179    }
180}
181
182/// Applies the formatting layer to hunks: finds changes that only move whitespace —
183/// re-indenting, re-spacing, re-wrapping a statement over more or fewer lines — and
184/// either turns them into context (hidden) or keeps nothing else (only).
185pub fn filter_formatting(hunks: &mut Vec<Hunk>, mode: LayerMode, indentation_matters: bool) {
186    if mode == LayerMode::Shown {
187        return;
188    }
189    for hunk in hunks.iter_mut() {
190        let formatting = formatting_rows(&hunk.rows, indentation_matters);
191        let rows = std::mem::take(&mut hunk.rows);
192        hunk.rows = rows
193            .into_iter()
194            .zip(formatting)
195            .filter_map(
196                |(row, is_formatting)| match (mode, row.kind, is_formatting) {
197                    (_, RowKind::Context, _) => Some(row),
198                    // The new layout stays visible as context, so the code is not missing.
199                    (LayerMode::Hidden, RowKind::Added, true) => Some(Row {
200                        kind: RowKind::Context,
201                        old_line: None,
202                        emphasis: Vec::new(),
203                        ..row
204                    }),
205                    (LayerMode::Hidden, _, true) => None,
206                    (LayerMode::Only, _, false) => None,
207                    _ => Some(row),
208                },
209            )
210            .collect();
211    }
212    hunks.retain(|hunk| hunk.changed().next().is_some());
213}
214
215/// For each row, whether it belongs to a formatting-only change. A run of changed rows
216/// counts as a whole when its removed and added lines hold the same tokens; otherwise
217/// lines replaced one-for-one are compared pairwise.
218fn formatting_rows(rows: &[Row], indentation_matters: bool) -> Vec<bool> {
219    let mut marks = vec![false; rows.len()];
220    let mut start = 0;
221    while start < rows.len() {
222        if rows[start].kind == RowKind::Context {
223            start += 1;
224            continue;
225        }
226        let end = (start..rows.len())
227            .find(|&i| rows[i].kind == RowKind::Context)
228            .unwrap_or(rows.len());
229        let removed: Vec<usize> = (start..end)
230            .filter(|&i| rows[i].kind == RowKind::Removed)
231            .collect();
232        let added: Vec<usize> = (start..end)
233            .filter(|&i| rows[i].kind == RowKind::Added)
234            .collect();
235        let stream = |indices: &[usize]| -> Vec<String> {
236            indices
237                .iter()
238                .flat_map(|&i| significant_tokens(&rows[i].text, indentation_matters))
239                .collect()
240        };
241        if !(removed.is_empty() && added.is_empty()) {
242            // Equal streams include added or removed blank lines: layout only.
243            if stream(&removed) == stream(&added) {
244                marks[start..end].iter_mut().for_each(|mark| *mark = true);
245            } else if removed.len() == added.len() {
246                for (&old, &new) in removed.iter().zip(&added) {
247                    if stream(&[old]) == stream(&[new]) {
248                        marks[old] = true;
249                        marks[new] = true;
250                    }
251                }
252            }
253        }
254        start = end;
255    }
256    marks
257}
258
259/// The tokens of a line that whitespace changes cannot affect, plus its indentation
260/// when indentation is part of the syntax.
261fn significant_tokens(line: &str, indentation_matters: bool) -> Vec<String> {
262    let mut out = Vec::new();
263    if indentation_matters && !line.trim().is_empty() {
264        out.push(format!(
265            "indent:{}",
266            &line[..line.len() - line.trim_start().len()]
267        ));
268    }
269    out.extend(
270        tokens(line)
271            .into_iter()
272            .map(|range| &line[range])
273            .filter(|token| !token.trim().is_empty())
274            .map(str::to_string),
275    );
276    out
277}