lib/source_code.ex

defmodule ALF.SourceCode do
  @moduledoc "Extracts source code"

  @line_length 60

  @spec module_doc(atom()) :: String.t() | nil
  def module_doc(module) when is_atom(module) do
    case Code.fetch_docs(module) do
      {:docs_v1, _, _, _, %{} = doc, _, _} ->
        doc["en"]

      {:docs_v1, _, _, _, _, _, _} ->
        nil

      {:error, :module_not_found} ->
        nil
    end
  end

  @spec function_doc(atom(), atom()) :: String.t() | nil
  def function_doc(module, function) when is_atom(module) do
    case Code.fetch_docs(module) do
      {:docs_v1, _, _, _, _, _, docs} ->
        case Enum.find(docs, fn
               {{kind, function_name, arity}, _, _, _, _} ->
                 kind == :function and
                   function_name == function and
                   arity == 2
             end) do
          {_, _, _, %{} = doc, _} ->
            doc["en"]

          {_, _, _, _, _} ->
            nil

          nil ->
            nil
        end

      {:error, :module_not_found} ->
        nil
    end
  end

  @spec module_source(atom()) :: String.t() | nil
  def module_source(module) when is_atom(module) do
    case module_ast(module) do
      nil ->
        nil

      ast ->
        format_ast(ast)
    end
  end

  @spec function_source(atom(), atom() | (... -> any())) :: String.t() | nil
  def function_source(module, function) when is_atom(module) and is_atom(function) do
    case function_asts(module, function) do
      [] ->
        nil

      asts ->
        asts
        |> Enum.map(&format_ast/1)
        |> Enum.join("\n\n")
    end
  end

  def function_source(module, function) when is_atom(module) and is_function(function) do
    inspect(function)
  end

  defp format_ast(ast) do
    ast
    |> Macro.to_string()
    |> Code.format_string!(line_length: @line_length)
    |> Enum.join()
  end

  defp function_asts(module, function) do
    case module_ast(module) do
      nil ->
        []

      module_ast ->
        case module_ast do
          {:defmodule, _, [_aliases, [do: {:__block__, _, fun_asts}]]} ->
            find_functions(fun_asts, function)

          {:defmodule, _, [_aliases, [do: fun_ast]]} ->
            find_functions([fun_ast], function)
        end
    end
  end

  defp find_functions(fun_asts, function) do
    fun_asts
    |> Enum.filter(fn fun_ast ->
      case fun_ast do
        {:def, _, [{^function, _, _args}, _do_block]} ->
          fun_ast

        _ ->
          false
      end
    end)
  end

  defp module_ast(module) do
    if module_exist?(module) do
      ast = read_ast_from_source_file(module)
      result = traverse_modules(ast, %{}, [])
      module_aliases = split_module_to_aliases(module)
      Map.get(result, module_aliases, nil)
    end
  end

  defp read_ast_from_source_file(module) do
    module.module_info(:compile)[:source]
    |> File.read()
    |> case do
      {:ok, content} ->
        Code.string_to_quoted!(content)

      {:error, :enoent} ->
        ""
    end
  end

  defp split_module_to_aliases(module) do
    module
    |> Module.split()
    |> Enum.map(&String.to_atom(&1))
  end

  defp traverse_modules({:__block__, _, tree}, modules_acc, aliases_before) do
    tree
    |> Enum.reduce(modules_acc, fn subtree, acc ->
      Map.merge(acc, traverse_modules(subtree, modules_acc, aliases_before))
    end)
  end

  defp traverse_modules(
         {:defmodule, _,
          [
            {:__aliases__, _, aliases},
            [do: tree]
          ]} = subtree,
         modules_acc,
         aliases_before
       ) do
    modules_acc
    |> Map.put(aliases_before ++ aliases, subtree)
    |> Map.merge(traverse_modules(tree, %{}, aliases_before ++ aliases))
  end

  defp traverse_modules(_tree, _modules_acc, _aliases), do: %{}

  defp module_exist?(module) do
    case Code.ensure_compiled(module) do
      {:module, ^module} ->
        true

      {:error, _} ->
        false
    end
  end
end