aboutsummaryrefslogtreecommitdiff
path: root/crates
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
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')
-rw-r--r--crates/expect/Cargo.toml10
-rw-r--r--crates/expect/src/lib.rs293
-rw-r--r--crates/ra_ide/Cargo.toml1
-rw-r--r--crates/ra_ide/src/goto_definition.rs41
-rw-r--r--crates/rust-analyzer/src/handlers.rs25
-rw-r--r--crates/rust-analyzer/src/lsp_ext.rs2
-rw-r--r--crates/rust-analyzer/src/to_proto.rs1
7 files changed, 361 insertions, 12 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}
diff --git a/crates/ra_ide/Cargo.toml b/crates/ra_ide/Cargo.toml
index bbc6a5c9b..8e8892309 100644
--- a/crates/ra_ide/Cargo.toml
+++ b/crates/ra_ide/Cargo.toml
@@ -28,6 +28,7 @@ ra_cfg = { path = "../ra_cfg" }
28ra_fmt = { path = "../ra_fmt" } 28ra_fmt = { path = "../ra_fmt" }
29ra_prof = { path = "../ra_prof" } 29ra_prof = { path = "../ra_prof" }
30test_utils = { path = "../test_utils" } 30test_utils = { path = "../test_utils" }
31expect = { path = "../expect" }
31ra_assists = { path = "../ra_assists" } 32ra_assists = { path = "../ra_assists" }
32ra_ssr = { path = "../ra_ssr" } 33ra_ssr = { path = "../ra_ssr" }
33 34
diff --git a/crates/ra_ide/src/goto_definition.rs b/crates/ra_ide/src/goto_definition.rs
index bea7fbfa7..969d5e0ff 100644
--- a/crates/ra_ide/src/goto_definition.rs
+++ b/crates/ra_ide/src/goto_definition.rs
@@ -103,6 +103,7 @@ pub(crate) fn reference_definition(
103 103
104#[cfg(test)] 104#[cfg(test)]
105mod tests { 105mod tests {
106 use expect::{expect, Expect};
106 use test_utils::assert_eq_text; 107 use test_utils::assert_eq_text;
107 108
108 use crate::mock_analysis::analysis_and_position; 109 use crate::mock_analysis::analysis_and_position;
@@ -142,16 +143,40 @@ mod tests {
142 nav.assert_match(expected); 143 nav.assert_match(expected);
143 } 144 }
144 145
146 fn check(ra_fixture: &str, expect: Expect) {
147 let (analysis, pos) = analysis_and_position(ra_fixture);
148
149 let mut navs = analysis.goto_definition(pos).unwrap().unwrap().info;
150 if navs.len() == 0 {
151 panic!("unresolved reference")
152 }
153 assert_eq!(navs.len(), 1);
154
155 let nav = navs.pop().unwrap();
156 let file_text = analysis.file_text(nav.file_id()).unwrap();
157
158 let mut actual = nav.debug_render();
159 actual += "\n";
160 actual += &file_text[nav.full_range()].to_string();
161 if let Some(focus) = nav.focus_range() {
162 actual += "|";
163 actual += &file_text[focus];
164 actual += "\n";
165 }
166 expect.assert_eq(&actual);
167 }
168
145 #[test] 169 #[test]
146 fn goto_def_in_items() { 170 fn goto_def_in_items() {
147 check_goto( 171 check(
148 " 172 r#"
149 //- /lib.rs 173struct Foo;
150 struct Foo; 174enum E { X(Foo<|>) }
151 enum E { X(Foo<|>) } 175"#,
152 ", 176 expect![[r#"
153 "Foo STRUCT_DEF FileId(1) 0..11 7..10", 177 Foo STRUCT_DEF FileId(1) 0..11 7..10
154 "struct Foo;|Foo", 178 struct Foo;|Foo
179 "#]],
155 ); 180 );
156 } 181 }
157 182
diff --git a/crates/rust-analyzer/src/handlers.rs b/crates/rust-analyzer/src/handlers.rs
index e35a5e846..0940fcc28 100644
--- a/crates/rust-analyzer/src/handlers.rs
+++ b/crates/rust-analyzer/src/handlers.rs
@@ -23,7 +23,7 @@ use ra_ide::{
23}; 23};
24use ra_prof::profile; 24use ra_prof::profile;
25use ra_project_model::TargetKind; 25use ra_project_model::TargetKind;
26use ra_syntax::{AstNode, SyntaxKind, TextRange, TextSize}; 26use ra_syntax::{algo, ast, AstNode, SyntaxKind, TextRange, TextSize};
27use serde::{Deserialize, Serialize}; 27use serde::{Deserialize, Serialize};
28use serde_json::to_value; 28use serde_json::to_value;
29use stdx::{format_to, split_delim}; 29use stdx::{format_to, split_delim};
@@ -407,8 +407,19 @@ pub(crate) fn handle_runnables(
407 let file_id = from_proto::file_id(&snap, &params.text_document.uri)?; 407 let file_id = from_proto::file_id(&snap, &params.text_document.uri)?;
408 let line_index = snap.analysis.file_line_index(file_id)?; 408 let line_index = snap.analysis.file_line_index(file_id)?;
409 let offset = params.position.map(|it| from_proto::offset(&line_index, it)); 409 let offset = params.position.map(|it| from_proto::offset(&line_index, it));
410 let mut res = Vec::new();
411 let cargo_spec = CargoTargetSpec::for_file(&snap, file_id)?; 410 let cargo_spec = CargoTargetSpec::for_file(&snap, file_id)?;
411
412 let expect_test = match offset {
413 Some(offset) => {
414 let source_file = snap.analysis.parse(file_id)?;
415 algo::find_node_at_offset::<ast::MacroCall>(source_file.syntax(), offset)
416 .and_then(|it| it.path()?.segment()?.name_ref())
417 .map_or(false, |it| it.text() == "expect")
418 }
419 None => false,
420 };
421
422 let mut res = Vec::new();
412 for runnable in snap.analysis.runnables(file_id)? { 423 for runnable in snap.analysis.runnables(file_id)? {
413 if let Some(offset) = offset { 424 if let Some(offset) = offset {
414 if !runnable.nav.full_range().contains_inclusive(offset) { 425 if !runnable.nav.full_range().contains_inclusive(offset) {
@@ -418,8 +429,12 @@ pub(crate) fn handle_runnables(
418 if should_skip_target(&runnable, cargo_spec.as_ref()) { 429 if should_skip_target(&runnable, cargo_spec.as_ref()) {
419 continue; 430 continue;
420 } 431 }
421 432 let mut runnable = to_proto::runnable(&snap, file_id, runnable)?;
422 res.push(to_proto::runnable(&snap, file_id, runnable)?); 433 if expect_test {
434 runnable.label = format!("{} + expect", runnable.label);
435 runnable.args.expect_test = Some(true);
436 }
437 res.push(runnable);
423 } 438 }
424 439
425 // Add `cargo check` and `cargo test` for the whole package 440 // Add `cargo check` and `cargo test` for the whole package
@@ -438,6 +453,7 @@ pub(crate) fn handle_runnables(
438 spec.package.clone(), 453 spec.package.clone(),
439 ], 454 ],
440 executable_args: Vec::new(), 455 executable_args: Vec::new(),
456 expect_test: None,
441 }, 457 },
442 }) 458 })
443 } 459 }
@@ -451,6 +467,7 @@ pub(crate) fn handle_runnables(
451 workspace_root: None, 467 workspace_root: None,
452 cargo_args: vec!["check".to_string(), "--workspace".to_string()], 468 cargo_args: vec!["check".to_string(), "--workspace".to_string()],
453 executable_args: Vec::new(), 469 executable_args: Vec::new(),
470 expect_test: None,
454 }, 471 },
455 }); 472 });
456 } 473 }
diff --git a/crates/rust-analyzer/src/lsp_ext.rs b/crates/rust-analyzer/src/lsp_ext.rs
index 1371f6cb4..1befe678c 100644
--- a/crates/rust-analyzer/src/lsp_ext.rs
+++ b/crates/rust-analyzer/src/lsp_ext.rs
@@ -161,6 +161,8 @@ pub struct CargoRunnable {
161 pub cargo_args: Vec<String>, 161 pub cargo_args: Vec<String>,
162 // stuff after -- 162 // stuff after --
163 pub executable_args: Vec<String>, 163 pub executable_args: Vec<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub expect_test: Option<bool>,
164} 166}
165 167
166pub enum InlayHints {} 168pub enum InlayHints {}
diff --git a/crates/rust-analyzer/src/to_proto.rs b/crates/rust-analyzer/src/to_proto.rs
index f6cb8e4bb..a03222ae9 100644
--- a/crates/rust-analyzer/src/to_proto.rs
+++ b/crates/rust-analyzer/src/to_proto.rs
@@ -666,6 +666,7 @@ pub(crate) fn runnable(
666 workspace_root: workspace_root.map(|it| it.into()), 666 workspace_root: workspace_root.map(|it| it.into()),
667 cargo_args, 667 cargo_args,
668 executable_args, 668 executable_args,
669 expect_test: None,
669 }, 670 },
670 }) 671 })
671} 672}