Skip to main content

pfx_editor_toml/
diff.rs

1use std::ops::Range;
2use std::path::Path;
3
4use pfx_editor_doc::TextSplice;
5use similar::{DiffTag, TextDiff};
6
7pub const CONTEXT: usize = 3;
8pub const REVERT: &str = "revert hunk";
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Mark {
12    Add,
13    Del,
14    Change,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct Hunk {
19    pub mark: Mark,
20    pub old: Range<usize>,
21    pub new: Range<usize>,
22    pub old_lines: Range<usize>,
23    pub new_lines: Range<usize>,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum Row {
28    Head(usize),
29    Context {
30        line: usize,
31        text: String,
32    },
33    Fold(usize),
34    Del {
35        hunk: usize,
36        text: String,
37    },
38    Add {
39        hunk: usize,
40        line: usize,
41        text: String,
42    },
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
46pub struct Diff {
47    hunks: Vec<Hunk>,
48    rows: Vec<Row>,
49}
50
51fn offsets(lines: &[&str]) -> Vec<usize> {
52    let mut at = 0;
53    let mut starts = vec![0];
54    for line in lines {
55        at += line.len();
56        starts.push(at);
57    }
58    starts
59}
60
61fn bare(line: &str) -> String {
62    line.trim_end_matches(['\n', '\r']).to_string()
63}
64
65fn context<'a>(news: &'a [&str], lines: Range<usize>) -> impl Iterator<Item = Row> + 'a {
66    lines.map(|line| Row::Context {
67        line,
68        text: bare(news[line]),
69    })
70}
71
72impl Hunk {
73    pub fn revert(&self, file: impl AsRef<Path>, base: &str, text: &str) -> TextSplice {
74        let old = &base[self.old.clone()];
75        let end = self.new.start + old.len();
76        TextSplice::new(
77            file.as_ref(),
78            self.new.clone(),
79            &text[self.new.clone()],
80            old,
81        )
82        .labelled(REVERT)
83        .with_carets(self.new.clone(), end..end)
84    }
85}
86
87impl Diff {
88    pub fn between(base: &str, text: &str) -> Diff {
89        let diff = TextDiff::from_lines(base, text);
90        let (olds, news) = (diff.old_slices(), diff.new_slices());
91        let (old_at, new_at) = (offsets(olds), offsets(news));
92        let ops = diff.ops();
93        let mut hunks: Vec<Hunk> = Vec::new();
94        let mut rows = Vec::new();
95        let mut index = 0;
96        while index < ops.len() {
97            let (tag, old, new) = ops[index].as_tag_tuple();
98            if tag == DiffTag::Equal {
99                let before = !hunks.is_empty();
100                let after = index + 1 < ops.len();
101                let (head, tail) = (
102                    if before { CONTEXT } else { 0 },
103                    if after { CONTEXT } else { 0 },
104                );
105                if head + tail >= new.len() {
106                    rows.extend(context(news, new));
107                } else {
108                    rows.extend(context(news, new.start..new.start + head));
109                    rows.push(Row::Fold(new.len() - head - tail));
110                    rows.extend(context(news, new.end - tail..new.end));
111                }
112                index += 1;
113                continue;
114            }
115            let (mut old_lines, mut new_lines) = (old, new);
116            index += 1;
117            while index < ops.len() && ops[index].tag() != DiffTag::Equal {
118                let (_, old, new) = ops[index].as_tag_tuple();
119                old_lines.end = old.end;
120                new_lines.end = new.end;
121                index += 1;
122            }
123            let mark = if old_lines.is_empty() {
124                Mark::Add
125            } else if new_lines.is_empty() {
126                Mark::Del
127            } else {
128                Mark::Change
129            };
130            let at = hunks.len();
131            rows.push(Row::Head(at));
132            for line in old_lines.clone() {
133                rows.push(Row::Del {
134                    hunk: at,
135                    text: bare(olds[line]),
136                });
137            }
138            for line in new_lines.clone() {
139                rows.push(Row::Add {
140                    hunk: at,
141                    line,
142                    text: bare(news[line]),
143                });
144            }
145            hunks.push(Hunk {
146                mark,
147                old: old_at[old_lines.start]..old_at[old_lines.end],
148                new: new_at[new_lines.start]..new_at[new_lines.end],
149                old_lines,
150                new_lines,
151            });
152        }
153        Diff { hunks, rows }
154    }
155
156    pub fn hunks(&self) -> &[Hunk] {
157        &self.hunks
158    }
159
160    pub fn rows(&self) -> &[Row] {
161        &self.rows
162    }
163
164    pub fn is_empty(&self) -> bool {
165        self.hunks.is_empty()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    const BASE: &str = "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\nf = 6\ng = 7\nh = 8\ni = 9\nj = 10\n";
174
175    #[test]
176    fn equal_texts_have_no_hunks() {
177        let diff = Diff::between(BASE, BASE);
178        assert!(diff.is_empty());
179        assert!(diff.rows().iter().all(|row| !matches!(row, Row::Head(_))));
180    }
181
182    #[test]
183    fn a_changed_line_is_one_change_hunk_with_context() {
184        let text = BASE.replace("e = 5", "e = 50");
185        let diff = Diff::between(BASE, &text);
186        let [hunk] = diff.hunks() else {
187            panic!("{:?}", diff.hunks())
188        };
189        assert_eq!(hunk.mark, Mark::Change);
190        assert_eq!(&BASE[hunk.old.clone()], "e = 5\n");
191        assert_eq!(&text[hunk.new.clone()], "e = 50\n");
192        assert_eq!(hunk.old_lines, 4..5);
193        let rows = diff.rows();
194        assert!(rows.contains(&Row::Del {
195            hunk: 0,
196            text: "e = 5".into()
197        }));
198        assert!(rows.contains(&Row::Add {
199            hunk: 0,
200            line: 4,
201            text: "e = 50".into()
202        }));
203        assert!(rows.contains(&Row::Fold(1)));
204        assert!(rows.contains(&Row::Context {
205            line: 7,
206            text: "h = 8".into()
207        }));
208        assert!(!rows.contains(&Row::Context {
209            line: 8,
210            text: "i = 9".into()
211        }));
212    }
213
214    #[test]
215    fn added_and_deleted_lines_are_marked() {
216        let text = format!("new = 0\n{}", BASE.replace("j = 10\n", ""));
217        let diff = Diff::between(BASE, &text);
218        let marks: Vec<_> = diff.hunks().iter().map(|hunk| hunk.mark).collect();
219        assert_eq!(marks, vec![Mark::Add, Mark::Del]);
220    }
221
222    #[test]
223    fn the_same_texts_give_the_same_diff() {
224        let text = BASE.replace("c = 3", "c = 30").replace("i = 9", "i = 90");
225        assert_eq!(Diff::between(BASE, &text), Diff::between(BASE, &text));
226        assert_eq!(Diff::between(BASE, &text).hunks().len(), 2);
227    }
228
229    #[test]
230    fn a_revert_puts_the_base_text_back_over_the_hunk() {
231        let text = BASE
232            .replace("c = 3", "c = 30\nc2 = 31")
233            .replace("i = 9", "i = 90");
234        let diff = Diff::between(BASE, &text);
235        let splice = diff.hunks()[0].revert("x.toml", BASE, &text);
236        assert_eq!(splice.label, REVERT);
237        let mut back = text.clone();
238        back.replace_range(splice.range.clone(), &splice.new);
239        assert_eq!(back, BASE.replace("i = 9", "i = 90"));
240    }
241
242    #[test]
243    fn a_missing_final_newline_is_a_change() {
244        let diff = Diff::between("a = 1\n", "a = 1");
245        assert_eq!(diff.hunks().len(), 1);
246        assert_eq!(diff.hunks()[0].mark, Mark::Change);
247    }
248}