lib/exp.ex

defmodule Exp do
  @moduledoc """
  Execute and inline expressions at compile time.

  * In a regular code, all you need is `Exp.inline/1`.
  * For usage inside of macros when you don't know if arguments are safe,
    see `Exp.maybe_inline/1`.
  * `Exp.abs/1` is a real-world example of using `Exp.maybe_inline/1`.
  * `Exp.pure_func?/3` and `Exp.safe_node?/1` are utility function that
    `Exp.maybe_inline/1` uses to make decision if expression is safe to inline.

  Source: [github.com/orsinium-labs/exp](https://github.com/orsinium-labs/exp)
  """

  @doc """
  Macro to statically execute and inline an expression at compile time.

  If the expression cannot be inlined, it will explode at compile time.
  This is your responsibility to make sure the code can be inlined and is safe to inline.

  ## What to inline

  There are requirements for a good candidate to be inlined:

  * It doesn't make network requests.
  * It doesn't depend on a service or ecto.
  * For the same input, it always produces the same output.
  * It's not too slow.
  * Its result doesn't take too much memory.

  A good example of such function is `Regex.compile!/2`.

  ## Examples

  The usage is straightforward, just wrap whatever you want to inline:

      iex> require Exp
      iex> Exp.inline(1 + 2)
      3

  The difference is that the expression inside will be executed at compile time
  (when expanding the AST) and included into the compiled code:

      iex(21)> q = quote do: 1 + 2
      iex(22)> {:+, _, [1, 2]} = Macro.expand(q, __ENV__)
      iex(23)> q = quote do: Exp.inline(1 + 2)
      iex(24)> 3 = Macro.expand(q, __ENV__)

  """
  @spec inline(Macro.t()) :: Macro.t()
  defmacro inline(call) do
    {term, _} = Code.eval_quoted(call)
    Macro.escape(term)
  end

  # list of pure BIFs
  @pure_funcs [
    {:is_atom, 1},
    {:is_binary, 1},
    {:is_bitstring, 1},
    {:is_boolean, 1},
    {:is_float, 1},
    {:is_function, 1},
    {:is_function, 2},
    {:is_integer, 1},
    {:is_list, 1},
    {:is_map, 1},
    {:is_map_key, 2},
    {:is_number, 1},
    {:is_pid, 1},
    {:is_port, 1},
    {:is_reference, 1},
    {:is_tuple, 1},
    {:<, 2},
    {:"=<", 2},
    {:>, 2},
    {:>=, 2},
    {:"/=", 2},
    {:"=/=", 2},
    {:==, 2},
    {:"=:=", 2},
    {:*, 2},
    {:+, 1},
    {:+, 2},
    {:-, 1},
    {:-, 2},
    {:/, 2},
    {:abs, 1},
    {:ceil, 1},
    {:floor, 1},
    {:round, 1},
    {:trunc, 1},
    {:element, 2},
    {:hd, 1},
    {:length, 1},
    {:map_get, 2},
    {:map_size, 1},
    {:tl, 1},
    {:tuple_size, 1},
    {:binary_part, 3},
    {:bit_size, 1},
    {:byte_size, 1},
    {:size, 1},
    {:div, 2},
    {:rem, 2},
    {:bnot, 1},
    {:band, 2},
    {:bor, 2},
    {:bxor, 2},
    {:bsl, 2},
    {:bsr, 2},
    {:or, 2},
    {:and, 2},
    {:xor, 2},
    {:not, 1},
    {:andalso, 2},
    {:orelse, 2}
  ]
  # List of modules that contain only pure functions
  @pure_modules [
    # basic types
    Atom,
    Base,
    Bitwise,
    Float,
    Integer,
    Kernel,
    String,
    Tuple,
    URI,
    Version,

    # collections
    Access,
    Enum,
    Keyword,
    List,
    Map,
    MapSet,
    Range,
    Stream,

    # popular third-party
    Jason,
    Poison
  ]

  @doc """
  Check if the function of given name and arity is pure.

  ## Examples

      iex> Exp.pure_func?(:abs, 1)
      true
      iex> Exp.pure_func?(:abs, 2)  # bad arity
      false
      iex> Exp.pure_func?(:send, 2)
      false

  """
  @spec pure_func?(atom(), non_neg_integer()) :: boolean()
  def pure_func?(name, arity)

  Enum.each(@pure_funcs, fn {name, arity} ->
    def pure_func?(unquote(name), unquote(arity)), do: true
  end)

  def pure_func?(_, _), do: false

  @doc """
  Check if the function of given module, name, and arity is pure.

  ## Examples

      iex> Exp.pure_func?(String, :upcase, 1)
      true
      iex> Exp.pure_func?(String, :upcase, 4) # bad arity
      false
      iex> Exp.pure_func?(File, :cwd, 0)
      false
      iex> Exp.pure_func?(Kernel, :abs, 1)
      true
      iex> Exp.pure_func?(:erlang, :abs, 1)
      true
  """
  @spec pure_func?(atom(), atom(), non_neg_integer()) :: boolean()
  def pure_func?(module, function, arity)

  Enum.each(@pure_modules, fn module ->
    def pure_func?(m = unquote(module), f, a),
      do: Code.ensure_loaded?(m) and function_exported?(m, f, a)
  end)

  def pure_func?(:Kernel, name, arity), do: pure_func?(name, arity)
  def pure_func?(:erlang, name, arity), do: pure_func?(name, arity)
  def pure_func?(_, _, _), do: false

  @doc """
  Check if the given AST node is safe to be statically executed.

  This check is conservative. If it doesn't know anything about the function,
  the result is `false`.

  It's used be `Exp.maybe_inline/1` to make a decision if the code should be inlined.

  ## Examples

      iex> Exp.safe_node?(quote do: 1)
      true
      iex> Exp.safe_node?(quote do: "hello")
      true
      iex> Exp.safe_node?(quote do: 1+2)
      true
      iex> Exp.safe_node?(quote do: div(2, 3))
      true
      iex> Exp.safe_node?(quote do: File.cwd())
      false
  """
  @spec safe_node?(Macro.t()) :: boolean()
  def safe_node?(term)
  def safe_node?({:__aliases__, _, args}), do: safe_node?(args)
  def safe_node?({:%, _, [left, right]}), do: safe_node?(left) and safe_node?(right)
  def safe_node?({:%{}, _, args}), do: safe_node?(args)
  def safe_node?({:{}, _, args}), do: safe_node?(args)
  def safe_node?({left, right}), do: safe_node?(left) and safe_node?(right)
  def safe_node?(list) when is_list(list), do: Enum.all?(list, &safe_node?/1)

  def safe_node?({{:., _, [{_, _, [mname]}, fname]}, [], list}) when is_list(list),
    do: pure_func?(mname, fname, length(list)) and safe_node?(list)

  def safe_node?({fname, _, list}) when is_list(list),
    do: pure_func?(fname, length(list)) and safe_node?(list)

  def safe_node?(term), do: is_atom(term) or is_number(term) or is_binary(term)

  defp get_unquote({:unquote, _, [expr]}), do: [expr]
  defp get_unquote(_), do: []

  @doc """
  A safe implementation of `inline/1` for macros.

  It inlines the given quoted expression if and only if all `unquote` arguments
  are safe to execute at compile time. The safety of the expression itself isn't checked.
  The result is also a quoted expression.

  It's supposed to be used from macros when you don't know if the macros
  will be used for a safe to execute code or not.


  ## Examples

  Here, `var` is safe and so the expression is inlined:

      iex> var = quote do: 13
      iex> 13 = Exp.maybe_inline(quote do: abs(unquote(var)))

  Here, `var` is not safe and so the expression is left as-is:

      iex> var = quote do: node()
      iex> {:abs, _, _} = Exp.maybe_inline(quote do: abs(unquote(var)))

  Here, the expression isn't safe but it's still inlined because `maybe_inline/2`
  checks only safety of expressions inside `Kernel.SpecialForms.unquote/1`:

      iex> Exp.maybe_inline(quote do: node())
      :nonode@nohost

  See also `Exp.abs/1` for a real-world usage example.
  """
  @spec maybe_inline(Macro.t()) :: Macro.t()
  defmacro maybe_inline(block) do
    params = Enum.flat_map(Macro.prewalker(block), &get_unquote/1)

    quote generated: true do
      if Exp.safe_node?(unquote(params)) do
        {term, _} = unquote(block) |> Code.eval_quoted()
        Macro.escape(term)
      else
        unquote(block)
      end
    end
  end

  @doc """
  Inlined version of `Kernel.abs/1`.

  Returns the arithmetical absolute value of the `number`.

  ## Examples

      iex> Exp.abs(-2)
      2
      iex> Exp.abs(2)
      2

  This is how you can check if it was inlined:

      iex> # inlined:
      iex> q = quote do: Exp.abs(-2)
      iex> 2 = Macro.expand(q, __ENV__)
      iex> # not inlined because unsafe:
      iex> q = quote do: Exp.abs(node())
      iex> {:abs, _, _} = Macro.expand(q, __ENV__)

  """
  @spec abs(Macro.t()) :: Macro.t()
  defmacro abs(number) do
    maybe_inline(quote do: abs(unquote(number)))
  end

  @doc false
  @spec to_charlist(Macro.t()) :: Macro.t()
  defmacro to_charlist(str) do
    maybe_inline(quote do: Kernel.to_charlist(unquote(str)))
  end

  @doc false
  @spec to_string(Macro.t()) :: Macro.t()
  defmacro to_string(str) do
    maybe_inline(quote do: Kernel.to_string(unquote(str)))
  end
end