diff options
Diffstat (limited to 'crates/cargo-test-macro/src')
-rw-r--r-- | crates/cargo-test-macro/src/lib.rs | 245 |
1 files changed, 245 insertions, 0 deletions
diff --git a/crates/cargo-test-macro/src/lib.rs b/crates/cargo-test-macro/src/lib.rs new file mode 100644 index 0000000..aa06f47 --- /dev/null +++ b/crates/cargo-test-macro/src/lib.rs @@ -0,0 +1,245 @@ +extern crate proc_macro; + +use proc_macro::*; +use std::process::Command; +use std::sync::Once; + +#[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"); + } + s if s.starts_with("requires_") => { + let command = &s[9..]; + 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 mut VERSION: (u32, bool) = (0, false); + +fn version() -> &'static (u32, bool) { + static INIT: Once = Once::new(); + INIT.call_once(|| { + 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(); + unsafe { VERSION = (minor, is_nightly) } + }); + unsafe { &VERSION } +} + +fn has_command(command: &str) -> bool { + let output = match Command::new(command).arg("--version").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. + if is_ci() && command != "hg" { + panic!( + "expected command `{}` to be somewhere in PATH: {}", + command, e + ); + } + return false; + } + }; + if !output.status.success() { + panic!( + "expected command `{}` to be runnable, got error {}:\n\ + stderr:{}\n\ + stdout:{}\n", + command, + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + } + true +} + +/// 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() +} |