aboutsummaryrefslogtreecommitdiff
path: root/crates/expect/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/expect/src/lib.rs')
-rw-r--r--crates/expect/src/lib.rs293
1 files changed, 293 insertions, 0 deletions
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}