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