defmodule CCPrecompiler do @moduledoc """ Precompile with existing crosscompiler in the system. """ require Logger @behaviour ElixirMake.Precompiler # The default configuration for this precompiler module on linux systems. # It will detect for the following targets # - x86_64-linux-gnu # - i686-linux-gnu # - aarch64-linux-gnu # - armv7l-linux-gnueabihf # - riscv64-linux-gnu # - powerpc64le-linux-gnu # - s390x-linux-gnu # by trying to find the corresponding executable, i.e., # - x86_64-linux-gnu-gcc # - i686-linux-gnu-gcc # - aarch64-linux-gnu-gcc # - arm-linux-gnueabihf-gcc # - riscv64-linux-gnu-gcc # - powerpc64le-linux-gnu-gcc # - s390x-linux-gnu-gcc # (this module will only try to find the CC executable, a step further # will be trying to compile a simple C/C++ program using them) @default_compilers %{ {:unix, :linux} => %{ "x86_64-linux-gnu" => "x86_64-linux-gnu-", "i686-linux-gnu" => "i686-linux-gnu-", "aarch64-linux-gnu" => "aarch64-linux-gnu-", "armv7l-linux-gnueabihf" => "arm-linux-gnueabihf-", "riscv64-linux-gnu" => "riscv64-linux-gnu-", "powerpc64le-linux-gnu" => "powerpc64le-linux-gnu-", "s390x-linux-gnu" => "s390x-linux-gnu-" }, {:unix, :darwin} => %{ "x86_64-apple-darwin" => { "gcc", "g++", "<%= cc %> -arch x86_64", "<%= cxx %> -arch x86_64" }, "aarch64-apple-darwin" => { "gcc", "g++", "<%= cc %> -arch arm64", "<%= cxx %> -arch arm64" } }, {:win32, :nt} => %{ "x86_64-windows-msvc" => {"cl", "cl"} } } defp default_compilers, do: @default_compilers defp user_config, do: Mix.Project.config()[:cc_precompiler] || default_compilers() defp compilers, do: Access.get(user_config(), :compilers, default_compilers()) defp compilers_current_os, do: {Access.get(compilers(), :os.type(), %{}), Access.get(default_compilers(), :os.type(), %{})} defp compilers_current_os_with_override do {compiler_map1, compiler_map2} = compilers_current_os() if Map.has_key?(compiler_map1, :include_default_ones) do include_default_ones = Map.get(compiler_map1, :include_default_ones, false) compiler_map1 = Map.delete(compiler_map1, :include_default_ones) if include_default_ones == true do Map.merge(compiler_map1, compiler_map2, fn _, _, user_override -> user_override end) else compiler_map1 end else compiler_map1 end end defp only_listed_targets, do: Access.get(user_config(), :only_listed_targets, false) defp exclude_current_target, do: Access.get(user_config(), :exclude_current_target, false) defp allow_missing_compiler, do: Access.get(user_config(), :allow_missing_compiler, false) @impl ElixirMake.Precompiler def current_target do current_target_from_env = current_target_from_env() if current_target_from_env do # overwrite current target triplet from environment variables {:ok, current_target_from_env} else current_target(:os.type()) end end defp current_target_from_env do arch = System.get_env("TARGET_ARCH") os = System.get_env("TARGET_OS") abi = System.get_env("TARGET_ABI") if !Enum.all?([arch, os, abi], &Kernel.is_nil/1) do "#{arch}-#{os}-#{abi}" end end def current_target({:win32, _}) do processor_architecture = String.downcase(String.trim(System.get_env("PROCESSOR_ARCHITECTURE"))) # https://docs.microsoft.com/en-gb/windows/win32/winprog64/wow64-implementation-details?redirectedfrom=MSDN partial_triplet = case processor_architecture do "amd64" -> "x86_64-windows-" "ia64" -> "ia64-windows-" "arm64" -> "aarch64-windows-" "x86" -> "x86-windows-" end {compiler, _} = :erlang.system_info(:c_compiler_used) case compiler do :msc -> {:ok, partial_triplet <> "msvc"} :gnuc -> {:ok, partial_triplet <> "gnu"} other -> {:ok, partial_triplet <> Atom.to_string(other)} end end def current_target({:unix, _}) do # get current target triplet from `:erlang.system_info/1` system_architecture = to_string(:erlang.system_info(:system_architecture)) current = String.split(system_architecture, "-", trim: true) case length(current) do 4 -> {:ok, "#{Enum.at(current, 0)}-#{Enum.at(current, 2)}-#{Enum.at(current, 3)}"} 3 -> case :os.type() do {:unix, :darwin} -> # could be something like aarch64-apple-darwin21.0.0 # but we don't really need the last 21.0.0 part if String.match?(Enum.at(current, 2), ~r/^darwin.*/) do {:ok, "#{Enum.at(current, 0)}-#{Enum.at(current, 1)}-darwin"} else {:ok, system_architecture} end _ -> {:ok, system_architecture} end _ -> {:error, "cannot decide current target"} end end defp only_local do System.get_env("CC_PRECOMPILER_PRECOMPILE_ONLY_LOCAL") == "true" end @impl ElixirMake.Precompiler def all_supported_targets(:compile) do # this callback is expected to return a list of string for # all supported targets by this precompiler. in this # implementation, we will try to find a few crosscompilers # available in the system. # Note that this implementation is mainly used for demonstration # purpose, therefore the hardcoded compiler names are used in # DEBIAN/Ubuntu Linux (as I only installed these ones at the # time of writing this example) available_targets = find_all_available_targets() targets = case {only_local(), only_listed_targets(), current_target()} do {true, true, {:ok, current}} -> if Enum.member?(available_targets, current) do [current] else [] end {true, _, {:error, err_msg}} -> Mix.raise(err_msg) {true, false, {:ok, current}} -> Enum.uniq([current] ++ available_targets) {false, true, _} -> available_targets {false, false, {:ok, current}} -> Enum.uniq([current] ++ available_targets) end if exclude_current_target() do case current_target() do {:ok, current} -> targets -- [current] _ -> targets end else targets end end @impl ElixirMake.Precompiler def all_supported_targets(:fetch) do Enum.map(compilers(), fn {os, compilers} -> Enum.map(Map.keys(compilers), fn key -> if key == :include_default_ones do Map.keys(default_compilers()[os]) else key end end) end) |> List.flatten() end @impl ElixirMake.Precompiler def unavailable_target(_) do if only_listed_targets() do :ignore else :compile end end defp find_all_available_targets do compilers = compilers_current_os_with_override() compilers |> Map.keys() |> Enum.map(&find_available_compilers(&1, Map.get(compilers, &1))) |> Enum.reject(fn x -> x == nil end) end defp find_available_compilers(triplet, prefix) when is_binary(prefix) do if ensure_executable(["#{prefix}gcc", "#{prefix}g++"]) do Logger.debug("Found compiler for #{triplet}") triplet else Logger.debug("Compiler not found for #{triplet}") nil end end defp find_available_compilers(triplet, {cc, cxx}) when is_binary(cc) and is_binary(cxx) do if ensure_executable([cc, cxx]) do Logger.debug("Found compiler for #{triplet}") triplet else Logger.debug("Compiler not found for #{triplet}") nil end end defp find_available_compilers(triplet, {:script, _, _}) do triplet end defp find_available_compilers(triplet, {cc_executable, cxx_executable, _, _}) when is_binary(cc_executable) and is_binary(cxx_executable) do if ensure_executable([cc_executable, cxx_executable]) do Logger.debug("Found compiler for #{triplet}") triplet else Logger.debug("Compiler not found for #{triplet}") nil end end defp find_available_compilers(triplet, invalid) do Mix.raise( "Invalid configuration for #{triplet}, expecting a string, 2-tuple or 4-tuple. Got `#{inspect(invalid)}`" ) end defp ensure_executable(executable_list) when is_list(executable_list) do if allow_missing_compiler() do Enum.any?(executable_list, &System.find_executable/1) else Enum.all?(executable_list, &System.find_executable/1) end end @impl ElixirMake.Precompiler def build_native(args) do # In this callback we just build the NIF library natively, # and because this precompiler module is designed for NIF # libraries that use C/C++ as the main language with Makefile, # we can just call `ElixirMake.Precompiler.mix_compile(args)` # # It's also possible to forward this call to: # # `precompile(args, elem(current_target(), 1))` # # This could be useful when the precompiler is using a universal # (cross-)compiler, say zig. in this way, the compiled binaries # (`mix compile`) will be consistent as the corresponding precompiled # one (with `mix elixir_make.precompile`) # # However, if you'd prefer to having the same behaviour for `mix compile` # then the following line is okay ElixirMake.Precompiler.mix_compile(args) end @impl ElixirMake.Precompiler def precompile(args, target) do # in this callback we compile the NIF library for a given target config = Mix.Project.config() app = config[:app] version = config[:version] priv_paths = config[:make_precompiler_priv_paths] || ["."] saved_cc = System.get_env("CC") || "" saved_cxx = System.get_env("CXX") || "" saved_cpp = System.get_env("CPP") || "" Logger.debug("Current compiling target: #{target}") cc_cxx = get_cc_and_cxx(target) # remove files in the lists app_priv = Path.join(Mix.Project.app_path(config), "priv") case priv_paths do ["."] -> File.rm_rf!(app_priv) _ -> for include <- priv_paths, file <- Path.wildcard(Path.join(app_priv, include)) do File.rm_rf(file) end end File.mkdir_p!(app_priv) case cc_cxx do {cc, cxx} -> System.put_env("CC", cc) System.put_env("CXX", cxx) System.put_env("CPP", cxx) System.put_env("CC_PRECOMPILER_CURRENT_TARGET", target) ElixirMake.Precompiler.mix_compile(args) {:script, module, custom_args} -> System.put_env("CC_PRECOMPILER_CURRENT_TARGET", target) Kernel.apply(module, :compile, [ app, version, "#{:erlang.system_info(:nif_version)}", target, args, custom_args ]) end System.put_env("CC", saved_cc) System.put_env("CXX", saved_cxx) System.put_env("CPP", saved_cpp) :ok end defp get_cc_and_cxx(triplet) do case Access.get(compilers_current_os_with_override(), triplet, nil) do nil -> cc = System.get_env("CC") cxx = System.get_env("CXX") cpp = System.get_env("CPP") case {cc, cxx, cpp} do {nil, _, _} -> {"gcc", "g++"} {_, nil, nil} -> {"gcc", "g++"} {_, _, nil} -> {cc, cxx} {_, nil, _} -> {cc, cpp} {_, _, _} -> {cc, cxx} end {cc, cxx} -> {cc, cxx} prefix when is_binary(prefix) -> {"#{prefix}gcc", "#{prefix}g++"} {:script, script_path, {module, args}} -> case {script_path, module} do {"", CCPrecompiler.UniversalBinary} -> {:script, module, args} _ -> Code.require_file(script_path) {:script, module, args} end {cc, cxx, cc_args, cxx_args} -> {EEx.eval_string(cc_args, cc: cc), EEx.eval_string(cxx_args, cxx: cxx)} end end @impl true def post_precompile_target(target) do config = Mix.Project.config() cc_precompiler_config = config[:cc_precompiler] cleanup(config, cc_precompiler_config[:cleanup], target) end defp cleanup(_, nil, _), do: :ok defp cleanup(config, make_target, current_precompilation_target) when is_binary(make_target) do exec = System.get_env("MAKE") || os_specific_executable(Keyword.get(config, :make_executable, :default)) makefile = Keyword.get(config, :make_makefile, :default) env = Keyword.get(config, :make_env, %{}) env = if is_function(env), do: env.(), else: env env = default_env(config, env, current_precompilation_target) # In OTP 19, Erlang's `open_port/2` ignores the current working # directory when expanding relative paths. This means that `:make_cwd` # must be an absolute path. This is a different behaviour from earlier # OTP versions and appears to be a bug. It is being tracked at # https://bugs.erlang.org/browse/ERL-175. cwd = Keyword.get(config, :make_cwd, ".") |> Path.expand(File.cwd!()) if String.contains?(cwd, " ") do IO.warn( "the absolute path to the makefile for this project contains spaces. Make might " <> "not work properly if spaces are present in the path. The absolute path is: " <> inspect(cwd) ) end base = exec |> Path.basename() |> Path.rootname() args = args_for_makefile(base, makefile) ++ [make_target] case cmd(exec, args, cwd, env) do 0 -> :ok exit_status -> raise_cleanup_error(exec, exit_status) end end defp raise_cleanup_error(exec, exit_status) do Mix.raise(~s{Could not complete cleanup work with "#{exec}" (exit status: #{exit_status}).\n}) end # Returns a map of default environment variables # Defaults may be overwritten. defp default_env(config, default_env, current_precompilation_target) do root_dir = :code.root_dir() erl_interface_dir = Path.join(root_dir, "usr") erts_dir = Path.join(root_dir, "erts-#{:erlang.system_info(:version)}") erts_include_dir = Path.join(erts_dir, "include") erl_ei_lib_dir = Path.join(erl_interface_dir, "lib") erl_ei_include_dir = Path.join(erl_interface_dir, "include") Map.merge( %{ # Don't use Mix.target/0 here for backwards compatibility "MIX_TARGET" => env("MIX_TARGET", "host"), "MIX_ENV" => to_string(Mix.env()), "MIX_BUILD_PATH" => Mix.Project.build_path(config), "MIX_APP_PATH" => Mix.Project.app_path(config), "MIX_COMPILE_PATH" => Mix.Project.compile_path(config), "MIX_CONSOLIDATION_PATH" => Mix.Project.consolidation_path(config), "MIX_DEPS_PATH" => Mix.Project.deps_path(config), "MIX_MANIFEST_PATH" => Mix.Project.manifest_path(config), # Rebar naming "ERL_EI_LIBDIR" => env("ERL_EI_LIBDIR", erl_ei_lib_dir), "ERL_EI_INCLUDE_DIR" => env("ERL_EI_INCLUDE_DIR", erl_ei_include_dir), # erlang.mk naming "ERTS_INCLUDE_DIR" => env("ERTS_INCLUDE_DIR", erts_include_dir), "ERL_INTERFACE_LIB_DIR" => env("ERL_INTERFACE_LIB_DIR", erl_ei_lib_dir), "ERL_INTERFACE_INCLUDE_DIR" => env("ERL_INTERFACE_INCLUDE_DIR", erl_ei_include_dir), # Disable default erlang values "BINDIR" => nil, "ROOTDIR" => nil, "PROGNAME" => nil, "EMU" => nil, # cc_precompiler "CC_PRECOMPILER_CURRENT_TARGET" => current_precompilation_target }, default_env ) end defp os_specific_executable(exec) when is_binary(exec) do exec end defp os_specific_executable(:default) do case :os.type() do {:win32, _} -> cond do System.find_executable("nmake") -> "nmake" System.find_executable("make") -> "make" true -> "nmake" end {:unix, type} when type in [:freebsd, :openbsd, :netbsd] -> "gmake" _ -> "make" end end # Returns a list of command-line args to pass to make (or nmake/gmake) in # order to specify the makefile to use. defp args_for_makefile("nmake", :default), do: ["/F", "Makefile.win"] defp args_for_makefile("nmake", makefile), do: ["/F", makefile] defp args_for_makefile(_, :default), do: [] defp args_for_makefile(_, makefile), do: ["-f", makefile] # Runs `exec [args]` in `cwd` and prints the stdout and stderr in real time, # as soon as `exec` prints them (using `IO.Stream`). defp cmd(exec, args, cwd, env) do opts = [ into: IO.stream(:stdio, :line), stderr_to_stdout: true, cd: cwd, env: env ] {%IO.Stream{}, status} = System.cmd(find_executable(exec), args, opts) status end defp find_executable(exec) do System.find_executable(exec) || Mix.raise(""" "#{exec}" not found in the path. If you have set the MAKE environment variable, please make sure it is correct. """) end defp env(var, default) do System.get_env(var) || default end end