facet_diff_core/layout/
backend.rs

1//! Color backends for diff rendering.
2//!
3//! This module provides an abstraction for how semantic colors are rendered.
4//! The render code only knows about semantic meanings (deleted, inserted, etc.),
5//! and the backend decides how to actually style the text.
6
7use std::fmt::Write;
8
9use owo_colors::OwoColorize;
10
11use crate::DiffTheme;
12
13/// Semantic color meaning for diff elements.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SemanticColor {
16    // === Accent colors (full brightness) ===
17    /// Deleted content on line background
18    Deleted,
19    /// Deleted content on highlight background (the actual changed value)
20    DeletedHighlight,
21    /// Inserted content on line background
22    Inserted,
23    /// Inserted content on highlight background (the actual changed value)
24    InsertedHighlight,
25    /// Moved content on line background
26    Moved,
27    /// Moved content on highlight background
28    MovedHighlight,
29
30    // === Syntax colors (context-aware) ===
31    /// Key/field name in deleted context (blended toward orange)
32    DeletedKey,
33    /// Key/field name in inserted context (blended toward blue)
34    InsertedKey,
35    /// Key/field name in unchanged context
36    Key,
37
38    /// Structural element in deleted context
39    DeletedStructure,
40    /// Structural element in inserted context
41    InsertedStructure,
42    /// Structural element in unchanged context
43    Structure,
44
45    /// Comment/type hint in deleted context
46    DeletedComment,
47    /// Comment/type hint in inserted context
48    InsertedComment,
49    /// Comment in unchanged context
50    Comment,
51
52    // === Value type colors (context-aware) ===
53    /// String value in deleted context (blended)
54    DeletedString,
55    /// String value in inserted context (blended)
56    InsertedString,
57    /// String value in unchanged context
58    String,
59
60    /// Number value in deleted context (blended)
61    DeletedNumber,
62    /// Number value in inserted context (blended)
63    InsertedNumber,
64    /// Number value in unchanged context
65    Number,
66
67    /// Boolean value in deleted context (blended)
68    DeletedBoolean,
69    /// Boolean value in inserted context (blended)
70    InsertedBoolean,
71    /// Boolean value in unchanged context
72    Boolean,
73
74    /// Null/None value in deleted context (blended)
75    DeletedNull,
76    /// Null/None value in inserted context (blended)
77    InsertedNull,
78    /// Null/None value in unchanged context
79    Null,
80
81    // === Whitespace and separators ===
82    /// Whitespace, commas, and other separator characters (no highlight background)
83    Whitespace,
84
85    // === Other ===
86    /// Unchanged content (neutral)
87    Unchanged,
88}
89
90/// A backend that decides how to render semantic colors.
91pub trait ColorBackend {
92    /// Write styled text to the output.
93    fn write_styled<W: Write>(
94        &self,
95        w: &mut W,
96        text: &str,
97        color: SemanticColor,
98    ) -> std::fmt::Result;
99
100    /// Write a diff prefix (-/+/←/→) with appropriate styling.
101    fn write_prefix<W: Write>(
102        &self,
103        w: &mut W,
104        prefix: char,
105        color: SemanticColor,
106    ) -> std::fmt::Result {
107        self.write_styled(w, &prefix.to_string(), color)
108    }
109}
110
111/// Plain backend - no styling, just plain text.
112///
113/// Use this for tests and non-terminal output.
114#[derive(Debug, Clone, Copy, Default)]
115pub struct PlainBackend;
116
117impl ColorBackend for PlainBackend {
118    fn write_styled<W: Write>(
119        &self,
120        w: &mut W,
121        text: &str,
122        _color: SemanticColor,
123    ) -> std::fmt::Result {
124        write!(w, "{}", text)
125    }
126}
127
128/// ANSI backend - emits ANSI escape codes for terminal colors.
129///
130/// Use this for terminal output with a color theme.
131#[derive(Debug, Clone)]
132pub struct AnsiBackend {
133    theme: DiffTheme,
134}
135
136impl AnsiBackend {
137    /// Create a new ANSI backend with the given theme.
138    pub fn new(theme: DiffTheme) -> Self {
139        Self { theme }
140    }
141
142    /// Create a new ANSI backend with the default (One Dark Pro) theme.
143    pub fn with_default_theme() -> Self {
144        Self::new(DiffTheme::default())
145    }
146}
147
148impl Default for AnsiBackend {
149    fn default() -> Self {
150        Self::with_default_theme()
151    }
152}
153
154impl ColorBackend for AnsiBackend {
155    fn write_styled<W: Write>(
156        &self,
157        w: &mut W,
158        text: &str,
159        color: SemanticColor,
160    ) -> std::fmt::Result {
161        let (fg, bg) = match color {
162            // Accent colors
163            SemanticColor::Deleted => {
164                (self.theme.deleted, self.theme.desaturated_deleted_line_bg())
165            }
166            SemanticColor::DeletedHighlight => (
167                self.theme.deleted,
168                self.theme.desaturated_deleted_highlight_bg(),
169            ),
170            SemanticColor::Inserted => (
171                self.theme.inserted,
172                self.theme.desaturated_inserted_line_bg(),
173            ),
174            SemanticColor::InsertedHighlight => (
175                self.theme.inserted,
176                self.theme.desaturated_inserted_highlight_bg(),
177            ),
178            SemanticColor::Moved => (self.theme.moved, self.theme.desaturated_moved_line_bg()),
179            SemanticColor::MovedHighlight => (
180                self.theme.moved,
181                self.theme.desaturated_moved_highlight_bg(),
182            ),
183
184            // Context-aware syntax colors
185            SemanticColor::DeletedKey => (
186                self.theme.deleted_highlight_key(),
187                self.theme.desaturated_deleted_highlight_bg(),
188            ),
189            SemanticColor::InsertedKey => (
190                self.theme.inserted_highlight_key(),
191                self.theme.desaturated_inserted_highlight_bg(),
192            ),
193            SemanticColor::Key => (self.theme.key, None),
194
195            SemanticColor::DeletedStructure => (
196                self.theme.deleted_structure(),
197                self.theme.desaturated_deleted_line_bg(),
198            ),
199            SemanticColor::InsertedStructure => (
200                self.theme.inserted_structure(),
201                self.theme.desaturated_inserted_line_bg(),
202            ),
203            SemanticColor::Structure => (self.theme.structure, None),
204
205            SemanticColor::DeletedComment => (
206                self.theme.deleted_highlight_comment(),
207                self.theme.desaturated_deleted_highlight_bg(),
208            ),
209            SemanticColor::InsertedComment => (
210                self.theme.inserted_highlight_comment(),
211                self.theme.desaturated_inserted_highlight_bg(),
212            ),
213            SemanticColor::Comment => (self.theme.comment, None),
214
215            // Context-aware value type colors
216            SemanticColor::DeletedString => (
217                self.theme.deleted_highlight_string(),
218                self.theme.desaturated_deleted_highlight_bg(),
219            ),
220            SemanticColor::InsertedString => (
221                self.theme.inserted_highlight_string(),
222                self.theme.desaturated_inserted_highlight_bg(),
223            ),
224            SemanticColor::String => (self.theme.string, None),
225
226            SemanticColor::DeletedNumber => (
227                self.theme.deleted_highlight_number(),
228                self.theme.desaturated_deleted_highlight_bg(),
229            ),
230            SemanticColor::InsertedNumber => (
231                self.theme.inserted_highlight_number(),
232                self.theme.desaturated_inserted_highlight_bg(),
233            ),
234            SemanticColor::Number => (self.theme.number, None),
235
236            SemanticColor::DeletedBoolean => (
237                self.theme.deleted_highlight_boolean(),
238                self.theme.desaturated_deleted_highlight_bg(),
239            ),
240            SemanticColor::InsertedBoolean => (
241                self.theme.inserted_highlight_boolean(),
242                self.theme.desaturated_inserted_highlight_bg(),
243            ),
244            SemanticColor::Boolean => (self.theme.boolean, None),
245
246            SemanticColor::DeletedNull => (
247                self.theme.deleted_highlight_null(),
248                self.theme.desaturated_deleted_highlight_bg(),
249            ),
250            SemanticColor::InsertedNull => (
251                self.theme.inserted_highlight_null(),
252                self.theme.desaturated_inserted_highlight_bg(),
253            ),
254            SemanticColor::Null => (self.theme.null, None),
255
256            // Whitespace and separators (no background, use comment color which is muted)
257            SemanticColor::Whitespace => (self.theme.comment, None),
258
259            // Neutral
260            SemanticColor::Unchanged => (self.theme.unchanged, None),
261        };
262        if let Some(bg) = bg {
263            write!(w, "{}", text.color(fg).on_color(bg))
264        } else {
265            write!(w, "{}", text.color(fg))
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_plain_backend() {
276        let backend = PlainBackend;
277        let mut out = String::new();
278
279        backend
280            .write_styled(&mut out, "hello", SemanticColor::Deleted)
281            .unwrap();
282        assert_eq!(out, "hello");
283
284        out.clear();
285        backend
286            .write_styled(&mut out, "world", SemanticColor::Inserted)
287            .unwrap();
288        assert_eq!(out, "world");
289    }
290
291    #[test]
292    fn test_ansi_backend() {
293        let backend = AnsiBackend::default();
294        let mut out = String::new();
295
296        backend
297            .write_styled(&mut out, "deleted", SemanticColor::Deleted)
298            .unwrap();
299        // Should contain ANSI escape codes
300        assert!(out.contains("\x1b["));
301        assert!(out.contains("deleted"));
302    }
303
304    #[test]
305    fn test_prefix() {
306        let backend = PlainBackend;
307        let mut out = String::new();
308
309        backend
310            .write_prefix(&mut out, '-', SemanticColor::Deleted)
311            .unwrap();
312        assert_eq!(out, "-");
313    }
314}