diff --git a/go.mod b/go.mod index 4f11508fe8..bbd07e2dc4 100644 --- a/go.mod +++ b/go.mod @@ -119,6 +119,11 @@ require ( sigs.k8s.io/yaml v1.6.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 cloud.google.com/go v0.123.0 // indirect @@ -251,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 @@ -319,7 +323,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..f2e4e9c0ec 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" + "github.com/mattn/go-runewidth" + "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,237 @@ 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 := 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 := runewidth.StringWidth(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 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 := runewidth.StringWidth(s) + if n >= width { + return s + } + return s + strings.Repeat(" ", width-n) +} + +// 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 || 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. + j := i + for j < len(runes) && !unicode.IsSpace(runes[j]) { + j++ + } + word := runes[i:j] + wordWidth := runewidth.StringWidth(string(word)) + + // Collect the whitespace that follows the word. + k := j + for k < len(runes) && unicode.IsSpace(runes[k]) { + k++ + } + space := runes[j:k] + spaceWidth := runewidth.StringWidth(string(space)) + i = k + + // 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] + } + // 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...) + 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 currentWidth+spaceWidth <= width { + current = append(current, space...) + currentWidth += spaceWidth + } else { + lines = append(lines, string(trimTrailingSpace(current))) + current = current[:0] + currentWidth = 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..865aed1eb9 100644 --- a/pkg/cli/output/table_test.go +++ b/pkg/cli/output/table_test.go @@ -20,7 +20,9 @@ import ( "bytes" "strings" "testing" + "unicode/utf8" + "github.com/mattn/go-runewidth" "github.com/stretchr/testify/require" ) @@ -145,6 +147,172 @@ 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 + // 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 🚀🚀🚀\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") +} + +// 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()) +} + +// 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",