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=