Skip to main content

styled_str/style_diff/
mod.rs

1//! Comparing `Styled` instances by styling.
2
3use 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    /// Writes a single plaintext `line` to `out` using styles from `spans_iter`.
26    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                // The span has ended, can proceed to the next one.
49                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    /// Would this style be visible on whitespace (e.g., ' ' or '\t')?
67    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        // We've handled the case with inverted colors above, so we check the background color specifically
76        style.get_bg_color().is_some()
77    }
78
79    /// Trims whitespace at the start and end of the string; we don't care about diffs there.
80    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                // Newline chars are not affected by any styles
88                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/// Difference in styles between two [styled strings](StyledStr) that can be output in detailed
115/// human-readable format via [`Display`](fmt::Display).
116///
117/// The `Display` implementation supports two output formats:
118///
119/// - With the default / non-alternate format (e.g., `{}`), the diff will be output as the LHS line-by-line,
120///   highlighting differences in styles on each differing line. That is, this format is similar
121///   to [`pretty_assertions::Comparison`].
122/// - With the alternate format (`{:#}`), the diff will be output as a table of all differing spans.
123///
124/// # Examples
125///
126/// ```
127/// use styled_str::{styled, StyleDiff, StyledString};
128///
129/// let lhs = styled!("[[red on white]]Hello,[[/]] [[bold green]]world!");
130/// let rhs = styled!("[[red on white!]]Hello,[[/]] [[bold green]]world[[/]]!");
131/// let diff = StyleDiff::new(lhs, rhs);
132/// assert!(!diff.is_empty());
133///
134/// let diff_str = StyledString::from_ansi(&format!("{diff}"))?;
135/// assert_eq!(
136///     diff_str.text(),
137///     "> Hello, world!\n\
138///      > ^^^^^^      ^\n"
139/// );
140///
141/// let diff_str = StyledString::from_ansi(&format!("{diff:#}"))?;
142/// let expected =
143///     "|Positions         Left style                Right style       |
144///      |========== ========================= =========================|
145///      |      0..6       red on white              red on white!      |
146///      |    12..13        bold green                  (none)          |";
147/// let expected: String = expected
148///     .lines()
149///     .flat_map(|line| [line.trim().trim_matches('|'), "\n"])
150///     .collect();
151/// assert_eq!(diff_str.text(), expected);
152/// # anyhow::Ok(())
153/// ```
154#[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    /// Computes style difference between two styled strings.
172    ///
173    /// # Panics
174    ///
175    /// Panics if `lhs` and `rhs` have differing lengths.
176    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            // Record a diff span if the color specs differ.
201            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                    // ^ `unwrap()` here and below are safe; we've checked that
218                    // `lhs` and `rhs` contain same total span coverage.
219                }
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    /// Checks whether this difference is empty.
239    pub fn is_empty(&self) -> bool {
240        self.differing_spans.is_empty()
241    }
242
243    /// Highlights this diff on the specified `text` which has styling set with `color_spans`.
244    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                // Span is finished on this line; can proceed to the next one.
308                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        // Write table header.
319        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        // Write table itself.
335        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    /// Writes `color_spec` in human-readable format.
367    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            // We can use `len()` because all text is ASCII.
377            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}