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