Fix cmdutil.PrintTable to handle ansi escapes and non-byte glyphs (#8344)

Fixes two bugs in how padding was calculated in PrintTable.

Firstly we remove all ANSI escape codes from the string before measuring
how wide it is. Secondly we measure glyph count (using rivo/uniseg) not
byte or rune count of the string.

Together these fix the padding/alignment issues I saw when using
PrintTable with plan output. They also slightly change the layout of
"pulumi stack", for example the below is printed with current master and
has 6 characters of space for padding between SecurityGroup and
web-secgrp:

```
Current stack resources (4):
    TYPE                                        NAME
    pulumi:pulumi:Stack                         aws-cs-webserver-test
    ├─ aws:ec2/securityGroup:SecurityGroup      web-secgrp
    ├─ aws:ec2/instance:Instance                web-server-www
    └─ pulumi:providers:aws                     default_4_25_0
```

While printed with this commit you only get 2 characters of space for
padding (which is correct, the column gap is set to "  "):
```
Current stack resources (4):
    TYPE                                    NAME
    pulumi:pulumi:Stack                     aws-cs-webserver-test
    ├─ aws:ec2/securityGroup:SecurityGroup  web-secgrp
    ├─ aws:ec2/instance:Instance            web-server-www
    └─ pulumi:providers:aws                 default_4_25_0
```
This commit is contained in:
Fraser Waters 2021-11-04 10:06:20 +00:00 committed by GitHub
parent 7dd1865575
commit b58c39476f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 161 additions and 23 deletions

View file

@ -129,6 +129,7 @@ require (
github.com/opentracing/basictracer-go v1.0.0 // indirect
github.com/pierrec/lz4 v2.6.0+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect

View file

@ -574,6 +574,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8=
github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=

View file

@ -26,6 +26,7 @@ require (
github.com/opentracing/basictracer-go v1.0.0 // indirect
github.com/opentracing/opentracing-go v1.1.0
github.com/pkg/errors v0.9.1
github.com/rivo/uniseg v0.2.0
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94
github.com/sergi/go-diff v1.1.0 // indirect
github.com/spf13/cast v1.3.1

View file

@ -164,6 +164,8 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0=

View file

@ -17,10 +17,11 @@ package cmdutil
import (
"fmt"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/rivo/uniseg"
"golang.org/x/crypto/ssh/terminal"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/ciutil"
@ -139,6 +140,18 @@ func (table Table) String() string {
return table.ToStringWithGap(" ")
}
// 7-bit C1 ANSI sequences
var ansiEscape = regexp.MustCompile(`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`)
// MeasureText returns the number of glyphs in a string.
// Importantly this also ignores ANSI escape sequences, so can be used to calculate layout of colorized strings.
func MeasureText(text string) int {
// Strip ansi escape sequences
clean := ansiEscape.ReplaceAllString(text, "")
// Need to count graphemes not runes or bytes
return uniseg.GraphemeClusterCount(clean)
}
func (table *Table) ToStringWithGap(columnGap string) string {
columnCount := len(table.Headers)
@ -161,36 +174,36 @@ func (table *Table) ToStringWithGap(columnGap string) string {
}
for columnIndex, val := range columns {
preferredColumnWidths[columnIndex] = max(preferredColumnWidths[columnIndex], len(val))
preferredColumnWidths[columnIndex] = max(preferredColumnWidths[columnIndex], MeasureText(val))
}
}
format := ""
for i, maxWidth := range preferredColumnWidths {
if i < len(preferredColumnWidths)-1 {
format += "%-" + strconv.Itoa(maxWidth+len(columnGap)) + "s"
} else {
result := ""
for _, row := range allRows {
result += table.Prefix
for columnIndex, val := range row.Columns {
result += val
if columnIndex < columnCount-1 {
// Work out how much whitespace we need to add to this string to bring it up to the
// preferredColumnWidth for this column.
maxWidth := preferredColumnWidths[columnIndex]
padding := maxWidth - MeasureText(val)
result += strings.Repeat(" ", padding)
// Now, ensure we have the requested gap between columns as well.
result += columnGap
}
// do not want whitespace appended to the last column. It would cause wrapping on lines
// that were not actually long if some other line was very long.
format += "%s"
}
}
format += "\n"
result := ""
columns := make([]interface{}, columnCount)
for _, row := range allRows {
for columnIndex, value := range row.Columns {
// Now, ensure we have the requested gap between columns as well.
if columnIndex < columnCount-1 {
value += columnGap
}
columns[columnIndex] = value
}
result += fmt.Sprintf(table.Prefix+format, columns...)
result += "\n"
if row.AdditionalInfo != "" {
result += fmt.Sprint(row.AdditionalInfo)
result += row.AdditionalInfo
}
}
return result

View file

@ -0,0 +1,116 @@
// Copyright 2016-2021, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmdutil
import (
"regexp"
"testing"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
"github.com/stretchr/testify/assert"
)
func TestMeasureText(t *testing.T) {
cases := []struct {
text string
expected int
}{
{
text: "",
expected: 0,
},
{
text: "a",
expected: 1,
},
{
text: "├",
expected: 1,
},
{
text: "├─ ",
expected: 4,
},
{
text: "\x1b[4m\x1b[38;5;12mType\x1b[0m",
expected: 4,
},
}
for _, c := range cases {
t.Run(c.text, func(t *testing.T) {
count := MeasureText(c.text)
assert.Equal(t, c.expected, count)
})
}
}
func TestTablePrinting(t *testing.T) {
rows := []TableRow{
{Columns: []string{"A", "B", "C"}},
{Columns: []string{"Some A", "B", "Some C"}},
}
table := &Table{
Headers: []string{"ColumnA", "Long column B", "C"},
Rows: rows,
Prefix: " ",
}
expected := "" +
" ColumnA Long column B C\n" +
" A B C\n" +
" Some A B Some C\n"
assert.Equal(t, expected, table.ToStringWithGap(" "))
}
func TestColorTablePrinting(t *testing.T) {
greenText := func(msg string) string {
return colors.Always.Colorize(colors.Green + msg + colors.Reset)
}
rows := []TableRow{
{Columns: []string{greenText("+"), "pulumi:pulumi:Stack", "aws-cs-webserver-test", greenText("create")}},
{Columns: []string{greenText("+"), "├─ aws:ec2/instance:Instance", "web-server-www", greenText("create")}},
{Columns: []string{greenText("+"), "├─ aws:ec2/securityGroup:SecurityGroup", "web-secgrp", greenText("create")}},
{Columns: []string{greenText("+"), "└─ pulumi:providers:aws", "default_4_25_0", greenText("create")}},
}
columnHeader := func(msg string) string {
return colors.Always.Colorize(colors.Underline + colors.BrightBlue + msg + colors.Reset)
}
table := &Table{
Headers: []string{"", columnHeader("Type"), columnHeader("Name"), columnHeader("Plan")},
Rows: rows,
Prefix: " ",
}
expected := "" +
" Type Name Plan\n" +
" + pulumi:pulumi:Stack aws-cs-webserver-test create\n" +
" + ├─ aws:ec2/instance:Instance web-server-www create\n" +
" + ├─ aws:ec2/securityGroup:SecurityGroup web-secgrp create\n" +
" + └─ pulumi:providers:aws default_4_25_0 create\n"
colorTable := table.ToStringWithGap(" ")
// 7-bit C1 ANSI sequences
ansiEscape := regexp.MustCompile(`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`)
cleanTable := ansiEscape.ReplaceAllString(colorTable, "")
assert.Equal(t, expected, cleanTable)
}

View file

@ -97,6 +97,7 @@ require (
github.com/pierrec/lz4 v2.6.0+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rjeczalik/notify v0.9.2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect

View file

@ -567,6 +567,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8=
github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=