1#![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#[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
64pub 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
72fn 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
83pub 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
91fn 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
135pub 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
148pub 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
194pub 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
204pub fn extract_annotations(text: &str) -> Vec<(TextRange, String)> {
234 let mut res = Vec::new();
235 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
390pub 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
412pub 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
432pub 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#[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
482pub 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}