forked from grosser/go-testcov
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.go
216 lines (181 loc) · 6.11 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
package main
import (
"fmt"
"os"
"regexp"
"sort"
"strings"
)
func main() {
argv := os.Args[1:len(os.Args)] // remove executable name
exitFunction(goTestCheckCoverage(argv))
}
type Section struct {
path string
startLine int
startChar int
endLine int
endChar int
sortValue int
}
// covert raw coverage line into a section github.com/foo/bar/baz.go:1.2,3.5 1 0
func NewSection(raw string) Section {
parts := strings.SplitN(raw, ":", 2)
file := parts[0]
parts = strings.FieldsFunc(parts[1], func(r rune) bool { return r == '.' || r == ',' || r == ' ' })
startLine := stringToInt(parts[0])
startChar := stringToInt(parts[1])
endLine := stringToInt(parts[2])
endChar := stringToInt(parts[3])
sortValue := startLine*100000 + startChar // we group by path, so we only need to sort by line+char
return Section{file, startLine, startChar, endLine, endChar, sortValue}
}
func (s Section) Numbers() string {
return fmt.Sprintf("%v.%v,%v.%v", s.startLine, s.startChar, s.endLine, s.endChar)
}
// injection point to enable test coverage
var exitFunction func(code int) = os.Exit
// Run go test with given arguments + coverage and inspect coverage after run
func goTestCheckCoverage(argv []string) (exitCode int) {
// Run go test
coveragePath := "coverage.out"
os.Remove(coveragePath)
defer os.Remove(coveragePath)
exitCode = runGoTestWithCoverage(argv, coveragePath)
if exitCode == 0 {
exitCode = checkCoverage(coveragePath)
}
return
}
func runGoTestWithCoverage(argv []string, coveragePath string) (exitCode int) {
argv = append([]string{"test"}, argv...)
argv = append(argv, "-coverprofile", coveragePath)
return runCommand("go", argv...)
}
// Tests passed, so let's check coverage for each path that has coverage
func checkCoverage(coveragePath string) (exitCode int) {
uncoveredSections := uncoveredSections(coveragePath)
pathSections := groupSectionsByPath(uncoveredSections)
wd, err := os.Getwd()
check(err)
iterateSorted(pathSections, func(path string, sections []Section) {
// remove package prefix like "github.com/user/lib", but cache the call to os.Getwd
displayPath, readPath := normalizeModulePath(path, wd)
configured := configuredUncovered(readPath)
current := len(sections)
if current == configured {
return
}
// keep sections that are marked with "untested section" comment
// need to be careful to not change the list while iterating, see https://pauladamsmith.com/blog/2016/07/go-modify-slice-iteration.html
// NOTE: this is a bit rough as it does not account for partial lines via start/end characters
content := strings.Split(readFile(readPath), "\n")
regex := regexp.MustCompile("//.*untested section(\\s|,|$)")
uncheckedSections := sections
sections = []Section{}
for _, section := range uncheckedSections {
for lineNumber := section.startLine; lineNumber <= section.endLine; lineNumber++ {
if regex.MatchString(content[lineNumber-1]) {
break // section is ignored
} else if lineNumber == section.endLine {
sections = append(sections, section) // keep the section
}
}
}
current = len(sections)
if current == configured {
return
}
details := fmt.Sprintf("(%v current vs %v configured)", current, configured)
if current > configured {
// TODO: color when tty
fmt.Fprintf(os.Stderr, "%v new uncovered sections introduced %v\n", displayPath, details)
// sort sections since go does not
sort.Slice(sections, func(i, j int) bool {
return sections[i].sortValue < sections[j].sortValue
})
for _, section := range sections {
// copy-paste friendly snippets
fmt.Fprintln(os.Stderr, displayPath+":"+section.Numbers())
}
exitCode = 1
} else {
fmt.Fprintf(os.Stderr, "%v has less uncovered sections %v, decrement configured uncovered?\n", displayPath, details)
}
})
return
}
func groupSectionsByPath(sections []Section) (grouped map[string][]Section) {
grouped = map[string][]Section{}
for _, section := range sections {
path := section.path
group, ok := grouped[path]
if !ok {
grouped[path] = []Section{}
}
grouped[path] = append(group, section)
}
return
}
// Find the uncovered sections given a coverage path
func uncoveredSections(coverageFilePath string) (sections []Section) {
sections = []Section{}
content := readFile(coverageFilePath)
lines := splitWithoutEmpty(content, '\n')
// remove the initial `set: mode` line
if len(lines) == 0 {
return
}
lines = lines[1:]
// we want lines that end in " 0", they have no coverage
for _, line := range lines {
if strings.HasSuffix(line, " 0") {
sections = append(sections, NewSection(line))
}
}
return
}
func normalizeModulePath(path string, workingDirectory string) (displayPath string, readPath string) {
modulePrefixSize := 3 // foo.com/bar/baz + file.go
separator := string(os.PathSeparator)
parts := strings.SplitN(path, separator, modulePrefixSize+1)
goPath, hasGoPath := os.LookupEnv("GOPATH")
inGoPath := false
goPrefixedPath := joinPath(goPath, "src", path)
if hasGoPath {
_, err := os.Stat(goPrefixedPath)
inGoPath = !os.IsNotExist(err)
}
// path too short, return a good guess
if len(parts) <= modulePrefixSize {
if inGoPath {
return path, goPrefixedPath
} else {
return path, path
}
}
prefix := strings.Join(parts[:modulePrefixSize], separator)
demodularized := strings.SplitN(path, prefix+separator, 2)[1]
// folder is not in go path ... remove module nesting
if !inGoPath {
return demodularized, demodularized
}
// we are in a nested folder ... remove module nesting and expand full goPath
if strings.HasSuffix(workingDirectory, prefix) {
return demodularized, goPrefixedPath
}
// testing remote package, don't expand display but expand full goPath
return path, goPrefixedPath
}
// How many sections are expected to be uncovered, 0 if not configured
// TODO: return an error when the file does not exist and handle that gracefully in the caller
func configuredUncovered(path string) (count int) {
content := readFile(path)
regex := regexp.MustCompile("// *untested sections: *([0-9]+)")
match := regex.FindStringSubmatch(content)
if len(match) == 2 {
return stringToInt(match[1])
} else {
return 0
}
}