blob: 373bf7c9d1b5f1d78617b3d7c0c7fa797f9d5048 [file] [log] [blame]
// Copyright 2020 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package covargs
import (
"bytes"
"compress/zlib"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"github.com/golang/protobuf/jsonpb"
"go.fuchsia.dev/fuchsia/tools/debug/covargs/api/llvm"
"go.fuchsia.dev/fuchsia/tools/debug/covargs/api/third_party/codecoverage"
"golang.org/x/sync/errgroup"
)
// DiffMapping represents a source transformation done by a diff (i.e. patch),
// where for each file it has a mapping from the old line number to the new one.
type DiffMapping map[string]LineMapping
// LineMapping maps the old line number to the new one within a single file.
type LineMapping map[int]int
type line struct {
line, count int
}
type lineData []line
type block struct {
first, last int
}
type blockData map[int][]block
func extractData(segments []llvm.Segment) (lineData, blockData) {
var ld lineData
bd := blockData{}
// The most recent segment that starts from a previous line.
var w *llvm.Segment
var lineSegments []llvm.Segment
nextSegmentIndex := 0
for i := 1; i <= segments[len(segments)-1].LineNumber; i += 1 {
// Calculate the execution count for each line. Follow the logic in llvm-cov:
// https://github.com/llvm-mirror/llvm/blob/3b35e17b21e388832d7b560a06a4f9eeaeb35330/lib/ProfileData/Coverage/CoverageMapping.cpp#L686
if len(lineSegments) > 0 {
w = &lineSegments[len(lineSegments)-1]
}
lineSegments = nil
for nextSegmentIndex < len(segments) && segments[nextSegmentIndex].LineNumber == i {
lineSegments = append(lineSegments, segments[nextSegmentIndex])
nextSegmentIndex += 1
}
lineStartsNewRegion := false
for _, s := range lineSegments {
if s.HasCount && s.IsRegionEntry {
lineStartsNewRegion = true
break
}
}
startOfSkippedRegion := (len(lineSegments) > 0 && !lineSegments[0].HasCount) && lineSegments[0].IsRegionEntry
coverable := !startOfSkippedRegion && ((w != nil && w.HasCount) || lineStartsNewRegion)
if !coverable {
continue
}
count := 0
if w != nil {
count = w.Count
}
for _, s := range lineSegments {
if s.HasCount && s.IsRegionEntry {
if s.Count > count {
count = s.Count
}
}
}
ld = append(ld, line{i, count})
// Calculate the uncovered blocks within the current line. Follow the logic in llvm-cov:
// https://github.com/llvm-mirror/llvm/blob/993ef0ca960f8ffd107c33bfbf1fd603bcf5c66c/tools/llvm-cov/SourceCoverageViewText.cpp#L114
if count == 0 {
// Skips calculating uncovered blocks if the whole line is not covered.
continue
}
columnStart := 1
isBlockNotCovered := (w != nil && w.HasCount) && w.Count == 0
for _, s := range lineSegments {
columnEnd := s.ColumnNumber
if isBlockNotCovered {
bd[s.LineNumber] = append(bd[s.LineNumber], block{columnStart, columnEnd - 1})
}
isBlockNotCovered = s.HasCount && s.Count == 0
columnStart = columnEnd
}
if isBlockNotCovered {
lastSegment := lineSegments[len(lineSegments)-1]
bd[lastSegment.LineNumber] = append(bd[lastSegment.LineNumber], block{columnStart, -1})
}
}
return ld, bd
}
func rebaseData(lines lineData, blocks blockData, mapping LineMapping) (lineData, blockData) {
var ld lineData
for _, l := range lines {
if _, ok := mapping[l.line]; !ok {
continue
}
ld = append(ld, line{mapping[l.line], l.count})
}
var bd blockData
for i, b := range blocks {
if _, ok := mapping[i]; !ok {
continue
}
bd[mapping[i]] = b
}
return ld, bd
}
func compressData(lines lineData, blocks blockData) ([]*codecoverage.LineRange, []*codecoverage.ColumnRanges) {
// Aggregate contiguous blocks of lines with the exact same hit count.
var lr []*codecoverage.LineRange
last := 0
for i := 1; i <= len(lines); i++ {
isContinuous := i < len(lines) && lines[i].line == lines[i-1].line+1
hasSameCount := i < len(lines) && lines[i].count == lines[i-1].count
// Merge two lines iff they have continuous line number and exactly the same count, e.g. (101, 10) and (102, 10).
if isContinuous && hasSameCount {
continue
}
lr = append(lr, &codecoverage.LineRange{
First: int32(lines[last].line),
Last: int32(lines[i-1].line),
Count: int64(lines[last].count),
})
last = i
}
var cr []*codecoverage.ColumnRanges
for i, block := range blocks {
var ranges []*codecoverage.ColumnRange
for _, b := range block {
ranges = append(ranges, &codecoverage.ColumnRange{
First: int32(b.first),
Last: int32(b.last),
})
}
cr = append(cr, &codecoverage.ColumnRanges{
Line: int32(i),
Ranges: ranges,
})
}
return lr, cr
}
func getRevision(path string) (string, int64, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return "", 0, nil
}
var stdout bytes.Buffer
cmd := exec.Command("git", "--literal-pathspecs", "log", "-n", "1", `--pretty=format:%H:%ct`, path)
cmd.Dir = filepath.Dir(path)
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return "", 0, fmt.Errorf("failed to obtain revision: %w", err)
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", 0, nil
}
parts := strings.Split(out, ":")
if len(parts) != 2 {
return "", 0, fmt.Errorf("not in hash:timestamp format: %s", out)
}
hash := parts[0]
timestamp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", 0, fmt.Errorf("invalid timestamp %q: %w", parts[1], err)
}
return hash, timestamp, nil
}
func convertFile(file llvm.File, base string, mapping *DiffMapping) (*codecoverage.File, error) {
if file.Segments == nil {
return nil, nil
}
rel, err := filepath.Rel(base, file.Filename)
if err != nil {
return nil, err
}
ld, bd := extractData(file.Segments)
sort.Slice(ld, func(i, j int) bool {
return ld[i].line < ld[j].line
})
var revision string
var timestamp int64
if mapping != nil {
if _, ok := (*mapping)[rel]; ok {
ld, bd = rebaseData(ld, bd, (*mapping)[rel])
}
} else {
revision, timestamp, err = getRevision(file.Filename)
if err != nil {
return nil, err
}
}
lr, cr := compressData(ld, bd)
return &codecoverage.File{
Path: "//" + rel,
Lines: lr,
UncoveredBlocks: cr,
Summaries: []*codecoverage.Metric{
{
Name: "function",
Covered: int32(file.Summary.Functions.Covered),
Total: int32(file.Summary.Functions.Count),
},
{
Name: "region",
Covered: int32(file.Summary.Regions.Covered),
Total: int32(file.Summary.Regions.Count),
},
{
Name: "line",
Covered: int32(file.Summary.Lines.Covered),
Total: int32(file.Summary.Lines.Count),
},
},
Revision: revision,
Timestamp: timestamp,
}, nil
}
func mergeSummaries(summaries ...[]*codecoverage.Metric) []*codecoverage.Metric {
s := []*codecoverage.Metric{
{
Name: "function",
Covered: 0,
Total: 0,
},
{
Name: "region",
Covered: 0,
Total: 0,
},
{
Name: "line",
Covered: 0,
Total: 0,
},
}
d := map[string]int{}
for i, m := range s {
d[m.Name] = i
}
for _, m := range summaries {
if m != nil {
for _, n := range m {
s[d[n.Name]].Covered += n.Covered
s[d[n.Name]].Total += n.Total
}
}
}
return s
}
// ComputeSummaries calculates aggregate summaries for all directories.
func ComputeSummaries(files []*codecoverage.File) ([]*codecoverage.GroupCoverageSummary, []*codecoverage.Metric) {
summaries := map[string][]*codecoverage.Metric{}
for _, f := range files {
// We can't use filepath.Dir and filepath.Base because they normalize '//'.
dir, _ := filepath.Split(f.Path)
for dir != "//" {
// In the coverage data format, dirs end with '/' except for root.
summaries[dir] = mergeSummaries(summaries[dir], f.Summaries)
dir, _ = filepath.Split(dir[:len(dir)-1])
}
summaries["//"] = mergeSummaries(summaries["//"], f.Summaries)
}
fileSummaries := map[string][]*codecoverage.CoverageSummary{}
for _, f := range files {
dir, file := filepath.Split(f.Path)
fileSummaries[dir] = append(fileSummaries[dir], &codecoverage.CoverageSummary{
Name: file,
Path: f.Path,
Summaries: f.Summaries,
})
}
dirSummaries := map[string][]*codecoverage.CoverageSummary{}
for p, s := range summaries {
if p == "//" {
continue
}
// The path already ends with '/' so we have to omit it.
dir, file := filepath.Split(p[:len(p)-1])
dirSummaries[dir] = append(dirSummaries[dir], &codecoverage.CoverageSummary{
Name: file + "/",
Path: p,
Summaries: s,
})
}
var groupSummaries []*codecoverage.GroupCoverageSummary
for p, s := range summaries {
groupSummaries = append(groupSummaries, &codecoverage.GroupCoverageSummary{
Path: p,
Dirs: dirSummaries[p],
Files: fileSummaries[p],
Summaries: s,
})
}
sort.Slice(groupSummaries, func(i, j int) bool {
return groupSummaries[i].Path < groupSummaries[j].Path
})
return groupSummaries, summaries["//"]
}
// ConvertFiles converts the data in LLVM coverage JSON format into the
// compressed coverage format used by Chromium coverage service.
func ConvertFiles(export *llvm.Export, base string, mapping *DiffMapping) ([]*codecoverage.File, error) {
var files []*codecoverage.File
var g errgroup.Group
var mu sync.Mutex
s := make(chan struct{}, runtime.NumCPU())
for _, d := range export.Data {
for _, f := range d.Files {
f := f
s <- struct{}{}
g.Go(func() error {
defer func() { <-s }()
file, err := convertFile(f, base, mapping)
if err != nil {
return err
}
mu.Lock()
files = append(files, file)
mu.Unlock()
return nil
})
}
}
if err := g.Wait(); err != nil {
return nil, err
}
return files, nil
}
func saveReport(report *codecoverage.CoverageReport, filename string) error {
f, err := os.Create(filename)
if err != nil {
return fmt.Errorf("cannot open file %q: %v", filename, err)
}
defer f.Close()
w := zlib.NewWriter(f)
defer w.Close()
m := &jsonpb.Marshaler{
OrigName: true,
EmitDefaults: true,
}
if err := m.Marshal(w, report); err != nil {
return fmt.Errorf("cannot marshal report: %w", err)
}
return nil
}
// SaveReport saves compresses coverage data to disk, optionally sharding the
// data into multiple files each of the same size.
func SaveReport(files []*codecoverage.File, shardSize int, dir string) (*codecoverage.CoverageReport, error) {
dirs, summaries := ComputeSummaries(files)
report := &codecoverage.CoverageReport{
Dirs: dirs,
Summaries: summaries,
}
if numFiles := len(files); numFiles > shardSize {
const filename = "files%0*d.json.gz"
numShards := int(math.Ceil(float64(numFiles) / float64(shardSize)))
width := 1 + int(math.Log10(float64(numShards)))
fileShards := make([]string, numShards)
// TODO(phosek): Use goroutines to process slices in parallel.
for i := 0; i < numShards; i++ {
from := i * shardSize
to := (i + 1) * shardSize
if to > numFiles {
to = numFiles
}
report := codecoverage.CoverageReport{Files: files[from:to]}
filename := fmt.Sprintf(filename, width, i+1)
if err := saveReport(&report, filepath.Join(dir, filename)); err != nil {
return nil, fmt.Errorf("failed to save report %q: %w", filename, err)
}
fileShards[i] = filename
}
report.FileShards = fileShards
} else {
report.Files = files
}
const filename = "all.json.gz"
if err := saveReport(report, filepath.Join(dir, filename)); err != nil {
return nil, fmt.Errorf("failed to save report %q: %w", filename, err)
}
return report, nil
}