lib/recode/ast.ex

defmodule Recode.AST do
  @moduledoc """
  This module provides functions to get informations from the AST and to
  manipulate the AST.

  Most of the functions in this module require an AST with additional
  informations. This information is provided by `Sourceror` or
  `Code.string_to_quoted/2` with the options
  ```elixir
  [
    columns: true,
    literal_encoder: &{:ok, {:__block__, &2, [&1]}},
    token_metadata: true,
    unescape: false
  ]
  ```
  See also [Formatting considerations](https://hexdocs.pm/elixir/Code.html#quoted_to_algebra/2-formatting-considerations)
  in the docs for `Code.quoted_to_algebra/2`.

  This module provides `literal?` functions that are also work with encoded
  literals generate by `literal_encoder: %{:ok, {:__block__, &2, [&1]}}`.
  For an example see `atom?/1`.
  """

  alias Sourceror.Zipper

  @doc """
  Returns `true` if the given AST represents an atom.

  ## Examples

      iex> ":atom" |> Code.string_to_quoted!() |> atom?()
      true

      iex> ":atom" |> Sourceror.parse_string!() |> atom?()
      true

      iex> "42" |> Sourceror.parse_string!() |> atom?()
      false

      iex> ast = Code.string_to_quoted!(
      ...>   ":a", literal_encoder: &{:ok, {:__block__, &2, [&1]}})
      {:__block__, [line: 1], [:a]}
      iex> atom?(ast)
      true
  """
  @spec atom?(Macro.t()) :: boolean()
  def atom?(atom) when is_atom(atom), do: true

  def atom?({:__block__, _meta, [atom]}) when is_atom(atom), do: true

  def atom?(_ast), do: false

  @operators [
    :"=>",
    :&&&,
    :&&,
    :*,
    :+,
    :-,
    :->,
    :/,
    :<-,
    :=,
    :==,
    :and,
    :in,
    :not,
    :or,
    :||,
    :|||
  ]

  @doc ~S'''
  Returns `true` when the given `ast` represents an expression that spans over
  multiple lines.

  `multiline?` does not pay attention to do blocks.

  ## Examples

      iex> """
      ...> def foo(x)
      ...>     when is_integer(x) do
      ...>   {:foo, x}
      ...> end
      ...> """
      ...> |> Sourceror.parse_string!() |> multiline?()
      true

      iex> """
      ...> def foo(x) when is_integer(x) do
      ...>   {:foo, x}
      ...> end
      ...> """
      ...> |> Sourceror.parse_string!() |> multiline?()
      false

      iex> """
      ...> {
      ...>   x,
      ...>   y
      ...> }
      ...> """
      ...> |> Sourceror.parse_string!() |> multiline?()
      true

      iex> """
      ...> {x, y}
      ...> """
      ...> |> Sourceror.parse_string!() |> multiline?()
      false
  '''
  @spec multiline?(Macro.t() | Macro.metadata()) :: boolean()
  def multiline?({op, _meta, [left, right]}) when op in @operators do
    last_line(right) > first_line(left)
  end

  def multiline?({_expr, meta, _args}), do: multiline?(meta)

  def multiline?(meta) when is_list(meta) do
    case {Keyword.has_key?(meta, :closing), Keyword.has_key?(meta, :do)} do
      {true, false} -> meta[:line] < meta[:closing][:line]
      {false, true} -> meta[:line] < meta[:do][:line]
      _else -> false
    end
  end

  def to_same_line({op, _meta, [left, right]} = ast) when op in @operators do
    to_same_line(ast, first_line(left), last_line(right))
  end

  def to_same_line({_expr, meta, _args} = ast) do
    begin_line = meta[:line]

    end_line =
      case {Keyword.has_key?(meta, :closing), Keyword.has_key?(meta, :do)} do
        {true, false} -> meta[:closing][:line]
        {false, true} -> meta[:do][:line]
        _fallback -> begin_line
      end

    to_same_line(ast, begin_line, end_line)
  end

  defp to_same_line(meta, line) when is_list(meta) do
    meta =
      meta
      |> Keyword.delete(:newlines)
      |> Keyword.put(:line, line)

    cond do
      Keyword.has_key?(meta, :closing) -> Keyword.put(meta, :closing, line: line)
      Keyword.has_key?(meta, :do) -> Keyword.put(meta, :do, line: line)
      true -> meta
    end
  end

  defp to_same_line(ast, begin_line, end_line) do
    ast
    |> Zipper.zip()
    |> Zipper.traverse_while(fn
      %Zipper{node: {expr, meta, args}} = zipper ->
        if meta[:line] >= begin_line and meta[:line] <= end_line do
          meta = to_same_line(meta, begin_line)
          {:cont, Zipper.replace(zipper, {expr, meta, args})}
        else
          {:halt, zipper}
        end

      zipper ->
        {:cont, zipper}
    end)
    |> Zipper.node()
  end

  @doc ~S'''
  Returns the line in which the given AST starts.

  Note: The AST must be constructed by `Sourceror` or with the `Code` module and
  the options `columns: true, token_metadata: true`.

  ## Examples

      iex> code = """
      ...> 1 +
      ...>   2 -
      ...>   3
      ...> """
      iex> code |> Sourceror.parse_string!() |> first_line()
      1
      iex> code |> Sourceror.parse_string!() |> last_line()
      3
      iex> code
      ...> |> Code.string_to_quoted!(
      ...>   columns: true,
      ...>   token_metadata: true,
      ...>   literal_encoder: &{:ok, {:__block__, &2, [&1]}})
      ...> |> last_line()
      3

  '''
  def first_line(ast), do: get_line_by(ast, &min/2)

  def last_line(ast), do: get_line_by(ast, &max/2)

  defp get_line_by(ast, fun) do
    ast
    |> Zipper.zip()
    |> Zipper.traverse_while(nil, fn
      %Zipper{node: {_name, meta, _args}} = zipper, acc ->
        line = meta[:line]

        case acc do
          nil -> {:cont, zipper, line}
          _line -> {:cont, zipper, fun.(acc, line)}
        end

      zipper, acc ->
        {:cont, zipper, acc}
    end)
    |> elem(1)
  end

  @doc ~S'''
  Updates the AST representing a definition.

  The keyword list `updates` can have the keys `name`, `meta` and `args`.

  ## Examples

      iex> ast = Sourceror.parse_string!("def foo(x), do: x")
      iex> ast |> update_definition(name: :bar) |> Macro.to_string()
      "def bar(x), do: x"
      iex> ast |> update_definition(args: [{:y, [], nil}]) |> Macro.to_string()
      "def foo(y), do: x"
  '''
  @spec update_definition(Macro.t(), updates :: keyword()) :: Macro.t()
  def update_definition(
        {:def, meta, [{:when, meta1, [{name, meta2, args}, expr1]}, expr2]},
        updates
      ) do
    name = Keyword.get(updates, :name, name)
    meta = Keyword.get(updates, :meta, meta)
    args = Keyword.get(updates, :args, args)

    {:def, meta, [{:when, meta1, [{name, meta2, args}, expr1]}, expr2]}
  end

  def update_definition({def, meta, [{name, meta1, args}, expr]}, updates) do
    name = Keyword.get(updates, :name, name)
    meta = Keyword.get(updates, :meta, meta)
    args = Keyword.get(updates, :args, args)

    {def, meta, [{name, meta1, args}, expr]}
  end

  @doc """
  Updates a spec.

  The keyword list `updates` can have the keys `name`, `meta`, `args` and
  `return`.

  ## Examples

      iex> quote do
      ...>   @spec foo(integer()) :: integer()
      ...> end
      ...> |> update_spec(name: :bar, return: {:term, [], []})
      ...> |> Macro.to_string()
      "@spec bar(integer()) :: term()"
  """
  @spec update_spec(Macro.t(), updates :: keyword()) :: Macro.t()
  def update_spec(
        {:@, meta,
         [
           {:spec, meta_spec,
            [
              {:"::", meta_op, [{name, meta_name, args}, return]}
            ]}
         ]},
        updates
      ) do
    name = Keyword.get(updates, :name, name)
    meta = Keyword.get(updates, :meta, meta)
    args = Keyword.get(updates, :args, args)
    return = Keyword.get(updates, :return, return)

    {:@, meta,
     [
       {:spec, meta_spec,
        [
          {:"::", meta_op, [{name, meta_name, args}, return]}
        ]}
     ]}
  end

  def update_spec(
        {:@, meta,
         [
           {:spec, meta_spec,
            [
              {:when, meta_when,
               [
                 {:"::", meta_op,
                  [
                    {name, meta_name, args},
                    return
                  ]},
                 when_block
               ]}
            ]}
         ]},
        updates
      ) do
    name = Keyword.get(updates, :name, name)
    meta = Keyword.get(updates, :meta, meta)
    args = Keyword.get(updates, :args, args)
    return = Keyword.get(updates, :return, return)

    {:@, meta,
     [
       {:spec, meta_spec,
        [
          {:when, meta_when,
           [
             {:"::", meta_op,
              [
                {name, meta_name, args},
                return
              ]},
             when_block
           ]}
        ]}
     ]}
  end

  @doc """
  Update a function call.

  The keyword list `updates` can have the keys `name`, `meta` and `args`.

  ## Examples

      iex> quote do
      ...>   foo(x)
      ...> end
      ...> |> update_call(name: :bar)
      ...> |> Macro.to_string()
      "bar(x)"
  """
  @spec update_call(Macro.t(), updates :: keyword()) :: Macro.t()
  def update_call({name, meta, args}, updates) do
    name = Keyword.get(updates, :name, name)
    meta = Keyword.get(updates, :meta, meta)
    args = Keyword.get(updates, :args, args)

    {name, meta, args}
  end

  @doc """
  Update a dotted function call.

  ## Examples

      iex> ast = quote do
      ...>   Foo.foo(x)
      ...> end
      iex> update_dot_call(ast, name: :bar)
      {{:., [], [{:__aliases__, [alias: false], [:Foo]}, :bar]}, [], [{:x, [], Recode.ASTTest}]}
  """
  @spec update_dot_call(Macro.t(), updates :: keyword()) :: Macro.t()
  def update_dot_call(
        {{:., meta, [{:__aliases__, meta1, module}, name]}, meta2, args},
        updates
      ) do
    name = Keyword.get(updates, :name, name)
    meta = Keyword.get(updates, :meta, meta)
    args = Keyword.get(updates, :args, args)

    {{:., meta, [{:__aliases__, meta1, module}, name]}, meta2, args}
  end

  @doc """
  Returns a `mfa`-tuple for the given `.`-call.
  """
  @spec mfa({{:., keyword(), list()}, Macro.metadata(), Macro.t()}) ::
          {module(), atom(), non_neg_integer()}
  def mfa({{:., _meta1, [{:__aliases__, _meta2, aliases}, fun]}, _meta3, args}) do
    {Module.concat(aliases), fun, length(args)}
  end

  @doc """
  Puts the given value `newlines` under the key `nevlines` in
  `meta[:end_of_expression]`.
  """
  @spec put_newlines({term(), Macro.metadata(), Macro.t()}, integer()) ::
          {term(), keyword(), list()}
  def put_newlines({name, meta, args}, newlines) do
    meta =
      Keyword.update(meta, :end_of_expression, [newlines: newlines], fn end_of_expression ->
        Keyword.put(end_of_expression, :newlines, newlines)
      end)

    {name, meta, args}
  end

  @doc """
  Returns the `newlines` value from `meta[:end_of_expression]`, or `nil`.
  """
  @spec get_newlines(Macro.t()) :: integer()
  def get_newlines({_name, meta, _args}) do
    case Keyword.fetch(meta, :end_of_expression) do
      {:ok, end_of_expression} -> Keyword.get(end_of_expression, :newlines)
      :error -> nil
    end
  end

  @doc """
  Returns the infos from an AST representing an `alias` expression.

  The function returns 3-tuple containing the alias, the multi part and the
  `:as`.

  ## Examples

      iex> ast = quote do
      ...>   alias Foo.Bar
      ...> end
      iex> alias_info(ast)
      {Foo.Bar, [], nil}

      iex> ast = quote do
      ...>   alias Foo.{Bar, Baz}
      ...> end
      iex> alias_info(ast)
      {Foo, [Bar, Baz], nil}

      iex> ast = quote do
      ...>   alias Foo, as: Baz
      ...> end
      iex> alias_info(ast)
      {Foo, [], Baz}
  """
  @spec alias_info(Macro.t()) :: {module(), [module()], module() | nil}
  def alias_info({:alias, _meta1, [{:__aliases__, _meta2, aliases}]}) do
    aliases =
      Enum.map(aliases, fn
        {:__MODULE__, _meta, _args} -> :__MODULE__
        alias -> alias
      end)

    module = Module.concat(aliases)
    {module, [], nil}
  end

  def alias_info({:alias, _meta1, [{:__MODULE__, _meta2, nil}]}) do
    {:__MODULE__, [], nil}
  end

  def alias_info(
        {:alias, _meta1, [{{:., _meta2, [{:__MODULE__, _meta3, _args}, _opts]}, _meta4, multi}]}
      ) do
    multi = Enum.map(multi, &aliases_concat/1)

    {:__MODULE__, multi, nil}
  end

  def alias_info({:alias, _meta1, [{{:., _meta2, [aliases, _opts]}, _meta3, multi}]}) do
    module = aliases_concat(aliases)
    multi = Enum.map(multi, &aliases_concat/1)

    {module, multi, nil}
  end

  def alias_info({:alias, _meta1, [{:__aliases__, _meta2, aliases}, [{_block, as}]]}) do
    module = Module.concat(aliases)
    as = aliases_concat(as)
    {module, [], as}
  end

  def alias_info({:alias, _meta1, [{:__block__, _meta2, aliases}, [{_block, as}]]}) do
    module = Module.concat(aliases)
    as = aliases_concat(as)
    {module, [], as}
  end

  def alias_info({:alias, _meta1, [{:unquote, _meta2, _args}]}) do
    {nil, [], nil}
  end

  def alias_info({:alias, _meta1, [{:unquote, _meta2, _args}, [{_block, as}]]}) do
    {nil, [], as}
  end

  @doc """
  Concatinates the aliases of an `:__aliases__` tuple.

  ## Examples

      iex> aliases_concat({:__aliases__, [], [:Alpha, :Bravo]})
      Alpha.Bravo
  """
  @spec aliases_concat({:__aliases__, Macro.metadata(), [atom()]}) :: module()
  def aliases_concat({:__aliases__, _meta, aliases}) do
    Module.concat(aliases)
  end

  @doc """
  Converts AST representing a name to a string.

  This function suppresses the prefix `"Elixir."`.

  ## Examples

      iex> name([Recode, AST])
      "Recode.AST"

      iex> name(Recode.AST)
      "Recode.AST"
  """
  @spec name(atom() | [atom()]) :: String.t()
  def name(aliases) when is_list(aliases) do
    Enum.map_join(aliases, ".", &name/1)
  end

  def name(atom) when is_atom(atom) do
    with "Elixir." <> name <- to_string(atom) do
      name
    end
  end

  @doc """
  Returns the value from a `:__block__` with a single argument.

  ## Examples

      iex> "[1, 2]"
      ...> |> Sourceror.parse_string!()
      ...> |> get_value()
      ...> |> Enum.map(&get_value/1)
      [1, 2]
  """
  @spec get_value(Macro.t()) :: term
  def get_value({:__block__, _meta, [value]}), do: value

  @doc """
  Puts the given `value` in the `:__block__` AST.

  ## Examples

      iex> "[1, 2]"
      ...> |> Sourceror.parse_string!()
      ...> |> get_value()
      ...> |> Enum.map(fn ast -> put_value(ast, "0") end)
      ...> |> Enum.map(&get_value/1)
      ["0", "0"]
  """
  @spec put_value(Macro.t(), term()) :: Macro.t()
  def put_value({:__block__, meta, [_value]}, value), do: {:__block__, meta, [value]}

  @doc """
  Updates the function name of a capture.
  """
  @spec update_capture(Macro.t(), name: atom()) :: Macro.t()
  def update_capture(
        {:&, meta1, [{:/, meta2, [{_name, meta3, nil}, {:__block__, meta4, [arity]}]}]},
        name: name
      ) do
    {:&, meta1, [{:/, meta2, [{name, meta3, nil}, {:__block__, meta4, [arity]}]}]}
  end

  def update_capture(
        {:&, meta1,
         [
           {:/, meta2,
            [
              {{:., meta3, [{:__aliases__, meta4, alias}, _name]}, meta5, []},
              {:__block__, meta6, [arity]}
            ]}
         ]},
        name: name
      ) do
    {:&, meta1,
     [
       {:/, meta2,
        [
          {{:., meta3, [{:__aliases__, meta4, alias}, name]}, meta5, []},
          {:__block__, meta6, [arity]}
        ]}
     ]}
  end
end