-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathmain.go
247 lines (210 loc) · 7.86 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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
const version = "v1.10.0"
// reused regex
var inlineIgnore = "//.*untested section(\\s|,|$)"
var anyInlineIgnore = regexp.MustCompile(inlineIgnore)
var startsWithInlineIgnore = regexp.MustCompile("^\\s*" + inlineIgnore)
var perFileIgnore = regexp.MustCompile("// *untested sections: *(\\S+)")
var generatedFile = regexp.MustCompile("/*generated.*\\.go$")
// test injection point to enable test coverage of exit behavior
var exitFunction = os.Exit
// delegate to runGoTestAndCheckCoverage, so we have an easy to test method
func main() {
argv := os.Args[1:len(os.Args)] // remove executable name
// print out version instead of go version when asked
if len(argv) == 1 && argv[0] == "version" {
fmt.Println(version)
exitFunction(0)
} else { // wrapping in else in case exitFunction was stubbed
exitFunction(runGoTestAndCheckCoverage(argv))
}
}
// run go test with given arguments + coverage and inspect coverage after run
func runGoTestAndCheckCoverage(argv []string) (exitCode int) {
coveragePath := "coverage.out"
_ = os.Remove(coveragePath) // remove file if it exists, to avoid confusion when test run fails
// allow users to keep the coverage.out file when they passed -cover manually
// TODO: parse options to find the location the user wanted and use+keep that
if !containsString(argv, "-cover") {
defer os.Remove(coveragePath)
}
var command []string
if len(argv) >= 1 && argv[0] == "ginko" {
// ginko needs full path or it dumps into each package
coveragePath, _ = filepath.Abs(coveragePath)
// ginko needs to files (i.e. ./...) to come last + -cover
command = append([]string{"ginko", "-cover", "-coverprofile", coveragePath}, argv[1:]...)
} else {
command = append(append([]string{"go", "test"}, argv...), "-coverprofile", coveragePath)
}
exitCode = runCommand(command...)
if exitCode != 0 {
return exitCode
}
return checkCoverage(coveragePath)
}
// check coverage for each path that has coverage
func checkCoverage(coverageFilePath string) (exitCode int) {
exitCode = 0
untestedSections := untestedSections(coverageFilePath)
sectionsByPath := groupSectionsByPath(untestedSections)
wd, err := os.Getwd()
check(err)
iterateBySortedKey(sectionsByPath, func(path string, sections []Section) {
// skip generated files since their coverage does not matter and would often have gaps
if generatedFile.MatchString(path) {
return
}
displayPath, readPath := normalizeCoveredPath(path, wd)
configuredUntested, ignoreUntested, configuredUntestedAtLine := configuredUntestedForFile(readPath)
lines := strings.Split(readFile(readPath), "\n")
sections = removeSectionsMarkedWithInlineComment(sections, lines)
actualUntested := len(sections)
details := fmt.Sprintf("(%v current vs %v configured)", actualUntested, configuredUntested)
if ignoreUntested || actualUntested == configuredUntested {
// exactly as much as we expected, nothing to do
} else if actualUntested > configuredUntested {
printUntestedSections(sections, displayPath, details)
exitCode = 1 // at least 1 failure, so say to add more tests
} else {
_, _ = fmt.Fprintf(
os.Stderr,
"%v has less untested sections %v, decrement configured untested?\nconfigured on: %v:%v",
displayPath, details, readPath, configuredUntestedAtLine)
}
})
return exitCode
}
func printUntestedSections(sections []Section, displayPath string, details string) {
// TODO: color when tty
_, _ = fmt.Fprintf(os.Stderr, "%v new untested sections introduced %v\n", displayPath, details)
// sort sections since go coverage output is not sorted
sort.Slice(sections, func(i, j int) bool {
return sections[i].sortValue < sections[j].sortValue
})
// print copy-paste friendly snippets
for _, section := range sections {
_, _ = fmt.Fprintln(os.Stderr, displayPath+":"+section.Location())
}
}
// keep untested 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
// TODO: warn about sections that have a comment but are not uncovered
func removeSectionsMarkedWithInlineComment(sections []Section, lines []string) []Section {
uncheckedSections := sections
sections = []Section{}
for _, section := range uncheckedSections {
for lineNumber := section.startLine; lineNumber <= section.endLine; lineNumber++ {
if anyInlineIgnore.MatchString(lines[lineNumber-1]) {
break // section is ignored
} else if lineNumber >= 2 && startsWithInlineIgnore.MatchString(lines[lineNumber-2]) {
break // section is ignored by inline ignore above it
} else if lineNumber == section.endLine {
sections = append(sections, section) // keep the section
}
}
}
return sections
}
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 untested sections given a coverage path
func untestedSections(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
}
// find relative path of file in current directory
func findFile(path string) (readPath string) {
parts := strings.Split(path, string(os.PathSeparator))
for len(parts) > 0 {
_, err := os.Stat(strings.Join(parts, string(os.PathSeparator)))
if err != nil {
parts = parts[1:] // shift directory to continue to look for file
} else {
break
}
}
return strings.Join(parts, string(os.PathSeparator))
}
// remove path prefix like "github.com/user/lib", but cache the call to os.Get
func normalizeCoveredPath(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 := findFile(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 untested, 0 if not configured
// also return at what line we found the comment, so we can point the user to it
func configuredUntestedForFile(path string) (count int, ignore bool, lineNumber int) {
content := readFile(path)
match := perFileIgnore.FindStringSubmatch(content)
if len(match) == 2 {
index := perFileIgnore.FindStringIndex(content)[0]
linesBeforeMatch := strings.Count(content[0:index], "\n")
if match[1] == "ignore" {
return 0, true, linesBeforeMatch + 1
} else {
return stringToInt(match[1]), false, linesBeforeMatch + 1
}
} else {
return 0, false, 0
}
}