lib/credo/code/module.ex

defmodule Credo.Code.Module do
  @moduledoc """
  This module provides helper functions to analyse modules, return the defined
  functions or module attributes.
  """

  alias Credo.Code.Block
  alias Credo.Code.Name

  @type module_part ::
          :moduledoc
          | :shortdoc
          | :behaviour
          | :use
          | :import
          | :alias
          | :require
          | :module_attribute
          | :defstruct
          | :opaque
          | :type
          | :typep
          | :callback
          | :macrocallback
          | :optional_callbacks
          | :public_fun
          | :private_fun
          | :public_macro
          | :private_macro
          | :public_guard
          | :private_guard
          | :callback_fun
          | :callback_macro
          | :module

  @type location :: [line: pos_integer, column: pos_integer]

  @def_ops [:def, :defp, :defmacro]

  @doc "Returns the list of aliases defined in a given module source code."
  def aliases({:defmodule, _, _arguments} = ast) do
    ast
    |> Credo.Code.postwalk(&find_aliases/2)
    |> Enum.uniq()
  end

  defp find_aliases({:alias, _, [{:__aliases__, _, mod_list}]} = ast, aliases) do
    module_names =
      mod_list
      |> Name.full()
      |> List.wrap()

    {ast, aliases ++ module_names}
  end

  # Multi alias
  defp find_aliases(
         {:alias, _,
          [
            {{:., _, [{:__aliases__, _, mod_list}, :{}]}, _, multi_mod_list}
          ]} = ast,
         aliases
       ) do
    module_names =
      Enum.map(multi_mod_list, fn tuple ->
        Name.full([Name.full(mod_list), Name.full(tuple)])
      end)

    {ast, aliases ++ module_names}
  end

  defp find_aliases(ast, aliases) do
    {ast, aliases}
  end

  @doc "Reads an attribute from a module's `ast`"
  def attribute(ast, attr_name) do
    case Credo.Code.postwalk(ast, &find_attribute(&1, &2, attr_name), {:error, nil}) do
      {:ok, value} ->
        value

      error ->
        error
    end
  end

  defp find_attribute({:@, _meta, arguments} = ast, tuple, attribute_name) do
    case List.first(arguments) do
      {^attribute_name, _meta, [value]} ->
        {:ok, value}

      _ ->
        {ast, tuple}
    end
  end

  defp find_attribute(ast, tuple, _name) do
    {ast, tuple}
  end

  @doc "Returns the function/macro count for the given module's AST"
  def def_count(nil), do: 0

  def def_count({:defmodule, _, _arguments} = ast) do
    ast
    |> Credo.Code.postwalk(&collect_defs/2)
    |> Enum.count()
  end

  def defs(nil), do: []

  def defs({:defmodule, _, _arguments} = ast) do
    Credo.Code.postwalk(ast, &collect_defs/2)
  end

  @doc "Returns the arity of the given function definition `ast`"
  def def_arity(ast)

  for op <- @def_ops do
    def def_arity({unquote(op) = op, _, [{:when, _, fun_ast}, _]}) do
      def_arity({op, nil, fun_ast})
    end

    def def_arity({unquote(op), _, [{_fun_name, _, arguments}, _]})
        when is_list(arguments) do
      Enum.count(arguments)
    end

    def def_arity({unquote(op), _, [{_fun_name, _, _}, _]}), do: 0
  end

  def def_arity(_), do: nil

  @doc "Returns the name of the function/macro defined in the given `ast`"
  for op <- @def_ops do
    def def_name({unquote(op) = op, _, [{:when, _, fun_ast}, _]}) do
      def_name({op, nil, fun_ast})
    end

    def def_name({unquote(op), _, [{fun_name, _, _arguments}, _]})
        when is_atom(fun_name) do
      fun_name
    end
  end

  def def_name(_), do: nil

  @doc "Returns the {fun_name, op} tuple of the function/macro defined in the given `ast`"
  for op <- @def_ops do
    def def_name_with_op({unquote(op) = op, _, _} = ast) do
      {def_name(ast), op}
    end

    def def_name_with_op({unquote(op) = op, _, _} = ast, arity) do
      if def_arity(ast) == arity do
        {def_name(ast), op}
      else
        nil
      end
    end
  end

  def def_name_with_op(_), do: nil

  @doc "Returns the name of the functions/macros for the given module's `ast`"
  def def_names(nil), do: []

  def def_names({:defmodule, _, _arguments} = ast) do
    ast
    |> Credo.Code.postwalk(&collect_defs/2)
    |> Enum.map(&def_name/1)
    |> Enum.uniq()
  end

  @doc "Returns the name of the functions/macros for the given module's `ast`"
  def def_names_with_op(nil), do: []

  def def_names_with_op({:defmodule, _, _arguments} = ast) do
    ast
    |> Credo.Code.postwalk(&collect_defs/2)
    |> Enum.map(&def_name_with_op/1)
    |> Enum.uniq()
  end

  @doc "Returns the name of the functions/macros for the given module's `ast` if it has the given `arity`."
  def def_names_with_op(nil, _arity), do: []

  def def_names_with_op({:defmodule, _, _arguments} = ast, arity) do
    ast
    |> Credo.Code.postwalk(&collect_defs/2)
    |> Enum.map(&def_name_with_op(&1, arity))
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
  end

  for op <- @def_ops do
    defp collect_defs({:@, _, [{unquote(op), _, arguments} = ast]}, defs)
         when is_list(arguments) do
      {ast, defs -- [ast]}
    end

    defp collect_defs({unquote(op), _, arguments} = ast, defs)
         when is_list(arguments) do
      {ast, defs ++ [ast]}
    end
  end

  defp collect_defs(ast, defs) do
    {ast, defs}
  end

  @doc "Returns the list of modules used in a given module source code."
  def modules({:defmodule, _, _arguments} = ast) do
    ast
    |> Credo.Code.postwalk(&find_dependent_modules/2)
    |> Enum.uniq()
  end

  # exclude module name
  defp find_dependent_modules(
         {:defmodule, _, [{:__aliases__, _, mod_list}, _do_block]} = ast,
         modules
       ) do
    module_names =
      mod_list
      |> Name.full()
      |> List.wrap()

    {ast, modules -- module_names}
  end

  # single alias
  defp find_dependent_modules(
         {:alias, _, [{:__aliases__, _, mod_list}]} = ast,
         aliases
       ) do
    module_names =
      mod_list
      |> Name.full()
      |> List.wrap()

    {ast, aliases -- module_names}
  end

  # multi alias
  defp find_dependent_modules(
         {:alias, _,
          [
            {{:., _, [{:__aliases__, _, mod_list}, :{}]}, _, multi_mod_list}
          ]} = ast,
         modules
       ) do
    module_names =
      Enum.flat_map(multi_mod_list, fn tuple ->
        [Name.full(mod_list), Name.full(tuple)]
      end)

    {ast, modules -- module_names}
  end

  defp find_dependent_modules({:__aliases__, _, mod_list} = ast, modules) do
    module_names =
      mod_list
      |> Name.full()
      |> List.wrap()

    {ast, modules ++ module_names}
  end

  defp find_dependent_modules(ast, modules) do
    {ast, modules}
  end

  @doc """
  Returns the name of a module's given ast node.
  """
  def name(ast)

  def name({:defmodule, _, [{:__aliases__, _, name_list}, _]}) do
    Enum.map_join(name_list, ".", &name/1)
  end

  def name({:__MODULE__, _meta, nil}), do: "__MODULE__"

  def name(atom) when is_atom(atom), do: atom |> to_string |> String.replace(~r/^Elixir\./, "")

  def name(string) when is_binary(string), do: string

  def name(_), do: "<Unknown Module Name>"

  # TODO: write unit test
  def exception?({:defmodule, _, [{:__aliases__, _, _name_list}, arguments]}) do
    arguments
    |> Block.calls_in_do_block()
    |> Enum.any?(&defexception?/1)
  end

  def exception?(_), do: nil

  defp defexception?({:defexception, _, _}), do: true
  defp defexception?(_), do: false

  @spec analyze(Macro.t()) :: [{module, [{module_part, location}]}]
  def analyze(ast) do
    {_ast, state} = Macro.prewalk(ast, initial_state(), &traverse_file/2)
    module_parts(state)
  end

  defp traverse_file({:defmodule, meta, args}, state) do
    [first_arg, [do: module_ast]] = args

    state = start_module(state, meta)
    {_ast, state} = Macro.prewalk(module_ast, state, &traverse_module/2)

    module_name = module_name(first_arg)
    this_module = {module_name, state.current_module}
    submodules = find_inner_modules(module_name, module_ast)

    state = update_in(state.modules, &(&1 ++ [this_module | submodules]))
    {[], state}
  end

  defp traverse_file(ast, state), do: {ast, state}

  defp module_name({:__aliases__, _, name_parts}) do
    name_parts
    |> Enum.map(fn
      atom when is_atom(atom) -> atom
      _other -> Unknown
    end)
    |> Module.concat()
  end

  defp module_name(_other), do: Unknown

  defp find_inner_modules(module_name, module_ast) do
    {_ast, definitions} = Macro.prewalk(module_ast, initial_state(), &traverse_file/2)

    Enum.map(
      definitions.modules,
      fn {submodule_name, submodule_spec} ->
        {Module.concat(module_name, submodule_name), submodule_spec}
      end
    )
  end

  defp traverse_module(ast, state) do
    case analyze(state, ast) do
      nil -> traverse_deeper(ast, state)
      state -> traverse_sibling(state)
    end
  end

  defp traverse_deeper(ast, state), do: {ast, state}
  defp traverse_sibling(state), do: {[], state}

  # Part extractors

  defp analyze(state, {:@, _meta, [{:doc, _, [value]}]}),
    do: set_next_fun_modifier(state, if(value == false, do: :private, else: nil))

  defp analyze(state, {:@, _meta, [{:impl, _, [value]}]}),
    do: set_next_fun_modifier(state, if(value == false, do: nil, else: :impl))

  defp analyze(state, {:@, meta, [{attribute, _, _}]})
       when attribute in ~w/moduledoc shortdoc behaviour type typep opaque callback macrocallback optional_callbacks/a,
       do: add_module_element(state, attribute, meta)

  defp analyze(state, {:@, _meta, [{ignore_attribute, _, _}]})
       when ignore_attribute in ~w/after_compile before_compile compile impl deprecated doc
       typedoc dialyzer external_resource file on_definition on_load vsn spec/a,
       do: state

  defp analyze(state, {:@, meta, _}),
    do: add_module_element(state, :module_attribute, meta)

  defp analyze(state, {clause, meta, args})
       when clause in ~w/use import alias require defstruct/a and is_list(args),
       do: add_module_element(state, clause, meta)

  defp analyze(state, {clause, meta, [{:when, _, [fun | _rest]}, body]})
       when clause in ~w/def defmacro defguard defp defmacrop defguardp/a do
    analyze(state, {clause, meta, [fun, body]})
  end

  defp analyze(state, {clause, meta, definition})
       when clause in ~w/def defmacro defguard defp defmacrop defguardp/a do
    fun_name = fun_name(definition)

    if fun_name != state.last_fun_name do
      state
      |> add_module_element(code_type(clause, state.next_fun_modifier), meta)
      |> Map.put(:last_fun_name, fun_name)
      |> clear_next_fun_modifier()
    else
      state
    end
  end

  defp analyze(state, {:do, _code}) do
    # Not entering a do block, since this is possibly a custom macro invocation we can't
    # understand.
    state
  end

  defp analyze(state, {:defmodule, meta, _args}),
    do: add_module_element(state, :module, meta)

  defp analyze(_state, _ast), do: nil

  defp fun_name([{name, _context, arity} | _]) when is_list(arity), do: {name, length(arity)}
  defp fun_name([{name, _context, _} | _]), do: {name, 0}
  defp fun_name(_), do: nil

  defp code_type(:def, nil), do: :public_fun
  defp code_type(:def, :impl), do: :callback_fun
  defp code_type(:def, :private), do: :private_fun
  defp code_type(:defp, _), do: :private_fun

  defp code_type(:defmacro, nil), do: :public_macro
  defp code_type(:defmacro, :impl), do: :callback_macro
  defp code_type(macro, _) when macro in ~w/defmacro defmacrop/a, do: :private_macro

  defp code_type(:defguard, nil), do: :public_guard
  defp code_type(guard, _) when guard in ~w/defguard defguardp/a, do: :private_guard

  # Internal state

  defp initial_state,
    do: %{modules: [], current_module: nil, next_fun_modifier: nil, last_fun_name: nil}

  defp set_next_fun_modifier(state, value), do: %{state | next_fun_modifier: value}

  defp clear_next_fun_modifier(state), do: set_next_fun_modifier(state, nil)

  defp module_parts(state) do
    state.modules
    |> Enum.sort_by(fn {_name, module} -> module.location end)
    |> Enum.map(fn {name, module} -> {name, Enum.reverse(module.parts)} end)
  end

  defp start_module(state, meta) do
    %{state | current_module: %{parts: [], location: Keyword.take(meta, ~w/line column/a)}}
  end

  defp add_module_element(state, element, meta) do
    location = Keyword.take(meta, ~w/line column/a)
    update_in(state.current_module.parts, &[{element, location} | &1])
  end
end