]> git.lizzy.rs Git - rust.git/blob - crates/test_utils/src/lib.rs
fix: improve parameter completion
[rust.git] / crates / test_utils / src / lib.rs
1 //! Assorted testing utilities.
2 //!
3 //! Most notable things are:
4 //!
5 //! * Rich text comparison, which outputs a diff.
6 //! * Extracting markup (mainly, `$0` markers) out of fixture strings.
7 //! * marks (see the eponymous module).
8
9 pub mod bench_fixture;
10 mod fixture;
11 mod assert_linear;
12
13 use std::{
14     collections::BTreeMap,
15     env, fs,
16     path::{Path, PathBuf},
17 };
18
19 use profile::StopWatch;
20 use stdx::is_ci;
21 use text_size::{TextRange, TextSize};
22
23 pub use dissimilar::diff as __diff;
24 pub use rustc_hash::FxHashMap;
25
26 pub use crate::{
27     assert_linear::AssertLinear,
28     fixture::{Fixture, MiniCore},
29 };
30
31 pub const CURSOR_MARKER: &str = "$0";
32 pub const ESCAPED_CURSOR_MARKER: &str = "\\$0";
33
34 /// Asserts that two strings are equal, otherwise displays a rich diff between them.
35 ///
36 /// The diff shows changes from the "original" left string to the "actual" right string.
37 ///
38 /// All arguments starting from and including the 3rd one are passed to
39 /// `eprintln!()` macro in case of text inequality.
40 #[macro_export]
41 macro_rules! assert_eq_text {
42     ($left:expr, $right:expr) => {
43         assert_eq_text!($left, $right,)
44     };
45     ($left:expr, $right:expr, $($tt:tt)*) => {{
46         let left = $left;
47         let right = $right;
48         if left != right {
49             if left.trim() == right.trim() {
50                 std::eprintln!("Left:\n{:?}\n\nRight:\n{:?}\n\nWhitespace difference\n", left, right);
51             } else {
52                 let diff = $crate::__diff(left, right);
53                 std::eprintln!("Left:\n{}\n\nRight:\n{}\n\nDiff:\n{}\n", left, right, $crate::format_diff(diff));
54             }
55             std::eprintln!($($tt)*);
56             panic!("text differs");
57         }
58     }};
59 }
60
61 /// Infallible version of `try_extract_offset()`.
62 pub fn extract_offset(text: &str) -> (TextSize, String) {
63     match try_extract_offset(text) {
64         None => panic!("text should contain cursor marker"),
65         Some(result) => result,
66     }
67 }
68
69 /// Returns the offset of the first occurrence of `$0` marker and the copy of `text`
70 /// without the marker.
71 fn try_extract_offset(text: &str) -> Option<(TextSize, String)> {
72     let cursor_pos = text.find(CURSOR_MARKER)?;
73     let mut new_text = String::with_capacity(text.len() - CURSOR_MARKER.len());
74     new_text.push_str(&text[..cursor_pos]);
75     new_text.push_str(&text[cursor_pos + CURSOR_MARKER.len()..]);
76     let cursor_pos = TextSize::from(cursor_pos as u32);
77     Some((cursor_pos, new_text))
78 }
79
80 /// Infallible version of `try_extract_range()`.
81 pub fn extract_range(text: &str) -> (TextRange, String) {
82     match try_extract_range(text) {
83         None => panic!("text should contain cursor marker"),
84         Some(result) => result,
85     }
86 }
87
88 /// Returns `TextRange` between the first two markers `$0...$0` and the copy
89 /// of `text` without both of these markers.
90 fn try_extract_range(text: &str) -> Option<(TextRange, String)> {
91     let (start, text) = try_extract_offset(text)?;
92     let (end, text) = try_extract_offset(&text)?;
93     Some((TextRange::new(start, end), text))
94 }
95
96 #[derive(Clone, Copy)]
97 pub enum RangeOrOffset {
98     Range(TextRange),
99     Offset(TextSize),
100 }
101
102 impl RangeOrOffset {
103     pub fn expect_offset(self) -> TextSize {
104         match self {
105             RangeOrOffset::Offset(it) => it,
106             RangeOrOffset::Range(_) => panic!("expected an offset but got a range instead"),
107         }
108     }
109     pub fn expect_range(self) -> TextRange {
110         match self {
111             RangeOrOffset::Range(it) => it,
112             RangeOrOffset::Offset(_) => panic!("expected a range but got an offset"),
113         }
114     }
115     pub fn range_or_empty(self) -> TextRange {
116         match self {
117             RangeOrOffset::Range(range) => range,
118             RangeOrOffset::Offset(offset) => TextRange::empty(offset),
119         }
120     }
121 }
122
123 impl From<RangeOrOffset> for TextRange {
124     fn from(selection: RangeOrOffset) -> Self {
125         match selection {
126             RangeOrOffset::Range(it) => it,
127             RangeOrOffset::Offset(it) => TextRange::empty(it),
128         }
129     }
130 }
131
132 /// Extracts `TextRange` or `TextSize` depending on the amount of `$0` markers
133 /// found in `text`.
134 ///
135 /// # Panics
136 /// Panics if no `$0` marker is present in the `text`.
137 pub fn extract_range_or_offset(text: &str) -> (RangeOrOffset, String) {
138     if let Some((range, text)) = try_extract_range(text) {
139         return (RangeOrOffset::Range(range), text);
140     }
141     let (offset, text) = extract_offset(text);
142     (RangeOrOffset::Offset(offset), text)
143 }
144
145 /// Extracts ranges, marked with `<tag> </tag>` pairs from the `text`
146 pub fn extract_tags(mut text: &str, tag: &str) -> (Vec<(TextRange, Option<String>)>, String) {
147     let open = format!("<{}", tag);
148     let close = format!("</{}>", tag);
149     let mut ranges = Vec::new();
150     let mut res = String::new();
151     let mut stack = Vec::new();
152     loop {
153         match text.find('<') {
154             None => {
155                 res.push_str(text);
156                 break;
157             }
158             Some(i) => {
159                 res.push_str(&text[..i]);
160                 text = &text[i..];
161                 if text.starts_with(&open) {
162                     let close_open = text.find('>').unwrap();
163                     let attr = text[open.len()..close_open].trim();
164                     let attr = if attr.is_empty() { None } else { Some(attr.to_string()) };
165                     text = &text[close_open + '>'.len_utf8()..];
166                     let from = TextSize::of(&res);
167                     stack.push((from, attr));
168                 } else if text.starts_with(&close) {
169                     text = &text[close.len()..];
170                     let (from, attr) =
171                         stack.pop().unwrap_or_else(|| panic!("unmatched </{}>", tag));
172                     let to = TextSize::of(&res);
173                     ranges.push((TextRange::new(from, to), attr));
174                 } else {
175                     res.push('<');
176                     text = &text['<'.len_utf8()..];
177                 }
178             }
179         }
180     }
181     assert!(stack.is_empty(), "unmatched <{}>", tag);
182     ranges.sort_by_key(|r| (r.0.start(), r.0.end()));
183     (ranges, res)
184 }
185 #[test]
186 fn test_extract_tags() {
187     let (tags, text) = extract_tags(r#"<tag fn>fn <tag>main</tag>() {}</tag>"#, "tag");
188     let actual = tags.into_iter().map(|(range, attr)| (&text[range], attr)).collect::<Vec<_>>();
189     assert_eq!(actual, vec![("fn main() {}", Some("fn".into())), ("main", None),]);
190 }
191
192 /// Inserts `$0` marker into the `text` at `offset`.
193 pub fn add_cursor(text: &str, offset: TextSize) -> String {
194     let offset: usize = offset.into();
195     let mut res = String::new();
196     res.push_str(&text[..offset]);
197     res.push_str("$0");
198     res.push_str(&text[offset..]);
199     res
200 }
201
202 /// Extracts `//^^^ some text` annotations.
203 ///
204 /// A run of `^^^` can be arbitrary long and points to the corresponding range
205 /// in the line above.
206 ///
207 /// The `// ^file text` syntax can be used to attach `text` to the entirety of
208 /// the file.
209 ///
210 /// Multiline string values are supported:
211 ///
212 /// // ^^^ first line
213 /// //   | second line
214 ///
215 /// Trailing whitespace is sometimes desired but usually stripped by the editor
216 /// if at the end of a line, or incorrectly sized if followed by another
217 /// annotation. In those cases the annotation can be explicitly ended with the
218 /// `$` character.
219 ///
220 /// // ^^^ trailing-ws-wanted  $
221 ///
222 /// Annotations point to the last line that actually was long enough for the
223 /// range, not counting annotations themselves. So overlapping annotations are
224 /// possible:
225 /// ```no_run
226 /// // stuff        other stuff
227 /// // ^^ 'st'
228 /// // ^^^^^ 'stuff'
229 /// //              ^^^^^^^^^^^ 'other stuff'
230 /// ```
231 pub fn extract_annotations(text: &str) -> Vec<(TextRange, String)> {
232     let mut res = Vec::new();
233     // map from line length to beginning of last line that had that length
234     let mut line_start_map = BTreeMap::new();
235     let mut line_start: TextSize = 0.into();
236     let mut prev_line_annotations: Vec<(TextSize, usize)> = Vec::new();
237     for line in text.split_inclusive('\n') {
238         let mut this_line_annotations = Vec::new();
239         let line_length = if let Some((prefix, suffix)) = line.split_once("//") {
240             let ss_len = TextSize::of("//");
241             let annotation_offset = TextSize::of(prefix) + ss_len;
242             for annotation in extract_line_annotations(suffix.trim_end_matches('\n')) {
243                 match annotation {
244                     LineAnnotation::Annotation { mut range, content, file } => {
245                         range += annotation_offset;
246                         this_line_annotations.push((range.end(), res.len()));
247                         let range = if file {
248                             TextRange::up_to(TextSize::of(text))
249                         } else {
250                             let line_start = line_start_map.range(range.end()..).next().unwrap();
251
252                             range + line_start.1
253                         };
254                         res.push((range, content));
255                     }
256                     LineAnnotation::Continuation { mut offset, content } => {
257                         offset += annotation_offset;
258                         let &(_, idx) = prev_line_annotations
259                             .iter()
260                             .find(|&&(off, _idx)| off == offset)
261                             .unwrap();
262                         res[idx].1.push('\n');
263                         res[idx].1.push_str(&content);
264                         res[idx].1.push('\n');
265                     }
266                 }
267             }
268             annotation_offset
269         } else {
270             TextSize::of(line)
271         };
272
273         line_start_map = line_start_map.split_off(&line_length);
274         line_start_map.insert(line_length, line_start);
275
276         line_start += TextSize::of(line);
277
278         prev_line_annotations = this_line_annotations;
279     }
280
281     res
282 }
283
284 enum LineAnnotation {
285     Annotation { range: TextRange, content: String, file: bool },
286     Continuation { offset: TextSize, content: String },
287 }
288
289 fn extract_line_annotations(mut line: &str) -> Vec<LineAnnotation> {
290     let mut res = Vec::new();
291     let mut offset: TextSize = 0.into();
292     let marker: fn(char) -> bool = if line.contains('^') { |c| c == '^' } else { |c| c == '|' };
293     while let Some(idx) = line.find(marker) {
294         offset += TextSize::try_from(idx).unwrap();
295         line = &line[idx..];
296
297         let mut len = line.chars().take_while(|&it| it == '^').count();
298         let mut continuation = false;
299         if len == 0 {
300             assert!(line.starts_with('|'));
301             continuation = true;
302             len = 1;
303         }
304         let range = TextRange::at(offset, len.try_into().unwrap());
305         let line_no_caret = &line[len..];
306         let end_marker = line_no_caret.find(|c| c == '$');
307         let next = line_no_caret.find(marker).map_or(line.len(), |it| it + len);
308
309         let cond = |end_marker| {
310             end_marker < next
311                 && (line_no_caret[end_marker + 1..].is_empty()
312                     || line_no_caret[end_marker + 1..]
313                         .strip_prefix(|c: char| c.is_whitespace() || c == '^')
314                         .is_some())
315         };
316         let mut content = match end_marker {
317             Some(end_marker) if cond(end_marker) => &line_no_caret[..end_marker],
318             _ => line_no_caret[..next - len].trim_end(),
319         };
320
321         let mut file = false;
322         if !continuation && content.starts_with("file") {
323             file = true;
324             content = &content["file".len()..];
325         }
326
327         let content = content.trim_start().to_string();
328
329         let annotation = if continuation {
330             LineAnnotation::Continuation { offset: range.end(), content }
331         } else {
332             LineAnnotation::Annotation { range, content, file }
333         };
334         res.push(annotation);
335
336         line = &line[next..];
337         offset += TextSize::try_from(next).unwrap();
338     }
339
340     res
341 }
342
343 #[test]
344 fn test_extract_annotations_1() {
345     let text = stdx::trim_indent(
346         r#"
347 fn main() {
348     let (x,     y) = (9, 2);
349        //^ def  ^ def
350     zoo + 1
351 } //^^^ type:
352   //  | i32
353
354 // ^file
355     "#,
356     );
357     let res = extract_annotations(&text)
358         .into_iter()
359         .map(|(range, ann)| (&text[range], ann))
360         .collect::<Vec<_>>();
361
362     assert_eq!(
363         res[..3],
364         [("x", "def".into()), ("y", "def".into()), ("zoo", "type:\ni32\n".into())]
365     );
366     assert_eq!(res[3].0.len(), 115);
367 }
368
369 #[test]
370 fn test_extract_annotations_2() {
371     let text = stdx::trim_indent(
372         r#"
373 fn main() {
374     (x,   y);
375    //^ a
376       //  ^ b
377   //^^^^^^^^ c
378 }"#,
379     );
380     let res = extract_annotations(&text)
381         .into_iter()
382         .map(|(range, ann)| (&text[range], ann))
383         .collect::<Vec<_>>();
384
385     assert_eq!(res, [("x", "a".into()), ("y", "b".into()), ("(x,   y)", "c".into())]);
386 }
387
388 /// Returns `false` if slow tests should not run, otherwise returns `true` and
389 /// also creates a file at `./target/.slow_tests_cookie` which serves as a flag
390 /// that slow tests did run.
391 pub fn skip_slow_tests() -> bool {
392     let should_skip = std::env::var("CI").is_err() && std::env::var("RUN_SLOW_TESTS").is_err();
393     if should_skip {
394         eprintln!("ignoring slow test");
395     } else {
396         let path = project_root().join("./target/.slow_tests_cookie");
397         fs::write(&path, ".").unwrap();
398     }
399     should_skip
400 }
401
402 /// Returns the path to the root directory of `rust-analyzer` project.
403 pub fn project_root() -> PathBuf {
404     let dir = env!("CARGO_MANIFEST_DIR");
405     PathBuf::from(dir).parent().unwrap().parent().unwrap().to_owned()
406 }
407
408 pub fn format_diff(chunks: Vec<dissimilar::Chunk>) -> String {
409     let mut buf = String::new();
410     for chunk in chunks {
411         let formatted = match chunk {
412             dissimilar::Chunk::Equal(text) => text.into(),
413             dissimilar::Chunk::Delete(text) => format!("\x1b[41m{}\x1b[0m", text),
414             dissimilar::Chunk::Insert(text) => format!("\x1b[42m{}\x1b[0m", text),
415         };
416         buf.push_str(&formatted);
417     }
418     buf
419 }
420
421 /// Utility for writing benchmark tests.
422 ///
423 /// A benchmark test looks like this:
424 ///
425 /// ```
426 /// #[test]
427 /// fn benchmark_foo() {
428 ///     if skip_slow_tests() { return; }
429 ///
430 ///     let data = bench_fixture::some_fixture();
431 ///     let analysis = some_setup();
432 ///
433 ///     let hash = {
434 ///         let _b = bench("foo");
435 ///         actual_work(analysis)
436 ///     };
437 ///     assert_eq!(hash, 92);
438 /// }
439 /// ```
440 ///
441 /// * We skip benchmarks by default, to save time.
442 ///   Ideal benchmark time is 800 -- 1500 ms in debug.
443 /// * We don't count preparation as part of the benchmark
444 /// * The benchmark itself returns some kind of numeric hash.
445 ///   The hash is used as a sanity check that some code is actually run.
446 ///   Otherwise, it's too easy to win the benchmark by just doing nothing.
447 pub fn bench(label: &'static str) -> impl Drop {
448     struct Bencher {
449         sw: StopWatch,
450         label: &'static str,
451     }
452
453     impl Drop for Bencher {
454         fn drop(&mut self) {
455             eprintln!("{}: {}", self.label, self.sw.elapsed());
456         }
457     }
458
459     Bencher { sw: StopWatch::start(), label }
460 }
461
462 /// Checks that the `file` has the specified `contents`. If that is not the
463 /// case, updates the file and then fails the test.
464 #[track_caller]
465 pub fn ensure_file_contents(file: &Path, contents: &str) {
466     if let Err(()) = try_ensure_file_contents(file, contents) {
467         panic!("Some files were not up-to-date");
468     }
469 }
470
471 /// Checks that the `file` has the specified `contents`. If that is not the
472 /// case, updates the file and return an Error.
473 pub fn try_ensure_file_contents(file: &Path, contents: &str) -> Result<(), ()> {
474     match std::fs::read_to_string(file) {
475         Ok(old_contents) if normalize_newlines(&old_contents) == normalize_newlines(contents) => {
476             return Ok(())
477         }
478         _ => (),
479     }
480     let display_path = file.strip_prefix(&project_root()).unwrap_or(file);
481     eprintln!(
482         "\n\x1b[31;1merror\x1b[0m: {} was not up-to-date, updating\n",
483         display_path.display()
484     );
485     if is_ci() {
486         eprintln!("    NOTE: run `cargo test` locally and commit the updated files\n");
487     }
488     if let Some(parent) = file.parent() {
489         let _ = std::fs::create_dir_all(parent);
490     }
491     std::fs::write(file, contents).unwrap();
492     Err(())
493 }
494
495 fn normalize_newlines(s: &str) -> String {
496     s.replace("\r\n", "\n")
497 }