Skip to main content

rab/tui/components/
diff.rs

1use crate::agent::ui::theme::ThemeKey;
2use crate::tui::Theme;
3
4/// Diff line parsed into prefix, (optional) line number, and content.
5/// Matches pi's `parseDiffLine` return type.
6struct ParsedDiffLine<'a> {
7    prefix: char,
8    line_num: &'a str,
9    content: &'a str,
10}
11
12/// Parse a diff line into prefix, line number, and content.
13/// Format: `(+|\s)(\s*\d*)\s(.*)` (pi-compatible).
14/// - `+  3 content` → prefix '+', line_num '3', content 'content'
15/// - `-content` → does NOT match (needs space after prefix)
16/// - `-  content` → prefix '-', line_num '', content 'content'
17/// - `   ...` → prefix ' ', line_num '', content '...'
18fn parse_diff_line(line: &str) -> Option<ParsedDiffLine<'_>> {
19    let bytes = line.as_bytes();
20    if bytes.is_empty() {
21        return None;
22    }
23    let prefix = bytes[0] as char;
24    if prefix != '+' && prefix != '-' && prefix != ' ' {
25        return None;
26    }
27    let rest = &line[1..];
28    // Find where the line number/whitespace ends and content begins:
29    // after an optional sequence of whitespace and digits, there must be a space
30    let mut idx = 0;
31    let content_start = 'b: {
32        // First, consume all whitespace and digits
33        while idx < rest.len() {
34            let c = rest.as_bytes()[idx] as char;
35            if c == ' ' || c.is_ascii_digit() {
36                idx += 1;
37            } else {
38                break;
39            }
40        }
41        // There must be a space before content, OR idx is at end (no content)
42        if idx < rest.len() && rest.as_bytes()[idx] == b' ' {
43            break 'b idx + 1;
44        }
45        // If we consumed everything and rest is not empty,
46        // it means format is like "+content" without space - not pi-compatible.
47        // Still handle it: no line number, content starts at 0.
48        if idx == 0 {
49            break 'b 0;
50        }
51        // If we consumed whitespace/digits but no space follows, treat as no line number
52        break 'b idx;
53    };
54
55    let line_num_part = &rest[..idx].trim();
56    let content = &rest[content_start.min(rest.len())..];
57
58    Some(ParsedDiffLine {
59        prefix,
60        line_num: if line_num_part.is_empty() {
61            ""
62        } else {
63            line_num_part
64        },
65        content,
66    })
67}
68
69/// Render a diff string with colored lines and intra-line change highlighting.
70/// Matches pi's `renderDiff()` in `diff.ts`.
71///
72/// Input format (pi-compatible):
73/// `+{lineNum} {content}` / `-{lineNum} {content}` / ` {lineNum} {content}` / `  ...`
74/// Also handles simple format without line numbers:
75/// `-{content}` / `+{content}` / ` {content}`
76///
77/// Output: ANSI-styled lines with:
78/// - `-` lines: `toolDiffRemoved` (red), with inverse on changed tokens for single-line changes
79/// - `+` lines: `toolDiffAdded` (green), with inverse on changed tokens for single-line changes
80/// - ` ` lines: `toolDiffContext` (gray)
81///
82/// Multi-line changes show all removed lines first, then all added lines (no intra-line diff).
83/// Single-line changes (1 removed + 1 added) render intra-line word-diff with inverse.
84///
85/// Takes a `&dyn Theme` parameter to avoid calling `current_theme()` which
86/// would deadlock if the theme lock is already held by a caller.
87pub fn render_diff(diff_text: &str, theme: &dyn Theme) -> Vec<String> {
88    let mut lines: Vec<String> = Vec::new();
89    let diff_lines: Vec<&str> = diff_text.lines().collect();
90    let mut i = 0;
91
92    while i < diff_lines.len() {
93        let raw = diff_lines[i];
94
95        if raw.is_empty() {
96            i += 1;
97            continue;
98        }
99
100        // Skip unified diff headers (transitional: support old format too)
101        if raw.starts_with("---") || raw.starts_with("+++") || raw.starts_with("@@") {
102            i += 1;
103            continue;
104        }
105
106        let parsed = parse_diff_line(raw);
107        if parsed.is_none() {
108            // Non-diff line (e.g. hunk headers) - skip
109            i += 1;
110            continue;
111        }
112        let parsed = parsed.unwrap();
113
114        if parsed.prefix == '-' {
115            // Collect consecutive removed lines
116            let mut removed: Vec<ParsedDiffLine> = Vec::new();
117            while i < diff_lines.len() {
118                let l = diff_lines[i];
119                if let Some(p) = parse_diff_line(l)
120                    && p.prefix == '-'
121                {
122                    removed.push(p);
123                    i += 1;
124                } else {
125                    break;
126                }
127            }
128
129            // Collect consecutive added lines
130            let mut added: Vec<ParsedDiffLine> = Vec::new();
131            while i < diff_lines.len() {
132                let l = diff_lines[i];
133                if let Some(p) = parse_diff_line(l)
134                    && p.prefix == '+'
135                {
136                    added.push(p);
137                    i += 1;
138                } else {
139                    break;
140                }
141            }
142
143            // Single-line change: intra-line word diff
144            if removed.len() == 1 && added.len() == 1 {
145                render_intra_line_diff(
146                    &replace_tabs(removed[0].content),
147                    &replace_tabs(added[0].content),
148                    &mut lines,
149                    theme,
150                );
151            } else {
152                // Multi-line change: show all removed, then all added
153                for r in &removed {
154                    let content = replace_tabs(r.content);
155                    let line_num = r.line_num;
156                    if line_num.is_empty() {
157                        lines.push(
158                            theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", content)),
159                        );
160                    } else {
161                        lines.push(theme.fg_key(
162                            ThemeKey::ToolDiffRemoved,
163                            &format!("-{} {}", line_num, content),
164                        ));
165                    }
166                }
167                for a in &added {
168                    let content = replace_tabs(a.content);
169                    let line_num = a.line_num;
170                    if line_num.is_empty() {
171                        lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
172                    } else {
173                        lines.push(theme.fg_key(
174                            ThemeKey::ToolDiffAdded,
175                            &format!("+{} {}", line_num, content),
176                        ));
177                    }
178                }
179            }
180        } else if parsed.prefix == '+' {
181            // Standalone added line (no preceding removal)
182            let content = replace_tabs(parsed.content);
183            let line_num = parsed.line_num;
184            if line_num.is_empty() {
185                lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
186            } else {
187                lines.push(theme.fg_key(
188                    ThemeKey::ToolDiffAdded,
189                    &format!("+{} {}", line_num, content),
190                ));
191            }
192            i += 1;
193        } else {
194            // Context line
195            let content = replace_tabs(parsed.content);
196            let line_num = parsed.line_num;
197            if line_num.is_empty() {
198                lines.push(theme.fg_key(ThemeKey::ToolDiffContext, &format!(" {}", content)));
199            } else {
200                lines.push(theme.fg_key(
201                    ThemeKey::ToolDiffContext,
202                    &format!(" {} {}", line_num, content),
203                ));
204            }
205            i += 1;
206        }
207    }
208
209    lines
210}
211
212/// Replace tabs with spaces for consistent rendering (matching pi's `replaceTabs`).
213fn replace_tabs(text: &str) -> String {
214    text.replace('\t', "   ")
215}
216
217/// Render intra-line diff for a single-line change (one removed, one added).
218/// Uses word-level diff and applies inverse (reverse video) on changed parts.
219///
220/// Matches pi's `renderIntraLineDiff()` which uses `diffWords` to find changed
221/// tokens and applies `theme.inverse()` on them.
222/// Strips leading whitespace from inverse to avoid highlighting indentation.
223fn render_intra_line_diff(old: &str, new: &str, output: &mut Vec<String>, theme: &dyn Theme) {
224    let changes = compute_word_diff(old, new);
225
226    let mut removed_line = String::new();
227    let mut added_line = String::new();
228
229    for change in &changes {
230        match change {
231            Change::Equal(text) => {
232                removed_line.push_str(text);
233                added_line.push_str(text);
234            }
235            Change::Removed(text) => {
236                // Strip leading whitespace (matching pi's behavior)
237                let trimmed = text.trim_start();
238                if trimmed.len() < text.len() {
239                    let ws = &text[..text.len() - trimmed.len()];
240                    removed_line.push_str(ws);
241                }
242                if !trimmed.is_empty() {
243                    removed_line.push_str(&theme.inverse(trimmed));
244                }
245            }
246            Change::Added(text) => {
247                // Strip leading whitespace
248                let trimmed = text.trim_start();
249                if trimmed.len() < text.len() {
250                    let ws = &text[..text.len() - trimmed.len()];
251                    added_line.push_str(ws);
252                }
253                if !trimmed.is_empty() {
254                    added_line.push_str(&theme.inverse(trimmed));
255                }
256            }
257        }
258    }
259
260    output.push(theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", removed_line)));
261    output.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", added_line)));
262}
263
264/// A change in a diff: equal, removed, or added.
265#[derive(Debug)]
266enum Change {
267    Equal(String),
268    Removed(String),
269    Added(String),
270}
271
272/// Compute a word-level diff between two strings.
273/// Splits text into word tokens (alphanumeric sequences) and computes LCS.
274/// Groups consecutive same-type changes for compact output.
275/// Matches pi's `diffWords` behavior.
276fn compute_word_diff(old: &str, new: &str) -> Vec<Change> {
277    let old_tokens = split_words(old);
278    let new_tokens = split_words(new);
279    let n = old_tokens.len();
280    let m = new_tokens.len();
281
282    // Build LCS table using trimmed equality (matching pi's diffWords.equals:
283    // `left.trim() === right.trim()`)
284    let tokens_equal = |a: &str, b: &str| a.trim() == b.trim();
285
286    let mut dp = vec![vec![0usize; m + 1]; n + 1];
287    for i in 1..=n {
288        for j in 1..=m {
289            if tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
290                dp[i][j] = dp[i - 1][j - 1] + 1;
291            } else {
292                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
293            }
294        }
295    }
296
297    // Backtrack to extract diff
298    let mut temp = Vec::new();
299    let mut i = n;
300    let mut j = m;
301    while i > 0 || j > 0 {
302        if i > 0 && j > 0 && tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
303            temp.push(Change::Equal(old_tokens[i - 1].clone()));
304            i -= 1;
305            j -= 1;
306        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
307            temp.push(Change::Added(new_tokens[j - 1].clone()));
308            j -= 1;
309        } else {
310            temp.push(Change::Removed(old_tokens[i - 1].clone()));
311            i -= 1;
312        }
313    }
314    temp.reverse();
315
316    // Merge consecutive same-type changes
317    let mut merged: Vec<Change> = Vec::new();
318    for change in temp {
319        let should_merge = merged.last().is_some_and(|last| {
320            matches!(
321                (last, &change),
322                (Change::Equal(_), Change::Equal(_))
323                    | (Change::Removed(_), Change::Removed(_))
324                    | (Change::Added(_), Change::Added(_))
325            )
326        });
327
328        if should_merge {
329            if let Some(last) = merged.last_mut() {
330                let text = match change {
331                    Change::Equal(t) | Change::Removed(t) | Change::Added(t) => t,
332                };
333                match last {
334                    Change::Equal(t) => t.push_str(&text),
335                    Change::Removed(t) => t.push_str(&text),
336                    Change::Added(t) => t.push_str(&text),
337                }
338            }
339        } else {
340            merged.push(change);
341        }
342    }
343
344    merged
345}
346
347/// Split text into word tokens for diffing, matching pi's `diff.diffWords`.
348///
349/// First, splits into runs of whitespace and runs of non-whitespace.
350/// Then stitches whitespace tokens onto adjacent non-whitespace tokens:
351/// - Whitespace after a non-whitespace token gets appended to it
352/// - Whitespace before a non-whitespace token gets prepended to it
353/// - Leading whitespace (no preceding non-whitespace) stays as its own token
354///
355/// This matches the `tokenize` method of jsdiff's `WordDiff` class,
356/// which uses `tokenizeIncludingWhitespace` regex then groups whitespace
357/// with neighboring word/punctuation tokens.
358fn split_words(text: &str) -> Vec<String> {
359    // Phase 1: split into alternating whitespace and non-whitespace runs
360    // pi's regex: /[^\S\n]+|\n|[^\s\n]+/g for each char class, but simplified:
361    // We split on runs of whitespace and non-whitespace characters.
362    let mut parts: Vec<String> = Vec::new();
363    let mut current: Vec<char> = Vec::new();
364    let mut in_whitespace = None;
365
366    for ch in text.chars() {
367        let is_ws = ch.is_whitespace();
368        match in_whitespace {
369            Some(ws) if ws == is_ws => current.push(ch),
370            Some(_) => {
371                parts.push(current.iter().collect());
372                current.clear();
373                current.push(ch);
374                in_whitespace = Some(is_ws);
375            }
376            None => {
377                current.push(ch);
378                in_whitespace = Some(is_ws);
379            }
380        }
381    }
382    if !current.is_empty() {
383        parts.push(current.iter().collect());
384    }
385
386    if parts.is_empty() {
387        return vec![];
388    }
389
390    // Phase 2: stitch whitespace onto adjacent non-whitespace tokens.
391    // pi logic:
392    //   for each part:
393    //     if part is whitespace:
394    //       if first token: push as-is (leading whitespace)
395    //       else: pop last token, append whitespace, push back
396    //     elif prev was whitespace:
397    //       if last token == prev whitespace: pop and prepend whitespace to current
398    //       else: prepend whitespace to current
399    //     else (non-ws, prev non-ws): push as-is
400    let mut tokens: Vec<String> = Vec::new();
401    let mut prev_part: Option<&str> = None;
402
403    for part in &parts {
404        if part.is_empty() {
405            continue;
406        }
407        let is_ws = part.chars().all(|c| c.is_whitespace());
408
409        if is_ws {
410            if prev_part.is_none() {
411                // Leading whitespace: push as its own token
412                tokens.push(part.clone());
413            } else {
414                // Trailing whitespace: append to previous token
415                if let Some(last) = tokens.last_mut() {
416                    last.push_str(part);
417                }
418            }
419        } else if let Some(prev) = prev_part {
420            let prev_is_ws = prev.chars().all(|c| c.is_whitespace());
421            if prev_is_ws {
422                // Preceding whitespace: prepend to current non-ws token
423                if tokens.last().map(|t| t.as_str()) == Some(prev) {
424                    // The last token is the whitespace itself: pop, prepend to current
425                    tokens.pop();
426                    let mut merged = prev.to_string();
427                    merged.push_str(part);
428                    tokens.push(merged);
429                } else {
430                    // The last token has been merged: prepend the whitespace part
431                    tokens.push(prev.to_string() + part);
432                }
433            } else {
434                tokens.push(part.clone());
435            }
436        } else {
437            tokens.push(part.clone());
438        }
439
440        prev_part = Some(part.as_str());
441    }
442
443    tokens
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn test_theme() -> crate::agent::ui::theme::RabTheme {
451        crate::agent::ui::theme::current_theme().clone()
452    }
453
454    #[test]
455    fn test_empty_diff() {
456        let theme = test_theme();
457        let result = render_diff("", &theme);
458        assert!(result.is_empty());
459    }
460
461    #[test]
462    fn test_skips_headers() {
463        let theme = test_theme();
464        let diff = "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,4 @@\n";
465        let result = render_diff(diff, &theme);
466        assert!(result.is_empty(), "should skip all headers");
467    }
468
469    #[test]
470    fn test_context_lines() {
471        crate::agent::ui::theme::init_theme(Some("dark"), false);
472        let theme = test_theme();
473        let diff = " line1\n line2\n";
474        let result = render_diff(diff, &theme);
475        assert_eq!(result.len(), 2);
476        assert!(result[0].contains("line1"));
477        assert!(result[0].starts_with("\x1b")); // has ANSI color
478        assert!(result[0].contains("\x1b[39m")); // has reset
479    }
480
481    #[test]
482    fn test_removed_line() {
483        crate::agent::ui::theme::init_theme(Some("dark"), false);
484        let theme = test_theme();
485        let diff = "-old_line\n";
486        let result = render_diff(diff, &theme);
487        assert_eq!(result.len(), 1);
488        // Prefix should be preserved
489        assert!(result[0].contains('-'));
490        assert!(result[0].contains("old_line"));
491    }
492
493    #[test]
494    fn test_added_line() {
495        crate::agent::ui::theme::init_theme(Some("dark"), false);
496        let theme = test_theme();
497        let diff = "+new_line\n";
498        let result = render_diff(diff, &theme);
499        assert_eq!(result.len(), 1);
500        assert!(result[0].contains('+'));
501        assert!(result[0].contains("new_line"));
502    }
503
504    #[test]
505    fn test_single_line_modification() {
506        crate::agent::ui::theme::init_theme(Some("dark"), false);
507        let theme = test_theme();
508        let diff = "-foo\n+bar\n";
509        let result = render_diff(diff, &theme);
510        assert_eq!(result.len(), 2);
511        assert!(result[0].contains('-'));
512        assert!(result[1].contains('+'));
513        // Intra-line diff should have inverse markers
514        assert!(
515            result[0].contains("\x1b[7m"),
516            "should have inverse on removed"
517        );
518        assert!(
519            result[1].contains("\x1b[7m"),
520            "should have inverse on added"
521        );
522    }
523
524    #[test]
525    fn test_multi_line_removes() {
526        crate::agent::ui::theme::init_theme(Some("dark"), false);
527        let theme = test_theme();
528        let diff = "-a\n-b\n+c\n";
529        let result = render_diff(diff, &theme);
530        // Two removed lines, then one added
531        assert_eq!(result.len(), 3);
532        assert!(result[0].contains("-a"));
533        assert!(result[1].contains("-b"));
534        assert!(result[2].contains("+c"));
535    }
536
537    #[test]
538    fn test_multi_line_removes_no_intra_diff() {
539        crate::agent::ui::theme::init_theme(Some("dark"), false);
540        let theme = test_theme();
541        let diff = "-aaa\n-bbb\n+ccc\n+ddd\n";
542        let result = render_diff(diff, &theme);
543        assert_eq!(result.len(), 4);
544        // No intra-line diff for multi-line changes - no inverse markers
545        assert!(
546            !result[0].contains("\x1b[7m"),
547            "no inverse on multi-line remove"
548        );
549    }
550
551    #[test]
552    fn test_compute_word_diff_basic() {
553        let changes = compute_word_diff("abc", "abd");
554        assert!(!changes.is_empty());
555    }
556
557    #[test]
558    fn test_compute_word_diff_identical() {
559        let changes = compute_word_diff("hello", "hello");
560        assert_eq!(changes.len(), 1);
561        assert!(matches!(changes[0], Change::Equal(_)));
562    }
563
564    #[test]
565    fn test_tabs_replaced() {
566        crate::agent::ui::theme::init_theme(Some("dark"), false);
567        let theme = test_theme();
568        let diff = "-\tindented\n";
569        let result = render_diff(diff, &theme);
570        assert_eq!(result.len(), 1);
571        assert!(!result[0].contains('\t'), "tabs should be replaced");
572    }
573
574    #[test]
575    fn test_context_line_format() {
576        crate::agent::ui::theme::init_theme(Some("dark"), false);
577        let theme = test_theme();
578        let diff = " context\n";
579        let result = render_diff(diff, &theme);
580        assert_eq!(result.len(), 1);
581        assert!(result[0].contains("context"));
582        assert!(result[0].starts_with("\x1b"));
583    }
584}