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