-
Notifications
You must be signed in to change notification settings - Fork 1
/
pretty_table.go
362 lines (315 loc) · 8.5 KB
/
pretty_table.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
// Package pretty provides a utility to print out organized data in a pretty
// manner.
//
// Table can be used as thus:
// prettyTable, _ := NewPrettyTable(
// NewColumnDef("Name"),
// NewColumnDef("Type"))
// prettyTable.AddRow("Noel", "Human")
// prettyTable.AddRow("David", "Cyborg")
// prettyTable.AddRow("Pranava", "Crusher")
// prettyTable.Print()
//
// Output looks like:
// +---------+---------+
// | Name | Type |
// +---------+---------+
// | Noel | Human |
// | David | Cyborg |
// | Pranava | Crusher |
// +---------+---------+
//
package pretty
import (
"bytes"
"fmt"
"os"
"strings"
"unicode"
"github.com/fatih/color"
)
// Table creates formatted tables for human readability.
type Table struct {
header *string
columnDefs []ColumnDef
rows [][]string
shouldPrintRowCount bool
}
// ColumnDef is a representation of a column definition with a name and a
// maximum width. The max width must be > 3, and the name must be shorter than
// the max width. Errors will happen on instantiation of the table.
type ColumnDef struct {
name string
maxWidth *int
}
// NewColumnDef creates a ColumnDef with a name and no maximum width.
func NewColumnDef(name string) ColumnDef {
return ColumnDef{name: name}
}
// NewColumnDefWithWidth creates a ColumnDef with a name and maximum width.
func NewColumnDefWithWidth(name string, maxWidth int) ColumnDef {
return ColumnDef{
name: name,
maxWidth: &maxWidth,
}
}
type alignment uint
const (
leftJustify alignment = iota
rightJustify alignment = iota
)
var (
columnColors = []color.Attribute{
color.FgRed,
color.FgMagenta,
color.FgBlue,
color.FgWhite,
}
rowColors = []color.Attribute{
color.FgYellow,
color.FgGreen,
}
)
// NewPrettyTable creates a new Table.
func NewPrettyTable(columnDefs ...ColumnDef) (*Table, error) {
if len(columnDefs) < 1 {
return nil, fmt.Errorf("must have at least 1 column")
}
for _, columnDef := range columnDefs {
if columnDef.maxWidth == nil {
continue
}
if *columnDef.maxWidth <= 3 {
return nil, fmt.Errorf(
"column %s max width %d must be greater than 3",
columnDef.name,
columnDef.maxWidth)
}
if strLengthWithEncoding(columnDef.name) > *columnDef.maxWidth {
return nil, fmt.Errorf(
"column name %s cannot be longer than max width %d",
columnDef.name,
columnDef.maxWidth)
}
}
return &Table{
columnDefs: columnDefs,
rows: make([][]string, 0),
}, nil
}
// SetHeader creates a header for the table.
func (table *Table) SetHeader(header string) {
table.header = &header
}
// ShowRowCount is a configuration, defaulted to false, that can be toggled
// on to print row count when Print() is called.
func (table *Table) ShowRowCount(showRowCount bool) {
table.shouldPrintRowCount = showRowCount
}
// SetRows sets the rows of the table, overriding any that might
// currently be there.
func (table *Table) SetRows(rows [][]string) error {
for _, row := range rows {
if len(row) != len(table.columnDefs) {
return fmt.Errorf(
"row length %d must match columns %d",
len(row),
len(table.columnDefs))
}
}
table.rows = rows
return nil
}
// AddRow adds a row to the table.
func (table *Table) AddRow(row ...string) error {
if err := table.validateRowSize(row); err != nil {
return err
}
table.rows = append(table.rows, row)
return nil
}
// PrettyString creates the pretty string representing this table.
func (table *Table) PrettyString() (string, error) {
for _, row := range table.rows {
err := table.validateRowSize(row)
if err != nil {
return "", err
}
}
columnSizes := make([]int, len(table.columnDefs))
for i, columnDef := range table.columnDefs {
columnSize := strLengthWithEncoding(columnDef.name)
for _, row := range table.rows {
if strLengthWithEncoding(row[i]) > columnSize {
columnSize = strLengthWithEncoding(row[i])
}
}
if columnDef.maxWidth != nil && columnSize > *columnDef.maxWidth {
columnSizes[i] = *columnDef.maxWidth
} else {
columnSizes[i] = columnSize
}
}
var buffer bytes.Buffer
var columnNames []string
for _, columnDef := range table.columnDefs {
columnNames = append(columnNames, columnDef.name)
}
// Write the header. Keep track of the length of the materialized header,
// so that we can extend the header line in the case that the header is
// longer than the width of the table.
headerLength := 0
if table.header != nil {
var headerStr string
headerStr, headerLength = renderHeader(*table.header)
buffer.WriteString(headerStr)
}
// Write and create table borders
headerLineStrings := make([]string, len(columnSizes))
for i := range columnSizes {
// Add 2 for the single space at beginning and end of cell
headerLineStrings[i] = strings.Repeat("-", columnSizes[i]+2)
}
border := "+" + strings.Join(headerLineStrings, "+") + "+"
// Extend upper border if the header is longer than the width of table.
upperBorder := border
if headerLength > len(upperBorder) {
upperBorder = upperBorder +
strings.Repeat("-", headerLength-len(upperBorder))
}
buffer.WriteString(upperBorder + "\n")
border += "\n"
// Write the column headers
err := renderRow(&buffer, columnSizes, columnNames, columnColors, leftJustify)
if err != nil {
return "", err
}
buffer.WriteString("\n")
// Write another border between columns and data rows.
buffer.WriteString(border)
// Write the content rows
for _, row := range table.rows {
err = renderRow(&buffer, columnSizes, row, rowColors, rightJustify)
if err != nil {
return "", err
}
buffer.WriteString("\n")
}
// Write the last border.
buffer.WriteString(border)
// Write row count, if needed.
if table.shouldPrintRowCount {
buffer.WriteString(
fmt.Sprintf("Count: %d\n", len(table.rows)))
}
// Pretty print!
return buffer.String(), nil
}
// Print prints the table to stdout.
func (table *Table) Print() error {
strOutput, err := table.PrettyString()
if err != nil {
return err
}
_, err = fmt.Fprintln(os.Stdout, strOutput)
return err
}
func (table *Table) validateRowSize(row []string) error {
if len(row) != len(table.columnDefs) {
return fmt.Errorf(
"row length %d must match columns %d",
len(row),
len(table.columnDefs))
}
return nil
}
func renderRow(
buffer *bytes.Buffer,
columnSizes []int,
contents []string,
colors []color.Attribute,
justification alignment,
) error {
contentStrings := make([]string, len(contents))
for i := range contents {
cell, err := renderCell(
contents[i],
columnSizes[i],
justification,
colors[i%len(colors)])
if err != nil {
return err
}
contentStrings[i] = cell
}
_, err := buffer.WriteString(
"|" + strings.Join(contentStrings, "|") + "|")
return err
}
func renderCell(
content string,
cellLength int,
justification alignment,
textAttribute color.Attribute,
) (string, error) {
truncatedContent := content
if strLengthWithEncoding(content) > cellLength {
truncatedContent = fmt.Sprintf(
"%s...",
truncateStringWithEncoding(content, cellLength-3))
}
paddingLength := cellLength - strLengthWithEncoding(truncatedContent)
padding := strings.Repeat(" ", paddingLength)
textColor := color.New(textAttribute, color.Bold)
switch justification {
case leftJustify:
return textColor.Sprintf(" %s%s ", truncatedContent, padding), nil
case rightJustify:
return textColor.Sprintf(" %s%s ", padding, truncatedContent),
nil
default:
return "", fmt.Errorf("did not match alignment")
}
}
// renderHeader renders the header, as well as returns its horizontal length.
func renderHeader(header string) (string, int) {
horizontalBorder := strings.Repeat("-", strLengthWithEncoding(header)+2)
rendered := fmt.Sprintf(
"%s\n %s |\n",
horizontalBorder,
header)
return rendered, strLengthWithEncoding(horizontalBorder)
}
func strLengthWithEncoding(str string) int {
length := 0
for _, strRune := range str {
if shouldCountEncodedRune(strRune) {
length++
}
}
return length
}
func truncateStringWithEncoding(str string, truncateLength int) string {
if truncateLength == 0 {
return ""
}
// Find the index at which we must truncate the string. Only truncate when
// we absolutely must, i.e. when a counted rune puts us over the
// truncateLength.
strTruncateIndex := 0
runeCount := 0
for _, strRune := range str {
if shouldCountEncodedRune(strRune) {
if runeCount == truncateLength {
break
}
runeCount++
}
strTruncateIndex++
}
return string([]rune(str)[:strTruncateIndex])
}
func shouldCountEncodedRune(r rune) bool {
// DO NOT count non-spacing marks in the output!
return !unicode.IsMark(r)
}