midenc_expect_test/
lib.rs

1//! Minimalistic snapshot testing for Rust.
2//!
3//! # Introduction
4//!
5//! `midenc_expect_test` is a small addition over plain `assert_eq!` testing approach,
6//! which allows to automatically update tests results.
7//!
8//! The core of the library is the `expect!` macro. It can be though of as a
9//! super-charged string literal, which can update itself.
10//!
11//! Let's see an example:
12//!
13//! ```no_run
14//! use midenc_expect_test::expect;
15//!
16//! let actual = 2 + 2;
17//! let expected = expect!["5"]; // or expect![["5"]]
18//! expected.assert_eq(&actual.to_string())
19//! ```
20//!
21//! Running this code will produce a test failure, as `"5"` is indeed not equal
22//! to `"4"`. Running the test with `UPDATE_EXPECT=1` env variable however would
23//! "magically" update the code to:
24//!
25//! ```no_run
26//! # use midenc_expect_test::expect;
27//! let actual = 2 + 2;
28//! let expected = expect!["4"];
29//! expected.assert_eq(&actual.to_string())
30//! ```
31//!
32//! This becomes very useful when you have a lot of tests with verbose and
33//! potentially changing expected output.
34//!
35//! Under the hood, the `expect!` macro uses `file!`, `line!` and `column!` to
36//! record source position at compile time. At runtime, this position is used
37//! to patch the file in-place, if `UPDATE_EXPECT` is set.
38//!
39//! # Guide
40//!
41//! `expect!` returns an instance of `Expect` struct, which holds position
42//! information and a string literal. Use `Expect::assert_eq` for string
43//! comparison. Use `Expect::assert_debug_eq` for verbose debug comparison. Note
44//! that leading indentation is automatically removed.
45//!
46//! ```
47//! use midenc_expect_test::expect;
48//!
49//! #[derive(Debug)]
50//! struct Foo {
51//!     value: i32,
52//! }
53//!
54//! let actual = Foo { value: 92 };
55//! let expected = expect![["
56//!     Foo {
57//!         value: 92,
58//!     }
59//! "]];
60//! expected.assert_debug_eq(&actual);
61//! ```
62//!
63//! Be careful with `assert_debug_eq` - in general, stability of the debug
64//! representation is not guaranteed. However, even if it changes, you can
65//! quickly update all the tests by running the test suite with `UPDATE_EXPECT`
66//! environmental variable set.
67//!
68//! If the expected data is too verbose to include inline, you can store it in
69//! an external file using the `expect_file!` macro:
70//!
71//! ```no_run
72//! use midenc_expect_test::expect_file;
73//!
74//! let actual = 42;
75//! let expected = expect_file!["./the-answer.txt"];
76//! expected.assert_eq(&actual.to_string());
77//! ```
78//!
79//! File path is relative to the current file.
80//!
81//! # Suggested Workflows
82//!
83//! I like to use data-driven tests with `midenc_expect_test`. I usually define a
84//! single driver function `check` and then call it from individual tests:
85//!
86//! ```
87//! use midenc_expect_test::{expect, Expect};
88//!
89//! fn check(actual: i32, expect: Expect) {
90//!     let actual = actual.to_string();
91//!     expect.assert_eq(&actual);
92//! }
93//!
94//! #[test]
95//! fn test_addition() {
96//!     check(90 + 2, expect![["92"]]);
97//! }
98//!
99//! #[test]
100//! fn test_multiplication() {
101//!     check(46 * 2, expect![["92"]]);
102//! }
103//! ```
104//!
105//! Each test's body is a single call to `check`. All the variation in tests
106//! comes from the input data.
107//!
108//! When writing a new test, I usually copy-paste an old one, leave the `expect`
109//! blank and use `UPDATE_EXPECT` to fill the value for me:
110//!
111//! ```
112//! # use midenc_expect_test::{expect, Expect};
113//! # fn check(_: i32, _: Expect) {}
114//! #[test]
115//! fn test_division() {
116//!     check(92 / 2, expect![[""]])
117//! }
118//! ```
119//!
120//! See
121//! <https://blog.janestreet.com/using-ascii-waveforms-to-test-hardware-designs/>
122//! for a cool example of snapshot testing in the wild!
123//!
124
125#![allow(clippy::test_attr_in_doctest)]
126
127use std::{
128    collections::HashMap,
129    convert::TryInto,
130    env, fmt, fs, mem,
131    ops::Range,
132    panic,
133    path::{Path, PathBuf},
134    sync::Mutex,
135};
136
137use once_cell::sync::{Lazy, OnceCell};
138
139const HELP: &str = "
140You can update all `expect!` tests by running:
141
142    env UPDATE_EXPECT=1 cargo test
143
144To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer.
145";
146
147fn update_expect() -> bool {
148    env::var("UPDATE_EXPECT").is_ok()
149}
150
151/// Creates an instance of `Expect` from string literal:
152///
153/// ```
154/// # use midenc_expect_test::expect;
155/// expect![["
156///     Foo { value: 92 }
157/// "]];
158/// expect![r#"{"Foo": 92}"#];
159/// ```
160///
161/// Leading indentation is stripped.
162#[macro_export]
163macro_rules! expect {
164    [$data:literal] => { $crate::expect![[$data]] };
165    [[$data:literal]] => {$crate::Expect {
166        position: $crate::Position {
167            file: file!(),
168            line: line!(),
169            column: column!(),
170        },
171        data: $data,
172        indent: true,
173    }};
174    [] => { $crate::expect![[""]] };
175    [[]] => { $crate::expect![[""]] };
176}
177
178/// Creates an instance of `ExpectFile` from relative or absolute path:
179///
180/// ```
181/// # use midenc_expect_test::expect_file;
182/// expect_file!["./test_data/bar.html"];
183/// ```
184#[macro_export]
185macro_rules! expect_file {
186    [$path:expr] => {$crate::ExpectFile {
187        path: std::path::PathBuf::from($path),
188        position: file!(),
189    }};
190}
191
192/// Self-updating string literal.
193#[derive(Debug)]
194pub struct Expect {
195    #[doc(hidden)]
196    pub position: Position,
197    #[doc(hidden)]
198    pub data: &'static str,
199    #[doc(hidden)]
200    pub indent: bool,
201}
202
203/// Self-updating file.
204#[derive(Debug)]
205pub struct ExpectFile {
206    #[doc(hidden)]
207    pub path: PathBuf,
208    #[doc(hidden)]
209    pub position: &'static str,
210}
211
212/// Position of original `expect!` in the source file.
213#[derive(Debug)]
214pub struct Position {
215    #[doc(hidden)]
216    pub file: &'static str,
217    #[doc(hidden)]
218    pub line: u32,
219    #[doc(hidden)]
220    pub column: u32,
221}
222
223impl fmt::Display for Position {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        write!(f, "{}:{}:{}", self.file, self.line, self.column)
226    }
227}
228
229#[derive(Clone, Copy)]
230enum StrLitKind {
231    Normal,
232    Raw(usize),
233}
234
235impl StrLitKind {
236    fn write_start(self, w: &mut impl std::fmt::Write) -> std::fmt::Result {
237        match self {
238            Self::Normal => write!(w, "\""),
239            Self::Raw(n) => {
240                write!(w, "r")?;
241                for _ in 0..n {
242                    write!(w, "#")?;
243                }
244                write!(w, "\"")
245            }
246        }
247    }
248
249    fn write_end(self, w: &mut impl std::fmt::Write) -> std::fmt::Result {
250        match self {
251            Self::Normal => write!(w, "\""),
252            Self::Raw(n) => {
253                write!(w, "\"")?;
254                for _ in 0..n {
255                    write!(w, "#")?;
256                }
257                Ok(())
258            }
259        }
260    }
261}
262
263impl Expect {
264    /// Checks if this expect is equal to `actual`.
265    pub fn assert_eq(&self, actual: &str) {
266        let trimmed = self.trimmed();
267        if trimmed == actual {
268            return;
269        }
270        Runtime::fail_expect(self, &trimmed, actual);
271    }
272
273    /// Checks if this expect is equal to `format!("{:#?}", actual)`.
274    pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) {
275        let actual = format!("{actual:#?}\n");
276        self.assert_eq(&actual)
277    }
278
279    /// If `true` (default), in-place update will indent the string literal.
280    pub fn indent(&mut self, yes: bool) {
281        self.indent = yes;
282    }
283
284    /// Returns the content of this expect.
285    pub fn data(&self) -> &str {
286        self.data
287    }
288
289    fn trimmed(&self) -> String {
290        if !self.data.contains('\n') {
291            return self.data.to_string();
292        }
293        trim_indent(self.data)
294    }
295
296    fn locate(&self, file: &str) -> Location {
297        let mut target_line = None;
298        let mut line_start = 0;
299        for (i, line) in lines_with_ends(file).enumerate() {
300            if i == self.position.line as usize - 1 {
301                // `column` points to the first character of the macro invocation:
302                //
303                //    expect![[r#""#]]        expect![""]
304                //    ^       ^               ^       ^
305                //  column   offset                 offset
306                //
307                // Seek past the exclam, then skip any whitespace and
308                // the macro delimiter to get to our argument.
309                #[allow(clippy::skip_while_next)]
310                let byte_offset = line
311                    .char_indices()
312                    .skip((self.position.column - 1).try_into().unwrap())
313                    .skip_while(|&(_, c)| c != '!')
314                    .skip(1) // !
315                    .skip_while(|&(_, c)| c.is_whitespace())
316                    .skip(1) // [({
317                    .skip_while(|&(_, c)| c.is_whitespace())
318                    .next()
319                    .expect("Failed to parse macro invocation")
320                    .0;
321
322                let literal_start = line_start + byte_offset;
323                let indent = line.chars().take_while(|&it| it == ' ').count();
324                target_line = Some((literal_start, indent));
325                break;
326            }
327            line_start += line.len();
328        }
329        let (literal_start, line_indent) = target_line.unwrap();
330
331        let lit_to_eof = &file[literal_start..];
332        let lit_to_eof_trimmed = lit_to_eof.trim_start();
333
334        let literal_start = literal_start + (lit_to_eof.len() - lit_to_eof_trimmed.len());
335
336        let literal_len =
337            locate_end(lit_to_eof_trimmed).expect("Couldn't find closing delimiter for `expect!`.");
338        let literal_range = literal_start..literal_start + literal_len;
339        Location {
340            line_indent,
341            literal_range,
342        }
343    }
344}
345
346fn locate_end(arg_start_to_eof: &str) -> Option<usize> {
347    match arg_start_to_eof.chars().next()? {
348        c if c.is_whitespace() => panic!("skip whitespace before calling `locate_end`"),
349
350        // expect![[]]
351        '[' => {
352            let str_start_to_eof = arg_start_to_eof[1..].trim_start();
353            let str_len = find_str_lit_len(str_start_to_eof)?;
354            let str_end_to_eof = &str_start_to_eof[str_len..];
355            let closing_brace_offset = str_end_to_eof.find(']')?;
356            Some((arg_start_to_eof.len() - str_end_to_eof.len()) + closing_brace_offset + 1)
357        }
358
359        // expect![] | expect!{} | expect!()
360        ']' | '}' | ')' => Some(0),
361
362        // expect!["..."] | expect![r#"..."#]
363        _ => find_str_lit_len(arg_start_to_eof),
364    }
365}
366
367/// Parses a string literal, returning the byte index of its last character
368/// (either a quote or a hash).
369fn find_str_lit_len(str_lit_to_eof: &str) -> Option<usize> {
370    use StrLitKind::*;
371
372    fn try_find_n_hashes(
373        s: &mut impl Iterator<Item = char>,
374        desired_hashes: usize,
375    ) -> Option<(usize, Option<char>)> {
376        let mut n = 0;
377        loop {
378            match s.next()? {
379                '#' => n += 1,
380                c => return Some((n, Some(c))),
381            }
382
383            if n == desired_hashes {
384                return Some((n, None));
385            }
386        }
387    }
388
389    let mut s = str_lit_to_eof.chars();
390    let kind = match s.next()? {
391        '"' => Normal,
392        'r' => {
393            let (n, c) = try_find_n_hashes(&mut s, usize::MAX)?;
394            if c != Some('"') {
395                return None;
396            }
397            Raw(n)
398        }
399        _ => return None,
400    };
401
402    let mut oldc = None;
403    loop {
404        let c = oldc.take().or_else(|| s.next())?;
405        match (c, kind) {
406            ('\\', Normal) => {
407                let _escaped = s.next()?;
408            }
409            ('"', Normal) => break,
410            ('"', Raw(0)) => break,
411            ('"', Raw(n)) => {
412                let (seen, c) = try_find_n_hashes(&mut s, n)?;
413                if seen == n {
414                    break;
415                }
416                oldc = c;
417            }
418            _ => {}
419        }
420    }
421
422    Some(str_lit_to_eof.len() - s.as_str().len())
423}
424
425impl ExpectFile {
426    /// Checks if file contents is equal to `actual`.
427    pub fn assert_eq(&self, actual: &str) {
428        let expected = self.data();
429        if actual == expected {
430            return;
431        }
432        Runtime::fail_file(self, &expected, actual);
433    }
434
435    /// Checks if file contents is equal to `format!("{:#?}", actual)`.
436    pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) {
437        let actual = format!("{actual:#?}\n");
438        self.assert_eq(&actual)
439    }
440
441    /// Returns the content of this expect.
442    pub fn data(&self) -> String {
443        fs::read_to_string(self.abs_path()).unwrap_or_default().replace("\r\n", "\n")
444    }
445
446    fn write(&self, contents: &str) {
447        fs::write(self.abs_path(), contents).unwrap()
448    }
449
450    fn abs_path(&self) -> PathBuf {
451        if self.path.is_absolute() {
452            self.path.to_owned()
453        } else {
454            let dir = Path::new(self.position).parent().unwrap();
455            to_abs_ws_path(&dir.join(&self.path))
456        }
457    }
458}
459
460#[derive(Default)]
461struct Runtime {
462    help_printed: bool,
463    per_file: HashMap<&'static str, FileRuntime>,
464}
465static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default);
466
467impl Runtime {
468    fn fail_expect(expect: &Expect, expected: &str, actual: &str) {
469        let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
470        if update_expect() {
471            println!("\x1b[1m\x1b[92mupdating\x1b[0m: {}", expect.position);
472            rt.per_file
473                .entry(expect.position.file)
474                .or_insert_with(|| FileRuntime::new(expect))
475                .update(expect, actual);
476            return;
477        }
478        rt.panic(expect.position.to_string(), expected, actual);
479    }
480
481    fn fail_file(expect: &ExpectFile, expected: &str, actual: &str) {
482        let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
483        if update_expect() {
484            println!("\x1b[1m\x1b[92mupdating\x1b[0m: {}", expect.path.display());
485            expect.write(actual);
486            return;
487        }
488        rt.panic(expect.path.display().to_string(), expected, actual);
489    }
490
491    fn panic(&mut self, position: String, expected: &str, actual: &str) {
492        let print_help = !mem::replace(&mut self.help_printed, true);
493        let help = if print_help { HELP } else { "" };
494
495        let diff = format_unified_diff(expected, actual);
496
497        println!(
498            "\n
499\x1b[1m\x1b[91merror\x1b[97m: expect test failed\x1b[0m
500   \x1b[1m\x1b[34m-->\x1b[0m {position}
501{help}
502\x1b[1mExpect\x1b[0m:
503----
504{expected}
505----
506
507\x1b[1mActual\x1b[0m:
508----
509{actual}
510----
511
512\x1b[1mDiff\x1b[0m:
513----
514{diff}
515----
516"
517        );
518        // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise.
519        panic::resume_unwind(Box::new(()));
520    }
521}
522
523struct FileRuntime {
524    path: PathBuf,
525    original_text: String,
526    patchwork: Patchwork,
527}
528
529impl FileRuntime {
530    fn new(expect: &Expect) -> FileRuntime {
531        let path = to_abs_ws_path(Path::new(expect.position.file));
532        let original_text = fs::read_to_string(&path).unwrap();
533        let patchwork = Patchwork::new(original_text.clone());
534        FileRuntime {
535            path,
536            original_text,
537            patchwork,
538        }
539    }
540
541    fn update(&mut self, expect: &Expect, actual: &str) {
542        let loc = expect.locate(&self.original_text);
543        let desired_indent = if expect.indent {
544            Some(loc.line_indent)
545        } else {
546            None
547        };
548        let patch = format_patch(desired_indent, actual);
549        self.patchwork.patch(loc.literal_range, &patch);
550        fs::write(&self.path, &self.patchwork.text).unwrap()
551    }
552}
553
554#[derive(Debug)]
555struct Location {
556    line_indent: usize,
557
558    /// The byte range of the argument to `expect!`, including the inner `[]` if it exists.
559    literal_range: Range<usize>,
560}
561
562#[derive(Debug)]
563struct Patchwork {
564    text: String,
565    indels: Vec<(Range<usize>, usize)>,
566}
567
568impl Patchwork {
569    fn new(text: String) -> Patchwork {
570        Patchwork {
571            text,
572            indels: Vec::new(),
573        }
574    }
575
576    fn patch(&mut self, mut range: Range<usize>, patch: &str) {
577        self.indels.push((range.clone(), patch.len()));
578        self.indels.sort_by_key(|(delete, _insert)| delete.start);
579
580        let (delete, insert) = self
581            .indels
582            .iter()
583            .take_while(|(delete, _)| delete.start < range.start)
584            .map(|(delete, insert)| (delete.end - delete.start, insert))
585            .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2));
586
587        for pos in &mut [&mut range.start, &mut range.end] {
588            **pos -= delete;
589            **pos += insert;
590        }
591
592        self.text.replace_range(range, patch);
593    }
594}
595
596fn lit_kind_for_patch(patch: &str) -> StrLitKind {
597    let has_dquote = patch.chars().any(|c| c == '"');
598    if !has_dquote {
599        let has_bslash_or_newline = patch.chars().any(|c| matches!(c, '\\' | '\n'));
600        return if has_bslash_or_newline {
601            StrLitKind::Raw(1)
602        } else {
603            StrLitKind::Normal
604        };
605    }
606
607    // Find the maximum number of hashes that follow a double quote in the string.
608    // We need to use one more than that to delimit the string.
609    let leading_hashes = |s: &str| s.chars().take_while(|&c| c == '#').count();
610    let max_hashes = patch.split('"').map(leading_hashes).max().unwrap();
611    StrLitKind::Raw(max_hashes + 1)
612}
613
614fn format_patch(desired_indent: Option<usize>, patch: &str) -> String {
615    let lit_kind = lit_kind_for_patch(patch);
616    let indent = desired_indent.map(|it| " ".repeat(it));
617    let is_multiline = patch.contains('\n');
618
619    let mut buf = String::new();
620    if matches!(lit_kind, StrLitKind::Raw(_)) {
621        buf.push('[');
622    }
623    lit_kind.write_start(&mut buf).unwrap();
624    if is_multiline {
625        buf.push('\n');
626    }
627    let mut final_newline = false;
628    for line in lines_with_ends(patch) {
629        if is_multiline && !line.trim().is_empty() {
630            if let Some(indent) = &indent {
631                buf.push_str(indent);
632                buf.push_str("    ");
633            }
634        }
635        buf.push_str(line);
636        final_newline = line.ends_with('\n');
637    }
638    if final_newline {
639        if let Some(indent) = &indent {
640            buf.push_str(indent);
641        }
642    }
643    lit_kind.write_end(&mut buf).unwrap();
644    if matches!(lit_kind, StrLitKind::Raw(_)) {
645        buf.push(']');
646    }
647    buf
648}
649
650fn to_abs_ws_path(path: &Path) -> PathBuf {
651    if path.is_absolute() {
652        return path.to_owned();
653    }
654
655    static WORKSPACE_ROOT: OnceCell<PathBuf> = OnceCell::new();
656    WORKSPACE_ROOT
657        .get_or_try_init(|| {
658            // Until https://github.com/rust-lang/cargo/issues/3946 is resolved, this
659            // is set with a hack like https://github.com/rust-lang/cargo/issues/3946#issuecomment-973132993
660            if let Ok(workspace_root) = env::var("CARGO_WORKSPACE_DIR") {
661                return Ok(workspace_root.into());
662            }
663
664            // If a hack isn't used, we use a heuristic to find the "top-level" workspace.
665            // This fails in some cases, see https://github.com/rust-analyzer/expect-test/issues/33
666            let my_manifest = env::var("CARGO_MANIFEST_DIR")?;
667            let workspace_root = Path::new(&my_manifest)
668                .ancestors()
669                .filter(|it| it.join("Cargo.toml").exists())
670                .last()
671                .unwrap()
672                .to_path_buf();
673
674            Ok(workspace_root)
675        })
676        .unwrap_or_else(|_: env::VarError| {
677            panic!("No CARGO_MANIFEST_DIR env var and the path is relative: {}", path.display())
678        })
679        .join(path)
680}
681
682fn trim_indent(mut text: &str) -> String {
683    if text.starts_with('\n') {
684        text = &text[1..];
685    }
686    let indent = text
687        .lines()
688        .filter(|it| !it.trim().is_empty())
689        .map(|it| it.len() - it.trim_start().len())
690        .min()
691        .unwrap_or(0);
692
693    lines_with_ends(text)
694        .map(|line| {
695            if line.len() <= indent {
696                line.trim_start_matches(' ')
697            } else {
698                &line[indent..]
699            }
700        })
701        .collect()
702}
703
704fn lines_with_ends(text: &str) -> LinesWithEnds<'_> {
705    LinesWithEnds { text }
706}
707
708struct LinesWithEnds<'a> {
709    text: &'a str,
710}
711
712impl<'a> Iterator for LinesWithEnds<'a> {
713    type Item = &'a str;
714
715    fn next(&mut self) -> Option<&'a str> {
716        if self.text.is_empty() {
717            return None;
718        }
719        let idx = self.text.find('\n').map_or(self.text.len(), |it| it + 1);
720        let (res, next) = self.text.split_at(idx);
721        self.text = next;
722        Some(res)
723    }
724}
725
726fn format_unified_diff(expected: &str, actual: &str) -> String {
727    use similar::{ChangeTag, TextDiff};
728
729    let diff = TextDiff::from_lines(expected, actual);
730    let mut result = String::new();
731
732    for (idx, group) in diff.grouped_ops(3).into_iter().enumerate() {
733        if idx > 0 {
734            result.push('\n');
735        }
736        for op in group {
737            for change in diff.iter_changes(&op) {
738                let (sign, color) = match change.tag() {
739                    ChangeTag::Delete => ("-", "\x1b[31m"), // red
740                    ChangeTag::Insert => ("+", "\x1b[32m"), // green
741                    ChangeTag::Equal => (" ", ""),
742                };
743
744                result.push_str(color);
745                result.push_str(sign);
746                result.push(' ');
747
748                let line = change.value();
749                result.push_str(line);
750                if !line.ends_with('\n') {
751                    result.push('\n');
752                }
753
754                if !color.is_empty() {
755                    result.push_str("\x1b[0m");
756                }
757            }
758        }
759    }
760
761    result
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn test_trivial_assert() {
770        expect!["5"].assert_eq("5");
771    }
772
773    #[test]
774    fn test_format_patch() {
775        let patch = format_patch(None, "hello\nworld\n");
776        expect![[r##"
777            [r#"
778            hello
779            world
780            "#]"##]]
781        .assert_eq(&patch);
782
783        let patch = format_patch(None, r"hello\tworld");
784        expect![[r##"[r#"hello\tworld"#]"##]].assert_eq(&patch);
785
786        let patch = format_patch(None, "{\"foo\": 42}");
787        expect![[r##"[r#"{"foo": 42}"#]"##]].assert_eq(&patch);
788
789        let patch = format_patch(Some(0), "hello\nworld\n");
790        expect![[r##"
791            [r#"
792                hello
793                world
794            "#]"##]]
795        .assert_eq(&patch);
796
797        let patch = format_patch(Some(4), "single line");
798        expect![[r#""single line""#]].assert_eq(&patch);
799    }
800
801    #[test]
802    fn test_patchwork() {
803        let mut patchwork = Patchwork::new("one two three".to_string());
804        patchwork.patch(4..7, "zwei");
805        patchwork.patch(0..3, "один");
806        patchwork.patch(8..13, "3");
807        expect![[r#"
808            Patchwork {
809                text: "один zwei 3",
810                indels: [
811                    (
812                        0..3,
813                        8,
814                    ),
815                    (
816                        4..7,
817                        4,
818                    ),
819                    (
820                        8..13,
821                        1,
822                    ),
823                ],
824            }
825        "#]]
826        .assert_debug_eq(&patchwork);
827    }
828
829    #[test]
830    fn test_expect_file() {
831        expect_file!["./lib.rs"].assert_eq(include_str!("./lib.rs"))
832    }
833
834    #[test]
835    fn smoke_test_indent() {
836        fn check_indented(input: &str, mut expect: Expect) {
837            expect.indent(true);
838            expect.assert_eq(input);
839        }
840        fn check_not_indented(input: &str, mut expect: Expect) {
841            expect.indent(false);
842            expect.assert_eq(input);
843        }
844
845        check_indented(
846            "\
847line1
848  line2
849",
850            expect![[r#"
851                line1
852                  line2
853            "#]],
854        );
855
856        check_not_indented(
857            "\
858line1
859  line2
860",
861            expect![[r#"
862line1
863  line2
864"#]],
865        );
866    }
867
868    #[test]
869    fn test_locate() {
870        macro_rules! check_locate {
871            ($( [[$s:literal]] ),* $(,)?) => {$({
872                let lit = stringify!($s);
873                let with_trailer = format!("{} \t]]\n", lit);
874                assert_eq!(locate_end(&with_trailer), Some(lit.len()));
875            })*};
876        }
877
878        // Check that we handle string literals containing "]]" correctly.
879        check_locate!(
880            [[r#"{ arr: [[1, 2], [3, 4]], other: "foo" } "#]],
881            [["]]"]],
882            [["\"]]"]],
883            [[r#""]]"#]],
884        );
885
886        // Check `expect![[  ]]` as well.
887        assert_eq!(locate_end("]]"), Some(0));
888    }
889
890    #[test]
891    fn test_find_str_lit_len() {
892        macro_rules! check_str_lit_len {
893            ($( $s:literal ),* $(,)?) => {$({
894                let lit = stringify!($s);
895                assert_eq!(find_str_lit_len(lit), Some(lit.len()));
896            })*}
897        }
898
899        check_str_lit_len![
900            r##"foa\""#"##,
901            r##"
902
903                asdf][]]""""#
904            "##,
905            "",
906            "\"",
907            "\"\"",
908            "#\"#\"#",
909        ];
910    }
911
912    #[test]
913    fn test_format_unified_diff_insertions() {
914        // Insertion at the beginning
915        let result = format_unified_diff("world", "Hello world");
916        expect![
917            "- world
918+ Hello world
919"
920        ]
921        .assert_eq(&result);
922
923        // Insertion in the middle
924        let result = format_unified_diff("Hello world", "Hello beautiful world");
925        expect![
926            "- Hello world
927+ Hello beautiful world
928"
929        ]
930        .assert_eq(&result);
931
932        // Insertion at the end
933        let result = format_unified_diff("Hello world", "Hello world!");
934        expect![
935            "- Hello world
936+ Hello world!
937"
938        ]
939        .assert_eq(&result);
940    }
941
942    #[test]
943    fn test_format_unified_diff_deletions() {
944        // Deletion at the beginning
945        let result = format_unified_diff("Hello world", "world");
946        expect![
947            "- Hello world
948+ world
949"
950        ]
951        .assert_eq(&result);
952
953        // Deletion in the middle
954        let result = format_unified_diff("Hello beautiful world", "Hello world");
955        expect![
956            "- Hello beautiful world
957+ Hello world
958"
959        ]
960        .assert_eq(&result);
961
962        // Deletion at the end
963        let result = format_unified_diff("Hello world!", "Hello world");
964        expect![
965            "- Hello world!
966+ Hello world
967"
968        ]
969        .assert_eq(&result);
970    }
971
972    #[test]
973    fn test_format_unified_diff_mixed() {
974        // Mixed insertion and deletion
975        let result = format_unified_diff("The quick brown fox", "The slow brown fox");
976        expect![
977            "- The quick brown fox
978+ The slow brown fox
979"
980        ]
981        .assert_eq(&result);
982    }
983}