use crate::prelude::*;
use core::panic::Location;
#[cfg(feature = "tokens")]
use proc_macro2::TokenStream;
#[cfg(feature = "tokens")]
use quote::ToTokens;
use std::fmt::Debug;
use std::sync::LazyLock;
use std::sync::Mutex;
#[extend::ext(name=MatcherDebugSnapshot)]
pub impl<T: Debug> T {
#[track_caller]
fn xpect_debug_snapshot(&self) -> &Self {
self.xfmt().xpect_snapshot();
self
}
}
#[extend::ext(name=MatcherSnapshot)]
pub impl<T, M> T
where
T: StringComp<M>,
{
#[track_caller]
fn xpect_snapshot(&self) -> &Self {
let received = self.to_comp_string();
match parse_snapshot(&received, Location::caller()) {
Ok(Some(expected)) => {
panic_ext::assert_diff(&expected, received.into_maybe_not());
}
Ok(None) => {
}
Err(err) => {
panic_ext::panic_str(err.to_string());
}
}
self
}
}
struct SnapMap;
impl SnapMap {
fn cache() -> &'static Mutex<MultiMap<WsPathBuf, (LineCol, String)>> {
static CACHE: LazyLock<Mutex<MultiMap<WsPathBuf, (LineCol, String)>>> =
LazyLock::new(|| Mutex::new(MultiMap::default()));
&CACHE
}
fn get(file_path: &WsPathBuf, loc: LineCol) -> Result<String> {
Self::init_key(file_path)?;
let cache = Self::cache().lock().unwrap();
let entries = cache.get_vec(file_path).expect("key initialized");
let entry = entries.iter().find(|(l, _)| *l == loc);
match entry {
Some((_, snapshot)) => Ok(snapshot.clone()),
None => {
let available = entries
.iter()
.map(|(l, _)| format!(" {}:{}", l.line, l.col))
.collect::<Vec<_>>()
.join("\n");
bevybail!(
"Snapshot location {}:{} not found in parsed locations.\n\
This likely means the source file has changed since snapshots were generated.\n\
Available locations:\n{}\n\
Please run `cargo test -- --snap` to regenerate snapshots.",
loc.line,
loc.col,
available
)
}
}
}
fn set(file_path: &WsPathBuf, loc: LineCol, value: String) -> Result<()> {
Self::init_key(file_path)?;
let mut cache = Self::cache().lock().unwrap();
let entries = cache.get_vec_mut(file_path).expect("key initialized");
let entry_idx = entries.iter().position(|(l, _)| *l == loc);
match entry_idx {
Some(idx) => {
entries[idx] = (loc, value);
}
None => {
let available = entries
.iter()
.map(|(l, _)| format!(" {}:{}", l.line, l.col))
.collect::<Vec<_>>()
.join("\n");
bevybail!(
"Snapshot location {}:{} not found in parsed locations.\n\
Available locations:\n{}",
loc.line,
loc.col,
available
);
}
}
Self::save_snapshots(file_path, &entries)?;
Ok(())
}
fn init_key(file_path: &WsPathBuf) -> Result {
let mut cache = Self::cache().lock().unwrap();
if !cache.contains_key(file_path) {
let entries = Self::load_file_data(file_path)?;
cache.insert_vec(file_path.clone(), entries);
}
Ok(())
}
fn load_file_data(file_path: &WsPathBuf) -> Result<Vec<(LineCol, String)>> {
let locs = Self::parse_snapshot_locations(file_path)?;
let snap_dir = Self::snapshot_path(file_path);
let entries = locs
.iter()
.enumerate()
.map(|(i, loc)| {
let snap_file = snap_dir.join(format!("{}.snap", i + 1));
let content = fs_ext::read_to_string(&snap_file)
.unwrap_or_else(|_| String::new());
(*loc, content)
})
.collect();
Ok(entries)
}
fn parse_snapshot_locations(file_path: &WsPathBuf) -> Result<Vec<LineCol>> {
let abs_path = file_path.into_abs();
let source = fs_ext::read_to_string(&abs_path).map_err(|err| {
bevyhow!("Failed to read source file {}: {}", abs_path, err)
})?;
let pattern = ".xpect_snapshot()";
let mut locations = Vec::new();
for (line_idx, line_content) in source.lines().enumerate() {
let line_num = (line_idx + 1) as u32;
let mut search_start = 0;
while let Some(pos) = line_content[search_start..].find(pattern) {
let byte_pos = search_start + pos + 1; let col_1indexed =
Self::byte_to_column(&line_content[..byte_pos]);
let col_0indexed = col_1indexed.saturating_sub(1);
locations.push(LineCol::new(line_num, col_0indexed));
search_start += pos + pattern.len();
}
}
Ok(locations)
}
fn byte_to_column(text: &str) -> u32 {
let mut col = 1u32; for ch in text.chars() {
if ch == '\t' {
col = ((col - 1) / 4 + 1) * 4 + 1;
} else {
col += 1;
}
}
col
}
fn snapshot_path(file_path: &WsPathBuf) -> AbsPathBuf {
let dir_name = format!(".beet/snapshots/{}", file_path);
AbsPathBuf::new_workspace_rel(dir_name)
.expect("Failed to create snapshot path")
}
fn save_snapshots(
file_path: &WsPathBuf,
snapshots: &[(LineCol, String)],
) -> Result<()> {
let snap_dir = Self::snapshot_path(file_path);
fs_ext::create_dir_all(&snap_dir)?;
for (idx, (_linecol, snapshot)) in snapshots.iter().enumerate() {
let snap_file = snap_dir.join(format!("{}.snap", idx + 1));
fs_ext::write(&snap_file, snapshot)?;
}
Ok(())
}
}
enum SnapMode {
Save,
Show,
Test,
}
impl SnapMode {
fn parse() -> Self {
let args = env_ext::args();
let contains = |flag: &str| args.iter().any(|arg| arg == flag);
if contains("--snap-show") {
Self::Show
} else if contains("--snap") || contains("-s") {
Self::Save
} else {
Self::Test
}
}
}
fn parse_snapshot(
received: &str,
caller_loc: &Location,
) -> Result<Option<String>> {
let file_path = WsPathBuf::new(caller_loc.file());
let loc = LineCol::from_location(&caller_loc);
match SnapMode::parse() {
SnapMode::Save => {
SnapMap::set(&file_path, loc, received.to_string())?;
crate::cross_log!(
"Snapshot saved: {}:{}:{}",
file_path,
loc.line,
loc.col
);
Ok(None)
}
SnapMode::Show => {
let snap = SnapMap::get(&file_path, loc)?;
crate::cross_log!("Snapshot:\n{}", snap);
Ok(None)
}
SnapMode::Test => SnapMap::get(&file_path, loc)?.xsome().xok(),
}
}
pub trait StringComp<M> {
fn to_comp_string(&self) -> String;
}
pub struct ToTokensStringCompMarker;
#[cfg(feature = "tokens")]
macro_rules! impl_string_comp_for_tokens {
($($ty:ty),*) => {
$(
impl StringComp<ToTokensStringCompMarker> for $ty {
fn to_comp_string(&self) -> String {
pretty_parse(self.to_token_stream())
}
}
)*
};
}
#[cfg(feature = "tokens")]
impl_string_comp_for_tokens!(
proc_macro2::TokenStream,
syn::File,
syn::Item,
syn::Expr,
syn::Stmt,
syn::Type,
syn::Pat,
syn::Ident,
syn::Block,
syn::Path,
syn::Attribute
);
macro_rules! impl_string_comp_for_primitives {
($($ty:ty),*) => {
$(
impl StringComp<$ty> for $ty {
fn to_comp_string(&self) -> String {
self.to_string()
}
}
)*
};
}
impl_string_comp_for_primitives!(&str, String, std::borrow::Cow<'static, str>);
#[cfg(feature = "tokens")]
pub fn pretty_parse(tokens: TokenStream) -> String {
use syn::File;
match syn::parse2::<File>(tokens.clone()) {
Ok(file) => prettyplease::unparse(&file),
Err(_) => {
match syn::parse2::<File>(quote::quote! {
fn deleteme(){
#tokens
}
}) {
Ok(file) => {
let mut str = prettyplease::unparse(&file);
str = str.replace("fn deleteme() {\n", "");
if let Some(pos) = str.rfind("\n}") {
str.replace_range(pos..pos + 3, "");
}
str =
str.lines()
.map(|line| {
if line.len() >= 4 { &line[4..] } else { line }
})
.collect::<Vec<_>>()
.join("\n");
str
}
Err(_) =>
{
tokens.to_string()
}
}
}
}
}
#[cfg(test)]
mod test {
use crate::prelude::*;
#[test]
fn once() { "foobar".xpect_snapshot(); }
#[test]
fn multiple_snapshots_in_one_test() {
"first".xpect_snapshot();
"second".xpect_snapshot();
"third".xpect_snapshot();
}
#[cfg(feature = "tokens")]
#[test]
fn prettyparse() {
use quote::quote;
pretty_parse(quote! {fn main(){let foo = bar;}})
.xpect_eq("fn main() {\n let foo = bar;\n}\n");
pretty_parse(quote! {let foo = bar; let bazz = boo;})
.xpect_eq("let foo = bar;\nlet bazz = boo;");
}
}