Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add diff task #282

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ end
- [[mix coveralls.json] Show coverage as JSON report](#mix-coverallsjson-show-coverage-as-json-report)
- [[mix coveralls.xml] Show coverage as XML report](#mix-coverallsxml-show-coverage-as-xml-report)
- [[mix coveralls.lcov] Show coverage as lcov repor (Experimental)](#mix-coverallslcov-show-coverage-as-lcov-report-experimental)
- [[mix coveralls.diff] Show coverage on new git diff lines (Experimental)](#mix-coverallsdiff-show-coverage-on-new-git-diff-lines-experimental)
- [coveralls.json](#coverallsjson)
- [Stop Words](#stop-words)
- [Exclude Files](#exclude-files)
Expand Down Expand Up @@ -366,6 +367,20 @@ Output to the shell is the same as running the command `mix coveralls` (to suppr

Output reports are written to `cover/lcov.info` by default, however, the path can be specified by overwriting the `"output_dir"` coverage option.

### [mix coveralls.diff] Show coverage on new git diff lines (Experimental)
This task checks coverage on lines that were added up to `HEAD` from git revision specified in the `--from-git-rev` option. If total coverage
for those lines is less than minimum (`--threshold` option), then task exits with status `1`.

Task will:
- Print total coverage information for changed lines.
- Print total coverage information for changed lines for each file.
- Print changed lines and highlight covered and missed lines.

```Shell
$ MIX_ENV=test mix coveralls.diff --from-git-rev master
```
![diff output](./assets/diff_output.jpg?raw=true "diff output")

## coveralls.json
`coveralls.json` provides settings for excoveralls.

Expand Down
Binary file added assets/diff_output.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions lib/excoveralls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule ExCoveralls do
alias ExCoveralls.Post
alias ExCoveralls.Xml
alias ExCoveralls.Lcov
alias ExCoveralls.Diff

@type_travis "travis"
@type_github "github"
Expand All @@ -32,6 +33,7 @@ defmodule ExCoveralls do
@type_post "post"
@type_xml "xml"
@type_lcov "lcov"
@type_diff "diff"

@doc """
This method will be called from mix to trigger coverage analysis.
Expand Down Expand Up @@ -113,6 +115,10 @@ defmodule ExCoveralls do
Post.execute(stats, options)
end

def analyze(stats, @type_diff, options) do
Diff.execute(stats, options)
end

def analyze(_stats, type, _options) do
raise "Undefined type (#{type}) is specified for ExCoveralls"
end
Expand Down
230 changes: 230 additions & 0 deletions lib/excoveralls/diff.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
defmodule ExCoveralls.Diff do
@moduledoc """
Analyze coverage stats for the git diff.
"""

alias ExCoveralls.Stats

@changed_line [IO.ANSI.color(0, 5, 0)]
@covered_line [IO.ANSI.color(1, 3, 1)]
@missed_line [IO.ANSI.color(3, 1, 1)]
@no_color []
@git_diff_regex ~r/^@@[^+]+\+(\d+),?(\d+)? @@/

@doc """
Provides an entry point for the module.
"""
def execute(stats, options) do
files = options[:files]

stats
|> Enum.filter(&Enum.member?(files, &1[:name]))
|> Stats.source()
|> analyze(options)
|> print_coverage_results(options)
end

@doc """
Get changed files from git-diff.
"""
def diff_file_names(options) do
rev = options[:from_git_rev]
paths = options[:paths]

case System.cmd("git", ["diff", "--name-only", rev, "HEAD", "--"] ++ paths) do
{"", 0} ->
Keyword.put(options, :files, [])

{diff, 0} ->
Keyword.put(options, :files, String.split(diff, "\n"))

_else ->
raise ExCoveralls.InvalidOptionError, message: "Unexpected arguments"
end
end

defp analyze(%Stats.Source{} = source, options) do
changed_ranges = get_changed_ranges(source, options[:from_git_rev])

{sloc, misses} =
Enum.map(changed_ranges, &Enum.slice(source.source, &1))
|> List.flatten()
|> Enum.reduce({0, 0}, fn line, {sloc, misses} ->
cond do
line.coverage == nil ->
{sloc, misses}

line.coverage > 0 ->
{sloc + 1, misses}

true ->
{sloc + 1, misses + 1}
end
end)

%{
filename: source.filename,
lines: source.source,
sloc: sloc,
misses: misses,
changed_ranges: changed_ranges
}
end

defp analyze(report, options) do
files = Enum.map(report.files, &analyze(&1, options))

{sloc, misses} =
Enum.reduce(files, {0, 0}, fn file, {sloc, misses} ->
{sloc + file.sloc, misses + file.misses}
end)

covered = sloc - misses

coverage =
if sloc != 0 do
Float.round(100 * covered / sloc, 2)
else
0.0
end

%{files: files, sloc: sloc, covered: covered, misses: misses, coverage: coverage}
end

defp get_changed_ranges(source, rev) do
{diff, 0} = System.cmd("git", ["diff", "-U0", "--no-color", rev, "HEAD", source.filename])

diff
|> String.split("\n")
|> Enum.reduce([], fn str, acc ->
case Regex.run(@git_diff_regex, str) do
[_0, line] ->
start = String.to_integer(line)
[(start - 1)..(start - 1) | acc]

[_0, line, shift] ->
start = String.to_integer(line)
count = String.to_integer(shift)

if count > 0 do
[(start - 1)..(start - 2 + count) | acc]
else
acc
end

nil ->
acc
end
end)
end

defp print_coverage_results(report, options) do
report.files
|> Enum.reject(&(&1.sloc == 0))
|> Enum.each(fn source ->
covered = source.sloc - source.misses
IO.puts("\nNew code coverage for #{source.filename}:\n")
IO.puts([" ", colorize([], "Relevant: #{source.sloc}")])
IO.puts([" ", colorize([@covered_line], "Covered: #{covered}")])
IO.puts([" ", colorize([@missed_line], "Missed: #{source.misses}")])

print_coverage_lines(source, options)

IO.puts("\n")
end)

IO.puts("TOTAL\n")
IO.puts([colorize([], "Relevant: #{report.sloc}")])
IO.puts([colorize([@covered_line], "Covered: #{report.covered}")])
IO.puts([colorize([@missed_line], "Missed: #{report.misses}")])
IO.puts([colorize([@no_color], "Coverage: #{report.coverage}\n")])

if report.coverage < options[:threshold] && report.sloc != 0 do
message =
"FAILED: Expected minimum coverage of #{options[:threshold]}%, got #{report.coverage}%."

IO.puts(IO.ANSI.format([:red, :bright, message]))
exit({:shutdown, 1})
end
end

defp print_coverage_lines(source, options) do
{diff, 0} =
System.cmd("git", ["diff", "--no-color", options[:from_git_rev], "HEAD", source.filename])

diff
|> String.split("\n")
|> Enum.each(fn str ->
case Regex.run(@git_diff_regex, str) do
[_0, line] ->
start = String.to_integer(line)
print_diff_range((start - 1)..(start - 1), source)

[_0, line, shift] ->
start = String.to_integer(line)
count = String.to_integer(shift)

if count > 0 do
print_diff_range((start - 1)..(start - 2 + count), source)
else
:skip
end

nil ->
:skip
end
end)
end

defp print_diff_range(range, source) do
if Enum.any?(source.changed_ranges, &(!disjoint?(&1, range))) do
IO.puts("\n")

Enum.each(range, fn line_number ->
changed? = Enum.any?(source.changed_ranges, &Enum.member?(&1, line_number))
line = Enum.at(source.lines, line_number)
if line, do: print_line(line_number, line, changed?)
end)
end
end

defp print_line(line_number, line, changed?) do
status = line_status(changed?, line.coverage)

line_color =
case status do
:covered -> @covered_line
:missed -> @missed_line
_else -> @no_color
end

line_num = String.pad_leading(to_string(line_number + 1), 4)

line_num_color =
case status do
:regular -> @no_color
_else -> @changed_line
end

IO.puts([
line_num,
colorize(line_num_color, " │"),
colorize(line_color, "#{line.source}")
])
end

defp line_status(true, nil), do: :changed
defp line_status(true, 0), do: :missed
defp line_status(true, _coverage), do: :covered
defp line_status(false, _coverage), do: :regular

defp colorize(escape, string) do
[escape, string, :reset]
|> IO.ANSI.format_fragment(true)
|> IO.iodata_to_binary()
end

defp disjoint?(one, two) do
!Enum.any?(one, &Enum.member?(two, &1))
end
end
29 changes: 29 additions & 0 deletions lib/mix/tasks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -323,4 +323,33 @@ defmodule Mix.Tasks.Coveralls do
end
end
end

defmodule Diff do
@moduledoc """
Provides an entry point for coverage analysis of new code
"""
use Mix.Task

@preferred_cli_env :test

@impl Mix.Task
def run(args) do
# Use '--' to separate revisions from paths
{cover_args, paths} = Enum.split_while(args, & &1 != "--")

{remaining, options} = Mix.Tasks.Coveralls.parse_common_options(
cover_args,
switches: [from_git_rev: :string, threshold: :float]
)

opts = ExCoveralls.Diff.diff_file_names([
type: "diff",
from_git_rev: options[:from_git_rev] || "master",
paths: List.delete(paths, "--"),
threshold: options[:threshold] || 0.0
])

Mix.Tasks.Coveralls.do_run(remaining, opts)
end
end
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ defmodule ExCoveralls.Mixfile do
"coveralls.detail",
"coveralls.html",
"coveralls.json",
"coveralls.post"
"coveralls.post",
"coveralls.diff"
])
]
end
Expand Down
41 changes: 41 additions & 0 deletions test/diff_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
defmodule ExCoveralls.DiffTest do
use ExUnit.Case
import Mock
import ExUnit.CaptureIO
alias ExCoveralls.Diff

@content "defmodule Test do\n def test do\n called\n not_called\n end\n def test2, do: nil\nend\n"
@counts [0, 0, 1, 0, nil, nil]
@filename "test/fixtures/test.ex"
@source_info [%{name: @filename, source: @content, coverage: @counts}]
@git_diff "@@ +3,2 @@\n\n@@ -4 +4 @@"

test "prints coverage info" do
options = [threshold: 0.0, files: [@filename]]

with_mock(System, cmd: fn "git", ["diff" | _rest] -> {@git_diff, 0} end) do
output = capture_io(fn -> Diff.execute(@source_info, options) end)
assert output =~ @filename
assert output =~ "3\e[38;5;46m │\e[0m\e[38;5;71m called\e[0m"
assert output =~ "4\e[38;5;46m │\e[0m\e[38;5;131m not_called\e[0m"
end
end

test "exits with code 1 when coverage is less than minimum" do
options = [threshold: 100.0, files: [@filename]]

result =
catch_exit(
with_mock(System, cmd: fn "git", ["diff" | _rest] -> {@git_diff, 0} end) do
output =
capture_io(fn ->
Diff.execute(@source_info, options)
end)

assert String.contains?(output, "FAILED: Expected minimum coverage of 100%, got 50%.")
end
)

assert result == {:shutdown, 1}
end
end
5 changes: 5 additions & 0 deletions test/excoveralls_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ defmodule ExCoverallsTest do
assert called ExCoveralls.Json.execute(@stats,[])
end

test_with_mock "analyze diff", ExCoveralls.Diff, [execute: fn(_, _) -> nil end] do
ExCoveralls.analyze(@stats, "diff", [])
assert called ExCoveralls.Diff.execute(@stats, [])
end

test_with_mock "analyze general", ExCoveralls.Post, [execute: fn(_,_) -> nil end] do
ExCoveralls.analyze(@stats, "post", [])
assert called ExCoveralls.Post.execute(@stats, [])
Expand Down
Loading