# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.


# Rust is required by `rust_compiler` below. We allow_missing here
# to propagate failures to the better error message there.
option(env="RUSTC", nargs=1, help="Path to the rust compiler")
option(env="CARGO", nargs=1, help="Path to the Cargo package manager")

rustc = check_prog(
    "_RUSTC",
    ["rustc"],
    what="rustc",
    paths=rust_search_path,
    input="RUSTC",
    allow_missing=True,
)
cargo = check_prog(
    "_CARGO",
    ["cargo"],
    what="cargo",
    paths=rust_search_path,
    input="CARGO",
    allow_missing=True,
)


@template
def unwrap_rustup(prog, name):
    # rustc and cargo can either be rustup wrappers, or they can be the actual,
    # plain executables. For cargo, on OSX, rustup sets DYLD_LIBRARY_PATH (at
    # least until https://github.com/rust-lang/rustup.rs/pull/1752 is merged
    # and shipped) and that can wreak havoc (see bug 1536486). Similarly, for
    # rustc, rustup silently honors toolchain overrides set by vendored crates
    # (see bug 1547196).
    #
    # In either case, we need to find the plain executables.
    #
    # To achieve that, try to run `PROG +stable`. When the rustup wrapper is in
    # use, it either prints PROG's help and exits with status 0, or prints
    # an error message (error: toolchain 'stable' is not installed) and exits
    # with status 1. In the cargo case, when plain cargo is in use, it exits
    # with a different error message (e.g. "error: no such subcommand:
    # `+stable`"), and exits with status 101.
    #
    # Unfortunately, in the rustc case, when plain rustc is in use,
    # `rustc +stable` will exit with status 1, complaining about a missing
    # "+stable" file. We'll examine the error output to try and distinguish
    # between failing rustup and failing rustc.
    @depends(prog, dependable(name))
    @imports(_from="__builtin__", _import="open")
    @imports("os")
    def unwrap(prog, name):
        if not prog:
            return

        def from_rustup_which():
            out = check_cmd_output("rustup", "which", name, executable=prog).rstrip()
            # If for some reason the above failed to return something, keep the
            # PROG we found originally.
            if out:
                log.info("Actually using '%s'", out)
                return out

            log.info("No `rustup which` output, using '%s'", prog)
            return prog

        (retcode, stdout, stderr) = get_cmd_output(prog, "+stable")

        if name == "cargo" and retcode != 101:
            prog = from_rustup_which()
        elif name == "rustc":
            if retcode == 0:
                prog = from_rustup_which()
            elif "+stable" in stderr:
                # PROG looks like plain `rustc`.
                pass
            else:
                # Assume PROG looks like `rustup`. This case is a little weird,
                # insofar as the user doesn't have the "stable" toolchain
                # installed, but go ahead and unwrap anyway: the user might
                # have only certain versions, beta, or nightly installed, and
                # we'll catch invalid versions later.
                prog = from_rustup_which()

        return normalize_path(prog)

    return unwrap


rustc = unwrap_rustup(rustc, "rustc")
cargo = unwrap_rustup(cargo, "cargo")


set_config("CARGO", cargo)
set_config("RUSTC", rustc)


@depends_if(rustc)
@checking("rustc version", lambda info: info.version)
def rustc_info(rustc):
    if not rustc:
        return
    out = check_cmd_output(rustc, "--version", "--verbose").splitlines()
    info = dict((s.strip() for s in line.split(":", 1)) for line in out[1:])
    return namespace(
        version=Version(info.get("release", "0")),
        commit=info.get("commit-hash", "unknown"),
        host=info["host"],
        llvm_version=Version(info.get("LLVM version", "0")),
    )


set_config(
    "RUSTC_VERSION",
    depends(rustc_info)(lambda info: str(info.version) if info else None),
)


set_config(
    "RUSTC_LLVM_VERSION",
    depends(rustc_info)(lambda info: str(info.llvm_version) if info else None),
)

set_config(
    "MOZ_CLANG_NEWER_THAN_RUSTC_LLVM",
    depends(c_compiler, rustc_info)(
        lambda c_compiler, rustc_info: rustc_info
        and c_compiler.type == "clang"
        and c_compiler.version.major > rustc_info.llvm_version.major
    ),
)


@depends_if(cargo)
@checking("cargo version", lambda info: info.version)
@imports("re")
def cargo_info(cargo):
    if not cargo:
        return
    out = check_cmd_output(cargo, "--version", "--verbose").splitlines()
    info = dict((s.strip() for s in line.split(":", 1)) for line in out[1:])
    version = info.get("release")
    # Older versions of cargo didn't support --verbose, in which case, they
    # only output a not-really-pleasant-to-parse output. Fortunately, they
    # don't error out, so we can just try some regexp matching on the output
    # we already got.
    if version is None:
        VERSION_FORMAT = r"^cargo (\d\.\d+\.\d+).*"

        m = re.search(VERSION_FORMAT, out[0])
        # Fail fast if cargo changes its output on us.
        if not m:
            die("Could not determine cargo version from output: %s", out)
        version = m.group(1)

    return namespace(
        version=Version(version),
    )


@depends(rustc_info, cargo_info, target)
@imports(_from="mozboot.util", _import="MINIMUM_RUST_VERSION")
@imports(_from="textwrap", _import="dedent")
def rust_compiler(rustc_info, cargo_info, target):
    if not rustc_info:
        die(
            dedent(
                """\
        Rust compiler not found.
        To compile rust language sources, you must have 'rustc' in your path.
        See https://www.rust-lang.org/ for more information.

        You can install rust by running './mach bootstrap'
        or by directly running the installer from https://rustup.rs/
        """
            )
        )
    rustc_min_version = Version(MINIMUM_RUST_VERSION)
    cargo_min_version = rustc_min_version

    version = rustc_info.version
    is_nightly = "nightly" in version.version
    is_version_number_match = (
        version.major == rustc_min_version.major
        and version.minor == rustc_min_version.minor
        and version.patch == rustc_min_version.patch
    )

    if version < rustc_min_version or (is_version_number_match and is_nightly):
        die(
            dedent(
                """\
        Rust compiler {} is too old.

        To compile Rust language sources please install at least
        version {} of the 'rustc' toolchain (or, if using nightly,
        at least one version newer than {}) and make sure it is
        first in your path.

        You can verify this by typing 'rustc --version'.

        If you have the 'rustup' tool installed you can upgrade
        to the latest release by typing 'rustup update'. The
        installer is available from https://rustup.rs/
        """.format(
                    version, rustc_min_version, rustc_min_version
                )
            )
        )

    if target.kernel == "WINNT" and (version.major, version.minor) == (1, 56):
        die(
            dedent(
                """\
        Rust compiler 1.56.* is not supported for Windows builds.

        Use a newer or an older version.

        See https://github.com/rust-lang/rust/issues/88576.
        """
            )
        )

    if not cargo_info:
        die(
            dedent(
                """\
        Cargo package manager not found.
        To compile Rust language sources, you must have 'cargo' in your path.
        See https://www.rust-lang.org/ for more information.

        You can install cargo by running './mach bootstrap'
        or by directly running the installer from https://rustup.rs/
        """
            )
        )

    version = cargo_info.version
    if version < cargo_min_version:
        die(
            dedent(
                """\
        Cargo package manager {} is too old.

        To compile Rust language sources please install at least
        version {} of 'cargo' and make sure it is first in your path.

        You can verify this by typing 'cargo --version'.
        """
            ).format(version, cargo_min_version)
        )

    return True


@depends(rustc, when=rust_compiler)
@imports(_from="__builtin__", _import="ValueError")
def rust_supported_targets(rustc):
    out = check_cmd_output(rustc, "--print", "target-list").splitlines()
    data = {}
    for t in out:
        try:
            info = split_triplet(t, allow_wasi=True)
        except ValueError:
            if t.startswith("thumb"):
                cpu, rest = t.split("-", 1)
                retry = "-".join(("arm", rest))
            else:
                continue
            try:
                info = split_triplet(retry, allow_wasi=True)
            except ValueError:
                continue
        key = (info.cpu, info.endianness, info.os)
        data.setdefault(key, []).append(namespace(rust_target=t, target=info))
    return data


def detect_rustc_target(
    host_or_target, compiler_info, arm_target, rust_supported_targets
):
    # Rust's --target options are similar to, but not exactly the same
    # as, the autoconf-derived targets we use.  An example would be that
    # Rust uses distinct target triples for targetting the GNU C++ ABI
    # and the MSVC C++ ABI on Win32, whereas autoconf has a single
    # triple and relies on the user to ensure that everything is
    # compiled for the appropriate ABI.  We need to perform appropriate
    # munging to get the correct option to rustc.
    # We correlate the autoconf-derived targets with the list of targets
    # rustc gives us with --print target-list.
    candidates = rust_supported_targets.get(
        (host_or_target.cpu, host_or_target.endianness, host_or_target.os), []
    )

    def find_candidate(candidates):
        if len(candidates) == 1:
            return candidates[0].rust_target
        elif not candidates:
            return None

        # We have multiple candidates. There are two cases where we can try to
        # narrow further down using extra information from the build system.
        # - For windows targets, correlate with the C compiler type
        if host_or_target.kernel == "WINNT":
            if host_or_target.abi:
                if host_or_target.abi == "msvc":
                    suffix = "windows-msvc"
                elif host_or_target.abi == "mingw":
                    suffix = "windows-gnu"
            elif compiler_info.type in ("gcc", "clang"):
                suffix = "windows-gnu"
            else:
                suffix = "windows-msvc"
            narrowed = [
                c for c in candidates if c.rust_target.endswith("-{}".format(suffix))
            ]
            if len(narrowed) == 1:
                return narrowed[0].rust_target
            elif narrowed:
                candidates = narrowed

            vendor_aliases = {"pc": ("w64", "windows")}
            narrowed = [
                c
                for c in candidates
                if host_or_target.vendor in vendor_aliases.get(c.target.vendor, ())
            ]

            if len(narrowed) == 1:
                return narrowed[0].rust_target

        # - For arm targets, correlate with arm_target
        #   we could be more thorough with the supported rust targets, but they
        #   don't support OSes that are supported to build Gecko anyways.
        #   Also, sadly, the only interface to check the rust target cpu features
        #   is --print target-spec-json, and it's unstable, so we have to rely on
        #   our own knowledge of what each arm target means.
        if host_or_target.cpu == "arm" and host_or_target.endianness == "little":
            prefixes = []
            if arm_target.arm_arch >= 7:
                if arm_target.thumb2 and arm_target.fpu == "neon":
                    prefixes.append("thumbv7neon")
                if arm_target.thumb2:
                    prefixes.append("thumbv7a")
                prefixes.append("armv7")
            if arm_target.arm_arch >= 6:
                prefixes.append("armv6")
                if host_or_target.os != "Android":
                    # arm-* rust targets are armv6... except arm-linux-androideabi
                    prefixes.append("arm")
            if arm_target.arm_arch >= 5:
                prefixes.append("armv5te")
                if host_or_target.os == "Android":
                    # arm-* rust targets are armv6... except arm-linux-androideabi
                    prefixes.append("arm")
            if arm_target.arm_arch >= 4:
                prefixes.append("armv4t")
            # rust freebsd targets are the only ones that don't have a 'hf' suffix
            # for hard-float. Technically, that means if the float abi ever is not
            # hard-float, this will pick a wrong target, but since rust only
            # supports hard-float, let's assume that means freebsd only support
            # hard-float.
            if arm_target.float_abi == "hard" and host_or_target.os != "FreeBSD":
                suffix = "hf"
            else:
                suffix = ""
            for p in prefixes:
                for c in candidates:
                    if c.rust_target.startswith(
                        "{}-".format(p)
                    ) and c.rust_target.endswith(suffix):
                        return c.rust_target

        # See if we can narrow down on the exact alias.
        # We use the sub_configure_alias to keep support mingw32 triplets as input.
        narrowed = [
            c
            for c in candidates
            if c.target.sub_configure_alias == host_or_target.sub_configure_alias
        ]
        if len(narrowed) == 1:
            return narrowed[0].rust_target
        elif narrowed:
            candidates = narrowed

        # See if we can narrow down with the raw OS
        narrowed = [c for c in candidates if c.target.raw_os == host_or_target.raw_os]
        if len(narrowed) == 1:
            return narrowed[0].rust_target
        elif narrowed:
            candidates = narrowed

        # See if we can narrow down with the raw OS and raw CPU
        narrowed = [
            c
            for c in candidates
            if c.target.raw_os == host_or_target.raw_os
            and c.target.raw_cpu == host_or_target.raw_cpu
        ]
        if len(narrowed) == 1:
            return narrowed[0].rust_target

        # Finally, see if the vendor can be used to disambiguate.
        narrowed = [c for c in candidates if c.target.vendor == host_or_target.vendor]
        if len(narrowed) == 1:
            return narrowed[0].rust_target

        return None

    rustc_target = find_candidate(candidates)

    if rustc_target is None:
        die("Don't know how to translate {} for rustc".format(host_or_target.alias))

    return rustc_target


@imports("os")
@imports(_from="tempfile", _import="mkstemp")
@imports(_from="textwrap", _import="dedent")
@imports(_from="mozbuild.configure.util", _import="LineIO")
def assert_rust_compile(host_or_target, rustc_target, rustc):
    # Check to see whether our rustc has a reasonably functional stdlib
    # for our chosen target.
    target_arg = "--target=" + rustc_target
    in_fd, in_path = mkstemp(prefix="conftest", suffix=".rs", text=True)
    out_fd, out_path = mkstemp(prefix="conftest", suffix=".rlib")
    os.close(out_fd)
    try:
        source = b'pub extern fn hello() { println!("Hello world"); }'
        log.debug("Creating `%s` with content:", in_path)
        with LineIO(lambda l: log.debug("| %s", l)) as out:
            out.write(source)

        os.write(in_fd, source)
        os.close(in_fd)

        cmd = [
            rustc,
            "--crate-type",
            "staticlib",
            target_arg,
            "-o",
            out_path,
            in_path,
        ]

        def failed():
            die(
                dedent(
                    """\
            Cannot compile for {} with {}
            The target may be unsupported, or you may not have
            a rust std library for that target installed. Try:

              rustup target add {}
            """.format(
                        host_or_target.alias, rustc, rustc_target
                    )
                )
            )

        check_cmd_output(*cmd, onerror=failed)
        if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
            failed()
    finally:
        os.remove(in_path)
        os.remove(out_path)


@depends(
    rustc,
    host,
    host_c_compiler,
    rustc_info.host,
    rust_supported_targets,
    arm_target,
    when=rust_compiler,
)
@checking("for rust host triplet")
@imports(_from="textwrap", _import="dedent")
def rust_host_triple(
    rustc, host, compiler_info, rustc_host, rust_supported_targets, arm_target
):
    rustc_target = detect_rustc_target(
        host, compiler_info, arm_target, rust_supported_targets
    )
    if rustc_target != rustc_host:
        if host.alias == rustc_target:
            configure_host = host.alias
        else:
            configure_host = "{}/{}".format(host.alias, rustc_target)
        die(
            dedent(
                """\
        The rust compiler host ({rustc}) is not suitable for the configure host ({configure}).

        You can solve this by:
        * Set your configure host to match the rust compiler host by editing your
        mozconfig and adding "ac_add_options --host={rustc}".
        * Or, install the rust toolchain for {configure}, if supported, by running
        "rustup default stable-{rustc_target}"
        """.format(
                    rustc=rustc_host,
                    configure=configure_host,
                    rustc_target=rustc_target,
                )
            )
        )
    assert_rust_compile(host, rustc_target, rustc)
    return rustc_target


@depends(
    rustc, target, c_compiler, rust_supported_targets, arm_target, when=rust_compiler
)
@checking("for rust target triplet")
def rust_target_triple(
    rustc, target, compiler_info, rust_supported_targets, arm_target
):
    rustc_target = detect_rustc_target(
        target, compiler_info, arm_target, rust_supported_targets
    )
    assert_rust_compile(target, rustc_target, rustc)
    return rustc_target


set_config("RUST_TARGET", rust_target_triple)
set_config("RUST_HOST_TARGET", rust_host_triple)


# This is used for putting source info into symbol files.
set_config("RUSTC_COMMIT", depends(rustc_info)(lambda i: i.commit))

# Rustdoc is required by Rust tests below.
option(env="RUSTDOC", nargs=1, help="Path to the rustdoc program")

rustdoc = check_prog(
    "RUSTDOC",
    ["rustdoc"],
    paths=rust_search_path,
    input="RUSTDOC",
    allow_missing=True,
)

# This option is separate from --enable-tests because Rust tests are particularly
# expensive in terms of compile time (especially for code in libxul).
option(
    "--enable-rust-tests",
    help="Enable building and running of Rust tests during `make check`",
)


@depends("--enable-rust-tests", rustdoc)
def rust_tests(enable_rust_tests, rustdoc):
    if enable_rust_tests and not rustdoc:
        die("--enable-rust-tests requires rustdoc")
    return bool(enable_rust_tests)


set_config("MOZ_RUST_TESTS", rust_tests)


@depends(target, c_compiler, rustc)
@imports("os")
def rustc_natvis_ldflags(target, compiler_info, rustc):
    if target.kernel == "WINNT" and compiler_info.type == "clang-cl":
        sysroot = check_cmd_output(rustc, "--print", "sysroot").strip()
        etc = os.path.join(sysroot, "lib/rustlib/etc")
        ldflags = []
        if os.path.isdir(etc):
            for f in os.listdir(etc):
                if f.endswith(".natvis"):
                    ldflags.append("-NATVIS:" + normsep(os.path.join(etc, f)))
        return ldflags


set_config("RUSTC_NATVIS_LDFLAGS", rustc_natvis_ldflags)


option(
    "--enable-rust-debug",
    default=depends(when="--enable-debug")(lambda: True),
    help="{Build|Do not build} Rust code with debug assertions turned " "on.",
)


@depends(when="--enable-rust-debug")
def debug_rust():
    return True


set_config("MOZ_DEBUG_RUST", debug_rust)
set_define("MOZ_DEBUG_RUST", debug_rust)

# ==============================================================

option(env="RUSTFLAGS", nargs=1, help="Rust compiler flags")
set_config("RUSTFLAGS", depends("RUSTFLAGS")(lambda flags: flags))


# Rust compiler flags
# ==============================================================


@depends(moz_optimize)
def rustc_opt_level_default(moz_optimize):
    return "2" if moz_optimize.optimize else "0"


option(
    env="RUSTC_OPT_LEVEL",
    default=rustc_opt_level_default,
    nargs=1,
    help="Rust compiler optimization level (-C opt-level=%s)",
)


@depends("RUSTC_OPT_LEVEL")
def rustc_opt_level(opt_level_option):
    return opt_level_option[0]


set_config("CARGO_PROFILE_RELEASE_OPT_LEVEL", rustc_opt_level)
set_config("CARGO_PROFILE_DEV_OPT_LEVEL", rustc_opt_level)


@depends(
    rustc_opt_level,
    debug_rust,
    target,
    "--enable-debug-symbols",
    "--enable-frame-pointers",
    path_remapping,
    path_remappings,
)
def rust_compile_flags(
    opt_level,
    debug_rust,
    target,
    debug_symbols,
    frame_pointers,
    path_remapping,
    path_remappings,
):
    # Cargo currently supports only two interesting profiles for building:
    # development and release. Those map (roughly) to --enable-debug and
    # --disable-debug in Gecko, respectively.
    #
    # But we'd also like to support an additional axis of control for
    # optimization level. Since Cargo only supports 2 profiles, we're in
    # a bit of a bind.
    #
    # Code here derives various compiler options given other configure options.
    # The options defined here effectively override defaults specified in
    # Cargo.toml files.

    debug_assertions = None
    debug_info = None

    # opt-level=0 implies -C debug-assertions, which may not be desired
    # unless Rust debugging is enabled.
    if opt_level == "0" and not debug_rust:
        debug_assertions = False

    if debug_symbols:
        debug_info = "2"

    opts = []

    if debug_assertions is not None:
        opts.append("debug-assertions=%s" % ("yes" if debug_assertions else "no"))
    if debug_info is not None:
        opts.append("debuginfo=%s" % debug_info)
    if frame_pointers:
        opts.append("force-frame-pointers=yes")
    # CFG for arm64 is crashy, see `def security_hardening_cflags`.
    if target.kernel == "WINNT" and target.cpu != "aarch64":
        opts.append("control-flow-guard=yes")

    flags = []
    for opt in opts:
        flags.extend(["-C", opt])

    if "rust" in path_remapping:
        # rustc has supported --remap-path-prefix since version 1.26, well
        # before our required minimum Rust version, so there's no need to
        # feature-detect or gate on versions.
        for old, new in path_remappings:
            flags.append(f"--remap-path-prefix={old}={new}")

    return flags


# Rust incremental compilation
# ==============================================================


option("--disable-cargo-incremental", help="Disable incremental rust compilation.")


@depends(
    developer_options,
    debug_rust,
    "MOZ_AUTOMATION",
    code_coverage,
    "--disable-cargo-incremental",
    using_sccache,
    "RUSTC_WRAPPER",
)
@imports("os")
def cargo_incremental(
    developer_options,
    debug_rust,
    automation,
    code_coverage,
    enabled,
    using_sccache,
    rustc_wrapper,
):
    """Return a value for the CARGO_INCREMENTAL environment variable."""

    if not enabled:
        return "0"
    elif enabled.origin != "default":
        return "1"

    # We never want to use incremental compilation in automation.  sccache
    # handles our automation use case much better than incremental compilation
    # would.
    if automation:
        return "0"

    # Coverage instrumentation doesn't play well with incremental compilation
    # https://github.com/rust-lang/rust/issues/50203.
    if code_coverage:
        return "0"

    # Incremental compilation doesn't work as well as it should, and if we're
    # using sccache, it's better to use sccache than incremental compilation.
    if not using_sccache and rustc_wrapper:
        rustc_wrapper = os.path.basename(rustc_wrapper[0])
        if os.path.splitext(rustc_wrapper)[0].lower() == "sccache":
            using_sccache = True
    if using_sccache:
        return "0"

    # Incremental compilation is automatically turned on for debug builds, so
    # we don't need to do anything special here.
    if debug_rust:
        return

    # Don't enable on --enable-release builds, because of the runtime
    # performance cost.
    if not developer_options:
        return

    # We're clear to use incremental compilation!
    return "1"


set_config("CARGO_INCREMENTAL", cargo_incremental)


@depends(rust_compile_flags, "--enable-warnings-as-errors", rustc_info)
def rust_flags(compile_flags, warnings_as_errors, rustc_info):
    warning_flags = []

    # Note that cargo passes --cap-lints warn to rustc for third-party code, so
    # we don't need a very complicated setup.
    if warnings_as_errors:
        warning_flags.append("-Dwarnings")
        # Work around https://github.com/rust-lang/rust/issues/84428
        if rustc_info.version >= "1.52":
            warning_flags.append("-Aproc-macro-back-compat")
    else:
        warning_flags.extend(("--cap-lints", "warn"))

    return compile_flags + warning_flags


set_config("MOZ_RUST_DEFAULT_FLAGS", rust_flags)