lib/elixirst/retriever.ex

defmodule ElixirST.Retriever do
  require Logger
  alias ElixirST.ST

  @moduledoc """
  Retrieves bytecode and (session) typechecks it.
  """

  @doc """
  Input as bytecode from a BEAM file, takes the Elixir AST from the debug_info
  and forwards it to the typechecker.
  """
  @spec process(binary, list) :: list
  def process(bytecode, options \\ []) do
    try do
      # Gets debug_info chunk from BEAM file
      chunks =
        case :beam_lib.chunks(bytecode, [:debug_info]) do
          {:ok, {_mod, chunks}} -> chunks
          {:error, _, error} -> throw({:error, inspect(error)})
        end

      # Gets the (extended) Elixir abstract syntax tree from debug_info chunk
      dbgi_map =
        case chunks[:debug_info] do
          {:debug_info_v1, :elixir_erl, metadata} ->
            case metadata do
              {:elixir_v1, map, _} ->
                # Erlang extended AST available
                map

              {version, _, _} ->
                throw({:error, "Found version #{version} but expected :elixir_v1."})
            end

          x ->
            throw({:error, inspect(x)})
        end

      # Gets the list of session types, which were stored as attributes in the module
      session_types = Keyword.get_values(dbgi_map[:attributes], :session_type_collection)

      session_types_parsed =
        for {{name, arity}, session_type_string} <- Keyword.values(session_types) do
          {{name, arity}, ST.string_to_st(session_type_string)}
        end

      # Retrieve dual session types (as labels)
      duals = Keyword.get_values(dbgi_map[:attributes], :dual_unprocessed_collection)

      # Retrieve errors created within the @session/@dual/@spec attributes
      invalid_collection = Keyword.get_values(dbgi_map[:attributes], :invalid_collection)

      function_types = Keyword.get_values(dbgi_map[:attributes], :type_specs)

      all_functions =
        get_all_functions!(dbgi_map)
        |> add_types_to_functions(to_map(function_types))

      # If there were any errors collected in the invalid_collection attribute, then expose them
      for {{name, arity}, error_message} <- invalid_collection do
        # Match the function name/arity with its line number in file
        # for better error localization
        line = get_function_line(all_functions, name, arity)
        raise ElixirSTError, message: error_message, lines: [line || 1]
      end

      dual_session_types_parsed =
        for {{name, arity}, dual_label} <- duals do
          case Keyword.fetch(session_types, dual_label) do
            {:ok, {{_dual_name, _dual_arity}, session_type}} ->
              dual =
                ST.string_to_st(session_type)
                |> ST.dual()

              {{name, arity}, dual}

            :error ->
              error_message = "Dual session type '#{dual_label}' does not exist"
              line = get_function_line(all_functions, name, arity)
              raise ElixirSTError, message: error_message, lines: [line || 1]
          end
        end

      # Session typechecking of each individual function
      ElixirST.SessionTypechecking.session_typecheck_module(
        all_functions,
        to_map(session_types_parsed ++ dual_session_types_parsed),
        dbgi_map[:module],
        options
      )
    catch
      {:error, message} ->
        raise ElixirSTError, message: "Error while reading BEAM files: " <> message, lines: [1]

      :error, error = %ElixirSTError{} ->
        raise ElixirSTError, message: error.message, lines: error.lines
    end
  end

  defp to_map(list) do
    list
    |> Enum.into(%{})
  end

  # Given the debug info chunk from the Beam files,
  # return a list of all functions

  # Structure of [Elixir] functions in Beam
  # {{name, arity}, :def_or_p, meta, [{meta, parameters, guards, body}, case2, ...]}
  # E.g.
  # {{:function1, 1}, :def, [line: 36],
  #  [
  #    {[line: 36], [7777],                       [],       {:__block__, [], [...]}}, # Case 1
  #    {[line: 47], [{:server, [line: 47], nil}], [guards], {...}                  }, # Case 2
  #    ...
  #  ]
  # }
  defp get_all_functions!(dbgi_map) do
    dbgi_map[:definitions]
    |> Enum.map(fn
      {{name, arity}, def_p, meta, function_body} ->
        # Unzipping function_body
        {metas, parameters, guards, bodies} =
          Enum.reduce(function_body, {[], [], [], []}, fn {curr_m, curr_p, curr_g, curr_b}, {accu_m, accu_p, accu_g, accu_b} ->
            {[curr_m | accu_m], [curr_p | accu_p], [curr_g | accu_g], [curr_b | accu_b]}
          end)

        {{name, arity},
         %ST.Function{
           name: name,
           arity: arity,
           def_p: def_p,
           meta: meta,
           cases: length(bodies),
           case_metas: metas,
           parameters: parameters,
           guards: guards,
           bodies: bodies
         }}

      x ->
        throw({:error, "Unknown info for #{inspect(x)}"})
    end)
    |> to_map()
  end

  defp add_types_to_functions(all_functions, function_types) do
    for {{name, arity}, function} <- all_functions do
      types = Map.get(function_types, {name, arity}, nil)

      if not is_nil(types) do
        {param_types, return_type} = types

        {{name, arity}, %{function | types_known?: true, return_type: return_type, param_types: param_types}}
      else
        {{name, arity}, function}
      end
    end
    |> to_map()
  end

  # Returns the line where a function is defined within a files
  defp get_function_line(all_functions, name, arity) do
    function_with_issues = Map.get(all_functions, {name, arity}, nil)

    if function_with_issues do
      function_with_issues.meta[:line]
    else
      nil
    end
  end
end