blob: fd70fab9384fa4a06ac099429645db3bb1d07eeb [file] [log] [blame]
package term
import (
"context"
"fmt"
"os"
"reflect"
"strings"
"time"
"go.skia.org/infra/go/human"
"go.skia.org/infra/go/now"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/util"
"golang.org/x/term"
)
// TerminalWidthGetter is a function which returns the width of the terminal.
type TerminalWidthGetter func() int
// GetTerminalWidthFunc returns a TerminalWidthGetter which finds the width of
// the terminal or returns the given default if not running in a terminal.
func GetTerminalWidthFunc(defaultWidth int) TerminalWidthGetter {
return func() int {
terminalWidth, _, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return terminalWidth
}
return defaultWidth
}
}
// TableConfig provides configuration for building textual tables.
type TableConfig struct {
MaxLineWidth int
MaxColumnWidth int
// GetTerminalWidth takes precedence over MaxLineWidth if provided.
GetTerminalWidth TerminalWidthGetter
// IncludeHeader indicates whether the first line of data is a header.
IncludeHeader bool
// JSONTagsAsHeaders indicates whether to use struct `json` tags as the
// values for the header line. Implies IncludeHeader. Only used with
// Structs().
JSONTagsAsHeaders bool
// TimeAsDiffs causes time.Time fields to be converted to human-readable
// diffs from the current time.
TimeAsDiffs bool
// EmptyCollectionsBlank causes empty slices and maps to appear blank as
// opposed to "[]" or "map[]".
EmptyCollectionsBlank bool
}
// MakeTable creates a textual table from the given data, which is presumed to
// be broken into rows of cells.
func (c TableConfig) MakeTable(data [][]string) string {
maxLineWidth := c.MaxLineWidth
if c.GetTerminalWidth != nil {
maxLineWidth = c.GetTerminalWidth()
}
numColumns := 0
for _, row := range data {
if numColumns < len(row) {
numColumns = len(row)
}
}
columnWidths := make([]int, numColumns)
for _, row := range data {
for colIdx, cell := range row {
if columnWidths[colIdx] < len(cell) {
columnWidths[colIdx] = len(cell)
}
}
}
if c.MaxColumnWidth > 0 {
for idx, colWidth := range columnWidths {
if colWidth > c.MaxColumnWidth {
columnWidths[idx] = c.MaxColumnWidth
}
}
}
rv := make([]string, 0, len(data))
for idx, row := range data {
str := make([]string, 0, len(row))
for colIdx, cell := range row {
// If there are multiple lines in the string, keep only the first.
cell = strings.Split(cell, "\n")[0]
cell = strings.TrimSpace(cell)
if c.MaxColumnWidth > 0 {
cell = util.TruncateNoEllipses(cell, c.MaxColumnWidth)
}
padding := columnWidths[colIdx] - len(cell)
cell = cell + strings.Repeat(" ", padding)
str = append(str, cell)
}
line := strings.Join(str, " ")
if maxLineWidth > 0 {
line = util.TruncateNoEllipses(line, maxLineWidth)
}
line = strings.TrimSpace(line)
rv = append(rv, line)
if idx == 0 && (c.IncludeHeader || c.JSONTagsAsHeaders) {
rv = append(rv, strings.Repeat("-", len(line)))
}
}
return strings.Join(rv, "\n")
}
// valueToStrings converts a reflect.Value to at least one string, flattening
// nested structs as needed.
func (c TableConfig) valueToStrings(elem reflect.Value, isHeader bool, now time.Time) []string {
typ := elem.Type()
if typ.Kind() == reflect.Pointer {
elem = elem.Elem()
typ = elem.Type()
}
if typ == reflect.TypeOf(time.Time{}) {
if c.TimeAsDiffs {
ts := elem.Interface().(time.Time)
return []string{human.Duration(now.Sub(ts))}
} else {
vals := elem.MethodByName("String").Call([]reflect.Value{})
return []string{vals[0].String()}
}
} else if typ.Implements(reflect.TypeOf((*fmt.Stringer)(nil)).Elem()) {
vals := elem.MethodByName("String").Call([]reflect.Value{})
return []string{vals[0].String()}
} else if typ.Kind() == reflect.Struct {
row := make([]string, 0, elem.NumField())
for f := 0; f < elem.NumField(); f++ {
field := elem.Field(f)
fieldTyp := typ.Field(f).Type
if fieldTyp.Kind() == reflect.Pointer {
fieldTyp = fieldTyp.Elem()
}
// Flatten nested structs, but treat time.Time as a scalar value.
if fieldTyp.Kind() == reflect.Struct && fieldTyp != reflect.TypeOf(time.Time{}) {
row = append(row, c.valueToStrings(field, isHeader, now)...)
} else if isHeader {
if c.JSONTagsAsHeaders {
row = append(row, typ.Field(f).Tag.Get("json"))
} else {
row = append(row, typ.Field(f).Name)
}
} else {
row = append(row, c.valueToStrings(field, isHeader, now)...)
}
}
return row
} else if c.EmptyCollectionsBlank &&
(typ.Kind() == reflect.Slice || typ.Kind() == reflect.Map) &&
elem.Len() == 0 {
return []string{""}
} else {
return []string{fmt.Sprintf("%v", elem)}
}
}
// Structs creates a table from a slice of structs.
func (c TableConfig) Structs(ctx context.Context, structs interface{}) (string, error) {
ts := now.Now(ctx)
typ := reflect.TypeOf(structs)
if typ.Kind() != reflect.Slice {
return "", skerr.Fmt("invalid kind %s", typ.Kind())
}
if typ.Elem().Kind() != reflect.Struct && (typ.Elem().Kind() == reflect.Pointer && typ.Elem().Elem().Kind() != reflect.Struct) {
return "", skerr.Fmt("expected a slice of structs but found %q", typ.Elem().Kind())
}
val := reflect.ValueOf(structs)
data := make([][]string, 0, val.Len())
for i := 0; i < val.Len(); i++ {
elem := val.Index(i)
if i == 0 && (c.IncludeHeader || c.JSONTagsAsHeaders) {
headerRow := c.valueToStrings(elem, true, ts)
data = append(data, headerRow)
}
row := c.valueToStrings(elem, false, ts)
data = append(data, row)
}
return c.MakeTable(data), nil
}