#
cargo_test_macro/
lib.rs

    1//! # Cargo test macro.
    2//!
    3//! This is meant to be consumed alongside `cargo-test-support`. See
    4//! <https://rust-lang.github.io/cargo/contrib/> for a guide on writing tests.
    5//!
    6//! > This crate is maintained by the Cargo team, primarily for use by Cargo
    7//! > and not intended for external use. This
    8//! > crate may make major changes to its APIs or be deprecated without warning.
    9
    10use proc_macro::*;
    11use std::path::Path;
    12use std::process::Command;
    13use std::sync::LazyLock;
    14
    15/// Replacement for `#[test]`
    16///
    17/// The `#[cargo_test]` attribute extends `#[test]` with some setup before starting the test.
    18/// It will create a filesystem "sandbox" under the "cargo integration test" directory for each test, such as `/path/to/cargo/target/tmp/cit/t123/`.
    19/// The sandbox will contain a `home` directory that will be used instead of your normal home directory.
    20///
    21/// The `#[cargo_test]` attribute takes several options that will affect how the test is generated.
    22/// They are listed in parentheses separated with commas, such as:
    23///
    24/// ```rust,ignore
    25/// #[cargo_test(nightly, reason = "-Zfoo is unstable")]
    26/// ```
    27///
    28/// The options it supports are:
    29///
    30/// * `>=1.64` --- This indicates that the test will only run with the given version of `rustc` or newer.
    31///   This can be used when a new `rustc` feature has been stabilized that the test depends on.
    32///   If this is specified, a `reason` is required to explain why it is being checked.
    33/// * `nightly` --- This will cause the test to be ignored if not running on the nightly toolchain.
    34///   This is useful for tests that use unstable options in `rustc` or `rustdoc`.
    35///   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.
    36///   A `reason` field is required to explain why it is nightly-only.
    37/// * `requires = "<cmd>"` --- This indicates a command that is required to be installed to be run.
    38///   For example, `requires = "rustfmt"` means the test will only run if the executable `rustfmt` is installed.
    39///   These tests are *always* run on CI.
    40///   This is mainly used to avoid requiring contributors from having every dependency installed.
    41/// * `build_std_real` --- This is a "real" `-Zbuild-std` test (in the `build_std` integration test).
    42///   This only runs on nightly, and only if the environment variable `CARGO_RUN_BUILD_STD_TESTS` is set (these tests on run on Linux).
    43/// * `build_std_mock` --- This is a "mock" `-Zbuild-std` test (which uses a mock standard library).
    44///   This only runs on nightly, and is disabled for windows-gnu.
    45/// * `public_network_test` --- This tests contacts the public internet.
    46///   These tests are disabled unless the `CARGO_PUBLIC_NETWORK_TESTS` environment variable is set.
    47///   Use of this should be *extremely rare*, please avoid using it if possible.
    48///   The hosts it contacts should have a relatively high confidence that they are reliable and stable (such as github.com), especially in CI.
    49///   The tests should be carefully considered for developer security and privacy as well.
    50/// * `container_test` --- This indicates that it is a test that uses Docker.
    51///   These tests are disabled unless the `CARGO_CONTAINER_TESTS` environment variable is set.
    52///   This requires that you have Docker installed.
    53///   The SSH tests also assume that you have OpenSSH installed.
    54///   These should work on Linux, macOS, and Windows where possible.
    55///   Unfortunately these tests are not run in CI for macOS or Windows (no Docker on macOS, and Windows does not support Linux images).
    56///   See [`cargo-test-support::containers`](https://doc.rust-lang.org/nightly/nightly-rustc/cargo_test_support/containers) for more on writing these tests.
    57/// * `ignore_windows="reason"` --- Indicates that the test should be ignored on windows for the given reason.
    58#[proc_macro_attribute]
    59pub fn cargo_test(attr: TokenStream, item: TokenStream) -> TokenStream {
    60    // Ideally these options would be embedded in the test itself. However, I
    61    // find it very helpful to have the test clearly state whether or not it
    62    // is ignored. It would be nice to have some kind of runtime ignore
    63    // support (such as
    64    // https://internals.rust-lang.org/t/pre-rfc-skippable-tests/14611).
    65    //
    66    // Unfortunately a big drawback here is that if the environment changes
    67    // (such as the existence of the `git` CLI), this will not trigger a
    68    // rebuild and the test will still be ignored. In theory, something like
    69    // `tracked_env` or `tracked_path`
    70    // (https://github.com/rust-lang/rust/issues/99515) could help with this,
    71    // but they don't really handle the absence of files well.
    72    let mut ignore = false;
    73    let mut requires_reason = false;
    74    let mut explicit_reason = None;
    75    let mut implicit_reasons = Vec::new();
    76    macro_rules! set_ignore {
    77        ($predicate:expr, $($arg:tt)*) => {
    78            let p = $predicate;
    79            ignore |= p;
    80            if p {
    81                implicit_reasons.push(std::fmt::format(format_args!($($arg)*)));
    82            }
    83        };
    84    }
    85    let is_not_nightly = !version().1;
    86    for rule in split_rules(attr) {
    87        match rule.as_str() {
    88            "build_std_real" => {
    89                // Only run the "real" build-std tests on nightly and with an
    90                // explicit opt-in (these generally only work on linux, and
    91                // have some extra requirements, and are slow, and can pollute
    92                // the environment since it downloads dependencies).
    93                set_ignore!(is_not_nightly, "requires nightly");
    94                set_ignore!(
    95                    option_env!("CARGO_RUN_BUILD_STD_TESTS").is_none(),
    96                    "CARGO_RUN_BUILD_STD_TESTS must be set"
    97                );
    98            }
    99            "build_std_mock" => {
    100                // Only run the "mock" build-std tests on nightly and disable
    101                // for windows-gnu which is missing object files (see
    102                // https://github.com/rust-lang/wg-cargo-std-aware/issues/46).
    103                set_ignore!(is_not_nightly, "requires nightly");
    104                set_ignore!(
    105                    cfg!(all(target_os = "windows", target_env = "gnu")),
    106                    "does not work on windows-gnu"
    107                );
    108            }
    109            "container_test" => {
    110                // These tests must be opt-in because they require docker.
    111                set_ignore!(
    112                    option_env!("CARGO_CONTAINER_TESTS").is_none(),
    113                    "CARGO_CONTAINER_TESTS must be set"
    114                );
    115            }
    116            "public_network_test" => {
    117                // These tests must be opt-in because they touch the public
    118                // network. The use of these should be **EXTREMELY RARE**, and
    119                // should only touch things which would nearly certainly work
    120                // in CI (like github.com).
    121                set_ignore!(
    122                    option_env!("CARGO_PUBLIC_NETWORK_TESTS").is_none(),
    123                    "CARGO_PUBLIC_NETWORK_TESTS must be set"
    124                );
    125            }
    126            "nightly" => {
    127                requires_reason = true;
    128                set_ignore!(is_not_nightly, "requires nightly");
    129            }
    130            "requires_rustup_stable" => {
    131                set_ignore!(
    132                    !has_rustup_stable(),
    133                    "rustup or stable toolchain not installed"
    134                );
    135            }
    136            s if s.starts_with("requires=") => {
    137                let command = &s[9..];
    138                let Ok(literal) = command.parse::<Literal>() else {
    139                    panic!("expect a string literal, found: {command}");
    140                };
    141                let literal = literal.to_string();
    142                let Some(command) = literal
    143                    .strip_prefix('"')
    144                    .and_then(|lit| lit.strip_suffix('"'))
    145                else {
    146                    panic!("expect a quoted string literal, found: {literal}");
    147                };
    148                set_ignore!(!has_command(command), "{command} not installed");
    149            }
    150            s if s.starts_with(">=1.") => {
    151                requires_reason = true;
    152                let min_minor = s[4..].parse().unwrap();
    153                let minor = version().0;
    154                set_ignore!(minor < min_minor, "requires rustc 1.{minor} or newer");
    155            }
    156            s if s.starts_with("reason=") => {
    157                explicit_reason = Some(s[7..].parse().unwrap());
    158            }
    159            s if s.starts_with("ignore_windows=") => {
    160                set_ignore!(cfg!(windows), "{}", &s[16..s.len() - 1]);
    161            }
    162            _ => panic!("unknown rule {:?}", rule),
    163        }
    164    }
    165    if requires_reason && explicit_reason.is_none() {
    166        panic!(
    167            "#[cargo_test] with a rule also requires a reason, \
    168            such as #[cargo_test(nightly, reason = \"needs -Z unstable-thing\")]"
    169        );
    170    }
    171
    172    // Construct the appropriate attributes.
    173    let span = Span::call_site();
    174    let mut ret = TokenStream::new();
    175    let add_attr = |ret: &mut TokenStream, attr_name, attr_input| {
    176        ret.extend(Some(TokenTree::from(Punct::new('#', Spacing::Alone))));
    177        let attr = TokenTree::from(Ident::new(attr_name, span));
    178        let mut attr_stream: TokenStream = attr.into();
    179        if let Some(input) = attr_input {
    180            attr_stream.extend(input);
    181        }
    182        ret.extend(Some(TokenTree::from(Group::new(
    183            Delimiter::Bracket,
    184            attr_stream,
    185        ))));
    186    };
    187    add_attr(&mut ret, "test", None);
    188    if ignore {
    189        let reason = explicit_reason
    190            .or_else(|| {
    191                (!implicit_reasons.is_empty())
    192                    .then(|| TokenTree::from(Literal::string(&implicit_reasons.join(", "))).into())
    193            })
    194            .map(|reason: TokenStream| {
    195                let mut stream = TokenStream::new();
    196                stream.extend(Some(TokenTree::from(Punct::new('=', Spacing::Alone))));
    197                stream.extend(Some(reason));
    198                stream
    199            });
    200        add_attr(&mut ret, "ignore", reason);
    201    }
    202
    203    // Find where the function body starts, and add the boilerplate at the start.
    204    for token in item {
    205        let group = match token {
    206            TokenTree::Group(g) => {
    207                if g.delimiter() == Delimiter::Brace {
    208                    g
    209                } else {
    210                    ret.extend(Some(TokenTree::Group(g)));
    211                    continue;
    212                }
    213            }
    214            other => {
    215                ret.extend(Some(other));
    216                continue;
    217            }
    218        };
    219
    220        let mut new_body = to_token_stream(
    221            r#"let _test_guard = {
    222                let tmp_dir = option_env!("CARGO_TARGET_TMPDIR");
    223                cargo_test_support::paths::init_root(tmp_dir)
    224            };"#,
    225        );
    226
    227        new_body.extend(group.stream());
    228        ret.extend(Some(TokenTree::from(Group::new(
    229            group.delimiter(),
    230            new_body,
    231        ))));
    232    }
    233
    234    ret
    235}
    236
    237fn split_rules(t: TokenStream) -> Vec<String> {
    238    let tts: Vec<_> = t.into_iter().collect();
    239    tts.split(|tt| match tt {
    240        TokenTree::Punct(p) => p.as_char() == ',',
    241        _ => false,
    242    })
    243    .filter(|parts| !parts.is_empty())
    244    .map(|parts| {
    245        parts
    246            .into_iter()
    247            .map(|part| part.to_string())
    248            .collect::<String>()
    249    })
    250    .collect()
    251}
    252
    253fn to_token_stream(code: &str) -> TokenStream {
    254    code.parse().unwrap()
    255}
    256
    257static VERSION: std::sync::LazyLock<(u32, bool)> = LazyLock::new(|| {
    258    let output = Command::new("rustc")
    259        .arg("-V")
    260        .output()
    261        .expect("rustc should run");
    262    let stdout = std::str::from_utf8(&output.stdout).expect("utf8");
    263    let vers = stdout.split_whitespace().skip(1).next().unwrap();
    264    let is_nightly = option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_none()
    265        && (vers.contains("-nightly") || vers.contains("-dev"));
    266    let minor = vers.split('.').skip(1).next().unwrap().parse().unwrap();
    267    (minor, is_nightly)
    268});
    269
    270fn version() -> (u32, bool) {
    271    LazyLock::force(&VERSION).clone()
    272}
    273
    274fn check_command(command_path: &Path, args: &[&str]) -> bool {
    275    let mut command = Command::new(command_path);
    276    let command_name = command.get_program().to_str().unwrap().to_owned();
    277    command.args(args);
    278    let output = match command.output() {
    279        Ok(output) => output,
    280        Err(e) => {
    281            // * hg is not installed on GitHub macOS or certain constrained
    282            //   environments like Docker. Consider installing it if Cargo
    283            //   gains more hg support, but otherwise it isn't critical.
    284            // * lldb is not pre-installed on Ubuntu and Windows, so skip.
    285            if is_ci() && !matches!(command_name.as_str(), "hg" | "lldb") {
    286                panic!("expected command `{command_name}` to be somewhere in PATH: {e}",);
    287            }
    288            return false;
    289        }
    290    };
    291    if !output.status.success() {
    292        // Debian specific patch, upstream wontfix:
    293        // qemu has a faulty vfork where it fails to fail if a command is not
    294        // found, with a unix_wait_status of 32512, or 0x7f00, 7f meaning
    295        // exit code 127. See https://github.com/rust-lang/rust/issues/90825
    296        use std::os::unix::process::ExitStatusExt;
    297        if output.status.into_raw() == 0x7f00 {
    298            return false;
    299        }
    300        panic!(
    301            "expected command `{command_name}` to be runnable, got error {}:\n\
    302            stderr:{}\n\
    303            stdout:{}\n",
    304            output.status,
    305            String::from_utf8_lossy(&output.stderr),
    306            String::from_utf8_lossy(&output.stdout)
    307        );
    308    }
    309    true
    310}
    311
    312fn has_command(command: &str) -> bool {
    313    check_command(Path::new(command), &["--version"])
    314}
    315
    316fn has_rustup_stable() -> bool {
    317    if option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_some() {
    318        // This cannot run on rust-lang/rust CI due to the lack of rustup.
    319        return false;
    320    }
    321    // Cargo mucks with PATH on Windows, adding sysroot host libdir, which is
    322    // "bin", which circumvents the rustup wrapper. Use the path directly from
    323    // CARGO_HOME.
    324    let home = match option_env!("CARGO_HOME") {
    325        Some(home) => home,
    326        None if is_ci() => panic!("expected to run under rustup"),
    327        None => return false,
    328    };
    329    let cargo = Path::new(home).join("bin/cargo");
    330    check_command(&cargo, &["+stable", "--version"])
    331}
    332
    333/// Whether or not this running in a Continuous Integration environment.
    334fn is_ci() -> bool {
    335    // Consider using `tracked_env` instead of option_env! when it is stabilized.
    336    // `tracked_env` will handle changes, but not require rebuilding the macro
    337    // itself like option_env does.
    338    option_env!("CI").is_some() || option_env!("TF_BUILD").is_some()
    339}