From cfc25907dbe62a666c01ef587a0eb23e2318cdf6 Mon Sep 17 00:00:00 2001 From: Simone Carletti Date: Mon, 24 Aug 2026 12:47:02 +0200 Subject: [PATCH 1/3] Fit table output to the terminal width tabwriter sizes a column by its widest cell, so a 400 character DNSKEY value padded the CONTENT cell of every row and pushed TTL and REGIONS past the right edge of the screen. One long value broke the alignment of the whole table. The columns before the last one now shrink to fit the terminal, and a value that no longer fits ends with an ellipsis. The last column never shrinks, because tabwriter does not pad the last cell of a row. A writer that is not a terminal keeps every value in full, so a pipe, a redirect, --json, and --format stay lossless. --- CHANGELOG.md | 1 + README.md | 2 + internal/output/format.go | 4 ++ internal/output/format_test.go | 86 +++++++++++++++++++++++++++ internal/output/table.go | 104 ++++++++++++++++++++++++++++++++- 5 files changed, 196 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dcc599..4d0f793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses [Semantic Versioning 2.0.0](http://semver.org/), the format is - The new release notice now prints the release page URL for every installation method, colors the version numbers, and separates itself from the command output with a blank line above and below. `--no-color` and the `NO_COLOR` environment variable turn the color off. The `DNSIMPLE_NO_UPDATE_CHECK` environment variable, which turns the check off, is now documented in the README. - Table output puts the header row in bold, and the pagination hints that go with a multi-page list are faint. `--no-color` and the `NO_COLOR` environment variable turn the color off, and redirected output stays plain. - The update check no longer runs when the `BUILD_NUMBER` or the `RUN_ID` environment variable is set, and it now requires both the standard output stream and the standard error stream to be a terminal. +- Table output is cut to the width of the terminal. A long value, for example a DNSKEY record, no longer pushes the columns after it off the screen. Redirected output, `--json`, `--format`, and the single-resource `get` commands still return the full value. ## 0.10.0 - 2026-06-15 diff --git a/README.md b/README.md index bc8fb39..10b4a35 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ To discover available template fields, inspect the corresponding `--json` output Table output puts the header row in bold, and the pagination hints that go with a multi-page list are faint. Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn the colored output off. Redirected output is always plain. +A table is cut to the width of the terminal, and a value that does not fit ends with `...`. Redirected output is never cut, and `--json`, `--format`, and the single-resource `get` commands always return the full value. + ### Sandbox Environment We highly recommend testing against our [sandbox environment](https://developer.dnsimple.com/sandbox/) before using our production environment. This will allow you to avoid real purchases, live charges on your credit card, and reduce the chance of your running up against rate limits. diff --git a/internal/output/format.go b/internal/output/format.go index 2caaec3..7eea474 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -45,6 +45,10 @@ type Printer struct { Format Format Template string NoColor bool + + // width bounds the table output. It is 0 in production, where the width + // comes from the terminal. + width int } // NewPrinter creates a new Printer with the given format settings. diff --git a/internal/output/format_test.go b/internal/output/format_test.go index ff339be..4567e55 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -2,6 +2,7 @@ package output import ( "bytes" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -88,6 +89,91 @@ func TestPrinterPrintTableEmptyHeaders(t *testing.T) { assert.Zero(t, buf.Len()) } +func wideTable() *stubFormattable { + return &stubFormattable{ + headers: []string{"ID", "CONTENT", "TTL"}, + rows: [][]string{{"1", strings.Repeat("a", 60), "3600"}}, + } +} + +func TestPrinterPrintTableTruncatesToWidth(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable, width: 40} + + err := p.Print(wideTable()) + if !assert.NoError(t, err) { + return + } + + // The CONTENT column gives up the space the TTL column needs, so every row + // still ends with its own TTL. + want := "ID CONTENT" + strings.Repeat(" ", 25) + "TTL\n" + + "1 " + strings.Repeat("a", 27) + "... 3600\n" + assert.Equal(t, want, buf.String()) +} + +func TestPrinterPrintTableKeepsTheLastColumn(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable, width: 40} + + err := p.Print(&stubFormattable{ + headers: []string{"FIELD", "VALUE"}, + rows: [][]string{{"Content", strings.Repeat("a", 60)}}, + }) + if !assert.NoError(t, err) { + return + } + + want := "FIELD VALUE\nContent " + strings.Repeat("a", 60) + "\n" + assert.Equal(t, want, buf.String()) +} + +func TestPrinterPrintTableWithoutWidthKeepsEveryValue(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable} + + err := p.Print(wideTable()) + if !assert.NoError(t, err) { + return + } + + want := "ID CONTENT" + strings.Repeat(" ", 55) + "TTL\n" + + "1 " + strings.Repeat("a", 60) + " 3600\n" + assert.Equal(t, want, buf.String()) +} + +func TestPrinterPrintTableUnderWidthIsUnchanged(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable, width: 80} + + err := p.Print(&stubFormattable{ + headers: []string{"NAME", "VALUE"}, + rows: [][]string{ + {"alpha", "1"}, + {"beta", "22"}, + }, + }) + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, "NAME VALUE\nalpha 1\nbeta 22\n", buf.String()) +} + +func TestPrinterPrintTableKeepsHeadersWhenWidthIsTooSmall(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable, width: 10} + + err := p.Print(wideTable()) + if !assert.NoError(t, err) { + return + } + + // The table cannot fit, so it stops at the narrowest columns that still + // carry a whole header. + assert.Equal(t, "ID CONTENT TTL\n1 aaaaa... 3600\n", buf.String()) +} + func listData() *stubFormattable { return &stubFormattable{ headers: []string{"NAME"}, diff --git a/internal/output/table.go b/internal/output/table.go index ae4f157..6d70971 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -4,10 +4,20 @@ import ( "bytes" "fmt" "io" + "os" "strings" "text/tabwriter" "github.com/fatih/color" + "golang.org/x/term" +) + +const ( + // Cell padding printTable gives tabwriter. + tablePadding = 2 + // Narrowest a column shrinks to, before its header is taken into account. + minColumnWidth = 8 + ellipsis = "..." ) func (p *Printer) printTable(data Formattable) error { @@ -18,8 +28,10 @@ func (p *Printer) printTable(data Formattable) error { return nil } + rows = fitColumns(headers, rows, p.tableWidth()) + var buf bytes.Buffer - w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + w := tabwriter.NewWriter(&buf, 0, 0, tablePadding, ' ', 0) // Print header fmt.Fprintln(w, strings.Join(headers, "\t")) @@ -44,3 +56,93 @@ func (p *Printer) printTable(data Formattable) error { _, err := io.WriteString(p.Writer, out) return err } + +// tableWidth returns the width the table must fit in. It is 0 when the writer +// has no width of its own, which lets a redirect or a pipe carry every value in +// full. +func (p *Printer) tableWidth() int { + if p.width > 0 { + return p.width + } + + f, ok := p.Writer.(*os.File) + if !ok { + return 0 + } + width, _, err := term.GetSize(int(f.Fd())) + if err != nil { + return 0 + } + return width +} + +// fitColumns truncates the cells that make the table wider than limit. Only the +// columns before the last one shrink: tabwriter pads every cell of a row except +// the last, so a long value in the last column cannot move the columns before it. +func fitColumns(headers []string, rows [][]string, limit int) [][]string { + if limit <= 0 || len(headers) < 2 { + return rows + } + + widths := make([]int, len(headers)) + floors := make([]int, len(headers)) + for i, header := range headers { + widths[i] = runeLen(header) + floors[i] = max(widths[i], minColumnWidth) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) { + widths[i] = max(widths[i], runeLen(cell)) + } + } + } + + total := tablePadding * (len(widths) - 1) + for _, width := range widths { + total += width + } + + for total > limit { + widest := -1 + for i := range widths[:len(widths)-1] { + if widths[i] > floors[i] && (widest == -1 || widths[i] > widths[widest]) { + widest = i + } + } + if widest == -1 { + break + } + widths[widest]-- + total-- + } + + fitted := make([][]string, len(rows)) + for i, row := range rows { + cells := make([]string, len(row)) + copy(cells, row) + for j := range cells { + if j < len(widths)-1 { + cells[j] = truncate(cells[j], widths[j]) + } + } + fitted[i] = cells + } + return fitted +} + +// truncate cuts s to width, giving the last characters to the ellipsis. +func truncate(s string, width int) string { + runes := []rune(s) + if len(runes) <= width { + return s + } + if width <= len(ellipsis) { + return string(runes[:width]) + } + return string(runes[:width-len(ellipsis)]) + ellipsis +} + +func runeLen(s string) int { + return len([]rune(s)) +} From 1b4b6f94be0ed51c174b98e90001885b0da8fc78 Mon Sep 17 00:00:00 2001 From: Simone Carletti Date: Mon, 24 Aug 2026 12:59:36 +0200 Subject: [PATCH 2/3] Cut the table only when cutting helps A last column wider than the terminal never shrinks, so the columns before it were giving up their values for nothing. The FIELD labels of a detail table were cut to "Syste..." while the value they describe stayed whole. Leave the table alone in that case, and skip the row copy when the table already fits. Fold the terminal probe into terminalWidth next to IsTerminal, so the *os.File assertion has one home, and count runes with the standard library instead of a local helper. --- internal/output/color.go | 14 ++++++++++++ internal/output/table.go | 46 +++++++++++++++++----------------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/internal/output/color.go b/internal/output/color.go index 0558c47..1807171 100644 --- a/internal/output/color.go +++ b/internal/output/color.go @@ -15,6 +15,20 @@ func IsTerminal(w io.Writer) bool { return ok && term.IsTerminal(int(f.Fd())) } +// terminalWidth returns the width of w in columns, or 0 when w has no width of +// its own. +func terminalWidth(w io.Writer) int { + f, ok := w.(*os.File) + if !ok { + return 0 + } + width, _, err := term.GetSize(int(f.Fd())) + if err != nil { + return 0 + } + return width +} + // ColorEnabled reports whether w accepts colored output. The noColor argument // carries the --no-color flag. func ColorEnabled(w io.Writer, noColor bool) bool { diff --git a/internal/output/table.go b/internal/output/table.go index 6d70971..363d678 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -4,12 +4,11 @@ import ( "bytes" "fmt" "io" - "os" "strings" "text/tabwriter" + "unicode/utf8" "github.com/fatih/color" - "golang.org/x/term" ) const ( @@ -64,16 +63,7 @@ func (p *Printer) tableWidth() int { if p.width > 0 { return p.width } - - f, ok := p.Writer.(*os.File) - if !ok { - return 0 - } - width, _, err := term.GetSize(int(f.Fd())) - if err != nil { - return 0 - } - return width + return terminalWidth(p.Writer) } // fitColumns truncates the cells that make the table wider than limit. Only the @@ -85,15 +75,17 @@ func fitColumns(headers []string, rows [][]string, limit int) [][]string { } widths := make([]int, len(headers)) - floors := make([]int, len(headers)) for i, header := range headers { - widths[i] = runeLen(header) + widths[i] = utf8.RuneCountInString(header) + } + floors := make([]int, len(headers)-1) + for i := range floors { floors[i] = max(widths[i], minColumnWidth) } for _, row := range rows { for i, cell := range row { if i < len(widths) { - widths[i] = max(widths[i], runeLen(cell)) + widths[i] = max(widths[i], utf8.RuneCountInString(cell)) } } } @@ -103,9 +95,15 @@ func fitColumns(headers []string, rows [][]string, limit int) [][]string { total += width } + // Nothing to cut, or nothing to gain: a last column wider than the limit + // never shrinks, so the columns before it give up their values for nothing. + if total <= limit || widths[len(widths)-1] >= limit { + return rows + } + for total > limit { widest := -1 - for i := range widths[:len(widths)-1] { + for i := range floors { if widths[i] > floors[i] && (widest == -1 || widths[i] > widths[widest]) { widest = i } @@ -121,10 +119,8 @@ func fitColumns(headers []string, rows [][]string, limit int) [][]string { for i, row := range rows { cells := make([]string, len(row)) copy(cells, row) - for j := range cells { - if j < len(widths)-1 { - cells[j] = truncate(cells[j], widths[j]) - } + for j := 0; j < len(floors) && j < len(cells); j++ { + cells[j] = truncate(cells[j], widths[j]) } fitted[i] = cells } @@ -133,16 +129,12 @@ func fitColumns(headers []string, rows [][]string, limit int) [][]string { // truncate cuts s to width, giving the last characters to the ellipsis. func truncate(s string, width int) string { + if len(s) <= width { + return s + } runes := []rune(s) if len(runes) <= width { return s } - if width <= len(ellipsis) { - return string(runes[:width]) - } return string(runes[:width-len(ellipsis)]) + ellipsis } - -func runeLen(s string) int { - return len([]rune(s)) -} From 4577f9aee009929a87ab0e20f9df21d240d10225 Mon Sep 17 00:00:00 2001 From: Simone Carletti Date: Mon, 24 Aug 2026 13:00:17 +0200 Subject: [PATCH 3/3] Cover a label wider than the column floor The last column test used a FIELD label of seven characters, which is under the floor and can never be cut. It passed whatever the code did. --- internal/output/format_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/output/format_test.go b/internal/output/format_test.go index 4567e55..940a585 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -118,13 +118,15 @@ func TestPrinterPrintTableKeepsTheLastColumn(t *testing.T) { err := p.Print(&stubFormattable{ headers: []string{"FIELD", "VALUE"}, - rows: [][]string{{"Content", strings.Repeat("a", 60)}}, + rows: [][]string{{"System Record", strings.Repeat("a", 60)}}, }) if !assert.NoError(t, err) { return } - want := "FIELD VALUE\nContent " + strings.Repeat("a", 60) + "\n" + // The last column never shrinks, so cutting the FIELD labels would lose + // values without bringing the table inside the width. + want := "FIELD VALUE\nSystem Record " + strings.Repeat("a", 60) + "\n" assert.Equal(t, want, buf.String()) }