Current section
Files
Jump to
Current section
Files
lib/six/stats.ex
defmodule Six.Stats do
@moduledoc false
@type line_coverage :: nil | non_neg_integer()
@type file_stats :: %{
path: String.t(),
source: String.t(),
coverage: [line_coverage()],
function_calls: %{{module(), atom(), non_neg_integer()} => non_neg_integer()},
lines: non_neg_integer(),
relevant: non_neg_integer(),
covered: non_neg_integer(),
missed: non_neg_integer(),
cold_lines: non_neg_integer(),
max_hits: non_neg_integer(),
percentage: float()
}
@type summary :: %{
files: [file_stats()],
total_lines: non_neg_integer(),
total_relevant: non_neg_integer(),
total_covered: non_neg_integer(),
total_missed: non_neg_integer(),
total_cold_lines: non_neg_integer(),
project_max_hits: non_neg_integer(),
percentage: float()
}
@doc """
Builds per-file stats from raw cover data.
`line_data` is a map of `%{module => [{{module, line}, count}]}`.
`function_data` is a map of `%{module => [{{module, fun, arity}, count}]}`.
Call counts for the same source line (when multiple modules compile into
one file) are summed, not OR-ed — heat depends on the true total.
"""
def build(line_data, function_data \\ %{}) do
function_maps = build_function_maps(function_data)
line_data
|> Enum.reduce(%{}, fn {module, results}, acc ->
case Six.Cover.module_path(module) do
nil ->
acc
path ->
module_cover_map = results_to_cover_map(results)
Map.update(acc, path, module_cover_map, fn cover_map ->
Map.merge(cover_map, module_cover_map, fn _line, existing, current ->
existing + current
end)
end)
end
end)
|> Enum.map(fn {path, cover_map} ->
build_file_stats(path, cover_map, Map.get(function_maps, path, %{}))
end)
|> Enum.sort_by(& &1.path)
end
defp build_function_maps(function_data) do
Enum.reduce(function_data, %{}, fn {module, results}, acc ->
case Six.Cover.module_path(module) do
nil ->
acc
path ->
fun_map = results_to_function_map(module, results)
Map.update(acc, path, fun_map, fn existing ->
Map.merge(existing, fun_map, fn _key, a, b -> a + b end)
end)
end
end)
end
defp build_file_stats(path, cover_map, function_calls) do
source = File.read!(path)
source_lines = String.split(source, "\n")
total_lines = length(source_lines)
coverage =
for i <- 1..max(total_lines, 1) do
Map.get(cover_map, i, nil)
end
%{
path: path,
source: source,
coverage: coverage,
function_calls: function_calls,
lines: total_lines
}
|> recalculate()
end
defp results_to_cover_map(results) do
Map.new(results, fn {{_mod, line}, count} -> {line, count} end)
end
defp results_to_function_map(module, results) do
Enum.reduce(results, %{}, fn {{_mod, fun, arity}, count}, acc ->
Map.update(acc, {module, fun, arity}, count, &(&1 + count))
end)
end
@doc """
Aggregates file stats into a summary.
"""
def summarize(file_stats_list) do
total_lines = Enum.sum(Enum.map(file_stats_list, & &1.lines))
total_relevant = Enum.sum(Enum.map(file_stats_list, & &1.relevant))
total_covered = Enum.sum(Enum.map(file_stats_list, & &1.covered))
total_missed = total_relevant - total_covered
total_cold_lines = Enum.sum(Enum.map(file_stats_list, &Map.get(&1, :cold_lines, 0)))
project_max_hits =
file_stats_list |> Enum.map(&Map.get(&1, :max_hits, 0)) |> Enum.max(fn -> 0 end)
%{
files: file_stats_list,
total_lines: total_lines,
total_relevant: total_relevant,
total_covered: total_covered,
total_missed: total_missed,
total_cold_lines: total_cold_lines,
project_max_hits: project_max_hits,
percentage: calc_percentage(total_covered, total_relevant)
}
end
@doc """
Removes files whose paths match any of the given patterns.
"""
def skip_files(file_stats_list, patterns) when is_list(patterns) do
Enum.reject(file_stats_list, fn %{path: path} ->
Enum.any?(patterns, fn pattern ->
cond do
is_struct(pattern, Regex) -> Regex.match?(pattern, path)
is_binary(pattern) -> String.contains?(path, pattern)
true -> false
end
end)
end)
end
@doc """
Recalculates relevant/covered/missed/percentage for a file_stats
based on the current coverage array. Used after filtering nullifies lines.
"""
def recalculate(%{coverage: coverage} = file_stats) do
relevant = Enum.count(coverage, &(&1 != nil))
covered = Enum.count(coverage, &(&1 != nil && &1 > 0))
missed = relevant - covered
file_stats
|> Map.merge(%{
relevant: relevant,
covered: covered,
missed: missed,
cold_lines: Six.Heatmap.cold_lines(coverage),
max_hits: Six.Heatmap.max_hits(coverage),
percentage: calc_percentage(covered, relevant)
})
end
defp calc_percentage(_covered, 0), do: 100.0
defp calc_percentage(covered, relevant) do
Float.floor(covered / relevant * 100, 1)
end
end