blob: 55b7a2628d0e95c45b1fbcbfceb29b248540918a [file] [log] [blame]
// Copyright 2017 The Go 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 benchstat
import (
"encoding/csv"
"fmt"
"io"
"path/filepath"
"strings"
"unicode/utf8"
)
// FormatText appends a fixed-width text formatting of the tables to w.
func FormatText(w io.Writer, tables []*Table) {
var textTables [][]*textRow
for _, t := range tables {
textTables = append(textTables, toText(t))
}
var max []int
for _, table := range textTables {
for _, row := range table {
if len(row.cols) == 1 {
// Header row
continue
}
for len(max) < len(row.cols) {
max = append(max, 0)
}
for i, s := range row.cols {
n := utf8.RuneCountInString(s)
if max[i] < n {
max[i] = n
}
}
}
}
for i, table := range textTables {
if i > 0 {
fmt.Fprintf(w, "\n")
}
// headings
row := table[0]
for i, s := range row.cols {
switch i {
case 0:
fmt.Fprintf(w, "%-*s", max[i], s)
default:
fmt.Fprintf(w, " %-*s", max[i], s)
case len(row.cols) - 1:
fmt.Fprintf(w, " %s\n", s)
}
}
// data
for _, row := range table[1:] {
for i, s := range row.cols {
switch {
case len(row.cols) == 1:
// Header row
fmt.Fprint(w, s)
case i == 0:
fmt.Fprintf(w, "%-*s", max[i], s)
default:
if i == len(row.cols)-1 && len(s) > 0 && s[0] == '(' {
// Left-align p value.
fmt.Fprintf(w, " %s", s)
break
}
fmt.Fprintf(w, " %*s", max[i], s)
}
}
fmt.Fprintf(w, "\n")
}
}
}
func must(e error) {
if e != nil {
panic(e)
}
}
// FormatCSV appends a CSV formatting of the tables to w.
// norange suppresses the range columns.
func FormatCSV(w io.Writer, tables []*Table, norange bool) {
var textTables [][]*textRow
for _, t := range tables {
textTables = append(textTables, toCSV(t, norange))
}
for i, table := range textTables {
if i > 0 {
fmt.Fprintf(w, "\n")
}
csvw := csv.NewWriter(w)
// headings
row := table[0]
must(csvw.Write(row.cols))
// data
for _, row := range table[1:] {
must(csvw.Write(row.cols))
}
csvw.Flush()
}
}
// trimCommonPathPrefix returns a string slice with common
// path-separator-terminated prefixes removed. Empty strings
// are ignored and a singleton non-empty string is left unchanged
func trimCommonPathPrefix(ss []string) []string {
commonPrefixLen := -1
commonPrefix := ""
trimCommonPrefix := false // true if more than one data row w/ non-empty title
// begin finding common byte prefix (could end on partial rune)
for _, s := range ss {
if s == "" {
continue
}
if commonPrefixLen == -1 {
commonPrefix = s
commonPrefixLen = len(s)
continue
}
trimCommonPrefix = true // More than one not-empty
if commonPrefixLen > len(s) {
commonPrefixLen = len(s)
commonPrefix = commonPrefix[0:commonPrefixLen]
}
for j := 0; j < commonPrefixLen; j++ {
if commonPrefix[j] != s[j] {
commonPrefixLen = j
commonPrefix = commonPrefix[0:commonPrefixLen]
break
}
}
}
if !trimCommonPrefix || commonPrefixLen == 0 {
return ss
}
// trim to include last path separator.
commonPrefixLen = 1 + strings.LastIndex(commonPrefix, string(filepath.Separator))
if commonPrefixLen == 0 {
// No separator found means commonPrefixLen = 1 + (-1) == 0
return ss
}
rs := []string{}
for _, s := range ss {
if s == "" {
rs = append(rs, "")
} else {
rs = append(rs, s[commonPrefixLen:])
}
}
return rs
}
// A textRow is a row of printed text columns.
type textRow struct {
cols []string
}
func newTextRow(cols ...string) *textRow {
return &textRow{cols: cols}
}
// newTextRowDelta returns a labeled row of text, with "±" inserted after
// each member of "cols" unless norange is true.
func newTextRowDelta(norange bool, label string, cols ...string) *textRow {
newcols := []string{}
newcols = append(newcols, label)
for _, s := range cols {
newcols = append(newcols, s)
if !norange {
newcols = append(newcols, "±")
}
}
return &textRow{cols: newcols}
}
func (r *textRow) add(col string) {
r.cols = append(r.cols, col)
}
func (r *textRow) trim() {
for len(r.cols) > 0 && r.cols[len(r.cols)-1] == "" {
r.cols = r.cols[:len(r.cols)-1]
}
}
// toText converts the Table to a textual grid of cells,
// which can then be printed in fixed-width output.
func toText(t *Table) []*textRow {
var textRows []*textRow
switch len(t.Configs) {
case 1:
textRows = append(textRows, newTextRow("name", t.Metric))
case 2:
textRows = append(textRows, newTextRow("name", "old "+t.Metric, "new "+t.Metric, "delta"))
default:
row := newTextRow("name \\ " + t.Metric)
row.cols = append(row.cols, t.Configs...) // TODO Should this also trim common path prefix? (see toCSV)
textRows = append(textRows, row)
}
var group string
for _, row := range t.Rows {
if row.Group != group {
group = row.Group
textRows = append(textRows, newTextRow(group))
}
text := newTextRow(row.Benchmark)
for _, m := range row.Metrics {
text.cols = append(text.cols, m.Format(row.Scaler))
}
if len(t.Configs) == 2 {
delta := row.Delta
if delta == "~" {
delta = "~ "
}
text.cols = append(text.cols, delta)
text.cols = append(text.cols, row.Note)
}
textRows = append(textRows, text)
}
for _, r := range textRows {
r.trim()
}
return textRows
}
// toCSV converts the Table to a slice of rows containing CSV-separated data
// If norange is true, suppress the range information for each data item.
// If norange is false, insert a "±" in the appropriate columns of the header row.
func toCSV(t *Table, norange bool) []*textRow {
var textRows []*textRow
units := ""
if len(t.Rows) > 0 && len(t.Rows[0].Metrics) > 0 {
units = " (" + t.Rows[0].Metrics[0].Unit + ")"
}
switch len(t.Configs) {
case 1:
textRows = append(textRows, newTextRowDelta(norange, "name", t.Metric+units))
case 2:
textRows = append(textRows, newTextRowDelta(norange, "name", "old "+t.Metric+units, "new "+t.Metric+units, "delta"))
default:
rowname := "name \\ " + t.Metric
row := newTextRowDelta(norange, rowname+units, trimCommonPathPrefix(t.Configs)...)
textRows = append(textRows, row)
}
var group string
for _, row := range t.Rows {
if row.Group != group {
group = row.Group
textRows = append(textRows, newTextRow(group))
}
text := newTextRow(row.Benchmark)
for _, m := range row.Metrics {
mean := fmt.Sprintf("%.5E", m.Mean)
diff := m.FormatDiff()
if m.Unit == "" {
mean = ""
diff = ""
}
text.cols = append(text.cols, mean)
if !norange {
text.cols = append(text.cols, diff)
}
}
if len(t.Configs) == 2 {
delta := row.Delta
text.cols = append(text.cols, delta)
text.cols = append(text.cols, row.Note)
}
textRows = append(textRows, text)
}
for _, r := range textRows {
r.trim()
}
return textRows
}