pulumi/pkg/codegen/hcl2/model/pretty/display.go

615 lines
14 KiB
Go
Raw Normal View History

// Copyright 2016-2022, 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.
// pretty is an extensible utility library to pretty-print nested structures.
package pretty
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
)
const (
DefaultColumns int = 100
DefaultIndent string = " "
)
// Run the String() function for a formatter.
func fmtString(f Formatter) string {
tg := newTagGenerator()
f.visit(tg.visit)
return f.string(tg)
}
// The value type for `tagGenerator`.
type visitedFormatter struct {
// The tag associated with a `Formatter`. If no tag is associated with the
// `Formatter`, the tag is "".
tag string
// The number of occurrences of the `Formatter` at the current node. If the number
// ever exceeds 1, it indicates the type is recursive.
count int
}
type tagGenerator struct {
// Go `Formatter`s already seen in the traversal.
//
// valueSeen exists to prevent infinite recursion when visiting types to detect
// structural recursion. Since all `Formatter`s are pointer types, `valueSeen` allows
// multiple types that are structurally the same, but different values in memory.
valueSeen map[Formatter]bool
// A cache of `Formatter` to their hash values.
//
// Since hashing is O(n), it is helpful to store
knownHashes map[Formatter]string
// The "hash" of `Formatter`s already seen.
//
// structuralSeen exists to prevent infinite recursion when printing types, and
// operates at the level of structural equality (ignoring pointers).
structuralSeen map[string]visitedFormatter
// Type tags are labeled by occurrence, so we keep track of how many have been
// generated.
generatedTags int
}
func newTagGenerator() *tagGenerator {
return &tagGenerator{
valueSeen: map[Formatter]bool{},
knownHashes: map[Formatter]string{},
structuralSeen: map[string]visitedFormatter{},
}
}
// Visit a type.
//
// A function is returned to be called when leaving the type. nil indicates that the type
// is already visited.
func (s *tagGenerator) visit(f Formatter) func() {
h := s.hash(f)
seen := s.structuralSeen[h]
seen.count++
s.structuralSeen[h] = seen
if seen.count > 1 {
return nil
}
return func() { c := s.structuralSeen[h]; c.count--; s.structuralSeen[h] = c }
}
func (s *tagGenerator) hash(f Formatter) string {
h, ok := s.knownHashes[f]
if !ok {
h = f.hash(s.valueSeen)
s.knownHashes[f] = h
}
return h
}
// Fetch a tag for a Formatter, if applicable.
//
// If no tag is necessary "", false is returned.
//
// If f is the defining instance of the type and should be labeled with the tag T; T,
// false is returned.
//
// If f is an inner usage of a Formatter with tag T and thus should not be printed; T,
// true is returned.
func (s *tagGenerator) tag(f Formatter) (tag string, tagOnly bool) {
h := s.hash(f)
seen, hasValue := s.structuralSeen[h]
if !hasValue {
// All values should have been visited before printing.
panic(fmt.Sprintf("Unexpected new value: h=%q", h))
}
// We don't need to tag this type, since it only shows up once
if seen.count == 0 {
return "", false
}
if seen.tag != "" {
// We have seen this type before, so we want to return the tag and the tag alone.
return seen.tag, true
}
// We are generating a new tag for a type that needs a tag.
s.generatedTags++
tag = fmt.Sprintf("'T%d", s.generatedTags)
s.structuralSeen[h] = visitedFormatter{
tag: tag,
count: seen.count,
}
return tag, false
}
// A formatter understands how to turn itself into a string while respecting a desired
// column target.
type Formatter interface {
fmt.Stringer
// Set the number of columns to print out.
// This method does not mutate.
Columns(int) Formatter
// Set the columns for the Formatter and return the receiver.
columns(int) Formatter
// An inner print function
string(tg *tagGenerator) string
// Visit each underlying Formatter
visit(visitor func(Formatter) func())
// A structural id/hash of the underlying Formatter
hash(seen map[Formatter]bool) string
}
// Indent a (multi-line) string, passing on column adjustments.
type indent struct {
// The prefix to be applied to each line
prefix string
inner Formatter
}
func sanitizeColumns(i int) int {
if i <= 0 {
return 1
}
return i
}
func (i *indent) hash(seen map[Formatter]bool) string {
seen[i] = true
return fmt.Sprintf("i%s%s", i.prefix, i.inner.hash(seen))
}
func (i *indent) columns(columns int) Formatter {
i.inner = i.inner.columns(columns - len(i.prefix))
return i
}
func (i indent) Columns(columns int) Formatter {
return i.columns(columns)
}
func (i indent) String() string {
return fmtString(&i)
}
func (i *indent) visit(visitor func(Formatter) func()) {
leave := visitor(i)
if leave == nil {
return
}
defer leave()
i.inner.visit(visitor)
}
func (i indent) string(tg *tagGenerator) string {
lines := strings.Split(i.inner.string(tg), "\n")
for j, l := range lines {
lines[j] = i.prefix + l
}
return strings.Join(lines, "\n")
}
// Create a new Formatter of a raw string.
func FromString(s string) Formatter {
return &literal{s: s}
}
// Create a new Formatter from a fmt.Stringer.
func FromStringer(s fmt.Stringer) Formatter {
return &literal{t: s}
}
// A string literal that implements Formatter (ignoring Column).
type literal struct {
// A source for a string.
t fmt.Stringer
// A raw string.
s string
}
func (b *literal) visit(visiter func(Formatter) func()) {
// We don't need to do anything here
leave := visiter(b)
if leave != nil {
leave()
}
}
func (b *literal) string(tg *tagGenerator) string {
// If we don't have a cached value, but we can compute one,
if b.s == "" && b.t != nil {
// Set the known value to the computed value
b.s = b.t.String()
// Nil the value source, since we won't need it again, and it might produce "".
b.t = nil
}
return b.s
}
func (b *literal) String() string {
return fmtString(b)
}
func (b *literal) hash(seen map[Formatter]bool) string {
seen[b] = true
return strconv.Quote(b.string(nil))
}
func (b *literal) columns(int) Formatter {
// We are just calling .String() here, so we can't do anything with columns.
return b
}
func (b literal) Columns(columns int) Formatter {
return b.columns(columns)
}
// A Formatter that wraps an inner value with prefixes and postfixes.
//
// Wrap attempts to respect its column target by changing if the prefix and postfix are on
// the same line as the inner value, or their own lines.
//
// As an example, consider the following instance of Wrap:
//
// Wrap {
// Prefix: "number(", Postfix: ")"
// Value: FromString("123456")
// }
//
// It could be rendered as
//
// number(123456)
//
// or
//
// number(
// 123456
// )
//
// depending on the column constrains.
type Wrap struct {
Prefix, Postfix string
// Require that the Postfix is always on the same line as Value.
PostfixSameline bool
Value Formatter
cols int
}
func (w *Wrap) String() string {
return fmtString(w)
}
func (w *Wrap) visit(visitor func(Formatter) func()) {
leave := visitor(w)
if leave == nil {
return
}
defer leave()
w.Value.visit(visitor)
}
func (w Wrap) hash(seen map[Formatter]bool) string {
return fmt.Sprintf("w(%s,%s,%s)", w.Prefix, w.Value.hash(seen), w.Postfix)
}
func (w *Wrap) string(tg *tagGenerator) string {
columns := w.cols
if columns == 0 {
columns = DefaultColumns
}
inner := w.Value.columns(columns - len(w.Prefix) - len(w.Postfix)).string(tg)
lines := strings.Split(inner, "\n")
if len(lines) == 1 {
// Full result on one line, and it fits
if len(inner)+len(w.Prefix)+len(w.Postfix) < columns ||
// Or its more efficient to include the wrapping instead of the indent.
len(w.Prefix)+len(w.Postfix) < len(DefaultIndent) {
return w.Prefix + inner + w.Postfix
}
// Print the prefix and postix on their own line, then indent the inner value.
pre := w.Prefix
if pre != "" {
pre += "\n"
}
post := w.Postfix
if post != "" && !w.PostfixSameline {
post = "\n" + post
}
if w.PostfixSameline {
columns -= len(w.Postfix)
}
return pre + (&indent{
prefix: DefaultIndent,
inner: w.Value,
}).columns(columns).string(tg) + post
}
// See if we can afford to wrap the prefix & postfix around the first and last lines.
separate := (w.Prefix != "" && len(w.Prefix)+len(lines[0]) >= columns) ||
(w.Postfix != "" && len(w.Postfix)+len(lines[len(lines)-1]) >= columns && !w.PostfixSameline)
if !separate {
return w.Prefix + inner + w.Postfix
}
s := w.Prefix
if w.Prefix != "" {
s += "\n"
}
s += (&indent{
prefix: DefaultIndent,
inner: w.Value,
}).columns(columns).string(tg)
if w.Postfix != "" && !w.PostfixSameline {
s += "\n" + w.Postfix
}
return s
}
func (w *Wrap) columns(i int) Formatter {
w.cols = sanitizeColumns(i)
return w
}
func (w Wrap) Columns(columns int) Formatter {
return w.columns(columns)
}
// Object is a Formatter that prints string-Formatter pairs, respecting columns where
// possible.
//
// It does this by deciding if the object should be compressed into a single line, or have
// one field per line.
type Object struct {
Properties map[string]Formatter
cols int
}
func (o *Object) String() string {
return fmtString(o)
}
func (o *Object) hash(seen map[Formatter]bool) string {
if seen[o] {
Enable perfsprint linter (#14813) <!--- Thanks so much for your contribution! If this is your first time contributing, please ensure that you have read the [CONTRIBUTING](https://github.com/pulumi/pulumi/blob/master/CONTRIBUTING.md) documentation. --> # Description <!--- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. --> Prompted by a comment in another review: https://github.com/pulumi/pulumi/pull/14654#discussion_r1419995945 This lints that we don't use `fmt.Errorf` when `errors.New` will suffice, it also covers a load of other cases where `Sprintf` is sub-optimal. Most of these edits were made by running `perfsprint --fix`. ## Checklist - [x] I have run `make tidy` to update any new dependencies - [x] I have run `make lint` to verify my code passes the lint check - [x] I have formatted my code using `gofumpt` <!--- Please provide details if the checkbox below is to be left unchecked. --> - [ ] I have added tests that prove my fix is effective or that my feature works <!--- User-facing changes require a CHANGELOG entry. --> - [ ] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change <!-- If the change(s) in this PR is a modification of an existing call to the Pulumi Cloud, then the service should honor older versions of the CLI where this change would not exist. You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add it to the service. --> - [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Cloud API version <!-- @Pulumi employees: If yes, you must submit corresponding changes in the service repo. -->
2023-12-12 12:19:42 +00:00
return strconv.Itoa(len(seen))
}
defer func() { seen[o] = false }()
seen[o] = true
s := "o("
keys := slice.Prealloc[string](len(o.Properties))
for key := range o.Properties {
keys = append(keys, key)
}
sort.StringSlice(keys).Sort()
for i, k := range keys {
if i != 0 {
s += ","
}
s += k + ":" + o.Properties[k].hash(seen)
}
return s + ")"
}
func (o *Object) visit(visiter func(Formatter) func()) {
leave := visiter(o)
if leave == nil {
return
}
defer leave()
// Check if we can do the whole object in a single line
keys := slice.Prealloc[string](len(o.Properties))
for key := range o.Properties {
keys = append(keys, key)
}
sort.StringSlice(keys).Sort()
for _, k := range keys {
o.Properties[k].visit(visiter)
}
}
func (o *Object) string(tg *tagGenerator) string {
if len(o.Properties) == 0 {
return "{}"
}
columns := o.cols
if columns <= 0 {
columns = DefaultColumns
}
tag, tagOnly := tg.tag(o)
if tagOnly {
return tag
}
if tag != "" {
tag += " "
}
// Check if we can do the whole object in a single line
keys := slice.Prealloc[string](len(o.Properties))
for key := range o.Properties {
keys = append(keys, key)
}
sort.StringSlice(keys).Sort()
// Try to build the object in a single line
singleLine := true
s := tag + "{ "
overflowing := func() bool {
return columns < len(s)-1
}
for i, key := range keys {
s += key + ": "
v := o.Properties[key].columns(columns - len(s) - 1).string(tg)
if strings.ContainsRune(v, '\n') {
singleLine = false
break
}
if i+1 < len(keys) {
v += ","
}
s += v + " "
if overflowing() {
// The object is too big for a single line. Give up and create a multi-line
// object.
singleLine = false
break
}
}
if singleLine {
return s + "}"
}
// reset for a mutl-line object.
s = tag + "{\n"
for _, key := range keys {
s += (&indent{
prefix: DefaultIndent,
inner: &Wrap{
Prefix: key + ": ",
Postfix: ",",
PostfixSameline: true,
Value: o.Properties[key],
},
}).columns(columns).string(tg) + "\n"
}
return s + "}"
}
func (o *Object) columns(i int) Formatter {
o.cols = sanitizeColumns(i)
return o
}
func (o *Object) Columns(columns int) Formatter {
return o.columns(columns)
}
// An ordered set of items displayed with a separator between them.
//
// Items can be displayed on a single line if it fits within the column constraint.
// Otherwise items will be displayed across multiple lines.
type List struct {
Elements []Formatter
Separator string
AdjoinSeparator bool
cols int
}
func (l *List) String() string {
return fmtString(l)
}
func (l *List) hash(seen map[Formatter]bool) string {
if seen[l] {
Enable perfsprint linter (#14813) <!--- Thanks so much for your contribution! If this is your first time contributing, please ensure that you have read the [CONTRIBUTING](https://github.com/pulumi/pulumi/blob/master/CONTRIBUTING.md) documentation. --> # Description <!--- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. --> Prompted by a comment in another review: https://github.com/pulumi/pulumi/pull/14654#discussion_r1419995945 This lints that we don't use `fmt.Errorf` when `errors.New` will suffice, it also covers a load of other cases where `Sprintf` is sub-optimal. Most of these edits were made by running `perfsprint --fix`. ## Checklist - [x] I have run `make tidy` to update any new dependencies - [x] I have run `make lint` to verify my code passes the lint check - [x] I have formatted my code using `gofumpt` <!--- Please provide details if the checkbox below is to be left unchecked. --> - [ ] I have added tests that prove my fix is effective or that my feature works <!--- User-facing changes require a CHANGELOG entry. --> - [ ] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change <!-- If the change(s) in this PR is a modification of an existing call to the Pulumi Cloud, then the service should honor older versions of the CLI where this change would not exist. You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add it to the service. --> - [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Cloud API version <!-- @Pulumi employees: If yes, you must submit corresponding changes in the service repo. -->
2023-12-12 12:19:42 +00:00
return strconv.Itoa(len(seen))
}
defer func() { seen[l] = false }()
seen[l] = true
s := "(l," + l.Separator
for _, el := range l.Elements {
s += "," + el.hash(seen)
}
return s + ")"
}
func (l *List) visit(visiter func(Formatter) func()) {
leave := visiter(l)
if leave == nil {
return
}
defer leave()
for _, el := range l.Elements {
el.visit(visiter)
}
}
func (l *List) string(tg *tagGenerator) string {
tag, tagOnly := tg.tag(l)
if tagOnly {
return tag
}
if tag != "" {
tag += " "
}
columns := l.cols
if columns <= 0 {
columns = DefaultColumns
}
s := tag
singleLine := true
for i, el := range l.Elements {
v := el.columns(columns - len(s)).string(tg)
if strings.ContainsRune(v, '\n') {
singleLine = false
break
}
s += v
if i+1 < len(l.Elements) {
s += l.Separator
}
if len(s) > columns {
singleLine = false
break
}
}
if singleLine {
return s
}
s = tag
if l.AdjoinSeparator {
separator := strings.TrimRight(l.Separator, " ")
for i, el := range l.Elements {
v := el.columns(columns - len(separator)).string(tg)
if i+1 != len(l.Elements) {
v += separator + "\n"
}
s += v
}
return s
}
separator := strings.TrimLeft(l.Separator, " ")
for i, el := range l.Elements {
v := (&indent{
prefix: strings.Repeat(" ", len(separator)),
inner: el,
}).columns(columns).string(tg)
if i != 0 {
v = "\n" + separator + v[len(separator):]
}
s += v
}
return s
}
func (l *List) columns(i int) Formatter {
l.cols = sanitizeColumns(i)
return l
}
func (l List) Columns(columns int) Formatter {
return l.columns(columns)
}