| // 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 | |
| } |