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 (pi-style with line numbers)
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                    removed[0].line_num,
149                    added[0].line_num,
150                    &mut lines,
151                    theme,
152                );
153            } else {
154                // Multi-line change: show all removed, then all added
155                for r in &removed {
156                    let content = replace_tabs(r.content);
157                    let line_num = r.line_num;
158                    if line_num.is_empty() {
159                        lines.push(
160                            theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", content)),
161                        );
162                    } else {
163                        lines.push(theme.fg_key(
164                            ThemeKey::ToolDiffRemoved,
165                            &format!("-{} {}", line_num, content),
166                        ));
167                    }
168                }
169                for a in &added {
170                    let content = replace_tabs(a.content);
171                    let line_num = a.line_num;
172                    if line_num.is_empty() {
173                        lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
174                    } else {
175                        lines.push(theme.fg_key(
176                            ThemeKey::ToolDiffAdded,
177                            &format!("+{} {}", line_num, content),
178                        ));
179                    }
180                }
181            }
182        } else if parsed.prefix == '+' {
183            // Standalone added line (no preceding removal)
184            let content = replace_tabs(parsed.content);
185            let line_num = parsed.line_num;
186            if line_num.is_empty() {
187                lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
188            } else {
189                lines.push(theme.fg_key(
190                    ThemeKey::ToolDiffAdded,
191                    &format!("+{} {}", line_num, content),
192                ));
193            }
194            i += 1;
195        } else {
196            // Context line
197            let content = replace_tabs(parsed.content);
198            let line_num = parsed.line_num;
199            if line_num.is_empty() {
200                lines.push(theme.fg_key(ThemeKey::ToolDiffContext, &format!(" {}", content)));
201            } else {
202                lines.push(theme.fg_key(
203                    ThemeKey::ToolDiffContext,
204                    &format!(" {} {}", line_num, content),
205                ));
206            }
207            i += 1;
208        }
209    }
210
211    lines
212}
213
214/// Replace tabs with spaces for consistent rendering (matching pi's `replaceTabs`).
215fn replace_tabs(text: &str) -> String {
216    text.replace('\t', "   ")
217}
218
219/// Render intra-line diff for a single-line change (one removed, one added).
220/// Uses word-level diff and applies inverse (reverse video) on changed parts.
221///
222/// Matches pi's `renderIntraLineDiff()` which uses `diffWords` to find changed
223/// tokens and applies `theme.inverse()` on them.
224/// Strips leading whitespace from inverse to avoid highlighting indentation.
225/// Includes line numbers (pi-compatible): `-{lineNum} {content}` / `+{lineNum} {content}`
226fn render_intra_line_diff(
227    old_content: &str,
228    new_content: &str,
229    old_line_num: &str,
230    new_line_num: &str,
231    output: &mut Vec<String>,
232    theme: &dyn Theme,
233) {
234    let changes = compute_word_diff(old_content, new_content);
235
236    let mut removed_line = String::new();
237    let mut added_line = String::new();
238
239    for change in &changes {
240        match change {
241            Change::Equal(text) => {
242                removed_line.push_str(text);
243                added_line.push_str(text);
244            }
245            Change::Removed(text) => {
246                // Strip leading whitespace (matching pi's behavior)
247                let trimmed = text.trim_start();
248                if trimmed.len() < text.len() {
249                    let ws = &text[..text.len() - trimmed.len()];
250                    removed_line.push_str(ws);
251                }
252                if !trimmed.is_empty() {
253                    removed_line.push_str(&theme.inverse(trimmed));
254                }
255            }
256            Change::Added(text) => {
257                // Strip leading whitespace
258                let trimmed = text.trim_start();
259                if trimmed.len() < text.len() {
260                    let ws = &text[..text.len() - trimmed.len()];
261                    added_line.push_str(ws);
262                }
263                if !trimmed.is_empty() {
264                    added_line.push_str(&theme.inverse(trimmed));
265                }
266            }
267        }
268    }
269
270    if old_line_num.is_empty() {
271        output.push(theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", removed_line)));
272    } else {
273        output.push(theme.fg_key(
274            ThemeKey::ToolDiffRemoved,
275            &format!("-{} {}", old_line_num, removed_line),
276        ));
277    }
278    if new_line_num.is_empty() {
279        output.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", added_line)));
280    } else {
281        output.push(theme.fg_key(
282            ThemeKey::ToolDiffAdded,
283            &format!("+{} {}", new_line_num, added_line),
284        ));
285    }
286}
287
288/// A change in a diff: equal, removed, or added.
289#[derive(Debug)]
290enum Change {
291    Equal(String),
292    Removed(String),
293    Added(String),
294}
295
296/// Compute a word-level diff between two strings.
297/// Splits text into word tokens (alphanumeric sequences) and computes LCS.
298/// Groups consecutive same-type changes for compact output.
299/// Matches pi's `diffWords` behavior.
300fn compute_word_diff(old: &str, new: &str) -> Vec<Change> {
301    let old_tokens = split_words(old);
302    let new_tokens = split_words(new);
303    let n = old_tokens.len();
304    let m = new_tokens.len();
305
306    // Build LCS table using trimmed equality (matching pi's diffWords.equals:
307    // `left.trim() === right.trim()`)
308    let tokens_equal = |a: &str, b: &str| a.trim() == b.trim();
309
310    let mut dp = vec![vec![0usize; m + 1]; n + 1];
311    for i in 1..=n {
312        for j in 1..=m {
313            if tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
314                dp[i][j] = dp[i - 1][j - 1] + 1;
315            } else {
316                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
317            }
318        }
319    }
320
321    // Backtrack to extract diff
322    let mut temp = Vec::new();
323    let mut i = n;
324    let mut j = m;
325    while i > 0 || j > 0 {
326        if i > 0 && j > 0 && tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
327            temp.push(Change::Equal(old_tokens[i - 1].clone()));
328            i -= 1;
329            j -= 1;
330        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
331            temp.push(Change::Added(new_tokens[j - 1].clone()));
332            j -= 1;
333        } else {
334            temp.push(Change::Removed(old_tokens[i - 1].clone()));
335            i -= 1;
336        }
337    }
338    temp.reverse();
339
340    // Merge consecutive same-type changes
341    let mut merged: Vec<Change> = Vec::new();
342    for change in temp {
343        let should_merge = merged.last().is_some_and(|last| {
344            matches!(
345                (last, &change),
346                (Change::Equal(_), Change::Equal(_))
347                    | (Change::Removed(_), Change::Removed(_))
348                    | (Change::Added(_), Change::Added(_))
349            )
350        });
351
352        if should_merge {
353            if let Some(last) = merged.last_mut() {
354                let text = match change {
355                    Change::Equal(t) | Change::Removed(t) | Change::Added(t) => t,
356                };
357                match last {
358                    Change::Equal(t) => t.push_str(&text),
359                    Change::Removed(t) => t.push_str(&text),
360                    Change::Added(t) => t.push_str(&text),
361                }
362            }
363        } else {
364            merged.push(change);
365        }
366    }
367
368    merged
369}
370
371/// Split text into word tokens for diffing, matching pi's `diff.diffWords`.
372///
373/// First, splits into runs of whitespace and runs of non-whitespace.
374/// Then stitches whitespace tokens onto adjacent non-whitespace tokens:
375/// - Whitespace after a non-whitespace token gets appended to it
376/// - Whitespace before a non-whitespace token gets prepended to it
377/// - Leading whitespace (no preceding non-whitespace) stays as its own token
378///
379/// This matches the `tokenize` method of jsdiff's `WordDiff` class,
380/// which uses `tokenizeIncludingWhitespace` regex then groups whitespace
381/// with neighboring word/punctuation tokens.
382fn split_words(text: &str) -> Vec<String> {
383    // Phase 1: split into alternating whitespace and non-whitespace runs
384    // pi's regex: /[^\S\n]+|\n|[^\s\n]+/g for each char class, but simplified:
385    // We split on runs of whitespace and non-whitespace characters.
386    let mut parts: Vec<String> = Vec::new();
387    let mut current: Vec<char> = Vec::new();
388    let mut in_whitespace = None;
389
390    for ch in text.chars() {
391        let is_ws = ch.is_whitespace();
392        match in_whitespace {
393            Some(ws) if ws == is_ws => current.push(ch),
394            Some(_) => {
395                parts.push(current.iter().collect());
396                current.clear();
397                current.push(ch);
398                in_whitespace = Some(is_ws);
399            }
400            None => {
401                current.push(ch);
402                in_whitespace = Some(is_ws);
403            }
404        }
405    }
406    if !current.is_empty() {
407        parts.push(current.iter().collect());
408    }
409
410    if parts.is_empty() {
411        return vec![];
412    }
413
414    // Phase 2: stitch whitespace onto adjacent non-whitespace tokens.
415    // pi logic:
416    //   for each part:
417    //     if part is whitespace:
418    //       if first token: push as-is (leading whitespace)
419    //       else: pop last token, append whitespace, push back
420    //     elif prev was whitespace:
421    //       if last token == prev whitespace: pop and prepend whitespace to current
422    //       else: prepend whitespace to current
423    //     else (non-ws, prev non-ws): push as-is
424    let mut tokens: Vec<String> = Vec::new();
425    let mut prev_part: Option<&str> = None;
426
427    for part in &parts {
428        if part.is_empty() {
429            continue;
430        }
431        let is_ws = part.chars().all(|c| c.is_whitespace());
432
433        if is_ws {
434            if prev_part.is_none() {
435                // Leading whitespace: push as its own token
436                tokens.push(part.clone());
437            } else {
438                // Trailing whitespace: append to previous token
439                if let Some(last) = tokens.last_mut() {
440                    last.push_str(part);
441                }
442            }
443        } else if let Some(prev) = prev_part {
444            let prev_is_ws = prev.chars().all(|c| c.is_whitespace());
445            if prev_is_ws {
446                // Preceding whitespace: prepend to current non-ws token
447                if tokens.last().map(|t| t.as_str()) == Some(prev) {
448                    // The last token is the whitespace itself: pop, prepend to current
449                    tokens.pop();
450                    let mut merged = prev.to_string();
451                    merged.push_str(part);
452                    tokens.push(merged);
453                } else {
454                    // The last token has been merged: prepend the whitespace part
455                    tokens.push(prev.to_string() + part);
456                }
457            } else {
458                tokens.push(part.clone());
459            }
460        } else {
461            tokens.push(part.clone());
462        }
463
464        prev_part = Some(part.as_str());
465    }
466
467    tokens
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn test_theme() -> crate::agent::ui::theme::RabTheme {
475        crate::agent::ui::theme::current_theme().clone()
476    }
477
478    #[test]
479    fn test_empty_diff() {
480        let theme = test_theme();
481        let result = render_diff("", &theme);
482        assert!(result.is_empty());
483    }
484
485    #[test]
486    fn test_skips_headers() {
487        let theme = test_theme();
488        let diff = "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,4 @@\n";
489        let result = render_diff(diff, &theme);
490        assert!(result.is_empty(), "should skip all headers");
491    }
492
493    #[test]
494    fn test_context_lines() {
495        crate::agent::ui::theme::init_theme(Some("dark"), false);
496        let theme = test_theme();
497        let diff = " line1\n line2\n";
498        let result = render_diff(diff, &theme);
499        assert_eq!(result.len(), 2);
500        assert!(result[0].contains("line1"));
501        assert!(result[0].starts_with("\x1b")); // has ANSI color
502        assert!(result[0].contains("\x1b[39m")); // has reset
503    }
504
505    #[test]
506    fn test_removed_line() {
507        crate::agent::ui::theme::init_theme(Some("dark"), false);
508        let theme = test_theme();
509        let diff = "-old_line\n";
510        let result = render_diff(diff, &theme);
511        assert_eq!(result.len(), 1);
512        // Prefix should be preserved
513        assert!(result[0].contains('-'));
514        assert!(result[0].contains("old_line"));
515    }
516
517    #[test]
518    fn test_added_line() {
519        crate::agent::ui::theme::init_theme(Some("dark"), false);
520        let theme = test_theme();
521        let diff = "+new_line\n";
522        let result = render_diff(diff, &theme);
523        assert_eq!(result.len(), 1);
524        assert!(result[0].contains('+'));
525        assert!(result[0].contains("new_line"));
526    }
527
528    #[test]
529    fn test_single_line_modification() {
530        crate::agent::ui::theme::init_theme(Some("dark"), false);
531        let theme = test_theme();
532        let diff = "-foo\n+bar\n";
533        let result = render_diff(diff, &theme);
534        assert_eq!(result.len(), 2);
535        assert!(result[0].contains('-'));
536        assert!(result[1].contains('+'));
537        // Intra-line diff should have inverse markers
538        assert!(
539            result[0].contains("\x1b[7m"),
540            "should have inverse on removed"
541        );
542        assert!(
543            result[1].contains("\x1b[7m"),
544            "should have inverse on added"
545        );
546    }
547
548    #[test]
549    fn test_single_line_modification_with_line_numbers() {
550        crate::agent::ui::theme::init_theme(Some("dark"), false);
551        let theme = test_theme();
552        let diff = "- 42 foo\n+ 42 bar\n";
553        let result = render_diff(diff, &theme);
554        assert_eq!(result.len(), 2);
555        // Intra-line diff should have line numbers (pi-compatible)
556        assert!(
557            result[0].contains("-42"),
558            "should show line number on removed line: {:?}",
559            result[0]
560        );
561        assert!(
562            result[1].contains("+42"),
563            "should show line number on added line: {:?}",
564            result[1]
565        );
566        // Should have inverse markers on changed words
567        assert!(
568            result[0].contains("\x1b[7m"),
569            "should have inverse on removed"
570        );
571    }
572
573    #[test]
574    fn test_multi_line_removes() {
575        crate::agent::ui::theme::init_theme(Some("dark"), false);
576        let theme = test_theme();
577        let diff = "-a\n-b\n+c\n";
578        let result = render_diff(diff, &theme);
579        // Two removed lines, then one added
580        assert_eq!(result.len(), 3);
581        assert!(result[0].contains("-a"));
582        assert!(result[1].contains("-b"));
583        assert!(result[2].contains("+c"));
584    }
585
586    #[test]
587    fn test_multi_line_removes_no_intra_diff() {
588        crate::agent::ui::theme::init_theme(Some("dark"), false);
589        let theme = test_theme();
590        let diff = "-aaa\n-bbb\n+ccc\n+ddd\n";
591        let result = render_diff(diff, &theme);
592        assert_eq!(result.len(), 4);
593        // No intra-line diff for multi-line changes - no inverse markers
594        assert!(
595            !result[0].contains("\x1b[7m"),
596            "no inverse on multi-line remove"
597        );
598    }
599
600    #[test]
601    fn test_compute_word_diff_basic() {
602        let changes = compute_word_diff("abc", "abd");
603        assert!(!changes.is_empty());
604    }
605
606    #[test]
607    fn test_compute_word_diff_identical() {
608        let changes = compute_word_diff("hello", "hello");
609        assert_eq!(changes.len(), 1);
610        assert!(matches!(changes[0], Change::Equal(_)));
611    }
612
613    #[test]
614    fn test_tabs_replaced() {
615        crate::agent::ui::theme::init_theme(Some("dark"), false);
616        let theme = test_theme();
617        let diff = "-\tindented\n";
618        let result = render_diff(diff, &theme);
619        assert_eq!(result.len(), 1);
620        assert!(!result[0].contains('\t'), "tabs should be replaced");
621    }
622
623    #[test]
624    fn test_context_line_format() {
625        crate::agent::ui::theme::init_theme(Some("dark"), false);
626        let theme = test_theme();
627        let diff = " context\n";
628        let result = render_diff(diff, &theme);
629        assert_eq!(result.len(), 1);
630        assert!(result[0].contains("context"));
631        assert!(result[0].starts_with("\x1b"));
632    }
633}