aboutsummaryrefslogtreecommitdiff
path: root/crates/expect
diff options
context:
space:
mode:
Diffstat (limited to 'crates/expect')
-rw-r--r--crates/expect/Cargo.toml10
-rw-r--r--crates/expect/src/lib.rs348
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]
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..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
3use std::{
4 collections::HashMap,
5 env, fmt, fs, mem,
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 running:
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![[r#"inline snapshot"#]]
29#[macro_export]
30macro_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]
44macro_rules! expect_file {
45 [$path:literal] => {$crate::ExpectFile { path: $path }};
46}
47
48#[derive(Debug)]
49pub struct Expect {
50 pub position: Position,
51 pub data: &'static str,
52}
53
54#[derive(Debug)]
55pub struct ExpectFile {
56 pub path: &'static str,
57}
58
59#[derive(Debug)]
60pub struct Position {
61 pub file: &'static str,
62 pub line: u32,
63 pub column: u32,
64}
65
66impl 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
72impl 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
114impl 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)]
134struct Runtime {
135 help_printed: bool,
136 per_file: HashMap<&'static str, FileRuntime>,
137}
138static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default);
139
140impl 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
197struct FileRuntime {
198 path: PathBuf,
199 original_text: String,
200 patchwork: Patchwork,
201}
202
203impl 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)]
219struct Location {
220 line_indent: usize,
221 literal_range: Range<usize>,
222}
223
224#[derive(Debug)]
225struct Patchwork {
226 text: String,
227 indels: Vec<(Range<usize>, usize)>,
228}
229
230impl 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
254fn 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
293fn 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)]
304mod 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}