lib/mix/tasks/fennec.precompile.ex

defmodule Mix.Tasks.Fennec.Precompile do
  @moduledoc """
  Download and use precompiled NIFs safely with checksums.

  Fennec Precompile is a tool for library maintainers that use `:elixir_make`
  and wish to ship precompiled binaries. This tool aims to be a drop-in
  replacement for `:elixir_make`.

  It helps by removing the need to have the C/C++ compiler and other dependencies
  installed in the user's machine.

  Check the [Precompilation Guide](PRECOMPILATION_GUIDE.md) for details.

  ## Options to set in the `project` function of the `mix.exs` file.

    - `:fennec_base_url`. Required.

      Specifies the base download URL of the precompiled binaries.

    - `:fennec_nif_filename`. Optional.

      Specifies the name of the precompiled binary file, excluding the file extension.

    - `:fennec_force_build`. Optional.

      Indicates whether to force the app to be built.

      The value of this option will always be `true` for pre-releases (like "2.1.0-dev").

      When this value is `false` and there are no local or remote precompiled binaries,
      a compilation error will be raised.

    - `:fennec_force_build_args`. Optional.

      Defaults to `[]`.

      This option will be used when `:force_build` is `true`. The optional compiliation
      args will be forwarded to `:elixir_make`.

    - `:fennec_force_build_using_zig`. Optional.

      Defaults to `false`.

      This option will be used when `:force_build` is `true`. Set this option to `true`
      to always using `zig` as the C/C++ compiler.

    - `:fennec_targets`. Optional.

      A list of targets [supported by Zig](https://ziglang.org/learn/overview/#support-table)
      for which precompiled assets are avilable. By default the following targets are
      configured:

      ### on macOS
        - `x86_64-macos`
        - `x86_64-linux-gnu`
        - `x86_64-linux-musl`
        - `x86_64-windows-gnu`
        - `aarch64-macos`
        - `aarch64-linux-gnu`
        - `aarch64-linux-musl`
        - `riscv64-linux-musl`

      ### on Linux
        - `x86_64-linux-gnu`
        - `x86_64-linux-musl`
        - `x86_64-windows-gnu`
        - `aarch64-linux-gnu`
        - `aarch64-linux-musl`
        - `riscv64-linux-musl`

      `:fennec_targets` in the `project` will only be used in the following cases:

        1. When `:fennec_force_build` is set to `true`. In this case, the `:targets` acts
          as a list of compatible targets in terms of the source code. For example,
          NIFs that are specifically written for ARM64 Linux will fail to compile
          for other OS or CPU architeture. If the source code is not compatible with
          the current node, the build will fail.
        2. When `:fennec_force_build` is set to `false`. In this case, the `:targets` acts as
          a list of available targets of the precompiled binaries. If there is no
          match with the current node, no precompiled NIF will be downloaded and
          the app will fail to start.
  """

  use Mix.Task
  require Logger

  @user_config Application.compile_env(:fennec_precompile, :config, [])
  @return if Version.match?(System.version(), "~> 1.9"), do: {:ok, []}, else: :ok

  @impl true
  def run(args) do
    build_with_targets(args, compile_targets(), true)
  end

  def build_with_targets(args, targets, post_clean) do
    saved_cwd = File.cwd!()
    cache_dir = System.get_env("FENNEC_CACHE_DIR", nil)
    if cache_dir do
      System.put_env("FENNEC_CACHE_DIR", cache_dir)
    end
    cache_dir = FennecPrecompile.cache_dir("")

    app = get_app_name()
    do_fennec_precompile(app, args, targets, saved_cwd, cache_dir)
    if post_clean do
      make_priv_dir(app, :clean)
    else
      with {:ok, target} <- FennecPrecompile.target(targets) do
        version = get_app_version()
        nif_version = "#{:erlang.system_info(:nif_version)}"
        tar_filename = "#{app}-nif-#{nif_version}-#{target}-#{version}.tar.gz"
        cached_tar_gz = Path.join([cache_dir, tar_filename])
        FennecPrecompile.restore_nif_file(cached_tar_gz, app)
      end
    end
    Mix.Project.build_structure()
    @return
  end

  def build_native_using_zig(args) do
    with {:ok, target} <- get_native_target() do
      build_with_targets(args, [target], false)
    end
  end

  def build_native(args) do
    if always_use_zig?() do
      build_native_using_zig(args)
    else
      Mix.Tasks.Compile.ElixirMake.run(args)
    end
  end

  defp get_native_target() do
    with {:ok, targets} <- FennecPrecompile.target(FennecPrecompile.Config.default_targets()) do
      {:ok, targets}
    else
      _ ->
        custom_native_target = System.get_env("FENNEC_PRECOMPILE_NATIVE_TARGET")
        if custom_native_target == nil do
          raise RuntimeError, "Cannot identify triplets for native target"
        else
          {:ok, custom_native_target}
        end
    end
  end

  defp always_use_zig?() do
    always_use_zig?(System.get_env("FENNEC_PRECOMPILE_ALWAYS_USE_ZIG", "NO"))
  end

  defp always_use_zig?("true"), do: true
  defp always_use_zig?("TRUE"), do: true
  defp always_use_zig?("YES"), do: true
  defp always_use_zig?("yes"), do: true
  defp always_use_zig?("y"), do: true
  defp always_use_zig?("on"), do: true
  defp always_use_zig?("ON"), do: true
  defp always_use_zig?(_), do: false

  defp compile_targets() do
    targets = System.get_env("FENNEC_PRECOMPILE_TARGETS")
    if targets do
      String.split(targets, ",", trim: true)
    else
      app = get_app_name()
      user_targets = Keyword.get(Keyword.get(@user_config, app, []), :targets)
      if user_targets != nil do
        user_targets
      else
        FennecPrecompile.Config.default_targets()
      end
    end
  end

  defp do_fennec_precompile(app, args, targets, saved_cwd, cache_dir) do
    saved_cc = System.get_env("CC") || ""
    saved_cxx = System.get_env("CXX") || ""
    saved_cpp = System.get_env("CPP") || ""

    checksums = fennec_precompile(app, args, targets, cache_dir)
    FennecPrecompile.write_checksum!(app, checksums)

    File.cd!(saved_cwd)
    System.put_env("CC", saved_cc)
    System.put_env("CXX", saved_cxx)
    System.put_env("CPP", saved_cpp)
  end

  defp fennec_precompile(app, args, targets, cache_dir) do
    Enum.reduce(targets, [], fn target, checksums ->
      Logger.debug("Current compiling target: #{target}")
      make_priv_dir(app, :clean)
      {cc, cxx} =
        case {:os.type(), target} do
          {{:unix, :darwin}, "x86_64-macos" <> _} ->
            {"gcc -arch x86_64", "g++ -arch x86_64"}
          {{:unix, :darwin}, "aarch64-macos" <> _} ->
            {"gcc -arch arm64", "g++ -arch arm64"}
          _ ->
            {"zig cc -target #{target}", "zig c++ -target #{target}"}
        end
      System.put_env("CC", cc)
      System.put_env("CXX", cxx)
      System.put_env("CPP", cxx)
      Mix.Tasks.Compile.ElixirMake.run(args)

      {archive_full_path, archive_tar_gz} = create_precompiled_archive(target, cache_dir)
      {:ok, algo, checksum} = FennecPrecompile.compute_checksum(archive_full_path, :sha256)
      [%{path: archive_tar_gz, checksum_algo: algo, checksum: checksum} | checksums]
    end)
  end

  defp create_precompiled_archive(target, cache_dir) do
    saved_cwd = File.cwd!()
    app = get_app_name()
    version = get_app_version()

    app_priv = app_priv(app)
    File.cd!(app_priv)
    nif_version = FennecPrecompile.current_nif_version()

    archive_filename = "#{app}-nif-#{nif_version}-#{target}-#{version}"
    archive_tar_gz = "#{archive_filename}.tar.gz"
    archive_full_path = Path.expand(Path.join([cache_dir, archive_tar_gz]))
    File.mkdir_p!(cache_dir)
    Logger.debug("Creating precompiled archive: #{archive_full_path}")

    filelist = build_file_list_at(app_priv)
    File.cd!(app_priv)
    :ok = :erl_tar.create(archive_full_path, filelist, [:compressed])

    File.cd!(saved_cwd)
    {archive_full_path, archive_tar_gz}
  end

  defp build_file_list_at(dir) do
    saved_cwd = File.cwd!()
    File.cd!(dir)
    {filelist, _} = build_file_list_at(".", %{}, [])
    File.cd!(saved_cwd)
    Enum.map(filelist, &to_charlist/1)
  end

  defp build_file_list_at(dir, visited, filelist) do
    visited? = Map.get(visited, dir)
    if visited? do
      {filelist, visited}
    else
      visited = Map.put(visited, dir, true)
      saved_cwd = File.cwd!()

      case {File.dir?(dir), File.read_link(dir)} do
        {true, {:error, _}} ->
          File.cd!(dir)
          cur_filelist = File.ls!()
          {files, folders} =
            Enum.reduce(cur_filelist, {[], []}, fn filepath, {files, folders} ->
              if File.dir?(filepath) do
                symlink_dir? = Path.join([File.cwd!(), filepath])
                case File.read_link(symlink_dir?) do
                  {:error, _} ->
                    {files, [filepath | folders]}
                  {:ok, _} ->
                    {[Path.join([dir, filepath]) | files], folders}
                end
              else
                {[Path.join([dir, filepath]) | files], folders}
              end
            end)
          File.cd!(saved_cwd)

          filelist = files ++ filelist ++ [dir]
          {files_in_folder, visited} =
            Enum.reduce(folders, {[], visited}, fn folder_path, {files_in_folder, visited} ->
              {filelist, visited} = build_file_list_at(Path.join([dir, folder_path]), visited, files_in_folder)
              {files_in_folder ++ filelist, visited}
            end)
          filelist = filelist ++ files_in_folder
          {filelist, visited}
      _ ->
        {filelist, visited}
      end
    end
  end

  defp get_app_name() do
    System.get_env("FENNEC_PRECOMPILE_OTP_APP", to_string(Mix.Project.config()[:app]))
    |> String.to_atom()
  end

  defp get_app_version() do
    System.get_env("FENNEC_PRECOMPILE_VERSION", to_string(Mix.Project.config()[:version]))
  end

  defp app_priv(app) when is_atom(app) do
    build_path = Mix.Project.build_path()
    Path.join([build_path, "lib", "#{app}", "priv"])
  end

  defp make_priv_dir(app, :clean) when is_atom(app) do
    app_priv = app_priv(app)
    File.rm_rf!(app_priv)
    make_priv_dir(app)
  end

  defp make_priv_dir(app) when is_atom(app) do
    File.mkdir_p!(app_priv(app))
  end
end