lib/ex_factor/parser.ex

defmodule ExFactor.Parser do
  @moduledoc """
  `ExFactor.Parser` we're making some assumptions that you're using this library within the
  context of a Mix app and that every file contains one or more `defmodule` blocks.
  """

  @doc """
  Parse the contents of a filepath in an Abstract Syntaxt Tree (AST) and
  extraxct the block contents of the module at the filepath.
  """
  def read_file(filepath) when is_binary(filepath) do
    contents = File.read!(filepath)
    list = String.split(contents, "\n")
    {:ok, ast} = Code.string_to_quoted(contents, token_metadata: true)
    {ast, list}
  end

  def block_contents(filepath) when is_binary(filepath) do
    filepath
    |> File.read!()
    |> Code.string_to_quoted(token_metadata: true)
    |> block_contents()
  end

  def block_contents({:ok, ast}) do
    Macro.postwalk(ast, [], fn node, acc ->
      {node, ast_block(node, acc)}
    end)
  end

  @doc """
  Identify public and private functions from a module AST.
  """
  def all_functions(filepath) when is_binary(filepath) do
    filepath
    |> File.read!()
    |> Code.string_to_quoted(token_metadata: true)
    |> all_functions()
  end

  def all_functions({:ok, _ast} = input) do
    {_ast, public_functions} = public_functions(input)
    {ast, private_functions} = private_functions(input)
    all_fns = public_functions ++ private_functions
    {ast, Enum.uniq(all_fns)}
  end

  @doc """
  Identify public functions from a module AST.
  """
  def public_functions(filepath) when is_binary(filepath) do
    filepath
    |> File.read!()
    |> Code.string_to_quoted(token_metadata: true)
    |> public_functions()
  end

  def public_functions({:ok, ast}) do
    Macro.postwalk(ast, [], fn node, acc ->
      {node, walk_ast(node, acc, :def)}
    end)
  end

  @doc """
  Identify private functions from a module AST.
  """
  def private_functions(filepath) when is_binary(filepath) do
    filepath
    |> File.read!()
    |> Code.string_to_quoted(token_metadata: true)
    |> private_functions()
  end

  def private_functions({:ok, ast}) do
    Macro.postwalk(ast, [], fn node, acc ->
      {node, walk_ast(node, acc, :defp)}
    end)
  end

  defp walk_ast({:@, _, [{:doc, _meta, _} | _]} = node, acc, _token) do
    map = %{name: :doc, ast: node, arity: 0, defn: "@doc"}
    [map | acc]
  end

  defp walk_ast(
         {:@, fn_meta, [{:spec, _meta, [{_, _, [{name, _, args} | _]} | _]} | _]} = node,
         acc,
         _token
       ) do
    arity = length(args)
    map = merge_maps(%{name: name, ast: node, arity: arity, defn: "@spec"}, fn_meta)
    [map | acc]
  end

  defp walk_ast(
         {tkn, fn_meta, [{:when, _when_meta, [{name, _meta, args} | _]} | _]} = node,
         acc,
         token
       )
       when tkn == token do
    arity = length(args)
    map = merge_maps(%{name: name, ast: node, arity: arity, defn: token}, fn_meta)
    [map | acc]
  end

  defp walk_ast({tkn, fn_meta, [{name, _meta, args} | _]} = node, acc, token) when tkn == token do
    arity = length(args)
    map = merge_maps(%{name: name, ast: node, arity: arity, defn: token}, fn_meta)
    [map | acc]
  end

  defp walk_ast(_node, acc, _token) do
    acc
  end

  defp merge_maps(map, meta) do
    meta
    |> find_lines()
    |> Map.merge(map)
  end

  defp find_lines(meta) do
    start_line = Keyword.get(meta, :line, :unknown)
    end_line = find_end_line(meta)
    %{start_line: start_line, end_line: end_line}
  end

  defp find_end_line(meta) do
    end_expression_line =
      meta
      |> Keyword.get(:end_of_expression, [])
      |> Keyword.get(:line, :unknown)

    end_line =
      meta
      |> Keyword.get(:end, [])
      |> Keyword.get(:line, :unknown)

    cond do
      end_line != :unknown -> end_line
      end_expression_line != :unknown -> end_expression_line
      true -> :unknown
    end
  end

  defp ast_block([do: {:__block__, [], block_contents}], _acc) do
    block_contents
  end

  defp ast_block([do: block_contents], _acc) do
    [block_contents]
  end

  defp ast_block(_block, acc) do
    acc
  end
end