1use similar::TextDiff;
20
21use crate::ansi::strip_ansi;
22use crate::theme::theme;
23use crate::utils::{truncate_to_width, visible_width};
24
25pub fn render_diff(diff_text: &str, width: usize) -> Vec<String> {
36 let colors = &theme().colors;
37 let removed_color = colors.tool_diff_removed; let added_color = colors.tool_diff_added; let context_color = colors.tool_diff_context; let gutter_color = colors.dim;
41
42 let lines: Vec<&str> = diff_text.split('\n').collect();
43 let num_width = lines
46 .iter()
47 .filter_map(|l| parse_diff_line(l))
48 .map(|p| p.line_num.len())
49 .max()
50 .unwrap_or(1);
51 let mut out: Vec<String> = Vec::with_capacity(lines.len());
52
53 let mut i = 0;
54 while i < lines.len() {
55 let line = lines[i];
56 let Some(parsed) = parse_diff_line(line) else {
57 out.push(context_color.fg(&clip(line, width)));
59 i += 1;
60 continue;
61 };
62
63 if parsed.prefix == '-' {
64 let mut removed: Vec<ParsedLine> = Vec::new();
66 while i < lines.len() {
67 let Some(p) = parse_diff_line(lines[i]) else {
68 break;
69 };
70 if p.prefix != '-' {
71 break;
72 }
73 removed.push(p);
74 i += 1;
75 }
76 let mut added: Vec<ParsedLine> = Vec::new();
78 while i < lines.len() {
79 let Some(p) = parse_diff_line(lines[i]) else {
80 break;
81 };
82 if p.prefix != '+' {
83 break;
84 }
85 added.push(p);
86 i += 1;
87 }
88
89 if removed.len() == 1 && added.len() == 1 {
90 let (rem_body, add_body) = render_intra_line_diff(
92 &replace_tabs(&removed[0].content),
93 &replace_tabs(&added[0].content),
94 );
95 out.push(render_diff_row(
96 '-',
97 &removed[0].line_num,
98 &rem_body,
99 removed_color,
100 gutter_color,
101 num_width,
102 width,
103 ));
104 out.push(render_diff_row(
105 '+',
106 &added[0].line_num,
107 &add_body,
108 added_color,
109 gutter_color,
110 num_width,
111 width,
112 ));
113 } else {
114 for r in &removed {
115 out.push(render_diff_row(
116 '-',
117 &r.line_num,
118 &replace_tabs(&r.content),
119 removed_color,
120 gutter_color,
121 num_width,
122 width,
123 ));
124 }
125 for a in &added {
126 out.push(render_diff_row(
127 '+',
128 &a.line_num,
129 &replace_tabs(&a.content),
130 added_color,
131 gutter_color,
132 num_width,
133 width,
134 ));
135 }
136 }
137 } else if parsed.prefix == '+' {
138 out.push(render_diff_row(
139 '+',
140 &parsed.line_num,
141 &replace_tabs(&parsed.content),
142 added_color,
143 gutter_color,
144 num_width,
145 width,
146 ));
147 i += 1;
148 } else {
149 out.push(render_diff_row(
151 ' ',
152 &parsed.line_num,
153 &replace_tabs(&parsed.content),
154 context_color,
155 gutter_color,
156 num_width,
157 width,
158 ));
159 i += 1;
160 }
161 }
162
163 out
164}
165
166fn render_diff_row(
171 sign: char,
172 line_num: &str,
173 body: &str,
174 body_color: crate::theme::Color,
175 gutter_color: crate::theme::Color,
176 num_width: usize,
177 width: usize,
178) -> String {
179 let num = format!("{num:>num_width$}", num = line_num, num_width = num_width);
180 let gutter = format!(
181 "{} {}{}",
182 gutter_color.fg(&sign.to_string()),
183 gutter_color.fg(&num),
184 gutter_color.fg("│")
185 );
186 let body = format!(" {}", body_color.fg(body));
187 clip(&format!("{}{}", gutter, body), width)
188}
189
190#[derive(Debug)]
191struct ParsedLine {
192 prefix: char,
193 line_num: String,
194 content: String,
195}
196
197fn parse_diff_line(line: &str) -> Option<ParsedLine> {
201 let mut chars = line.chars();
202 let prefix_char = chars.next()?;
203 if prefix_char != '+' && prefix_char != '-' && prefix_char != ' ' {
204 return None;
205 }
206 let after_prefix = &line[prefix_char.len_utf8()..];
209 let mut rest = after_prefix;
210 while rest.starts_with(' ') {
211 rest = &rest[1..];
212 }
213 let mut num = String::new();
214 while let Some(c) = rest.chars().next() {
215 if c.is_ascii_digit() {
216 num.push(c);
217 rest = &rest[c.len_utf8()..];
218 } else {
219 break;
220 }
221 }
222 if let Some(stripped) = rest.strip_prefix(' ') {
223 rest = stripped;
224 }
225 Some(ParsedLine {
226 prefix: prefix_char,
227 line_num: num,
228 content: rest.to_string(),
229 })
230}
231
232fn replace_tabs(text: &str) -> String {
234 text.replace('\t', " ")
235}
236
237fn clip(line: &str, width: usize) -> String {
239 if width == 0 {
240 return line.to_string();
241 }
242 let vw = visible_width(line);
243 if vw <= width {
244 line.to_string()
245 } else {
246 truncate_to_width(line, width, "…")
247 }
248}
249
250fn render_intra_line_diff(old_content: &str, new_content: &str) -> (String, String) {
256 let diff = TextDiff::from_unicode_words(old_content, new_content);
257
258 let mut removed_line = String::new();
259 let mut added_line = String::new();
260 let mut first_removed = true;
261 let mut first_added = true;
262
263 for change in diff.iter_all_changes() {
264 let value = change.value();
265 match change.tag() {
266 similar::ChangeTag::Delete => {
267 let (leading, rest) = split_leading_ws(value);
268 if first_removed {
269 removed_line.push_str(leading);
270 first_removed = false;
271 }
272 if !rest.is_empty() {
273 removed_line.push_str(&inverse(rest));
274 }
275 }
276 similar::ChangeTag::Insert => {
277 let (leading, rest) = split_leading_ws(value);
278 if first_added {
279 added_line.push_str(leading);
280 first_added = false;
281 }
282 if !rest.is_empty() {
283 added_line.push_str(&inverse(rest));
284 }
285 }
286 similar::ChangeTag::Equal => {
287 removed_line.push_str(value);
288 added_line.push_str(value);
289 }
290 }
291 }
292
293 (removed_line, added_line)
294}
295
296fn split_leading_ws(s: &str) -> (&str, &str) {
298 let idx = s.find(|c: char| !c.is_whitespace()).unwrap_or(s.len());
299 (&s[..idx], &s[idx..])
300}
301
302fn inverse(text: &str) -> String {
305 let clean = strip_ansi(text);
306 format!("\x1b[7m{clean}\x1b[27m")
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_render_diff_colors_removed_and_added() {
315 let diff = "-1 old\n+1 new";
316 let lines = render_diff(diff, 80);
317 assert_eq!(lines.len(), 2);
318 assert!(lines[0].contains("old"));
320 assert!(lines[1].contains("new"));
322 }
323
324 #[test]
325 fn test_render_diff_intra_line_word_highlight() {
326 let diff = "-1 hello world\n+1 hello earth";
327 let lines = render_diff(diff, 80);
328 assert!(
330 lines[0].contains("\x1b[7m"),
331 "removed intra-line not inversed"
332 );
333 assert!(
334 lines[1].contains("\x1b[7m"),
335 "added intra-line not inversed"
336 );
337 assert!(lines[1].contains("earth"));
338 }
339
340 #[test]
341 fn test_render_diff_context_dim() {
342 let diff = " 5 context line";
343 let lines = render_diff(diff, 80);
344 assert_eq!(lines.len(), 1);
345 assert!(lines[0].contains("\x1b[38;5;244m"), "context not dimmed");
347 }
348
349 #[test]
350 fn test_render_diff_clips_long_lines() {
351 let long = format!("+1 {}", "x".repeat(200));
352 let lines = render_diff(&long, 40);
353 assert_eq!(lines.len(), 1);
354 assert!(lines[0].contains('…'));
355 assert!(visible_width(&lines[0]) <= 40);
356 }
357}