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"
                );
            }
            "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());
            }
            _ => 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()
}
