diff --git a/CLAUDE.md b/CLAUDE.md index 877ee04f..cfb3363d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,11 @@ 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, 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 - Integrates with whatsun library for codebase analysis diff --git a/go.mod b/go.mod index c398f9e7..83a59593 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ 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 @@ -50,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 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")) } diff --git a/internal/table/adaptive.go b/internal/table/adaptive.go new file mode 100644 index 00000000..0f038401 --- /dev/null +++ b/internal/table/adaptive.go @@ -0,0 +1,261 @@ +package table + +import ( + "os" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/charmbracelet/x/ansi" + "golang.org/x/term" +) + +// 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 { + 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. +// +// 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]) + 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) + maxWidths[j] = max(maxWidths[j], w) + if (j < len(noWrap) && noWrap[j]) || i < bodyStart { + fixedWidths[j] = max(fixedWidths[j], w) + } else { + // 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)) + } + } + } + + 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 remainders[b] - remainders[a] }) + for _, j := range order[:available-sum(widths)] { + widths[j]++ + } + return widths +} + +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, +// plus its indentation, which is repeated on each wrapped line. +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)) + } + } + 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. +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(wordwrap(trimmed, max(width-len(indent), 1)), "\n") + if indent != "" { + for i, line := range wrapped { + wrapped[i] = indent + line + } + } + 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 { + // 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 + 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\\)`) +) + +const hyperlinkClose = "\x1b]8;;\x1b\\" + +// 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 { + style, link := "", "" + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = style + link + line + for _, m := range sgrRegex.FindAllStringSubmatch(line, -1) { + if sgrResets(m[1]) { + style = "" + } + if m[1] != "" && m[1] != "0" { + style += m[0] + } + } + 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" + } + } + 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/render.go b/internal/table/render.go new file mode 100644 index 00000000..1d5d3fe6 --- /dev/null +++ b/internal/table/render.go @@ -0,0 +1,139 @@ +package table + +import ( + "io" + "regexp" + "strings" + + "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, `"`, `""`) + `"` + } + return lineBreakRegex.ReplaceAllString(cell, "\n") + } +} + +var plainReplaceRegex = regexp.MustCompile(`[\r\n\t]+`) + +// 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(ansi.Strip(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. +// 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...) + } + if len(all) == 0 || len(all[0]) == 0 { + return nil + } + + // 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", " ") + 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("+") + 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]-ansi.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..67947fce --- /dev/null +++ b/internal/table/table.go @@ -0,0 +1,332 @@ +// 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 + // NoWrap stops the column's cells from being wrapped in the table format. + NoWrap bool +} + +// 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 + // 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. +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 + } + + // 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() { + indexes[name] = i + } + filter := func(row []string) []string { + filtered := make([]string, len(toDisplay)) + for i, name := range toDisplay { + if j, ok := indexes[name]; ok && 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: + noWrap := make([]bool, len(toDisplay)) + for i, name := range toDisplay { + if j, ok := indexes[name]; ok { + noWrap[i] = t.Columns[j].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 + } +} + +// 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..8ea327ff --- /dev/null +++ b/internal/table/table_test.go @@ -0,0 +1,482 @@ +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" +) + +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) { + t.Setenv("COLUMNS", "80") + 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) { + t.Setenv("COLUMNS", "80") + 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.") +} + +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: "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 | | | | ++--------------+------------+--------------------+--------------------+--------+ +`, + }, + { + 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 | ++---+------+ +`, + }, + { + 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()) +} + +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)) + }) + } +} + +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"})) +} + +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"}, + {"\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) + } +} + +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 51b03f3b..3f4bb75c 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 @@ -157,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); @@ -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,17 @@ 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 { + // 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; @@ -296,29 +300,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 +368,26 @@ protected function getMaxContentWidth(int $columnCount): int|float - $paddingQuantity * strlen($style->getPaddingChar()); } + /** + * Get the width of the longest word in a table cell, plus its indentation. + */ + 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)); + } + // Indentation is repeated on each wrapped line. + $width += strlen($plain) - strlen(ltrim($plain, ' ')); + 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..791b7f2a 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,87 @@ 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()); + } + + /** + * 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()); + } + + /** + * 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. *