Before we start playing with parser and tree, I'd like to introduce to you changes regarding our unit tests.

The code for this chapter is available at Github.

Note - I introduced tags, so you can always return to this part of my journey.

Ok, let's begin!

Right now our unit tests look like this:

#[test]
fn lexer_test() {
    let input = "(add 2 (京 4 5))";
    let mut lexer = lexer(input);
    let res: Vec<_> = lexer.collect();
    assert_eq!(
        res,
        vec![
            Token { kind: OpenParent, span: "(", },
            Token { kind: Atom, span: "add", },
            Token { kind: Trivia, span: " ", },
            Token { kind: Atom, span: "2", },
            Token { kind: Trivia, span: " ", },
            Token { kind: OpenParent, span: "(", },
            Token { kind: Atom, span: "", },
            Token { kind: Trivia, span: " ", },
            Token { kind: Atom, span: "4", },
            Token { kind: Trivia, span: " ", },
            Token { kind: Atom, span: "5", },
            Token { kind: CloseParent, span: ")", },
            Token { kind: CloseParent, span: ")", },
        ]
    );
}

It's quite verbose and cumbersome to maintain. What I really want is snapshot testing. We will store expected results in text files *.snap and write function to read and compare them automatically.

We could use nice library called insta, however in my opinion for our purposes it's to heavy. Plus I wanted to learn how to do it by myself :)

Let's start from the beginning.

First of all I'd like to use my test-case crate to parameterize our tests.

# In Cargo.toml

[dev-dependencies]
test-case = "1.0.0"

Testing module

Next, I add new module testing to our lib.rs

pub mod lexer;
pub mod offset;
pub mod peekable;
pub mod token;

pub mod testing; // <--- new module

Then I can define our snapshot function:

use std::io::Write;
use std::path::{PathBuf, Path};
use std::fmt::Display;
use std::fs::{File, read_to_string, create_dir_all, remove_file};
use std::env::{current_dir, set_current_dir};

pub fn snap(actual_result: impl Display, file: &str, test_case_name: &str) {

First fof all we want some result, a file where it happened (we will generate it with file!() macro) and test case name.

We can start by creating string with result.

    let actual_result = format!("{}", actual_result);
    println!("{}", actual_result);

Then generate all necessary paths to directories and files:

    let file_path = PathBuf::from(file);

    goto_workdir(&file_path); // TODO: Implement me later

    let mut dir_path = file_path.clone();
    dir_path.set_extension("");
    let file_name = dir_path.file_stem().expect("File_name");

    let mut snap_dir_path: PathBuf = file_path.parent().expect("Parent directory").into();
    snap_dir_path.push("snaps");
    snap_dir_path.push(file_name);

    let snap_path: PathBuf = snap_dir_path.join(format!("{}.snap", test_case_name));
    let new_snap_path: PathBuf = snap_dir_path.join(format!("{}.snap.new", test_case_name));

goto_workdir will automatically setup for us current working directory, because we need file_path to be available from our test.

There are three important paths:

  • snap_dir_path - to a directory containing snapshots
  • snap_path - to expected result snapshot
  • new_snap_path - to new version of snapshot - so we can make a diff

Now the logic is relatively simple:

    if !snap_path.exists() {
        save_new_snap(&snap_dir_path, &new_snap_path, &actual_result); // TODO: Implement me later 

        panic!("Couldn't find snap. Created new one");
    } else {
        let expected_result = read_to_string(&snap_path).expect("Couldn't read expected snap");

        if expected_result != actual_result {
            save_new_snap(&snap_dir_path, &new_snap_path, &actual_result);

            assert_eq!(expected_result, actual_result);
        } else if new_snap_path.exists() {
            remove_file(&new_snap_path).expect("Couldn't remove new snap");
        }
    }
}

We either compare results if possible and save new snapshot version if it's different than previous (or previous doesn't exist yet).

Now we have two functions left: save_new_snap and goto_workdir.

fn goto_workdir(file_path: impl AsRef<Path>) {
    let file_path = file_path.as_ref();
    let mut path = current_dir().expect("Current dir");
    loop {
        if file_path.exists() {
            break;
        }
        path = path.parent().expect("Couldn't go up").into();

        set_current_dir(&path).expect("Couldn't go up");
    }
}

We walk up through the directory tree until our file_path is available. The reason is - file!() can return a path from workspace perspective. Therefore we have to go to the workspace level. We couldn't simply detect Cargo.toml because in one workspace there may be more than one file with the same name.

Saving new snapshot is much much easier. We simply create a directory if it doesn't exist and then save the file.

fn save_new_snap(snap_dir_path: &Path, new_snap_path: &Path, result: &str) {
    let _r = create_dir_all(&snap_dir_path);
    File::create(&new_snap_path)
        .and_then(|mut file| file.write_all(result.as_bytes()))
        .expect("Couldn't save snap");
}

Token

Before we change our sexp example I'd like to also add to our Token implementation of Display:

// In token.rs

impl<'a, K> Display for Token<'a, K>
where
    K: TokenKind,
{
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "{:?}", self.kind)?;
        write!(f, " `{}`", self.span)
    }
}

Thanks to that we will get nice and clean snapshots.

Now we are ready for our example:

Example

#[cfg(test)]
mod tests {
    use super::*;
    use test_case::test_case;

    #[test_case("(add 2 (京 4 5))",  "unicode" ; "unicode")]
    #[test_case("(add 2 (+++ 4 5))", "error"   ; "error")]
    fn lexer_tests(input: &str, test_case_name: &str) {
        let lexer = lexer(input);

        let res: Vec<_> = lexer.map(|t| t.to_string()).collect();
        parsing_tutorial::testing::snap(
            format!("```\n{}\n```\n\n{:#?}", input, res),
            file!(),
            test_case_name,
        );
    }
}

As you can see its much much shorter!

First of all now we have one function with two parameterized tests. Each test contains input, and short description which we use to generate filename and test case name. Right now these name is duplicated, but don't you worry. I'm working on next version of test-case where I will resolve this issue.

Note, we map our token into string, so we can use our new implementation of Display. Then we save in snapshot two things:

  • Input
  • Expected output

The input is quite important, because thanks to that you can know the context.

If we run our functions we should get two failing tests:

running 2 tests
test tests::lexer_tests::unicode ... FAILED
test tests::lexer_tests::error ... FAILED

failures:

---- tests::lexer_tests::unicode stdout ----
```
(add 2 (京 4 5))
```

[
    "OpenParent `(`",
    "Atom `add`",
    "Trivia ` `",
    "Atom `2`",
    "Trivia ` `",
    "OpenParent `(`",
    "Atom `京`",
    "Trivia ` `",
    "Atom `4`",
    "Trivia ` `",
    "Atom `5`",
    "CloseParent `)`",
    "CloseParent `)`",
]
thread 'tests::lexer_tests::unicode' panicked at 'Couldn't find snap. Created new one', <::std::macros::panic macros>:2:4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- tests::lexer_tests::error stdout ----
```
(add 2 (+++ 4 5))
```

[
    "OpenParent `(`",
    "Atom `add`",
    "Trivia ` `",
    "Atom `2`",
    "Trivia ` `",
    "OpenParent `(`",
    "Error `+++`",
    "Trivia ` `",
    "Atom `4`",
    "Trivia ` `",
    "Atom `5`",
    "CloseParent `)`",
    "CloseParent `)`",
]
thread 'tests::lexer_tests::error' panicked at 'Couldn't find snap. Created new one', <::std::macros::panic macros>:2:4


failures:
    tests::lexer_tests::error
    tests::lexer_tests::unicode

test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out

And in examples/snaps/sexp we can see two new files:

error.snap.new
unicode.snap.new

We could simply rename them, but it is better to write simple bash script which travels through the project and do little diff:

# In scripts/review.sh
#!/bin/bash

echo ""
echo ""

for file in $(find . -type f -name "*.new"); do
  ACTUAL="$file"
  EXPECTED="${file%%.new}"

  echo "Accepting: $ACTUAL";
  echo "-----"

  diff -y -N "$EXPECTED" "$ACTUAL" | colordiff

  echo ""
  echo ""
  echo "-----"
  read -p "[A]ccept, [R]reject or [S]kip" -n 1 -r
  echo 

  if [[ $REPLY =~ ^[Aa]$ ]]
  then
    mv -- "$ACTUAL" "$EXPECTED"
  elif [[ $REPLY =~ ^[Rr]$ ]]
  then
    rm -- "$ACTUAL"
  elif [[ $REPLY =~ ^[Ss]$ ]]
  then
    echo "Skipping"
  fi
done
echo "All processed"

I mark is as executable and then run:

chmod +x scripts/review.sh
./scripts/review.sh

Now all our tests are passing :-)

running 2 tests
test tests::lexer_tests::error ... ok
test tests::lexer_tests::unicode ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out