├── assets
├── gocol_1.png
├── gocol_2.png
├── gocol_3.png
├── gocol_4.png
└── cov.txt
├── go.mod
├── .gitignore
├── LICENSE
├── README.md
├── go.sum
└── main.go
/assets/gocol_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/enrichman/gocol/HEAD/assets/gocol_1.png
--------------------------------------------------------------------------------
/assets/gocol_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/enrichman/gocol/HEAD/assets/gocol_2.png
--------------------------------------------------------------------------------
/assets/gocol_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/enrichman/gocol/HEAD/assets/gocol_3.png
--------------------------------------------------------------------------------
/assets/gocol_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/enrichman/gocol/HEAD/assets/gocol_4.png
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/enrichman/gocol
2 |
3 | go 1.21
4 |
5 | require github.com/charmbracelet/lipgloss v0.9.1
6 |
7 | require (
8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
9 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
10 | github.com/mattn/go-isatty v0.0.20 // indirect
11 | github.com/mattn/go-runewidth v0.0.15 // indirect
12 | github.com/muesli/reflow v0.3.0 // indirect
13 | github.com/muesli/termenv v0.15.2 // indirect
14 | github.com/rivo/uniseg v0.2.0 // indirect
15 | golang.org/x/sys v0.14.0 // indirect
16 | )
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 |
--------------------------------------------------------------------------------
/assets/cov.txt:
--------------------------------------------------------------------------------
1 | ? github.com/enrichman/gocol [no test files]
2 | ok github.com/enrichman/gocol 6.316s coverage: 0.0% of statements [no tests to run]
3 | ok github.com/enrichman/gocol 6.316s coverage: 9.5% of statements
4 | ok github.com/enrichman/gocol 1.073s coverage: 13.2% of statements
5 | ok github.com/enrichman/gocol 0.594s coverage: 23.5% of statements
6 | ok github.com/enrichman/gocol 1.690s coverage: 35.1% of statements
7 | ok github.com/enrichman/gocol 0.445s coverage: 42.1% of statements
8 | ok github.com/enrichman/gocol 0.372s coverage: 55.9% of statements
9 | ok github.com/enrichman/gocol 0.219s coverage: 67.4% of statements
10 | ok github.com/enrichman/gocol 0.263s coverage: 72.3% of statements
11 | ok github.com/enrichman/gocol 0.263s coverage: 84.3% of statements
12 | ok github.com/enrichman/gocol (cached) coverage: 95.3% of statements
13 | ok github.com/enrichman/gocol (cached) coverage: 100.0% of statements
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Enrico Candino
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gocol
2 |
3 | Go color your test coverage! ✨
4 |
5 | ```
6 | go test -cover ./... | gocol
7 | ```
8 |
9 | `gocol` will turn this:
10 |
11 |
12 |
13 | into this!
14 |
15 |
16 |
17 | See immediately how much your projects is covered!
18 |
19 | ## Installation
20 |
21 | To install `gocol` just use `go install`
22 | ```
23 | go install github.com/enrichman/gocol@v0.0.1
24 | ```
25 |
26 | ## Usage
27 |
28 | Pipe the output of a `go test -cover` to `gocol`
29 | ```
30 | go test -cover ./... | gocol
31 | ```
32 |
33 | If you are using the verbose `-v` then the `PASS|FAIL|SKIP` lines will be coloured as well.
34 |
35 |
36 |
37 |
38 | # Colors and ranges 🌈
39 |
40 | Currently only a fixed range of colors and percentage is available.
41 |
42 |
43 |
44 | # Feedback
45 | If you like the project please star it on Github 🌟, and feel free to drop me a note, or [open an issue](https://github.com/enrichman/gocol/issues/new)!
46 |
47 | [Twitter](https://twitter.com/enrichmann)
48 |
49 | # License
50 |
51 | [MIT](LICENSE)
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
4 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
5 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
6 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
7 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
9 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
10 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
11 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
12 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
13 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
14 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
15 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
16 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
17 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
18 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
19 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20 | golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
21 | golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
22 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "log"
9 | "os"
10 | "runtime/debug"
11 | "strconv"
12 | "strings"
13 |
14 | "github.com/charmbracelet/lipgloss"
15 | )
16 |
17 | const (
18 | WHITE = "#FFF"
19 | BLACK = "#000"
20 | GRAY = "#AAA"
21 |
22 | RED = "#E03C32"
23 | ORANGE = "#FF8C01"
24 | YELLOW = "#FFE733"
25 | LIGHT_GREEN = "#7BB662"
26 | GREEN = "#006B3D"
27 | DARK_GREEN = "#024E1B"
28 |
29 | BRIGHT_GREEN = "#00D700"
30 | FUCHSIA = "#FF00FF"
31 | )
32 |
33 | var (
34 | brightGreenStyle = NewStyleWithFG(BRIGHT_GREEN)
35 | yellowStyle = NewStyleWithFG(YELLOW)
36 | redColor = NewStyleWithFG(RED)
37 | fuchsiaColor = NewStyleWithFG(FUCHSIA)
38 |
39 | covNopeStyle = NewStyleWithFG(BLACK).
40 | Background(lipgloss.Color(GRAY))
41 |
42 | covNope = NewCoverageColor(0, 0, BLACK, GRAY)
43 | covColors = []*CoverageColor{
44 | NewCoverageColor(0, 30, BLACK, RED),
45 | NewCoverageColor(30, 50, BLACK, ORANGE),
46 | NewCoverageColor(50, 70, BLACK, YELLOW),
47 | NewCoverageColor(70, 80, BLACK, LIGHT_GREEN),
48 | NewCoverageColor(80, 90, WHITE, GREEN),
49 | NewCoverageColor(90, 100, WHITE, DARK_GREEN),
50 | }
51 | )
52 |
53 | type CoverageColor struct {
54 | start float64
55 | end float64
56 | style lipgloss.Style
57 | }
58 |
59 | func (c *CoverageColor) Color(v string) string {
60 | return c.style.Render(v)
61 | }
62 |
63 | func NewCoverageColor(start, end float64, fgColor, bgColor string) *CoverageColor {
64 | return &CoverageColor{
65 | start: start,
66 | end: end,
67 | style: NewStyleWithFG(fgColor).
68 | Background(lipgloss.Color(bgColor)),
69 | }
70 | }
71 |
72 | func NewStyleWithFG(fgColor string) lipgloss.Style {
73 | return lipgloss.NewStyle().Foreground(lipgloss.Color(fgColor))
74 | }
75 |
76 | func main() {
77 | checkPipe()
78 |
79 | // read lines and fail if something happened
80 | lines, err := readLines()
81 | if err != nil {
82 | log.Fatal(err)
83 | }
84 |
85 | noTestFileLines := []string{}
86 | colorizedLines := []string{}
87 |
88 | for _, line := range lines {
89 | // append to write [no test files] at the end
90 | if strings.Contains(line, "[no test files]") {
91 | line = reorderNoTestLine(line)
92 | line := colorizeLine(line)
93 | noTestFileLines = append(noTestFileLines, line)
94 | continue
95 | }
96 |
97 | // reorder columns for coverage lines
98 | if strings.Contains(line, "coverage:") {
99 | line = reorderCoverageLine(line)
100 | }
101 |
102 | line := colorizeLine(line)
103 | colorizedLines = append(colorizedLines, line)
104 | }
105 |
106 | // print colorized lines
107 | for _, line := range colorizedLines {
108 | fmt.Println(line)
109 | }
110 |
111 | // print [no test files] lines
112 | for _, line := range noTestFileLines {
113 | fmt.Println(line)
114 | }
115 | }
116 |
117 | func checkPipe() {
118 | // check if there is something to read on STDIN
119 | stat, err := os.Stdin.Stat()
120 | if err != nil {
121 | log.Fatal(err)
122 | }
123 |
124 | isPipe := (stat.Mode() & os.ModeCharDevice) == 0
125 | if !isPipe {
126 | help := `Gocol colorize your coverage
127 | Version: %s
128 |
129 | Usage:
130 | go test -cover ./... | gocol
131 | `
132 | version := ""
133 | if info, ok := debug.ReadBuildInfo(); ok {
134 | if info.Main.Version != "" {
135 | version = info.Main.Version
136 | }
137 | }
138 | fmt.Printf(help, version)
139 | os.Exit(0)
140 | }
141 | }
142 |
143 | func readLines() ([]string, error) {
144 | var lines []string
145 | var line string
146 | var err error
147 |
148 | // read until EOF
149 | reader := bufio.NewReader(os.Stdin)
150 | for err == nil {
151 | line, err = reader.ReadString('\n')
152 | line = strings.TrimSpace(line)
153 | if line != "" {
154 | lines = append(lines, line)
155 | }
156 | }
157 |
158 | if errors.Is(err, io.EOF) {
159 | return lines, nil
160 | }
161 | return nil, err
162 | }
163 |
164 | func reorderCoverageLine(line string) string {
165 | splitted := strings.Split(line, "\t")
166 |
167 | if len(splitted) > 3 {
168 | covColumn := splitted[3]
169 |
170 | // keep the [no tests to run] at the end
171 | if strings.HasSuffix(covColumn, " [no tests to run]") {
172 | covColumn = strings.TrimSuffix(covColumn, " [no tests to run]")
173 | splitted = append(splitted, "[no tests to run]")
174 | }
175 |
176 | // switch package and coverage
177 | splitted[3], splitted[1] = splitted[1], covColumn
178 | }
179 |
180 | return strings.Join(splitted, "\t")
181 | }
182 |
183 | func reorderNoTestLine(line string) string {
184 | splitted := strings.Split(line, "\t")
185 |
186 | if len(splitted) == 3 {
187 | covColumn := splitted[2]
188 | // switch [no test files] and package
189 | splitted[2], splitted[1] = splitted[1], covColumn
190 | }
191 |
192 | return strings.Join(splitted, "\t")
193 | }
194 |
195 | func colorizeLine(line string) string {
196 | if strings.Contains(line, "coverage:") {
197 | return colorizeCoverageLine(line)
198 | }
199 |
200 | if strings.HasPrefix(line, "?") {
201 | line = colorizeMatch(line, "?", fuchsiaColor)
202 | line = colorizeMatchBg(line, "[no test files]", covNopeStyle)
203 | return line
204 | }
205 |
206 | trimmed := line
207 | if strings.HasPrefix(line, "--- ") {
208 | trimmed = strings.TrimPrefix(line, "--- ")
209 | }
210 |
211 | style := lipgloss.NewStyle()
212 |
213 | switch {
214 | case strings.HasPrefix(trimmed, "PASS"):
215 | style = brightGreenStyle
216 | case strings.HasPrefix(trimmed, "SKIP"):
217 | style = yellowStyle
218 | case strings.HasPrefix(trimmed, "FAIL"):
219 | style = redColor
220 | }
221 |
222 | return style.Render(line)
223 | }
224 |
225 | func colorizeCoverageLine(line string) string {
226 | line = colorizeMatch(line, "ok", brightGreenStyle)
227 | line = colorizeMatch(line, "(cached)", yellowStyle)
228 | line = colorizeMatch(line, "[no tests to run]", fuchsiaColor)
229 | line = colorizeCoverage(line)
230 |
231 | return line
232 | }
233 |
234 | func colorizeMatch(line, match string, style lipgloss.Style) string {
235 | index := strings.Index(line, match)
236 | if index > -1 {
237 | return strings.Replace(line, match, style.Render(match), 1)
238 | }
239 | return line
240 | }
241 |
242 | func colorizeMatchBg(line, match string, style lipgloss.Style) string {
243 | index := strings.Index(line, match)
244 | if index > -1 {
245 | return strings.Replace(line, match, style.Render(match), 1)
246 | }
247 | return line
248 | }
249 |
250 | func colorizeCoverage(line string) string {
251 | coverageIndex := strings.Index(line, "coverage:")
252 | if coverageIndex == -1 {
253 | return line
254 | }
255 |
256 | // fix different percentage lenght
257 | // if strings.Contains(line, "coverage: 0.0%") {
258 | // line = strings.Replace(line, "coverage: 0.0%", "coverage: 0.0%", 1)
259 | // } else if !strings.Contains(line, "coverage: 100.0%") {
260 | // line = strings.Replace(line, "coverage: ", "coverage: ", 1)
261 | // }
262 |
263 | statementsIndex := strings.Index(line, "statements")
264 | if statementsIndex == -1 {
265 | return line
266 | }
267 | end := statementsIndex + len("statements")
268 |
269 | // fix different percentage lenght
270 | switch end - coverageIndex {
271 | case 28:
272 | line = strings.Replace(line, "coverage: ", "coverage: ", 1)
273 | end += 2
274 | case 29:
275 | line = strings.Replace(line, "coverage: ", "coverage: ", 1)
276 | end += 1
277 | }
278 |
279 | percentage, err := findPercentageValue(line)
280 | if err != nil {
281 | fmt.Println(err)
282 | return line
283 | }
284 |
285 | covColor := getCoverageColor(percentage)
286 |
287 | return line[:coverageIndex] +
288 | covColor.Color(line[coverageIndex:end]) +
289 | line[end:]
290 | }
291 |
292 | func findPercentageValue(line string) (float64, error) {
293 | percentageStr := ""
294 |
295 | for _, field := range strings.Fields(line) {
296 | if strings.HasSuffix(field, "%") {
297 | percentageStr = strings.TrimSuffix(field, "%")
298 | break
299 | }
300 | }
301 |
302 | return strconv.ParseFloat(percentageStr, 32)
303 | }
304 |
305 | func getCoverageColor(coverage float64) *CoverageColor {
306 | if coverage == 0 {
307 | return covNope
308 | }
309 |
310 | for _, covCol := range covColors {
311 | if coverage >= covCol.start &&
312 | (coverage < covCol.end || covCol.end == 100) {
313 | return covCol
314 |
315 | }
316 | }
317 |
318 | return covNope
319 | }
320 |
--------------------------------------------------------------------------------