Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions internal/output/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ type Formattable interface {

// Printer handles output rendering.
type Printer struct {
Writer io.Writer
ErrWriter io.Writer
Format Format
Template string
NoColor bool
Writer io.Writer
ErrWriter io.Writer
Format Format
Template string
NoColor bool
TableWidth int
}

// NewPrinter creates a new Printer with the given format settings.
Expand Down
25 changes: 25 additions & 0 deletions internal/output/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ func TestPrinterPrintTable(t *testing.T) {
}
}

func TestPrinterPrintTableWrapsLongCells(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable, TableWidth: 38}

err := p.Print(&stubFormattable{
headers: []string{"ID", "CONTENT", "TTL"},
rows: [][]string{
{"1", "alpha beta gamma delta epsilon zeta", "3600"},
{"2", strings.Repeat("A", 20), "3600"},
},
})
if !assert.NoError(t, err) {
return
}

out := strings.TrimSuffix(buf.String(), "\n")
for _, line := range strings.Split(out, "\n") {
assert.LessOrEqual(t, len([]rune(line)), 38, "line exceeds configured width: %q", line)
}

assert.Contains(t, out, "alpha beta")
assert.Contains(t, out, "gamma delta")
assert.Contains(t, out, "AAAAAAAAAAAA")
}

func TestPrinterPrintTableEmptyHeaders(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable}
Expand Down
258 changes: 251 additions & 7 deletions internal/output/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ package output

import (
"fmt"
"io"
"os"
"strconv"
"strings"
"text/tabwriter"
"unicode/utf8"
)

const (
tablePadding = 2
maxCompactColumnWidth = 12
)

func (p *Printer) printTable(data Formattable) error {
Expand All @@ -14,15 +22,251 @@ func (p *Printer) printTable(data Formattable) error {
return nil
}

w := tabwriter.NewWriter(p.Writer, 0, 0, 2, ' ', 0)
widths := naturalColumnWidths(headers, rows)
if tableWidth, ok := p.effectiveTableWidth(); ok {
widths = fitColumnWidths(widths, headers, tableWidth)
}

return writeTable(p.Writer, headers, rows, widths)
}

func (p *Printer) effectiveTableWidth() (int, bool) {
if p.TableWidth > 0 {
return p.TableWidth, true
}

if width, ok := detectTerminalWidth(p.Writer); ok {
return width, true
}

if raw, ok := os.LookupEnv("COLUMNS"); ok {
if width, err := strconv.Atoi(raw); err == nil && width > 0 {
return width, true
}
}

return 0, false
}

func naturalColumnWidths(headers []string, rows [][]string) []int {
widths := make([]int, len(headers))
for i, header := range headers {
widths[i] = cellWidth(header)
}

for _, row := range rows {
for i := 0; i < len(headers) && i < len(row); i++ {
if width := cellWidth(row[i]); width > widths[i] {
widths[i] = width
}
}
}

return widths
}

func fitColumnWidths(widths []int, headers []string, tableWidth int) []int {
if tableWidth <= 0 || totalTableWidth(widths) <= tableWidth {
return widths
}

fitted := append([]int(nil), widths...)
minWidths := minimumColumnWidths(widths, headers)

for totalTableWidth(fitted) > tableWidth {
column := widestShrinkableColumn(fitted, minWidths)
if column == -1 {
break
}
fitted[column]--
}

return fitted
}

// Print header
fmt.Fprintln(w, strings.Join(headers, "\t"))
func minimumColumnWidths(widths []int, headers []string) []int {
minWidths := make([]int, len(widths))
for i, width := range widths {
minWidths[i] = maxInt(cellWidth(headers[i]), minInt(width, maxCompactColumnWidth))
}
return minWidths
}

func widestShrinkableColumn(widths []int, minWidths []int) int {
column := -1
for i := range widths {
if widths[i] <= minWidths[i] {
continue
}
if column == -1 || widths[i] > widths[column] {
column = i
}
}
return column
}

func totalTableWidth(widths []int) int {
if len(widths) == 0 {
return 0
}

total := tablePadding * (len(widths) - 1)
for _, width := range widths {
total += width
}
return total
}

func writeTable(w io.Writer, headers []string, rows [][]string, widths []int) error {
if err := writeVisualRow(w, headers, widths); err != nil {
return err
}

// Print rows
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
cells := append([]string(nil), row...)
if len(cells) < len(headers) {
cells = append(cells, make([]string, len(headers)-len(cells))...)
}
if err := writeVisualRow(w, cells[:len(headers)], widths); err != nil {
return err
}
}

return w.Flush()
return nil
}

func writeVisualRow(w io.Writer, row []string, widths []int) error {
wrapped := make([][]string, len(widths))
lines := 1

for i, width := range widths {
cell := ""
if i < len(row) {
cell = row[i]
}
wrapped[i] = wrapCell(cell, width)
if len(wrapped[i]) > lines {
lines = len(wrapped[i])
}
}

for line := 0; line < lines; line++ {
var b strings.Builder
for column, width := range widths {
if column > 0 {
b.WriteString(strings.Repeat(" ", tablePadding))
}

part := ""
if line < len(wrapped[column]) {
part = wrapped[column][line]
}
b.WriteString(part)
if pad := width - utf8.RuneCountInString(part); pad > 0 {
b.WriteString(strings.Repeat(" ", pad))
}
}

if _, err := fmt.Fprintln(w, strings.TrimRight(b.String(), " ")); err != nil {
return err
}
}

return nil
}

func wrapCell(cell string, width int) []string {
if width <= 0 {
return []string{cell}
}

lines := strings.Split(cell, "\n")
wrapped := make([]string, 0, len(lines))
for _, line := range lines {
wrapped = append(wrapped, wrapLine(line, width)...)
}

if len(wrapped) == 0 {
return []string{""}
}

return wrapped
}

func wrapLine(line string, width int) []string {
if line == "" {
return []string{""}
}

words := strings.Fields(line)
if len(words) == 0 {
return []string{""}
}

wrapped := make([]string, 0, 1)
current := ""

for _, word := range words {
for utf8.RuneCountInString(word) > width {
if current != "" {
wrapped = append(wrapped, current)
current = ""
}

part, rest := splitAtWidth(word, width)
wrapped = append(wrapped, part)
word = rest
}

if current == "" {
current = word
continue
}

if utf8.RuneCountInString(current)+1+utf8.RuneCountInString(word) <= width {
current += " " + word
continue
}

wrapped = append(wrapped, current)
current = word
}

if current != "" {
wrapped = append(wrapped, current)
}

return wrapped
}

func splitAtWidth(s string, width int) (string, string) {
runes := []rune(s)
if len(runes) <= width {
return s, ""
}
return string(runes[:width]), string(runes[width:])
}

func cellWidth(cell string) int {
width := 0
for _, line := range strings.Split(cell, "\n") {
if lineWidth := utf8.RuneCountInString(line); lineWidth > width {
width = lineWidth
}
}
return width
}

func minInt(a int, b int) int {
if a < b {
return a
}
return b
}

func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}
9 changes: 9 additions & 0 deletions internal/output/table_width_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !unix

package output

import "io"

func detectTerminalWidth(io.Writer) (int, bool) {
return 0, false
}
24 changes: 24 additions & 0 deletions internal/output/table_width_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build unix

package output

import (
"io"
"os"

"golang.org/x/sys/unix"
)

func detectTerminalWidth(w io.Writer) (int, bool) {
file, ok := w.(*os.File)
if !ok {
return 0, false
}

ws, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
if err != nil || ws == nil || ws.Col == 0 {
return 0, false
}

return int(ws.Col), true
}