aboutsummaryrefslogtreecommitdiff
path: root/crates/expect
diff options
context:
space:
mode:
authorbors[bot] <26634292+bors[bot]@users.noreply.github.com>2020-06-30 09:34:08 +0100
committerGitHub <[email protected]>2020-06-30 09:34:08 +0100
commitd13ded6cbc8b835807f08606db90bedf18643154 (patch)
treed77c573d3ed0cadc5d318b2cfe57b8e8d4426fa7 /crates/expect
parent2bd717139918e15e537dcd833bb003e85d24b3d1 (diff)
parenta4f934efa8e37d3bc822575109d103998ecd8fe1 (diff)
Merge #5101
5101: Add expect -- a light-weight alternative to insta r=matklad a=matklad This PR implements a small snapshot-testing library. Snapshot updating is done by setting an env var, or by using editor feature (which runs a test with env-var set). Here's workflow for updating a failing test: ![expect](https://user-images.githubusercontent.com/1711539/85926956-28afa080-b8a3-11ea-9260-c6d0d8914d0b.gif) Here's workflow for adding a new test: ![expect-fresh](https://user-images.githubusercontent.com/1711539/85926961-306f4500-b8a3-11ea-9369-f2373e327a3f.gif) Note that colorized diffs are not implemented in this PR, but should be easy to add (we already use them in test_utils). Main differences from insta (which is essential for rust-analyzer development, thanks @mitsuhiko!): * self-updating tests, no need for a separate tool * fewer features (only inline snapshots, no redactions) * fewer deps (no yaml, no persistence) * tighter integration with editor * first-class snapshot object, which can be used to write test functions (as opposed to testing macros) * trivial to tweak for rust-analyzer needs, by virtue of being a workspace member. I think eventually we should converge to a single snapshot testing library, but I am not sure that `expect` is exactly right, so I suggest rolling with both insta and expect for some time (if folks agree that expect might be better in the first place!). # Editor Integration Implementation The thing I am most excited about is the ability to update a specific snapshot from the editor. I want this to be available to other snapshot-testing libraries (cc @mitsuhiko, @aaronabramov), so I want to document how this works. The ideal UI here would be a code action (:bulb:). Unfortunately, it seems like it is impossible to implement without some kind of persistence (if you save test failures into some kind of a database, like insta does, than you can read the database from the editor plugin). Note that it is possible to highlight error by outputing error message in rustc's format. Unfortunately, one can't use the same trick to implement a quick fix. For this reason, expect makes use of another rust-analyzer feature -- ability to run a single test at the cursor position. This does need some expect-specific code in rust-analyzer unfortunately. Specifically, if rust-analyzer notices that the cursor is on `expect!` macro, it adds a special flag to runnable's JSON. However, given #5017 it is possible to approximate this well-enough without rust-analyzer integration. Specifically, an extension can register a special runner which checks (using regexes) if rust-anlyzer runnable covers text with specific macro invocation and do special magic in that case. closes #3835 Co-authored-by: Aleksey Kladov <[email protected]>
Diffstat (limited to 'crates/expect')
-rw-r--r--crates/expect/Cargo.toml10
-rw-r--r--crates/expect/src/lib.rs293
2 files changed, 303 insertions, 0 deletions
diff --git a/crates/expect/Cargo.toml b/crates/expect/Cargo.toml
new file mode 100644
index 000000000..caee43106
--- /dev/null
+++ b/crates/expect/Cargo.toml
@@ -0,0 +1,10 @@
1[package]
2name = "expect"
3version = "0.1.0"
4authors = ["rust-analyzer developers"]
5edition = "2018"
6
7[dependencies]
8once_cell = "1"
9difference = "2"
10stdx = { path = "../stdx" }
diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs
new file mode 100644
index 000000000..dd7b96aab
--- /dev/null
+++ b/crates/expect/src/lib.rs
@@ -0,0 +1,293 @@
1//! Snapshot testing library, see
2//! https://github.com/rust-analyzer/rust-analyzer/pull/5101
3use std::{
4 collections::HashMap,
5 env, fmt, fs,
6 ops::Range,
7 panic,
8 path::{Path, PathBuf},
9 sync::Mutex,
10};
11
12use difference::Changeset;
13use once_cell::sync::Lazy;
14use stdx::{lines_with_ends, trim_indent};
15
16const HELP: &str = "
17You can update all `expect![[]]` tests by:
18
19 env UPDATE_EXPECT=1 cargo test
20
21To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer.
22";
23
24fn update_expect() -> bool {
25 env::var("UPDATE_EXPECT").is_ok()
26}
27
28/// expect![[""]]
29#[macro_export]
30macro_rules! expect {
31 [[$lit:literal]] => {$crate::Expect {
32 file: file!(),
33 line: line!(),
34 column: column!(),
35 data: $lit,
36 }};
37 [[]] => { $crate::expect![[""]] };
38}
39
40#[derive(Debug)]
41pub struct Expect {
42 pub file: &'static str,
43 pub line: u32,
44 pub column: u32,
45 pub data: &'static str,
46}
47
48impl Expect {
49 pub fn assert_eq(&self, actual: &str) {
50 let trimmed = self.trimmed();
51 if &trimmed == actual {
52 return;
53 }
54 Runtime::fail(self, &trimmed, actual);
55 }
56 pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) {
57 let actual = format!("{:#?}\n", actual);
58 self.assert_eq(&actual)
59 }
60
61 fn trimmed(&self) -> String {
62 if !self.data.contains('\n') {
63 return self.data.to_string();
64 }
65 trim_indent(self.data)
66 }
67
68 fn locate(&self, file: &str) -> Location {
69 let mut target_line = None;
70 let mut line_start = 0;
71 for (i, line) in lines_with_ends(file).enumerate() {
72 if i == self.line as usize - 1 {
73 let pat = "expect![[";
74 let offset = line.find(pat).unwrap();
75 let literal_start = line_start + offset + pat.len();
76 let indent = line.chars().take_while(|&it| it == ' ').count();
77 target_line = Some((literal_start, indent));
78 break;
79 }
80 line_start += line.len();
81 }
82 let (literal_start, line_indent) = target_line.unwrap();
83 let literal_length =
84 file[literal_start..].find("]]").expect("Couldn't find matching `]]` for `expect![[`.");
85 let literal_range = literal_start..literal_start + literal_length;
86 Location { line_indent, literal_range }
87 }
88}
89
90#[derive(Default)]
91struct Runtime {
92 help_printed: bool,
93 per_file: HashMap<&'static str, FileRuntime>,
94}
95static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default);
96
97impl Runtime {
98 fn fail(expect: &Expect, expected: &str, actual: &str) {
99 let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
100 let mut updated = "";
101 if update_expect() {
102 updated = " (updated)";
103 rt.per_file
104 .entry(expect.file)
105 .or_insert_with(|| FileRuntime::new(expect))
106 .update(expect, actual);
107 }
108 let print_help = !rt.help_printed && !update_expect();
109 rt.help_printed = true;
110
111 let help = if print_help { HELP } else { "" };
112
113 let diff = Changeset::new(actual, expected, "\n");
114
115 println!(
116 "\n
117\x1b[1m\x1b[91merror\x1b[97m: expect test failed\x1b[0m{}
118 \x1b[1m\x1b[34m-->\x1b[0m {}:{}:{}
119{}
120\x1b[1mExpect\x1b[0m:
121----
122{}
123----
124
125\x1b[1mActual\x1b[0m:
126----
127{}
128----
129
130\x1b[1mDiff\x1b[0m:
131----
132{}
133----
134",
135 updated, expect.file, expect.line, expect.column, help, expected, actual, diff
136 );
137 // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise.
138 panic::resume_unwind(Box::new(()));
139 }
140}
141
142struct FileRuntime {
143 path: PathBuf,
144 original_text: String,
145 patchwork: Patchwork,
146}
147
148impl FileRuntime {
149 fn new(expect: &Expect) -> FileRuntime {
150 let path = workspace_root().join(expect.file);
151 let original_text = fs::read_to_string(&path).unwrap();
152 let patchwork = Patchwork::new(original_text.clone());
153 FileRuntime { path, original_text, patchwork }
154 }
155 fn update(&mut self, expect: &Expect, actual: &str) {
156 let loc = expect.locate(&self.original_text);
157 let patch = format_patch(loc.line_indent.clone(), actual);
158 self.patchwork.patch(loc.literal_range, &patch);
159 fs::write(&self.path, &self.patchwork.text).unwrap()
160 }
161}
162
163#[derive(Debug)]
164struct Location {
165 line_indent: usize,
166 literal_range: Range<usize>,
167}
168
169#[derive(Debug)]
170struct Patchwork {
171 text: String,
172 indels: Vec<(Range<usize>, usize)>,
173}
174
175impl Patchwork {
176 fn new(text: String) -> Patchwork {
177 Patchwork { text, indels: Vec::new() }
178 }
179 fn patch(&mut self, mut range: Range<usize>, patch: &str) {
180 self.indels.push((range.clone(), patch.len()));
181 self.indels.sort_by_key(|(delete, _insert)| delete.start);
182
183 let (delete, insert) = self
184 .indels
185 .iter()
186 .take_while(|(delete, _)| delete.start < range.start)
187 .map(|(delete, insert)| (delete.end - delete.start, insert))
188 .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2));
189
190 for pos in &mut [&mut range.start, &mut range.end] {
191 **pos -= delete;
192 **pos += insert;
193 }
194
195 self.text.replace_range(range, &patch);
196 }
197}
198
199fn format_patch(line_indent: usize, patch: &str) -> String {
200 let mut max_hashes = 0;
201 let mut cur_hashes = 0;
202 for byte in patch.bytes() {
203 if byte != b'#' {
204 cur_hashes = 0;
205 continue;
206 }
207 cur_hashes += 1;
208 max_hashes = max_hashes.max(cur_hashes);
209 }
210 let hashes = &"#".repeat(max_hashes + 1);
211 let indent = &" ".repeat(line_indent);
212 let is_multiline = patch.contains('\n');
213
214 let mut buf = String::new();
215 buf.push('r');
216 buf.push_str(hashes);
217 buf.push('"');
218 if is_multiline {
219 buf.push('\n');
220 }
221 let mut final_newline = false;
222 for line in lines_with_ends(patch) {
223 if is_multiline {
224 buf.push_str(indent);
225 buf.push_str(" ");
226 }
227 buf.push_str(line);
228 final_newline = line.ends_with('\n');
229 }
230 if final_newline {
231 buf.push_str(indent);
232 }
233 buf.push('"');
234 buf.push_str(hashes);
235 buf
236}
237
238fn workspace_root() -> PathBuf {
239 Path::new(
240 &env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()),
241 )
242 .ancestors()
243 .nth(2)
244 .unwrap()
245 .to_path_buf()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_format_patch() {
254 let patch = format_patch(0, "hello\nworld\n");
255 expect![[r##"
256 r#"
257 hello
258 world
259 "#"##]]
260 .assert_eq(&patch);
261
262 let patch = format_patch(4, "single line");
263 expect![[r##"r#"single line"#"##]].assert_eq(&patch);
264 }
265
266 #[test]
267 fn test_patchwork() {
268 let mut patchwork = Patchwork::new("one two three".to_string());
269 patchwork.patch(4..7, "zwei");
270 patchwork.patch(0..3, "один");
271 patchwork.patch(8..13, "3");
272 expect![[r#"
273 Patchwork {
274 text: "один zwei 3",
275 indels: [
276 (
277 0..3,
278 8,
279 ),
280 (
281 4..7,
282 4,
283 ),
284 (
285 8..13,
286 1,
287 ),
288 ],
289 }
290 "#]]
291 .assert_debug_eq(&patchwork);
292 }
293}