report.mx raw
1 // Package mxcover turns a -cover run into a coverage report.
2 //
3 // The compiler writes a site table (<output>.cover_sites.json) beside a -cover
4 // build, and the instrumented runtime dumps "coversite <id> count=<n>" lines to
5 // MOXIE_COVER_OUT as each domain exits. Report joins the two: the totals, a
6 // per-file table, then every site that never ran.
7 //
8 // It is a package rather than a program because two callers need the same join:
9 // the mxcover command, and `moxie test -cover`, which prints this report for the
10 // package it just ran so the harness needs no external tool.
11 package mxcover
12
13 import (
14 "os"
15
16 "git.smesh.lol/moxie/pkg/mxutil"
17 )
18
19 // rec is one site from the table with its hit count.
20 type rec struct {
21 fn string
22 file string
23 line int32
24 hits uint64
25 }
26
27 // Report returns the coverage report for a site table and a counter dump,
28 // limited to files under prefix. errText is empty on success; otherwise it says
29 // what is missing (an unreadable file, or no sites under the prefix), which is
30 // what the command line prints after "mxcover: ".
31 func Report(sitesPath, countsPath, prefix string) (out string, errText string) {
32 // os.ReadFile, not mxutil.ReadFile: the latter is a host-provided C symbol
33 // the compiler links from its own host.c, so an ordinary program (the
34 // mxcover command) would fail to link it. The rest of mxutil used here is
35 // pure Moxie.
36 sitesData, serr := os.ReadFile(sitesPath)
37 if serr != nil {
38 return "", "cannot read " | sitesPath
39 }
40 countsData, cerr := os.ReadFile(countsPath)
41 if cerr != nil {
42 return "", "cannot read " | countsPath
43 }
44
45 recs := parseSites(string(sitesData), parseCounts(string(countsData)), prefix)
46 if len(recs) == 0 {
47 return "", "no sites" | prefixNote(prefix)
48 }
49 sortRecs(recs)
50
51 var b buf
52 covered := int32(0)
53 for _, r := range recs {
54 if r.hits > 0 {
55 covered++
56 }
57 }
58 b.w("sites: " | i32str(int32(len(recs))) | " covered: " | i32str(covered) | " (" | pct(covered, int32(len(recs))) | ")")
59 b.nl()
60
61 // Per-file table, in path order (sortRecs ordered by file, then line).
62 // The uncovered list below carries the detail.
63 var curFile string
64 var fileTotal, fileCov int32
65 for i := int32(0); i < int32(len(recs)); i++ {
66 r := recs[i]
67 if r.file != curFile {
68 if curFile != "" {
69 fileLine(&b, curFile, fileCov, fileTotal)
70 }
71 curFile = r.file
72 fileTotal = 0
73 fileCov = 0
74 }
75 fileTotal++
76 if r.hits > 0 {
77 fileCov++
78 }
79 }
80 if curFile != "" {
81 fileLine(&b, curFile, fileCov, fileTotal)
82 }
83
84 uncovered := int32(0)
85 for _, r := range recs {
86 if r.hits == 0 {
87 uncovered++
88 }
89 }
90 b.nl()
91 b.w("uncovered sites: " | i32str(uncovered))
92 b.nl()
93 for _, r := range recs {
94 if r.hits == 0 {
95 b.w(" " | r.file | ":" | i32str(r.line) | " " | r.fn)
96 b.nl()
97 }
98 }
99 return string(b.data[:b.off]), ""
100 }
101
102 // buf accumulates the report. Moxie has no strings.Builder, and the writer
103 // grows by hand so every store goes through an index, never through a
104 // subslice.
105 type buf struct {
106 data []byte
107 off int32
108 }
109
110 func (b *buf) w(s string) {
111 need := b.off + int32(len(s))
112 if need > int32(len(b.data)) {
113 newCap := int32(len(b.data)) * 2
114 if newCap < need {
115 newCap = need + 1024
116 }
117 nd := []byte{:newCap}
118 for i := int32(0); i < b.off; i++ {
119 nd[i] = b.data[i]
120 }
121 b.data = nd
122 }
123 for i := int32(0); i < int32(len(s)); i++ {
124 b.data[b.off+i] = s[i]
125 }
126 b.off += int32(len(s))
127 }
128
129 func (b *buf) nl() {
130 b.w("\n")
131 }
132
133 func fileLine(b *buf, file string, cov, total int32) {
134 b.w(pct(cov, total) | " " | i32str(cov) | "/" | i32str(total) | " " | file)
135 b.nl()
136 }
137
138 // pct renders a percentage with one decimal, as text.
139 func pct(n, total int32) (s string) {
140 if total == 0 {
141 return " 0.0%"
142 }
143 tenths := (int64(n) * 1000) / int64(total)
144 return i64str(tenths/10) | "." | i64str(tenths%10) | "%"
145 }
146
147 func i32str(n int32) (s string) { return i64str(int64(n)) }
148
149 func i64str(n int64) (s string) {
150 if n == 0 {
151 return "0"
152 }
153 neg := n < 0
154 if neg {
155 n = -n
156 }
157 digits := []byte{:24}
158 i := int32(24)
159 for n > 0 {
160 i--
161 digits[i] = byte('0'+n%10)
162 n = n / 10
163 }
164 if neg {
165 i--
166 digits[i] = '-'
167 }
168 return string(digits[i:])
169 }
170
171 func prefixNote(prefix string) (s string) {
172 if prefix == "" {
173 return ""
174 }
175 return " under " | prefix
176 }
177
178 // parseCounts reads the runtime's dump: one "coversite <id> count=<n>" line per
179 // site that ran, keyed by the id as text.
180 func parseCounts(data string) (m map[string]uint64) {
181 m = map[string]uint64{}
182 for _, line := range mxutil.SplitLines(data) {
183 line = mxutil.TrimSpace(line)
184 if !mxutil.HasPrefix(line, "coversite ") {
185 continue
186 }
187 rest := line[int32(len("coversite ")):]
188 idEnd := int32(0)
189 for idEnd < int32(len(rest)) && rest[idEnd] != ' ' {
190 idEnd++
191 }
192 if idEnd == 0 || idEnd == int32(len(rest)) {
193 continue
194 }
195 id := rest[:idEnd]
196 rest = rest[idEnd+1:]
197 if !mxutil.HasPrefix(rest, "count=") {
198 continue
199 }
200 m[id] = parseU64(rest[int32(len("count=")):])
201 }
202 return
203 }
204
205 func parseU64(s string) (v uint64) {
206 for i := int32(0); i < int32(len(s)); i++ {
207 if s[i] < '0' || s[i] > '9' {
208 break
209 }
210 v = v*10 + uint64(s[i]-'0')
211 }
212 return
213 }
214
215 // parseSites reads the compiler's table: [{"id":N,"name":"fn@file:line"}, ...].
216 // Hand-parsed because the shape is fixed and the encoder writes no escaped
217 // characters.
218 func parseSites(data string, hits map[string]uint64, prefix string) (rs []rec) {
219 i := int32(0)
220 for i < int32(len(data)) {
221 k := indexOf(data, i, "\"id\":")
222 if k < 0 {
223 break
224 }
225 i = k + int32(len("\"id\":"))
226 id := scanNumber(data, i)
227 i += int32(len(id))
228 nk := indexOf(data, i, "\"name\":\"")
229 if nk < 0 {
230 break
231 }
232 i = nk + int32(len("\"name\":\""))
233 end := indexOf(data, i, "\"")
234 if end < 0 {
235 break
236 }
237 name := data[i:end]
238 i = end + 1
239
240 r := rec{hits: hits[id]}
241 at := lastIndexOf(name, '@')
242 if at < 0 {
243 continue
244 }
245 r.fn = name[:at]
246 loc := name[at+1:]
247 colon := lastIndexOf(loc, ':')
248 if colon < 0 {
249 continue
250 }
251 r.file = loc[:colon]
252 r.line = parseInt32(loc[colon+1:])
253 if r.file == "" {
254 // A synthetic block (a package init) has no source file.
255 continue
256 }
257 if prefix != "" && !mxutil.HasPrefix(r.file, prefix) {
258 continue
259 }
260 rs = mxutil.Ensure(rs, 1)
261 push(rs, r)
262 }
263 return
264 }
265
266 func indexOf(s string, from int32, needle string) (n int32) {
267 if len(needle) == 0 || from < 0 {
268 return -1
269 }
270 for i := from; i+int32(len(needle)) <= int32(len(s)); i++ {
271 if s[i:i+int32(len(needle))] == needle {
272 return i
273 }
274 }
275 return -1
276 }
277
278 func lastIndexOf(s string, c byte) (n int32) {
279 for i := int32(len(s)) - 1; i >= 0; i-- {
280 if s[i] == c {
281 return i
282 }
283 }
284 return -1
285 }
286
287 func scanNumber(s string, from int32) (n string) {
288 i := from
289 for i < int32(len(s)) && s[i] >= '0' && s[i] <= '9' {
290 i++
291 }
292 return s[from:i]
293 }
294
295 func parseInt32(s string) (v int32) {
296 for i := int32(0); i < int32(len(s)); i++ {
297 if s[i] < '0' || s[i] > '9' {
298 break
299 }
300 v = v*10 + int32(s[i]-'0')
301 }
302 return
303 }
304
305 // sortRecs orders by file, then line, then function: insertion sort, because
306 // Moxie's sort package has no Slice.
307 func sortRecs(rs []rec) {
308 for i := int32(1); i < int32(len(rs)); i++ {
309 for j := i; j > 0 && less(rs[j], rs[j-1]); j-- {
310 rs[j], rs[j-1] = rs[j-1], rs[j]
311 }
312 }
313 }
314
315 func less(a, b rec) (ok bool) {
316 if a.file != b.file {
317 return a.file < b.file
318 }
319 if a.line != b.line {
320 return a.line < b.line
321 }
322 return a.fn < b.fn
323 }
324