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