defmodule Chaps.Stats do
@moduledoc """
Provide calculation logics of coverage stats.
"""
alias Chaps.Cover
alias Chaps.Settings
defmodule Source do
@moduledoc """
Stores count information for a file and all source lines.
"""
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.
"""
defstruct coverage: nil, source: ""
end
@doc """
Report the statistical information for the specified module.
"""
def report(modules) do
calculate_stats(modules)
|> generate_coverage
|> generate_source_info
|> skip_files
|> Chaps.StopWords.filter()
|> Chaps.Ignore.filter()
end
@doc """
Calculate the statistical information for the specified list of modules.
It uses :cover.analyse for getting the information.
"""
def calculate_stats(modules) do
Enum.reduce(modules, Map.new(), fn module, dict ->
{:ok, lines} = Cover.analyze(module)
analyze_lines(lines, dict)
end)
end
defp analyze_lines(lines, module_hash) do
Enum.reduce(lines, module_hash, fn {{module, line}, count}, module_hash ->
add_counts(module_hash, module, line, count)
end)
end
defp add_counts(module_hash, module, line, count) do
path = Cover.module_path(module)
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 """
Generate coverage, based on the pre-calculated statistic information.
"""
def generate_coverage(hash) do
keys = Map.keys(hash)
Enum.map(keys, fn file_path ->
total = get_source_line_count(file_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.
"""
def generate_source_info(coverage) do
Enum.map(coverage, fn {file_path, stats} ->
%{
name: file_path,
source: read_source(file_path),
coverage: stats
}
end)
end
@doc """
Append the name of the sub app to the source info stats.
"""
def append_sub_app_name(stats, sub_app_name, apps_path) do
Enum.map(stats, fn %{name: name} = stat ->
%{stat | name: "#{apps_path}/#{sub_app_name}/#{name}"}
end)
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
defp count_lines(string) do
1 + (Regex.scan(~r/\n/i, string) |> length)
end
@doc """
Returns the source file of the specified module.
"""
def read_module_source(module) do
Cover.module_path(module) |> read_source
end
@doc """
Wrapper for reading the specified file.
"""
def read_source(file_path) do
Chaps.PathReader.expand_path(file_path)
|> File.read!()
|> trim_empty_prefix_and_suffix
end
def trim_empty_prefix_and_suffix(string) do
string = Regex.replace(~r/\n\z/m, string, "")
Regex.replace(~r/\A\n/m, string, "")
end
def skip_files(converage) do
skip = Settings.get_skip_files()
Enum.reject(converage, fn cov ->
Enum.any?(skip, &Regex.match?(&1, cov[:name]))
end)
end
@doc """
Summarizes source coverage details.
"""
def source(stats, _patterns = nil), do: source(stats)
def source(stats, _patterns = []), do: source(stats)
def source(stats, patterns) do
Enum.filter(stats, fn stat -> String.contains?(stat[:name], patterns) end)
|> source
end
def source(stats) do
stats = Enum.sort(stats, fn x, y -> x[:name] <= y[:name] end)
stats |> transform_cov
end
defp transform_cov(stats) do
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 reduce_file_counts(%{sloc: sloc, hits: hits, misses: misses}, {s, h, m}) do
{s + sloc, h + hits, m + misses}
end
defp get_coverage(relevant, covered) do
value =
case relevant do
0 -> Settings.default_coverage_value()
_ -> covered / relevant * 100
end
if value == trunc(value) do
trunc(value)
else
Float.floor(value, 1)
end
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
@doc """
Exit the process with a status of 1 if coverage is below the minimum.
"""
def ensure_minimum_coverage(stats) do
coverage_options = Chaps.Settings.get_coverage_options()
minimum_coverage = coverage_options[:minimum_coverage]
if minimum_coverage > 0,
do: check_coverage_threshold(stats, minimum_coverage)
end
defp check_coverage_threshold(stats, minimum_coverage) do
result = source(stats)
if result.coverage < minimum_coverage do
message =
"FAILED: Expected minimum coverage of #{minimum_coverage}%, got #{result.coverage}%."
IO.puts(IO.ANSI.format([:red, :bright, message]))
exit({:shutdown, 1})
end
end
end