lib/zig/command.ex

defmodule Zig.Command do
  @moduledoc """
  contains all parts of the Zig library involved in calling the
  zig compiler toolchain, especially with regards to the `zig` command, except
  for assembling the build.zig file, which is performed by the
  `Zig.Builder` module.
  """

  alias Zig.Assembler
  alias Zig.Target

  require Logger

  #############################################################################
  ## API

  defp run_zig(command, opts) do
    args = String.split(command)

    base_opts = Keyword.take(opts, [:cd, :stderr_to_stdout])
    zig_cmd = executable_path(opts)
    Logger.debug("running command: #{zig_cmd} #{command}")

    case System.cmd(zig_cmd, args, base_opts) do
      {result, 0} ->
        result

      {error, code} ->
        raise Zig.CompileError, command: command, code: code, error: error
    end
  end

  def run_sema(file, opts) do
    priv_dir = :code.priv_dir(:zigler)
    sema_file = Keyword.get(opts, :sema_context, Path.join(priv_dir, "beam/sema.zig"))
    beam_file = Path.join(priv_dir, "beam/beam.zig")
    erl_nif_file = Path.join(priv_dir, "beam/stub_erl_nif.zig")

    package_opts =
      opts
      |> Keyword.get(:packages)
      |> List.wrap()
    
    erl_nif_pkg = {:erl_nif, erl_nif_file}

    package_files =
      Enum.map(package_opts, fn {name, {path, _}} -> {name, path} end) ++
        [beam: {beam_file, [erl_nif_pkg]}, erl_nif: erl_nif_file]

    packages =
      Enum.map(package_opts, fn
        {name, path} when is_binary(path) ->
          {name, path}

        {name, {path, []}} ->
          {name, path}

        {name, {path, deps}} ->
          deps_keyword = Enum.map(deps, &{&1, Keyword.fetch!(package_files, &1)})
          {name, {path, deps_keyword}}
      end)

    beam_pkg = {:beam, {beam_file, [erl_nif_pkg]}}

    packages =
      [
        erl_nif_pkg,
        beam_pkg,
        analyte:
          {file,
           [
             beam_pkg,
             erl_nif_pkg
           ] ++ packages}
      ]

    deps =
      packages
      |> package_deps()
      |> String.replace_prefix("", "--deps ")

    mods =
      packages
      |> package_mods()
      |> Enum.join(" ")

    # nerves will put in a `CC` command that we need to bypass because it misidentifies
    # libc locations for statically linking it.
    System.delete_env("CC")

    sema_command = "run #{sema_file} #{deps} #{mods} -lc #{link_opts(opts)}"

    # Need to make this an OK tuple
    {:ok, run_zig(sema_command, stderr_to_stdout: true)}
  end

  defp package_deps(packages) do
    packages
    |> Keyword.keys()
    |> Enum.map_join(",", &to_string/1)
  end

  defp package_mods(packages) do
    packages
    |> Enum.flat_map(fn
      {name, {file, deps}} ->
        ["--mod #{name}:#{package_deps(deps)}:#{file}"] ++ package_mods(deps)

      {name, file} ->
        ["--mod #{name}::#{file}"]
    end)
    |> Enum.uniq()
  end

  defp link_opts(opts) do
    opts
    |> Keyword.fetch!(:include_dir)
    |> Enum.map_join(" ", &"-I #{&1}")
  end

  def fmt(file) do
    run_zig("fmt #{file}", [])
  end

  def compile(module, opts) do
    assembly_dir = Assembler.directory(module)

    so_dir =
      opts
      |> Keyword.fetch!(:otp_app)
      |> :code.priv_dir()

    lib_dir = Path.join(so_dir, "lib")

    run_zig("build --prefix #{so_dir}", cd: assembly_dir)

    src_lib_name = Path.join(lib_dir, src_lib_name(module))
    dst_lib_name = Path.join(lib_dir, dst_lib_name(module))

    # on MacOS, we must delete the old library because otherwise library
    # integrity checker will kill the process
    File.rm(dst_lib_name)
    File.cp!(src_lib_name, dst_lib_name)

    Logger.debug("built library at #{dst_lib_name}")
  end

  def targets do
    run_zig("targets", [])
  end

  defp executable_path(opts) do
    cond do
      opts[:local_zig] -> System.find_executable("zig")
      path = opts[:zig_path] -> path
      true -> Path.join(directory(), "zig")
    end
  end

  defp src_lib_name(module) do
    case {Target.resolve(), :os.type()} do
      {nil, {:unix, :darwin}} ->
        "lib#{module}.dylib"

      {nil, {_, :nt}} ->
        "#{module}.dll"

      _ ->
        "lib#{module}.so"
    end
  end

  defp dst_lib_name(module) do
    case {Target.resolve(), :os.type()} do
      {nil, {:unix, :darwin}} ->
        "#{module}.so"

      {nil, {_, :nt}} ->
        "#{module}.dll"

      _ ->
        "#{module}.so"
    end
  end

  #############################################################################
  ## download zig from online sources.

  # TODO: move to target using :host as a parameter.

  @doc false
  def os_arch do
    "#{get_os()}-#{get_arch()}"
  end

  def get_os do
    case :os.type() do
      {:unix, :linux} ->
        "linux"

      {:unix, :freebsd} ->
        "freebsd"

      {:unix, :darwin} ->
        "macos"

      {_, :nt} ->
        windows_warn()
        "windows"
    end
  end

  @arches %{
    "i386" => "i386",
    "i486" => "i386",
    "i586" => "i386",
    "x86_64" => "x86_64",
    "armv6" => "armv6kz",
    "armv7" => "armv7a",
    "aarch64" => "aarch64",
    "amd64" => "x86_64",
    "win32" => "i386",
    "win64" => "x86_64"
  }

  # note this is the architecture of the machine where compilation is
  # being done, not the target architecture of cross-compiled
  def get_arch do
    arch =
      :system_architecture
      |> :erlang.system_info()
      |> List.to_string()

    Enum.find_value(@arches, fn
      {prefix, zig_arch} -> if String.starts_with?(arch, prefix), do: zig_arch
    end) || raise arch_warn()
  end

  defp arch_warn,
    do: """
      it seems like you are compiling from an unsupported architecture:
        #{:erlang.system_info(:system_architecture)}
      Please leave an issue at https://github.com/ityonemo/zigler/issues
    """

  defp windows_warn do
    raise "windows is not supported, and will be supported in zigler 0.11"
  end

  @zig_dir_path Path.expand("../../zig", Path.dirname(__ENV__.file))

  defp directory, do: Path.join(@zig_dir_path, "zig-#{os_arch()}-#{Zig.version()}")

  # TODO: rename this.
  def fetch!(version) do
    zig_dir = directory()
    zig_executable = Path.join(zig_dir, "zig")
    :global.set_lock({__MODULE__, self()})

    unless File.exists?(zig_executable) do
      # make sure the zig directory path exists and is ready.
      File.mkdir_p!(@zig_dir_path)

      # make sure that we're in the correct operating system.
      extension =
        if match?({_, :nt}, :os.type()) do
          ".zip"
        else
          ".tar.xz"
        end

      archive = "zig-#{os_arch()}-#{Zig.version()}#{extension}"

      # TODO: clean this up.
      Logger.configure(level: :info)

      zig_download_path = Path.join(@zig_dir_path, archive)
      download_zig_archive(zig_download_path, version, archive)

      # untar the zig directory.
      unarchive_zig(archive)
    end

    :global.del_lock({__MODULE__, self()})
  end

  defp download_zig_archive(zig_download_path, version, archive) do
    url = "https://ziglang.org/download/#{version}/#{archive}"
    Logger.info("downloading zig version #{version} (#{url}) and caching in #{@zig_dir_path}.")

    case httpc_get(url) do
      {:ok, %{status: 200, body: body}} ->
        # expected_checksum = Map.fetch!(@checksums, archive)
        # actual_checksum = :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower)

        # if expected_checksum != actual_checksum do
        #  raise "checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
        # end

        File.write!(zig_download_path, body)

      _ ->
        raise "failed to download the appropriate zig archive."
    end
  end

  defp httpc_get(url) do
    {:ok, _} = Application.ensure_all_started(:ssl)
    {:ok, _} = Application.ensure_all_started(:inets)
    headers = []
    request = {String.to_charlist(url), headers}
    http_options = [timeout: 600_000]
    options = [body_format: :binary]

    case :httpc.request(:get, request, http_options, options) do
      {:ok, {{_, status, _}, headers, body}} ->
        {:ok, %{status: status, headers: headers, body: body}}

      other ->
        other
    end
  end

  def unarchive_zig(archive) do
    System.cmd("tar", ["xvf", archive], cd: @zig_dir_path)
  end
end