insta/
output.rs

1use std::borrow::Cow;
2use std::{path::Path, time::Duration};
3
4use similar::{Algorithm, ChangeTag, TextDiff};
5
6use crate::content::yaml;
7use crate::snapshot::{MetaData, Snapshot, SnapshotContents};
8use crate::utils::{format_rust_expression, style, term_width};
9
10/// Snapshot printer utility.
11pub struct SnapshotPrinter<'a> {
12    workspace_root: &'a Path,
13    old_snapshot: Option<&'a Snapshot>,
14    new_snapshot: &'a Snapshot,
15    old_snapshot_hint: &'a str,
16    new_snapshot_hint: &'a str,
17    show_info: bool,
18    show_diff: bool,
19    title: Option<&'a str>,
20    line: Option<u32>,
21    snapshot_file: Option<&'a Path>,
22}
23
24impl<'a> SnapshotPrinter<'a> {
25    pub fn new(
26        workspace_root: &'a Path,
27        old_snapshot: Option<&'a Snapshot>,
28        new_snapshot: &'a Snapshot,
29    ) -> SnapshotPrinter<'a> {
30        SnapshotPrinter {
31            workspace_root,
32            old_snapshot,
33            new_snapshot,
34            old_snapshot_hint: "old snapshot",
35            new_snapshot_hint: "new results",
36            show_info: false,
37            show_diff: false,
38            title: None,
39            line: None,
40            snapshot_file: None,
41        }
42    }
43
44    pub fn set_snapshot_hints(&mut self, old: &'a str, new: &'a str) {
45        self.old_snapshot_hint = old;
46        self.new_snapshot_hint = new;
47    }
48
49    pub fn set_show_info(&mut self, yes: bool) {
50        self.show_info = yes;
51    }
52
53    pub fn set_show_diff(&mut self, yes: bool) {
54        self.show_diff = yes;
55    }
56
57    pub fn set_title(&mut self, title: Option<&'a str>) {
58        self.title = title;
59    }
60
61    pub fn set_line(&mut self, line: Option<u32>) {
62        self.line = line;
63    }
64
65    pub fn set_snapshot_file(&mut self, file: Option<&'a Path>) {
66        self.snapshot_file = file;
67    }
68
69    pub fn print(&self) {
70        if let Some(title) = self.title {
71            let width = term_width();
72            println!(
73                "{title:━^width$}",
74                title = style(format!(" {} ", title)).bold(),
75                width = width
76            );
77        }
78        self.print_snapshot_diff();
79    }
80
81    fn print_snapshot_diff(&self) {
82        self.print_snapshot_summary();
83        if self.show_diff {
84            self.print_changeset();
85        } else {
86            self.print_snapshot();
87        }
88    }
89
90    fn print_snapshot_summary(&self) {
91        print_snapshot_summary(
92            self.workspace_root,
93            self.new_snapshot,
94            self.snapshot_file,
95            self.line,
96        );
97    }
98
99    fn print_info(&self) {
100        print_info(self.new_snapshot.metadata());
101    }
102
103    fn print_snapshot(&self) {
104        print_line(term_width());
105
106        let width = term_width();
107        if self.show_info {
108            self.print_info();
109        }
110        println!("Snapshot Contents:");
111
112        match self.new_snapshot.contents() {
113            SnapshotContents::Text(new_contents) => {
114                let new_contents = new_contents.to_string();
115
116                println!("──────┬{:─^1$}", "", width.saturating_sub(7));
117                for (idx, line) in new_contents.lines().enumerate() {
118                    println!("{:>5} │ {}", style(idx + 1).cyan().dim().bold(), line);
119                }
120                println!("──────┴{:─^1$}", "", width.saturating_sub(7));
121            }
122            SnapshotContents::Binary(_) => {
123                println!(
124                    "{}",
125                    encode_file_link_escape(
126                        &self
127                            .new_snapshot
128                            .build_binary_path(
129                                self.snapshot_file.unwrap().with_extension("snap.new")
130                            )
131                            .unwrap()
132                    )
133                );
134            }
135        }
136    }
137
138    fn print_changeset(&self) {
139        let width = term_width();
140        print_line(width);
141
142        if self.show_info {
143            self.print_info();
144        }
145
146        if let Some(old_snapshot) = self.old_snapshot {
147            if old_snapshot.contents().is_binary() {
148                println!(
149                    "{}",
150                    style(format_args!(
151                        "-{}: {}",
152                        self.old_snapshot_hint,
153                        encode_file_link_escape(
154                            &old_snapshot
155                                .build_binary_path(self.snapshot_file.unwrap())
156                                .unwrap()
157                        ),
158                    ))
159                    .red()
160                );
161            }
162        }
163
164        if self.new_snapshot.contents().is_binary() {
165            println!(
166                "{}",
167                style(format_args!(
168                    "+{}: {}",
169                    self.new_snapshot_hint,
170                    encode_file_link_escape(
171                        &self
172                            .new_snapshot
173                            .build_binary_path(
174                                self.snapshot_file.unwrap().with_extension("snap.new")
175                            )
176                            .unwrap()
177                    ),
178                ))
179                .green()
180            );
181        }
182
183        if let Some((old, new)) = match (
184            self.old_snapshot.as_ref().map(|o| o.contents()),
185            self.new_snapshot.contents(),
186        ) {
187            (Some(SnapshotContents::Binary(_)) | None, SnapshotContents::Text(new)) => {
188                Some((None, Some(new.to_string())))
189            }
190            (Some(SnapshotContents::Text(old)), SnapshotContents::Binary { .. }) => {
191                Some((Some(old.to_string()), None))
192            }
193            (Some(SnapshotContents::Text(old)), SnapshotContents::Text(new)) => {
194                Some((Some(old.to_string()), Some(new.to_string())))
195            }
196            _ => None,
197        } {
198            let old_text = old.as_deref().unwrap_or("");
199            let new_text = new.as_deref().unwrap_or("");
200
201            let newlines_matter = newlines_matter(old_text, new_text);
202            let diff = TextDiff::configure()
203                .algorithm(Algorithm::Patience)
204                .timeout(Duration::from_millis(500))
205                .diff_lines(old_text, new_text);
206
207            if old.is_some() {
208                println!(
209                    "{}",
210                    style(format_args!("-{}", self.old_snapshot_hint)).red()
211                );
212            }
213
214            if new.is_some() {
215                println!(
216                    "{}",
217                    style(format_args!("+{}", self.new_snapshot_hint)).green()
218                );
219            }
220
221            println!("────────────┬{:─^1$}", "", width.saturating_sub(13));
222
223            // This is to make sure that binary and text snapshots are never reported as being
224            // equal (that would otherwise happen if the text snapshot is an empty string).
225            let mut has_changes = old.is_none() || new.is_none();
226
227            for (idx, group) in diff.grouped_ops(4).iter().enumerate() {
228                if idx > 0 {
229                    println!("┈┈┈┈┈┈┈┈┈┈┈┈┼{:┈^1$}", "", width.saturating_sub(13));
230                }
231                for op in group {
232                    for change in diff.iter_inline_changes(op) {
233                        match change.tag() {
234                            ChangeTag::Insert => {
235                                has_changes = true;
236                                print!(
237                                    "{:>5} {:>5} │{}",
238                                    "",
239                                    style(change.new_index().unwrap()).cyan().dim().bold(),
240                                    style("+").green(),
241                                );
242                                for &(emphasized, change) in change.values() {
243                                    let change = render_invisible(change, newlines_matter);
244                                    if emphasized {
245                                        print!("{}", style(change).green().underlined());
246                                    } else {
247                                        print!("{}", style(change).green());
248                                    }
249                                }
250                            }
251                            ChangeTag::Delete => {
252                                has_changes = true;
253                                print!(
254                                    "{:>5} {:>5} │{}",
255                                    style(change.old_index().unwrap()).cyan().dim(),
256                                    "",
257                                    style("-").red(),
258                                );
259                                for &(emphasized, change) in change.values() {
260                                    let change = render_invisible(change, newlines_matter);
261                                    if emphasized {
262                                        print!("{}", style(change).red().underlined());
263                                    } else {
264                                        print!("{}", style(change).red());
265                                    }
266                                }
267                            }
268                            ChangeTag::Equal => {
269                                print!(
270                                    "{:>5} {:>5} │ ",
271                                    style(change.old_index().unwrap()).cyan().dim(),
272                                    style(change.new_index().unwrap()).cyan().dim().bold(),
273                                );
274                                for &(_, change) in change.values() {
275                                    let change = render_invisible(change, newlines_matter);
276                                    print!("{}", style(change).dim());
277                                }
278                            }
279                        }
280                        if change.missing_newline() {
281                            println!();
282                        }
283                    }
284                }
285            }
286
287            if !has_changes {
288                println!(
289                    "{:>5} {:>5} │{}",
290                    "",
291                    style("-").dim(),
292                    style(" snapshots are matching").cyan(),
293                );
294            }
295
296            println!("────────────┴{:─^1$}", "", width.saturating_sub(13));
297        }
298    }
299}
300
301/// Prints the summary of a snapshot
302pub fn print_snapshot_summary(
303    workspace_root: &Path,
304    snapshot: &Snapshot,
305    snapshot_file: Option<&Path>,
306    line: Option<u32>,
307) {
308    if let Some(snapshot_file) = snapshot_file {
309        let snapshot_file = workspace_root
310            .join(snapshot_file)
311            .strip_prefix(workspace_root)
312            .ok()
313            .map(|x| x.to_path_buf())
314            .unwrap_or_else(|| snapshot_file.to_path_buf());
315        println!(
316            "Snapshot file: {}",
317            style(snapshot_file.display()).cyan().underlined()
318        );
319    }
320    if let Some(name) = snapshot.snapshot_name() {
321        println!("Snapshot: {}", style(name).yellow());
322    } else {
323        println!("Snapshot: {}", style("<inline>").dim());
324    }
325
326    if let Some(ref value) = snapshot.metadata().get_relative_source(workspace_root) {
327        println!(
328            "Source: {}{}",
329            style(value.display()).cyan(),
330            line.or(
331                // default to old assertion line from snapshot.
332                snapshot.metadata().assertion_line()
333            )
334            .map(|line| format!(":{}", style(line).bold()))
335            .unwrap_or_default()
336        );
337    }
338
339    if let Some(ref value) = snapshot.metadata().input_file() {
340        println!("Input file: {}", style(value).cyan());
341    }
342}
343
344fn print_line(width: usize) {
345    println!("{:─^1$}", "", width);
346}
347
348fn trailing_newline(s: &str) -> &str {
349    if s.ends_with("\r\n") {
350        "\r\n"
351    } else if s.ends_with('\r') {
352        "\r"
353    } else if s.ends_with('\n') {
354        "\n"
355    } else {
356        ""
357    }
358}
359
360fn detect_newlines(s: &str) -> (bool, bool, bool) {
361    let mut last_char = None;
362    let mut detected_crlf = false;
363    let mut detected_cr = false;
364    let mut detected_lf = false;
365
366    for c in s.chars() {
367        if c == '\n' {
368            if last_char.take() == Some('\r') {
369                detected_crlf = true;
370            } else {
371                detected_lf = true;
372            }
373        }
374        if last_char == Some('\r') {
375            detected_cr = true;
376        }
377        last_char = Some(c);
378    }
379    if last_char == Some('\r') {
380        detected_cr = true;
381    }
382
383    (detected_cr, detected_crlf, detected_lf)
384}
385
386fn newlines_matter(left: &str, right: &str) -> bool {
387    if trailing_newline(left) != trailing_newline(right) {
388        return true;
389    }
390
391    let (cr1, crlf1, lf1) = detect_newlines(left);
392    let (cr2, crlf2, lf2) = detect_newlines(right);
393
394    !matches!(
395        (cr1 || cr2, crlf1 || crlf2, lf1 || lf2),
396        (false, false, false) | (true, false, false) | (false, true, false) | (false, false, true)
397    )
398}
399
400fn render_invisible(s: &str, newlines_matter: bool) -> Cow<'_, str> {
401    if newlines_matter || s.find(&['\x1b', '\x07', '\x08', '\x7f'][..]).is_some() {
402        Cow::Owned(
403            s.replace('\r', "␍\r")
404                .replace('\n', "␊\n")
405                .replace("␍\r␊\n", "␍␊\r\n")
406                .replace('\x07', "␇")
407                .replace('\x08', "␈")
408                .replace('\x1b', "␛")
409                .replace('\x7f', "␡"),
410        )
411    } else {
412        Cow::Borrowed(s)
413    }
414}
415
416fn print_info(metadata: &MetaData) {
417    let width = term_width();
418    if let Some(expr) = metadata.expression() {
419        println!("Expression: {}", style(format_rust_expression(expr)));
420        print_line(width);
421    }
422    if let Some(descr) = metadata.description() {
423        println!("{}", descr);
424        print_line(width);
425    }
426    if let Some(info) = metadata.private_info() {
427        let out = yaml::to_string(info);
428        // TODO: does the yaml output always start with '---'?
429        println!("{}", out.trim().strip_prefix("---").unwrap().trim_start());
430        print_line(width);
431    }
432}
433
434/// Encodes a path as an OSC-8 escape sequence. This makes it a clickable link in supported
435/// terminal emulators.
436fn encode_file_link_escape(path: &Path) -> String {
437    assert!(path.is_absolute());
438    format!(
439        "\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\",
440        path.display(),
441        path.display()
442    )
443}
444
445#[test]
446fn test_invisible() {
447    assert_eq!(
448        render_invisible("\r\n\x1b\r\x07\x08\x7f\n", true),
449        "␍␊\r\n␛␍\r␇␈␡␊\n"
450    );
451}