lib/stats.ex

defmodule ExIntegrationCoveralls.Stats do
  @moduledoc """
  Provide calculation logics of coverage stats.
  """
  alias ExIntegrationCoveralls.Cover
  alias ExIntegrationCoveralls.PathReader

  defmodule Source do
    @moduledoc """
    Stores count information for a file and all source lines.
    """

    @derive [Poison.Encoder]
    defstruct filename: "", coverage: 0, sloc: 0, hits: 0, misses: 0, source: []
  end

  defmodule Line do
    @moduledoc """
    Stores count information and source for a single line.
    """

    @derive [Poison.Encoder]
    defstruct coverage: nil, source: ""
  end

  @doc """
  Report the statistical information for the specified module.

  ## Parameters
  - compile_time_source_lib_abs_path: source code project abs path in compile-time machine.
  - source_lib_absolute_path: source code project abs path in run-time machine.

  ## Returns
  Return value like this:

      [
        %{
          coverage: [0, 1, nil, nil],
          name: "lib/hello.ex",
          source: "defmodule Test do\\n  def test do\\n  end\\nend"
        }
      ]
  """
  def report(modules, compile_time_source_lib_abs_path, source_lib_absolute_path) do
    calculate_stats(modules, compile_time_source_lib_abs_path)
    |> generate_coverage(source_lib_absolute_path)
    |> generate_source_info(source_lib_absolute_path)
  end

  @doc """
  Calculate the statistical information for the specified list of modules.
  It uses :cover.analyse for getting the information.

  ## Parameters
  - modules: Cover.modules() return value

  ## Returns
  a Map stores the number of executions of executable line in the source file.

  ## Examples

      %{
        "test/fixtures/test1.ex" => %{1 => 0, 2 => 1},
        "test/fixtures/test2.ex" => %{1 => 0, 2 => 0}
      }
  """
  def calculate_stats(modules, source_lib_absolute_path \\ File.cwd!()) do
    Enum.reduce(modules, Map.new(), fn module, dict ->
      {:ok, lines} = Cover.analyze(module)
      # calc the number of executions
      analyze_lines(lines, dict, source_lib_absolute_path)
    end)
  end

  defp analyze_lines(lines, module_hash, source_lib_absolute_path) do
    Enum.reduce(lines, module_hash, fn {{module, line}, count}, module_hash ->
      add_counts(module_hash, module, line, count, source_lib_absolute_path)
    end)
  end

  defp add_counts(module_hash, module, line, count, source_lib_absolute_path) do
    path = Cover.module_path(module, source_lib_absolute_path)
    count_hash = Map.get(module_hash, path, Map.new())

    Map.put(
      module_hash,
      path,
      Map.put(count_hash, line, max(Map.get(count_hash, line, 0), count))
    )
  end

  @doc """
  Read source code
  """
  def read_source(file_path) do
    file_path |> File.read!() |> trim_empty_prefix_and_suffix
  end

  @doc """
  Returns total line counts of the specified source file.
  """
  def get_source_line_count(file_path) do
    read_source(file_path) |> count_lines
  end

  @doc """
  Remove the front and back blank lines
  """
  def trim_empty_prefix_and_suffix(string) do
    string = Regex.replace(~r/\n\z/m, string, "")
    Regex.replace(~r/\A\n/m, string, "")
  end

  defp count_lines(string) do
    1 + (Regex.scan(~r/\n/i, string) |> length)
  end

  @doc """
  Generate coverage, based on the pre-calculated statistic information.

  ## Parameters
  - hash: calculate_stats() return value
  - base_path: the source code project absolute path

  ## Returns
  A tuple array. The first element of the tuple is the source code path
  The second element of the tuple stores the number of times each row was executed.
  The nil means it's not an executable line.
  Non-negative numbers represent the number of times each line  of source code is executed.

  ## Examples

      [{"lib/hello.ex", [ nil, 0, nil, 0, nil, 1]}, ...]
  """
  def generate_coverage(hash, base_path \\ File.cwd!()) do
    keys = Map.keys(hash)

    Enum.map(keys, fn file_path ->
      total = get_source_line_count(PathReader.expand_path(file_path, base_path))
      {file_path, do_generate_coverage(Map.fetch!(hash, file_path), total, [])}
    end)
  end

  defp do_generate_coverage(_hash, 0, acc), do: acc

  defp do_generate_coverage(hash, index, acc) do
    count = Map.get(hash, index, nil)
    do_generate_coverage(hash, index - 1, [count | acc])
  end

  @doc """
  Generate objects which stores source-file and coverage stats information.

  ## Parameters
  - coverage: generate_coverage() return value

  ## Returns
  A map array.

  ## Examples
  Return value like this:

      [
        %{
          coverage: [0, 1, nil, nil],
          name: "lib/hello.ex",
          source: "defmodule Test do\\n  def test do\\n  end\\nend"
        }
      ]
  """
  def generate_source_info(coverage, base_path \\ File.cwd!()) do
    Enum.map(coverage, fn {file_path, stats} ->
      %{
        name: file_path,
        source: read_source(PathReader.expand_path(file_path, base_path)),
        coverage: stats
      }
    end)
  end

  @doc """
  Organize coverage data in a human-readable way.

  ## Parameters
  - stats: generate_source_info() return value

  ## Return
  A map.

  ## Examples
  Return value link this:

      %{
        coverage: 50,
        files: [
          %ExIntegrationCoveralls.Stats.Source{
            coverage: 50,
            filename: "test/fixtures/test.ex",
            hits: 1,
            misses: 1,
            sloc: 2,
            source: [
              %ExIntegrationCoveralls.Stats.Line{coverage: 0, source: "defmodule Test do"},
              %ExIntegrationCoveralls.Stats.Line{coverage: 1, source: "  def test do"},
              %ExIntegrationCoveralls.Stats.Line{coverage: nil, source: "  end"},
              %ExIntegrationCoveralls.Stats.Line{coverage: nil, source: "end"}
            ]
          }
        ],
        hits: 1,
        misses: 1,
        sloc: 2
      }
  """
  def transform_cov(stats) do
    stats = Enum.sort(stats, fn x, y -> x[:name] <= y[:name] end)

    files = Enum.map(stats, &populate_file/1)
    {relevant, hits, misses} = Enum.reduce(files, {0, 0, 0}, &reduce_file_counts/2)
    covered = relevant - misses

    %{
      coverage: get_coverage(relevant, covered),
      sloc: relevant,
      hits: hits,
      misses: misses,
      files: files
    }
  end

  defp populate_file(stat) do
    coverage = stat[:coverage]
    source = map_source(stat[:source], coverage)
    relevant = Enum.count(coverage, fn e -> e != nil end)
    hits = Enum.reduce(coverage, 0, fn e, acc -> (e || 0) + acc end)
    misses = Enum.count(coverage, fn e -> e == 0 end)
    covered = relevant - misses

    %Source{
      filename: stat[:name],
      coverage: get_coverage(relevant, covered),
      sloc: relevant,
      hits: hits,
      misses: misses,
      source: source
    }
  end

  defp map_source(source, coverage) do
    source
    |> String.split("\n")
    |> Enum.with_index()
    |> Enum.map(&populate_source(&1, coverage))
  end

  defp populate_source({line, i}, coverage) do
    %Line{coverage: Enum.at(coverage, i), source: line}
  end

  defp get_coverage(relevant, covered) do
    value =
      case relevant do
        0 -> 100.0
        _ -> covered / relevant * 100
      end

    if value == trunc(value) do
      trunc(value)
    else
      Float.round(value, 1)
    end
  end

  defp reduce_file_counts(%{sloc: sloc, hits: hits, misses: misses}, {s, h, m}) do
    {s + sloc, h + hits, m + misses}
  end
end