Skip to main content

vtcode_ui/design/
diff.rs

1//! Unified diff formatting with ANSI colors.
2//!
3//! Provides `format_colored_diff` as the single canonical implementation
4//! for rendering diff hunks with terminal colors. Previously duplicated
5//! in `vtcode-core` and `vtcode-ui`.
6
7use anstyle::{AnsiColor, Color, Reset, RgbColor, Style};
8use std::fmt::Write;
9use vtcode_diff::{DiffDocument, format_unified_hunks};
10
11// Keep the published UI facade's legacy options shape while exposing the
12// renderer-neutral types from the extracted implementation. New consumers
13// that need algorithm or timeout controls should depend on `vtcode-diff`.
14pub use vtcode_commons::diff::{DiffOptions, compute_diff};
15pub use vtcode_diff::{Chunk, DiffBundle, DiffHunk, DiffLine, DiffLineKind, compute_diff_chunks};
16
17/// Named diff color themes with WCAG AA-checked accents.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum DiffTheme {
20    /// Light theme modeled on GitHub's diff colors.
21    #[default]
22    Github,
23    /// Dark theme modeled on Ayu.
24    Ayu,
25    /// Dark theme modeled on Gruvbox.
26    Gruvbox,
27    /// Dark theme modeled on Nord.
28    Nord,
29    /// Light theme modeled on Solarized.
30    Solarized,
31    /// Dark theme modeled on Dracula.
32    Dracula,
33}
34
35impl DiffTheme {
36    /// Every built-in diff theme, in selection order.
37    pub const ALL: [DiffTheme; 6] = [
38        DiffTheme::Github,
39        DiffTheme::Ayu,
40        DiffTheme::Gruvbox,
41        DiffTheme::Nord,
42        DiffTheme::Solarized,
43        DiffTheme::Dracula,
44    ];
45
46    /// Stable selection name.
47    #[must_use]
48    pub const fn name(self) -> &'static str {
49        match self {
50            Self::Github => "github",
51            Self::Ayu => "ayu",
52            Self::Gruvbox => "gruvbox",
53            Self::Nord => "nord",
54            Self::Solarized => "solarized",
55            Self::Dracula => "dracula",
56        }
57    }
58
59    /// Resolves a theme by its stable selection name.
60    #[must_use]
61    pub fn from_name(name: &str) -> Option<Self> {
62        Self::ALL.into_iter().find(|theme| theme.name() == name)
63    }
64
65    fn palette(self) -> DiffPalette {
66        match self {
67            Self::Github => DiffPalette {
68                background: (0xff, 0xff, 0xff),
69                header: (0x57, 0x60, 0x6a),
70                addition: (0x22, 0x86, 0x3a),
71                deletion: (0xcb, 0x24, 0x31),
72            },
73            Self::Ayu => DiffPalette {
74                background: (0x0b, 0x0e, 0x14),
75                header: (0x8b, 0x94, 0x9e),
76                addition: (0xaa, 0xd9, 0x4c),
77                deletion: (0xf0, 0x71, 0x78),
78            },
79            Self::Gruvbox => DiffPalette {
80                background: (0x28, 0x28, 0x28),
81                header: (0x83, 0xa5, 0x98),
82                addition: (0xb8, 0xbb, 0x26),
83                deletion: (0xfc, 0x6b, 0x57),
84            },
85            Self::Nord => DiffPalette {
86                background: (0x2e, 0x34, 0x40),
87                header: (0x88, 0xc0, 0xd0),
88                addition: (0xa3, 0xbe, 0x8c),
89                deletion: (0xeb, 0x8f, 0x97),
90            },
91            Self::Solarized => DiffPalette {
92                background: (0xfd, 0xf6, 0xe3),
93                header: (0x58, 0x6e, 0x75),
94                addition: (0x5b, 0x73, 0x00),
95                deletion: (0xb5, 0x37, 0x2f),
96            },
97            Self::Dracula => DiffPalette {
98                background: (0x28, 0x2a, 0x36),
99                header: (0x8b, 0x96, 0xc9),
100                addition: (0x50, 0xfa, 0x7b),
101                deletion: (0xff, 0x55, 0x55),
102            },
103        }
104    }
105}
106
107/// Foreground accents and background for one diff theme.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct DiffPalette {
110    /// Terminal background the accents are contrast-checked against.
111    pub background: (u8, u8, u8),
112    /// Hunk header and file label color.
113    pub header: (u8, u8, u8),
114    /// Added-line color.
115    pub addition: (u8, u8, u8),
116    /// Deleted-line color.
117    pub deletion: (u8, u8, u8),
118}
119
120impl DiffPalette {
121    fn header_style(self) -> Style {
122        rgb_style(self.header)
123    }
124
125    fn addition_style(self) -> Style {
126        rgb_style(self.addition)
127    }
128
129    fn deletion_style(self) -> Style {
130        rgb_style(self.deletion)
131    }
132}
133
134fn rgb_style((red, green, blue): (u8, u8, u8)) -> Style {
135    Style::new().fg_color(Some(Color::Rgb(RgbColor(red, green, blue))))
136}
137
138/// Format a unified diff without ANSI color codes.
139pub fn format_unified_diff(old: &str, new: &str, options: DiffOptions<'_>) -> String {
140    let mut options = shared_options(&options);
141    options.missing_newline_hint = false;
142    let document = DiffDocument::between(old, new, options.clone());
143    format_unified_hunks(&document.hunks, &options)
144}
145
146/// Compute a structured diff bundle using the default theme-aware formatter.
147pub fn compute_diff_with_theme(old: &str, new: &str, options: DiffOptions<'_>) -> DiffBundle {
148    let shared = shared_options(&options);
149    let document = DiffDocument::between(old, new, shared);
150    let formatted = format_colored_diff(&document.hunks, &options);
151    DiffBundle {
152        hunks: document.hunks,
153        formatted,
154        is_empty: old == new,
155    }
156}
157
158fn shared_options<'a>(options: &DiffOptions<'a>) -> vtcode_diff::DiffOptions<'a> {
159    vtcode_diff::DiffOptions {
160        context_lines: options.context_lines,
161        old_label: options.old_label,
162        new_label: options.new_label,
163        missing_newline_hint: options.missing_newline_hint,
164        ..vtcode_diff::DiffOptions::default()
165    }
166}
167
168/// Format diff hunks with standard ANSI colors for terminal display.
169///
170/// This is the single canonical implementation. Both `vtcode-core` and
171/// `vtcode-ui` delegate to this function.
172pub fn format_colored_diff(hunks: &[DiffHunk], options: &DiffOptions<'_>) -> String {
173    if hunks.is_empty() {
174        return String::new();
175    }
176
177    // Keep the canonical portable ANSI-16 colors for the legacy entry point;
178    // RGB palettes are opt-in through `format_colored_diff_with_theme`.
179    let cyan_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
180    let addition_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
181    let deletion_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
182    let context_style = Style::new();
183    format_colored_diff_impl(hunks, options, cyan_style, addition_style, deletion_style, context_style)
184}
185
186/// Format diff hunks with the colors of a selected diff theme.
187///
188/// Keeps the canonical `format_colored_diff` behavior for the default theme
189/// while allowing selectable themes (github, ayu, gruvbox, nord, solarized,
190/// dracula) for diff previews.
191pub fn format_colored_diff_with_theme(hunks: &[DiffHunk], options: &DiffOptions<'_>, theme: DiffTheme) -> String {
192    if hunks.is_empty() {
193        return String::new();
194    }
195
196    let palette = theme.palette();
197    format_colored_diff_impl(
198        hunks,
199        options,
200        palette.header_style(),
201        palette.addition_style(),
202        palette.deletion_style(),
203        Style::new(),
204    )
205}
206
207fn format_colored_diff_impl(
208    hunks: &[DiffHunk],
209    options: &DiffOptions<'_>,
210    header_style: Style,
211    addition_style: Style,
212    deletion_style: Style,
213    context_style: Style,
214) -> String {
215    let mut output = String::new();
216
217    if let (Some(old_label), Some(new_label)) = (options.old_label, options.new_label) {
218        let _ = write!(output, "{}--- {old_label}\n{}", header_style.render(), Reset.render());
219
220        let _ = write!(output, "{}+++ {new_label}\n{}", header_style.render(), Reset.render());
221    }
222
223    for hunk in hunks {
224        let _ = write!(
225            output,
226            "{}@@ -{},{} +{},{} @@\n{}",
227            header_style.render(),
228            hunk.old_start,
229            hunk.old_lines,
230            hunk.new_start,
231            hunk.new_lines,
232            Reset.render()
233        );
234
235        for line in &hunk.lines {
236            let (style, prefix) = match line.kind {
237                DiffLineKind::Addition => (&addition_style, '+'),
238                DiffLineKind::Deletion => (&deletion_style, '-'),
239                DiffLineKind::Context => (&context_style, ' '),
240            };
241
242            let mut display = String::with_capacity(line.text.len() + 2);
243            display.push(prefix);
244            display.push_str(&line.text);
245
246            // Apply Reset before the line terminator to prevent color
247            // bleeding, and normalize CR/CRLF for terminal output.
248            let display_content = display
249                .strip_suffix("\r\n")
250                .or_else(|| display.strip_suffix('\n'))
251                .or_else(|| display.strip_suffix('\r'))
252                .unwrap_or(&display);
253
254            let _ = write!(output, "{}{} {}", style.render(), display_content, Reset.render());
255            output.push('\n');
256
257            let has_line_terminator = line.text.ends_with('\n') || line.text.ends_with('\r');
258            if options.missing_newline_hint && !has_line_terminator {
259                let eof_hint = r"\ No newline at end of file";
260                let _ = write!(output, "{}{} {}", context_style.render(), eof_hint, Reset.render());
261                output.push('\n');
262            }
263        }
264    }
265
266    output
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn computes_structured_diff() {
275        let before = "a\nb\nc\n";
276        let after = "a\nc\nd\n";
277        let bundle = compute_diff(
278            before,
279            after,
280            DiffOptions {
281                context_lines: 2,
282                old_label: Some("old"),
283                new_label: Some("new"),
284                ..Default::default()
285            },
286            format_colored_diff,
287        );
288
289        assert!(!bundle.is_empty);
290        assert_eq!(bundle.hunks.len(), 1);
291        let hunk = &bundle.hunks[0];
292        assert_eq!(hunk.old_start, 1);
293        assert_eq!(hunk.new_start, 1);
294        assert!(bundle.formatted.contains("@@"));
295        assert!(hunk.lines.iter().any(|line| matches!(line.kind, DiffLineKind::Deletion)));
296        assert!(hunk.lines.iter().any(|line| matches!(line.kind, DiffLineKind::Addition)));
297    }
298
299    #[test]
300    fn empty_hunks_returns_empty_string() {
301        let result = format_colored_diff(&[], &DiffOptions::default());
302        assert!(result.is_empty());
303    }
304
305    #[test]
306    fn format_unified_diff_has_no_ansi() {
307        let before = "hello\n";
308        let after = "world\n";
309        let result = format_unified_diff(before, after, DiffOptions::default());
310        // The result should not contain ANSI escape sequences
311        assert!(!result.contains('\x1b'));
312        // But should contain the diff content
313        assert!(result.contains('-') || result.contains('+'));
314    }
315
316    #[test]
317    fn format_colored_diff_normalizes_crlf_and_cr_terminators() {
318        let bundle = compute_diff_with_theme("old\r\n", "new\r\n", DiffOptions::default());
319        assert!(!bundle.formatted.contains("\r"));
320
321        let bundle = compute_diff_with_theme("old\r", "new\r", DiffOptions::default());
322        assert!(!bundle.formatted.contains("\r"));
323        assert!(!bundle.formatted.contains("No newline at end"));
324    }
325
326    #[test]
327    fn theme_names_round_trip() {
328        for theme in DiffTheme::ALL {
329            assert_eq!(DiffTheme::from_name(theme.name()), Some(theme));
330        }
331        assert_eq!(DiffTheme::from_name("unknown"), None);
332    }
333
334    #[test]
335    fn every_theme_meets_wcag_aa_contrast() {
336        fn relative_luminance((red, green, blue): (u8, u8, u8)) -> f64 {
337            fn channel(value: u8) -> f64 {
338                let scaled = f64::from(value) / 255.0;
339                if scaled <= 0.039_28 {
340                    scaled / 12.92
341                } else {
342                    ((scaled + 0.055) / 1.055).powf(2.4)
343                }
344            }
345            0.212_6 * channel(red) + 0.715_2 * channel(green) + 0.072_2 * channel(blue)
346        }
347
348        fn contrast_ratio(foreground: (u8, u8, u8), background: (u8, u8, u8)) -> f64 {
349            let foreground_luma = relative_luminance(foreground);
350            let background_luma = relative_luminance(background);
351            let (lighter, darker) = if foreground_luma >= background_luma {
352                (foreground_luma, background_luma)
353            } else {
354                (background_luma, foreground_luma)
355            };
356            (lighter + 0.05) / (darker + 0.05)
357        }
358
359        for theme in DiffTheme::ALL {
360            let palette = theme.palette();
361            for (label, accent) in [
362                ("header", palette.header),
363                ("addition", palette.addition),
364                ("deletion", palette.deletion),
365            ] {
366                let ratio = contrast_ratio(accent, palette.background);
367                assert!(ratio >= 4.5, "{:?} {label} contrast {ratio:.2} is below WCAG AA 4.5:1", theme.name());
368            }
369        }
370    }
371
372    #[test]
373    fn themed_rendering_uses_theme_colors() {
374        let options = DiffOptions::default();
375        let shared = shared_options(&options);
376        let document = DiffDocument::between("old\n", "new\n", shared);
377        let rendered = format_colored_diff_with_theme(&document.hunks, &options, DiffTheme::Dracula);
378        // Dracula deletion accent (0xff, 0x55, 0x55) must appear in the output.
379        assert!(rendered.contains("\x1b[38;2;255;85;85m"));
380        // The default theme keeps the canonical portable ANSI-16 colors.
381        let default_rendered = format_colored_diff(&document.hunks, &options);
382        assert!(default_rendered.contains("\x1b[38;5;10m") || default_rendered.contains("\x1b[32m"));
383    }
384}