├── 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 | gocol 1 12 | 13 | into this! 14 | 15 | gocol 2 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 | gocol 4 36 | 37 | 38 | # Colors and ranges 🌈 39 | 40 | Currently only a fixed range of colors and percentage is available. 41 | 42 | gocol 3 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 | --------------------------------------------------------------------------------