lib/zig/nif.ex

defmodule Zig.Nif do
  @moduledoc """
  module encapsulating all of the information required to correctly generate
  a nif function.

  Note that all information obtained from semantic analysis of the function is
  stashed in the `Zig.Nif.Function` module.
  """

  # nif gets an access behaviour so that it can be easily used in EEx
  # files.
  @behaviour Access

  @enforce_keys [:export, :concurrency, :type]

  @impl true
  defdelegate fetch(function, key), to: Map

  defstruct @enforce_keys ++ ~w[name raw args return leak_check alias doc spec]a

  alias Zig.Nif.DirtyCpu
  alias Zig.Nif.DirtyIo
  alias Zig.Nif.Synchronous
  alias Zig.Nif.Threaded
  alias Zig.Nif.Yielding
  alias Zig.Type
  alias Zig.Type.Error
  alias Zig.Type.Function

  @type t :: %__MODULE__{
          name: atom,
          export: boolean,
          concurrency: Synchronous | Threaded | Yielding | DirtyCpu | DirtyIo,
          type: Function.t(),
          raw: nil | :beam | :erl_nif | :c,
          args: [keyword],
          return: keyword,
          leak_check: boolean(),
          alias: nil | atom,
          doc: nil | String.t(),
          spec: Macro.t()
        }

  defmodule Concurrency do
    @moduledoc """
    behaviour module which describes the interface for "plugins" which
    generate concurrency-specific code.
    """

    alias Zig.Nif

    @callback render_elixir(Nif.t()) :: Macro.t()
    @callback render_erlang(Nif.t()) :: term
    @callback render_zig(Nif.t()) :: iodata

    @type concurrency :: :synchronous | :dirty_cpu | :dirty_io
    @type table_entry ::
            {name :: atom, arity :: non_neg_integer, function_pointer :: atom,
             bootstrap :: concurrency}

    @doc """
    returns "table_entry" tuples which are then used to generate the nif table.
    if a nif function needs multiple parts, for example, for concurrency
    management, then multiple entries should be returned.
    """
    @callback table_entries(Nif.t()) :: [table_entry]
    @callback resources(Nif.t()) :: [{:root, atom}]
  end

  @concurrency_modules %{
    :synchronous => Synchronous,
    :threaded => Threaded,
    :yielding => Yielding,
    :dirty_cpu => DirtyCpu,
    :dirty_io => DirtyIo
  }

  @doc """
  based on nif options for this function keyword at (opts :: nifs :: function_name)
  """
  def new(name, opts) do
    %__MODULE__{
      name: name,
      export: Keyword.fetch!(opts, :export),
      concurrency: Map.get(@concurrency_modules, Keyword.fetch!(opts, :concurrency)),
      type: opts[:type],
      raw: extract_raw(opts[:raw], opts[:type]),
      args: opts[:args],
      return: opts[:return],
      leak_check: opts[:leak_check],
      alias: opts[:alias],
      doc: opts[:doc],
      spec: Keyword.get(opts, :spec, :auto)
    }
  end

  defp extract_raw(raw_opt, %{return: return}) do
    case {raw_opt, return} do
      {nil, _} -> nil
      {{:c, arity}, _} when is_integer(arity) -> :c
      {arity, :term} when is_integer(arity) -> :beam
      {arity, :erl_nif_term} when is_integer(arity) -> :erl_nif
    end
  end

  def render_elixir(%{concurrency: concurrency} = nif) do
    doc =
      if nif_doc = nif.doc do
        quote do
          @doc unquote(nif_doc)
        end
      end

    typespec =
      case nif.spec do
        false ->
          quote do
          end

        [{:->, _, [params, res]}] ->
          quote do
            @spec unquote(nif.name)(unquote_splicing(params)) :: unquote(res)
          end

        :auto ->
          quote do
            @spec unquote(spec(nif))
          end
      end

    functions = concurrency.render_elixir(nif)

    quote context: Elixir do
      unquote(doc)
      unquote(typespec)
      unquote(functions)
    end
  end

  def render_erlang(nif, _opts \\ []) do
    # TODO: typespec in erlang.

    function =
      nif
      |> nif.concurrency.render_erlang
      |> List.wrap()

    function
  end

  require EEx

  def render_zig(%__MODULE__{} = nif) do
    nif.concurrency.render_zig(nif)
  end

  @flags %{
    synchronous: "0",
    dirty_cpu: "e.ERL_NIF_DIRTY_JOB_CPU_BOUND",
    dirty_io: "e.ERL_NIF_DIRTY_JOB_IO_BOUND"
  }

  def table_entries(nif) do
    nif.concurrency.table_entries(nif)
    |> Enum.map(fn
      {function, arity, fptr, concurrency} ->
        flags = Map.fetch!(@flags, concurrency)
        ~s(.{.name="#{function}", .arity=#{arity}, .fptr=#{fptr}, .flags=#{flags}})
    end)
  end

  def indexed_parameters([:env | rest]) do
    indexed_parameters(rest)
  end

  def indexed_parameters(params_list) do
    Enum.with_index(params_list)
  end

  def indexed_args([:env | rest]) do
    case indexed_args(rest) do
      "" -> "env"
      argstrs -> "env, #{argstrs}"
    end
  end

  def indexed_args(params_list) do
    params_list
    |> Enum.with_index()
    |> Enum.map_join(", ", fn {_, index} -> "arg#{index}" end)
  end

  def maybe_catch(%Error{}) do
    """
    catch |err| {
        return beam.raise_with_error_return(env, err, @errorReturnTrace()).v;
    }
    """
  end

  def maybe_catch(_), do: nil

  def validate_return!(function, file, line) do
    unless Type.return_allowed?(function.return) do
      raise CompileError,
        description: "functions returning #{function.return} are not allowed",
        file: Path.relative_to_cwd(file),
        line: line
    end
  end

  def spec(nif) do
    if nif.raw do
      spec_raw(nif)
    else
      spec_coded(nif)
    end
  end

  defp spec_raw(%{type: type}) do
    params =
      List.duplicate(
        quote do
          term()
        end,
        type.arity
      )

    quote context: Elixir do
      unquote(type.name)(unquote_splicing(params)) :: term()
    end
  end

  defp spec_coded(%{type: type, return: return_opts}) do
    trimmed =
      case type.params do
        [:env | list] -> list
        list -> list
      end

    param_types = Enum.map(trimmed, &Type.spec(&1, :param, []))

    # TODO: check for easy_c
    return =
      if arg = return_opts[:arg] do
        type.params
        |> Enum.at(arg)
        |> Type.spec(:return, return_opts)
      else
        Type.spec(type.return, :return, return_opts)
      end

    quote context: Elixir do
      unquote(type.name)(unquote_splicing(param_types)) :: unquote(return)
    end
  end

  #############################################################################
  ## OPTIONS NORMALIZATION

  # nifs are either atom() or {atom(), keyword()}.  This turns all atom nifs
  # into {atom(), []}, so that downstream they are easier to deal with.

  @doc false
  def default_options,
    do: [
      concurrency: :synchronous,
      args: nil,
      return: [type: :default],
      export: true
    ]

  @doc false
  def normalize_options!(function_name, common_options) when is_atom(function_name) do
    {function_name, common_options}
  end

  def normalize_options!({function_name, opts}, common_options) do
    updated_opts =
      default_options()
      |> Keyword.merge(common_options)
      |> Keyword.merge(Enum.map(opts, &normalize_option!/1))

    {function_name, updated_opts}
  end

  @nif_option_types %{
    concurrency: "one of `:synchronous`, `:dirty_cpu`, `:dirty_io`, `:threaded`, `:yielding`",
    leak_check: "boolean",
    alias: "atom",
    export: "boolean"
  }

  @atom_options %{
    leak_check: {:leak_check, true},
    def: {:export, true},
    defp: {:export, false},
    dirty_cpu: {:concurrency, :dirty_cpu},
    dirty_io: {:concurrency, :dirty_io},
    synchronous: {:concurrency, :synchronous},
    threaded: {:concurrency, :threaded},
    yielding: {:concurrency, :yielding}
  }

  defp normalize_option!(atom) when is_map_key(@atom_options, atom),
    do: Map.fetch!(@atom_options, atom)

  defp normalize_option!(:defp), do: {}

  defp normalize_option!({:leak_check, should_check} = option) when is_boolean(should_check),
    do: option

  defp normalize_option!({:alias, function} = option) when is_atom(function), do: option

  defp normalize_option!({:export, should_export} = option) when is_boolean(should_export),
    do: option

  defp normalize_option!({:raw, integer} = option) when is_integer(integer), do: option

  defp normalize_option!({:raw, {:c, integer}} = option) when is_integer(integer), do: option

  @return_types [:list, :binary, :default]
  @return_option_types %{
    raw: "integer or `{:c, integer}`",
    arg: "integer",
    type: "one of `:list`, `:binary`, `:default`",
    noclean: "boolean",
    length: "`integer` or `{:arg, integer}`"
  }

  @as_is_options ~w[args spec docs]a

  defp normalize_option!({:return, return_opts}) do
    updated_return =
      return_opts
      |> List.wrap()
      |> Enum.map(fn
        integer when is_integer(integer) ->
          {:arg, integer}

        type when type in @return_types ->
          {:type, type}

        :noclean ->
          {:noclean, true}

        option = {:raw, integer} when is_integer(integer) ->
          option

        option = {:raw, {:c, integer}} when is_integer(integer) ->
          option

        option = {:arg, integer} when is_integer(integer) ->
          option

        option = {:type, type} when type in @return_types ->
          option

        option = {:noclean, boolean} when is_boolean(boolean) ->
          option

        option = {:length, integer} when is_integer(integer) ->
          option

        option = {:length, {:arg, integer}} when is_integer(integer) ->
          option

        {ret_opt, _} when is_map_key(@return_option_types, ret_opt) ->
          raise CompileError,
            description:
              "return option `:#{ret_opt}` must be #{Map.fetch!(@return_option_types, ret_opt)}"

        other ->
          raise CompileError, description: "unrecognized return option `#{inspect(other)}`"
      end)
      |> Keyword.put_new(:type, :default)

    {:return, updated_return}
  end

  defp normalize_option!({tag, _} = as_is) when tag in @as_is_options, do: as_is

  defp normalize_option!({opt, value}) when is_map_key(@nif_option_types, opt) do
    raise CompileError,
      description:
        "nif option :#{opt} must be #{Map.fetch!(@nif_option_types, opt)}, got: #{inspect(value)}"
  end

  defp normalize_option!(other) do
    raise CompileError, description: "unrecognized nif option `#{inspect(other)}`"
  end

  # Access behaviour guards
  @impl true
  def get_and_update(_, _, _), do: raise("you should not update a function")

  @impl true
  def pop(_, _), do: raise("you should not pop a function")
end