Skip to main content

seal_tui/diff/
parse.rs

1//! Unified diff parser
2//!
3//! Parses standard unified diff format into structured data.
4
5/// A parsed unified diff
6#[derive(Debug, Clone, Default)]
7pub struct ParsedDiff {
8    pub file_a: Option<String>,
9    pub file_b: Option<String>,
10    pub hunks: Vec<DiffHunk>,
11}
12
13/// Line ranges covered by diff hunks (union of old-side and new-side),
14/// merged and sorted. Used to exclude already-displayed lines from orphaned
15/// context sections.
16#[must_use]
17pub fn hunk_exclusion_ranges(hunks: &[DiffHunk]) -> Vec<(i64, i64)> {
18    let mut ranges: Vec<(i64, i64)> = Vec::new();
19    for h in hunks {
20        if h.new_count > 0 {
21            ranges.push((
22                i64::from(h.new_start),
23                i64::from(h.new_start + h.new_count.saturating_sub(1)),
24            ));
25        }
26    }
27    ranges.sort_by_key(|r| r.0);
28    // Merge overlapping/adjacent ranges
29    let mut merged: Vec<(i64, i64)> = Vec::new();
30    for (s, e) in ranges {
31        if let Some(last) = merged.last_mut() {
32            if s <= last.1 + 1 {
33                last.1 = last.1.max(e);
34            } else {
35                merged.push((s, e));
36            }
37        } else {
38            merged.push((s, e));
39        }
40    }
41    merged
42}
43
44/// A single hunk from a diff
45#[derive(Debug, Clone)]
46pub struct DiffHunk {
47    /// The @@ header line
48    pub header: String,
49    /// Starting line in old file
50    pub old_start: u32,
51    /// Number of lines in old file
52    pub old_count: u32,
53    /// Starting line in new file
54    pub new_start: u32,
55    /// Number of lines in new file
56    pub new_count: u32,
57    /// Lines in this hunk
58    pub lines: Vec<DiffLine>,
59}
60
61/// A single line in a diff hunk
62#[derive(Debug, Clone)]
63pub struct DiffLine {
64    pub kind: DiffLineKind,
65    /// Line number in old file (if applicable)
66    pub old_line: Option<u32>,
67    /// Line number in new file (if applicable)
68    pub new_line: Option<u32>,
69    /// The line content (without the +/- prefix)
70    pub content: String,
71}
72
73/// Type of diff line
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DiffLineKind {
76    Context,
77    Added,
78    Removed,
79}
80
81impl ParsedDiff {
82    /// Parse a unified diff string
83    #[must_use]
84    pub fn parse(diff: &str) -> Self {
85        let mut result = Self::default();
86        let mut lines = diff.lines().peekable();
87
88        // Parse header (--- and +++ lines)
89        while let Some(line) = lines.peek() {
90            if line.starts_with("---") {
91                result.file_a = line.strip_prefix("--- ").map(|s| {
92                    // Remove a/ prefix if present
93                    s.strip_prefix("a/").unwrap_or(s).to_string()
94                });
95                lines.next();
96            } else if line.starts_with("+++") {
97                result.file_b = line.strip_prefix("+++ ").map(|s| {
98                    // Remove b/ prefix if present
99                    s.strip_prefix("b/").unwrap_or(s).to_string()
100                });
101                lines.next();
102            } else if line.starts_with("@@") {
103                break;
104            } else {
105                lines.next(); // Skip other header lines (diff --git, index, etc.)
106            }
107        }
108
109        // Parse hunks
110        while let Some(line) = lines.next() {
111            if line.starts_with("@@") {
112                if let Some(hunk) = Self::parse_hunk(line, &mut lines) {
113                    result.hunks.push(hunk);
114                }
115            }
116        }
117
118        result
119    }
120
121    fn parse_hunk(
122        header: &str,
123        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
124    ) -> Option<DiffHunk> {
125        // Parse @@ -start,count +start,count @@ optional context
126        // Example: @@ -1,5 +1,7 @@ fn main() {
127        let header_str = header.to_string();
128
129        let parts: Vec<&str> = header.split_whitespace().collect();
130        if parts.len() < 3 {
131            return None;
132        }
133
134        let (old_start, old_count) = Self::parse_range(parts[1].trim_start_matches('-'))?;
135        let (new_start, new_count) = Self::parse_range(parts[2].trim_start_matches('+'))?;
136
137        let mut hunk = DiffHunk {
138            header: header_str,
139            old_start,
140            old_count,
141            new_start,
142            new_count,
143            lines: Vec::new(),
144        };
145
146        let mut old_line = old_start;
147        let mut new_line = new_start;
148
149        while let Some(line) = lines.peek() {
150            if line.starts_with("@@") || line.starts_with("diff ") {
151                break;
152            }
153
154            let line = lines.next().unwrap_or_default();
155
156            let (kind, content) = if let Some(content) = line.strip_prefix('+') {
157                (DiffLineKind::Added, content)
158            } else if let Some(content) = line.strip_prefix('-') {
159                (DiffLineKind::Removed, content)
160            } else if let Some(content) = line.strip_prefix(' ') {
161                (DiffLineKind::Context, content)
162            } else if line.is_empty() {
163                // Empty context line
164                (DiffLineKind::Context, "")
165            } else if line.starts_with('\\') {
166                // "\ No newline at end of file"
167                continue;
168            } else {
169                // Unknown line format, treat as context
170                (DiffLineKind::Context, line)
171            };
172
173            let diff_line = match kind {
174                DiffLineKind::Added => {
175                    let dl = DiffLine {
176                        kind,
177                        old_line: None,
178                        new_line: Some(new_line),
179                        content: content.to_string(),
180                    };
181                    new_line += 1;
182                    dl
183                }
184                DiffLineKind::Removed => {
185                    let dl = DiffLine {
186                        kind,
187                        old_line: Some(old_line),
188                        new_line: None,
189                        content: content.to_string(),
190                    };
191                    old_line += 1;
192                    dl
193                }
194                DiffLineKind::Context => {
195                    let dl = DiffLine {
196                        kind,
197                        old_line: Some(old_line),
198                        new_line: Some(new_line),
199                        content: content.to_string(),
200                    };
201                    old_line += 1;
202                    new_line += 1;
203                    dl
204                }
205            };
206
207            hunk.lines.push(diff_line);
208        }
209
210        Some(hunk)
211    }
212
213    fn parse_range(s: &str) -> Option<(u32, u32)> {
214        if let Some((start, count)) = s.split_once(',') {
215            Some((start.parse().ok()?, count.parse().ok()?))
216        } else {
217            // Single line: "5" means start=5, count=1
218            let start = s.parse().ok()?;
219            Some((start, 1))
220        }
221    }
222
223    /// Get total number of lines across all hunks
224    #[must_use]
225    pub fn total_lines(&self) -> usize {
226        self.hunks.iter().map(|h| h.lines.len()).sum()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_parse_simple_diff() {
236        let diff = r#"diff --git a/src/main.rs b/src/main.rs
237index abc123..def456 100644
238--- a/src/main.rs
239+++ b/src/main.rs
240@@ -1,5 +1,7 @@
241 fn main() {
242-    println!("Hello");
243+    println!("Hello, world!");
244+    println!("Goodbye!");
245 }
246"#;
247
248        let parsed = ParsedDiff::parse(diff);
249
250        assert_eq!(parsed.file_a, Some("src/main.rs".to_string()));
251        assert_eq!(parsed.file_b, Some("src/main.rs".to_string()));
252        assert_eq!(parsed.hunks.len(), 1);
253
254        let hunk = &parsed.hunks[0];
255        assert_eq!(hunk.old_start, 1);
256        assert_eq!(hunk.old_count, 5);
257        assert_eq!(hunk.new_start, 1);
258        assert_eq!(hunk.new_count, 7);
259
260        // Should have: context, removed, added, added, context
261        assert_eq!(hunk.lines.len(), 5);
262        assert_eq!(hunk.lines[0].kind, DiffLineKind::Context);
263        assert_eq!(hunk.lines[1].kind, DiffLineKind::Removed);
264        assert_eq!(hunk.lines[2].kind, DiffLineKind::Added);
265        assert_eq!(hunk.lines[3].kind, DiffLineKind::Added);
266        assert_eq!(hunk.lines[4].kind, DiffLineKind::Context);
267    }
268
269    #[test]
270    fn test_line_numbers() {
271        let diff = r#"--- a/test.txt
272+++ b/test.txt
273@@ -10,3 +10,4 @@
274 context
275-removed
276+added1
277+added2
278"#;
279
280        let parsed = ParsedDiff::parse(diff);
281        let lines = &parsed.hunks[0].lines;
282
283        // Context line 10
284        assert_eq!(lines[0].old_line, Some(10));
285        assert_eq!(lines[0].new_line, Some(10));
286
287        // Removed line 11
288        assert_eq!(lines[1].old_line, Some(11));
289        assert_eq!(lines[1].new_line, None);
290
291        // Added line 11
292        assert_eq!(lines[2].old_line, None);
293        assert_eq!(lines[2].new_line, Some(11));
294
295        // Added line 12
296        assert_eq!(lines[3].old_line, None);
297        assert_eq!(lines[3].new_line, Some(12));
298    }
299}