From b58c39476f822a8f0e2966d737d642c255d92505 Mon Sep 17 00:00:00 2001 From: Fraser Waters Date: Thu, 4 Nov 2021 10:06:20 +0000 Subject: [PATCH] Fix cmdutil.PrintTable to handle ansi escapes and non-byte glyphs (#8344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``` --- pkg/go.mod | 1 + pkg/go.sum | 2 + sdk/go.mod | 1 + sdk/go.sum | 2 + sdk/go/common/util/cmdutil/console.go | 59 +++++++---- sdk/go/common/util/cmdutil/console_test.go | 116 +++++++++++++++++++++ tests/go.mod | 1 + tests/go.sum | 2 + 8 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 sdk/go/common/util/cmdutil/console_test.go diff --git a/pkg/go.mod b/pkg/go.mod index 559f67075..f7c215450 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -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 diff --git a/pkg/go.sum b/pkg/go.sum index 9594ac88c..d1ace4103 100644 --- a/pkg/go.sum +++ b/pkg/go.sum @@ -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= diff --git a/sdk/go.mod b/sdk/go.mod index fa6c9a614..ee882b993 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -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 diff --git a/sdk/go.sum b/sdk/go.sum index 4cf99186f..62d5cf162 100644 --- a/sdk/go.sum +++ b/sdk/go.sum @@ -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= diff --git a/sdk/go/common/util/cmdutil/console.go b/sdk/go/common/util/cmdutil/console.go index 87d875f0c..ff77c06ed 100644 --- a/sdk/go/common/util/cmdutil/console.go +++ b/sdk/go/common/util/cmdutil/console.go @@ -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 diff --git a/sdk/go/common/util/cmdutil/console_test.go b/sdk/go/common/util/cmdutil/console_test.go new file mode 100644 index 000000000..2ed213e2b --- /dev/null +++ b/sdk/go/common/util/cmdutil/console_test.go @@ -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) +} diff --git a/tests/go.mod b/tests/go.mod index 0474294d2..01954b019 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -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 diff --git a/tests/go.sum b/tests/go.sum index aae236ec7..fac3e6f94 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -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=