From 84b39e45e55ed89f7d0e1ae8c184c3a8528ee037 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 22:37:21 +0100 Subject: [PATCH 1/9] feat: add Go table output compatible with the legacy CLI Add internal/table, a Go equivalent of the legacy Table service, as a building block for moving list/info commands to Go. - AddFlags registers --format, --columns/-c and --no-header with the same descriptions as the legacy CLI. - Column selection supports repeated or comma/whitespace-separated values, % and * wildcards, and "+" for the default columns, with the same "Column not found" and "Invalid format" errors. - The csv, tsv and plain formats match the legacy output (checked against the PHP Table class), including quoting and LF line breaks. - The table format uses the Symfony default border style, without the legacy column wrapping. - RenderProperties covers the legacy renderSimple (Property/Value). Value formatting (PropertyFormatter, --date-fmt) is left to callers. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 + go.mod | 2 +- internal/table/render.go | 116 +++++++++++++ internal/table/table.go | 319 +++++++++++++++++++++++++++++++++++ internal/table/table_test.go | 172 +++++++++++++++++++ 5 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 internal/table/render.go create mode 100644 internal/table/table.go create mode 100644 internal/table/table_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 877ee04f..d22c2e4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,10 @@ The CLI operates as a wrapper around a legacy PHP CLI: - JWT handling and OAuth2 flow - Custom transport for API authentication +**Table Output**: `internal/table/` +- Renders tables with the same `--format`, `--columns` and `--no-header` options as the legacy CLI's `Table` service +- The `csv`, `tsv` and `plain` formats match the legacy output; the `table` format has no column wrapping + **Project Initialization**: `internal/init/` - AI-powered project configuration generation - Integrates with whatsun library for codebase analysis diff --git a/go.mod b/go.mod index c398f9e7..6622c23b 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-chi/chi/v5 v5.3.2 github.com/go-playground/validator/v10 v10.30.5 github.com/gofrs/flock v0.13.1 + github.com/mattn/go-runewidth v0.0.23 github.com/oklog/ulid/v2 v2.1.2 github.com/platformsh/platformify v0.5.0 github.com/spf13/cobra v1.10.2 @@ -92,7 +93,6 @@ require ( github.com/mailru/easyjson v0.9.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mholt/archives v0.1.5 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect diff --git a/internal/table/render.go b/internal/table/render.go new file mode 100644 index 00000000..ef6765dc --- /dev/null +++ b/internal/table/render.go @@ -0,0 +1,116 @@ +package table + +import ( + "io" + "regexp" + "strings" + + "github.com/mattn/go-runewidth" +) + +// Line breaks matched by PCRE's \R. +var lineBreakRegex = regexp.MustCompile("\r\n|[\n\v\f\r\u0085

]") + +// csvCell returns a function formatting a cell like the legacy CLI's Csv class, with LF line breaks. +func csvCell(delimiter string) func(string) string { + return func(cell string) string { + if strings.ContainsAny(cell, `"`+"\n"+delimiter) { + cell = `"` + strings.ReplaceAll(cell, `"`, `""`) + `"` + } + return lineBreakRegex.ReplaceAllString(cell, "\n") + } +} + +var plainReplaceRegex = regexp.MustCompile(`[\r\n\t]+`) + +// plainCell formats a cell for the plain format, replacing newlines and tabs with a space. +func plainCell(cell string) string { + return plainReplaceRegex.ReplaceAllString(cell, " ") +} + +func renderDelimited( + w io.Writer, header []string, rows [][]string, delimiter string, formatCell func(string) string, +) error { + if header != nil { + rows = append([][]string{header}, rows...) + } + var b strings.Builder + for _, row := range rows { + for i, cell := range row { + if i > 0 { + b.WriteString(delimiter) + } + b.WriteString(formatCell(cell)) + } + b.WriteString("\n") + } + _, err := io.WriteString(w, b.String()) + return err +} + +// renderTable writes a table with borders, in the default style of Symfony Console tables. +func renderTable(w io.Writer, header []string, rows [][]string) error { + all := rows + if header != nil { + all = append([][]string{header}, rows...) + } + if len(all) == 0 || len(all[0]) == 0 { + return nil + } + + // Split each cell into lines, and find the width of each column. + widths := make([]int, len(all[0])) + split := make([][][]string, len(all)) + for i, row := range all { + split[i] = make([][]string, len(row)) + for j, cell := range row { + lines := strings.Split(strings.ReplaceAll(cell, "\r\n", "\n"), "\n") + split[i][j] = lines + for _, line := range lines { + widths[j] = max(widths[j], runewidth.StringWidth(line)) + } + } + } + + var b strings.Builder + separator := func() { + b.WriteString("+") + for _, width := range widths { + b.WriteString(strings.Repeat("-", width+2) + "+") + } + b.WriteString("\n") + } + writeRow := func(cells [][]string) { + height := 0 + for _, lines := range cells { + height = max(height, len(lines)) + } + for l := range height { + b.WriteString("|") + for j, lines := range cells { + var line string + if l < len(lines) { + line = lines[l] + } + b.WriteString(" " + line + strings.Repeat(" ", widths[j]-runewidth.StringWidth(line)) + " |") + } + b.WriteString("\n") + } + } + + separator() + if header != nil { + writeRow(split[0]) + separator() + split = split[1:] + } + for _, cells := range split { + writeRow(cells) + } + if len(split) > 0 { + separator() + } + + _, err := io.WriteString(w, b.String()) + return err +} diff --git a/internal/table/table.go b/internal/table/table.go new file mode 100644 index 00000000..21429c00 --- /dev/null +++ b/internal/table/table.go @@ -0,0 +1,319 @@ +// Package table renders tabular output with the legacy CLI's --format, --columns and --no-header options. +package table + +import ( + "fmt" + "io" + "regexp" + "slices" + "sort" + "strings" + + "github.com/spf13/pflag" +) + +// Output formats. +const ( + FormatTable = "table" + FormatCSV = "csv" + FormatTSV = "tsv" + FormatPlain = "plain" +) + +// Column describes a table column. +type Column struct { + // Header is the column's heading. + Header string + // Name identifies the column in --columns (case-insensitively). It defaults to Header. + Name string +} + +// Table describes the columns of a table. +type Table struct { + Columns []Column + // DefaultColumns lists the names of the columns displayed by default. If empty, all columns are displayed. + DefaultColumns []string +} + +// Options holds the user's output options. +type Options struct { + Format string + Columns []string + NoHeader bool +} + +// AddFlags adds the --format, --columns and --no-header flags. +func (t *Table) AddFlags(fs *pflag.FlagSet) { + fs.String("format", FormatTable, "The output format: table, csv, tsv, or plain") + + desc := "Columns to display." + if len(t.Columns) > 0 { + if len(t.DefaultColumns) > 0 { + desc += "\nAvailable columns: " + formatAvailableColumns(t.names(), lowerAll(t.DefaultColumns)) + + " (* = default columns)." + desc += "\n" + `The character "+" can be used as a placeholder for the default columns.` + } else { + desc += "\nAvailable columns: " + formatAvailableColumns(t.names(), nil) + "." + } + } + desc += "\nThe % or * characters may be used as a wildcard." + + "\n" + `Values may be split by commas (e.g. "a,b,c") and/or whitespace.` + shorthand := "c" + if fs.ShorthandLookup(shorthand) != nil { + shorthand = "" + } + fs.StringArrayP("columns", shorthand, nil, desc) + + fs.Bool("no-header", false, "Do not output the table header") +} + +// OptionsFromFlags reads the options added by AddFlags. +func OptionsFromFlags(fs *pflag.FlagSet) (Options, error) { + var ( + opts Options + err error + ) + if opts.Format, err = fs.GetString("format"); err != nil { + return opts, err + } + if opts.Columns, err = fs.GetStringArray("columns"); err != nil { + return opts, err + } + if opts.NoHeader, err = fs.GetBool("no-header"); err != nil { + return opts, err + } + return opts, nil +} + +// IsMachineReadable returns whether the format is intended for machines (csv, tsv or plain). +func (o Options) IsMachineReadable() bool { + switch strings.ToLower(o.Format) { + case FormatCSV, FormatTSV, FormatPlain: + return true + } + return false +} + +// Render writes rows to w. Each row's cells are in the same order as t.Columns; missing cells are empty. +func (t *Table) Render(w io.Writer, rows [][]string, opts Options) error { + toDisplay, err := t.columnsToDisplay(opts.Columns) + if err != nil { + return err + } + + indexes := make(map[string]int, len(t.Columns)) + for i, name := range t.names() { + if _, ok := indexes[name]; !ok { + indexes[name] = i + } + } + filter := func(row []string) []string { + filtered := make([]string, len(toDisplay)) + for i, name := range toDisplay { + if j := indexes[name]; j < len(row) { + filtered[i] = row[j] + } + } + return filtered + } + + filteredRows := make([][]string, len(rows)) + for i, row := range rows { + filteredRows[i] = filter(row) + } + var header []string + if !opts.NoHeader { + headers := make([]string, len(t.Columns)) + for i, c := range t.Columns { + headers[i] = c.Header + } + header = filter(headers) + } + + switch format := strings.ToLower(opts.Format); format { + case FormatCSV: + return renderDelimited(w, header, filteredRows, ",", csvCell(",")) + case FormatTSV: + return renderDelimited(w, header, filteredRows, "\t", csvCell("\t")) + case FormatPlain: + return renderDelimited(w, header, filteredRows, "\t", plainCell) + case "", FormatTable: + return renderTable(w, header, filteredRows) + default: + return fmt.Errorf(`Invalid format: "%s". Supported formats: table, csv, tsv, plain`, format) //nolint:staticcheck + } +} + +// RenderProperties writes a two-column table of property names and values. +func RenderProperties(w io.Writer, names, values []string, opts Options) error { + rows := make([][]string, len(names)) + for i, name := range names { + var value string + if i < len(values) { + value = values[i] + } + rows[i] = []string{name, value} + } + return PropertiesTable().Render(w, rows, opts) +} + +// PropertiesTable returns the table used by RenderProperties, e.g. to add its flags. +func PropertiesTable() *Table { + return &Table{Columns: []Column{{Header: "Property"}, {Header: "Value"}}} +} + +// names returns the lower-cased name of each column. +func (t *Table) names() []string { + names := make([]string, len(t.Columns)) + for i, c := range t.Columns { + name := c.Name + if name == "" { + name = c.Header + } + names[i] = strings.ToLower(name) + } + return names +} + +// columnsToDisplay returns the (lower-cased) names of the columns to display. +func (t *Table) columnsToDisplay(specified []string) ([]string, error) { + available := uniq(t.names()) + defaults := available + if len(t.DefaultColumns) > 0 { + defaults = lowerAll(t.DefaultColumns) + } + + specified = splitColumns(specified) + if len(specified) == 0 { + return defaults, nil + } + + var requested []string + for _, s := range specified { + if s == "+" { + requested = append(requested, defaults...) + } else { + requested = append(requested, strings.ToLower(s)) + } + } + + var toDisplay []string + for _, r := range requested { + matched := wildcardSelect(available, r) + if len(matched) == 0 { + return nil, fmt.Errorf("Column not found: %s (available columns: %s)", + r, formatAvailableColumns(available, nil)) + } + toDisplay = append(toDisplay, matched...) + } + + return uniq(toDisplay), nil +} + +var ( + plusAfterRegex = regexp.MustCompile(`([\w%])\+`) + plusBeforeRegex = regexp.MustCompile(`\+([\w%])`) + splitRegex = regexp.MustCompile(`[,\s]+`) +) + +// splitColumns splits the --columns values by commas and whitespace, and separates a "+" from adjacent names. +func splitColumns(values []string) []string { + if len(values) == 1 && strings.Contains(values[0], "+") { + v := plusAfterRegex.ReplaceAllString(values[0], "$1,+") + v = plusBeforeRegex.ReplaceAllString(v, "+,$1") + values = []string{v} + } + var split []string + for _, v := range values { + for _, s := range splitRegex.Split(v, -1) { + if s != "" { + split = append(split, s) + } + } + } + return split +} + +// wildcardSelect returns the subjects matching a pattern, in which "%" or "*" match any characters. +func wildcardSelect(subjects []string, wildcard string) []string { + pattern := strings.NewReplacer("%", ".*", `\*`, ".*").Replace(regexp.QuoteMeta(wildcard)) + re := regexp.MustCompile("^" + pattern + "$") + var found []string + for _, s := range subjects { + if re.MatchString(s) { + found = append(found, s) + } + } + return found +} + +// formatAvailableColumns lists column names, with any defaults first (marked with "*"), then the rest sorted. +func formatAvailableColumns(names, defaults []string) string { + rest := uniq(names) + sort.SliceStable(rest, func(i, j int) bool { return naturalLess(rest[i], rest[j]) }) + if len(defaults) == 0 { + return strings.Join(rest, ", ") + } + rest = slices.DeleteFunc(rest, func(n string) bool { return slices.Contains(defaults, n) }) + list := make([]string, 0, len(defaults)+len(rest)) + for _, d := range defaults { + list = append(list, d+"*") + } + return strings.Join(append(list, rest...), ", ") +} + +// naturalLess compares strings case-insensitively, treating runs of digits as numbers. +func naturalLess(a, b string) bool { + a, b = strings.ToLower(a), strings.ToLower(b) + for a != "" && b != "" { + if isDigit(a[0]) && isDigit(b[0]) { + na, ra := leadingDigits(a) + nb, rb := leadingDigits(b) + na, nb = strings.TrimLeft(na, "0"), strings.TrimLeft(nb, "0") + if len(na) != len(nb) { + return len(na) < len(nb) + } + if na != nb { + return na < nb + } + a, b = ra, rb + continue + } + if a[0] != b[0] { + return a[0] < b[0] + } + a, b = a[1:], b[1:] + } + return len(a) < len(b) +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func leadingDigits(s string) (digits, rest string) { + i := 0 + for i < len(s) && isDigit(s[i]) { + i++ + } + return s[:i], s[i:] +} + +func lowerAll(s []string) []string { + lowered := make([]string, len(s)) + for i, v := range s { + lowered[i] = strings.ToLower(v) + } + return lowered +} + +// uniq returns a copy of s without duplicates, keeping the first occurrence of each. +func uniq(s []string) []string { + seen := make(map[string]struct{}, len(s)) + out := make([]string, 0, len(s)) + for _, v := range s { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + out = append(out, v) + } + } + return out +} diff --git a/internal/table/table_test.go b/internal/table/table_test.go new file mode 100644 index 00000000..cc18294e --- /dev/null +++ b/internal/table/table_test.go @@ -0,0 +1,172 @@ +package table + +import ( + "bytes" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testTable = &Table{ + Columns: []Column{ + {Header: "ID"}, + {Header: "Title"}, + {Header: "Created", Name: "created_at"}, + {Header: "Status"}, + {Header: "Region 10"}, + {Header: "Region 9"}, + }, + DefaultColumns: []string{"id", "Title", "status"}, +} + +var testRows = [][]string{ + {"1", "Foo", "2026-01-01", "active", "a", "b"}, + {"2", "Bar, \"Baz\"", "2026-01-02", "inactive"}, + {"3", "Multi\nline\ttab", "2026-01-03", "active", "c", "d"}, +} + +func TestRender(t *testing.T) { + cases := []struct { + name string + opts Options + want string + wantErr string + }{ + { + name: "table defaults", + opts: Options{}, + want: `+----+------------+----------+ +| ID | Title | Status | ++----+------------+----------+ +| 1 | Foo | active | +| 2 | Bar, "Baz" | inactive | +| 3 | Multi | active | +| | line tab | | ++----+------------+----------+ +`, + }, + { + name: "csv", + opts: Options{Format: "CSV"}, + want: `ID,Title,Status +1,Foo,active +2,"Bar, ""Baz""",inactive +3,"Multi +line tab",active +`, + }, + { + name: "tsv", + opts: Options{Format: "tsv"}, + want: "ID\tTitle\tStatus\n1\tFoo\tactive\n2\t\"Bar, \"\"Baz\"\"\"\tinactive\n3\t\"Multi\nline\ttab\"\tactive\n", + }, + { + name: "plain without header", + opts: Options{Format: "plain", NoHeader: true}, + want: "1\tFoo\tactive\n2\tBar, \"Baz\"\tinactive\n3\tMulti line tab\tactive\n", + }, + { + name: "columns with wildcard and plus", + opts: Options{Format: "csv", Columns: []string{"created%+"}}, + want: "Created,ID,Title,Status\n2026-01-01,1,Foo,active\n2026-01-02,2,\"Bar, \"\"Baz\"\"\",inactive\n" + + "2026-01-03,3,\"Multi\nline\ttab\",active\n", + }, + { + name: "repeated columns split by commas and spaces", + opts: Options{Format: "plain", Columns: []string{"id, region%", "ID status"}}, + want: "ID\tRegion 10\tRegion 9\tStatus\n1\ta\tb\tactive\n2\t\t\tinactive\n3\tc\td\tactive\n", + }, + { + name: "unknown column", + opts: Options{Columns: []string{"id,Foo"}}, + wantErr: "Column not found: foo (available columns: created_at, id, region 9, region 10, status, title)", + }, + { + name: "invalid format", + opts: Options{Format: "JSON"}, + wantErr: `Invalid format: "json". Supported formats: table, csv, tsv, plain`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b bytes.Buffer + err := testTable.Render(&b, testRows, c.opts) + if c.wantErr != "" { + assert.EqualError(t, err, c.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, c.want, b.String()) + }) + } +} + +func TestRenderTableEdgeCases(t *testing.T) { + tbl := &Table{Columns: []Column{{Header: "Name"}, {Header: "Value"}}} + cases := []struct { + name string + rows [][]string + opts Options + want string + }{ + { + name: "no rows", + want: "+------+-------+\n| Name | Value |\n+------+-------+\n", + }, + { + name: "no header", + rows: [][]string{{"日本", "x"}}, + opts: Options{NoHeader: true}, + want: "+------+---+\n| 日本 | x |\n+------+---+\n", + }, + { + name: "no header or rows", + opts: Options{NoHeader: true}, + want: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b bytes.Buffer + require.NoError(t, tbl.Render(&b, c.rows, c.opts)) + assert.Equal(t, c.want, b.String()) + }) + } +} + +func TestRenderProperties(t *testing.T) { + var b bytes.Buffer + err := RenderProperties(&b, []string{"id", "email"}, []string{"123", "a@example.com"}, + Options{Format: "csv", Columns: []string{"value"}}) + require.NoError(t, err) + assert.Equal(t, "Value\n123\na@example.com\n", b.String()) +} + +func TestFlags(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + testTable.AddFlags(fs) + + columns := fs.Lookup("columns") + require.NotNil(t, columns) + assert.Equal(t, "c", columns.Shorthand) + assert.Equal(t, `Columns to display. +Available columns: id*, title*, status*, created_at, region 9, region 10 (* = default columns). +The character "+" can be used as a placeholder for the default columns. +The % or * characters may be used as a wildcard. +Values may be split by commas (e.g. "a,b,c") and/or whitespace.`, columns.Usage) + + require.NoError(t, fs.Parse([]string{"--format", "csv", "-c", "id", "--columns", "title", "--no-header"})) + opts, err := OptionsFromFlags(fs) + require.NoError(t, err) + assert.Equal(t, Options{Format: "csv", Columns: []string{"id", "title"}, NoHeader: true}, opts) + assert.True(t, opts.IsMachineReadable()) + + // The -c shorthand is not used if it is taken. + fs = pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.BoolP("cool", "c", false, "") + PropertiesTable().AddFlags(fs) + assert.Empty(t, fs.Lookup("columns").Shorthand) + assert.Contains(t, fs.Lookup("columns").Usage, "Available columns: property, value.") +} From b87bcbefbbc4fb3ab5d45dddb6101eecaa4c3ea7 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 22:38:42 +0100 Subject: [PATCH 2/9] fix(table): replace tabs in table cells to keep columns aligned Co-Authored-By: Claude Opus 5.5 --- internal/table/render.go | 4 +++- internal/table/table_test.go | 16 ++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/table/render.go b/internal/table/render.go index ef6765dc..532512cd 100644 --- a/internal/table/render.go +++ b/internal/table/render.go @@ -64,7 +64,9 @@ func renderTable(w io.Writer, header []string, rows [][]string) error { for i, row := range all { split[i] = make([][]string, len(row)) for j, cell := range row { - lines := strings.Split(strings.ReplaceAll(cell, "\r\n", "\n"), "\n") + // Tabs are replaced, as their display width depends on their position. + cell = strings.ReplaceAll(strings.ReplaceAll(cell, "\r\n", "\n"), "\t", " ") + lines := strings.Split(cell, "\n") split[i][j] = lines for _, line := range lines { widths[j] = max(widths[j], runewidth.StringWidth(line)) diff --git a/internal/table/table_test.go b/internal/table/table_test.go index cc18294e..679a2e76 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -37,14 +37,14 @@ func TestRender(t *testing.T) { { name: "table defaults", opts: Options{}, - want: `+----+------------+----------+ -| ID | Title | Status | -+----+------------+----------+ -| 1 | Foo | active | -| 2 | Bar, "Baz" | inactive | -| 3 | Multi | active | -| | line tab | | -+----+------------+----------+ + want: `+----+-------------+----------+ +| ID | Title | Status | ++----+-------------+----------+ +| 1 | Foo | active | +| 2 | Bar, "Baz" | inactive | +| 3 | Multi | active | +| | line tab | | ++----+-------------+----------+ `, }, { From cff185f30282bb026ffcb6945fe45ec3dbe273fa Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 22:49:24 +0100 Subject: [PATCH 3/9] feat(table): wrap table cells to the terminal width and support ANSI styles Port the legacy AdaptiveTable column-width algorithm: columns get a share of the terminal width in proportion to their natural width, but never less than 10 characters (or their own width). Headers and columns marked NoWrap are not wrapped. The width comes from $COLUMNS, then the first standard stream that is a terminal, then 80. Measure and wrap cells with charmbracelet/x/ansi (replacing go-runewidth), so ANSI-styled text is aligned correctly. A style that is still active at the end of a cell's line is reset there and re-applied on the next line, so it does not leak into the borders or other cells. The csv, tsv and plain formats strip ANSI sequences. Wrapping matches the legacy output except that ansi.Wrap also breaks after hyphens and drops leading spaces on wrapped lines. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- go.mod | 4 +- internal/table/adaptive.go | 123 ++++++++++++++++++++++++++++++++++ internal/table/render.go | 43 ++++++++---- internal/table/table.go | 14 +++- internal/table/table_test.go | 124 +++++++++++++++++++++++++++++++++++ 6 files changed, 296 insertions(+), 15 deletions(-) create mode 100644 internal/table/adaptive.go diff --git a/CLAUDE.md b/CLAUDE.md index d22c2e4a..cfb3363d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,8 @@ The CLI operates as a wrapper around a legacy PHP CLI: **Table Output**: `internal/table/` - Renders tables with the same `--format`, `--columns` and `--no-header` options as the legacy CLI's `Table` service -- The `csv`, `tsv` and `plain` formats match the legacy output; the `table` format has no column wrapping +- The `csv`, `tsv` and `plain` formats match the legacy output, with ANSI escape sequences removed +- The `table` format wraps cells to the terminal width (a port of the legacy `AdaptiveTable`), and supports ANSI-styled cells **Project Initialization**: `internal/init/` - AI-powered project configuration generation diff --git a/go.mod b/go.mod index 6622c23b..83a59593 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,11 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/x/ansi v0.11.7 github.com/fatih/color v1.19.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-playground/validator/v10 v10.30.5 github.com/gofrs/flock v0.13.1 - github.com/mattn/go-runewidth v0.0.23 github.com/oklog/ulid/v2 v2.1.2 github.com/platformsh/platformify v0.5.0 github.com/spf13/cobra v1.10.2 @@ -51,7 +51,6 @@ require ( github.com/buger/jsonparser v1.1.2 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect @@ -93,6 +92,7 @@ require ( github.com/mailru/easyjson v0.9.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mholt/archives v0.1.5 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go new file mode 100644 index 00000000..f74fcb2e --- /dev/null +++ b/internal/table/adaptive.go @@ -0,0 +1,123 @@ +package table + +import ( + "math" + "os" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/charmbracelet/x/ansi" + "golang.org/x/term" +) + +// minColumnWidth is the width below which columns are not wrapped. +const minColumnWidth = 10 + +// terminalWidth returns the width of the terminal, from $COLUMNS or the first standard stream that is a terminal. +func terminalWidth() int { + if w, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && w > 0 { + return w + } + for _, f := range []*os.File{os.Stdout, os.Stderr, os.Stdin} { + if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 { + return w + } + } + return 80 +} + +// maxColumnWidths finds the maximum width of each column's content, so that the table fits in maxWidth. +// It is a port of the legacy CLI's AdaptiveTable::getMaxColumnWidths(). +// The rows before bodyStart are header rows, which are not wrapped. +func maxColumnWidths(rows [][][]string, bodyStart int, noWrap []bool, maxWidth int) []int { + count := len(rows[0]) + originalWidths := make([]int, count) + minWidths := make([]int, count) + for i, row := range rows { + for j, lines := range row { + w := cellWidth(lines) + originalWidths[j] = max(originalWidths[j], w) + minCellWidth := minColumnWidth + if w < minColumnWidth || noWrap[j] || i < bodyStart { + minCellWidth = w + } + minWidths[j] = max(minWidths[j], minCellWidth) + } + } + + // Distribute the available width between columns in proportion to their original widths, + // starting with the narrowest. + maxContentWidth := float64(maxWidth - (count + 1) - count*2) + totalWidth := 0 + for _, w := range originalWidths { + totalWidth += w + } + order := make([]int, count) + for j := range order { + order[j] = j + } + slices.SortStableFunc(order, func(a, b int) int { return originalWidths[a] - originalWidths[b] }) + + widths := make([]int, count) + for _, j := range order { + var w int + if totalWidth > 0 { + w = int(math.Round(maxContentWidth / float64(totalWidth) * float64(originalWidths[j]))) + } + w = max(w, minWidths[j]) + widths[j] = w + totalWidth -= originalWidths[j] + maxContentWidth -= float64(w) + } + return widths +} + +// cellWidth returns the display width of the longest line in a cell. +func cellWidth(lines []string) int { + w := 0 + for _, line := range lines { + w = max(w, ansi.StringWidth(line)) + } + return w +} + +// wrapCell word-wraps a cell's lines to fit the width, keeping any left indentation. +func wrapCell(lines []string, width int) []string { + contents := strings.Join(lines, "\n") + trimmed := strings.TrimLeft(contents, " ") + indent := contents[:len(contents)-len(trimmed)] + wrapped := strings.Split(ansi.Wrap(trimmed, max(width-len(indent), 1), " "), "\n") + if indent != "" { + for i, line := range wrapped { + wrapped[i] = indent + line + } + } + return wrapped +} + +var sgrRegex = regexp.MustCompile(`\x1b\[([0-9;:]*)m`) + +// isolateStyles ends each line with a reset if an ANSI style (SGR) is still active, and re-applies it on the next line. +// This stops styles from leaking into the table's borders and other cells. +func isolateStyles(lines []string) []string { + active := "" + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = active + line + for _, m := range sgrRegex.FindAllStringSubmatch(line, -1) { + params := m[1] + if params == "" || slices.Contains(strings.Split(params, ";"), "0") { + active = "" + } + if params != "" && params != "0" { + active += m[0] + } + } + if active != "" { + out[i] += "\x1b[0m" + } + } + return out +} diff --git a/internal/table/render.go b/internal/table/render.go index 532512cd..1d5d3fe6 100644 --- a/internal/table/render.go +++ b/internal/table/render.go @@ -5,15 +5,17 @@ import ( "regexp" "strings" - "github.com/mattn/go-runewidth" + "github.com/charmbracelet/x/ansi" ) // Line breaks matched by PCRE's \R. var lineBreakRegex = regexp.MustCompile("\r\n|[\n\v\f\r\u0085

]") // csvCell returns a function formatting a cell like the legacy CLI's Csv class, with LF line breaks. +// ANSI escape sequences are removed. func csvCell(delimiter string) func(string) string { return func(cell string) string { + cell = ansi.Strip(cell) if strings.ContainsAny(cell, `"`+"\n"+delimiter) { cell = `"` + strings.ReplaceAll(cell, `"`, `""`) + `"` } @@ -23,9 +25,9 @@ func csvCell(delimiter string) func(string) string { var plainReplaceRegex = regexp.MustCompile(`[\r\n\t]+`) -// plainCell formats a cell for the plain format, replacing newlines and tabs with a space. +// plainCell formats a cell for the plain format, removing ANSI escape sequences and replacing newlines and tabs. func plainCell(cell string) string { - return plainReplaceRegex.ReplaceAllString(cell, " ") + return plainReplaceRegex.ReplaceAllString(ansi.Strip(cell), " ") } func renderDelimited( @@ -49,7 +51,8 @@ func renderDelimited( } // renderTable writes a table with borders, in the default style of Symfony Console tables. -func renderTable(w io.Writer, header []string, rows [][]string) error { +// Cells are wrapped so that the table fits in maxWidth, unless their column is in noWrap. +func renderTable(w io.Writer, header []string, rows [][]string, noWrap []bool, maxWidth int) error { all := rows if header != nil { all = append([][]string{header}, rows...) @@ -58,22 +61,40 @@ func renderTable(w io.Writer, header []string, rows [][]string) error { return nil } - // Split each cell into lines, and find the width of each column. - widths := make([]int, len(all[0])) + // Split each cell into lines. split := make([][][]string, len(all)) for i, row := range all { split[i] = make([][]string, len(row)) for j, cell := range row { // Tabs are replaced, as their display width depends on their position. cell = strings.ReplaceAll(strings.ReplaceAll(cell, "\r\n", "\n"), "\t", " ") - lines := strings.Split(cell, "\n") - split[i][j] = lines - for _, line := range lines { - widths[j] = max(widths[j], runewidth.StringWidth(line)) + split[i][j] = strings.Split(cell, "\n") + } + } + + // Wrap the rows (not the header) to fit the maximum width. + bodyStart := 0 + if header != nil { + bodyStart = 1 + } + maxColumnWidths := maxColumnWidths(split, bodyStart, noWrap, maxWidth) + for _, row := range split[bodyStart:] { + for j, lines := range row { + if cellWidth(lines) > maxColumnWidths[j] { + row[j] = wrapCell(lines, maxColumnWidths[j]) } } } + // Find the width of each column, and stop styles from continuing to other cells. + widths := make([]int, len(all[0])) + for _, row := range split { + for j, lines := range row { + row[j] = isolateStyles(lines) + widths[j] = max(widths[j], cellWidth(lines)) + } + } + var b strings.Builder separator := func() { b.WriteString("+") @@ -94,7 +115,7 @@ func renderTable(w io.Writer, header []string, rows [][]string) error { if l < len(lines) { line = lines[l] } - b.WriteString(" " + line + strings.Repeat(" ", widths[j]-runewidth.StringWidth(line)) + " |") + b.WriteString(" " + line + strings.Repeat(" ", widths[j]-ansi.StringWidth(line)) + " |") } b.WriteString("\n") } diff --git a/internal/table/table.go b/internal/table/table.go index 21429c00..b2c8ad76 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -26,6 +26,8 @@ type Column struct { Header string // Name identifies the column in --columns (case-insensitively). It defaults to Header. Name string + // NoWrap stops the column's cells from being wrapped in the table format. + NoWrap bool } // Table describes the columns of a table. @@ -33,6 +35,8 @@ type Table struct { Columns []Column // DefaultColumns lists the names of the columns displayed by default. If empty, all columns are displayed. DefaultColumns []string + // MaxWidth is the width that the table format wraps cells to fit. It defaults to the terminal width. + MaxWidth int } // Options holds the user's output options. @@ -138,7 +142,15 @@ func (t *Table) Render(w io.Writer, rows [][]string, opts Options) error { case FormatPlain: return renderDelimited(w, header, filteredRows, "\t", plainCell) case "", FormatTable: - return renderTable(w, header, filteredRows) + noWrap := make([]bool, len(toDisplay)) + for i, name := range toDisplay { + noWrap[i] = t.Columns[indexes[name]].NoWrap + } + maxWidth := t.MaxWidth + if maxWidth <= 0 { + maxWidth = terminalWidth() + } + return renderTable(w, header, filteredRows, noWrap, maxWidth) default: return fmt.Errorf(`Invalid format: "%s". Supported formats: table, csv, tsv, plain`, format) //nolint:staticcheck } diff --git a/internal/table/table_test.go b/internal/table/table_test.go index 679a2e76..491cd322 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -28,6 +28,7 @@ var testRows = [][]string{ } func TestRender(t *testing.T) { + t.Setenv("COLUMNS", "80") cases := []struct { name string opts Options @@ -104,6 +105,7 @@ line tab",active } func TestRenderTableEdgeCases(t *testing.T) { + t.Setenv("COLUMNS", "80") tbl := &Table{Columns: []Column{{Header: "Name"}, {Header: "Value"}}} cases := []struct { name string @@ -170,3 +172,125 @@ Values may be split by commas (e.g. "a,b,c") and/or whitespace.`, columns.Usage) assert.Empty(t, fs.Lookup("columns").Shorthand) assert.Contains(t, fs.Lookup("columns").Usage, "Available columns: property, value.") } + +func TestRenderTableWrapping(t *testing.T) { + cases := []struct { + name string + columns []Column + rows [][]string + width int + want string + }{ + { + name: "wraps to fit", + columns: []Column{{Header: "ID"}, {Header: "Description"}}, + rows: [][]string{ + {"1", "The quick brown fox jumps over the lazy dog and keeps running far away"}, + {"2", "short"}, + }, + width: 40, + want: `+----+--------------------------------+ +| ID | Description | ++----+--------------------------------+ +| 1 | The quick brown fox jumps over | +| | the lazy dog and keeps running | +| | far away | +| 2 | short | ++----+--------------------------------+ +`, + }, + { + name: "long words and indentation", + columns: []Column{{Header: "K"}, {Header: "V"}}, + rows: [][]string{ + {"a", "Averyveryveryverylongwordwithoutspaces"}, + {"b", " indented text that wraps around the cell"}, + }, + width: 30, + want: `+---+------------------------+ +| K | V | ++---+------------------------+ +| a | Averyveryveryverylongw | +| | ordwithoutspaces | +| b | indented text that | +| | wraps around the | +| | cell | ++---+------------------------+ +`, + }, + { + name: "no-wrap columns and headers keep their width", + columns: []Column{{Header: "A long header"}, {Header: "URL", NoWrap: true}, {Header: "Notes"}}, + rows: [][]string{{"x", "https://example.com/long/path", "some notes that wrap"}}, + width: 40, + want: `+---------------+-------------------------------+------------+ +| A long header | URL | Notes | ++---------------+-------------------------------+------------+ +| x | https://example.com/long/path | some notes | +| | | that wrap | ++---------------+-------------------------------+------------+ +`, + }, + { + name: "wide characters", + columns: []Column{{Header: "K"}, {Header: "V"}}, + rows: [][]string{{"a", "日本語 日本語 日本語 日本語"}}, + width: 20, + want: `+---+--------+ +| K | V | ++---+--------+ +| a | 日本語 | +| | 日本語 | +| | 日本語 | +| | 日本語 | ++---+--------+ +`, + }, + { + name: "styles are closed at the end of each line and reopened", + columns: []Column{{Header: "K"}, {Header: "V"}}, + rows: [][]string{{"\x1b[1ma\x1b[0m", "\x1b[32mgreen text that wraps\x1b[0m plain"}}, + width: 20, + want: "+---+------------+\n" + + "| K | V |\n" + + "+---+------------+\n" + + "| \x1b[1ma\x1b[0m | \x1b[32mgreen text\x1b[0m |\n" + + "| | \x1b[32mthat wraps\x1b[0m |\n" + + "| | plain |\n" + + "+---+------------+\n", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b bytes.Buffer + tbl := &Table{Columns: c.columns, MaxWidth: c.width} + require.NoError(t, tbl.Render(&b, c.rows, Options{})) + assert.Equal(t, c.want, b.String()) + }) + } +} + +func TestRenderStripsANSIFromMachineFormats(t *testing.T) { + tbl := &Table{Columns: []Column{{Header: "\x1b[32mName\x1b[0m"}, {Header: "Value"}}} + rows := [][]string{{"\x1b[1ma,b\x1b[0m", "\x1b[33mx\x1b[0m"}} + cases := []struct { + format string + want string + }{ + {"csv", "Name,Value\n\"a,b\",x\n"}, + {"tsv", "Name\tValue\na,b\tx\n"}, + {"plain", "Name\tValue\na,b\tx\n"}, + } + for _, c := range cases { + t.Run(c.format, func(t *testing.T) { + var b bytes.Buffer + require.NoError(t, tbl.Render(&b, rows, Options{Format: c.format})) + assert.Equal(t, c.want, b.String()) + }) + } +} + +func TestTerminalWidth(t *testing.T) { + t.Setenv("COLUMNS", "123") + assert.Equal(t, 123, terminalWidth()) +} From a8d8b2b40d6f845b6e62f9f41510a91fe99028f8 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 23:51:44 +0100 Subject: [PATCH 4/9] fix(table): don't treat extended color components as SGR resets A zero in an extended color (e.g. 38;2;0;255;0) was taken as a reset, dropping other active styles on continuation lines. Co-Authored-By: Claude Opus 5.5 --- internal/table/adaptive.go | 23 ++++++++++++++++--- internal/table/table_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go index f74fcb2e..339bc27c 100644 --- a/internal/table/adaptive.go +++ b/internal/table/adaptive.go @@ -107,11 +107,10 @@ func isolateStyles(lines []string) []string { for i, line := range lines { out[i] = active + line for _, m := range sgrRegex.FindAllStringSubmatch(line, -1) { - params := m[1] - if params == "" || slices.Contains(strings.Split(params, ";"), "0") { + if sgrResets(m[1]) { active = "" } - if params != "" && params != "0" { + if m[1] != "" && m[1] != "0" { active += m[0] } } @@ -121,3 +120,21 @@ func isolateStyles(lines []string) []string { } return out } + +// sgrResets returns whether SGR parameters include a reset, skipping the arguments of extended colors. +func sgrResets(params string) bool { + parts := strings.Split(params, ";") + for i := 0; i < len(parts); i++ { + switch parts[i] { + case "", "0": + return true + case "38", "48", "58": + if i+1 < len(parts) && parts[i+1] == "5" { + i += 2 + } else if i+1 < len(parts) && parts[i+1] == "2" { + i += 4 + } + } + } + return false +} diff --git a/internal/table/table_test.go b/internal/table/table_test.go index 491cd322..b2fc978b 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -294,3 +294,46 @@ func TestTerminalWidth(t *testing.T) { t.Setenv("COLUMNS", "123") assert.Equal(t, 123, terminalWidth()) } + +func TestIsolateStyles(t *testing.T) { + cases := []struct { + name string + lines []string + want []string + }{ + { + name: "unstyled", + lines: []string{"a", "b"}, + want: []string{"a", "b"}, + }, + { + name: "reset within a line", + lines: []string{"\x1b[1ma\x1b[0m", "b"}, + want: []string{"\x1b[1ma\x1b[0m", "b"}, + }, + { + name: "style continues to the next line", + lines: []string{"\x1b[1ma", "b\x1b[m"}, + want: []string{"\x1b[1ma\x1b[0m", "\x1b[1mb\x1b[m"}, + }, + { + name: "extended colors with zero components are not resets", + lines: []string{"\x1b[1m\x1b[38;2;0;255;0ma", "\x1b[48;5;0mb", "c"}, + want: []string{ + "\x1b[1m\x1b[38;2;0;255;0ma\x1b[0m", + "\x1b[1m\x1b[38;2;0;255;0m\x1b[48;5;0mb\x1b[0m", + "\x1b[1m\x1b[38;2;0;255;0m\x1b[48;5;0mc\x1b[0m", + }, + }, + { + name: "reset combined with a new style", + lines: []string{"\x1b[1ma", "\x1b[0;32mb", "c"}, + want: []string{"\x1b[1ma\x1b[0m", "\x1b[1m\x1b[0;32mb\x1b[0m", "\x1b[0;32mc\x1b[0m"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, isolateStyles(c.lines)) + }) + } +} From bb421840129cfeba3c0c13a689273b0013999eea Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 2 Oct 2026 01:18:25 +0100 Subject: [PATCH 5/9] fix(table): match legacy column lookup and isolate hyperlinks in cells - A default column that matches no column now gives empty cells, as in the legacy CLI, instead of showing the first column's data. - A duplicate column name refers to the last column with that name, like the legacy availableColumns(). - OSC 8 hyperlinks still open at the end of a cell's line are closed there and re-opened on the next line, like SGR styles. Co-Authored-By: Claude Opus 5.5 --- internal/table/adaptive.go | 30 +++++++++++++++++------- internal/table/table.go | 11 +++++---- internal/table/table_test.go | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go index 339bc27c..93d39605 100644 --- a/internal/table/adaptive.go +++ b/internal/table/adaptive.go @@ -97,24 +97,38 @@ func wrapCell(lines []string, width int) []string { return wrapped } -var sgrRegex = regexp.MustCompile(`\x1b\[([0-9;:]*)m`) +var ( + sgrRegex = regexp.MustCompile(`\x1b\[([0-9;:]*)m`) + hyperlinkRegex = regexp.MustCompile(`\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)`) +) + +const hyperlinkClose = "\x1b]8;;\x1b\\" -// isolateStyles ends each line with a reset if an ANSI style (SGR) is still active, and re-applies it on the next line. -// This stops styles from leaking into the table's borders and other cells. +// isolateStyles closes any ANSI style (SGR) or hyperlink (OSC 8) still open at the end of each line, +// and re-opens it on the next line. This stops them from leaking into the table's borders and other cells. func isolateStyles(lines []string) []string { - active := "" + style, link := "", "" out := make([]string, len(lines)) for i, line := range lines { - out[i] = active + line + out[i] = style + link + line for _, m := range sgrRegex.FindAllStringSubmatch(line, -1) { if sgrResets(m[1]) { - active = "" + style = "" } if m[1] != "" && m[1] != "0" { - active += m[0] + style += m[0] } } - if active != "" { + for _, m := range hyperlinkRegex.FindAllStringSubmatch(line, -1) { + link = "" + if m[1] != "" { + link = m[0] + } + } + if link != "" { + out[i] += hyperlinkClose + } + if style != "" { out[i] += "\x1b[0m" } } diff --git a/internal/table/table.go b/internal/table/table.go index b2c8ad76..67947fce 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -105,16 +105,15 @@ func (t *Table) Render(w io.Writer, rows [][]string, opts Options) error { return err } + // Like the legacy CLI, a duplicate name refers to the last column with that name. indexes := make(map[string]int, len(t.Columns)) for i, name := range t.names() { - if _, ok := indexes[name]; !ok { - indexes[name] = i - } + indexes[name] = i } filter := func(row []string) []string { filtered := make([]string, len(toDisplay)) for i, name := range toDisplay { - if j := indexes[name]; j < len(row) { + if j, ok := indexes[name]; ok && j < len(row) { filtered[i] = row[j] } } @@ -144,7 +143,9 @@ func (t *Table) Render(w io.Writer, rows [][]string, opts Options) error { case "", FormatTable: noWrap := make([]bool, len(toDisplay)) for i, name := range toDisplay { - noWrap[i] = t.Columns[indexes[name]].NoWrap + if j, ok := indexes[name]; ok { + noWrap[i] = t.Columns[j].NoWrap + } } maxWidth := t.MaxWidth if maxWidth <= 0 { diff --git a/internal/table/table_test.go b/internal/table/table_test.go index b2fc978b..a3875ef2 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -337,3 +337,48 @@ func TestIsolateStyles(t *testing.T) { }) } } + +func TestRenderColumnLookup(t *testing.T) { + cases := []struct { + name string + tbl *Table + opts Options + want string + }{ + { + name: "unknown default column is empty", + tbl: &Table{Columns: []Column{{Header: "ID"}, {Header: "Name"}}, DefaultColumns: []string{"name", "status"}}, + opts: Options{Format: "csv"}, + want: "Name,\na,\n", + }, + { + name: "duplicate names select the last column", + tbl: &Table{Columns: []Column{{Header: "ID"}, {Header: "Name"}, {Header: "Other", Name: "id"}}}, + opts: Options{Format: "csv", Columns: []string{"id"}}, + want: "Other\nx\n", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b bytes.Buffer + require.NoError(t, c.tbl.Render(&b, [][]string{{"1", "a", "x"}}, c.opts)) + assert.Equal(t, c.want, b.String()) + }) + } +} + +func TestIsolateHyperlinks(t *testing.T) { + const ( + open = "\x1b]8;;https://example.com\x1b\\" + end = "\x1b]8;;\x1b\\" + ) + assert.Equal(t, + []string{ + "\x1b[1m" + open + "a" + end + "\x1b[0m", + "\x1b[1m" + open + "b" + end + "\x1b[0m", + "\x1b[1m" + open + "c" + end + "\x1b[0m", + }, + isolateStyles([]string{"\x1b[1m" + open + "a", "b", "c" + end + "\x1b[0m"}), + ) + assert.Equal(t, []string{open + "a" + end, "b"}, isolateStyles([]string{open + "a" + end, "b"})) +} From 4cd21004734afe85226f462f04cc639b4ed43d46 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 2 Oct 2026 01:32:35 +0100 Subject: [PATCH 6/9] feat(table): browser-like column widths in adaptive tables (PHP and Go) Change the AdaptiveTable column-width algorithm, in both the legacy PHP CLI and the Go table package, to be closer to a browser's automatic table layout: - A column's minimum width is its longest word, capped at 20 instead of a fixed 10, so words are not cut when the table can fit. If the minimums don't fit, the cap is reduced down to 10. - Space above the minimums is shared in proportion to each column's maximum minus minimum width, using integer arithmetic so that PHP and Go give the same widths. In Go, replace ansi.Wrap with a port of PHP's wordwrap() (measuring display width and keeping ANSI sequences), so wrapping matches the legacy output and doesn't break at hyphens. It matched PHP on 100,000 random ASCII inputs, and table output matched PHP's AdaptiveTable in 309 of 313 generated tables (the rest had duplicate column names). Co-Authored-By: Claude Opus 5.5 --- internal/table/adaptive.go | 158 +++++++++++++++++---- internal/table/table_test.go | 74 +++++++++- legacy/src/Console/AdaptiveTable.php | 132 +++++++++++------ legacy/tests/Console/AdaptiveTableTest.php | 98 ++++++++----- 4 files changed, 348 insertions(+), 114 deletions(-) diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go index 93d39605..d9f5d110 100644 --- a/internal/table/adaptive.go +++ b/internal/table/adaptive.go @@ -1,7 +1,6 @@ package table import ( - "math" "os" "regexp" "slices" @@ -12,8 +11,12 @@ import ( "golang.org/x/term" ) -// minColumnWidth is the width below which columns are not wrapped. -const minColumnWidth = 10 +// Words up to maxUnbrokenWidth are not broken when wrapping if the table can fit, and words up to +// minUnbrokenWidth are not broken even if it does not. +const ( + maxUnbrokenWidth = 20 + minUnbrokenWidth = 10 +) // terminalWidth returns the width of the terminal, from $COLUMNS or the first standard stream that is a terminal. func terminalWidth() int { @@ -29,49 +32,91 @@ func terminalWidth() int { } // maxColumnWidths finds the maximum width of each column's content, so that the table fits in maxWidth. -// It is a port of the legacy CLI's AdaptiveTable::getMaxColumnWidths(). +// +// This is similar to a web browser's automatic table layout, and matches the legacy CLI's +// AdaptiveTable::getMaxColumnWidths(). Each column has a maximum width (its widest cell) and a minimum width +// (its longest word, up to maxUnbrokenWidth, or less if needed to fit). If the maximum widths do not fit, each +// column gets its minimum width plus a share of the remaining space, in proportion to the difference between its +// maximum and minimum. // The rows before bodyStart are header rows, which are not wrapped. func maxColumnWidths(rows [][][]string, bodyStart int, noWrap []bool, maxWidth int) []int { count := len(rows[0]) - originalWidths := make([]int, count) - minWidths := make([]int, count) + maxWidths := make([]int, count) + fixedWidths := make([]int, count) + wordWidths := make([]int, count) for i, row := range rows { for j, lines := range row { w := cellWidth(lines) - originalWidths[j] = max(originalWidths[j], w) - minCellWidth := minColumnWidth - if w < minColumnWidth || noWrap[j] || i < bodyStart { - minCellWidth = w + maxWidths[j] = max(maxWidths[j], w) + if noWrap[j] || i < bodyStart { + fixedWidths[j] = max(fixedWidths[j], w) + } else { + wordWidths[j] = max(wordWidths[j], longestWordWidth(lines)) } - minWidths[j] = max(minWidths[j], minCellWidth) } } - // Distribute the available width between columns in proportion to their original widths, - // starting with the narrowest. - maxContentWidth := float64(maxWidth - (count + 1) - count*2) - totalWidth := 0 - for _, w := range originalWidths { - totalWidth += w + available := maxWidth - (count + 1) - count*2 + maxTotal := sum(maxWidths) + if maxTotal <= available { + return maxWidths + } + + // The minimum column width is the width of the longest word, capped at maxUnbrokenWidth. + // The cap is reduced, down to minUnbrokenWidth, until the minimum widths fit. + minWidths := make([]int, count) + minTotal := 0 + for limit := maxUnbrokenWidth; ; limit-- { + for j := range minWidths { + minWidths[j] = max(fixedWidths[j], min(wordWidths[j], limit)) + } + minTotal = sum(minWidths) + if minTotal <= available || limit <= minUnbrokenWidth { + break + } + } + if minTotal >= available { + return minWidths + } + + // Share the extra space in proportion to each column's flexibility (its maximum minus its minimum width). + // The remainder is given to the columns with the largest fractional parts, in column order for ties. + extra, flexTotal := available-minTotal, maxTotal-minTotal + widths := make([]int, count) + remainders := make([]int, count) + for j := range widths { + share := (maxWidths[j] - minWidths[j]) * extra + widths[j] = minWidths[j] + share/flexTotal + remainders[j] = share % flexTotal } order := make([]int, count) for j := range order { order[j] = j } - slices.SortStableFunc(order, func(a, b int) int { return originalWidths[a] - originalWidths[b] }) + slices.SortStableFunc(order, func(a, b int) int { return remainders[b] - remainders[a] }) + for _, j := range order[:available-sum(widths)] { + widths[j]++ + } + return widths +} - widths := make([]int, count) - for _, j := range order { - var w int - if totalWidth > 0 { - w = int(math.Round(maxContentWidth / float64(totalWidth) * float64(originalWidths[j]))) +func sum(s []int) int { + total := 0 + for _, v := range s { + total += v + } + return total +} + +// longestWordWidth returns the display width of the longest whitespace-separated word in a cell. +func longestWordWidth(lines []string) int { + w := 0 + for _, line := range lines { + for _, word := range strings.Fields(ansi.Strip(line)) { + w = max(w, ansi.StringWidth(word)) } - w = max(w, minWidths[j]) - widths[j] = w - totalWidth -= originalWidths[j] - maxContentWidth -= float64(w) } - return widths + return w } // cellWidth returns the display width of the longest line in a cell. @@ -88,7 +133,7 @@ func wrapCell(lines []string, width int) []string { contents := strings.Join(lines, "\n") trimmed := strings.TrimLeft(contents, " ") indent := contents[:len(contents)-len(trimmed)] - wrapped := strings.Split(ansi.Wrap(trimmed, max(width-len(indent), 1), " "), "\n") + wrapped := strings.Split(wordwrap(trimmed, max(width-len(indent), 1)), "\n") if indent != "" { for i, line := range wrapped { wrapped[i] = indent + line @@ -97,6 +142,61 @@ func wrapCell(lines []string, width int) []string { return wrapped } +// wordwrap wraps text to the width, like PHP's wordwrap($text, $width, "\n", true). +// It is a port of PHP's implementation, measuring display width instead of bytes and keeping ANSI sequences. +func wordwrap(text string, width int) string { + var ( + units []string + widths []int + state byte + ) + for text != "" { + seq, w, n, newState := ansi.DecodeSequence(text, state, nil) + units, widths, state, text = append(units, seq), append(widths, w), newState, text[n:] + } + pos := make([]int, len(units)+1) + for i, w := range widths { + pos[i+1] = pos[i] + w + } + + var b strings.Builder + write := func(from, to int, lineBreak bool) { + b.WriteString(strings.Join(units[from:to], "")) + if lineBreak { + b.WriteString("\n") + } + } + lastStart, lastSpace := 0, 0 + for cur, unit := range units { + // The line is full, as in PHP, or the current unit would overflow it (if it is wide). + lineWidth := pos[cur] - pos[lastStart] + over := lineWidth >= width || lineWidth+widths[cur] > width + switch { + case unit == "\n" && cur+1 < len(units): + // Keep existing line breaks. + write(lastStart, cur+1, false) + lastStart, lastSpace = cur+1, cur+1 + case unit == " ": + // Break at a space at the line boundary. + if lineWidth >= width { + write(lastStart, cur, true) + lastStart = cur + 1 + } + lastSpace = cur + case over && lastStart >= lastSpace && cur > lastStart: + // Cut a word that is too long. + write(lastStart, cur, true) + lastStart, lastSpace = cur, cur + case over && lastStart < lastSpace: + // Break at the last space. + write(lastStart, lastSpace, true) + lastStart, lastSpace = lastSpace+1, lastSpace+1 + } + } + write(lastStart, len(units), false) + return b.String() +} + var ( sgrRegex = regexp.MustCompile(`\x1b\[([0-9;:]*)m`) hyperlinkRegex = regexp.MustCompile(`\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)`) diff --git a/internal/table/table_test.go b/internal/table/table_test.go index a3875ef2..732f31f9 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -223,12 +223,52 @@ func TestRenderTableWrapping(t *testing.T) { columns: []Column{{Header: "A long header"}, {Header: "URL", NoWrap: true}, {Header: "Notes"}}, rows: [][]string{{"x", "https://example.com/long/path", "some notes that wrap"}}, width: 40, - want: `+---------------+-------------------------------+------------+ -| A long header | URL | Notes | -+---------------+-------------------------------+------------+ -| x | https://example.com/long/path | some notes | -| | | that wrap | -+---------------+-------------------------------+------------+ + want: `+---------------+-------------------------------+-------+ +| A long header | URL | Notes | ++---------------+-------------------------------+-------+ +| x | https://example.com/long/path | some | +| | | notes | +| | | that | +| | | wrap | ++---------------+-------------------------------+-------+ +`, + }, + { + name: "long words are not broken if the table can fit", + columns: []Column{{Header: "Row"}, {Header: "Lorem"}, {Header: "ipsum"}, {Header: "dolor"}, {Header: "sit"}}, + rows: [][]string{ + {"#1", "amet", "consectetur", "adipiscing elit", "Quisque pulvinar"}, + {"#2", "tellus sit amet", "sollicitudin", "tincidunt", "risus"}, + }, + width: 60, + want: `+-----+----------+--------------+------------+----------+ +| Row | Lorem | ipsum | dolor | sit | ++-----+----------+--------------+------------+----------+ +| #1 | amet | consectetur | adipiscing | Quisque | +| | | | elit | pulvinar | +| #2 | tellus | sollicitudin | tincidunt | risus | +| | sit amet | | | | ++-----+----------+--------------+------------+----------+ +`, + }, + { + name: "the unbroken word width is reduced to fit", + columns: []Column{{Header: "ID"}, {Header: "Title"}, {Header: "Created"}, {Header: "Updated"}, {Header: "Status"}}, + rows: [][]string{{ + "abc123def456", "A project with a reasonably long title for testing", + "2026-01-01T00:00:00+00:00", "2026-01-02T00:00:00+00:00", "active", + }}, + width: 80, + want: `+--------------+------------+--------------------+--------------------+--------+ +| ID | Title | Created | Updated | Status | ++--------------+------------+--------------------+--------------------+--------+ +| abc123def456 | A project | 2026-01-01T00:00:0 | 2026-01-02T00:00:0 | active | +| | with a | 0+00:00 | 0+00:00 | | +| | reasonably | | | | +| | long title | | | | +| | for | | | | +| | testing | | | | ++--------------+------------+--------------------+--------------------+--------+ `, }, { @@ -382,3 +422,25 @@ func TestIsolateHyperlinks(t *testing.T) { ) assert.Equal(t, []string{open + "a" + end, "b"}, isolateStyles([]string{open + "a" + end, "b"})) } + +func TestWordwrap(t *testing.T) { + // The expected values match PHP's wordwrap($text, $width, "\n", true), except for wide characters and ANSI sequences. + cases := []struct { + text string + width int + want string + }{ + {"The quick brown fox", 10, "The quick\nbrown fox"}, + {"double spaced words", 8, "double \nspaced \nwords"}, + {"Averyverylongword", 5, "Avery\nveryl\nongwo\nrd"}, + {"2026-01-01T00:00:00+00:00", 18, "2026-01-01T00:00:0\n0+00:00"}, + {"keep\nbreaks here", 6, "keep\nbreaks\nhere"}, + {"trailing\n", 4, "trai\nling\n\n"}, + {"日本語 日本語", 6, "日本語\n日本語"}, + {"日本語日本語", 5, "日本\n語日\n本語"}, + {"\x1b[32mgreen text\x1b[0m here", 5, "\x1b[32mgreen\ntext\x1b[0m\nhere"}, + } + for _, c := range cases { + assert.Equal(t, c.want, wordwrap(c.text, c.width), "wordwrap(%q, %d)", c.text, c.width) + } +} diff --git a/legacy/src/Console/AdaptiveTable.php b/legacy/src/Console/AdaptiveTable.php index 51b03f3b..841ec666 100644 --- a/legacy/src/Console/AdaptiveTable.php +++ b/legacy/src/Console/AdaptiveTable.php @@ -30,9 +30,12 @@ class AdaptiveTable extends Table * * @param OutputInterface $outputCopy * @param int|null $maxTableWidth - * @param int $minColumnWidth + * @param int $maxUnbrokenWidth + * Words up to this width are not broken when wrapping, if the table can fit. + * @param int $minUnbrokenWidth + * Words up to this width are not broken when wrapping, even if the table does not fit. */ - public function __construct(protected OutputInterface $outputCopy, ?int $maxTableWidth = null, protected int $minColumnWidth = 10) + public function __construct(protected OutputInterface $outputCopy, ?int $maxTableWidth = null, protected int $maxUnbrokenWidth = 20, protected int $minUnbrokenWidth = 10) { $this->maxTableWidth = $maxTableWidth !== null ? $maxTableWidth @@ -248,17 +251,27 @@ public function wrapWithDecoration(string $formattedText, int $maxLength): strin } /** + * Finds the maximum width of each column's content, so that the table fits into the maximum table width. + * + * This is similar to a web browser's automatic table layout. Each column + * has a maximum width (its widest cell) and a minimum width (its longest + * word, up to $maxUnbrokenWidth, or less if needed to fit). If the maximum widths do not fit, each + * column gets its minimum width plus a share of the remaining space, in + * proportion to the difference between its maximum and minimum widths. + * * @return array * An array of the maximum column widths that fit into the table width, * indexed by the column's key in the table's rows (a name or number). */ protected function getMaxColumnWidths(): array { - // Loop through the table rows and headers, building multidimensional - // arrays of the 'original' and 'minimum' column widths. In the same - // loop, build a count of the number of columns. - $originalColumnWidths = []; - $minColumnWidths = []; + // Loop through the table rows and headers, building arrays of the + // maximum column widths, the widths of cells that cannot wrap, and the + // widths of the longest words. In the same loop, build a count of the + // number of columns. + $maxWidths = []; + $fixedWidths = []; + $wordWidths = []; $columnCounts = [0]; foreach (array_merge($this->rowsCopy, $this->headersCopy) as $rowNum => $row) { if ($row instanceof TableSeparator) { @@ -268,26 +281,16 @@ protected function getMaxColumnWidths(): array foreach ($row as $column => $cell) { $columnCount += $cell instanceof TableCell ? $cell->getColspan() - 1 : 1; - // The column width is the width of the widest cell. - $cellWidth = $this->getCellWidth($cell); - if (!isset($originalColumnWidths[$column]) || $originalColumnWidths[$column] < $cellWidth) { - $originalColumnWidths[$column] = $cellWidth; - } - - // Find the minimum width of the cell. The default is configured - // in minColumnWidth, but this is overridden for non-wrapping - // cells and very narrow cells. Additionally, table headers are - // never wrapped. - $minCellWidth = $this->minColumnWidth; - if ($cellWidth < $this->minColumnWidth - || ($cell instanceof AdaptiveTableCell && !$cell->canWrap()) - || !isset($this->rowsCopy[$rowNum])) { - $minCellWidth = $cellWidth; - } + // The maximum column width is the width of the widest cell. + $cellWidth = (int) ceil($this->getCellWidth($cell)); + $maxWidths[$column] = max($maxWidths[$column] ?? 0, $cellWidth); - // The minimum column width is the greatest minimum cell width. - if (!isset($minColumnWidths[$column]) || $minColumnWidths[$column] < $minCellWidth) { - $minColumnWidths[$column] = $minCellWidth; + // Non-wrapping cells and table headers are never wrapped. + // Otherwise, track the width of the longest word. + if (($cell instanceof AdaptiveTableCell && !$cell->canWrap()) || !isset($this->rowsCopy[$rowNum])) { + $fixedWidths[$column] = max($fixedWidths[$column] ?? 0, $cellWidth); + } else { + $wordWidths[$column] = max($wordWidths[$column] ?? 0, (int) ceil($this->getLongestWordWidth($cell))); } } $columnCounts[] = $columnCount; @@ -296,29 +299,52 @@ protected function getMaxColumnWidths(): array // Find the number of columns in the table. This uses the same process // as the parent private method Table->calculateNumberOfColumns(). $columnCount = max($columnCounts); + $available = (int) $this->getMaxContentWidth($columnCount); + + $maxTotal = array_sum($maxWidths); + if ($maxTotal <= $available) { + return $maxWidths; + } - // Find the maximum width for each column's content, to fit into the - // calculated maximum content width. - $maxContentWidth = $this->getMaxContentWidth($columnCount); - $maxColumnWidths = []; - $totalWidth = array_sum($originalColumnWidths); - asort($originalColumnWidths, SORT_NUMERIC); - foreach ($originalColumnWidths as $column => $columnWidth) { - $columnRatio = ($maxContentWidth / $totalWidth) * $columnWidth; - $maxColumnWidth = (int) round($columnRatio); - - // Do not change the width of columns which are already narrower - // than the minimum. - if (isset($minColumnWidths[$column]) && $maxColumnWidth < $minColumnWidths[$column]) { - $maxColumnWidth = $minColumnWidths[$column]; + // The minimum column width is the width of the longest word, capped + // at $maxUnbrokenWidth. The cap is reduced, down to + // $minUnbrokenWidth, until the minimum widths fit. + for ($cap = $this->maxUnbrokenWidth; ; $cap--) { + $minWidths = []; + foreach ($maxWidths as $column => $maxWidth) { + $minWidths[$column] = max($fixedWidths[$column] ?? 0, min($wordWidths[$column] ?? 0, $cap)); } + $minTotal = array_sum($minWidths); + if ($minTotal <= $available || $cap <= $this->minUnbrokenWidth) { + break; + } + } + if ($minTotal >= $available) { + return $minWidths; + } - $maxColumnWidths[$column] = (int) $maxColumnWidth; - $totalWidth -= $columnWidth; - $maxContentWidth -= $maxColumnWidth; + // Share the extra space in proportion to each column's flexibility + // (its maximum minus its minimum width). Integer arithmetic is used, + // and the remainder is given to the columns with the largest + // fractional parts. + $extra = $available - $minTotal; + $flexTotal = $maxTotal - $minTotal; + $widths = []; + $remainders = []; + foreach ($maxWidths as $column => $maxWidth) { + $share = ($maxWidth - $minWidths[$column]) * $extra; + $widths[$column] = $minWidths[$column] + intdiv($share, $flexTotal); + $remainders[$column] = $share % $flexTotal; + } + $leftover = $available - array_sum($widths); + // Sorting is stable, so ties are broken by column order. + $columns = array_keys($remainders); + usort($columns, fn($a, $b): int => $remainders[$b] <=> $remainders[$a]); + foreach (array_slice($columns, 0, $leftover) as $column) { + $widths[$column]++; } - return $maxColumnWidths; + return $widths; } /** @@ -341,6 +367,24 @@ protected function getMaxContentWidth(int $columnCount): int|float - $paddingQuantity * strlen($style->getPaddingChar()); } + /** + * Get the width of the longest word in a table cell. + */ + private function getLongestWordWidth(string|int|float|TableCell $cell): int|float + { + $formatter = $this->outputCopy->getFormatter(); + $plain = Helper::removeDecoration($formatter, (string) $cell); + $width = 0; + foreach (preg_split('/\s+/', $plain, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $word) { + $width = max($width, Helper::width($word)); + } + if ($cell instanceof TableCell && $cell->getColspan() > 1) { + $width /= $cell->getColspan(); + } + + return $width; + } + /** * Get the default width of a table cell (the length of its longest line). * diff --git a/legacy/tests/Console/AdaptiveTableTest.php b/legacy/tests/Console/AdaptiveTableTest.php index b2559041..74d4a6a2 100644 --- a/legacy/tests/Console/AdaptiveTableTest.php +++ b/legacy/tests/Console/AdaptiveTableTest.php @@ -42,18 +42,18 @@ public function testAdaptedRowsFitMaxTableWidth(): void $this->assertLessThanOrEqual($maxTableWidth, max($lineWidths)); $expected = <<<'EOT' - +-----+------------+------------+------------+----------+ - | Row | Lorem | ipsum | dolor | sit | - +-----+------------+------------+------------+----------+ - | #1 | amet | consectetu | adipiscing | Quisque | - | | | r | elit | pulvinar | - | #2 | tellus sit | sollicitud | tincidunt | risus | - | | amet | in | | | - +-----+------------+------------+------------+----------+ - | #3 | risus | sem | mattis | ex | - | #4 | quis | luctus | lorem | ligula | - | | | metus | cursus | | - +-----+------------+------------+------------+----------+ + +-----+----------+--------------+------------+----------+ + | Row | Lorem | ipsum | dolor | sit | + +-----+----------+--------------+------------+----------+ + | #1 | amet | consectetur | adipiscing | Quisque | + | | | | elit | pulvinar | + | #2 | tellus | sollicitudin | tincidunt | risus | + | | sit amet | | | | + +-----+----------+--------------+------------+----------+ + | #3 | risus | sem | mattis | ex | + | #4 | quis | luctus metus | lorem | ligula | + | | | | cursus | | + +-----+----------+--------------+------------+----------+ EOT; $this->assertEquals($expected, $result); @@ -88,18 +88,17 @@ public function testAdaptedRowsWithIndent(): void $this->assertLessThanOrEqual($maxTableWidth, max($lineWidths)); $expected = <<<'EOT' - +-----+------------+-------------+--------------+------------+ - | Row | Lorem | ipsum | dolor | Indented | - +-----+------------+-------------+--------------+------------+ - | #1 | amet | consectetur | adipiscing | Quisque | - | | | | elit | pulvinar | - | #2 | tellus sit | sollicitudi | tincidunt | risus | - | | amet | n | | | - +-----+------------+-------------+--------------+------------+ - | #3 | risus | sem | mattis | ex | - | #4 | quis | luctus | lorem cursus | ligula | - | | | metus | | | - +-----+------------+-------------+--------------+------------+ + +-----+------------+--------------+--------------+------------+ + | Row | Lorem | ipsum | dolor | Indented | + +-----+------------+--------------+--------------+------------+ + | #1 | amet | consectetur | adipiscing | Quisque | + | | | | elit | pulvinar | + | #2 | tellus sit | sollicitudin | tincidunt | risus | + | | amet | | | | + +-----+------------+--------------+--------------+------------+ + | #3 | risus | sem | mattis | ex | + | #4 | quis | luctus metus | lorem cursus | ligula | + +-----+------------+--------------+--------------+------------+ EOT; $this->assertEquals($expected, $result); @@ -126,22 +125,51 @@ public function testAdaptedRowsWithNonWrappingCell(): void $result = $buffer->fetch(); $expected = <<<'EOT' - +-----+------------+--------------+------------+----------+ - | Row | Lorem | ipsum | dolor | sit | - +-----+------------+--------------+------------+----------+ - | #1 | amet | consectetur | adipiscing | Quisque | - | | | | elit | pulvinar | - | #2 | tellus sit | sollicitudin | tincidunt | risus | - | | amet | | | | - | #3 | risus | sem | mattis | ex | - | #4 | quis | luctus metus | lorem | ligula | - | | | | cursus | | - +-----+------------+--------------+------------+----------+ + +-----+----------+--------------+------------+----------+ + | Row | Lorem | ipsum | dolor | sit | + +-----+----------+--------------+------------+----------+ + | #1 | amet | consectetur | adipiscing | Quisque | + | | | | elit | pulvinar | + | #2 | tellus | sollicitudin | tincidunt | risus | + | | sit amet | | | | + | #3 | risus | sem | mattis | ex | + | #4 | quis | luctus metus | lorem | ligula | + | | | | cursus | | + +-----+----------+--------------+------------+----------+ EOT; $this->assertEquals($expected, $result); } + /** + * Test that the width of unbroken words is reduced for the table to fit. + */ + public function testAdaptedRowsReduceUnbrokenWidth(): void + { + $buffer = new BufferedOutput(); + $table = new AdaptiveTable($buffer, 80); + $table->setHeaders([['ID', 'Title', 'Created', 'Updated', 'Status']]); + $table->setRows([ + ['abc123def456', 'A project with a reasonably long title for testing', '2026-01-01T00:00:00+00:00', '2026-01-02T00:00:00+00:00', 'active'], + ]); + $table->render(); + + $expected = <<<'EOT' + +--------------+------------+--------------------+--------------------+--------+ + | ID | Title | Created | Updated | Status | + +--------------+------------+--------------------+--------------------+--------+ + | abc123def456 | A project | 2026-01-01T00:00:0 | 2026-01-02T00:00:0 | active | + | | with a | 0+00:00 | 0+00:00 | | + | | reasonably | | | | + | | long title | | | | + | | for | | | | + | | testing | | | | + +--------------+------------+--------------------+--------------------+--------+ + + EOT; + $this->assertEquals($expected, $buffer->fetch()); + } + /** * Tests that a string can be wrapped with decoration at various lengths. * From 7e6f3e2ee5cf40b64062af5c61af0dc43606b606 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 2 Oct 2026 01:37:05 +0100 Subject: [PATCH 7/9] test: update wrapped table output for the new column widths Co-Authored-By: Claude Opus 5.5 --- integration-tests/activity_list_test.go | 16 ++++++++-------- integration-tests/environment_deploy_test.go | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/integration-tests/activity_list_test.go b/integration-tests/activity_list_test.go index e010e59a..7556ee2c 100644 --- a/integration-tests/activity_list_test.go +++ b/integration-tests/activity_list_test.go @@ -78,14 +78,14 @@ func TestActivityList(t *testing.T) { `, f.Run("act", "-p", projectID, "-e", ".")) assertTrimmed(t, ` -+------+----------------------+---------------------------------+----------+----------+---------+----------------+ -| ID | Created | Description | Progress | State | Result | Environment(s) | -+------+----------------------+---------------------------------+----------+----------+---------+----------------+ -| act1 | 2014-04-01T10:00:00+ | Mock User created variable X on | 100% | complete | success | main | -| | 00:00 | environment main | | | | | -| act2 | 2014-04-01T09:00:00+ | Mock User created variable X | 100% | complete | success | | -| | 00:00 | | | | | | -+------+----------------------+---------------------------------+----------+----------+---------+----------------+ ++------+-------------------------+---------------------------------+----------+----------+---------+----------------+ +| ID | Created | Description | Progress | State | Result | Environment(s) | ++------+-------------------------+---------------------------------+----------+----------+---------+----------------+ +| act1 | 2014-04-01T10:00:00+00: | Mock User created variable X on | 100% | complete | success | main | +| | 00 | environment main | | | | | +| act2 | 2014-04-01T09:00:00+00: | Mock User created variable X | 100% | complete | success | | +| | 00 | | | | | | ++------+-------------------------+---------------------------------+----------+----------+---------+----------------+ `, f.Run("act", "-p", projectID, "--all", "--limit", "20")) assertTrimmed(t, "complete", f.Run("act:get", "-p", projectID, "-e", ".", "act1", "-P", "state")) diff --git a/integration-tests/environment_deploy_test.go b/integration-tests/environment_deploy_test.go index 7d345607..2719b412 100644 --- a/integration-tests/environment_deploy_test.go +++ b/integration-tests/environment_deploy_test.go @@ -67,13 +67,13 @@ func TestEnvironmentDeploy(t *testing.T) { }) assertTrimmed(t, ` -+------+-------------------------+---------------------------------------------+-----------------------------+---------+ -| ID | Created | Description | Type | Result | -+------+-------------------------+---------------------------------------------+-----------------------------+---------+ -| act2 | 2014-04-02T10:00:00+00: | Mock User created variable X on environment | environment.variable.create | success | -| | 00 | main | | | -| act1 | 2014-04-01T10:00:00+00: | Mock User pushed to main | environment.push | success | -| | 00 | | | | -+------+-------------------------+---------------------------------------------+-----------------------------+---------+ ++------+--------------------------+---------------------------------+-----------------------------+---------+ +| ID | Created | Description | Type | Result | ++------+--------------------------+---------------------------------+-----------------------------+---------+ +| act2 | 2014-04-02T10:00:00+00:0 | Mock User created variable X on | environment.variable.create | success | +| | 0 | environment main | | | +| act1 | 2014-04-01T10:00:00+00:0 | Mock User pushed to main | environment.push | success | +| | 0 | | | | ++------+--------------------------+---------------------------------+-----------------------------+---------+ `, f.Run("env:deploy", "-p", projectID, "-e", "main")) } From 521383ad2db0bfe911500493ec93044467afb7ea Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 2 Oct 2026 01:43:03 +0100 Subject: [PATCH 8/9] fix(table): include indentation in minimum widths and ignore ANSI at wrap boundaries - Indentation is repeated on each wrapped line, so add it to a cell's minimum width. PHP's wordwrap() threw a ValueError when the width left after the indentation was zero. - Zero-width ANSI sequences no longer trigger a break in the Go wordwrap(), which produced extra lines for styled text that exactly filled the width. Co-Authored-By: Claude Opus 5.5 --- internal/table/adaptive.go | 10 ++++++++-- internal/table/table_test.go | 15 ++++++++++++++ legacy/src/Console/AdaptiveTable.php | 6 ++++-- legacy/tests/Console/AdaptiveTableTest.php | 23 ++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go index d9f5d110..826e7f6e 100644 --- a/internal/table/adaptive.go +++ b/internal/table/adaptive.go @@ -108,7 +108,8 @@ func sum(s []int) int { return total } -// longestWordWidth returns the display width of the longest whitespace-separated word in a cell. +// longestWordWidth returns the display width of the longest whitespace-separated word in a cell, +// plus its indentation, which is repeated on each wrapped line. func longestWordWidth(lines []string) int { w := 0 for _, line := range lines { @@ -116,7 +117,8 @@ func longestWordWidth(lines []string) int { w = max(w, ansi.StringWidth(word)) } } - return w + plain := ansi.Strip(strings.Join(lines, "\n")) + return w + len(plain) - len(strings.TrimLeft(plain, " ")) } // cellWidth returns the display width of the longest line in a cell. @@ -168,6 +170,10 @@ func wordwrap(text string, width int) string { } lastStart, lastSpace := 0, 0 for cur, unit := range units { + // Zero-width ANSI sequences never cause a break. + if widths[cur] == 0 && unit != "\n" { + continue + } // The line is full, as in PHP, or the current unit would overflow it (if it is wide). lineWidth := pos[cur] - pos[lastStart] over := lineWidth >= width || lineWidth+widths[cur] > width diff --git a/internal/table/table_test.go b/internal/table/table_test.go index 732f31f9..c88632fb 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -269,6 +269,19 @@ func TestRenderTableWrapping(t *testing.T) { | | for | | | | | | testing | | | | +--------------+------------+--------------------+--------------------+--------+ +`, + }, + { + name: "indentation is included in the minimum width", + columns: []Column{{Header: "K"}, {Header: "V"}}, + rows: [][]string{{"x", " ab cd"}}, + width: 10, + want: `+---+------+ +| K | V | ++---+------+ +| x | ab | +| | cd | ++---+------+ `, }, { @@ -439,6 +452,8 @@ func TestWordwrap(t *testing.T) { {"日本語 日本語", 6, "日本語\n日本語"}, {"日本語日本語", 5, "日本\n語日\n本語"}, {"\x1b[32mgreen text\x1b[0m here", 5, "\x1b[32mgreen\ntext\x1b[0m\nhere"}, + {"\x1b[32m12345\x1b[0m", 5, "\x1b[32m12345\x1b[0m"}, + {"\x1b[32m12345\x1b[0m 678", 5, "\x1b[32m12345\x1b[0m\n678"}, } for _, c := range cases { assert.Equal(t, c.want, wordwrap(c.text, c.width), "wordwrap(%q, %d)", c.text, c.width) diff --git a/legacy/src/Console/AdaptiveTable.php b/legacy/src/Console/AdaptiveTable.php index 841ec666..d76cd0ec 100644 --- a/legacy/src/Console/AdaptiveTable.php +++ b/legacy/src/Console/AdaptiveTable.php @@ -160,7 +160,7 @@ private function wrapCell(string $contents, int $width): string $indentAmount = Helper::width($contents) - Helper::width($trimmed); $indent = str_repeat(' ', $indentAmount); - return preg_replace('/^/m', $indent, $this->wrapWithDecoration($trimmed, $width - $indentAmount)); + return preg_replace('/^/m', $indent, $this->wrapWithDecoration($trimmed, max($width - $indentAmount, 1))); } return $this->wrapWithDecoration($contents, $width); @@ -368,7 +368,7 @@ protected function getMaxContentWidth(int $columnCount): int|float } /** - * Get the width of the longest word in a table cell. + * Get the width of the longest word in a table cell, plus its indentation. */ private function getLongestWordWidth(string|int|float|TableCell $cell): int|float { @@ -378,6 +378,8 @@ private function getLongestWordWidth(string|int|float|TableCell $cell): int|floa foreach (preg_split('/\s+/', $plain, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $word) { $width = max($width, Helper::width($word)); } + // Indentation is repeated on each wrapped line. + $width += strlen($plain) - strlen(ltrim($plain, ' ')); if ($cell instanceof TableCell && $cell->getColspan() > 1) { $width /= $cell->getColspan(); } diff --git a/legacy/tests/Console/AdaptiveTableTest.php b/legacy/tests/Console/AdaptiveTableTest.php index 74d4a6a2..ca4b8ca7 100644 --- a/legacy/tests/Console/AdaptiveTableTest.php +++ b/legacy/tests/Console/AdaptiveTableTest.php @@ -170,6 +170,29 @@ public function testAdaptedRowsReduceUnbrokenWidth(): void $this->assertEquals($expected, $buffer->fetch()); } + /** + * Test that indentation is included in the minimum column width. + */ + public function testAdaptedRowsIndentInMinimumWidth(): void + { + $buffer = new BufferedOutput(); + $table = new AdaptiveTable($buffer, 10); + $table->setHeaders([['K', 'V']]); + $table->setRows([['x', ' ab cd']]); + $table->render(); + + $expected = <<<'EOT' + +---+------+ + | K | V | + +---+------+ + | x | ab | + | | cd | + +---+------+ + + EOT; + $this->assertEquals($expected, $buffer->fetch()); + } + /** * Tests that a string can be wrapped with decoration at various lengths. * From 849cb4c70c2238f5d89a66c204a3e4d041f29816 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 2 Oct 2026 01:49:28 +0100 Subject: [PATCH 9/9] fix(table): cap a cell's minimum width at its own width The indentation added to the longest word could make a multi-line cell's minimum exceed its width, when the word is on a later, unindented line. Negative shares then panicked in Go (slice bounds) and gave wrong widths in PHP. A cell at its full width is not wrapped, so its minimum is now capped at that width. Co-Authored-By: Claude Opus 5.5 --- internal/table/adaptive.go | 5 +++-- internal/table/table_test.go | 21 +++++++++++++++++++++ legacy/src/Console/AdaptiveTable.php | 3 ++- legacy/tests/Console/AdaptiveTableTest.php | 13 +++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go index 826e7f6e..0f038401 100644 --- a/internal/table/adaptive.go +++ b/internal/table/adaptive.go @@ -48,10 +48,11 @@ func maxColumnWidths(rows [][][]string, bodyStart int, noWrap []bool, maxWidth i for j, lines := range row { w := cellWidth(lines) maxWidths[j] = max(maxWidths[j], w) - if noWrap[j] || i < bodyStart { + if (j < len(noWrap) && noWrap[j]) || i < bodyStart { fixedWidths[j] = max(fixedWidths[j], w) } else { - wordWidths[j] = max(wordWidths[j], longestWordWidth(lines)) + // A cell at its full width is not wrapped, so its minimum is no more than that. + wordWidths[j] = max(wordWidths[j], min(longestWordWidth(lines), w)) } } } diff --git a/internal/table/table_test.go b/internal/table/table_test.go index c88632fb..8ea327ff 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -2,8 +2,11 @@ package table import ( "bytes" + "strings" "testing" + "github.com/charmbracelet/x/ansi" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -459,3 +462,21 @@ func TestWordwrap(t *testing.T) { assert.Equal(t, c.want, wordwrap(c.text, c.width), "wordwrap(%q, %d)", c.text, c.width) } } + +func TestMaxColumnWidthsMultilineIndent(t *testing.T) { + // A cell's minimum width must not exceed its own width, even if a later line is not indented. + tbl := &Table{Columns: []Column{{Header: "A"}, {Header: "B"}, {Header: "C"}, {Header: "D"}}, MaxWidth: 89} + rows := [][]string{{ + " a\nwwww\nzzzzzzzzzz", + " a\nwwwwww\nzzzzzzzzzzzzzzzzz", + " a\nwwwwwwwwwwwww\nzzzzzzzzzzzzzzzzzzzz", + " a\nwwwwww\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + }} + var b bytes.Buffer + require.NotPanics(t, func() { require.NoError(t, tbl.Render(&b, rows, Options{})) }) + for _, line := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") { + assert.LessOrEqual(t, ansi.StringWidth(line), 89) + } + + assert.Equal(t, []int{6}, maxColumnWidths([][][]string{{{" a", "wwwwww"}}}, 0, []bool{false}, 5)) +} diff --git a/legacy/src/Console/AdaptiveTable.php b/legacy/src/Console/AdaptiveTable.php index d76cd0ec..3f4bb75c 100644 --- a/legacy/src/Console/AdaptiveTable.php +++ b/legacy/src/Console/AdaptiveTable.php @@ -290,7 +290,8 @@ protected function getMaxColumnWidths(): array if (($cell instanceof AdaptiveTableCell && !$cell->canWrap()) || !isset($this->rowsCopy[$rowNum])) { $fixedWidths[$column] = max($fixedWidths[$column] ?? 0, $cellWidth); } else { - $wordWidths[$column] = max($wordWidths[$column] ?? 0, (int) ceil($this->getLongestWordWidth($cell))); + // A cell at its full width is not wrapped, so its minimum is no more than that. + $wordWidths[$column] = max($wordWidths[$column] ?? 0, min((int) ceil($this->getLongestWordWidth($cell)), $cellWidth)); } } $columnCounts[] = $columnCount; diff --git a/legacy/tests/Console/AdaptiveTableTest.php b/legacy/tests/Console/AdaptiveTableTest.php index ca4b8ca7..791b7f2a 100644 --- a/legacy/tests/Console/AdaptiveTableTest.php +++ b/legacy/tests/Console/AdaptiveTableTest.php @@ -193,6 +193,19 @@ public function testAdaptedRowsIndentInMinimumWidth(): void $this->assertEquals($expected, $buffer->fetch()); } + /** + * Test that a cell's minimum width does not exceed its width, if a later line is not indented. + */ + public function testAdaptedRowsMultilineIndent(): void + { + $table = new AdaptiveTable(new BufferedOutput(), 5); + $table->setHeaders([['A']]); + $table->setRows([[" a\nwwwwww"]]); + + $method = new \ReflectionMethod($table, 'getMaxColumnWidths'); + $this->assertEquals([6], $method->invoke($table)); + } + /** * Tests that a string can be wrapped with decoration at various lengths. *