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