Skip to content

Commit 03c5a66

Browse files
committed
Add light-weight snapshot testing library with editor integration
1 parent 491d000 commit 03c5a66

File tree

8 files changed

+357
-5
lines changed

8 files changed

+357
-5
lines changed

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/expect/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
stdx = { path = "../stdx" }

crates/expect/src/lib.rs

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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,
6+
ops::Range,
7+
path::{Path, PathBuf},
8+
sync::Mutex,
9+
};
10+
11+
use once_cell::sync::Lazy;
12+
use stdx::{lines_with_ends, trim_indent};
13+
14+
const HELP: &str = "
15+
You can update all `expect![[]]` tests by:
16+
17+
env UPDATE_EXPECT=1 cargo test
18+
19+
To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer.
20+
";
21+
22+
fn update_expect() -> bool {
23+
env::var("UPDATE_EXPECT").is_ok()
24+
}
25+
26+
/// expect![[""]]
27+
#[macro_export]
28+
macro_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)]
39+
pub struct Expect {
40+
pub file: &'static str,
41+
pub line: u32,
42+
pub column: u32,
43+
pub data: &'static str,
44+
}
45+
46+
impl 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)]
88+
struct Runtime {
89+
help_printed: bool,
90+
per_file: HashMap<&'static str, FileRuntime>,
91+
}
92+
static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default);
93+
94+
impl 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
111+
error: expect test failed{}
112+
--> {}:{}:{}
113+
{}
114+
Expect:
115+
----
116+
{}
117+
----
118+
119+
Actual:
120+
----
121+
{}
122+
----
123+
",
124+
updated, expect.file, expect.line, expect.column, help, expected, actual
125+
)
126+
}
127+
}
128+
129+
struct FileRuntime {
130+
path: PathBuf,
131+
original_text: String,
132+
patchwork: Patchwork,
133+
}
134+
135+
impl 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)]
151+
struct Location {
152+
line_indent: usize,
153+
literal_range: Range<usize>,
154+
}
155+
156+
#[derive(Debug)]
157+
struct Patchwork {
158+
text: String,
159+
indels: Vec<(Range<usize>, usize)>,
160+
}
161+
162+
impl 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+
186+
fn 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+
225+
fn 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)]
236+
mod 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+
}

crates/rust-analyzer/src/handlers.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use ra_ide::{
2323
};
2424
use ra_prof::profile;
2525
use ra_project_model::TargetKind;
26-
use ra_syntax::{AstNode, SyntaxKind, TextRange, TextSize};
26+
use ra_syntax::{algo, ast, AstNode, SyntaxKind, TextRange, TextSize};
2727
use serde::{Deserialize, Serialize};
2828
use serde_json::to_value;
2929
use stdx::{format_to, split_delim};
@@ -407,8 +407,21 @@ pub(crate) fn handle_runnables(
407407
let file_id = from_proto::file_id(&snap, &params.text_document.uri)?;
408408
let line_index = snap.analysis.file_line_index(file_id)?;
409409
let offset = params.position.map(|it| from_proto::offset(&line_index, it));
410-
let mut res = Vec::new();
411410
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())
417+
.and_then(|it| it.segment())
418+
.and_then(|it| it.name_ref())
419+
.map_or(false, |it| it.text() == "expect")
420+
}
421+
None => false,
422+
};
423+
424+
let mut res = Vec::new();
412425
for runnable in snap.analysis.runnables(file_id)? {
413426
if let Some(offset) = offset {
414427
if !runnable.nav.full_range().contains_inclusive(offset) {
@@ -418,8 +431,12 @@ pub(crate) fn handle_runnables(
418431
if should_skip_target(&runnable, cargo_spec.as_ref()) {
419432
continue;
420433
}
421-
422-
res.push(to_proto::runnable(&snap, file_id, runnable)?);
434+
let mut runnable = to_proto::runnable(&snap, file_id, runnable)?;
435+
if expect_test {
436+
runnable.label = format!("{} + expect", runnable.label);
437+
runnable.args.expect_test = Some(true);
438+
}
439+
res.push(runnable);
423440
}
424441

425442
// Add `cargo check` and `cargo test` for the whole package
@@ -438,6 +455,7 @@ pub(crate) fn handle_runnables(
438455
spec.package.clone(),
439456
],
440457
executable_args: Vec::new(),
458+
expect_test: None,
441459
},
442460
})
443461
}
@@ -451,6 +469,7 @@ pub(crate) fn handle_runnables(
451469
workspace_root: None,
452470
cargo_args: vec!["check".to_string(), "--workspace".to_string()],
453471
executable_args: Vec::new(),
472+
expect_test: None,
454473
},
455474
});
456475
}

0 commit comments

Comments
 (0)