Skip to main content

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 run in Cargo's CI when CARGO_TEST_REQUIRE_EXTERNAL_TOOLS is set.
40///   Other environments, including rust-lang/rust's CI, ignore the test when the command is unavailable.
41///   This is mainly used to avoid requiring contributors from having every dependency installed.
42/// * `requires_host_split_debuginfo = "<value>"` --- This indicates a `-Csplit-debuginfo` value
43///   that is required to be supported by the host `rustc`.
44/// * `build_std_real` --- This is a "real" `-Zbuild-std` test (in the `build_std` integration test).
45///   This only runs on nightly, and only if the environment variable `CARGO_RUN_BUILD_STD_TESTS` is set (these tests on run on Linux).
46/// * `build_std_mock` --- This is a "mock" `-Zbuild-std` test (which uses a mock standard library).
47///   This only runs on nightly, and is disabled for windows-gnu.
48/// * `public_network_test` --- This tests contacts the public internet.
49///   These tests are disabled unless the `CARGO_PUBLIC_NETWORK_TESTS` environment variable is set.
50///   Use of this should be *extremely rare*, please avoid using it if possible.
51///   The hosts it contacts should have a relatively high confidence that they are reliable and stable (such as github.com), especially in CI.
52///   The tests should be carefully considered for developer security and privacy as well.
53/// * `container_test` --- This indicates that it is a test that uses Docker.
54///   These tests are disabled unless the `CARGO_CONTAINER_TESTS` environment variable is set.
55///   This requires that you have Docker installed.
56///   The SSH tests also assume that you have OpenSSH installed.
57///   These should work on Linux, macOS, and Windows where possible.
58///   Unfortunately these tests are not run in CI for macOS or Windows (no Docker on macOS, and Windows does not support Linux images).
59///   See [`cargo-test-support::containers`](https://doc.rust-lang.org/nightly/nightly-rustc/cargo_test_support/containers) for more on writing these tests.
60/// * `ignore_windows="reason"` --- Indicates that the test should be ignored on windows for the given reason.
61#[proc_macro_attribute]
62pub fn cargo_test(attr: TokenStream, item: TokenStream) -> TokenStream {
63    // Ideally these options would be embedded in the test itself. However, I
64    // find it very helpful to have the test clearly state whether or not it
65    // is ignored. It would be nice to have some kind of runtime ignore
66    // support (such as
67    // https://internals.rust-lang.org/t/pre-rfc-skippable-tests/14611).
68    //
69    // Unfortunately a big drawback here is that if the environment changes
70    // (such as the existence of the `git` CLI), this will not trigger a
71    // rebuild and the test will still be ignored. In theory, something like
72    // `tracked_env` or `tracked_path`
73    // (https://github.com/rust-lang/rust/issues/99515) could help with this,
74    // but they don't really handle the absence of files well.
75    let mut ignore = false;
76    let mut requires_reason = false;
77    let mut explicit_reason = None;
78    let mut implicit_reasons = Vec::new();
79    macro_rules! set_ignore {
80        ($predicate:expr, $($arg:tt)*) => {
81            let p = $predicate;
82            ignore |= p;
83            if p {
84                implicit_reasons.push(std::fmt::format(format_args!($($arg)*)));
85            }
86        };
87    }
88    let is_not_nightly = !version().1;
89    for rule in split_rules(attr) {
90        match rule.as_str() {
91            "build_std_real" => {
92                // Only run the "real" build-std tests on nightly and with an
93                // explicit opt-in (these generally only work on linux, and
94                // have some extra requirements, and are slow, and can pollute
95                // the environment since it downloads dependencies).
96                set_ignore!(is_not_nightly, "requires nightly");
97                set_ignore!(
98                    option_env!("CARGO_RUN_BUILD_STD_TESTS").is_none(),
99                    "CARGO_RUN_BUILD_STD_TESTS must be set"
100                );
101            }
102            "build_std_mock" => {
103                // Only run the "mock" build-std tests on nightly and disable
104                // for windows-gnu which is missing object files (see
105                // https://github.com/rust-lang/wg-cargo-std-aware/issues/46).
106                set_ignore!(is_not_nightly, "requires nightly");
107                set_ignore!(
108                    cfg!(all(target_os = "windows", target_env = "gnu")),
109                    "does not work on windows-gnu"
110                );
111            }
112            "container_test" => {
113                // These tests must be opt-in because they require docker.
114                set_ignore!(
115                    option_env!("CARGO_CONTAINER_TESTS").is_none(),
116                    "CARGO_CONTAINER_TESTS must be set"
117                );
118            }
119            "public_network_test" => {
120                // These tests must be opt-in because they touch the public
121                // network. The use of these should be **EXTREMELY RARE**, and
122                // should only touch things which would nearly certainly work
123                // in CI (like github.com).
124                set_ignore!(
125                    option_env!("CARGO_PUBLIC_NETWORK_TESTS").is_none(),
126                    "CARGO_PUBLIC_NETWORK_TESTS must be set"
127                );
128            }
129            "nightly" => {
130                requires_reason = true;
131                set_ignore!(is_not_nightly, "requires nightly");
132            }
133            "requires_rustup_stable" => {
134                set_ignore!(
135                    !has_rustup_stable(),
136                    "rustup or stable toolchain not installed"
137                );
138            }
139            s if s.starts_with("requires=") => {
140                let command = &s[9..];
141                let Ok(literal) = command.parse::<Literal>() else {
142                    panic!("expect a string literal, found: {command}");
143                };
144                let literal = literal.to_string();
145                let Some(command) = literal
146                    .strip_prefix('"')
147                    .and_then(|lit| lit.strip_suffix('"'))
148                else {
149                    panic!("expect a quoted string literal, found: {literal}");
150                };
151                set_ignore!(!has_command(command), "{command} not installed");
152            }
153            s if s.starts_with("requires_host_split_debuginfo=") => {
154                let split_debuginfo = &s[30..];
155                let Ok(literal) = split_debuginfo.parse::<Literal>() else {
156                    panic!("expect a string literal, found: {split_debuginfo}");
157                };
158                let literal = literal.to_string();
159                let Some(split_debuginfo) = literal
160                    .strip_prefix('"')
161                    .and_then(|lit| lit.strip_suffix('"'))
162                else {
163                    panic!("expect a quoted string literal, found: {literal}");
164                };
165                set_ignore!(
166                    !host_supports_split_debuginfo(split_debuginfo),
167                    "host rustc does not support -Csplit-debuginfo={split_debuginfo}"
168                );
169            }
170            s if s.starts_with(">=1.") => {
171                requires_reason = true;
172                let min_minor = s[4..].parse().unwrap();
173                let minor = version().0;
174                set_ignore!(minor < min_minor, "requires rustc 1.{minor} or newer");
175            }
176            s if s.starts_with("reason=") => {
177                explicit_reason = Some(s[7..].parse().unwrap());
178            }
179            s if s.starts_with("ignore_windows=") => {
180                set_ignore!(cfg!(windows), "{}", &s[16..s.len() - 1]);
181            }
182            _ => panic!("unknown rule {:?}", rule),
183        }
184    }
185    if requires_reason && explicit_reason.is_none() {
186        panic!(
187            "#[cargo_test] with a rule also requires a reason, \
188            such as #[cargo_test(nightly, reason = \"needs -Z unstable-thing\")]"
189        );
190    }
191
192    // Construct the appropriate attributes.
193    let span = Span::call_site();
194    let mut ret = TokenStream::new();
195    let add_attr = |ret: &mut TokenStream, attr_name, attr_input| {
196        ret.extend(Some(TokenTree::from(Punct::new('#', Spacing::Alone))));
197        let attr = TokenTree::from(Ident::new(attr_name, span));
198        let mut attr_stream: TokenStream = attr.into();
199        if let Some(input) = attr_input {
200            attr_stream.extend(input);
201        }
202        ret.extend(Some(TokenTree::from(Group::new(
203            Delimiter::Bracket,
204            attr_stream,
205        ))));
206    };
207    add_attr(&mut ret, "test", None);
208    if ignore {
209        let reason = explicit_reason
210            .or_else(|| {
211                (!implicit_reasons.is_empty())
212                    .then(|| TokenTree::from(Literal::string(&implicit_reasons.join(", "))).into())
213            })
214            .map(|reason: TokenStream| {
215                let mut stream = TokenStream::new();
216                stream.extend(Some(TokenTree::from(Punct::new('=', Spacing::Alone))));
217                stream.extend(Some(reason));
218                stream
219            });
220        add_attr(&mut ret, "ignore", reason);
221    }
222
223    let mut test_name = None;
224    let mut num = 0;
225
226    // Find where the function body starts, and add the boilerplate at the start.
227    for token in item {
228        let group = match token {
229            TokenTree::Group(g) => {
230                if g.delimiter() == Delimiter::Brace {
231                    g
232                } else {
233                    ret.extend(Some(TokenTree::Group(g)));
234                    continue;
235                }
236            }
237            TokenTree::Ident(i) => {
238                // The first time through it will be `fn` the second time is the
239                // name of the test.
240                if test_name.is_none() && num == 1 {
241                    test_name = Some(i.to_string())
242                } else {
243                    num += 1;
244                }
245                ret.extend(Some(TokenTree::Ident(i)));
246                continue;
247            }
248            other => {
249                ret.extend(Some(other));
250                continue;
251            }
252        };
253
254        let name = &test_name
255            .clone()
256            .map(|n| n.split("::").next().unwrap().to_string())
257            .unwrap();
258
259        let mut new_body = to_token_stream(&format!(
260            r#"let _test_guard = {{
261                let tmp_dir = env!("CARGO_TARGET_TMPDIR");
262                let test_dir = cargo_test_support::paths::test_dir(std::file!(), "{name}");
263                cargo_test_support::paths::init_root(tmp_dir, test_dir)
264            }};"#
265        ));
266
267        new_body.extend(group.stream());
268        ret.extend(Some(TokenTree::from(Group::new(
269            group.delimiter(),
270            new_body,
271        ))));
272    }
273
274    ret
275}
276
277fn split_rules(t: TokenStream) -> Vec<String> {
278    let tts: Vec<_> = t.into_iter().collect();
279    tts.split(|tt| match tt {
280        TokenTree::Punct(p) => p.as_char() == ',',
281        _ => false,
282    })
283    .filter(|parts| !parts.is_empty())
284    .map(|parts| {
285        parts
286            .into_iter()
287            .map(|part| part.to_string())
288            .collect::<String>()
289    })
290    .collect()
291}
292
293fn to_token_stream(code: &str) -> TokenStream {
294    code.parse().unwrap()
295}
296
297static VERSION: std::sync::LazyLock<(u32, bool)> = LazyLock::new(|| {
298    let output = Command::new("rustc")
299        .arg("-V")
300        .output()
301        .expect("rustc should run");
302
303    let exit_code = output.status.code();
304
305    if exit_code != Some(0) {
306        let stdout = std::str::from_utf8(&output.stdout).unwrap_or("<invalid utf8>");
307        let stderr = std::str::from_utf8(&output.stderr).unwrap_or("<invalid utf8>");
308        // On Windows, exit code `0xC0000017` is not very well documented but appears to
309        // usually happen when a command length exceeds the windows limit.
310        // Since we are just running `rustc -V` it likely that the PATH got too long.
311        // This can happen when Cargo's tests are executed from rustc bootstrap.
312        let extra = if exit_code.map(|c| c as u32) == Some(0xC0000017) {
313            "This likely indicates that PATH is too large for Windows.\n"
314        } else {
315            ""
316        };
317        panic!(
318            "'rustc -V' exited with non-zero exit code: {exit_code:?}\n{extra}stdout: {stdout}\nstderr: {stderr}",
319        );
320    }
321
322    let stdout = std::str::from_utf8(&output.stdout).expect("utf8");
323    let vers = stdout
324        .split_whitespace()
325        .skip(1)
326        .next()
327        .expect("version should have contain at least one space");
328    let is_nightly = option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_none()
329        && (vers.contains("-nightly") || vers.contains("-dev"));
330    let minor = vers
331        .split('.')
332        .skip(1)
333        .next()
334        .expect("malformed rustc version (not semver)")
335        .parse()
336        .expect("malformed rustc version (minor version was not u32)");
337    (minor, is_nightly)
338});
339
340fn version() -> (u32, bool) {
341    LazyLock::force(&VERSION).clone()
342}
343
344fn host_supports_split_debuginfo(split_debuginfo: &str) -> bool {
345    static SPLIT_DEBUGINFO: LazyLock<Vec<String>> = LazyLock::new(|| {
346        let output = Command::new("rustc")
347            .arg("--print=split-debuginfo")
348            .output()
349            .expect("rustc should run");
350
351        let exit_code = output.status.code();
352        if exit_code != Some(0) {
353            let stdout = std::str::from_utf8(&output.stdout).unwrap_or("<invalid utf8>");
354            let stderr = std::str::from_utf8(&output.stderr).unwrap_or("<invalid utf8>");
355            panic!(
356                "'rustc --print=split-debuginfo' exited with non-zero exit code: {exit_code:?}\n\
357                stdout: {stdout}\n\
358                stderr: {stderr}",
359            );
360        }
361
362        let stdout = std::str::from_utf8(&output.stdout).expect("utf8");
363        stdout.lines().map(str::to_owned).collect()
364    });
365
366    SPLIT_DEBUGINFO
367        .iter()
368        .any(|supported| supported == split_debuginfo)
369}
370
371fn check_command(command_path: &Path, args: &[&str]) -> bool {
372    let mut command = Command::new(command_path);
373    let command_name = command.get_program().to_str().unwrap().to_owned();
374    command.args(args);
375    let output = match command.output() {
376        Ok(output) => output,
377        Err(e) => {
378            if is_ci() {
379                panic!("expected command `{command_name}` to be somewhere in PATH: {e}",);
380            }
381            return false;
382        }
383    };
384    if !output.status.success() {
385        panic!(
386            "expected command `{command_name}` to be runnable, got error {}:\n\
387            stderr:{}\n\
388            stdout:{}\n",
389            output.status,
390            String::from_utf8_lossy(&output.stderr),
391            String::from_utf8_lossy(&output.stdout)
392        );
393    }
394    true
395}
396
397fn has_command(command: &str) -> bool {
398    use std::env::consts::EXE_EXTENSION;
399    // ALLOWED: For testing cargo itself only.
400    #[allow(clippy::disallowed_methods)]
401    let Some(paths) = std::env::var_os("PATH") else {
402        return false;
403    };
404    let found = std::env::split_paths(&paths)
405        .flat_map(|path| {
406            let candidate = path.join(&command);
407            let with_exe = if EXE_EXTENSION.is_empty() {
408                None
409            } else {
410                Some(candidate.with_extension(EXE_EXTENSION))
411            };
412            std::iter::once(candidate).chain(with_exe)
413        })
414        .find(|p| is_executable(p))
415        .is_some();
416    // * hg is not installed on GitHub macOS or certain constrained
417    //   environments like Docker. Consider installing it if Cargo
418    //   gains more hg support, but otherwise it isn't critical.
419    if !found && requires_external_tools() && command != "hg" {
420        panic!("expected command `{command}` to be somewhere in PATH");
421    }
422    found
423}
424
425#[cfg(unix)]
426fn is_executable<P: AsRef<Path>>(path: P) -> bool {
427    use std::os::unix::prelude::*;
428    std::fs::metadata(path)
429        .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
430        .unwrap_or(false)
431}
432
433#[cfg(windows)]
434fn is_executable<P: AsRef<Path>>(path: P) -> bool {
435    path.as_ref().is_file()
436}
437
438fn has_rustup_stable() -> bool {
439    if option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_some() {
440        // This cannot run on rust-lang/rust CI due to the lack of rustup.
441        return false;
442    }
443    // Cargo mucks with PATH on Windows, adding sysroot host libdir, which is
444    // "bin", which circumvents the rustup wrapper. Use the path directly from
445    // CARGO_HOME.
446    let home = match option_env!("CARGO_HOME") {
447        Some(home) => home,
448        None if is_ci() => panic!("expected to run under rustup"),
449        None => return false,
450    };
451    let cargo = Path::new(home).join("bin/cargo");
452    check_command(&cargo, &["+stable", "--version"])
453}
454
455/// Whether or not this running in a Continuous Integration environment.
456fn is_ci() -> bool {
457    // Consider using `tracked_env` instead of option_env! when it is stabilized.
458    // `tracked_env` will handle changes, but not require rebuilding the macro
459    // itself like option_env does.
460    option_env!("CI").is_some() || option_env!("TF_BUILD").is_some()
461}
462
463/// Whether to enforce external tools (`requires=<tool>`) availability.
464fn requires_external_tools() -> bool {
465    option_env!("CARGO_TEST_REQUIRE_EXTERNAL_TOOLS").is_some()
466}