dylint_testing/lib.rs
1//! This crate provides convenient access to the [`compiletest_rs`] package for testing [Dylint]
2//! libraries.
3//!
4//! **Note: If your test has dependencies, you must use `ui_test_example` or `ui_test_examples`.**
5//! See the [`question_mark_in_expression`] example in this repository.
6//!
7//! This crate provides the following three functions:
8//!
9//! - [`ui_test`] - test a library on all source files in a directory
10//! - [`ui_test_example`] - test a library on one example target
11//! - [`ui_test_examples`] - test a library on all example targets
12//!
13//! For most situations, you can add the following to your library's `lib.rs` file:
14//!
15//! ```rust,ignore
16//! #[test]
17//! fn ui() {
18//! dylint_testing::ui_test(env!("CARGO_PKG_NAME"), "ui");
19//! }
20//! ```
21//!
22//! And include one or more `.rs` and `.stderr` files in a `ui` directory alongside your library's
23//! `src` directory. See the [examples] in this repository.
24//!
25//! # Test builder
26//!
27//! In addition to the above three functions, [`ui::Test`] is a test "builder." Currently, the main
28//! advantage of using `Test` over the above functions is that `Test` allows flags to be passed to
29//! `rustc`. For an example of its use, see [`non_thread_safe_call_in_test`] in this repository.
30//!
31//! `Test` has three constructors, which correspond to the above three functions as follows:
32//!
33//! - [`ui::Test::src_base`] <-> [`ui_test`]
34//! - [`ui::Test::example`] <-> [`ui_test_example`]
35//! - [`ui::Test::examples`] <-> [`ui_test_examples`]
36//!
37//! In each case, the constructor's arguments are exactly those of the corresponding function.
38//!
39//! A `Test` instance has the following methods:
40//!
41//! - `dylint_toml` - set the `dylint.toml` file's contents (for testing [configurable libraries])
42//! - `rustc_flags` - pass flags to the compiler when running the test
43//! - `run` - run the test
44//!
45//! # Updating `.stderr` files
46//!
47//! If the standard error that results from running your `.rs` file differs from the contents of
48//! your `.stderr` file, `compiletest_rs` will produce a report like the following:
49//!
50//! ```text
51//! diff of stderr:
52//!
53//! error: calling `std::env::set_var` in a test could affect the outcome of other tests
54//! --> $DIR/main.rs:8:5
55//! |
56//! LL | std::env::set_var("KEY", "VALUE");
57//! | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
58//! |
59//! = note: `-D non-thread-safe-call-in-test` implied by `-D warnings`
60//!
61//! -error: aborting due to previous error
62//! +error: calling `std::env::set_var` in a test could affect the outcome of other tests
63//! + --> $DIR/main.rs:23:9
64//! + |
65//! +LL | std::env::set_var("KEY", "VALUE");
66//! + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
67//! +
68//! +error: aborting due to 2 previous errors
69//!
70//!
71//!
72//! The actual stderr differed from the expected stderr.
73//! Actual stderr saved to ...
74//! ```
75//!
76//! The meaning of each line is as follows:
77//!
78//! - A line beginning with a plus (`+`) is in the actual standard error, but not in your `.stderr`
79//! file.
80//! - A line beginning with a minus (`-`) is in your `.stderr` file, but not in the actual standard
81//! error.
82//! - A line beginning with a space (` `) is in both the actual standard error and your `.stderr`
83//! file, and is provided for context.
84//! - All other lines (e.g., `diff of stderr:`) contain `compiletest_rs` messages.
85//!
86//! **Note:** In the actual standard error, a blank line usually follows the `error: aborting due to
87//! N previous errors` line. So a correct `.stderr` file will typically contain one blank line at
88//! the end.
89//!
90//! In general, it is not too hard to update a `.stderr` file by hand. However, the `compiletest_rs`
91//! report should contain a line of the form `Actual stderr saved to PATH`. Copying `PATH` to your
92//! `.stderr` file should update it completely.
93//!
94//! Additional documentation on `compiletest_rs` can be found in [its repository].
95//!
96//! [Dylint]: https://github.com/trailofbits/dylint/tree/master
97//! [`compiletest_rs`]: https://github.com/Manishearth/compiletest-rs
98//! [`non_thread_safe_call_in_test`]: https://github.com/trailofbits/dylint/tree/master/examples/general/non_thread_safe_call_in_test/src/lib.rs
99//! [`question_mark_in_expression`]: https://github.com/trailofbits/dylint/tree/master/examples/restriction/question_mark_in_expression/Cargo.toml
100//! [`ui::Test::example`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html#method.example
101//! [`ui::Test::examples`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html#method.examples
102//! [`ui::Test::src_base`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html#method.src_base
103//! [`ui::Test`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html
104//! [`ui_test_example`]: https://docs.rs/dylint_testing/latest/dylint_testing/fn.ui_test_example.html
105//! [`ui_test_examples`]: https://docs.rs/dylint_testing/latest/dylint_testing/fn.ui_test_examples.html
106//! [`ui_test`]: https://docs.rs/dylint_testing/latest/dylint_testing/fn.ui_test.html
107//! [configurable libraries]: https://github.com/trailofbits/dylint/tree/master#configurable-libraries
108//! [docs.rs documentation]: https://docs.rs/dylint_testing/latest/dylint_testing/
109//! [examples]: https://github.com/trailofbits/dylint/tree/master/examples
110//! [its repository]: https://github.com/Manishearth/compiletest-rs
111
112use anyhow::{Context, Result, anyhow, ensure};
113use cargo_metadata::{Metadata, Package, Target, TargetKind};
114use compiletest_rs as compiletest;
115use dylint_internal::{CommandExt, env, library_filename, rustup::is_rustc};
116use once_cell::sync::OnceCell;
117use regex::Regex;
118use std::{
119 env::{consts, remove_var, set_var, var_os},
120 ffi::{OsStr, OsString},
121 fs::{copy, read_dir, remove_file},
122 io::BufRead,
123 path::{Path, PathBuf},
124 sync::{LazyLock, Mutex},
125};
126
127pub mod ui;
128
129static DRIVER: OnceCell<Result<PathBuf>> = OnceCell::new();
130static LINKING_FLAGS: OnceCell<Vec<String>> = OnceCell::new();
131
132/// Test a library on all source files in a directory.
133///
134/// - `name` is the name of a Dylint library to be tested. (Often, this is the same as the package
135/// name.)
136/// - `src_base` is a directory containing:
137/// - source files on which to test the library (`.rs` files), and
138/// - the output those files should produce (`.stderr` files).
139pub fn ui_test(name: &str, src_base: impl AsRef<Path>) {
140 ui::Test::src_base(name, src_base).run();
141}
142
143/// Test a library on one example target.
144///
145/// - `name` is the name of a Dylint library to be tested.
146/// - `example` is an example target on which to test the library.
147pub fn ui_test_example(name: &str, example: &str) {
148 ui::Test::example(name, example).run();
149}
150
151/// Test a library on all example targets.
152///
153/// - `name` is the name of a Dylint library to be tested.
154pub fn ui_test_examples(name: &str) {
155 ui::Test::examples(name).run();
156}
157
158fn initialize(name: &str) -> &Result<PathBuf> {
159 DRIVER.get_or_init(|| {
160 let _ = env_logger::try_init();
161
162 // smoelius: Try to order failures by how informative they are: failure to build the
163 // library, failure to find the library, failure to build/find the driver.
164
165 dylint_internal::cargo::build(&format!("library `{name}`"))
166 .build()
167 .success()?;
168
169 // smoelius: `DYLINT_LIBRARY_PATH` must be set before `dylint_libs` is called.
170 // smoelius: This was true when `dylint_libs` called `name_toolchain_map`, but that is
171 // no longer the case. I am leaving the comment here for now in case removal
172 // of the `name_toolchain_map` call causes a regression.
173 let metadata = dylint_internal::cargo::current_metadata().unwrap();
174 let dylint_library_path = metadata.target_directory.join("debug");
175 unsafe {
176 set_var(env::DYLINT_LIBRARY_PATH, dylint_library_path);
177 }
178
179 let dylint_libs = dylint_libs(name)?;
180 let driver = dylint::driver_builder::get(
181 &dylint::opts::Dylint::default(),
182 env!("RUSTUP_TOOLCHAIN"),
183 )?;
184
185 unsafe {
186 set_var(env::CLIPPY_DISABLE_DOCS_LINKS, "true");
187 set_var(env::DYLINT_LIBS, dylint_libs);
188 }
189
190 Ok(driver)
191 })
192}
193
194#[doc(hidden)]
195pub fn dylint_libs(name: &str) -> Result<String> {
196 let metadata = dylint_internal::cargo::current_metadata().unwrap();
197 let rustup_toolchain = env::var(env::RUSTUP_TOOLCHAIN)?;
198 let filename = library_filename(name, &rustup_toolchain);
199 let path = metadata.target_directory.join("debug").join(filename);
200 let paths = vec![path];
201 serde_json::to_string(&paths).map_err(Into::into)
202}
203
204fn example_target(package: &Package, example: &str) -> Result<Target> {
205 package
206 .targets
207 .iter()
208 .find(|target| target.kind == [TargetKind::Example] && target.name == example)
209 .cloned()
210 .ok_or_else(|| anyhow!("Could not find example `{example}`"))
211}
212
213#[allow(clippy::unnecessary_wraps)]
214fn example_targets(package: &Package) -> Result<Vec<Target>> {
215 Ok(package
216 .targets
217 .iter()
218 .filter(|target| target.kind == [TargetKind::Example])
219 .cloned()
220 .collect())
221}
222
223fn run_example_test(
224 driver: &Path,
225 metadata: &Metadata,
226 package: &Package,
227 target: &Target,
228 config: &ui::Config,
229) -> Result<()> {
230 let linking_flags = linking_flags(metadata, package, target)?;
231 let file_name = target
232 .src_path
233 .file_name()
234 .ok_or_else(|| anyhow!("Could not get file name"))?;
235
236 let tempdir = tempfile::tempdir().with_context(|| "`tempdir` failed")?;
237 let src_base = tempdir.path();
238 let to = src_base.join(file_name);
239
240 copy(&target.src_path, &to).with_context(|| {
241 format!(
242 "Could not copy `{}` to `{}`",
243 target.src_path,
244 to.to_string_lossy()
245 )
246 })?;
247 for extension in ["fixed", "stderr", "stdout"] {
248 copy_with_extension(&target.src_path, &to, extension)
249 .map(|_| ())
250 .unwrap_or_default();
251 }
252
253 let mut config = config.clone();
254 config.rustc_flags.extend(linking_flags.iter().cloned());
255
256 run_tests(driver, src_base, &config);
257
258 Ok(())
259}
260
261fn linking_flags(
262 metadata: &Metadata,
263 package: &Package,
264 target: &Target,
265) -> Result<&'static [String]> {
266 LINKING_FLAGS
267 .get_or_try_init(|| {
268 let rustc_flags = rustc_flags(metadata, package, target)?;
269
270 let mut linking_flags = Vec::new();
271
272 let mut iter = rustc_flags.into_iter();
273 while let Some(flag) = iter.next() {
274 if flag.starts_with("--edition=") {
275 linking_flags.push(flag);
276 } else if flag == "--extern" || flag == "-L" {
277 let arg = next(&flag, &mut iter)?;
278 linking_flags.extend([flag, arg.trim_matches('\'').to_owned()]);
279 }
280 }
281
282 Ok(linking_flags)
283 })
284 .map(Vec::as_slice)
285}
286
287// smoelius: We need to recover the `rustc` flags used to build a target. I can see four options:
288//
289// * Use `cargo build --build-plan`
290// - Pros: Easily parsable JSON output
291// - Cons: Unstable and likely to be removed: https://github.com/rust-lang/cargo/issues/7614
292// * Parse the output of `cargo build --verbose`
293// - Pros: ?
294// - Cons: Not as easily parsable, requires synchronization (see below)
295// * Use a custom executor like Siderophile does: https://github.com/trailofbits/siderophile/blob/26c067306f6c2f66d9530dacef6b17dbf59cdf8c/src/trawl_source/mod.rs#L399
296// - Pros: Ground truth
297// - Cons: Seems a bit of a heavy lift (Note: I think Siderophile's approach was inspired by
298// `cargo-geiger`.)
299// * Set `RUSTC_WORKSPACE_WRAPPER` to something that logs `rustc` invocations
300// - Pros: Ground truth
301// - Cons: Requires a separate executable/script, portability could be an issue
302//
303// I am going with the second option for now, because it seems to be the least of all evils. This
304// decision may need to be revisited.
305
306static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*Running\s*`(.*)`$").unwrap());
307
308fn rustc_flags(metadata: &Metadata, package: &Package, target: &Target) -> Result<Vec<String>> {
309 // smoelius: The following comments are old and retained for posterity. The linking flags are
310 // now initialized using a `OnceCell`, which makes the mutex unnecessary.
311 // smoelius: Force rebuilding of the example by removing it. This is kind of messy. The
312 // example is a shared resource that may be needed by multiple tests. For now, I lock a mutex
313 // while the example is removed and put back.
314 // smoelius: Should we use a temporary target directory here?
315 let output = {
316 remove_example(metadata, package, target)?;
317
318 // smoelius: Because of lazy initialization, `cargo build` is run only once. Seeing
319 // "Building example `target`" for one example but not for others is confusing. So instead
320 // say "Building `package` examples".
321 dylint_internal::cargo::build(&format!("`{}` examples", package.name))
322 .build()
323 .env_remove(env::CARGO_TERM_COLOR)
324 .args([
325 "--manifest-path",
326 package.manifest_path.as_ref(),
327 "--example",
328 &target.name,
329 "--verbose",
330 ])
331 .logged_output(true)?
332 };
333
334 let matches = output
335 .stderr
336 .lines()
337 .map(|line| {
338 let line =
339 line.with_context(|| format!("Could not read from `{}`", package.manifest_path))?;
340 Ok((*RE).captures(&line).and_then(|captures| {
341 let args = captures[1]
342 .split(' ')
343 .map(ToOwned::to_owned)
344 .collect::<Vec<_>>();
345 if args.first().is_some_and(is_rustc)
346 && args
347 .as_slice()
348 .windows(2)
349 .any(|window| window == ["--crate-name", &snake_case(&target.name)])
350 {
351 Some(args)
352 } else {
353 None
354 }
355 }))
356 })
357 .collect::<Result<Vec<Option<Vec<_>>>>>()?;
358
359 let mut matches = matches.into_iter().flatten().collect::<Vec<Vec<_>>>();
360 ensure!(
361 matches.len() <= 1,
362 "Found multiple `rustc` invocations for `{}`",
363 target.name
364 );
365 matches
366 .pop()
367 .ok_or_else(|| anyhow!("Found no `rustc` invocations for `{}`", target.name))
368}
369
370fn remove_example(metadata: &Metadata, _package: &Package, target: &Target) -> Result<()> {
371 let examples = metadata.target_directory.join("debug/examples");
372 for entry in
373 read_dir(&examples).with_context(|| format!("`read_dir` failed for `{examples}`"))?
374 {
375 let entry = entry.with_context(|| format!("`read_dir` failed for `{examples}`"))?;
376 let path = entry.path();
377
378 let file_name = entry.file_name();
379 let s = file_name.to_string_lossy();
380 let target_name = snake_case(&target.name);
381 if s == target_name.clone() + consts::EXE_SUFFIX
382 || s.starts_with(&(target_name.clone() + "-"))
383 {
384 remove_file(&path).with_context(|| {
385 format!("`remove_file` failed for `{}`", path.to_string_lossy())
386 })?;
387 }
388 }
389
390 Ok(())
391}
392
393fn next<I, T>(flag: &str, iter: &mut I) -> Result<T>
394where
395 I: Iterator<Item = T>,
396{
397 iter.next()
398 .ok_or_else(|| anyhow!("Missing argument for `{flag}`"))
399}
400
401fn copy_with_extension<P: AsRef<Path>, Q: AsRef<Path>>(
402 from: P,
403 to: Q,
404 extension: &str,
405) -> Result<u64> {
406 let from = from.as_ref().with_extension(extension);
407 let to = to.as_ref().with_extension(extension);
408 copy(from, to).map_err(Into::into)
409}
410
411static MUTEX: Mutex<()> = Mutex::new(());
412
413fn run_tests(driver: &Path, src_base: &Path, config: &ui::Config) {
414 let _lock = MUTEX.lock().unwrap();
415
416 // smoelius: There doesn't seem to be a way to set environment variables using `compiletest`'s
417 // [`Config`](https://docs.rs/compiletest_rs/0.7.1/compiletest_rs/common/struct.Config.html)
418 // struct. For comparison, where Clippy uses `compiletest`, it sets environment variables
419 // directly (see: https://github.com/rust-lang/rust-clippy/blob/master/tests/compile-test.rs).
420 //
421 // Of course, even if `compiletest` had such support, it would need to be incorporated into
422 // `dylint_testing`.
423
424 let _var = config
425 .dylint_toml
426 .as_ref()
427 .map(|value| VarGuard::set(env::DYLINT_TOML, value));
428
429 let config = compiletest::Config {
430 mode: compiletest::common::Mode::Ui,
431 rustc_path: driver.to_path_buf(),
432 src_base: src_base.to_path_buf(),
433 target_rustcflags: Some(
434 config.rustc_flags.clone().join(" ")
435 + " --emit=metadata"
436 + if cfg!(feature = "deny_warnings") {
437 " -Dwarnings"
438 } else {
439 ""
440 }
441 + " -Zui-testing",
442 ),
443 ..compiletest::Config::default()
444 };
445
446 compiletest::run_tests(&config);
447}
448
449// smoelius: `VarGuard` was copied from:
450// https://github.com/rust-lang/rust-clippy/blob/9cc8da222b3893bc13bc13c8827e93f8ea246854/tests/compile-test.rs
451// smoelius: Clippy dropped `VarGuard` when it switched to `ui_test`:
452// https://github.com/rust-lang/rust-clippy/commit/77d10ac63dae6ef0a691d9acd63d65de9b9bf88e
453
454/// Restores an env var on drop
455#[must_use]
456struct VarGuard {
457 key: &'static str,
458 value: Option<OsString>,
459}
460
461impl VarGuard {
462 fn set(key: &'static str, val: impl AsRef<OsStr>) -> Self {
463 let value = var_os(key);
464 unsafe {
465 set_var(key, val);
466 }
467 Self { key, value }
468 }
469}
470
471impl Drop for VarGuard {
472 fn drop(&mut self) {
473 match self.value.as_deref() {
474 None => unsafe { remove_var(self.key) },
475 Some(value) => unsafe { set_var(self.key, value) },
476 }
477 }
478}
479
480fn snake_case(name: &str) -> String {
481 name.replace('-', "_")
482}