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 panic!(
293 "expected command `{command_name}` to be runnable, got error {}:\n\
294 stderr:{}\n\
295 stdout:{}\n",
296 output.status,
297 String::from_utf8_lossy(&output.stderr),
298 String::from_utf8_lossy(&output.stdout)
299 );
300 }
301 true
302}
303
304fn has_command(command: &str) -> bool {
305 check_command(Path::new(command), &["--version"])
306}
307
308fn has_rustup_stable() -> bool {
309 if option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_some() {
310 // This cannot run on rust-lang/rust CI due to the lack of rustup.
311 return false;
312 }
313 // Cargo mucks with PATH on Windows, adding sysroot host libdir, which is
314 // "bin", which circumvents the rustup wrapper. Use the path directly from
315 // CARGO_HOME.
316 let home = match option_env!("CARGO_HOME") {
317 Some(home) => home,
318 None if is_ci() => panic!("expected to run under rustup"),
319 None => return false,
320 };
321 let cargo = Path::new(home).join("bin/cargo");
322 check_command(&cargo, &["+stable", "--version"])
323}
324
325/// Whether or not this running in a Continuous Integration environment.
326fn is_ci() -> bool {
327 // Consider using `tracked_env` instead of option_env! when it is stabilized.
328 // `tracked_env` will handle changes, but not require rebuilding the macro
329 // itself like option_env does.
330 option_env!("CI").is_some() || option_env!("TF_BUILD").is_some()
331}