├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── go.yml ├── go.mod ├── go.sum ├── license ├── readme.md ├── table.go └── table_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | github-actions: 9 | patterns: ["*"] 10 | - package-ecosystem: "gomod" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | groups: 15 | go: 16 | patterns: ["*"] 17 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '18 17 * * 6' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['go'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v3 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v3 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v3 63 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: ^1.14 20 | id: go 21 | 22 | - name: Check out code into the Go module directory 23 | uses: actions/checkout@v4 24 | 25 | - name: Get dependencies 26 | run: go get -v -t -d ./... 27 | 28 | - name: Build 29 | run: go build -v ./... 30 | 31 | - name: Test 32 | run: go test -v -race -cover ./... 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rodaine/table 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/google/go-cmp v0.7.0 7 | github.com/mattn/go-runewidth v0.0.16 8 | github.com/stretchr/testify v1.10.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 5 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 6 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 7 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 11 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 14 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 15 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 16 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 17 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 18 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 19 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 24 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 25 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 26 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Roche (rodaine+github@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # table
[![GoDoc](https://godoc.org/github.com/rodaine/table?status.svg)](https://godoc.org/github.com/rodaine/table) 2 | 3 | ![Example Table Output With ANSI Colors](http://res.cloudinary.com/rodaine/image/upload/v1442524799/go-table-example0.png) 4 | 5 | Package table provides a convenient way to generate tabular output of any data, primarily useful for CLI tools. 6 | 7 | ## Features 8 | 9 | - Accepts all data types (`string`, `int`, `interface{}`, everything!) and will use the `String() string` method of a type if available. 10 | - Can specify custom formatting for the header and first column cells for better readability. 11 | - Columns are left-aligned and sized to fit the data, with customizable padding. 12 | - The printed output can be sent to any `io.Writer`, defaulting to `os.Stdout`. 13 | - Built to an interface, so you can roll your own `Table` implementation. 14 | - Works well with ANSI colors ([fatih/color](https://github.com/fatih/color) in the example)! 15 | - Can provide a custom `WidthFunc` to accomodate multi- and zero-width characters (such as [runewidth](https://github.com/mattn/go-runewidth)) 16 | 17 | ## Usage 18 | 19 | **Download the package:** 20 | 21 | ```sh 22 | go get github.com/rodaine/table 23 | ``` 24 | 25 | **Example:** 26 | 27 | ```go 28 | package main 29 | 30 | import ( 31 | "fmt" 32 | "strings" 33 | 34 | "github.com/fatih/color" 35 | "github.com/rodaine/table" 36 | ) 37 | 38 | func main() { 39 | headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() 40 | columnFmt := color.New(color.FgYellow).SprintfFunc() 41 | 42 | tbl := table.New("ID", "Name", "Score", "Added") 43 | tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) 44 | 45 | for _, widget := range getWidgets() { 46 | tbl.AddRow(widget.ID, widget.Name, widget.Cost, widget.Added) 47 | } 48 | 49 | tbl.Print() 50 | } 51 | ``` 52 | 53 | _Consult the [documentation](https://godoc.org/github.com/rodaine/table) for further examples and usage information_ 54 | 55 | ## License 56 | 57 | table is released under the MIT License (Expat). See the [full license](https://github.com/rodaine/table/blob/master/license). 58 | -------------------------------------------------------------------------------- /table.go: -------------------------------------------------------------------------------- 1 | // Package table provides a convenient way to generate tabular output of any 2 | // data, primarily useful for CLI tools. 3 | // 4 | // Columns are left-aligned and padded to accomodate the largest cell in that 5 | // column. 6 | // 7 | // Source: https://github.com/rodaine/table 8 | // 9 | // table.DefaultHeaderFormatter = func(format string, vals ...interface{}) string { 10 | // return strings.ToUpper(fmt.Sprintf(format, vals...)) 11 | // } 12 | // 13 | // tbl := table.New("ID", "Name", "Cost ($)") 14 | // 15 | // for _, widget := range Widgets { 16 | // tbl.AddRow(widget.ID, widget.Name, widget.Cost) 17 | // } 18 | // 19 | // tbl.Print() 20 | // 21 | // // Output: 22 | // // ID NAME COST ($) 23 | // // 1 Foobar 1.23 24 | // // 2 Fizzbuzz 4.56 25 | // // 3 Gizmo 78.90 26 | package table 27 | 28 | import ( 29 | "fmt" 30 | "io" 31 | "os" 32 | "strings" 33 | "unicode/utf8" 34 | ) 35 | 36 | // These are the default properties for all Tables created from this package 37 | // and can be modified. 38 | var ( 39 | // DefaultPadding specifies the number of spaces between columns in a table. 40 | DefaultPadding = 2 41 | 42 | // DefaultWriter specifies the output io.Writer for the Table.Print method. 43 | DefaultWriter io.Writer = os.Stdout 44 | 45 | // DefaultHeaderFormatter specifies the default Formatter for the table header. 46 | DefaultHeaderFormatter Formatter 47 | 48 | // DefaultFirstColumnFormatter specifies the default Formatter for the first column cells. 49 | DefaultFirstColumnFormatter Formatter 50 | 51 | // DefaultWidthFunc specifies the default WidthFunc for calculating column widths 52 | DefaultWidthFunc WidthFunc = utf8.RuneCountInString 53 | 54 | // DefaultPrintHeaders specifies if headers should be printed 55 | DefaultPrintHeaders = true 56 | ) 57 | 58 | // Formatter functions expose a fmt.Sprintf signature that can be used to modify 59 | // the display of the text in either the header or first column of a Table. 60 | // The formatter should not change the width of original text as printed since 61 | // column widths are calculated pre-formatting (though this issue can be mitigated 62 | // with increased padding). 63 | // 64 | // tbl.WithHeaderFormatter(func(format string, vals ...interface{}) string { 65 | // return strings.ToUpper(fmt.Sprintf(format, vals...)) 66 | // }) 67 | // 68 | // A good use case for formatters is to use ANSI escape codes to color the cells 69 | // for a nicer interface. The package color (https://github.com/fatih/color) makes 70 | // it easy to generate these automatically: http://godoc.org/github.com/fatih/color#Color.SprintfFunc 71 | type Formatter func(string, ...interface{}) string 72 | 73 | // A WidthFunc calculates the width of a string. By default, the number of runes 74 | // is used but this may not be appropriate for certain character sets. The 75 | // package runewidth (https://github.com/mattn/go-runewidth) could be used to 76 | // accomodate multi-cell characters (such as emoji or CJK characters). 77 | type WidthFunc func(string) int 78 | 79 | // Table describes the interface for building up a tabular representation of data. 80 | // It exposes fluent/chainable methods for convenient table building. 81 | // 82 | // WithHeaderFormatter and WithFirstColumnFormatter sets the Formatter for the 83 | // header and first column, respectively. If nil is passed in (the default), no 84 | // formatting will be applied. 85 | // 86 | // New("foo", "bar").WithFirstColumnFormatter(func(f string, v ...interface{}) string { 87 | // return strings.ToUpper(fmt.Sprintf(f, v...)) 88 | // }) 89 | // 90 | // WithPadding specifies the minimum padding between cells in a row and defaults 91 | // to DefaultPadding. Padding values less than or equal to zero apply no extra 92 | // padding between the columns. 93 | // 94 | // New("foo", "bar").WithPadding(3) 95 | // 96 | // WithWriter modifies the writer which Print outputs to, defaulting to DefaultWriter 97 | // when instantiated. If nil is passed, os.Stdout will be used. 98 | // 99 | // New("foo", "bar").WithWriter(os.Stderr) 100 | // 101 | // WithWidthFunc sets the function used to calculate the width of the string in 102 | // a column. By default, the number of utf8 runes in the string is used. 103 | // 104 | // WithPrintHeaders specifies whether if the headers of the table should be 105 | // printed or not, which might be useful if the output is being piped to other 106 | // processes. By default, they are printed. 107 | // 108 | // AddRow adds another row of data to the table. Any values can be passed in and 109 | // will be output as its string representation as described in the fmt standard 110 | // package. Rows can have less cells than the total number of columns in the table; 111 | // subsequent cells will be rendered empty. Rows with more cells than the total 112 | // number of columns will be truncated. References to the data are not held, so 113 | // the passed in values can be modified without affecting the table's output. 114 | // 115 | // New("foo", "bar").AddRow("fizz", "buzz").AddRow(time.Now()).AddRow(1, 2, 3).Print() 116 | // // Output: 117 | // // foo bar 118 | // // fizz buzz 119 | // // 2006-01-02 15:04:05.0 -0700 MST 120 | // // 1 2 121 | // 122 | // Print writes the string representation of the table to the provided writer. 123 | // Print can be called multiple times, even after subsequent mutations of the 124 | // provided data. The output is always preceded and followed by a new line. 125 | type Table interface { 126 | WithHeaderFormatter(f Formatter) Table 127 | WithFirstColumnFormatter(f Formatter) Table 128 | WithPadding(p int) Table 129 | WithWriter(w io.Writer) Table 130 | WithWidthFunc(f WidthFunc) Table 131 | WithHeaderSeparatorRow(r rune) Table 132 | WithPrintHeaders(b bool) Table 133 | 134 | AddRow(vals ...interface{}) Table 135 | SetRows(rows [][]string) Table 136 | Print() 137 | } 138 | 139 | // New creates a Table instance with the specified header(s) provided. The number 140 | // of columns is fixed at this point to len(columnHeaders) and the defined defaults 141 | // are set on the instance. 142 | func New(columnHeaders ...interface{}) Table { 143 | t := table{header: make([]string, len(columnHeaders))} 144 | 145 | t.WithPadding(DefaultPadding) 146 | t.WithWriter(DefaultWriter) 147 | t.WithHeaderFormatter(DefaultHeaderFormatter) 148 | t.WithFirstColumnFormatter(DefaultFirstColumnFormatter) 149 | t.WithWidthFunc(DefaultWidthFunc) 150 | t.WithPrintHeaders(DefaultPrintHeaders) 151 | 152 | for i, col := range columnHeaders { 153 | t.header[i] = fmt.Sprint(col) 154 | } 155 | 156 | return &t 157 | } 158 | 159 | type table struct { 160 | FirstColumnFormatter Formatter 161 | HeaderFormatter Formatter 162 | Padding int 163 | Writer io.Writer 164 | Width WidthFunc 165 | HeaderSeparatorRune rune 166 | PrintHeaders bool 167 | 168 | header []string 169 | rows [][]string 170 | widths []int 171 | } 172 | 173 | func (t *table) WithHeaderFormatter(f Formatter) Table { 174 | t.HeaderFormatter = f 175 | return t 176 | } 177 | 178 | func (t *table) WithHeaderSeparatorRow(r rune) Table { 179 | t.HeaderSeparatorRune = r 180 | return t 181 | } 182 | 183 | func (t *table) WithFirstColumnFormatter(f Formatter) Table { 184 | t.FirstColumnFormatter = f 185 | return t 186 | } 187 | 188 | func (t *table) WithPadding(p int) Table { 189 | if p < 0 { 190 | p = 0 191 | } 192 | 193 | t.Padding = p 194 | return t 195 | } 196 | 197 | func (t *table) WithWriter(w io.Writer) Table { 198 | if w == nil { 199 | w = os.Stdout 200 | } 201 | 202 | t.Writer = w 203 | return t 204 | } 205 | 206 | func (t *table) WithWidthFunc(f WidthFunc) Table { 207 | t.Width = f 208 | return t 209 | } 210 | 211 | func (t *table) WithPrintHeaders(b bool) Table { 212 | t.PrintHeaders = b 213 | return t 214 | } 215 | 216 | func (t *table) AddRow(vals ...interface{}) Table { 217 | maxNumNewlines := 0 218 | for _, val := range vals { 219 | maxNumNewlines = max(strings.Count(fmt.Sprint(val), "\n"), maxNumNewlines) 220 | } 221 | for i := 0; i <= maxNumNewlines; i++ { 222 | row := make([]string, len(t.header)) 223 | for j, val := range vals { 224 | if j >= len(t.header) { 225 | break 226 | } 227 | v := strings.Split(fmt.Sprint(val), "\n") 228 | row[j] = safeOffset(v, i) 229 | } 230 | t.rows = append(t.rows, row) 231 | } 232 | 233 | return t 234 | } 235 | 236 | func (t *table) SetRows(rows [][]string) Table { 237 | t.rows = [][]string{} 238 | headerLength := len(t.header) 239 | 240 | for _, row := range rows { 241 | if len(row) > headerLength { 242 | t.rows = append(t.rows, row[:headerLength]) 243 | } else { 244 | t.rows = append(t.rows, row) 245 | } 246 | } 247 | 248 | return t 249 | } 250 | 251 | func (t *table) Print() { 252 | format := strings.Repeat("%s", len(t.header)) + "\n" 253 | t.calculateWidths() 254 | 255 | if t.PrintHeaders { 256 | t.printHeader(format) 257 | 258 | if t.HeaderSeparatorRune != 0 { 259 | t.printHeaderSeparator(format) 260 | } 261 | } 262 | 263 | for _, row := range t.rows { 264 | t.printRow(format, row) 265 | } 266 | } 267 | 268 | func (t *table) printHeaderSeparator(format string) { 269 | separators := make([]string, len(t.header)) 270 | 271 | // The separator could be any unicode char. Since some chars take up more 272 | // than one cell in a monospace context, we can get a number higher than 1 273 | // here. Am example would be this emoji 🤣. 274 | separatorCellWidth := t.Width(string([]rune{t.HeaderSeparatorRune})) 275 | for index, headerName := range t.header { 276 | headerCellWidth := t.Width(headerName) 277 | // Note that this might not be evenly divisble. In this case we'll get a 278 | // separator that is at least 1 cell shorter than the header. This was 279 | // an intentional design decision in order to prevent widening the cell 280 | // or overstepping the column bounds. 281 | repeatCharTimes := headerCellWidth / separatorCellWidth 282 | separator := make([]rune, repeatCharTimes) 283 | for i := 0; i < repeatCharTimes; i++ { 284 | separator[i] = t.HeaderSeparatorRune 285 | } 286 | separators[index] = string(separator) 287 | } 288 | 289 | vals := t.applyWidths(separators, t.widths) 290 | if t.HeaderFormatter != nil { 291 | txt := t.HeaderFormatter(format, vals...) 292 | fmt.Fprint(t.Writer, txt) 293 | } else { 294 | fmt.Fprintf(t.Writer, format, vals...) 295 | } 296 | } 297 | 298 | func (t *table) printHeader(format string) { 299 | vals := t.applyWidths(t.header, t.widths) 300 | if t.HeaderFormatter != nil { 301 | txt := t.HeaderFormatter(format, vals...) 302 | fmt.Fprint(t.Writer, txt) 303 | } else { 304 | fmt.Fprintf(t.Writer, format, vals...) 305 | } 306 | } 307 | 308 | func (t *table) printRow(format string, row []string) { 309 | vals := t.applyWidths(row, t.widths) 310 | 311 | if t.FirstColumnFormatter != nil { 312 | vals[0] = t.FirstColumnFormatter("%s", vals[0]) 313 | } 314 | 315 | fmt.Fprintf(t.Writer, format, vals...) 316 | } 317 | 318 | func (t *table) calculateWidths() { 319 | t.widths = make([]int, len(t.header)) 320 | for _, row := range t.rows { 321 | for i, v := range row { 322 | if w := t.Width(v) + t.Padding; w > t.widths[i] { 323 | t.widths[i] = w 324 | } 325 | } 326 | } 327 | 328 | for i, v := range t.header { 329 | if w := t.Width(v) + t.Padding; w > t.widths[i] { 330 | t.widths[i] = w 331 | } 332 | } 333 | } 334 | 335 | func (t *table) applyWidths(row []string, widths []int) []interface{} { 336 | out := make([]interface{}, len(row)) 337 | for i, s := range row { 338 | out[i] = s + t.lenOffset(s, widths[i]) 339 | } 340 | return out 341 | } 342 | 343 | func (t *table) lenOffset(s string, w int) string { 344 | l := w - t.Width(s) 345 | if l <= 0 { 346 | return "" 347 | } 348 | return strings.Repeat(" ", l) 349 | } 350 | 351 | func max(i1, i2 int) int { 352 | if i1 > i2 { 353 | return i1 354 | } 355 | return i2 356 | } 357 | 358 | func safeOffset(sarr []string, idx int) string { 359 | if idx >= len(sarr) { 360 | return "" 361 | } 362 | return sarr[idx] 363 | } 364 | -------------------------------------------------------------------------------- /table_test.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/mattn/go-runewidth" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestFormatter(t *testing.T) { 17 | t.Parallel() 18 | 19 | var formatter Formatter 20 | 21 | fn := func(a string, b ...interface{}) string { return "" } 22 | f := Formatter(fn) 23 | 24 | assert.IsType(t, formatter, f) 25 | } 26 | 27 | func TestTable_New(t *testing.T) { 28 | t.Parallel() 29 | 30 | buf := bytes.Buffer{} 31 | New("foo", "bar").WithWriter(&buf).Print() 32 | out := buf.String() 33 | 34 | assert.Contains(t, out, "foo") 35 | assert.Contains(t, out, "bar") 36 | 37 | buf.Reset() 38 | New().WithWriter(&buf).Print() 39 | out = buf.String() 40 | 41 | assert.Empty(t, strings.TrimSpace(out)) 42 | } 43 | 44 | func TestTable_WithHeaderFormatter(t *testing.T) { 45 | t.Parallel() 46 | 47 | uppercase := func(f string, v ...interface{}) string { 48 | return strings.ToUpper(fmt.Sprintf(f, v...)) 49 | } 50 | buf := bytes.Buffer{} 51 | 52 | tbl := New("foo", "bar").WithWriter(&buf).WithHeaderFormatter(uppercase) 53 | tbl.Print() 54 | out := buf.String() 55 | 56 | assert.Contains(t, out, "FOO") 57 | assert.Contains(t, out, "BAR") 58 | 59 | buf.Reset() 60 | tbl.WithHeaderFormatter(nil).Print() 61 | out = buf.String() 62 | 63 | assert.Contains(t, out, "foo") 64 | assert.Contains(t, out, "bar") 65 | } 66 | 67 | func TestTable_WithFirstColumnFormatter(t *testing.T) { 68 | t.Parallel() 69 | 70 | uppercase := func(f string, v ...interface{}) string { 71 | return strings.ToUpper(fmt.Sprintf(f, v...)) 72 | } 73 | 74 | buf := bytes.Buffer{} 75 | 76 | tbl := New("foo", "bar").WithWriter(&buf).WithFirstColumnFormatter(uppercase).AddRow("fizz", "buzz") 77 | tbl.Print() 78 | out := buf.String() 79 | 80 | assert.Contains(t, out, "foo") 81 | assert.Contains(t, out, "bar") 82 | assert.Contains(t, out, "FIZZ") 83 | assert.Contains(t, out, "buzz") 84 | 85 | buf.Reset() 86 | tbl.WithFirstColumnFormatter(nil).Print() 87 | out = buf.String() 88 | 89 | assert.Contains(t, out, "fizz") 90 | assert.Contains(t, out, "buzz") 91 | } 92 | 93 | func TestTable_WithPadding(t *testing.T) { 94 | t.Parallel() 95 | 96 | // zero value 97 | buf := bytes.Buffer{} 98 | tbl := New("foo", "bar").WithWriter(&buf).WithPadding(0) 99 | tbl.Print() 100 | out := buf.String() 101 | assert.Contains(t, out, "foobar") 102 | 103 | // positive value 104 | buf.Reset() 105 | tbl.WithPadding(4).Print() 106 | out = buf.String() 107 | assert.Contains(t, out, "foo bar ") 108 | 109 | // negative value 110 | buf.Reset() 111 | tbl.WithPadding(-1).Print() 112 | out = buf.String() 113 | assert.Contains(t, out, "foobar") 114 | } 115 | 116 | func TestTable_WithWriter(t *testing.T) { 117 | t.Parallel() 118 | 119 | // not that we haven't been using it in all these tests but: 120 | buf := bytes.Buffer{} 121 | New("foo", "bar").WithWriter(&buf).Print() 122 | assert.NotEmpty(t, buf.String()) 123 | 124 | stdout := os.Stdout 125 | temp, _ := ioutil.TempFile("", "") 126 | os.Stdout = temp 127 | defer func() { 128 | os.Stdout = stdout 129 | temp.Close() 130 | }() 131 | 132 | New("foo", "bar").WithWriter(nil).Print() 133 | temp.Seek(0, 0) 134 | 135 | out, _ := ioutil.ReadAll(temp) 136 | assert.NotEmpty(t, out) 137 | } 138 | 139 | func TestTable_AddRow(t *testing.T) { 140 | t.Parallel() 141 | 142 | buf := bytes.Buffer{} 143 | tbl := New("foo", "bar").WithWriter(&buf).AddRow("fizz", "buzz") 144 | tbl.Print() 145 | out := buf.String() 146 | assert.Contains(t, out, "fizz") 147 | assert.Contains(t, out, "buzz") 148 | lines := strings.Count(out, "\n") 149 | 150 | // empty should add empty line 151 | buf.Reset() 152 | tbl.AddRow().Print() 153 | assert.Equal(t, lines+1, strings.Count(buf.String(), "\n")) 154 | 155 | // less than one will fill left-to-right 156 | buf.Reset() 157 | tbl.AddRow("cat").Print() 158 | assert.Contains(t, buf.String(), "\ncat") 159 | 160 | // more than initial length are truncated 161 | buf.Reset() 162 | tbl.AddRow("bippity", "boppity", "boo").Print() 163 | assert.NotContains(t, buf.String(), "boo") 164 | 165 | // check the full table 166 | buf.Reset() 167 | tbl.Print() 168 | expected := `foo bar 169 | fizz buzz 170 | 171 | cat 172 | bippity boppity 173 | ` 174 | if diff := cmp.Diff(expected, buf.String()); diff != "" { 175 | t.Fatalf("table mismatch (-expected +got):\n%s\nout=%#v", diff, buf.String()) 176 | } 177 | } 178 | 179 | func TestTable_WithHeaderSeparatorRow(t *testing.T) { 180 | t.Parallel() 181 | 182 | buf := bytes.Buffer{} 183 | tbl := New("foo", "bar").WithHeaderSeparatorRow('-').WithWriter(&buf).AddRow("fizz", "buzz") 184 | 185 | // Add some rows 186 | tbl.AddRow() 187 | tbl.AddRow("cat") 188 | 189 | // add an entry that contains new lines 190 | tbl.AddRow("bippity", "boppity\nboop") 191 | 192 | // Add a couple more rows 193 | tbl.AddRow("a", "b") 194 | tbl.AddRow("c", "d") 195 | 196 | // and another entry with more new lines 197 | tbl.AddRow("1\n2", "x\ny\nz") 198 | 199 | // check the full table 200 | buf.Reset() 201 | tbl.Print() 202 | expected := `foo bar 203 | --- --- 204 | fizz buzz 205 | 206 | cat 207 | bippity boppity 208 | boop 209 | a b 210 | c d 211 | 1 x 212 | 2 y 213 | z 214 | ` 215 | if diff := cmp.Diff(expected, buf.String()); diff != "" { 216 | t.Fatalf("table mismatch (-expected +got):\n%s\nout=%#v", diff, buf.String()) 217 | } 218 | } 219 | 220 | func TestTable_AddRow_WithNewLines(t *testing.T) { 221 | t.Parallel() 222 | 223 | buf := bytes.Buffer{} 224 | tbl := New("foo", "bar").WithWriter(&buf).AddRow("fizz", "buzz") 225 | 226 | // Add some rows 227 | tbl.AddRow() 228 | tbl.AddRow("cat") 229 | 230 | // add an entry that contains new lines 231 | tbl.AddRow("bippity", "boppity\nboop") 232 | 233 | // Add a couple more rows 234 | tbl.AddRow("a", "b") 235 | tbl.AddRow("c", "d") 236 | 237 | // and another entry with more new lines 238 | tbl.AddRow("1\n2", "x\ny\nz") 239 | 240 | // check the full table 241 | buf.Reset() 242 | tbl.Print() 243 | expected := `foo bar 244 | fizz buzz 245 | 246 | cat 247 | bippity boppity 248 | boop 249 | a b 250 | c d 251 | 1 x 252 | 2 y 253 | z 254 | ` 255 | if diff := cmp.Diff(expected, buf.String()); diff != "" { 256 | t.Fatalf("table mismatch (-expected +got):\n%s\nout=%#v", diff, buf.String()) 257 | } 258 | } 259 | 260 | func TestTable_SetRows(t *testing.T) { 261 | t.Parallel() 262 | 263 | buf := bytes.Buffer{} 264 | tbl := New("foo", "bar").WithWriter(&buf).SetRows([][]string{ 265 | {"fizz", "buzz"}, 266 | {"lorem", "ipsum"}, 267 | }) 268 | tbl.Print() 269 | out := buf.String() 270 | assert.Contains(t, out, "fizz") 271 | assert.Contains(t, out, "buzz") 272 | assert.Contains(t, out, "lorem") 273 | assert.Contains(t, out, "ipsum") 274 | assert.Equal(t, 3, strings.Count(out, "\n")) 275 | 276 | // empty should remove all rows 277 | buf.Reset() 278 | tbl.SetRows([][]string{}).Print() 279 | assert.Equal(t, 1, strings.Count(buf.String(), "\n")) 280 | 281 | // less than one will fill left-to-right 282 | buf.Reset() 283 | tbl.SetRows([][]string{{"cat"}}).Print() 284 | assert.Contains(t, buf.String(), "\ncat") 285 | 286 | // more than initial length are truncated 287 | buf.Reset() 288 | tbl.SetRows([][]string{ 289 | {"lorem", "ipsum"}, 290 | {"bippity", "boppity", "boo"}, 291 | }).Print() 292 | assert.NotContains(t, buf.String(), "boo") 293 | } 294 | 295 | func TestTable_WithWidthFunc(t *testing.T) { 296 | t.Parallel() 297 | 298 | buf := bytes.Buffer{} 299 | 300 | New("", ""). 301 | WithWriter(&buf). 302 | WithPadding(1). 303 | WithWidthFunc(runewidth.StringWidth). 304 | AddRow("请求", "alpha"). 305 | AddRow("abc", "beta"). 306 | Print() 307 | 308 | actual := buf.String() 309 | assert.Contains(t, actual, "请求 alpha") 310 | assert.Contains(t, actual, "abc beta") 311 | } 312 | --------------------------------------------------------------------------------