From 50602b8bf36b76d9f3a2c7636c7929cd648ef6b5 Mon Sep 17 00:00:00 2001 From: Zach Casper Date: Mon, 18 May 2026 14:22:51 -0500 Subject: [PATCH 1/3] Implement line wrapping Signed-off-by: Zach Casper --- go.mod | 3 +- pkg/cli/output/table.go | 252 +++++++++++++++++++++++++++++------ pkg/cli/output/table_test.go | 132 ++++++++++++++++++ 3 files changed, 346 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index 4f11508fe8..a795fedd89 100644 --- a/go.mod +++ b/go.mod @@ -119,6 +119,8 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require golang.org/x/term v0.43.0 + require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect @@ -319,7 +321,6 @@ require ( golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect diff --git a/pkg/cli/output/table.go b/pkg/cli/output/table.go index 995853536d..7794740058 100644 --- a/pkg/cli/output/table.go +++ b/pkg/cli/output/table.go @@ -20,9 +20,12 @@ import ( "bytes" "errors" "io" + "os" "strings" - "text/tabwriter" + "unicode" + "unicode/utf8" + "golang.org/x/term" "k8s.io/client-go/util/jsonpath" ) @@ -35,12 +38,37 @@ const ( TableFlags = 0 ) +// terminalWidthForWriter is overridable for testing. +var terminalWidthForWriter = func(w io.Writer) int { + f, ok := w.(*os.File) + if !ok { + return 0 + } + width, _, err := term.GetSize(int(f.Fd())) + if err != nil || width <= 0 { + return 0 + } + return width +} + type TableFormatter struct { } // Format() takes in an object, a writer and formatting options and writes a table to the writer using the // formatting options. If no columns are defined, an error is returned. +// +// When the writer is a terminal, content in the last column that would overflow the terminal width is +// word-wrapped onto continuation lines, and earlier columns are padded with spaces on those continuation +// lines so the table stays visually aligned. When the terminal width is unknown (for example, when output +// is piped), no wrapping is performed. func (f *TableFormatter) Format(obj any, writer io.Writer, options FormatterOptions) error { + return f.formatWithWidth(obj, writer, options, terminalWidthForWriter(writer)) +} + +// formatWithWidth renders the table using an explicit terminal width. A width of 0 (or negative) +// disables wrapping based on terminal width, but cells containing literal newlines are still +// expanded into multiple physical rows. +func (f *TableFormatter) formatWithWidth(obj any, writer io.Writer, options FormatterOptions, terminalWidth int) error { if len(options.Columns) == 0 { return errors.New("no columns were defined, table format is not supported for this command") } @@ -57,8 +85,7 @@ func (f *TableFormatter) Format(obj any, writer io.Writer, options FormatterOpti headings = append(headings, c.Heading) p := jsonpath.New(c.Heading).AllowMissingKeys(true) - err := p.Parse(c.JSONPath) - if err != nil { + if err := p.Parse(c.JSONPath); err != nil { return err } @@ -66,70 +93,215 @@ func (f *TableFormatter) Format(obj any, writer io.Writer, options FormatterOpti transformers = append(transformers, c.Transformer) } - tabs := tabwriter.NewWriter(writer, TableColumnMinWidth, TableTabSize, TablePadSize, TablePadCharacter, TableFlags) - _, err = tabs.Write([]byte(strings.Join(headings, "\t") + "\n")) - if err != nil { - return err - } - - renderedRows := [][]string{} + // Resolve every cell up-front. cellLines[r][c] is the slice of lines for the cell at + // row r and column c after splitting any embedded newlines from the source data. + cellLines := make([][][]string, 0, len(rows)) for _, row := range rows { - // For each row evaluate the text and split across lines if necessary. - currentRows := [][]string{} + cells := make([][]string, len(parsers)) for i, p := range parsers { - transformer := transformers[i] buf := bytes.Buffer{} - err := p.Execute(&buf, row) - if err != nil { + if err := p.Execute(&buf, row); err != nil { return err } text := buf.String() - if transformer != nil { - text = transformer.Transform(text) + if transformers[i] != nil { + text = transformers[i].Transform(text) } - lines := strings.Split(text, "\n") - for j, line := range lines { - if len(currentRows) == j { - currentRows = append(currentRows, make([]string, len(parsers))) - } + cells[i] = strings.Split(text, "\n") + } + cellLines = append(cellLines, cells) + } - currentRows[j][i] = line + // Compute each column's natural slot width: max(content+pad, minWidth). The slot + // is the total number of characters consumed by the column on every rendered line, + // so adjacent columns sit flush against each other (no extra separator characters). + numCols := len(headings) + slots := make([]int, numCols) + for i, h := range headings { + if w := utf8.RuneCountInString(h) + TablePadSize; w > slots[i] { + slots[i] = w + } + } + for _, cells := range cellLines { + for i, lines := range cells { + for _, line := range lines { + if w := utf8.RuneCountInString(line) + TablePadSize; w > slots[i] { + slots[i] = w + } } } + } + for i := range slots { + if slots[i] < TableColumnMinWidth { + slots[i] = TableColumnMinWidth + } + } - renderedRows = append(renderedRows, currentRows...) + // The last column is rendered without trailing padding, so its effective content width + // is its slot width minus the pad we added when computing the slot. When we know the + // terminal width and the table would overflow, shrink the last column so its content + // fits in the remaining space, and wrap longer cells onto continuation lines. + lastContentWidth := slots[numCols-1] - TablePadSize + if terminalWidth > 0 { + nonLastSum := 0 + for i := 0; i < numCols-1; i++ { + nonLastSum += slots[i] + } + available := terminalWidth - nonLastSum + if available < TableColumnMinWidth { + available = TableColumnMinWidth + } + if available < lastContentWidth { + lastContentWidth = available + } } - // Now write the table - for _, renderedRow := range renderedRows { - for i, cell := range renderedRow { - _, err = tabs.Write([]byte(cell)) - if err != nil { - return err + writeRow := func(cells [][]string) error { + // Wrap the last column's lines so that each physical line fits within lastContentWidth. + if numCols > 0 { + lastIdx := numCols - 1 + wrapped := make([]string, 0, len(cells[lastIdx])) + for _, line := range cells[lastIdx] { + wrapped = append(wrapped, wordWrap(line, lastContentWidth)...) } + cells[lastIdx] = wrapped + } - if i < len(parsers)-1 { - _, err = tabs.Write([]byte("\t")) - if err != nil { - return err - } + // Compute row height (max number of physical lines across all columns). + height := 1 + for _, c := range cells { + if len(c) > height { + height = len(c) } } - _, err := tabs.Write([]byte("\n")) - if err != nil { - return err + for li := 0; li < height; li++ { + for ci, c := range cells { + cell := "" + if li < len(c) { + cell = c[li] + } + if ci < numCols-1 { + // Pad non-last cells (including empty cells on continuation lines) + // to the full slot width so columns stay aligned. + if _, err := io.WriteString(writer, padRight(cell, slots[ci])); err != nil { + return err + } + } else { + // Last column: write content as-is, no trailing padding. + if _, err := io.WriteString(writer, cell); err != nil { + return err + } + } + } + if _, err := io.WriteString(writer, "\n"); err != nil { + return err + } } + return nil } - err = tabs.Flush() - if err != nil { + // Heading row. + headingCells := make([][]string, numCols) + for i, h := range headings { + headingCells[i] = []string{h} + } + if err := writeRow(headingCells); err != nil { return err } + for _, cells := range cellLines { + if err := writeRow(cells); err != nil { + return err + } + } + return nil } +// padRight returns s padded with trailing spaces so that the resulting string is at least +// width runes long. If s is already wider than width, s is returned unchanged. +func padRight(s string, width int) string { + n := utf8.RuneCountInString(s) + if n >= width { + return s + } + return s + strings.Repeat(" ", width-n) +} + +// wordWrap wraps s into lines no wider than width runes, breaking on whitespace where +// possible. Words longer than width are broken mid-word on rune boundaries (never inside +// a UTF-8 sequence). Internal whitespace runs are preserved within a line; trailing +// whitespace at a wrap boundary is dropped. An empty input returns a single empty line. +func wordWrap(s string, width int) []string { + if width <= 0 || utf8.RuneCountInString(s) <= width { + return []string{s} + } + + runes := []rune(s) + var lines []string + var current []rune + i := 0 + for i < len(runes) { + // Collect the next non-whitespace word. + j := i + for j < len(runes) && !unicode.IsSpace(runes[j]) { + j++ + } + word := runes[i:j] + + // Collect the whitespace that follows the word. + k := j + for k < len(runes) && unicode.IsSpace(runes[k]) { + k++ + } + space := runes[j:k] + i = k + + // Place the word, breaking it across lines if it is itself longer than width. + if len(word) > width { + if len(current) > 0 { + lines = append(lines, string(trimTrailingSpace(current))) + current = current[:0] + } + for len(word) > width { + lines = append(lines, string(word[:width])) + word = word[width:] + } + current = append(current, word...) + } else if len(current)+len(word) <= width { + current = append(current, word...) + } else { + lines = append(lines, string(trimTrailingSpace(current))) + current = append(current[:0], word...) + } + + // Append the trailing whitespace if it still fits on the current line; otherwise + // drop it at the wrap boundary so we don't emit lines with trailing spaces. + if len(space) > 0 { + if len(current)+len(space) <= width { + current = append(current, space...) + } else { + lines = append(lines, string(trimTrailingSpace(current))) + current = current[:0] + } + } + } + if len(current) > 0 || len(lines) == 0 { + lines = append(lines, string(trimTrailingSpace(current))) + } + return lines +} + +// trimTrailingSpace returns runes with any trailing Unicode whitespace removed. +func trimTrailingSpace(runes []rune) []rune { + end := len(runes) + for end > 0 && unicode.IsSpace(runes[end-1]) { + end-- + } + return runes[:end] +} + var _ Formatter = (*TableFormatter)(nil) diff --git a/pkg/cli/output/table_test.go b/pkg/cli/output/table_test.go index d44f356227..83fca7af9f 100644 --- a/pkg/cli/output/table_test.go +++ b/pkg/cli/output/table_test.go @@ -20,6 +20,7 @@ import ( "bytes" "strings" "testing" + "unicode/utf8" "github.com/stretchr/testify/require" ) @@ -145,6 +146,137 @@ smoothness require.Equal(t, expected, buffer.String()) } +type wrappableInput struct { + Name string + Description string +} + +var wrappableInputOptions = FormatterOptions{ + Columns: []Column{ + {Heading: "NAME", JSONPath: "{ .Name }"}, + {Heading: "DESCRIPTION", JSONPath: "{ .Description }"}, + }, +} + +func Test_Table_WrapsLastColumnToTerminalWidth(t *testing.T) { + obj := []any{ + wrappableInput{ + Name: "size", + Description: "The size of the PostgreSQL database, non production environments can be (S)mall or (M)edium, production environments can be or (S)mall, (M)edium, (L)arge, or (XL)arge", + }, + wrappableInput{ + Name: "host", + Description: "The host name of the database server", + }, + } + + formatter := &TableFormatter{} + buffer := &bytes.Buffer{} + // Force a narrow terminal width so the long description must wrap. + err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 80) + require.NoError(t, err) + + // The first column slot is max(len("NAME")+pad, minWidth) = max(6, 10) = 10. The remaining + // 70 columns are available for the description. Expect the long description to wrap onto + // continuation lines that are padded under the description column. + expected := "NAME DESCRIPTION\n" + + "size The size of the PostgreSQL database, non production environments can\n" + + " be (S)mall or (M)edium, production environments can be or (S)mall,\n" + + " (M)edium, (L)arge, or (XL)arge\n" + + "host The host name of the database server\n" + + require.Equal(t, expected, buffer.String()) +} + +func Test_Table_NoWrapWhenTerminalWidthUnknown(t *testing.T) { + obj := []any{ + wrappableInput{ + Name: "size", + Description: "A long description that would otherwise be wrapped if a terminal width was supplied to the formatter", + }, + } + + formatter := &TableFormatter{} + buffer := &bytes.Buffer{} + // Width 0 means "unknown terminal width" -- no wrapping should occur. + err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 0) + require.NoError(t, err) + + expected := "NAME DESCRIPTION\n" + + "size A long description that would otherwise be wrapped if a terminal width was supplied to the formatter\n" + require.Equal(t, expected, buffer.String()) +} + +func Test_Table_WrapsLongUnbreakableWord(t *testing.T) { + obj := []any{ + wrappableInput{ + Name: "url", + Description: "https://example.com/very/long/path/that/has/no/spaces/at/all/and/exceeds/the/width", + }, + } + + formatter := &TableFormatter{} + buffer := &bytes.Buffer{} + err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 40) + require.NoError(t, err) + + // Available for last column: 40 - 10 = 30. The unbreakable URL should be split mid-word + // at width boundaries; continuation lines should be padded under the description column. + expected := "NAME DESCRIPTION\n" + + "url https://example.com/very/long/\n" + + " path/that/has/no/spaces/at/all\n" + + " /and/exceeds/the/width\n" + require.Equal(t, expected, buffer.String()) +} + +// Test_Table_AlignsAndWrapsUnicode verifies that column alignment and word wrapping use +// rune counts rather than byte lengths so multi-byte UTF-8 content stays aligned and is +// never split in the middle of a rune. +func Test_Table_AlignsAndWrapsUnicode(t *testing.T) { + obj := []any{ + wrappableInput{ + // Multi-byte runes in NAME ensure padding uses rune width, not byte length. + Name: "café", + // A description that mixes ASCII and multi-byte runes and exceeds width. + Description: "naïve façade — emoji 🚀🚀🚀 and more text that should wrap nicely", + }, + } + + formatter := &TableFormatter{} + buffer := &bytes.Buffer{} + err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 40) + require.NoError(t, err) + + // First column slot is max(len("NAME")+pad, minWidth) = 10. Last column has 30 runes + // available. The wrap should occur on whitespace boundaries and never produce invalid + // UTF-8. + expected := "NAME DESCRIPTION\n" + + "café naïve façade — emoji 🚀🚀🚀 and\n" + + " more text that should wrap\n" + + " nicely\n" + require.Equal(t, expected, buffer.String()) + require.True(t, utf8.ValidString(buffer.String()), "output must be valid UTF-8") +} + +// Test_Table_PreservesInternalWhitespace verifies that whitespace inside a cell that fits +// on a line is preserved (only trailing whitespace at a wrap boundary is dropped). +func Test_Table_PreservesInternalWhitespace(t *testing.T) { + obj := []any{ + wrappableInput{ + Name: "row", + Description: "a b c", + }, + } + formatter := &TableFormatter{} + buffer := &bytes.Buffer{} + err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 40) + require.NoError(t, err) + + expected := "NAME DESCRIPTION\n" + + "row a b c\n" + require.Equal(t, expected, buffer.String()) +} + func Test_convertToStruct(t *testing.T) { aStruct := tableInput{ Size: "medium", From 7db58ac23f29072b3fa69d030a325283e0259368 Mon Sep 17 00:00:00 2001 From: Zach Casper Date: Mon, 18 May 2026 14:35:11 -0500 Subject: [PATCH 2/3] Use display width (runewidth) for padding and wrap calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review feedback on #11931: replace utf8.RuneCountInString with runewidth.StringWidth so that wide characters (CJK, emoji) and zero-width combining marks are accounted for correctly in column alignment and last-column wrapping. Long unbreakable words are now split at display-column boundaries rather than rune-count boundaries. - pkg/cli/output/table.go: switch padRight and wordWrap (and the slot/width computation) to use runewidth.StringWidth and runewidth.RuneWidth. - pkg/cli/output/table_test.go: add Test_Table_WrapsWideCharactersByDisplayWidth exercising CJK, and update the Unicode wrap expectation to reflect that 🚀 is two display columns wide. - go.mod: promote github.com/mattn/go-runewidth to a direct dependency. Signed-off-by: Zach Casper --- go.mod | 6 ++-- pkg/cli/output/table.go | 61 +++++++++++++++++++++++++----------- pkg/cli/output/table_test.go | 46 ++++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index a795fedd89..bbd07e2dc4 100644 --- a/go.mod +++ b/go.mod @@ -119,7 +119,10 @@ require ( sigs.k8s.io/yaml v1.6.0 ) -require golang.org/x/term v0.43.0 +require ( + github.com/mattn/go-runewidth v0.0.23 + golang.org/x/term v0.43.0 +) require ( cel.dev/expr v0.25.1 // indirect @@ -253,7 +256,6 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect diff --git a/pkg/cli/output/table.go b/pkg/cli/output/table.go index 7794740058..dfcd5f5706 100644 --- a/pkg/cli/output/table.go +++ b/pkg/cli/output/table.go @@ -23,8 +23,8 @@ import ( "os" "strings" "unicode" - "unicode/utf8" + "github.com/mattn/go-runewidth" "golang.org/x/term" "k8s.io/client-go/util/jsonpath" ) @@ -120,14 +120,14 @@ func (f *TableFormatter) formatWithWidth(obj any, writer io.Writer, options Form numCols := len(headings) slots := make([]int, numCols) for i, h := range headings { - if w := utf8.RuneCountInString(h) + TablePadSize; w > slots[i] { + if w := runewidth.StringWidth(h) + TablePadSize; w > slots[i] { slots[i] = w } } for _, cells := range cellLines { for i, lines := range cells { for _, line := range lines { - if w := utf8.RuneCountInString(line) + TablePadSize; w > slots[i] { + if w := runewidth.StringWidth(line) + TablePadSize; w > slots[i] { slots[i] = w } } @@ -221,28 +221,35 @@ func (f *TableFormatter) formatWithWidth(obj any, writer io.Writer, options Form return nil } -// padRight returns s padded with trailing spaces so that the resulting string is at least -// width runes long. If s is already wider than width, s is returned unchanged. +// padRight returns s padded with trailing spaces so that the resulting string occupies +// at least width terminal columns. Width is measured in display columns (so wide +// characters like CJK or emoji count as 2 and combining marks count as 0), so the next +// column starts at the correct visual position. If s is already wider than width, s is +// returned unchanged. func padRight(s string, width int) string { - n := utf8.RuneCountInString(s) + n := runewidth.StringWidth(s) if n >= width { return s } return s + strings.Repeat(" ", width-n) } -// wordWrap wraps s into lines no wider than width runes, breaking on whitespace where -// possible. Words longer than width are broken mid-word on rune boundaries (never inside -// a UTF-8 sequence). Internal whitespace runs are preserved within a line; trailing -// whitespace at a wrap boundary is dropped. An empty input returns a single empty line. +// wordWrap wraps s into lines whose display width is no more than width terminal +// columns, breaking on whitespace where possible. Width is measured in display +// columns (wide characters count as 2, combining marks as 0), so the result fits +// the terminal even when the content contains CJK characters or emoji. Words wider +// than width are broken at rune boundaries (never inside a UTF-8 sequence). Internal +// whitespace runs are preserved within a line; trailing whitespace at a wrap boundary +// is dropped. An empty input returns a single empty line. func wordWrap(s string, width int) []string { - if width <= 0 || utf8.RuneCountInString(s) <= width { + if width <= 0 || runewidth.StringWidth(s) <= width { return []string{s} } runes := []rune(s) var lines []string var current []rune + currentWidth := 0 i := 0 for i < len(runes) { // Collect the next non-whitespace word. @@ -251,6 +258,7 @@ func wordWrap(s string, width int) []string { j++ } word := runes[i:j] + wordWidth := runewidth.StringWidth(string(word)) // Collect the whitespace that follows the word. k := j @@ -258,34 +266,49 @@ func wordWrap(s string, width int) []string { k++ } space := runes[j:k] + spaceWidth := runewidth.StringWidth(string(space)) i = k - // Place the word, breaking it across lines if it is itself longer than width. - if len(word) > width { + // Place the word, breaking it across lines if it is itself wider than width. + if wordWidth > width { if len(current) > 0 { lines = append(lines, string(trimTrailingSpace(current))) current = current[:0] + currentWidth = 0 } - for len(word) > width { - lines = append(lines, string(word[:width])) - word = word[width:] + // Walk the word and emit chunks of <= width display columns. + chunkStart := 0 + chunkWidth := 0 + for ci, r := range word { + rw := runewidth.RuneWidth(r) + if chunkWidth+rw > width && ci > chunkStart { + lines = append(lines, string(word[chunkStart:ci])) + chunkStart = ci + chunkWidth = 0 + } + chunkWidth += rw } + current = append(current, word[chunkStart:]...) + currentWidth = chunkWidth + } else if currentWidth+wordWidth <= width { current = append(current, word...) - } else if len(current)+len(word) <= width { - current = append(current, word...) + currentWidth += wordWidth } else { lines = append(lines, string(trimTrailingSpace(current))) current = append(current[:0], word...) + currentWidth = wordWidth } // Append the trailing whitespace if it still fits on the current line; otherwise // drop it at the wrap boundary so we don't emit lines with trailing spaces. if len(space) > 0 { - if len(current)+len(space) <= width { + if currentWidth+spaceWidth <= width { current = append(current, space...) + currentWidth += spaceWidth } else { lines = append(lines, string(trimTrailingSpace(current))) current = current[:0] + currentWidth = 0 } } } diff --git a/pkg/cli/output/table_test.go b/pkg/cli/output/table_test.go index 83fca7af9f..865aed1eb9 100644 --- a/pkg/cli/output/table_test.go +++ b/pkg/cli/output/table_test.go @@ -22,6 +22,7 @@ import ( "testing" "unicode/utf8" + "github.com/mattn/go-runewidth" "github.com/stretchr/testify/require" ) @@ -247,12 +248,13 @@ func Test_Table_AlignsAndWrapsUnicode(t *testing.T) { err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 40) require.NoError(t, err) - // First column slot is max(len("NAME")+pad, minWidth) = 10. Last column has 30 runes - // available. The wrap should occur on whitespace boundaries and never produce invalid - // UTF-8. + // First column slot is max(len("NAME")+pad, minWidth) = 10. Last column has 30 + // display columns available. Width is measured in terminal display columns, so each + // 🚀 counts as 2 columns. The wrap should occur on whitespace boundaries and never + // produce invalid UTF-8. expected := "NAME DESCRIPTION\n" + - "café naïve façade — emoji 🚀🚀🚀 and\n" + - " more text that should wrap\n" + + "café naïve façade — emoji 🚀🚀🚀\n" + + " and more text that should wrap\n" + " nicely\n" require.Equal(t, expected, buffer.String()) require.True(t, utf8.ValidString(buffer.String()), "output must be valid UTF-8") @@ -277,6 +279,40 @@ func Test_Table_PreservesInternalWhitespace(t *testing.T) { require.Equal(t, expected, buffer.String()) } +// Test_Table_WrapsWideCharactersByDisplayWidth verifies that columns and wrapping use +// terminal display width rather than rune count: wide characters (e.g. CJK ideographs) +// occupy two columns, so the column slot must grow accordingly and wrapping must occur +// before content exceeds the terminal width. +func Test_Table_WrapsWideCharactersByDisplayWidth(t *testing.T) { + obj := []any{ + wrappableInput{ + // Each CJK ideograph is two columns wide. "日本語" is 6 columns of display, + // while it is only 3 runes. NAME slot must size to display width. + Name: "日本語", + Description: "これは日本語の説明文です。長い説明文は端末の幅で折り返されます。", + }, + } + + formatter := &TableFormatter{} + buffer := &bytes.Buffer{} + err := formatter.formatWithWidth(obj, buffer, wrappableInputOptions, 40) + require.NoError(t, err) + + // "日本語" has display width 6. Slot = max(6+2, len("NAME")+2, minWidth) = 10. + // Remaining width for the description = 40 - 10 = 30 display columns. + // Each CJK char is 2 wide; the description has no spaces, so it must be split + // mid-string on rune boundaries at display-column boundaries. + got := buffer.String() + require.True(t, utf8.ValidString(got), "output must be valid UTF-8") + + // Verify that no physical line exceeds the terminal width when measured in + // display columns. This is the contract the reviewer asked us to enforce. + for _, line := range strings.Split(strings.TrimRight(got, "\n"), "\n") { + require.LessOrEqual(t, runewidth.StringWidth(line), 40, + "line %q exceeds terminal width", line) + } +} + func Test_convertToStruct(t *testing.T) { aStruct := tableInput{ Size: "medium", From 466dac2ccf72d0b7befbd29697dc22a43a40dee0 Mon Sep 17 00:00:00 2001 From: Zach Casper Date: Mon, 18 May 2026 14:56:45 -0500 Subject: [PATCH 3/3] Drop ineffectual currentWidth reset in wordWrap Fixes golangci-lint ineffassign: currentWidth is reassigned a few lines later from the long-word chunking loop, so the intermediate reset to 0 was dead. Signed-off-by: Zach Casper --- pkg/cli/output/table.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cli/output/table.go b/pkg/cli/output/table.go index dfcd5f5706..f2e4e9c0ec 100644 --- a/pkg/cli/output/table.go +++ b/pkg/cli/output/table.go @@ -274,7 +274,6 @@ func wordWrap(s string, width int) []string { if len(current) > 0 { lines = append(lines, string(trimTrailingSpace(current))) current = current[:0] - currentWidth = 0 } // Walk the word and emit chunks of <= width display columns. chunkStart := 0