Skip to main content

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
630            && !line.trim().is_empty()
631            && let Some(indent) = &indent
632        {
633            buf.push_str(indent);
634            buf.push_str("    ");
635        }
636        buf.push_str(line);
637        final_newline = line.ends_with('\n');
638    }
639    if final_newline && let Some(indent) = &indent {
640        buf.push_str(indent);
641    }
642    lit_kind.write_end(&mut buf).unwrap();
643    if matches!(lit_kind, StrLitKind::Raw(_)) {
644        buf.push(']');
645    }
646    buf
647}
648
649fn to_abs_ws_path(path: &Path) -> PathBuf {
650    if path.is_absolute() {
651        return path.to_owned();
652    }
653
654    static WORKSPACE_ROOT: OnceCell<PathBuf> = OnceCell::new();
655    WORKSPACE_ROOT
656        .get_or_try_init(|| {
657            // Until https://github.com/rust-lang/cargo/issues/3946 is resolved, this
658            // is set with a hack like https://github.com/rust-lang/cargo/issues/3946#issuecomment-973132993
659            if let Ok(workspace_root) = env::var("CARGO_WORKSPACE_DIR") {
660                return Ok(workspace_root.into());
661            }
662
663            // If a hack isn't used, we use a heuristic to find the "top-level" workspace.
664            // This fails in some cases, see https://github.com/rust-analyzer/expect-test/issues/33
665            let my_manifest = env::var("CARGO_MANIFEST_DIR")?;
666            let workspace_root = Path::new(&my_manifest)
667                .ancestors()
668                .filter(|it| it.join("Cargo.toml").exists())
669                .last()
670                .unwrap()
671                .to_path_buf();
672
673            Ok(workspace_root)
674        })
675        .unwrap_or_else(|_: env::VarError| {
676            panic!("No CARGO_MANIFEST_DIR env var and the path is relative: {}", path.display())
677        })
678        .join(path)
679}
680
681fn trim_indent(mut text: &str) -> String {
682    if text.starts_with('\n') {
683        text = &text[1..];
684    }
685    let indent = text
686        .lines()
687        .filter(|it| !it.trim().is_empty())
688        .map(|it| it.len() - it.trim_start().len())
689        .min()
690        .unwrap_or(0);
691
692    lines_with_ends(text)
693        .map(|line| {
694            if line.len() <= indent {
695                line.trim_start_matches(' ')
696            } else {
697                &line[indent..]
698            }
699        })
700        .collect()
701}
702
703fn lines_with_ends(text: &str) -> LinesWithEnds<'_> {
704    LinesWithEnds { text }
705}
706
707struct LinesWithEnds<'a> {
708    text: &'a str,
709}
710
711impl<'a> Iterator for LinesWithEnds<'a> {
712    type Item = &'a str;
713
714    fn next(&mut self) -> Option<&'a str> {
715        if self.text.is_empty() {
716            return None;
717        }
718        let idx = self.text.find('\n').map_or(self.text.len(), |it| it + 1);
719        let (res, next) = self.text.split_at(idx);
720        self.text = next;
721        Some(res)
722    }
723}
724
725fn format_unified_diff(expected: &str, actual: &str) -> String {
726    use similar::{ChangeTag, TextDiff};
727
728    let diff = TextDiff::from_lines(expected, actual);
729    let mut result = String::new();
730
731    for (idx, group) in diff.grouped_ops(3).into_iter().enumerate() {
732        if idx > 0 {
733            result.push('\n');
734        }
735        for op in group {
736            for change in diff.iter_changes(&op) {
737                let (sign, color) = match change.tag() {
738                    ChangeTag::Delete => ("-", "\x1b[31m"), // red
739                    ChangeTag::Insert => ("+", "\x1b[32m"), // green
740                    ChangeTag::Equal => (" ", ""),
741                };
742
743                result.push_str(color);
744                result.push_str(sign);
745                result.push(' ');
746
747                let line = change.value();
748                result.push_str(line);
749                if !line.ends_with('\n') {
750                    result.push('\n');
751                }
752
753                if !color.is_empty() {
754                    result.push_str("\x1b[0m");
755                }
756            }
757        }
758    }
759
760    result
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766
767    #[test]
768    fn test_trivial_assert() {
769        expect!["5"].assert_eq("5");
770    }
771
772    #[test]
773    fn test_format_patch() {
774        let patch = format_patch(None, "hello\nworld\n");
775        expect![[r##"
776            [r#"
777            hello
778            world
779            "#]"##]]
780        .assert_eq(&patch);
781
782        let patch = format_patch(None, r"hello\tworld");
783        expect![[r##"[r#"hello\tworld"#]"##]].assert_eq(&patch);
784
785        let patch = format_patch(None, "{\"foo\": 42}");
786        expect![[r##"[r#"{"foo": 42}"#]"##]].assert_eq(&patch);
787
788        let patch = format_patch(Some(0), "hello\nworld\n");
789        expect![[r##"
790            [r#"
791                hello
792                world
793            "#]"##]]
794        .assert_eq(&patch);
795
796        let patch = format_patch(Some(4), "single line");
797        expect![[r#""single line""#]].assert_eq(&patch);
798    }
799
800    #[test]
801    fn test_patchwork() {
802        let mut patchwork = Patchwork::new("one two three".to_string());
803        patchwork.patch(4..7, "zwei");
804        patchwork.patch(0..3, "один");
805        patchwork.patch(8..13, "3");
806        expect![[r#"
807            Patchwork {
808                text: "один zwei 3",
809                indels: [
810                    (
811                        0..3,
812                        8,
813                    ),
814                    (
815                        4..7,
816                        4,
817                    ),
818                    (
819                        8..13,
820                        1,
821                    ),
822                ],
823            }
824        "#]]
825        .assert_debug_eq(&patchwork);
826    }
827
828    #[test]
829    fn test_expect_file() {
830        expect_file!["./lib.rs"].assert_eq(include_str!("./lib.rs"))
831    }
832
833    #[test]
834    fn smoke_test_indent() {
835        fn check_indented(input: &str, mut expect: Expect) {
836            expect.indent(true);
837            expect.assert_eq(input);
838        }
839        fn check_not_indented(input: &str, mut expect: Expect) {
840            expect.indent(false);
841            expect.assert_eq(input);
842        }
843
844        check_indented(
845            "\
846line1
847  line2
848",
849            expect![[r#"
850                line1
851                  line2
852            "#]],
853        );
854
855        check_not_indented(
856            "\
857line1
858  line2
859",
860            expect![[r#"
861line1
862  line2
863"#]],
864        );
865    }
866
867    #[test]
868    fn test_locate() {
869        macro_rules! check_locate {
870            ($( [[$s:literal]] ),* $(,)?) => {$({
871                let lit = stringify!($s);
872                let with_trailer = format!("{} \t]]\n", lit);
873                assert_eq!(locate_end(&with_trailer), Some(lit.len()));
874            })*};
875        }
876
877        // Check that we handle string literals containing "]]" correctly.
878        check_locate!(
879            [[r#"{ arr: [[1, 2], [3, 4]], other: "foo" } "#]],
880            [["]]"]],
881            [["\"]]"]],
882            [[r#""]]"#]],
883        );
884
885        // Check `expect![[  ]]` as well.
886        assert_eq!(locate_end("]]"), Some(0));
887    }
888
889    #[test]
890    fn test_find_str_lit_len() {
891        macro_rules! check_str_lit_len {
892            ($( $s:literal ),* $(,)?) => {$({
893                let lit = stringify!($s);
894                assert_eq!(find_str_lit_len(lit), Some(lit.len()));
895            })*}
896        }
897
898        check_str_lit_len![
899            r##"foa\""#"##,
900            r##"
901
902                asdf][]]""""#
903            "##,
904            "",
905            "\"",
906            "\"\"",
907            "#\"#\"#",
908        ];
909    }
910
911    #[test]
912    fn test_format_unified_diff_insertions() {
913        // Insertion at the beginning
914        let result = format_unified_diff("world", "Hello world");
915        expect![
916            "- world
917+ Hello world
918"
919        ]
920        .assert_eq(&result);
921
922        // Insertion in the middle
923        let result = format_unified_diff("Hello world", "Hello beautiful world");
924        expect![
925            "- Hello world
926+ Hello beautiful world
927"
928        ]
929        .assert_eq(&result);
930
931        // Insertion at the end
932        let result = format_unified_diff("Hello world", "Hello world!");
933        expect![
934            "- Hello world
935+ Hello world!
936"
937        ]
938        .assert_eq(&result);
939    }
940
941    #[test]
942    fn test_format_unified_diff_deletions() {
943        // Deletion at the beginning
944        let result = format_unified_diff("Hello world", "world");
945        expect![
946            "- Hello world
947+ world
948"
949        ]
950        .assert_eq(&result);
951
952        // Deletion in the middle
953        let result = format_unified_diff("Hello beautiful world", "Hello world");
954        expect![
955            "- Hello beautiful world
956+ Hello world
957"
958        ]
959        .assert_eq(&result);
960
961        // Deletion at the end
962        let result = format_unified_diff("Hello world!", "Hello world");
963        expect![
964            "- Hello world!
965+ Hello world
966"
967        ]
968        .assert_eq(&result);
969    }
970
971    #[test]
972    fn test_format_unified_diff_mixed() {
973        // Mixed insertion and deletion
974        let result = format_unified_diff("The quick brown fox", "The slow brown fox");
975        expect![
976            "- The quick brown fox
977+ The slow brown fox
978"
979        ]
980        .assert_eq(&result);
981    }
982}