1#![allow(clippy::test_attr_in_doctest)]
126
127use std::{
128 collections::HashMap,
129 convert::TryInto,
130 env, fmt, fs, mem,
131 ops::Range,
132 panic,
133 path::{Path, PathBuf},
134 sync::Mutex,
135};
136
137use once_cell::sync::{Lazy, OnceCell};
138
139const HELP: &str = "
140You can update all `expect!` tests by running:
141
142 env UPDATE_EXPECT=1 cargo test
143
144To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer.
145";
146
147fn update_expect() -> bool {
148 env::var("UPDATE_EXPECT").is_ok()
149}
150
151#[macro_export]
163macro_rules! expect {
164 [$data:literal] => { $crate::expect![[$data]] };
165 [[$data:literal]] => {$crate::Expect {
166 position: $crate::Position {
167 file: file!(),
168 line: line!(),
169 column: column!(),
170 },
171 data: $data,
172 indent: true,
173 }};
174 [] => { $crate::expect![[""]] };
175 [[]] => { $crate::expect![[""]] };
176}
177
178#[macro_export]
185macro_rules! expect_file {
186 [$path:expr] => {$crate::ExpectFile {
187 path: std::path::PathBuf::from($path),
188 position: file!(),
189 }};
190}
191
192#[derive(Debug)]
194pub struct Expect {
195 #[doc(hidden)]
196 pub position: Position,
197 #[doc(hidden)]
198 pub data: &'static str,
199 #[doc(hidden)]
200 pub indent: bool,
201}
202
203#[derive(Debug)]
205pub struct ExpectFile {
206 #[doc(hidden)]
207 pub path: PathBuf,
208 #[doc(hidden)]
209 pub position: &'static str,
210}
211
212#[derive(Debug)]
214pub struct Position {
215 #[doc(hidden)]
216 pub file: &'static str,
217 #[doc(hidden)]
218 pub line: u32,
219 #[doc(hidden)]
220 pub column: u32,
221}
222
223impl fmt::Display for Position {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 write!(f, "{}:{}:{}", self.file, self.line, self.column)
226 }
227}
228
229#[derive(Clone, Copy)]
230enum StrLitKind {
231 Normal,
232 Raw(usize),
233}
234
235impl StrLitKind {
236 fn write_start(self, w: &mut impl std::fmt::Write) -> std::fmt::Result {
237 match self {
238 Self::Normal => write!(w, "\""),
239 Self::Raw(n) => {
240 write!(w, "r")?;
241 for _ in 0..n {
242 write!(w, "#")?;
243 }
244 write!(w, "\"")
245 }
246 }
247 }
248
249 fn write_end(self, w: &mut impl std::fmt::Write) -> std::fmt::Result {
250 match self {
251 Self::Normal => write!(w, "\""),
252 Self::Raw(n) => {
253 write!(w, "\"")?;
254 for _ in 0..n {
255 write!(w, "#")?;
256 }
257 Ok(())
258 }
259 }
260 }
261}
262
263impl Expect {
264 pub fn assert_eq(&self, actual: &str) {
266 let trimmed = self.trimmed();
267 if trimmed == actual {
268 return;
269 }
270 Runtime::fail_expect(self, &trimmed, actual);
271 }
272
273 pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) {
275 let actual = format!("{actual:#?}\n");
276 self.assert_eq(&actual)
277 }
278
279 pub fn indent(&mut self, yes: bool) {
281 self.indent = yes;
282 }
283
284 pub fn data(&self) -> &str {
286 self.data
287 }
288
289 fn trimmed(&self) -> String {
290 if !self.data.contains('\n') {
291 return self.data.to_string();
292 }
293 trim_indent(self.data)
294 }
295
296 fn locate(&self, file: &str) -> Location {
297 let mut target_line = None;
298 let mut line_start = 0;
299 for (i, line) in lines_with_ends(file).enumerate() {
300 if i == self.position.line as usize - 1 {
301 #[allow(clippy::skip_while_next)]
310 let byte_offset = line
311 .char_indices()
312 .skip((self.position.column - 1).try_into().unwrap())
313 .skip_while(|&(_, c)| c != '!')
314 .skip(1) .skip_while(|&(_, c)| c.is_whitespace())
316 .skip(1) .skip_while(|&(_, c)| c.is_whitespace())
318 .next()
319 .expect("Failed to parse macro invocation")
320 .0;
321
322 let literal_start = line_start + byte_offset;
323 let indent = line.chars().take_while(|&it| it == ' ').count();
324 target_line = Some((literal_start, indent));
325 break;
326 }
327 line_start += line.len();
328 }
329 let (literal_start, line_indent) = target_line.unwrap();
330
331 let lit_to_eof = &file[literal_start..];
332 let lit_to_eof_trimmed = lit_to_eof.trim_start();
333
334 let literal_start = literal_start + (lit_to_eof.len() - lit_to_eof_trimmed.len());
335
336 let literal_len =
337 locate_end(lit_to_eof_trimmed).expect("Couldn't find closing delimiter for `expect!`.");
338 let literal_range = literal_start..literal_start + literal_len;
339 Location {
340 line_indent,
341 literal_range,
342 }
343 }
344}
345
346fn locate_end(arg_start_to_eof: &str) -> Option<usize> {
347 match arg_start_to_eof.chars().next()? {
348 c if c.is_whitespace() => panic!("skip whitespace before calling `locate_end`"),
349
350 '[' => {
352 let str_start_to_eof = arg_start_to_eof[1..].trim_start();
353 let str_len = find_str_lit_len(str_start_to_eof)?;
354 let str_end_to_eof = &str_start_to_eof[str_len..];
355 let closing_brace_offset = str_end_to_eof.find(']')?;
356 Some((arg_start_to_eof.len() - str_end_to_eof.len()) + closing_brace_offset + 1)
357 }
358
359 ']' | '}' | ')' => Some(0),
361
362 _ => find_str_lit_len(arg_start_to_eof),
364 }
365}
366
367fn find_str_lit_len(str_lit_to_eof: &str) -> Option<usize> {
370 use StrLitKind::*;
371
372 fn try_find_n_hashes(
373 s: &mut impl Iterator<Item = char>,
374 desired_hashes: usize,
375 ) -> Option<(usize, Option<char>)> {
376 let mut n = 0;
377 loop {
378 match s.next()? {
379 '#' => n += 1,
380 c => return Some((n, Some(c))),
381 }
382
383 if n == desired_hashes {
384 return Some((n, None));
385 }
386 }
387 }
388
389 let mut s = str_lit_to_eof.chars();
390 let kind = match s.next()? {
391 '"' => Normal,
392 'r' => {
393 let (n, c) = try_find_n_hashes(&mut s, usize::MAX)?;
394 if c != Some('"') {
395 return None;
396 }
397 Raw(n)
398 }
399 _ => return None,
400 };
401
402 let mut oldc = None;
403 loop {
404 let c = oldc.take().or_else(|| s.next())?;
405 match (c, kind) {
406 ('\\', Normal) => {
407 let _escaped = s.next()?;
408 }
409 ('"', Normal) => break,
410 ('"', Raw(0)) => break,
411 ('"', Raw(n)) => {
412 let (seen, c) = try_find_n_hashes(&mut s, n)?;
413 if seen == n {
414 break;
415 }
416 oldc = c;
417 }
418 _ => {}
419 }
420 }
421
422 Some(str_lit_to_eof.len() - s.as_str().len())
423}
424
425impl ExpectFile {
426 pub fn assert_eq(&self, actual: &str) {
428 let expected = self.data();
429 if actual == expected {
430 return;
431 }
432 Runtime::fail_file(self, &expected, actual);
433 }
434
435 pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) {
437 let actual = format!("{actual:#?}\n");
438 self.assert_eq(&actual)
439 }
440
441 pub fn data(&self) -> String {
443 fs::read_to_string(self.abs_path()).unwrap_or_default().replace("\r\n", "\n")
444 }
445
446 fn write(&self, contents: &str) {
447 fs::write(self.abs_path(), contents).unwrap()
448 }
449
450 fn abs_path(&self) -> PathBuf {
451 if self.path.is_absolute() {
452 self.path.to_owned()
453 } else {
454 let dir = Path::new(self.position).parent().unwrap();
455 to_abs_ws_path(&dir.join(&self.path))
456 }
457 }
458}
459
460#[derive(Default)]
461struct Runtime {
462 help_printed: bool,
463 per_file: HashMap<&'static str, FileRuntime>,
464}
465static RT: Lazy<Mutex<Runtime>> = Lazy::new(Default::default);
466
467impl Runtime {
468 fn fail_expect(expect: &Expect, expected: &str, actual: &str) {
469 let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
470 if update_expect() {
471 println!("\x1b[1m\x1b[92mupdating\x1b[0m: {}", expect.position);
472 rt.per_file
473 .entry(expect.position.file)
474 .or_insert_with(|| FileRuntime::new(expect))
475 .update(expect, actual);
476 return;
477 }
478 rt.panic(expect.position.to_string(), expected, actual);
479 }
480
481 fn fail_file(expect: &ExpectFile, expected: &str, actual: &str) {
482 let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
483 if update_expect() {
484 println!("\x1b[1m\x1b[92mupdating\x1b[0m: {}", expect.path.display());
485 expect.write(actual);
486 return;
487 }
488 rt.panic(expect.path.display().to_string(), expected, actual);
489 }
490
491 fn panic(&mut self, position: String, expected: &str, actual: &str) {
492 let print_help = !mem::replace(&mut self.help_printed, true);
493 let help = if print_help { HELP } else { "" };
494
495 let diff = format_unified_diff(expected, actual);
496
497 println!(
498 "\n
499\x1b[1m\x1b[91merror\x1b[97m: expect test failed\x1b[0m
500 \x1b[1m\x1b[34m-->\x1b[0m {position}
501{help}
502\x1b[1mExpect\x1b[0m:
503----
504{expected}
505----
506
507\x1b[1mActual\x1b[0m:
508----
509{actual}
510----
511
512\x1b[1mDiff\x1b[0m:
513----
514{diff}
515----
516"
517 );
518 panic::resume_unwind(Box::new(()));
520 }
521}
522
523struct FileRuntime {
524 path: PathBuf,
525 original_text: String,
526 patchwork: Patchwork,
527}
528
529impl FileRuntime {
530 fn new(expect: &Expect) -> FileRuntime {
531 let path = to_abs_ws_path(Path::new(expect.position.file));
532 let original_text = fs::read_to_string(&path).unwrap();
533 let patchwork = Patchwork::new(original_text.clone());
534 FileRuntime {
535 path,
536 original_text,
537 patchwork,
538 }
539 }
540
541 fn update(&mut self, expect: &Expect, actual: &str) {
542 let loc = expect.locate(&self.original_text);
543 let desired_indent = if expect.indent {
544 Some(loc.line_indent)
545 } else {
546 None
547 };
548 let patch = format_patch(desired_indent, actual);
549 self.patchwork.patch(loc.literal_range, &patch);
550 fs::write(&self.path, &self.patchwork.text).unwrap()
551 }
552}
553
554#[derive(Debug)]
555struct Location {
556 line_indent: usize,
557
558 literal_range: Range<usize>,
560}
561
562#[derive(Debug)]
563struct Patchwork {
564 text: String,
565 indels: Vec<(Range<usize>, usize)>,
566}
567
568impl Patchwork {
569 fn new(text: String) -> Patchwork {
570 Patchwork {
571 text,
572 indels: Vec::new(),
573 }
574 }
575
576 fn patch(&mut self, mut range: Range<usize>, patch: &str) {
577 self.indels.push((range.clone(), patch.len()));
578 self.indels.sort_by_key(|(delete, _insert)| delete.start);
579
580 let (delete, insert) = self
581 .indels
582 .iter()
583 .take_while(|(delete, _)| delete.start < range.start)
584 .map(|(delete, insert)| (delete.end - delete.start, insert))
585 .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2));
586
587 for pos in &mut [&mut range.start, &mut range.end] {
588 **pos -= delete;
589 **pos += insert;
590 }
591
592 self.text.replace_range(range, patch);
593 }
594}
595
596fn lit_kind_for_patch(patch: &str) -> StrLitKind {
597 let has_dquote = patch.chars().any(|c| c == '"');
598 if !has_dquote {
599 let has_bslash_or_newline = patch.chars().any(|c| matches!(c, '\\' | '\n'));
600 return if has_bslash_or_newline {
601 StrLitKind::Raw(1)
602 } else {
603 StrLitKind::Normal
604 };
605 }
606
607 let leading_hashes = |s: &str| s.chars().take_while(|&c| c == '#').count();
610 let max_hashes = patch.split('"').map(leading_hashes).max().unwrap();
611 StrLitKind::Raw(max_hashes + 1)
612}
613
614fn format_patch(desired_indent: Option<usize>, patch: &str) -> String {
615 let lit_kind = lit_kind_for_patch(patch);
616 let indent = desired_indent.map(|it| " ".repeat(it));
617 let is_multiline = patch.contains('\n');
618
619 let mut buf = String::new();
620 if matches!(lit_kind, StrLitKind::Raw(_)) {
621 buf.push('[');
622 }
623 lit_kind.write_start(&mut buf).unwrap();
624 if is_multiline {
625 buf.push('\n');
626 }
627 let mut final_newline = false;
628 for line in lines_with_ends(patch) {
629 if is_multiline && !line.trim().is_empty() {
630 if let Some(indent) = &indent {
631 buf.push_str(indent);
632 buf.push_str(" ");
633 }
634 }
635 buf.push_str(line);
636 final_newline = line.ends_with('\n');
637 }
638 if final_newline {
639 if let Some(indent) = &indent {
640 buf.push_str(indent);
641 }
642 }
643 lit_kind.write_end(&mut buf).unwrap();
644 if matches!(lit_kind, StrLitKind::Raw(_)) {
645 buf.push(']');
646 }
647 buf
648}
649
650fn to_abs_ws_path(path: &Path) -> PathBuf {
651 if path.is_absolute() {
652 return path.to_owned();
653 }
654
655 static WORKSPACE_ROOT: OnceCell<PathBuf> = OnceCell::new();
656 WORKSPACE_ROOT
657 .get_or_try_init(|| {
658 if let Ok(workspace_root) = env::var("CARGO_WORKSPACE_DIR") {
661 return Ok(workspace_root.into());
662 }
663
664 let my_manifest = env::var("CARGO_MANIFEST_DIR")?;
667 let workspace_root = Path::new(&my_manifest)
668 .ancestors()
669 .filter(|it| it.join("Cargo.toml").exists())
670 .last()
671 .unwrap()
672 .to_path_buf();
673
674 Ok(workspace_root)
675 })
676 .unwrap_or_else(|_: env::VarError| {
677 panic!("No CARGO_MANIFEST_DIR env var and the path is relative: {}", path.display())
678 })
679 .join(path)
680}
681
682fn trim_indent(mut text: &str) -> String {
683 if text.starts_with('\n') {
684 text = &text[1..];
685 }
686 let indent = text
687 .lines()
688 .filter(|it| !it.trim().is_empty())
689 .map(|it| it.len() - it.trim_start().len())
690 .min()
691 .unwrap_or(0);
692
693 lines_with_ends(text)
694 .map(|line| {
695 if line.len() <= indent {
696 line.trim_start_matches(' ')
697 } else {
698 &line[indent..]
699 }
700 })
701 .collect()
702}
703
704fn lines_with_ends(text: &str) -> LinesWithEnds<'_> {
705 LinesWithEnds { text }
706}
707
708struct LinesWithEnds<'a> {
709 text: &'a str,
710}
711
712impl<'a> Iterator for LinesWithEnds<'a> {
713 type Item = &'a str;
714
715 fn next(&mut self) -> Option<&'a str> {
716 if self.text.is_empty() {
717 return None;
718 }
719 let idx = self.text.find('\n').map_or(self.text.len(), |it| it + 1);
720 let (res, next) = self.text.split_at(idx);
721 self.text = next;
722 Some(res)
723 }
724}
725
726fn format_unified_diff(expected: &str, actual: &str) -> String {
727 use similar::{ChangeTag, TextDiff};
728
729 let diff = TextDiff::from_lines(expected, actual);
730 let mut result = String::new();
731
732 for (idx, group) in diff.grouped_ops(3).into_iter().enumerate() {
733 if idx > 0 {
734 result.push('\n');
735 }
736 for op in group {
737 for change in diff.iter_changes(&op) {
738 let (sign, color) = match change.tag() {
739 ChangeTag::Delete => ("-", "\x1b[31m"), ChangeTag::Insert => ("+", "\x1b[32m"), ChangeTag::Equal => (" ", ""),
742 };
743
744 result.push_str(color);
745 result.push_str(sign);
746 result.push(' ');
747
748 let line = change.value();
749 result.push_str(line);
750 if !line.ends_with('\n') {
751 result.push('\n');
752 }
753
754 if !color.is_empty() {
755 result.push_str("\x1b[0m");
756 }
757 }
758 }
759 }
760
761 result
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn test_trivial_assert() {
770 expect!["5"].assert_eq("5");
771 }
772
773 #[test]
774 fn test_format_patch() {
775 let patch = format_patch(None, "hello\nworld\n");
776 expect![[r##"
777 [r#"
778 hello
779 world
780 "#]"##]]
781 .assert_eq(&patch);
782
783 let patch = format_patch(None, r"hello\tworld");
784 expect![[r##"[r#"hello\tworld"#]"##]].assert_eq(&patch);
785
786 let patch = format_patch(None, "{\"foo\": 42}");
787 expect![[r##"[r#"{"foo": 42}"#]"##]].assert_eq(&patch);
788
789 let patch = format_patch(Some(0), "hello\nworld\n");
790 expect![[r##"
791 [r#"
792 hello
793 world
794 "#]"##]]
795 .assert_eq(&patch);
796
797 let patch = format_patch(Some(4), "single line");
798 expect![[r#""single line""#]].assert_eq(&patch);
799 }
800
801 #[test]
802 fn test_patchwork() {
803 let mut patchwork = Patchwork::new("one two three".to_string());
804 patchwork.patch(4..7, "zwei");
805 patchwork.patch(0..3, "один");
806 patchwork.patch(8..13, "3");
807 expect![[r#"
808 Patchwork {
809 text: "один zwei 3",
810 indels: [
811 (
812 0..3,
813 8,
814 ),
815 (
816 4..7,
817 4,
818 ),
819 (
820 8..13,
821 1,
822 ),
823 ],
824 }
825 "#]]
826 .assert_debug_eq(&patchwork);
827 }
828
829 #[test]
830 fn test_expect_file() {
831 expect_file!["./lib.rs"].assert_eq(include_str!("./lib.rs"))
832 }
833
834 #[test]
835 fn smoke_test_indent() {
836 fn check_indented(input: &str, mut expect: Expect) {
837 expect.indent(true);
838 expect.assert_eq(input);
839 }
840 fn check_not_indented(input: &str, mut expect: Expect) {
841 expect.indent(false);
842 expect.assert_eq(input);
843 }
844
845 check_indented(
846 "\
847line1
848 line2
849",
850 expect![[r#"
851 line1
852 line2
853 "#]],
854 );
855
856 check_not_indented(
857 "\
858line1
859 line2
860",
861 expect![[r#"
862line1
863 line2
864"#]],
865 );
866 }
867
868 #[test]
869 fn test_locate() {
870 macro_rules! check_locate {
871 ($( [[$s:literal]] ),* $(,)?) => {$({
872 let lit = stringify!($s);
873 let with_trailer = format!("{} \t]]\n", lit);
874 assert_eq!(locate_end(&with_trailer), Some(lit.len()));
875 })*};
876 }
877
878 check_locate!(
880 [[r#"{ arr: [[1, 2], [3, 4]], other: "foo" } "#]],
881 [["]]"]],
882 [["\"]]"]],
883 [[r#""]]"#]],
884 );
885
886 assert_eq!(locate_end("]]"), Some(0));
888 }
889
890 #[test]
891 fn test_find_str_lit_len() {
892 macro_rules! check_str_lit_len {
893 ($( $s:literal ),* $(,)?) => {$({
894 let lit = stringify!($s);
895 assert_eq!(find_str_lit_len(lit), Some(lit.len()));
896 })*}
897 }
898
899 check_str_lit_len![
900 r##"foa\""#"##,
901 r##"
902
903 asdf][]]""""#
904 "##,
905 "",
906 "\"",
907 "\"\"",
908 "#\"#\"#",
909 ];
910 }
911
912 #[test]
913 fn test_format_unified_diff_insertions() {
914 let result = format_unified_diff("world", "Hello world");
916 expect![
917 "[31m- world
918[0m[32m+ Hello world
919[0m"
920 ]
921 .assert_eq(&result);
922
923 let result = format_unified_diff("Hello world", "Hello beautiful world");
925 expect![
926 "[31m- Hello world
927[0m[32m+ Hello beautiful world
928[0m"
929 ]
930 .assert_eq(&result);
931
932 let result = format_unified_diff("Hello world", "Hello world!");
934 expect![
935 "[31m- Hello world
936[0m[32m+ Hello world!
937[0m"
938 ]
939 .assert_eq(&result);
940 }
941
942 #[test]
943 fn test_format_unified_diff_deletions() {
944 let result = format_unified_diff("Hello world", "world");
946 expect![
947 "[31m- Hello world
948[0m[32m+ world
949[0m"
950 ]
951 .assert_eq(&result);
952
953 let result = format_unified_diff("Hello beautiful world", "Hello world");
955 expect![
956 "[31m- Hello beautiful world
957[0m[32m+ Hello world
958[0m"
959 ]
960 .assert_eq(&result);
961
962 let result = format_unified_diff("Hello world!", "Hello world");
964 expect![
965 "[31m- Hello world!
966[0m[32m+ Hello world
967[0m"
968 ]
969 .assert_eq(&result);
970 }
971
972 #[test]
973 fn test_format_unified_diff_mixed() {
974 let result = format_unified_diff("The quick brown fox", "The slow brown fox");
976 expect![
977 "[31m- The quick brown fox
978[0m[32m+ The slow brown fox
979[0m"
980 ]
981 .assert_eq(&result);
982 }
983}