Skip to main content

rich_ext/diff/
view.rs

1//! [`DiffView`]: a renderable line diff.
2
3use std::ops::Range;
4use std::sync::Arc;
5
6use rich::measure::Measurement;
7use rich::{AnsiDecoder, Console, ConsoleOptions, Renderable, Segment, Style, Text, Theme};
8
9use super::engine::{
10    diff_lines, group_ranges, hunk_header, split_lines_inclusive, strip_eol, Op, TextDiff,
11};
12use super::render::{self, Block, Kind, Options, Row};
13use super::{style, Layout};
14
15/// Which side of a diff.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Side {
18    Old,
19    New,
20}
21
22/// Builds the URL a line number links to.
23pub(crate) type LineLinks = Arc<dyn Fn(Side, usize) -> Option<String> + Send + Sync>;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub(crate) enum Tag {
27    Equal,
28    Delete,
29    Insert,
30    /// Same text, different styling.
31    Style,
32}
33
34/// A line diff as a renderable: unified (default) or side by side, with line
35/// numbers, hunk headers and word-level emphasis on changed lines.
36///
37/// ```
38/// use rich::Console;
39/// use rich_ext::diff::DiffView;
40///
41/// let console = Console::builder().width(40).no_color(true).build();
42/// let out = console.render_to_string(&DiffView::new("a\nb\n", "a\nc\n").line_numbers(false));
43/// assert_eq!(out, "@@ -1,2 +1,2 @@\n  a\n- b\n+ c");
44/// ```
45#[derive(Clone)]
46pub struct DiffView {
47    old: Vec<Text>,
48    new: Vec<Text>,
49    chunks: Vec<(Tag, Range<usize>, Range<usize>)>,
50    old_no_newline: bool,
51    new_no_newline: bool,
52    layout: Layout,
53    line_numbers: bool,
54    wrap: bool,
55    context: usize,
56    titles: Option<(String, String)>,
57    emphasis: bool,
58    links: Option<LineLinks>,
59}
60
61impl std::fmt::Debug for DiffView {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("DiffView")
64            .field("old_lines", &self.old.len())
65            .field("new_lines", &self.new.len())
66            .field("layout", &self.layout)
67            .field("context", &self.context)
68            .finish_non_exhaustive()
69    }
70}
71
72/// Lines of `text` without terminators; a trailing newline adds no line.
73#[cfg_attr(not(feature = "syntax"), allow(dead_code))]
74pub(crate) fn text_lines(text: &Text) -> Vec<Text> {
75    let plain = text.plain();
76    let offsets: Vec<usize> = plain
77        .match_indices('\n')
78        .map(|(i, _)| i + 1)
79        .filter(|&i| i < plain.len())
80        .collect();
81    if plain.is_empty() {
82        return Vec::new();
83    }
84    text.divide(&offsets)
85        .into_iter()
86        .map(|mut line| {
87            if line.plain().ends_with('\n') {
88                line.right_crop(1);
89            }
90            if line.plain().ends_with('\r') {
91                line.right_crop(1);
92            }
93            line
94        })
95        .collect()
96}
97
98/// The visible styling of a line, independent of how its spans are split.
99fn signature(text: &Text) -> Vec<Segment> {
100    let segments = text.render(&Theme::default_theme(), &Style::new());
101    Segment::simplify(
102        &segments
103            .into_iter()
104            .filter(|s| !s.text.is_empty())
105            .collect::<Vec<_>>(),
106    )
107}
108
109impl DiffView {
110    /// Diff two plain texts.
111    pub fn new(old: &str, new: &str) -> Self {
112        let plain = |s: &str| {
113            split_lines_inclusive(s)
114                .into_iter()
115                .map(|l| Text::new(strip_eol(l)))
116                .collect()
117        };
118        Self::from_parts(
119            plain(old),
120            plain(new),
121            &split_lines_inclusive(old),
122            &split_lines_inclusive(new),
123            false,
124        )
125    }
126
127    /// View an existing [`TextDiff`], keeping its context setting.
128    pub fn from_diff(diff: &TextDiff) -> Self {
129        let lines = |ls: &[String]| ls.iter().map(|l| Text::new(strip_eol(l))).collect();
130        let old: Vec<&str> = diff.old_lines().iter().map(String::as_str).collect();
131        let new: Vec<&str> = diff.new_lines().iter().map(String::as_str).collect();
132        let mut view = Self::from_ops(
133            lines(diff.old_lines()),
134            lines(diff.new_lines()),
135            diff.ops(),
136            &old,
137            &new,
138        );
139        view.context = diff.context_lines();
140        view
141    }
142
143    /// Diff two ANSI-styled texts. Lines are compared by their visible text;
144    /// lines with the same text but different styling are reported as
145    /// style-only changes (`~`). Each side keeps its own styling.
146    pub fn ansi(old: &str, new: &str) -> Self {
147        let decode = |s: &str| {
148            let mut decoder = AnsiDecoder::new();
149            let lines = split_lines_inclusive(s);
150            let texts: Vec<Text> = lines
151                .iter()
152                .map(|l| decoder.decode_line(strip_eol(l)))
153                .collect();
154            // Compare visible text, keeping each line's terminator.
155            let keys: Vec<String> = lines
156                .iter()
157                .zip(&texts)
158                .map(|(l, t)| {
159                    let mut key = t.plain().to_string();
160                    if l.ends_with('\n') {
161                        key.push('\n');
162                    }
163                    key
164                })
165                .collect();
166            (texts, keys)
167        };
168        let (old_texts, old_keys) = decode(old);
169        let (new_texts, new_keys) = decode(new);
170        let ok: Vec<&str> = old_keys.iter().map(String::as_str).collect();
171        let nk: Vec<&str> = new_keys.iter().map(String::as_str).collect();
172        Self::from_parts(old_texts, new_texts, &ok, &nk, true)
173    }
174
175    /// Diff two render snapshots through their ANSI output, so styling
176    /// regressions show as `~` lines.
177    #[cfg(feature = "testing")]
178    pub fn snapshots(
179        old: &crate::testing::RenderSnapshot,
180        new: &crate::testing::RenderSnapshot,
181    ) -> Self {
182        Self::ansi(&old.ansi, &new.ansi)
183    }
184
185    /// Diff styled lines by comparison keys (lines with terminators).
186    pub(crate) fn from_parts(
187        old: Vec<Text>,
188        new: Vec<Text>,
189        old_keys: &[&str],
190        new_keys: &[&str],
191        styles: bool,
192    ) -> Self {
193        let ops = diff_lines(old_keys, new_keys);
194        let mut view = Self::from_ops(old, new, &ops, old_keys, new_keys);
195        if styles {
196            view.detect_style_changes();
197        }
198        view
199    }
200
201    fn from_ops(
202        old: Vec<Text>,
203        new: Vec<Text>,
204        ops: &[Op],
205        old_keys: &[&str],
206        new_keys: &[&str],
207    ) -> Self {
208        let chunks = ops
209            .iter()
210            .map(|op| {
211                let tag = match op {
212                    Op::Equal { .. } => Tag::Equal,
213                    Op::Delete { .. } => Tag::Delete,
214                    Op::Insert { .. } => Tag::Insert,
215                };
216                (tag, op.old(), op.new_range())
217            })
218            .collect();
219        let lacks = |keys: &[&str]| keys.last().is_some_and(|l| !l.ends_with('\n'));
220        DiffView {
221            old,
222            new,
223            chunks,
224            old_no_newline: lacks(old_keys),
225            new_no_newline: lacks(new_keys),
226            layout: Layout::Unified,
227            line_numbers: true,
228            wrap: true,
229            context: 3,
230            titles: None,
231            emphasis: true,
232            links: None,
233        }
234    }
235
236    /// Split equal runs where the styling differs.
237    fn detect_style_changes(&mut self) {
238        let mut chunks = Vec::new();
239        for (tag, old, new) in std::mem::take(&mut self.chunks) {
240            if tag != Tag::Equal {
241                chunks.push((tag, old, new));
242                continue;
243            }
244            for (i, j) in old.clone().zip(new.clone()) {
245                let t = if signature(&self.old[i]) == signature(&self.new[j]) {
246                    Tag::Equal
247                } else {
248                    Tag::Style
249                };
250                match chunks.last_mut() {
251                    Some((last, a, b)) if *last == t && a.end == i && b.end == j => {
252                        a.end = i + 1;
253                        b.end = j + 1;
254                    }
255                    _ => chunks.push((t, i..i + 1, j..j + 1)),
256                }
257            }
258        }
259        self.chunks = chunks;
260    }
261
262    /// Unified (default) or side by side.
263    pub fn layout(mut self, layout: Layout) -> Self {
264        self.layout = layout;
265        self
266    }
267    /// Show line numbers (default on).
268    pub fn line_numbers(mut self, show: bool) -> Self {
269        self.line_numbers = show;
270        self
271    }
272    /// Wrap long lines (default) or truncate them with an ellipsis.
273    pub fn wrap(mut self, wrap: bool) -> Self {
274        self.wrap = wrap;
275        self
276    }
277    /// Unchanged lines around each change (default 3).
278    pub fn context(mut self, lines: usize) -> Self {
279        self.context = lines;
280        self
281    }
282    /// Titles for the old and new sides (`---`/`+++` when unified, column
283    /// headings side by side).
284    pub fn titles(mut self, old: impl Into<String>, new: impl Into<String>) -> Self {
285        self.titles = Some((old.into(), new.into()));
286        self
287    }
288    /// Word-level emphasis on changed lines (default on).
289    pub fn emphasis(mut self, on: bool) -> Self {
290        self.emphasis = on;
291        self
292    }
293    pub(crate) fn links(mut self, links: Option<LineLinks>) -> Self {
294        self.links = links;
295        self
296    }
297
298    /// Whether the two sides render identically.
299    pub fn is_equal(&self) -> bool {
300        self.chunks.iter().all(|c| c.0 == Tag::Equal)
301    }
302
303    /// `(added, removed, restyled)` line counts.
304    pub fn stats(&self) -> (usize, usize, usize) {
305        self.chunks
306            .iter()
307            .fold((0, 0, 0), |(a, r, s), (tag, o, n)| match tag {
308                Tag::Insert => (a + n.len(), r, s),
309                Tag::Delete => (a, r + o.len(), s),
310                Tag::Style => (a, r, s + o.len()),
311                Tag::Equal => (a, r, s),
312            })
313    }
314
315    /// The 1-based new-side lines whose text matches but styling differs.
316    pub fn style_changed_lines(&self) -> Vec<usize> {
317        self.chunks
318            .iter()
319            .filter(|c| c.0 == Tag::Style)
320            .flat_map(|c| c.2.clone().map(|j| j + 1))
321            .collect()
322    }
323
324    pub(crate) fn blocks(&self) -> Vec<Block> {
325        let runs: Vec<(bool, Range<usize>, Range<usize>)> = self
326            .chunks
327            .iter()
328            .map(|(t, o, n)| (*t == Tag::Equal, o.clone(), n.clone()))
329            .collect();
330        let link = |side: Side, n: usize| self.links.as_ref().and_then(|f| f(side, n));
331        let mut blocks = Vec::new();
332        for group in group_ranges(&runs, self.context) {
333            let old_range =
334                group.first().map_or(0, |g| g.1.start)..group.last().map_or(0, |g| g.1.end);
335            let new_range =
336                group.first().map_or(0, |g| g.2.start)..group.last().map_or(0, |g| g.2.end);
337            let mut block = Block {
338                header: Some(hunk_header(old_range, new_range)),
339                rows: Vec::new(),
340            };
341            for (index, old, new) in group {
342                let old_row = |i: usize, kind: Kind| {
343                    let mut row = Row::new(kind, Some(i + 1), None, self.old[i].clone());
344                    row.no_newline = self.old_no_newline && i + 1 == self.old.len();
345                    row.link = link(Side::Old, i + 1);
346                    row
347                };
348                let new_row = |j: usize, kind: Kind| {
349                    let mut row = Row::new(kind, None, Some(j + 1), self.new[j].clone());
350                    row.no_newline = self.new_no_newline && j + 1 == self.new.len();
351                    row.link = link(Side::New, j + 1);
352                    row
353                };
354                match self.chunks[index].0 {
355                    Tag::Equal => {
356                        for (i, j) in old.zip(new) {
357                            let mut row = new_row(j, Kind::Context);
358                            row.old_no = Some(i + 1);
359                            block.rows.push(row);
360                        }
361                    }
362                    Tag::Delete => block.rows.extend(old.map(|i| old_row(i, Kind::Delete))),
363                    Tag::Insert => block.rows.extend(new.map(|j| new_row(j, Kind::Insert))),
364                    Tag::Style => {
365                        for (i, j) in old.zip(new) {
366                            block.rows.push(old_row(i, Kind::StyleOld));
367                            block.rows.push(new_row(j, Kind::StyleNew));
368                        }
369                    }
370                }
371            }
372            if self.emphasis {
373                render::emphasize(&mut block);
374            }
375            blocks.push(block);
376        }
377        blocks
378    }
379
380    fn options(&self) -> Options {
381        Options {
382            layout: self.layout,
383            line_numbers: self.line_numbers,
384            wrap: self.wrap,
385            titles: self.titles.clone(),
386        }
387    }
388}
389
390impl Renderable for DiffView {
391    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
392        if options.max_width == 0 || options.height == Some(0) {
393            return Vec::new();
394        }
395        let blocks = self.blocks();
396        if blocks.is_empty() {
397            let rows = render::banner(
398                "no differences",
399                style(console, "diff.line_number"),
400                options.max_width,
401            );
402            return render::join(rows, options.height);
403        }
404        let rows = render::render(console, &blocks, &self.options(), options.max_width);
405        render::join(rows, options.height)
406    }
407
408    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
409        let blocks = self.blocks();
410        let (min, max) = if blocks.is_empty() {
411            (1, "no differences".len())
412        } else {
413            render::measure(&blocks, &self.options())
414        };
415        let max = max.min(options.max_width);
416        Measurement::new(min.min(max), max)
417    }
418}