Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
konradreiche committed Dec 21, 2023
0 parents commit dbbd1be
Show file tree
Hide file tree
Showing 18 changed files with 559 additions and 0 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: CI

on:
push:
tags:
- v*
branches:
- main
pull_request:

jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
cache: true
- run: go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 📑 coverdiff
[![ci](https://github.com/konradreiche/coverdiff/actions/workflows/ci.yaml/badge.svg)](https://github.com/konradreiche/coverdiff/actions) [![codecov](https://codecov.io/gh/konradreiche/coverdiff/graph/badge.svg?token=kXoAXWhLJS)](https://codecov.io/gh/konradreiche/coverdiff)

Print your Go test coverage line-by-line in the form of a code diff, highlighting each line. This tool takes inspiration from Go's HTML presentation of test coverage but brings it to the terminal instead.

[![asciicast](https://asciinema.org/a/627967.svg)](https://asciinema.org/a/627967)

## Getting Started

```
go install github.com/konradreiche/coverdiff
```

## Usage

```
Usage:
coverdiff [file]
Flags:
-h, --help print help text
Examples:
go test -cover -coverprofile=coverage.out
cat coverage.out | coverdiff
go test -cover -coverprofile >(coverdiff)
```
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- "testdata/**/*"
33 changes: 33 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"io"
"os"

"golang.org/x/tools/cover"
)

func command(stdin io.Reader, stdout io.Writer) error {
// parse coverage profiles from stdin or file path if provided
profiles, err := parseCoverProfiles(stdin)
if err != nil {
return err
}

// find path that points to module root which will be needed to construct an
// absolute file path to the Go source files to generate a diff for
moduleInfo, err := findModuleInfo()
if err != nil {
return err
}
return printDiff(stdout, profiles, moduleInfo.modulePath)
}

func parseCoverProfiles(stdin io.Reader) ([]*cover.Profile, error) {
if len(os.Args) > 1 && os.Args[1] != "-" {
profiles, err := cover.ParseProfiles(os.Args[1])
return profiles, err
}
profiles, err := cover.ParseProfilesFromReader(stdin)
return profiles, err
}
69 changes: 69 additions & 0 deletions command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"bytes"
"os"
"path/filepath"
"testing"
)

func TestCommand(t *testing.T) {
t.Run("from-stdin-pipe", func(t *testing.T) {
args := os.Args
t.Cleanup(func() { os.Args = args })

// override os.Args which contains flags from the test binary
os.Args = []string{
"coverdiff",
}

var stdout bytes.Buffer
stdin := bytes.NewBufferString(readFile(t, "testdata/coverage.out"))
if err := command(stdin, &stdout); err != nil {
t.Fatal(err)
}

got := stdout.String()
want := readFile(t, "testdata/coverdiff.out")
if got != want {
t.Errorf("got len=%d, want len=%d", len(got), len(want))
}
})

t.Run("from-file", func(t *testing.T) {
os.Args[1] = filepath.Join(projectPath, "testdata/coverage.out")

var stdout bytes.Buffer
if err := command(nil, &stdout); err != nil {
t.Fatal(err)
}

got := stdout.String()
want := readFile(t, "testdata/coverdiff.out")
if got != want {
t.Errorf("got len=%d, want len=%d", len(got), len(want))
}
})

t.Run("outside-go-module", func(t *testing.T) {
changeDir(t, "..")

var stdout bytes.Buffer
err := command(nil, &stdout)

got := err.Error()
want := "findModuleDir: no go.mod file found"
if got != want {
t.Errorf("got %s, want: %s", got, want)
}
})
}

func readFile(tb testing.TB, name string) string {
tb.Helper()
b, err := os.ReadFile(name)
if err != nil {
tb.Fatal(err)
}
return string(b)
}
62 changes: 62 additions & 0 deletions diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"fmt"
"io"
"os"
"strings"

"golang.org/x/tools/cover"
)

func printDiff(stdout io.Writer, profiles []*cover.Profile, modulePath string) error {
for _, profile := range profiles {
// create file path using absolute path to module
filepath := strings.ReplaceAll(profile.FileName, modulePath+"/", "")

b, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("os.ReadFile: %w", err)
}
lines := strings.Split(string(b), "\n")

// track coverage mapping source code line to profile block
blockByLine := make(map[int][]cover.ProfileBlock)

for _, block := range profile.Blocks {
for i := block.StartLine; i <= block.EndLine; i++ {
// handle coverage of blocks by skipping coverage for only one line
// otherwise we will print duplicate lines
if len(blockByLine[i]) == 1 {
continue
}
blockByLine[i] = append(blockByLine[i], block)
}
}

// print diff file headers
fmt.Fprintf(stdout, "diff --git a/%s b/%s\n", filepath, filepath)
fmt.Fprintf(stdout, "--- a/%s\n", filepath)
fmt.Fprintf(stdout, "+++ b/%s\n", filepath)

// print diff index header
fmt.Fprintf(stdout, "@@ -%d,%d +%d,%d @@ %s\n", 0, 0, len(lines), 0, lines[0])

// print all lines regardless of coverage
for i, line := range lines[1:] {
blocks, ok := blockByLine[i+2]
if !ok {
fmt.Fprintln(stdout, line)
continue
}
for _, block := range blocks {
if block.Count == 1 {
fmt.Fprintf(stdout, "+%s\n", line)
} else {
fmt.Fprintf(stdout, "-%s\n", line)
}
}
}
}
return nil
}
32 changes: 32 additions & 0 deletions diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package main

import (
"bytes"
"path/filepath"
"runtime"
"runtime/debug"
"testing"

"golang.org/x/tools/cover"
)

var (
_, b, _, _ = runtime.Caller(0)
projectPath = filepath.Dir(b)
)

func TestPrintDiff(t *testing.T) {
profiles, err := cover.ParseProfiles("testdata/coverage.out")
if err != nil {
t.Fatal(err)
}
bi, ok := debug.ReadBuildInfo()
if !ok {
t.Fatal("binary build information not available")
}

var b bytes.Buffer
if err := printDiff(&b, profiles, bi.Path); err != nil {
t.Fatal(err)
}
}
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/konradreiche/coverdiff

go 1.21.5

require (
golang.org/x/mod v0.14.0
golang.org/x/tools v0.16.0
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
35 changes: 35 additions & 0 deletions help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package main

import (
"fmt"
"io"
)

const usage = `📑 coverdiff - print Go test coverage as diff
Usage:
coverdiff [file]
Flags:
-h, --help print help text
coverdiff is a tool designed to process the cover profile output of Go and
display the coverage as a diff, similar to Go's -html option but optimized for
terminal convenience. You can provide the cover profile as a file or pass it
through standard input.
Examples:
go test -cover -coverprofile=coverage.out
cat coverage.out | coverdiff
go test -cover -coverprofile >(coverdiff)
`

func printUsage(stderr io.Writer) func() {
// return func() to conform with the required type of flag.CommandLine.Usage
return func() {
fmt.Fprint(stderr, usage)
}
}
17 changes: 17 additions & 0 deletions help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package main

import (
"bytes"
"testing"
)

func TestPrintUsage(t *testing.T) {
var stderr bytes.Buffer
printUsage(&stderr)()

got := stderr.String()
want := usage
if got != want {
t.Errorf("got %s, want %s", got, want)
}
}
18 changes: 18 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import (
"flag"
"fmt"
"os"
)

func main() {
flag.CommandLine.Usage = printUsage(os.Stderr)
flag.Parse()

if err := command(os.Stdin, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, fmt.Errorf("coverdiff: %w", err))
fmt.Fprintln(os.Stderr, "Use coverdiff --help to display help text")
os.Exit(1)
}
}
Loading

0 comments on commit dbbd1be

Please sign in to comment.