diff options
Diffstat (limited to 'crates/expect')
-rw-r--r-- | crates/expect/Cargo.toml | 10 | ||||
-rw-r--r-- | crates/expect/src/lib.rs | 348 |
2 files changed, 358 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] | ||
2 | name = "expect" | ||
3 | version = "0.1.0" | ||
4 | authors = ["rust-analyzer developers"] | ||
5 | edition = "2018" | ||
6 | |||
7 | [dependencies] | ||
8 | once_cell = "1" | ||
9 | difference = "2" | ||
10 | stdx = { path = "../stdx" } | ||
diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs new file mode 100644 index 000000000..a5e26fade --- /dev/null +++ b/crates/expect/src/lib.rs | |||
@@ -0,0 +1,348 @@ | |||
1 | //! Snapshot testing library, see | ||
2 | //! https://github.com/rust-analyzer/rust-analyzer/pull/5101 | ||
3 | use std::{ | ||
4 | collections::HashMap, | ||
5 | env, fmt, fs, mem, | ||
6 | ops::Range, | ||
7 | panic, | ||
8 | path::{Path, PathBuf}, | ||
9 | sync::Mutex, | ||
10 | }; | ||
11 | |||
12 | use difference::Changeset; | ||
13 | use once_cell::sync::Lazy; | ||
14 | use stdx::{lines_with_ends, trim_indent}; | ||
15 | |||
16 | const HELP: &str = " | ||
17 | You can update all `expect![[]]` tests by running: | ||
18 | |||
19 | env UPDATE_EXPECT=1 cargo test | ||
20 | |||
21 | To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer. | ||
22 | "; | ||
23 | |||
24 | fn update_expect() -> bool { | ||
25 | env::var("UPDATE_EXPECT").is_ok() | ||
26 | } | ||
27 | |||
28 | /// expect![[r#"inline snapshot"#]] | ||
29 | #[macro_export] | ||
30 | macro_rules! expect { | ||
31 | [[$data:literal]] => {$crate::Expect { | ||
32 | position: $crate::Position { | ||
33 | file: file!(), | ||
34 | line: line!(), | ||
35 | column: column!(), | ||
36 | }, | ||
37 | data: $data, | ||
38 | }}; | ||
39 | [[]] => { $crate::expect![[""]] }; | ||
40 | } | ||
41 | |||
42 | /// expect_file!["/crates/foo/test_data/bar.html"] | ||
43 | #[macro_export] | ||
44 | macro_rules! expect_file { | ||
45 | [$path:literal] => {$crate::ExpectFile { path: $path }}; | ||
46 | } | ||
47 | |||
48 | #[derive(Debug)] | ||
49 | pub struct Expect { | ||
50 | pub position: Position, | ||
51 | pub data: &'static str, | ||
52 | } | ||
53 | |||
54 | #[derive(Debug)] | ||
55 | pub struct ExpectFile { | ||
56 | pub path: &'static str, | ||
57 | } | ||
58 | |||
59 | #[derive(Debug)] | ||
60 | pub struct Position { | ||
61 | pub file: &'static str, | ||
62 | pub line: u32, | ||
63 | pub column: u32, | ||
64 | } | ||
65 | |||
66 | impl fmt::Display for Position { | ||
67 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
68 | write!(f, "{}:{}:{}", self.file, self.line, self.column) | ||
69 | } | ||
70 | } | ||
71 | |||
72 | impl Expect { | ||
73 | pub fn assert_eq(&self, actual: &str) { | ||
74 | let trimmed = self.trimmed(); | ||
75 | if &trimmed == actual { | ||
76 | return; | ||
77 | } | ||
78 | Runtime::fail_expect(self, &trimmed, actual); | ||
79 | } | ||
80 | pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) { | ||
81 | let actual = format!("{:#?}\n", actual); | ||
82 | self.assert_eq(&actual) | ||
83 | } | ||
84 | |||
85 | fn trimmed(&self) -> String { | ||
86 | if !self.data.contains('\n') { | ||
87 | return self.data.to_string(); | ||
88 | } | ||
89 | trim_indent(self.data) | ||
90 | } | ||
91 | |||
92 | fn locate(&self, file: &str) -> Location { | ||
93 | let mut target_line = None; | ||
94 | let mut line_start = 0; | ||
95 | for (i, line) in lines_with_ends(file).enumerate() { | ||
96 | if i == self.position.line as usize - 1 { | ||
97 | let pat = "expect![["; | ||
98 | let offset = line.find(pat).unwrap(); | ||
99 | let literal_start = line_start + offset + pat.len(); | ||
100 | let indent = line.chars().take_while(|&it| it == ' ').count(); | ||
101 | target_line = Some((literal_start, indent)); | ||
102 | break; | ||
103 | } | ||
104 | line_start += line.len(); | ||
105 | } | ||
106 | let (literal_start, line_indent) = target_line.unwrap(); | ||
107 | let literal_length = | ||
108 | file[literal_start..].find("]]").expect("Couldn't find matching `]]` for `expect![[`."); | ||
109 | let literal_range = literal_start..literal_start + literal_length; | ||
110 | Location { line_indent, literal_range } | ||
111 | } | ||
112 | } | ||
113 | |||
114 | impl ExpectFile { | ||
115 | pub fn assert_eq(&self, actual: &str) { | ||
116 | let expected = self.read(); | ||
117 | if actual == expected { | ||
118 | return; | ||
119 | } | ||
120 | Runtime::fail_file(self, &expected, actual); | ||
121 | } | ||
122 | fn read(&self) -> String { | ||
123 | fs::read_to_string(self.abs_path()).unwrap_or_default().replace("\r\n", "\n") | ||
124 | } | ||
125 | fn write(&self, contents: &str) { | ||
126 | fs::write(self.abs_path(), contents).unwrap() | ||
127 | } | ||
128 | fn abs_path(&self) -> PathBuf { | ||
129 | workspace_root().join(self.path) | ||
130 | } | ||
131 | } | ||
132 | |||
133 | #[derive(Default)] | ||
134 | struct Runtime { | ||
135 | help_printed: bool, | ||
136 | per_file: HashMap<&'static str, FileRuntime>, | ||
137 | } | ||
138 | static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default); | ||
139 | |||
140 | impl Runtime { | ||
141 | fn fail_expect(expect: &Expect, expected: &str, actual: &str) { | ||
142 | let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
143 | if update_expect() { | ||
144 | println!("\x1b[1m\x1b[92mupdating\x1b[0m: {}", expect.position); | ||
145 | rt.per_file | ||
146 | .entry(expect.position.file) | ||
147 | .or_insert_with(|| FileRuntime::new(expect)) | ||
148 | .update(expect, actual); | ||
149 | return; | ||
150 | } | ||
151 | rt.panic(expect.position.to_string(), expected, actual); | ||
152 | } | ||
153 | |||
154 | fn fail_file(expect: &ExpectFile, expected: &str, actual: &str) { | ||
155 | let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
156 | if update_expect() { | ||
157 | println!("\x1b[1m\x1b[92mupdating\x1b[0m: {}", expect.path); | ||
158 | expect.write(actual); | ||
159 | return; | ||
160 | } | ||
161 | rt.panic(expect.path.to_string(), expected, actual); | ||
162 | } | ||
163 | |||
164 | fn panic(&mut self, position: String, expected: &str, actual: &str) { | ||
165 | let print_help = !mem::replace(&mut self.help_printed, true); | ||
166 | let help = if print_help { HELP } else { "" }; | ||
167 | |||
168 | let diff = Changeset::new(actual, expected, "\n"); | ||
169 | |||
170 | println!( | ||
171 | "\n | ||
172 | \x1b[1m\x1b[91merror\x1b[97m: expect test failed\x1b[0m | ||
173 | \x1b[1m\x1b[34m-->\x1b[0m {} | ||
174 | {} | ||
175 | \x1b[1mExpect\x1b[0m: | ||
176 | ---- | ||
177 | {} | ||
178 | ---- | ||
179 | |||
180 | \x1b[1mActual\x1b[0m: | ||
181 | ---- | ||
182 | {} | ||
183 | ---- | ||
184 | |||
185 | \x1b[1mDiff\x1b[0m: | ||
186 | ---- | ||
187 | {} | ||
188 | ---- | ||
189 | ", | ||
190 | position, help, expected, actual, diff | ||
191 | ); | ||
192 | // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise. | ||
193 | panic::resume_unwind(Box::new(())); | ||
194 | } | ||
195 | } | ||
196 | |||
197 | struct FileRuntime { | ||
198 | path: PathBuf, | ||
199 | original_text: String, | ||
200 | patchwork: Patchwork, | ||
201 | } | ||
202 | |||
203 | impl FileRuntime { | ||
204 | fn new(expect: &Expect) -> FileRuntime { | ||
205 | let path = workspace_root().join(expect.position.file); | ||
206 | let original_text = fs::read_to_string(&path).unwrap(); | ||
207 | let patchwork = Patchwork::new(original_text.clone()); | ||
208 | FileRuntime { path, original_text, patchwork } | ||
209 | } | ||
210 | fn update(&mut self, expect: &Expect, actual: &str) { | ||
211 | let loc = expect.locate(&self.original_text); | ||
212 | let patch = format_patch(loc.line_indent.clone(), actual); | ||
213 | self.patchwork.patch(loc.literal_range, &patch); | ||
214 | fs::write(&self.path, &self.patchwork.text).unwrap() | ||
215 | } | ||
216 | } | ||
217 | |||
218 | #[derive(Debug)] | ||
219 | struct Location { | ||
220 | line_indent: usize, | ||
221 | literal_range: Range<usize>, | ||
222 | } | ||
223 | |||
224 | #[derive(Debug)] | ||
225 | struct Patchwork { | ||
226 | text: String, | ||
227 | indels: Vec<(Range<usize>, usize)>, | ||
228 | } | ||
229 | |||
230 | impl Patchwork { | ||
231 | fn new(text: String) -> Patchwork { | ||
232 | Patchwork { text, indels: Vec::new() } | ||
233 | } | ||
234 | fn patch(&mut self, mut range: Range<usize>, patch: &str) { | ||
235 | self.indels.push((range.clone(), patch.len())); | ||
236 | self.indels.sort_by_key(|(delete, _insert)| delete.start); | ||
237 | |||
238 | let (delete, insert) = self | ||
239 | .indels | ||
240 | .iter() | ||
241 | .take_while(|(delete, _)| delete.start < range.start) | ||
242 | .map(|(delete, insert)| (delete.end - delete.start, insert)) | ||
243 | .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2)); | ||
244 | |||
245 | for pos in &mut [&mut range.start, &mut range.end] { | ||
246 | **pos -= delete; | ||
247 | **pos += insert; | ||
248 | } | ||
249 | |||
250 | self.text.replace_range(range, &patch); | ||
251 | } | ||
252 | } | ||
253 | |||
254 | fn format_patch(line_indent: usize, patch: &str) -> String { | ||
255 | let mut max_hashes = 0; | ||
256 | let mut cur_hashes = 0; | ||
257 | for byte in patch.bytes() { | ||
258 | if byte != b'#' { | ||
259 | cur_hashes = 0; | ||
260 | continue; | ||
261 | } | ||
262 | cur_hashes += 1; | ||
263 | max_hashes = max_hashes.max(cur_hashes); | ||
264 | } | ||
265 | let hashes = &"#".repeat(max_hashes + 1); | ||
266 | let indent = &" ".repeat(line_indent); | ||
267 | let is_multiline = patch.contains('\n'); | ||
268 | |||
269 | let mut buf = String::new(); | ||
270 | buf.push('r'); | ||
271 | buf.push_str(hashes); | ||
272 | buf.push('"'); | ||
273 | if is_multiline { | ||
274 | buf.push('\n'); | ||
275 | } | ||
276 | let mut final_newline = false; | ||
277 | for line in lines_with_ends(patch) { | ||
278 | if is_multiline { | ||
279 | buf.push_str(indent); | ||
280 | buf.push_str(" "); | ||
281 | } | ||
282 | buf.push_str(line); | ||
283 | final_newline = line.ends_with('\n'); | ||
284 | } | ||
285 | if final_newline { | ||
286 | buf.push_str(indent); | ||
287 | } | ||
288 | buf.push('"'); | ||
289 | buf.push_str(hashes); | ||
290 | buf | ||
291 | } | ||
292 | |||
293 | fn workspace_root() -> PathBuf { | ||
294 | Path::new( | ||
295 | &env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()), | ||
296 | ) | ||
297 | .ancestors() | ||
298 | .nth(2) | ||
299 | .unwrap() | ||
300 | .to_path_buf() | ||
301 | } | ||
302 | |||
303 | #[cfg(test)] | ||
304 | mod tests { | ||
305 | use super::*; | ||
306 | |||
307 | #[test] | ||
308 | fn test_format_patch() { | ||
309 | let patch = format_patch(0, "hello\nworld\n"); | ||
310 | expect![[r##" | ||
311 | r#" | ||
312 | hello | ||
313 | world | ||
314 | "#"##]] | ||
315 | .assert_eq(&patch); | ||
316 | |||
317 | let patch = format_patch(4, "single line"); | ||
318 | expect![[r##"r#"single line"#"##]].assert_eq(&patch); | ||
319 | } | ||
320 | |||
321 | #[test] | ||
322 | fn test_patchwork() { | ||
323 | let mut patchwork = Patchwork::new("one two three".to_string()); | ||
324 | patchwork.patch(4..7, "zwei"); | ||
325 | patchwork.patch(0..3, "один"); | ||
326 | patchwork.patch(8..13, "3"); | ||
327 | expect![[r#" | ||
328 | Patchwork { | ||
329 | text: "один zwei 3", | ||
330 | indels: [ | ||
331 | ( | ||
332 | 0..3, | ||
333 | 8, | ||
334 | ), | ||
335 | ( | ||
336 | 4..7, | ||
337 | 4, | ||
338 | ), | ||
339 | ( | ||
340 | 8..13, | ||
341 | 1, | ||
342 | ), | ||
343 | ], | ||
344 | } | ||
345 | "#]] | ||
346 | .assert_debug_eq(&patchwork); | ||
347 | } | ||
348 | } | ||