lib/bundlex/loader.ex

defmodule Bundlex.Loader do
  @moduledoc """
  Some utilities to ease loading of Bundlex-based NIFs.
  """

  alias Bundlex.Helper.{MixHelper, PathHelper}

  @doc """
  Binds the module to the specified NIF.

  Accepts keyword list, that may contain two arguments:
  - `:nif` - required, name of the NIF to be bound (the same as specified in `bundlex.exs`)
  - `:app` - application defining the NIF, defaults to the current application

  After `use`'ing this module you can utilize `defnif/1` and `defnifp/1` macros
  to create bindings to particular native functions.

  ## Example

      defmodule My.Native.Module do
        use Bundlex.Loader, nif: :my_nif

        defnif native_function(arg1, arg2, arg3)

        def normal_function(arg1, arg2) do
          # ...
        end

        defnifp private_native_function(arg1, arg2)

      end
  """
  defmacro __using__(keyword) do
    quote do
      import unquote(__MODULE__), only: [defnif: 1, defnifp: 1]
      @before_compile unquote(__MODULE__)
      @bundlex_nif_name unquote(keyword |> Keyword.fetch!(:nif))
      @bundlex_app unquote(keyword |> Keyword.get(:app))
      Module.register_attribute(__MODULE__, :bundlex_defnifs, accumulate: true)
    end
  end

  @doc false
  @spec __before_compile__(Macro.Env.t()) :: :ok
  def __before_compile__(%{module: module} = env) do
    funs = Module.delete_attribute(module, :bundlex_defnifs)
    nif_name = Module.delete_attribute(module, :bundlex_nif_name)
    app = Module.delete_attribute(module, :bundlex_app)

    defs =
      funs
      |> Enum.map(fn fun ->
        {name, _location, args} = fun

        args =
          args
          |> Enum.map(fn
            {:\\, _meta, [arg, _default]} -> arg
            arg -> arg
          end)

        quote do
          # credo:disable-for-next-line Credo.Check.Readability.Specs
          def unquote(fun) do
            :erlang.nif_error(
              "Nif fail: #{unquote(module)}.#{unquote(name)}/#{length(unquote(args))}"
            )
          end
        end
      end)

    nif_module_content =
      quote do
        @moduledoc false
        require unquote(__MODULE__)
        @on_load :load_nif
        @spec load_nif() :: :ok | no_return()
        def load_nif() do
          unquote(__MODULE__).load_nif!(unquote(app), unquote(nif_name))
        end

        unquote(defs)
      end

    Module.create(module |> Module.concat(Nif), nif_module_content, env)
    :ok
  end

  @doc """
  Generates function bound to the native implementation. This module has to be
  `use`d for this macro to work.

  Function name should correspond to the native one.

  See `__using__/1` for examples.
  """
  defmacro defnif({name, _pos, args} = definition) do
    quote do
      @bundlex_defnifs unquote(Macro.escape(definition))
      @compile {:inline, [unquote({name, length(args)})]}
      defdelegate unquote(definition), to: __MODULE__.Nif
    end
  end

  @doc """
  Works the same way as `defnif/1`, but generates private function. This module
  has to be `use`d for this macro to work.

  See `__using__/1` for examples.
  """
  defmacro defnifp({name, _pos, args} = definition) do
    quote do
      @bundlex_defnifs unquote(Macro.escape(definition))
      @compile {:inline, [unquote({name, length(args)})]}
      defp unquote(definition) do
        __MODULE__.Nif.unquote(definition)
      end
    end
  end

  @doc """
  Binds calling module to NIF `nif_name` from application `app`.

  Second argument has to be an atom, the same as name of the NIF in the bundlex
  project.

  Invoked internally by `__using__/1` macro, which is the preferred way of loading
  NIFs.
  """
  defmacro load_nif!(app \\ nil, nif_name) do
    quote do
      app = unquote(app || MixHelper.get_app!())
      nif_name = unquote(nif_name)
      path = Bundlex.build_path(app, nif_name, :nif)

      with :ok <- :erlang.load_nif(path |> PathHelper.fix_slashes() |> to_charlist(), 0) do
        :ok
      else
        {:error, {reason, text}} ->
          raise """
          Bundlex cannot load nif #{inspect(nif_name)} of app #{inspect(app)}
          from "#{path}", check bundlex.exs file for information about nifs.
          Reason: #{inspect(reason)}, #{to_string(text)}
          """
      end
    end
  end
end