Skip to main content

pagesift_diff/
unified.rs

1//! git diff 风格的逐行差异(统一格式,带上下文行),给 Firecrawl 的 changeTracking `git-diff` 模式用。
2//! 行序列的对齐和块级 diff 共用同一套 patience + Myers 算法。
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::algo::{self, Edit};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum LineKind {
13    Normal,
14    Add,
15    Del,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct DiffLine {
21    pub kind: LineKind,
22    /// 在旧文本里的行号(从 1 开始);新增行没有。
23    pub old_line: Option<usize>,
24    /// 在新文本里的行号(从 1 开始);删除行没有。
25    pub new_line: Option<usize>,
26    /// 行内容,不带 `+`/`-`/空格前缀。
27    pub text: String,
28}
29
30impl DiffLine {
31    /// 带前缀的一行,和 git diff 输出里的一样。
32    pub fn prefixed(&self) -> String {
33        let p = match self.kind {
34            LineKind::Normal => ' ',
35            LineKind::Add => '+',
36            LineKind::Del => '-',
37        };
38        format!("{p}{}", self.text)
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct Hunk {
45    pub old_start: usize,
46    pub old_lines: usize,
47    pub new_start: usize,
48    pub new_lines: usize,
49    pub lines: Vec<DiffLine>,
50}
51
52impl Hunk {
53    /// `@@ -1,3 +1,4 @@`。行数总是写出来(`,1` 也写),和 Firecrawl 文档里的示例一致。
54    pub fn header(&self) -> String {
55        format!(
56            "@@ -{},{} +{},{} @@",
57            self.old_start, self.old_lines, self.new_start, self.new_lines
58        )
59    }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct UnifiedDiff {
65    pub hunks: Vec<Hunk>,
66    pub additions: usize,
67    pub deletions: usize,
68}
69
70impl UnifiedDiff {
71    /// 统一格式的文本:每个 hunk 一个 `@@` 头,后面是带前缀的行;不含文件头。没有变化时是空串。
72    pub fn text(&self) -> String {
73        let mut out = String::new();
74        for h in &self.hunks {
75            if !out.is_empty() {
76                out.push('\n');
77            }
78            out.push_str(&h.header());
79            for l in &h.lines {
80                out.push('\n');
81                out.push_str(&l.prefixed());
82            }
83        }
84        out
85    }
86}
87
88/// 逐行比较,每处变化前后带 `context` 行上下文;相邻变化的上下文重叠时合并成一个 hunk(同 git)。
89pub fn unified_diff(old: &str, new: &str, context: usize) -> UnifiedDiff {
90    let ol: Vec<&str> = old.lines().collect();
91    let nl: Vec<&str> = new.lines().collect();
92    let mut ids: HashMap<&str, u32> = HashMap::new();
93    let mut ni = Vec::with_capacity(nl.len());
94    let mut oi = Vec::with_capacity(ol.len());
95    for (ls, out) in [(&ol, &mut oi), (&nl, &mut ni)] {
96        for &l in ls.iter() {
97            let n = ids.len() as u32;
98            out.push(*ids.entry(l).or_insert(n));
99        }
100    }
101    let edits = algo::diff(&oi, &ni);
102
103    let mut out = UnifiedDiff::default();
104    let changed: Vec<usize> = edits
105        .iter()
106        .enumerate()
107        .filter(|(_, e)| !matches!(e, Edit::Equal(..)))
108        .map(|(p, _)| p)
109        .collect();
110    let Some(&first) = changed.first() else {
111        return out;
112    };
113    // 每个 hunk 覆盖的编辑下标区间(含两端),中间的相等行不超过 2×context 时合并
114    let mut groups: Vec<(usize, usize)> = vec![(first, first)];
115    for &p in &changed[1..] {
116        let last = groups.last_mut().expect("non-empty");
117        if p - last.1 - 1 > 2 * context {
118            groups.push((p, p));
119        } else {
120            last.1 = p;
121        }
122    }
123    // 每个编辑位置之前已经过了几行旧文本、几行新文本
124    let (mut before_old, mut before_new) = (Vec::with_capacity(edits.len()), Vec::new());
125    let (mut o, mut n) = (0usize, 0usize);
126    for e in &edits {
127        before_old.push(o);
128        before_new.push(n);
129        match e {
130            Edit::Equal(..) => {
131                o += 1;
132                n += 1;
133            }
134            Edit::Delete(_) => o += 1,
135            Edit::Insert(_) => n += 1,
136        }
137    }
138    for (start, end) in groups {
139        let lo = start.saturating_sub(context);
140        let hi = (end + context).min(edits.len() - 1);
141        let mut lines = Vec::with_capacity(hi - lo + 1);
142        let (mut old_lines, mut new_lines) = (0, 0);
143        for e in &edits[lo..=hi] {
144            let line = match *e {
145                Edit::Equal(i, j) => {
146                    old_lines += 1;
147                    new_lines += 1;
148                    DiffLine {
149                        kind: LineKind::Normal,
150                        old_line: Some(i + 1),
151                        new_line: Some(j + 1),
152                        text: ol[i].to_owned(),
153                    }
154                }
155                Edit::Delete(i) => {
156                    old_lines += 1;
157                    out.deletions += 1;
158                    DiffLine {
159                        kind: LineKind::Del,
160                        old_line: Some(i + 1),
161                        new_line: None,
162                        text: ol[i].to_owned(),
163                    }
164                }
165                Edit::Insert(j) => {
166                    new_lines += 1;
167                    out.additions += 1;
168                    DiffLine {
169                        kind: LineKind::Add,
170                        old_line: None,
171                        new_line: Some(j + 1),
172                        text: nl[j].to_owned(),
173                    }
174                }
175            };
176            lines.push(line);
177        }
178        // 某一边没有行时,起始行号写成它前面那一行(同 git)
179        let start_of = |before: usize, count: usize| if count == 0 { before } else { before + 1 };
180        out.hunks.push(Hunk {
181            old_start: start_of(before_old[lo], old_lines),
182            old_lines,
183            new_start: start_of(before_new[lo], new_lines),
184            new_lines,
185            lines,
186        });
187    }
188    out
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn identical_texts_have_no_hunks() {
197        let d = unified_diff("a\nb\n", "a\nb", 3);
198        assert!(d.hunks.is_empty());
199        assert_eq!(d.text(), "");
200    }
201
202    #[test]
203    fn single_line_change() {
204        let d = unified_diff("old content\n", "new content\n", 3);
205        assert_eq!(d.text(), "@@ -1,1 +1,1 @@\n-old content\n+new content");
206        assert_eq!((d.additions, d.deletions), (1, 1));
207        assert_eq!(d.hunks[0].lines[0].old_line, Some(1));
208        assert_eq!(d.hunks[0].lines[1].new_line, Some(1));
209    }
210
211    #[test]
212    fn context_lines_and_line_numbers() {
213        let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n";
214        let new = "1\n2\n3\n4\n5\nsix\n7\n8\n9\n10\n";
215        let d = unified_diff(old, new, 2);
216        assert_eq!(d.text(), "@@ -4,5 +4,5 @@\n 4\n 5\n-6\n+six\n 7\n 8");
217    }
218
219    #[test]
220    fn distant_changes_split_into_hunks_and_near_ones_merge() {
221        let old: String = (1..=20).map(|i| format!("l{i}\n")).collect();
222        let new = old.replace("l2\n", "L2\n").replace("l18\n", "L18\n");
223        let d = unified_diff(&old, &new, 3);
224        assert_eq!(d.hunks.len(), 2);
225        assert_eq!(d.hunks[0].header(), "@@ -1,5 +1,5 @@");
226        assert_eq!(d.hunks[1].header(), "@@ -15,6 +15,6 @@");
227        let near = old.replace("l2\n", "L2\n").replace("l8\n", "L8\n");
228        assert_eq!(unified_diff(&old, &near, 3).hunks.len(), 1);
229    }
230
231    #[test]
232    fn pure_insertions_and_deletions() {
233        let d = unified_diff("", "a\nb\n", 3);
234        assert_eq!(d.text(), "@@ -0,0 +1,2 @@\n+a\n+b");
235        let d = unified_diff("a\nb\n", "", 3);
236        assert_eq!(d.text(), "@@ -1,2 +0,0 @@\n-a\n-b");
237        let d = unified_diff("a\nb\n", "a\nb\nc\n", 0);
238        assert_eq!(d.text(), "@@ -2,0 +3,1 @@\n+c");
239    }
240}