dyntest/
lib.rs

1#![doc = include_str!("../README.md")]
2#![feature(test)]
3
4extern crate test;
5
6use std::{
7  borrow::Cow,
8  env,
9  path::{Path, PathBuf},
10  process::{Command, Stdio, Termination},
11};
12use test::{TestDesc, TestDescAndFn, TestFn, TestName, TestType, test_main};
13
14/// Enables the `dyntest` test runner, given a list of test-generating
15/// functions.
16#[macro_export]
17macro_rules! dyntest {
18  ($f:expr $(,)?) => {
19    fn main() {
20      $crate::_dyntest(env!("CARGO_MANIFEST_DIR"), file!(), line!() as usize, column!() as usize, $f)
21    }
22
23    #[test]
24    #[allow(unexpected_cfgs)]
25    fn dyntest() {
26      #[cfg(not(rust_analyzer))]
27      { $crate::_dyntest_harness_error!() }
28    }
29  };
30  ($($f:ident),+ $(,)?) => {
31    $crate::dyntest!(|tester| {
32      $(tester.group(stringify!($f), $f);)*
33    });
34  };
35}
36
37#[doc(hidden)]
38pub fn _dyntest(
39  manifest: &'static str,
40  file: &'static str,
41  line: usize,
42  col: usize,
43  f: impl FnOnce(&mut DynTester),
44) {
45  let args = env::args().collect::<Vec<_>>();
46  let mut tester = DynTester { manifest, file, line, col, tests: vec![], group: String::new() };
47  if args.get(1).is_some_and(|x| x == "dyntest") {
48    // rust-analyzer mode
49    let bin = args[0].clone();
50    tester.test("dyntest", || -> Result<(), String> {
51      let output = Command::new(bin)
52        .stdout(Stdio::inherit())
53        .stderr(Stdio::inherit())
54        .output()
55        .map_err(|e| e.to_string())?;
56      if output.status.success() { Ok(()) } else { Err("dyntests failed".into()) }
57    });
58  } else {
59    f(&mut tester);
60  }
61  let tests = tester.tests.into_iter().map(|x| x.0).collect();
62  test_main(&args, tests, None)
63}
64
65/// A dynamic test harness.
66pub struct DynTester {
67  manifest: &'static str,
68  file: &'static str,
69  line: usize,
70  col: usize,
71  tests: Vec<DynTest>,
72  group: String,
73}
74
75impl DynTester {
76  /// Registers a test, returning a reference to the test for further
77  /// configuration.
78  pub fn test<T: Termination, F: FnOnce() -> T + Send + 'static>(
79    &mut self,
80    name: impl Into<Name>,
81    f: F,
82  ) -> &mut DynTest {
83    let index = self.tests.len();
84    self.tests.push(DynTest(TestDescAndFn {
85      desc: TestDesc {
86        name: TestName::DynTestName(format!("{}{}", self.group, name.into().0)),
87        ignore: false,
88        ignore_message: None,
89        source_file: self.file,
90        start_line: self.line,
91        start_col: self.col,
92        end_line: self.line,
93        end_col: self.col,
94        should_panic: test::ShouldPanic::No,
95        compile_fail: false,
96        no_run: false,
97        test_type: TestType::IntegrationTest,
98      },
99      testfn: TestFn::DynTestFn(Box::new(|| test::assert_test_result(f()))),
100    }));
101    &mut self.tests[index]
102  }
103
104  /// Groups a set of tests, akin to a module of tests.
105  pub fn group(&mut self, name: impl Into<Name>, f: impl FnOnce(&mut Self)) {
106    let len = self.group.len();
107    self.group.push_str(&name.into().0);
108    self.group.push_str("::");
109    f(self);
110    self.group.truncate(len);
111  }
112
113  /// Resolves a path relative to the package's manifest directory.
114  pub fn resolve(&self, path: impl AsRef<Path>) -> PathBuf {
115    Path::join(self.manifest.as_ref(), path)
116  }
117
118  /// Globs for files, relative to the package's manifest directory.
119  ///
120  /// Returns an iterator of pretty names and absolute paths.
121  #[cfg(feature = "glob")]
122  pub fn glob(&self, pattern: &str) -> impl Iterator<Item = (Name, &'static Path)> {
123    self.glob_in(".", pattern)
124  }
125
126  /// Globs for files, relative to `base` (which is itself relative to the
127  /// package's manifest directory).
128  ///
129  /// Returns an iterator of pretty names and absolute paths.
130  #[cfg(feature = "glob")]
131  pub fn glob_in(
132    &self,
133    base: impl AsRef<Path>,
134    pattern: &str,
135  ) -> impl Iterator<Item = (Name, &'static Path)> {
136    use globset::GlobBuilder;
137    use walkdir::WalkDir;
138
139    let base = self.resolve(base);
140
141    let walker = WalkDir::new(&base).follow_links(true);
142
143    let glob = GlobBuilder::new(pattern)
144      .case_insensitive(true)
145      .literal_separator(true)
146      .build()
147      .unwrap()
148      .compile_matcher();
149
150    walker.into_iter().filter_map(move |file| {
151      let file = file.unwrap();
152      let path = file.path();
153      let relative_path = path.strip_prefix(&base).ok()?;
154      glob.is_match(relative_path).then(|| (relative_path.into(), leak(path.to_owned())))
155    })
156  }
157}
158
159/// A dynamically registered test that can be configured further.
160#[repr(transparent)]
161pub struct DynTest(TestDescAndFn);
162
163impl DynTest {
164  /// Configure whether or not this test should be ignored; this is the analogue
165  /// of `#[ignore]`.
166  ///
167  /// ```rust,no_run
168  /// # let test: &mut dyntest::DynTest = unreachable!();
169  /// test.ignore(true);     // #[ignore]
170  /// test.ignore("reason"); // #[ignore = "reason"]
171  /// ```
172  pub fn ignore(&mut self, ignore: impl Into<Ignore>) -> &mut Self {
173    let ignore = ignore.into();
174    self.0.desc.ignore = ignore.ignore();
175    self.0.desc.ignore_message = ignore.message();
176    self
177  }
178
179  /// Configure whether or not this test is supposed to panic; this is the
180  /// analogue of `#[should_panic]`.
181  ///
182  /// ```rust,no_run
183  /// # let test: &mut dyntest::DynTest = unreachable!();
184  /// test.should_panic(true);      // #[should_panic]
185  /// test.should_panic("message"); // #[should_panic(expected = "message")]
186  /// ```
187  pub fn should_panic(&mut self, should_panic: impl Into<ShouldPanic>) -> &mut Self {
188    self.0.desc.should_panic = should_panic.into().0;
189    self
190  }
191}
192
193/// A test name, or a fragment thereof.
194#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
195pub struct Name(Cow<'static, str>);
196
197/// Represents the value, if any, of a `#[ignore]` directive.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
199pub enum Ignore {
200  NoIgnore,
201  Ignore(Option<&'static str>),
202}
203
204/// Represents the value, if any, of a `#[should_panic]` directive.
205#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
206pub struct ShouldPanic(test::ShouldPanic);
207
208#[allow(non_snake_case, non_upper_case_globals)]
209impl ShouldPanic {
210  pub const No: ShouldPanic = ShouldPanic(test::ShouldPanic::No);
211  pub const Yes: ShouldPanic = ShouldPanic(test::ShouldPanic::Yes);
212  pub fn YesWithMessage(message: &'static str) -> Self {
213    ShouldPanic(test::ShouldPanic::YesWithMessage(message))
214  }
215}
216
217impl Into<String> for Name {
218  fn into(self) -> String {
219    self.0.into_owned()
220  }
221}
222
223impl From<&'static str> for Name {
224  fn from(value: &'static str) -> Self {
225    Name(Cow::Borrowed(value))
226  }
227}
228
229impl From<String> for Name {
230  fn from(value: String) -> Self {
231    Name(Cow::Owned(value))
232  }
233}
234
235impl From<&Path> for Name {
236  fn from(value: &Path) -> Self {
237    Name(Cow::Owned(
238      value
239        .with_extension("")
240        .components()
241        .map(|x| x.as_os_str().to_string_lossy())
242        .collect::<Vec<_>>()
243        .join("::"),
244    ))
245  }
246}
247
248impl Ignore {
249  pub fn ignore(&self) -> bool {
250    matches!(self, Ignore::Ignore(_))
251  }
252  pub fn message(&self) -> Option<&'static str> {
253    match *self {
254      Ignore::NoIgnore => None,
255      Ignore::Ignore(message) => message,
256    }
257  }
258}
259
260impl From<bool> for Ignore {
261  fn from(value: bool) -> Self {
262    if value { Ignore::Ignore(None) } else { Ignore::NoIgnore }
263  }
264}
265
266impl From<&'static str> for Ignore {
267  fn from(value: &'static str) -> Self {
268    Ignore::Ignore(Some(value))
269  }
270}
271
272impl From<String> for Ignore {
273  fn from(value: String) -> Self {
274    leak::<str>(value).into()
275  }
276}
277
278impl From<bool> for ShouldPanic {
279  fn from(value: bool) -> Self {
280    if value { ShouldPanic::Yes } else { ShouldPanic::No }
281  }
282}
283
284impl From<&'static str> for ShouldPanic {
285  fn from(value: &'static str) -> Self {
286    ShouldPanic::YesWithMessage(value)
287  }
288}
289
290impl From<String> for ShouldPanic {
291  fn from(value: String) -> Self {
292    leak::<str>(value).into()
293  }
294}
295
296fn leak<T: ?Sized>(value: impl Into<Box<T>>) -> &'static T {
297  Box::leak(value.into())
298}
299
300#[doc(hidden)]
301#[macro_export]
302macro_rules! _dyntest_harness_error {
303  () => {
304    compile_error!(
305      r#"`dyntest!` was invoked, but the default test harness is active, so this has no effect
306
307to fix this, add `harness = false` to this test in your `Cargo.toml`:
308```toml
309[[test]]
310name = "..."
311harness = false
312```
313"#
314    );
315  };
316}