Skip to main content

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}