lib/zigler.ex

defmodule :zigler do
  @moduledoc """
  Parse transform module for using Zigler with erlang.

  For the canonical example, see:
  https://www.erlang.org/doc/man/erl_id_trans.html

  ## Prerequisites

  In order to use Zigler in an erlang project, you must have the Elixir
  runtime.   You may do this any way you wish, but Zigler recommends
  rebar_mix:

  https://github.com/Supersonido/rebar_mix

  There are instructions on how to make sure Elixir is available at
  compile time for your erlang project.

  ## Building a Zig Module

  General documentation on parse transforms is very light.  To use zigler as
  a parse transform:

  ```erlang
  -module(my_erlang_module).
  -compile({parse_transform, zigler}).
  -export([...]).

  -zig_code("
  pub fn hello_world() [] const u8 {
    return "Hello, world!";
  }
  ")

  -zig_opts([{otp_app, my_app}]).
  ```

  This creates the `hello_world/0` function in your
  module which returns the "Hello, world!" binary.

  for options to be delivered in the `zig_opts` attribute, see the
  `Zig` module documentation.

  Note that the `...` for the `nifs` option is not representable in erlang AST.
  Instead, use the atom `auto`.

  > ### Note {: .warning }
  >
  > Erlang integration is highly experimental and the interface
  > may be changed in the future.
  """

  alias Zig.Compiler
  alias Zig.Options

  @doc """
  performs a parse transformation on the AST for an erlang module,
  converting public functions in the
  """
  def parse_transform(ast, _opts) do
    Application.ensure_all_started(:logger)
    Zig.Command.fetch!("0.10.1")

    file =
      Enum.find(ast, &match?({:attribute, _, :file, {_file, _}}, &1))
      |> elem(3)
      |> elem(0)

    opts =
      case Enum.find(ast, &match?({:attribute, _, :zig_opts, _}, &1))  do
        nil ->
          raise "No zig opts found"

        {:attribute, {line, _}, :zig_opts, opts} ->
          opts
          |> Keyword.merge(
            mod_file: file,
            mod_line: line,
            render: :render_erlang
          )
          |> Options.erlang_normalize!()
          |> Options.normalize!()
      end

    otp_app =
      case Keyword.fetch(opts, :otp_app) do
        {:ok, otp_app} -> otp_app
        _ -> raise "No otp_app found in zig opts"
      end

    # utility functions:  Make sure certain apps are running
    # and make sure certain libraries are loaded.
    ensure_eex()
    ensure_libs()
    ensure_priv_dir(otp_app)

    {:attribute, _, :file, {file, _}} = Enum.find(ast, &match?({:attribute, _, :file, _}, &1))

    module_dir =
      file
      |> Path.dirname()
      |> Path.absname()

    module =
      case Enum.find(ast, &match?({:attribute, _, :module, _}, &1)) do
        nil -> raise "No module definition found"
        {:attribute, _, :module, module} -> module
      end

    code =
      ast
      |> Enum.flat_map(fn
        {:attribute, {line, _}, :zig_code, code} ->
          ["// ref #{file}:#{line}", code]

        _ ->
          []
      end)
      |> IO.iodata_to_binary()

    code_dir =
      case Keyword.fetch(opts, :code_dir) do
        {:ok, code_dir = "/" <> _} ->
          code_dir

        {:ok, code_dir} ->
          Path.join(module_dir, code_dir)

        _ ->
          module_dir
      end

    rendered_erlang = Compiler.compile(code, module, code_dir, opts)

    ast =
      ast
      |> Enum.reject(&match?({:attribute, _, :zig_code, _}, &1))
      |> append_attrib(:on_load, {:__init__, 0})
      |> append_attrib(:zig_code, code)
      |> Kernel.++(rendered_erlang)
      |> Enum.sort(__MODULE__)

    if opts[:dump] do
      dump(ast)
    else
      ast
    end
  end

  defp append_attrib(ast, key, value), do: ast ++ [{:attribute, {1, 1}, key, value}]

  @order %{file: 0, attribute: 1, function: 2, error: 3, eof: 10}

  @doc false
  # This is a comparison function for sorting the AST.  By implementing
  # the compare/2 informal behaviour, we are able to sort the contents
  # of erlang ast
  def compare(ast1, ast2) do
    case {Map.fetch!(@order, elem(ast1, 0)), Map.fetch!(@order, elem(ast2, 0))} do
      {order1, order2} when order1 < order2 -> :lt
      {order1, order2} when order1 > order2 -> :gt
      _ -> :eq
    end
  end

  defp ensure_eex do
    # rebar_mix doesn't include the eex dependency out of the gate.  This function
    # retrieves the location of the eex code and adds it to the code path, ensuring
    # that the BEAM vm will have access to the eex code (at least, at compile time)
    :eex
    |> path_relative_to(:elixir)
    |> String.to_charlist()
    |> :code.add_path()
  end

  defp ensure_libs do
    # we can't put this dependency into rebar.config because it has resolution issues
    # since jason decimal requirement is 1.0.0 or 2.0.0
    Enum.each(~w[jason zig_parser]a, fn lib ->
      lib
      |> path_relative_to(:zigler)
      |> String.to_charlist()
      |> :code.add_path()
    end)
  end

  defp ensure_priv_dir(otp_app) do
    # makes sure the priv dir exists
    otp_app
    |> :code.lib_dir()
    |> Path.join("priv")
    |> File.mkdir_p!()
  end

  defp path_relative_to(target, start) do
    start
    |> :code.lib_dir()
    |> Path.join("../#{target}/ebin")
    |> Path.absname()
    |> to_string
  end

  defp dump(ast) do
    Enum.each(ast, fn
      tuple when elem(tuple, 0) == :attribute ->
        tuple
        |> :erl_pp.attribute()
        |> IO.puts()

      tuple when elem(tuple, 0) == :function ->
        tuple
        |> :erl_pp.function()
        |> IO.puts()

      _ ->
        :ok
    end)

    ast
  end
end