1use core::{
4 cmp::{self, Ordering},
5 fmt,
6 iter::{self, Peekable},
7 mem,
8 num::NonZeroUsize,
9};
10
11use anstyle::{AnsiColor, Color, Effects, Style};
12use unicode_width::UnicodeWidthStr;
13
14use crate::{
15 StyledStr,
16 alloc::{String, Vec, format},
17 rich_parser::RichStyle,
18 types::StyledSpan,
19};
20
21#[cfg(test)]
22mod tests;
23
24impl StyledSpan {
25 fn write_line<I>(
27 formatter: &mut fmt::Formatter<'_>,
28 spans_iter: &mut Peekable<I>,
29 line_start: usize,
30 line: &str,
31 ) -> fmt::Result
32 where
33 I: Iterator<Item = Self>,
34 {
35 let mut pos = 0;
36 while pos < line.len() {
37 let span = spans_iter.peek().expect("spans ended before lines");
38 let span_len = span.len.get() - line_start.saturating_sub(span.start);
39
40 let span_end = cmp::min(pos + span_len, line.len());
41 write!(
42 formatter,
43 "{style}{}{style:#}",
44 &line[pos..span_end],
45 style = span.style
46 )?;
47 if span_end == pos + span_len {
48 spans_iter.next();
50 }
51 pos += span_len;
52 }
53 writeln!(formatter)
54 }
55}
56
57#[derive(Debug)]
58struct DiffStyleSpan {
59 start: usize,
60 len: NonZeroUsize,
61 lhs_style: Style,
62 rhs_style: Style,
63}
64
65impl DiffStyleSpan {
66 fn affects_whitespace(style: &Style) -> bool {
68 let effects = style.get_effects();
69 if effects.contains(Effects::UNDERLINE)
70 || effects.contains(Effects::STRIKETHROUGH)
71 || effects.contains(Effects::INVERT)
72 {
73 return true;
74 }
75 style.get_bg_color().is_some()
77 }
78
79 fn new(diff_text: &str, start: usize, lhs_style: Style, rhs_style: Style) -> Option<Self> {
81 debug_assert!(!diff_text.is_empty());
82
83 let affects_whitespace =
84 Self::affects_whitespace(&lhs_style) || Self::affects_whitespace(&rhs_style);
85 let can_trim = |ch: char| {
86 if affects_whitespace {
87 ch == '\n' || ch == '\r'
89 } else {
90 ch.is_whitespace()
91 }
92 };
93
94 let first_pos = diff_text
95 .char_indices()
96 .find_map(|(i, ch)| (!can_trim(ch)).then_some(i))?;
97 let last_pos = diff_text
98 .char_indices()
99 .rev()
100 .find_map(|(i, ch)| (!can_trim(ch)).then_some(i + ch.len_utf8()))?;
101 debug_assert!(last_pos > first_pos);
102
103 Some(Self {
104 start: start + first_pos,
105 len: NonZeroUsize::new(last_pos - first_pos)?,
106 lhs_style,
107 rhs_style,
108 })
109 }
110}
111
112const STYLE_WIDTH: usize = 25;
113
114#[derive(Debug)]
155pub struct StyleDiff<'a> {
156 lhs: StyledStr<'a>,
157 differing_spans: Vec<DiffStyleSpan>,
158}
159
160impl fmt::Display for StyleDiff<'_> {
161 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162 if formatter.alternate() {
163 self.write_as_table(formatter)
164 } else {
165 self.highlight_text(formatter)
166 }
167 }
168}
169
170impl<'a> StyleDiff<'a> {
171 pub fn new(lhs: StyledStr<'a>, rhs: StyledStr<'a>) -> Self {
177 assert_eq!(
178 lhs.text.len(),
179 rhs.text.len(),
180 "Compared strings must have same length"
181 );
182
183 let mut this = Self {
184 lhs,
185 differing_spans: Vec::new(),
186 };
187 let mut pos = 0;
188 let mut lhs_iter = lhs.spans.iter();
189 let Some(mut lhs_span) = lhs_iter.next() else {
190 return this;
191 };
192 let mut rhs_iter = rhs.spans.iter();
193 let Some(mut rhs_span) = rhs_iter.next() else {
194 return this;
195 };
196
197 loop {
198 let common_len = cmp::min(lhs_span.len, rhs_span.len);
199
200 if lhs_span.style != rhs_span.style {
202 let diff_text = &this.lhs.text[pos..pos + common_len.get()];
203 this.differing_spans.extend(DiffStyleSpan::new(
204 diff_text,
205 pos,
206 lhs_span.style,
207 rhs_span.style,
208 ));
209 }
210
211 pos += common_len.get();
212
213 match lhs_span.len.cmp(&rhs_span.len) {
214 Ordering::Less => {
215 rhs_span.shrink_len(lhs_span.len.get());
216 lhs_span = lhs_iter.next().unwrap();
217 }
220 Ordering::Greater => {
221 lhs_span.shrink_len(rhs_span.len.get());
222 rhs_span = rhs_iter.next().unwrap();
223 }
224 Ordering::Equal => {
225 lhs_span = match lhs_iter.next() {
226 Some(span) => span,
227 None => return this,
228 };
229 rhs_span = match rhs_iter.next() {
230 Some(span) => span,
231 None => return this,
232 };
233 }
234 }
235 }
236 }
237
238 pub fn is_empty(&self) -> bool {
240 self.differing_spans.is_empty()
241 }
242
243 fn highlight_text(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245 const SIDELINE_HL: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
246
247 let highlights = HighlightedSpan::new(&self.differing_spans);
248 let mut highlights = highlights.iter().copied().peekable();
249 let mut line_start = 0;
250
251 let mut color_spans = self.lhs.spans.iter().peekable();
252
253 for line in self.lhs.text.split('\n') {
254 let line_contains_spans = highlights
255 .peek()
256 .is_some_and(|span| span.start <= line_start + line.len());
257
258 if line_contains_spans {
259 write!(formatter, "{SIDELINE_HL}> {SIDELINE_HL:#}")?;
260 StyledSpan::write_line(formatter, &mut color_spans, line_start, line)?;
261 write!(formatter, "{SIDELINE_HL}> {SIDELINE_HL:#}")?;
262 Self::highlight_line(formatter, &mut highlights, line_start, line)?;
263 } else {
264 write!(formatter, "= ")?;
265 StyledSpan::write_line(formatter, &mut color_spans, line_start, line)?;
266 }
267 line_start += line.len() + 1;
268 }
269 Ok(())
270 }
271
272 fn highlight_line<I>(
273 out: &mut fmt::Formatter<'_>,
274 spans_iter: &mut Peekable<I>,
275 line_offset: usize,
276 line: &str,
277 ) -> fmt::Result
278 where
279 I: Iterator<Item = HighlightedSpan>,
280 {
281 let line_len = line.len();
282 let mut line_pos = 0;
283
284 while line_pos < line_len {
285 let Some(span) = spans_iter.peek() else {
286 break;
287 };
288 let span_start = span.start.saturating_sub(line_offset);
289 if span_start >= line_len {
290 break;
291 }
292 let span_end = cmp::min(span.start + span.len.get() - line_offset, line_len);
293
294 if span_start > line_pos {
295 let spaces = " ".repeat(line[line_pos..span_start].width());
296 write!(out, "{spaces}")?;
297 }
298
299 let ch = span.kind.underline_char();
300 let underline: String =
301 iter::repeat_n(ch, line[span_start..span_end].width()).collect();
302 let hl = span.kind.highlight_style();
303 write!(out, "{hl}{underline}{hl:#}")?;
304
305 line_pos = span_end;
306 if span.start + span.len.get() <= line_offset + line_len {
307 spans_iter.next();
309 }
310 }
311 writeln!(out)
312 }
313
314 pub(crate) fn write_as_table(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
315 const TABLE_HEAD: Style = Style::new().bold();
316 const POS_WIDTH: usize = 10;
317
318 writeln!(
320 formatter,
321 "{TABLE_HEAD}{pos:^POS_WIDTH$} {lhs:^STYLE_WIDTH$} {rhs:^STYLE_WIDTH$}",
322 pos = "Positions",
323 lhs = "Left style",
324 rhs = "Right style"
325 )?;
326 writeln!(
327 formatter,
328 "{pos:=>POS_WIDTH$} {lhs:=>STYLE_WIDTH$} {rhs:=>STYLE_WIDTH$}{TABLE_HEAD:#}",
329 pos = "",
330 lhs = "",
331 rhs = ""
332 )?;
333
334 for differing_span in &self.differing_spans {
336 let lhs_style = &differing_span.lhs_style;
337 let mut lhs_lines = Self::write_style(lhs_style);
338 let rhs_style = &differing_span.rhs_style;
339 let mut rhs_lines = Self::write_style(rhs_style);
340 if lhs_lines.len() < rhs_lines.len() {
341 lhs_lines.resize_with(rhs_lines.len(), String::new);
342 } else {
343 rhs_lines.resize_with(lhs_lines.len(), String::new);
344 }
345
346 for (i, (lhs_line, rhs_line)) in lhs_lines.into_iter().zip(rhs_lines).enumerate() {
347 if i == 0 {
348 let start = differing_span.start;
349 let end = start + differing_span.len.get();
350 let pos = format!("{start}..{end}");
351 write!(formatter, "{pos:>POS_WIDTH$} ")?;
352 } else {
353 write!(formatter, "{:>POS_WIDTH$} ", "")?;
354 }
355 writeln!(
356 formatter,
357 "{lhs_style}{lhs_line:^STYLE_WIDTH$}{lhs_style:#} \
358 {rhs_style}{rhs_line:^STYLE_WIDTH$}{rhs_style:#}"
359 )?;
360 }
361 }
362
363 Ok(())
364 }
365
366 fn write_style(style: &Style) -> Vec<String> {
368 let mut tokens = RichStyle(style).tokens();
369 if tokens.is_empty() {
370 tokens.push("(none)".into());
371 }
372
373 let mut lines = Vec::new();
374 let mut current_line = String::new();
375 for token in tokens {
376 if !current_line.is_empty() && current_line.len() + 1 + token.len() > STYLE_WIDTH {
378 lines.push(mem::take(&mut current_line));
379 }
380 if !current_line.is_empty() {
381 current_line.push(' ');
382 }
383 current_line.push_str(&token);
384 }
385
386 if !current_line.is_empty() {
387 lines.push(current_line);
388 }
389 lines
390 }
391}
392
393#[derive(Debug, Clone, Copy)]
394enum SpanHighlightKind {
395 Main,
396 Aux,
397}
398
399impl SpanHighlightKind {
400 fn underline_char(self) -> char {
401 match self {
402 Self::Main => '^',
403 Self::Aux => '!',
404 }
405 }
406
407 fn highlight_style(self) -> Style {
408 let mut style = Style::new();
409 match self {
410 Self::Main => {
411 style = style
412 .fg_color(Some(AnsiColor::White.into()))
413 .bg_color(Some(AnsiColor::Red.into()));
414 }
415 Self::Aux => {
416 style = style
417 .fg_color(Some(AnsiColor::Black.into()))
418 .bg_color(Some(AnsiColor::Yellow.into()));
419 }
420 }
421 style
422 }
423}
424
425#[derive(Debug, Clone, Copy)]
426struct HighlightedSpan {
427 start: usize,
428 len: NonZeroUsize,
429 kind: SpanHighlightKind,
430}
431
432impl HighlightedSpan {
433 fn new(differing_spans: &[DiffStyleSpan]) -> Vec<Self> {
434 let mut sequential_span_count = 1;
435 let span_highlights = differing_spans.windows(2).map(|window| match window {
436 [prev, next] => {
437 if prev.start + prev.len.get() == next.start {
438 sequential_span_count += 1;
439 } else {
440 sequential_span_count = 1;
441 }
442 if sequential_span_count % 2 == 0 {
443 SpanHighlightKind::Aux
444 } else {
445 SpanHighlightKind::Main
446 }
447 }
448 _ => unreachable!(),
449 });
450
451 iter::once(SpanHighlightKind::Main)
452 .chain(span_highlights)
453 .zip(differing_spans)
454 .map(|(kind, span)| Self {
455 start: span.start,
456 len: span.len,
457 kind,
458 })
459 .collect()
460 }
461}