1// Copyright 2013 The Go Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style 3// license that can be found in the LICENSE file. 4 5package main 6 7import ( 8 "bufio" 9 "bytes" 10 "cmd/internal/browser" 11 "fmt" 12 "html/template" 13 "io" 14 "math" 15 "os" 16 "path/filepath" 17 "strings" 18 19 "golang.org/x/tools/cover" 20) 21 22// htmlOutput reads the profile data from profile and generates an HTML 23// coverage report, writing it to outfile. If outfile is empty, 24// it writes the report to a temporary file and opens it in a web browser. 25func htmlOutput(profile, outfile string) error { 26 profiles, err := cover.ParseProfiles(profile) 27 if err != nil { 28 return err 29 } 30 31 var d templateData 32 33 dirs, err := findPkgs(profiles) 34 if err != nil { 35 return err 36 } 37 38 for _, profile := range profiles { 39 fn := profile.FileName 40 if profile.Mode == "set" { 41 d.Set = true 42 } 43 file, err := findFile(dirs, fn) 44 if err != nil { 45 return err 46 } 47 src, err := os.ReadFile(file) 48 if err != nil { 49 return fmt.Errorf("can't read %q: %v", fn, err) 50 } 51 var buf strings.Builder 52 err = htmlGen(&buf, src, profile.Boundaries(src)) 53 if err != nil { 54 return err 55 } 56 d.Files = append(d.Files, &templateFile{ 57 Name: fn, 58 Body: template.HTML(buf.String()), 59 Coverage: percentCovered(profile), 60 }) 61 } 62 63 var out *os.File 64 if outfile == "" { 65 var dir string 66 dir, err = os.MkdirTemp("", "cover") 67 if err != nil { 68 return err 69 } 70 out, err = os.Create(filepath.Join(dir, "coverage.html")) 71 } else { 72 out, err = os.Create(outfile) 73 } 74 if err != nil { 75 return err 76 } 77 err = htmlTemplate.Execute(out, d) 78 if err2 := out.Close(); err == nil { 79 err = err2 80 } 81 if err != nil { 82 return err 83 } 84 85 if outfile == "" { 86 if !browser.Open("file://" + out.Name()) { 87 fmt.Fprintf(os.Stderr, "HTML output written to %s\n", out.Name()) 88 } 89 } 90 91 return nil 92} 93 94// percentCovered returns, as a percentage, the fraction of the statements in 95// the profile covered by the test run. 96// In effect, it reports the coverage of a given source file. 97func percentCovered(p *cover.Profile) float64 { 98 var total, covered int64 99 for _, b := range p.Blocks { 100 total += int64(b.NumStmt) 101 if b.Count > 0 { 102 covered += int64(b.NumStmt) 103 } 104 } 105 if total == 0 { 106 return 0 107 } 108 return float64(covered) / float64(total) * 100 109} 110 111// htmlGen generates an HTML coverage report with the provided filename, 112// source code, and tokens, and writes it to the given Writer. 113func htmlGen(w io.Writer, src []byte, boundaries []cover.Boundary) error { 114 dst := bufio.NewWriter(w) 115 for i := range src { 116 for len(boundaries) > 0 && boundaries[0].Offset == i { 117 b := boundaries[0] 118 if b.Start { 119 n := 0 120 if b.Count > 0 { 121 n = int(math.Floor(b.Norm*9)) + 1 122 } 123 fmt.Fprintf(dst, `<span class="cov%v" title="%v">`, n, b.Count) 124 } else { 125 dst.WriteString("</span>") 126 } 127 boundaries = boundaries[1:] 128 } 129 switch b := src[i]; b { 130 case '>': 131 dst.WriteString(">") 132 case '<': 133 dst.WriteString("<") 134 case '&': 135 dst.WriteString("&") 136 case '\t': 137 dst.WriteString(" ") 138 default: 139 dst.WriteByte(b) 140 } 141 } 142 return dst.Flush() 143} 144 145// rgb returns an rgb value for the specified coverage value 146// between 0 (no coverage) and 10 (max coverage). 147func rgb(n int) string { 148 if n == 0 { 149 return "rgb(192, 0, 0)" // Red 150 } 151 // Gradient from gray to green. 152 r := 128 - 12*(n-1) 153 g := 128 + 12*(n-1) 154 b := 128 + 3*(n-1) 155 return fmt.Sprintf("rgb(%v, %v, %v)", r, g, b) 156} 157 158// colors generates the CSS rules for coverage colors. 159func colors() template.CSS { 160 var buf bytes.Buffer 161 for i := 0; i < 11; i++ { 162 fmt.Fprintf(&buf, ".cov%v { color: %v }\n", i, rgb(i)) 163 } 164 return template.CSS(buf.String()) 165} 166 167var htmlTemplate = template.Must(template.New("html").Funcs(template.FuncMap{ 168 "colors": colors, 169}).Parse(tmplHTML)) 170 171type templateData struct { 172 Files []*templateFile 173 Set bool 174} 175 176// PackageName returns a name for the package being shown. 177// It does this by choosing the penultimate element of the path 178// name, so foo.bar/baz/foo.go chooses 'baz'. This is cheap 179// and easy, avoids parsing the Go file, and gets a better answer 180// for package main. It returns the empty string if there is 181// a problem. 182func (td templateData) PackageName() string { 183 if len(td.Files) == 0 { 184 return "" 185 } 186 fileName := td.Files[0].Name 187 elems := strings.Split(fileName, "/") // Package path is always slash-separated. 188 // Return the penultimate non-empty element. 189 for i := len(elems) - 2; i >= 0; i-- { 190 if elems[i] != "" { 191 return elems[i] 192 } 193 } 194 return "" 195} 196 197type templateFile struct { 198 Name string 199 Body template.HTML 200 Coverage float64 201} 202 203const tmplHTML = ` 204<!DOCTYPE html> 205<html> 206 <head> 207 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> 208 <title>{{$pkg := .PackageName}}{{if $pkg}}{{$pkg}}: {{end}}Go Coverage Report</title> 209 <style> 210 body { 211 background: black; 212 color: rgb(80, 80, 80); 213 } 214 body, pre, #legend span { 215 font-family: Menlo, monospace; 216 font-weight: bold; 217 } 218 #topbar { 219 background: black; 220 position: fixed; 221 top: 0; left: 0; right: 0; 222 height: 42px; 223 border-bottom: 1px solid rgb(80, 80, 80); 224 } 225 #content { 226 margin-top: 50px; 227 } 228 #nav, #legend { 229 float: left; 230 margin-left: 10px; 231 } 232 #legend { 233 margin-top: 12px; 234 } 235 #nav { 236 margin-top: 10px; 237 } 238 #legend span { 239 margin: 0 5px; 240 } 241 {{colors}} 242 </style> 243 </head> 244 <body> 245 <div id="topbar"> 246 <div id="nav"> 247 <select id="files"> 248 {{range $i, $f := .Files}} 249 <option value="file{{$i}}">{{$f.Name}} ({{printf "%.1f" $f.Coverage}}%)</option> 250 {{end}} 251 </select> 252 </div> 253 <div id="legend"> 254 <span>not tracked</span> 255 {{if .Set}} 256 <span class="cov0">not covered</span> 257 <span class="cov8">covered</span> 258 {{else}} 259 <span class="cov0">no coverage</span> 260 <span class="cov1">low coverage</span> 261 <span class="cov2">*</span> 262 <span class="cov3">*</span> 263 <span class="cov4">*</span> 264 <span class="cov5">*</span> 265 <span class="cov6">*</span> 266 <span class="cov7">*</span> 267 <span class="cov8">*</span> 268 <span class="cov9">*</span> 269 <span class="cov10">high coverage</span> 270 {{end}} 271 </div> 272 </div> 273 <div id="content"> 274 {{range $i, $f := .Files}} 275 <pre class="file" id="file{{$i}}" style="display: none">{{$f.Body}}</pre> 276 {{end}} 277 </div> 278 </body> 279 <script> 280 (function() { 281 var files = document.getElementById('files'); 282 var visible; 283 files.addEventListener('change', onChange, false); 284 function select(part) { 285 if (visible) 286 visible.style.display = 'none'; 287 visible = document.getElementById(part); 288 if (!visible) 289 return; 290 files.value = part; 291 visible.style.display = 'block'; 292 location.hash = part; 293 } 294 function onChange() { 295 select(files.value); 296 window.scrollTo(0, 0); 297 } 298 if (location.hash != "") { 299 select(location.hash.substr(1)); 300 } 301 if (!visible) { 302 select("file0"); 303 } 304 })(); 305 </script> 306</html> 307` 308