blob: 5f0eecfeb6c5307f4b946db5c314eb49d7fc0ac0 [file] [log] [blame]
//! # Cargo test macro.
//!
//! This is meant to be consumed alongside `cargo-test-support`. See
//! <https://rust-lang.github.io/cargo/contrib/> for a guide on writing tests.
//!
//! > This crate is maintained by the Cargo team, primarily for use by Cargo
//! > and not intended for external use. This
//! > crate may make major changes to its APIs or be deprecated without warning.
use proc_macro::*;
use std::path::Path;
use std::process::Command;
use std::sync::LazyLock;
/// Replacement for `#[test]`
///
/// The `#[cargo_test]` attribute extends `#[test]` with some setup before starting the test.
/// It will create a filesystem "sandbox" under the "cargo integration test" directory for each test, such as `/path/to/cargo/target/tmp/cit/t123/`.
/// The sandbox will contain a `home` directory that will be used instead of your normal home directory.
///
/// The `#[cargo_test]` attribute takes several options that will affect how the test is generated.
/// They are listed in parentheses separated with commas, such as:
///
/// ```rust,ignore
/// #[cargo_test(nightly, reason = "-Zfoo is unstable")]
/// ```
///
/// The options it supports are:
///
/// * `>=1.64` --- This indicates that the test will only run with the given version of `rustc` or newer.
/// This can be used when a new `rustc` feature has been stabilized that the test depends on.
/// If this is specified, a `reason` is required to explain why it is being checked.
/// * `nightly` --- This will cause the test to be ignored if not running on the nightly toolchain.
/// This is useful for tests that use unstable options in `rustc` or `rustdoc`.
/// These tests are run in Cargo's CI, but are disabled in rust-lang/rust's CI due to the difficulty of updating both repos simultaneously.
/// A `reason` field is required to explain why it is nightly-only.
/// * `requires = "<cmd>"` --- This indicates a command that is required to be installed to be run.
/// For example, `requires = "rustfmt"` means the test will only run if the executable `rustfmt` is installed.
/// These tests are *always* run on CI.
/// This is mainly used to avoid requiring contributors from having every dependency installed.
/// * `build_std_real` --- This is a "real" `-Zbuild-std` test (in the `build_std` integration test).
/// This only runs on nightly, and only if the environment variable `CARGO_RUN_BUILD_STD_TESTS` is set (these tests on run on Linux).
/// * `build_std_mock` --- This is a "mock" `-Zbuild-std` test (which uses a mock standard library).
/// This only runs on nightly, and is disabled for windows-gnu.
/// * `public_network_test` --- This tests contacts the public internet.
/// These tests are disabled unless the `CARGO_PUBLIC_NETWORK_TESTS` environment variable is set.
/// Use of this should be *extremely rare*, please avoid using it if possible.
/// The hosts it contacts should have a relatively high confidence that they are reliable and stable (such as github.com), especially in CI.
/// The tests should be carefully considered for developer security and privacy as well.
/// * `container_test` --- This indicates that it is a test that uses Docker.
/// These tests are disabled unless the `CARGO_CONTAINER_TESTS` environment variable is set.
/// This requires that you have Docker installed.
/// The SSH tests also assume that you have OpenSSH installed.
/// These should work on Linux, macOS, and Windows where possible.
/// Unfortunately these tests are not run in CI for macOS or Windows (no Docker on macOS, and Windows does not support Linux images).
/// See [`cargo-test-support::containers`](https://doc.rust-lang.org/nightly/nightly-rustc/cargo_test_support/containers) for more on writing these tests.
/// * `ignore_windows="reason"` --- Indicates that the test should be ignored on windows for the given reason.
#[proc_macro_attribute]
pub fn cargo_test(attr: TokenStream, item: TokenStream) -> TokenStream {
// Ideally these options would be embedded in the test itself. However, I
// find it very helpful to have the test clearly state whether or not it
// is ignored. It would be nice to have some kind of runtime ignore
// support (such as
// https://internals.rust-lang.org/t/pre-rfc-skippable-tests/14611).
//
// Unfortunately a big drawback here is that if the environment changes
// (such as the existence of the `git` CLI), this will not trigger a
// rebuild and the test will still be ignored. In theory, something like
// `tracked_env` or `tracked_path`
// (https://github.com/rust-lang/rust/issues/99515) could help with this,
// but they don't really handle the absence of files well.
let mut ignore = false;
let mut requires_reason = false;
let mut explicit_reason = None;
let mut implicit_reasons = Vec::new();
macro_rules! set_ignore {
($predicate:expr, $($arg:tt)*) => {
let p = $predicate;
ignore |= p;
if p {
implicit_reasons.push(std::fmt::format(format_args!($($arg)*)));
}
};
}
let is_not_nightly = !version().1;
for rule in split_rules(attr) {
match rule.as_str() {
"build_std_real" => {
// Only run the "real" build-std tests on nightly and with an
// explicit opt-in (these generally only work on linux, and
// have some extra requirements, and are slow, and can pollute
// the environment since it downloads dependencies).
set_ignore!(is_not_nightly, "requires nightly");
set_ignore!(
option_env!("CARGO_RUN_BUILD_STD_TESTS").is_none(),
"CARGO_RUN_BUILD_STD_TESTS must be set"
);
}
"build_std_mock" => {
// Only run the "mock" build-std tests on nightly and disable
// for windows-gnu which is missing object files (see
// https://github.com/rust-lang/wg-cargo-std-aware/issues/46).
set_ignore!(is_not_nightly, "requires nightly");
set_ignore!(
cfg!(all(target_os = "windows", target_env = "gnu")),
"does not work on windows-gnu"
);
}
"container_test" => {
// These tests must be opt-in because they require docker.
set_ignore!(
option_env!("CARGO_CONTAINER_TESTS").is_none(),
"CARGO_CONTAINER_TESTS must be set"
);
}
"public_network_test" => {
// These tests must be opt-in because they touch the public
// network. The use of these should be **EXTREMELY RARE**, and
// should only touch things which would nearly certainly work
// in CI (like github.com).
set_ignore!(
option_env!("CARGO_PUBLIC_NETWORK_TESTS").is_none(),
"CARGO_PUBLIC_NETWORK_TESTS must be set"
);
}
"nightly" => {
requires_reason = true;
set_ignore!(is_not_nightly, "requires nightly");
}
"requires_rustup_stable" => {
set_ignore!(
!has_rustup_stable(),
"rustup or stable toolchain not installed"
);
}
s if s.starts_with("requires=") => {
let command = &s[9..];
let Ok(literal) = command.parse::<Literal>() else {
panic!("expect a string literal, found: {command}");
};
let literal = literal.to_string();
let Some(command) = literal
.strip_prefix('"')
.and_then(|lit| lit.strip_suffix('"'))
else {
panic!("expect a quoted string literal, found: {literal}");
};
set_ignore!(!has_command(command), "{command} not installed");
}
s if s.starts_with(">=1.") => {
requires_reason = true;
let min_minor = s[4..].parse().unwrap();
let minor = version().0;
set_ignore!(minor < min_minor, "requires rustc 1.{minor} or newer");
}
s if s.starts_with("reason=") => {
explicit_reason = Some(s[7..].parse().unwrap());
}
s if s.starts_with("ignore_windows=") => {
set_ignore!(cfg!(windows), "{}", &s[16..s.len() - 1]);
}
_ => panic!("unknown rule {:?}", rule),
}
}
if requires_reason && explicit_reason.is_none() {
panic!(
"#[cargo_test] with a rule also requires a reason, \
such as #[cargo_test(nightly, reason = \"needs -Z unstable-thing\")]"
);
}
// Construct the appropriate attributes.
let span = Span::call_site();
let mut ret = TokenStream::new();
let add_attr = |ret: &mut TokenStream, attr_name, attr_input| {
ret.extend(Some(TokenTree::from(Punct::new('#', Spacing::Alone))));
let attr = TokenTree::from(Ident::new(attr_name, span));
let mut attr_stream: TokenStream = attr.into();
if let Some(input) = attr_input {
attr_stream.extend(input);
}
ret.extend(Some(TokenTree::from(Group::new(
Delimiter::Bracket,
attr_stream,
))));
};
add_attr(&mut ret, "test", None);
if ignore {
let reason = explicit_reason
.or_else(|| {
(!implicit_reasons.is_empty())
.then(|| TokenTree::from(Literal::string(&implicit_reasons.join(", "))).into())
})
.map(|reason: TokenStream| {
let mut stream = TokenStream::new();
stream.extend(Some(TokenTree::from(Punct::new('=', Spacing::Alone))));
stream.extend(Some(reason));
stream
});
add_attr(&mut ret, "ignore", reason);
}
// Find where the function body starts, and add the boilerplate at the start.
for token in item {
let group = match token {
TokenTree::Group(g) => {
if g.delimiter() == Delimiter::Brace {
g
} else {
ret.extend(Some(TokenTree::Group(g)));
continue;
}
}
other => {
ret.extend(Some(other));
continue;
}
};
let mut new_body = to_token_stream(
r#"let _test_guard = {
let tmp_dir = option_env!("CARGO_TARGET_TMPDIR");
cargo_test_support::paths::init_root(tmp_dir)
};"#,
);
new_body.extend(group.stream());
ret.extend(Some(TokenTree::from(Group::new(
group.delimiter(),
new_body,
))));
}
ret
}
fn split_rules(t: TokenStream) -> Vec<String> {
let tts: Vec<_> = t.into_iter().collect();
tts.split(|tt| match tt {
TokenTree::Punct(p) => p.as_char() == ',',
_ => false,
})
.filter(|parts| !parts.is_empty())
.map(|parts| {
parts
.into_iter()
.map(|part| part.to_string())
.collect::<String>()
})
.collect()
}
fn to_token_stream(code: &str) -> TokenStream {
code.parse().unwrap()
}
static VERSION: std::sync::LazyLock<(u32, bool)> = LazyLock::new(|| {
let output = Command::new("rustc")
.arg("-V")
.output()
.expect("rustc should run");
let stdout = std::str::from_utf8(&output.stdout).expect("utf8");
let vers = stdout.split_whitespace().skip(1).next().unwrap();
let is_nightly = option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_none()
&& (vers.contains("-nightly") || vers.contains("-dev"));
let minor = vers.split('.').skip(1).next().unwrap().parse().unwrap();
(minor, is_nightly)
});
fn version() -> (u32, bool) {
LazyLock::force(&VERSION).clone()
}
fn check_command(command_path: &Path, args: &[&str]) -> bool {
let mut command = Command::new(command_path);
let command_name = command.get_program().to_str().unwrap().to_owned();
command.args(args);
let output = match command.output() {
Ok(output) => output,
Err(e) => {
// * hg is not installed on GitHub macOS or certain constrained
// environments like Docker. Consider installing it if Cargo
// gains more hg support, but otherwise it isn't critical.
// * lldb is not pre-installed on Ubuntu and Windows, so skip.
if is_ci() && !matches!(command_name.as_str(), "hg" | "lldb") {
panic!("expected command `{command_name}` to be somewhere in PATH: {e}",);
}
return false;
}
};
if !output.status.success() {
panic!(
"expected command `{command_name}` to be runnable, got error {}:\n\
stderr:{}\n\
stdout:{}\n",
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
}
true
}
fn has_command(command: &str) -> bool {
use std::env::consts::EXE_EXTENSION;
// ALLOWED: For testing cargo itself only.
#[allow(clippy::disallowed_methods)]
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths)
.flat_map(|path| {
let candidate = path.join(&command);
let with_exe = if EXE_EXTENSION.is_empty() {
None
} else {
Some(candidate.with_extension(EXE_EXTENSION))
};
std::iter::once(candidate).chain(with_exe)
})
.find(|p| is_executable(p))
.is_some()
}
#[cfg(unix)]
fn is_executable<P: AsRef<Path>>(path: P) -> bool {
use std::os::unix::prelude::*;
std::fs::metadata(path)
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(windows)]
fn is_executable<P: AsRef<Path>>(path: P) -> bool {
path.as_ref().is_file()
}
fn has_rustup_stable() -> bool {
if option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_some() {
// This cannot run on rust-lang/rust CI due to the lack of rustup.
return false;
}
// Cargo mucks with PATH on Windows, adding sysroot host libdir, which is
// "bin", which circumvents the rustup wrapper. Use the path directly from
// CARGO_HOME.
let home = match option_env!("CARGO_HOME") {
Some(home) => home,
None if is_ci() => panic!("expected to run under rustup"),
None => return false,
};
let cargo = Path::new(home).join("bin/cargo");
check_command(&cargo, &["+stable", "--version"])
}
/// Whether or not this running in a Continuous Integration environment.
fn is_ci() -> bool {
// Consider using `tracked_env` instead of option_env! when it is stabilized.
// `tracked_env` will handle changes, but not require rebuilding the macro
// itself like option_env does.
option_env!("CI").is_some() || option_env!("TF_BUILD").is_some()
}