OXYGENHUB
# 19c71c828f86 oxygenhub / crates / hub / src / render / diff.rs
8.6 KiB · Rust · last changed Milestone 0.1: browse 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Diff rendering: `oxy`'s per-file hunks become unified rows with
2
//! context, and two snapshots become per-file hunks.
3
4
use oxy_core::repo::{DiffHunk, FileDiff};
5
use oxy_core::state::{FileContent, Snapshot};
6
use similar::{Algorithm, DiffOp};
7
use std::collections::BTreeMap;
8
9
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10
pub enum Kind {
11
    Context,
12
    Add,
13
    Del,
14
    /// Elided lines between hunks.
15
    Gap,
16
}
17
18
#[derive(Clone, Debug, PartialEq, Eq)]
19
pub struct Line {
20
    pub kind: Kind,
21
    pub old_no: Option<u32>,
22
    pub new_no: Option<u32>,
23
    /// Raw text, not escaped.
24
    pub text: String,
25
}
26
27
impl Kind {
28
    pub fn class(self) -> &'static str {
29
        match self {
30
            Kind::Context => "ctx",
31
            Kind::Add => "add",
32
            Kind::Del => "del",
33
            Kind::Gap => "gap",
34
        }
35
    }
36
37
    pub fn sign(self) -> &'static str {
38
        match self {
39
            Kind::Context => " ",
40
            Kind::Add => "+",
41
            Kind::Del => "-",
42
            Kind::Gap => "…",
43
        }
44
    }
45
}
46
47
fn text(b: &[u8]) -> String {
48
    String::from_utf8_lossy(b).trim_end_matches(['\n', '\r']).to_string()
49
}
50
51
/// Unified rows for one file: `base` is the file before the change (empty
52
/// for an added file) and `hunks` come from `oxy diff`, whose `base_line`
53
/// is the 1-based line the additions follow (0 = before the first line).
54
pub fn unified(base: &[u8], hunks: &[DiffHunk], context: usize) -> Vec<Line> {
55
    let base_lines: Vec<String> = oxy_core::diff::split_lines(base).into_iter().map(text).collect();
56
    // Turn hunks into a per-old-line plan: removed set, and additions keyed
57
    // by the base line they follow.
58
    let mut removed = std::collections::HashSet::new();
59
    let mut added: BTreeMap<u32, Vec<String>> = BTreeMap::new();
60
    for h in hunks {
61
        for (line, _) in &h.removed {
62
            removed.insert(*line);
63
        }
64
        added.entry(h.base_line).or_default().extend(h.added.iter().map(|b| text(b)));
65
    }
66
    // Walk the base, emitting rows; then trim to context around changes.
67
    let mut rows: Vec<Line> = Vec::new();
68
    let mut new_no: u32 = 0;
69
    if let Some(lines) = added.get(&0) {
70
        for l in lines {
71
            new_no += 1;
72
            rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: l.clone() });
73
        }
74
    }
75
    for (i, l) in base_lines.iter().enumerate() {
76
        let old_no = i as u32 + 1;
77
        if removed.contains(&old_no) {
78
            rows.push(Line { kind: Kind::Del, old_no: Some(old_no), new_no: None, text: l.clone() });
79
        } else {
80
            new_no += 1;
81
            rows.push(Line { kind: Kind::Context, old_no: Some(old_no), new_no: Some(new_no), text: l.clone() });
82
        }
83
        if let Some(lines) = added.get(&old_no) {
84
            for a in lines {
85
                new_no += 1;
86
                rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() });
87
            }
88
        }
89
    }
90
    // Any additions anchored past the end (defensive).
91
    for (anchor, lines) in &added {
92
        if *anchor as usize > base_lines.len() {
93
            for a in lines {
94
                new_no += 1;
95
                rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() });
96
            }
97
        }
98
    }
99
    with_context(rows, context)
100
}
101
102
/// Keep `context` rows around every change, replacing longer runs with a gap.
103
fn with_context(rows: Vec<Line>, context: usize) -> Vec<Line> {
104
    let changed: Vec<bool> = rows.iter().map(|r| r.kind != Kind::Context).collect();
105
    let mut keep = vec![false; rows.len()];
106
    for (i, c) in changed.iter().enumerate() {
107
        if *c {
108
            let lo = i.saturating_sub(context);
109
            let hi = (i + context + 1).min(rows.len());
110
            for k in keep.iter_mut().take(hi).skip(lo) {
111
                *k = true;
112
            }
113
        }
114
    }
115
    let mut out = Vec::new();
116
    let mut gap = 0;
117
    for (row, k) in rows.into_iter().zip(keep) {
118
        if k {
119
            if gap > 0 {
120
                out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") });
121
                gap = 0;
122
            }
123
            out.push(row);
124
        } else {
125
            gap += 1;
126
        }
127
    }
128
    if gap > 0 {
129
        out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") });
130
    }
131
    out
132
}
133
134
/// Hunks between two texts, in `oxy diff`'s shape.
135
pub fn hunks_between(old: &[u8], new: &[u8]) -> Vec<DiffHunk> {
136
    let old_lines = oxy_core::diff::split_lines(old);
137
    let new_lines = oxy_core::diff::split_lines(new);
138
    let ops = similar::capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines);
139
    let mut hunks: Vec<DiffHunk> = Vec::new();
140
    for op in ops {
141
        match op {
142
            DiffOp::Equal { .. } => {}
143
            DiffOp::Delete { old_index, old_len, .. } => {
144
                hunks.push(DiffHunk {
145
                    base_line: (old_index + old_len) as u32,
146
                    added: vec![],
147
                    removed: (old_index..old_index + old_len).map(|i| (i as u32 + 1, old_lines[i].to_vec())).collect(),
148
                });
149
            }
150
            DiffOp::Insert { old_index, new_index, new_len } => {
151
                hunks.push(DiffHunk {
152
                    base_line: old_index as u32,
153
                    added: (new_index..new_index + new_len).map(|i| new_lines[i].to_vec()).collect(),
154
                    removed: vec![],
155
                });
156
            }
157
            DiffOp::Replace { old_index, old_len, new_index, new_len } => {
158
                hunks.push(DiffHunk {
159
                    base_line: (old_index + old_len) as u32,
160
                    added: (new_index..new_index + new_len).map(|i| new_lines[i].to_vec()).collect(),
161
                    removed: (old_index..old_index + old_len).map(|i| (i as u32 + 1, old_lines[i].to_vec())).collect(),
162
                });
163
            }
164
        }
165
    }
166
    hunks
167
}
168
169
/// Every file that differs between two snapshots, with the base content
170
/// of files present in both (for context).
171
pub fn snapshot_diff(base: &Snapshot, head: &Snapshot) -> (Vec<FileDiff>, BTreeMap<String, Vec<u8>>) {
172
    let mut files = Vec::new();
173
    let mut bases = BTreeMap::new();
174
    let text_of = |f: &oxy_core::state::FileState| match &f.content {
175
        FileContent::Text { bytes, .. } => Some(bytes.clone()),
176
        _ => None,
177
    };
178
    for (path, hf) in &head.files {
179
        if hf.kind == oxy_core::objects::NodeKind::Dir {
180
            continue;
181
        }
182
        match base.files.get(path) {
183
            None => {
184
                let new = text_of(hf).unwrap_or_default();
185
                files.push(FileDiff { path: path.clone(), kind: "added".into(), hunks: hunks_between(b"", &new), from: None });
186
            }
187
            Some(bf) => {
188
                let (old, new) = (text_of(bf), text_of(hf));
189
                if old != new {
190
                    let old_b = old.clone().unwrap_or_default();
191
                    let hunks = hunks_between(&old_b, &new.unwrap_or_default());
192
                    if let Some(o) = old {
193
                        bases.insert(path.clone(), o);
194
                    }
195
                    files.push(FileDiff { path: path.clone(), kind: "modified".into(), hunks, from: None });
196
                } else if bf.mode != hf.mode {
197
                    files.push(FileDiff { path: path.clone(), kind: "mode".into(), hunks: vec![], from: None });
198
                }
199
            }
200
        }
201
    }
202
    for (path, bf) in &base.files {
203
        if bf.kind != oxy_core::objects::NodeKind::Dir && !head.files.contains_key(path) {
204
            let old = text_of(bf).unwrap_or_default();
205
            bases.insert(path.clone(), old.clone());
206
            files.push(FileDiff { path: path.clone(), kind: "removed".into(), hunks: hunks_between(&old, b""), from: None });
207
        }
208
    }
209
    files.sort_by(|a, b| a.path.cmp(&b.path));
210
    (files, bases)
211
}
212
213
#[cfg(test)]
214
mod tests {
215
    use super::*;
216
217
    #[test]
218
    fn unified_rows_with_context() {
219
        let base = b"a\nb\nc\nd\ne\nf\ng\nh\n";
220
        let hunks = hunks_between(base, b"a\nb\nc\nX\ne\nf\ng\nh\nZ\n");
221
        let rows = unified(base, &hunks, 1);
222
        let s: Vec<String> = rows.iter().map(|r| format!("{}{}", r.kind.sign(), r.text)).collect();
223
        assert_eq!(s, ["…2 unchanged lines", " c", "-d", "+X", " e", "…2 unchanged lines", " h", "+Z"]);
224
        assert_eq!(rows[2].old_no, Some(4));
225
        assert_eq!(rows[3].new_no, Some(4));
226
        assert_eq!(rows.last().unwrap().new_no, Some(9));
227
    }
228
229
    #[test]
230
    fn added_file() {
231
        let hunks = hunks_between(b"", b"one\ntwo\n");
232
        let rows = unified(b"", &hunks, 3);
233
        assert_eq!(rows.iter().map(|r| r.kind).collect::<Vec<_>>(), [Kind::Add, Kind::Add]);
234
    }
235
}