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