aboutsummaryrefslogtreecommitdiff
path: root/crates/expect/src
diff options
context:
space:
mode:
authorAleksey Kladov <[email protected]>2020-06-27 16:53:50 +0100
committerAleksey Kladov <[email protected]>2020-06-27 18:22:31 +0100
commit03c5a6690d943e48ac5b5464c2ac2fd054ea6251 (patch)
treeffb971e1d7647f9c72a24210f63b265ebae23c23 /crates/expect/src
parent491d000c27676305cc7d5d734d4476cf731b7940 (diff)
Add light-weight snapshot testing library with editor integration
Diffstat (limited to 'crates/expect/src')
-rw-r--r--crates/expect/src/lib.rs308
1 files changed, 308 insertions, 0 deletions
diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs
new file mode 100644
index 000000000..08d0eafdf
--- /dev/null
+++ b/crates/expect/src/lib.rs
@@ -0,0 +1,308 @@
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 path::{Path, PathBuf},
8 sync::Mutex,
9};
10
11use once_cell::sync::Lazy;
12use stdx::{lines_with_ends, trim_indent};
13
14const HELP: &str = "
15You can update all `expect![[]]` tests by:
16
17 env UPDATE_EXPECT=1 cargo test
18
19To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer.
20";
21
22fn update_expect() -> bool {
23 env::var("UPDATE_EXPECT").is_ok()
24}
25
26/// expect![[""]]
27#[macro_export]
28macro_rules! expect {
29 [[$lit:literal]] => {$crate::Expect {
30 file: file!(),
31 line: line!(),
32 column: column!(),
33 data: $lit,
34 }};
35 [[]] => { $crate::expect![[""]] };
36}
37
38#[derive(Debug)]
39pub struct Expect {
40 pub file: &'static str,
41 pub line: u32,
42 pub column: u32,
43 pub data: &'static str,
44}
45
46impl Expect {
47 pub fn assert_eq(&self, actual: &str) {
48 let trimmed = self.trimmed();
49 if &trimmed == actual {
50 return;
51 }
52 Runtime::fail(self, &trimmed, actual);
53 }
54 pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) {
55 let actual = format!("{:#?}\n", actual);
56 self.assert_eq(&actual)
57 }
58
59 fn trimmed(&self) -> String {
60 if !self.data.contains('\n') {
61 return self.data.to_string();
62 }
63 trim_indent(self.data)
64 }
65
66 fn locate(&self, file: &str) -> Location {
67 let mut target_line = None;
68 let mut line_start = 0;
69 for (i, line) in lines_with_ends(file).enumerate() {
70 if i == self.line as usize - 1 {
71 let pat = "expect![[";
72 let offset = line.find(pat).unwrap();
73 let literal_start = line_start + offset + pat.len();
74 let indent = line.chars().take_while(|&it| it == ' ').count();
75 target_line = Some((literal_start, indent));
76 break;
77 }
78 line_start += line.len();
79 }
80 let (literal_start, line_indent) = target_line.unwrap();
81 let literal_length = file[literal_start..].find("]]").unwrap();
82 let literal_range = literal_start..literal_start + literal_length;
83 Location { line_indent, literal_range }
84 }
85}
86
87#[derive(Default)]
88struct Runtime {
89 help_printed: bool,
90 per_file: HashMap<&'static str, FileRuntime>,
91}
92static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default);
93
94impl Runtime {
95 fn fail(expect: &Expect, expected: &str, actual: &str) {
96 let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
97 let mut updated = "";
98 if update_expect() {
99 updated = " (updated)";
100 rt.per_file
101 .entry(expect.file)
102 .or_insert_with(|| FileRuntime::new(expect))
103 .update(expect, actual);
104 }
105 let print_help = !rt.help_printed && !update_expect();
106 rt.help_printed = true;
107
108 let help = if print_help { HELP } else { "" };
109 panic!(
110 "\n
111error: expect test failed{}
112 --> {}:{}:{}
113{}
114Expect:
115----
116{}
117----
118
119Actual:
120----
121{}
122----
123",
124 updated, expect.file, expect.line, expect.column, help, expected, actual
125 )
126 }
127}
128
129struct FileRuntime {
130 path: PathBuf,
131 original_text: String,
132 patchwork: Patchwork,
133}
134
135impl FileRuntime {
136 fn new(expect: &Expect) -> FileRuntime {
137 let path = workspace_root().join(expect.file);
138 let original_text = fs::read_to_string(&path).unwrap();
139 let patchwork = Patchwork::new(original_text.clone());
140 FileRuntime { path, original_text, patchwork }
141 }
142 fn update(&mut self, expect: &Expect, actual: &str) {
143 let loc = expect.locate(&self.original_text);
144 let patch = format_patch(loc.line_indent.clone(), actual);
145 self.patchwork.patch(loc.literal_range, &patch);
146 fs::write(&self.path, &self.patchwork.text).unwrap()
147 }
148}
149
150#[derive(Debug)]
151struct Location {
152 line_indent: usize,
153 literal_range: Range<usize>,
154}
155
156#[derive(Debug)]
157struct Patchwork {
158 text: String,
159 indels: Vec<(Range<usize>, usize)>,
160}
161
162impl Patchwork {
163 fn new(text: String) -> Patchwork {
164 Patchwork { text, indels: Vec::new() }
165 }
166 fn patch(&mut self, mut range: Range<usize>, patch: &str) {
167 self.indels.push((range.clone(), patch.len()));
168 self.indels.sort_by_key(|(delete, _insert)| delete.start);
169
170 let (delete, insert) = self
171 .indels
172 .iter()
173 .take_while(|(delete, _)| delete.start < range.start)
174 .map(|(delete, insert)| (delete.end - delete.start, insert))
175 .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2));
176
177 for pos in &mut [&mut range.start, &mut range.end] {
178 **pos += insert;
179 **pos -= delete
180 }
181
182 self.text.replace_range(range, &patch);
183 }
184}
185
186fn format_patch(line_indent: usize, patch: &str) -> String {
187 let mut max_hashes = 0;
188 let mut cur_hashes = 0;
189 for byte in patch.bytes() {
190 if byte != b'#' {
191 cur_hashes = 0;
192 continue;
193 }
194 cur_hashes += 1;
195 max_hashes = max_hashes.max(cur_hashes);
196 }
197 let hashes = &"#".repeat(max_hashes + 1);
198 let indent = &" ".repeat(line_indent);
199 let is_multiline = patch.contains('\n');
200
201 let mut buf = String::new();
202 buf.push('r');
203 buf.push_str(hashes);
204 buf.push('"');
205 if is_multiline {
206 buf.push('\n');
207 }
208 let mut final_newline = false;
209 for line in lines_with_ends(patch) {
210 if is_multiline {
211 buf.push_str(indent);
212 buf.push_str(" ");
213 }
214 buf.push_str(line);
215 final_newline = line.ends_with('\n');
216 }
217 if final_newline {
218 buf.push_str(indent);
219 }
220 buf.push('"');
221 buf.push_str(hashes);
222 buf
223}
224
225fn workspace_root() -> PathBuf {
226 Path::new(
227 &env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()),
228 )
229 .ancestors()
230 .nth(2)
231 .unwrap()
232 .to_path_buf()
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_expect_macro() {
241 let empty = expect![[]];
242 expect![[r#"
243 Expect {
244 file: "crates/expect/src/lib.rs",
245 line: 241,
246 column: 21,
247 data: "",
248 }
249 "#]]
250 .assert_debug_eq(&empty);
251
252 let expect = expect![["
253 hello
254 world
255 "]];
256 expect![[r#"
257 Expect {
258 file: "crates/expect/src/lib.rs",
259 line: 252,
260 column: 22,
261 data: "\n hello\n world\n ",
262 }
263 "#]]
264 .assert_debug_eq(&expect);
265 }
266
267 #[test]
268 fn test_format_patch() {
269 let patch = format_patch(0, "hello\nworld\n");
270 expect![[r##"
271 r#"
272 hello
273 world
274 "#"##]]
275 .assert_eq(&patch);
276
277 let patch = format_patch(4, "single line");
278 expect![[r##"r#"single line"#"##]].assert_eq(&patch);
279 }
280
281 #[test]
282 fn test_patchwork() {
283 let mut patchwork = Patchwork::new("one two three".to_string());
284 patchwork.patch(4..7, "zwei");
285 patchwork.patch(0..3, "один");
286 patchwork.patch(8..13, "3");
287 expect![[r#"
288 Patchwork {
289 text: "один zwei 3",
290 indels: [
291 (
292 0..3,
293 8,
294 ),
295 (
296 4..7,
297 4,
298 ),
299 (
300 8..13,
301 1,
302 ),
303 ],
304 }
305 "#]]
306 .assert_debug_eq(&patchwork);
307 }
308}