backend/local: Use terminal properties to tweak the plan output

We now require the output to accept UTF-8 and we can determine how wide
the terminal (if any) is, so here we begin to make use of that for the
"terraform plan" command.

The horizontal rule is now made of box drawing characters instead of
hyphens and fills the whole terminal width.

The paragraphs of text in the output are now also wrapped to fill the
terminal width, instead of the hard-wrapping we did before.

This is just a start down the road of making better use of the terminal
capabilities. Lots of other commands could benefit from updates like these
too.
This commit is contained in:
Martin Atkins 2021-01-11 18:29:39 -08:00
parent d2c3403ab6
commit e6a516d87e
7 changed files with 102 additions and 34 deletions

View File

@ -32,6 +32,8 @@ func (b *Local) opPlan(
var diags tfdiags.Diagnostics
outputColumns := b.outputColumns()
if op.PlanFile != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
@ -150,6 +152,7 @@ func (b *Local) opPlan(
if runningOp.PlanEmpty {
b.CLI.Output("\n" + b.Colorize().Color(strings.TrimSpace(planNoChanges)))
b.CLI.Output("\n" + strings.TrimSpace(format.WordWrap(planNoChangesDetail, outputColumns)))
// Even if there are no changes, there still could be some warnings
b.ShowDiagnostics(diags)
return
@ -166,15 +169,15 @@ func (b *Local) opPlan(
// tool which is presumed to provide its own UI for further actions.
if !b.RunningInAutomation {
b.CLI.Output("\n------------------------------------------------------------------------")
b.outputHorizRule()
if path := op.PlanOutPath; path == "" {
b.CLI.Output(fmt.Sprintf(
"\n" + strings.TrimSpace(planHeaderNoOutput) + "\n",
"\n" + strings.TrimSpace(format.WordWrap(planHeaderNoOutput, outputColumns)) + "\n",
))
} else {
b.CLI.Output(fmt.Sprintf(
"\n"+strings.TrimSpace(planHeaderYesOutput)+"\n",
"\n"+strings.TrimSpace(format.WordWrap(planHeaderYesOutput, outputColumns))+"\n",
path, path,
))
}
@ -183,7 +186,7 @@ func (b *Local) opPlan(
}
func (b *Local) renderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) {
RenderPlan(plan, baseState, schemas, b.CLI, b.Colorize())
RenderPlan(plan, baseState, schemas, b.CLI, b.Colorize(), b.outputColumns())
}
// RenderPlan renders the given plan to the given UI.
@ -206,7 +209,7 @@ func (b *Local) renderPlan(plan *plans.Plan, baseState *states.State, schemas *t
// output values will not currently be rendered because their prior values
// are currently stored only in the prior state. (see the docstring for
// func planHasSideEffects for why this is and when that might change)
func RenderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, ui cli.Ui, colorize *colorstring.Colorize) {
func RenderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, ui cli.Ui, colorize *colorstring.Colorize, width int) {
counts := map[plans.Action]int{}
var rChanges []*plans.ResourceInstanceChangeSrc
for _, change := range plan.Changes.Resources {
@ -220,7 +223,7 @@ func RenderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Sc
}
headerBuf := &bytes.Buffer{}
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(planHeaderIntro))
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, width)))
if counts[plans.Create] > 0 {
fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create))
}
@ -330,18 +333,15 @@ func RenderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Sc
}
const planHeaderIntro = `
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
`
const planHeaderNoOutput = `
Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
`
const planHeaderYesOutput = `
This plan was saved to: %s
Saved the plan to: %s
To perform exactly these actions, run the following command to apply:
terraform apply %q
@ -349,8 +349,8 @@ To perform exactly these actions, run the following command to apply:
const planNoChanges = `
[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green]
This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, no
actions need to be performed.
`
const planNoChangesDetail = `
That Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take.
`

View File

@ -51,7 +51,7 @@ func TestLocal_planInAutomation(t *testing.T) {
defer cleanup()
TestLocalProvider(t, b, "test", planFixtureSchema())
const msg = `You didn't specify an "-out" parameter`
const msg = `You didn't use the -out option`
// When we're "in automation" we omit certain text from the
// plan output. However, testing for the absense of text is
@ -77,7 +77,7 @@ func TestLocal_planInAutomation(t *testing.T) {
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, msg) {
t.Fatalf("missing next-steps message when not in automation")
t.Fatalf("missing next-steps message when not in automation\nwant: %s\noutput:\n%s", msg, output)
}
}
@ -331,8 +331,8 @@ func TestLocal_planTainted(t *testing.T) {
t.Fatal("plan should not be empty")
}
expectedOutput := `An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
expectedOutput := `Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
Terraform will perform the following actions:
@ -433,8 +433,8 @@ func TestLocal_planDeposedOnly(t *testing.T) {
// it's also possible for there to be _multiple_ deposed objects, in the
// unlikely event that create_before_destroy _keeps_ crashing across
// subsequent runs.
expectedOutput := `An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
expectedOutput := `Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
- destroy
@ -507,8 +507,8 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) {
t.Fatal("plan should not be empty")
}
expectedOutput := `An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
expectedOutput := `Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+/- create replacement and then destroy
Terraform will perform the following actions:

View File

@ -4,6 +4,7 @@ import (
"log"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/format"
)
// backend.CLI impl.
@ -63,3 +64,17 @@ func (b *Local) errorColumns() int {
}
return b.Streams.Stderr.Columns()
}
// outputHorizRule will call b.CLI.Output with enough horizontal line
// characters to fill an entire row of output.
//
// This function does nothing if the backend doesn't have a CLI attached.
//
// If UI color is enabled, the rule will get a dark grey coloring to try to
// visually de-emphasize it.
func (b *Local) outputHorizRule() {
if b.CLI == nil {
return
}
b.CLI.Output(format.HorizontalRule(b.CLIColor, b.outputColumns()))
}

View File

@ -59,8 +59,8 @@ func TestPrimarySeparatePlan(t *testing.T) {
t.Errorf("incorrect plan tally; want 1 to add:\n%s", stdout)
}
if !strings.Contains(stdout, "This plan was saved to: tfplan") {
t.Errorf("missing \"This plan was saved to...\" message in plan output\n%s", stdout)
if !strings.Contains(stdout, "Saved the plan to: tfplan") {
t.Errorf("missing \"Saved the plan to...\" message in plan output\n%s", stdout)
}
if !strings.Contains(stdout, "terraform apply \"tfplan\"") {
t.Errorf("missing next-step instruction in plan output\n%s", stdout)
@ -169,8 +169,8 @@ func TestPrimaryChdirOption(t *testing.T) {
t.Errorf("incorrect plan tally; want 0 to add:\n%s", stdout)
}
if !strings.Contains(stdout, "This plan was saved to: tfplan") {
t.Errorf("missing \"This plan was saved to...\" message in plan output\n%s", stdout)
if !strings.Contains(stdout, "Saved the plan to: tfplan") {
t.Errorf("missing \"Saved the plan to...\" message in plan output\n%s", stdout)
}
if !strings.Contains(stdout, "terraform apply \"tfplan\"") {
t.Errorf("missing next-step instruction in plan output\n%s", stdout)

55
command/format/trivia.go Normal file
View File

@ -0,0 +1,55 @@
package format
import (
"strings"
"github.com/mitchellh/colorstring"
wordwrap "github.com/mitchellh/go-wordwrap"
)
// HorizontalRule returns a newline character followed by a number of
// horizontal line characters to fill the given width.
//
// If the given colorize has colors enabled, the rule will also be given a
// dark grey color to attempt to visually de-emphasize it for sighted users.
//
// This is intended for printing to the UI via mitchellh/cli.UI.Output, or
// similar, which will automatically append a trailing newline too.
func HorizontalRule(color *colorstring.Colorize, width int) string {
rule := strings.Repeat("─", width)
if color == nil { // sometimes unit tests don't populate this properly
return "\n" + rule
}
return color.Color("[dark_gray]\n" + rule)
}
// WordWrap takes a string containing unbroken lines of text and inserts
// newline characters to try to make the text fit within the given width.
//
// The string can already contain newline characters, for example if you are
// trying to render multiple paragraphs of text. (In that case, our usual
// style would be to have _two_ newline characters as the paragraph separator.)
//
// As a special case, any line that begins with at least one space will be left
// unbroken. This allows including literal segments in the output, such as
// code snippets or filenames, where word wrapping would be confusing.
func WordWrap(str string, width int) string {
if width == 0 {
// Silly edge case. We'll just return the original string to avoid
// panicking or doing other weird stuff.
return str
}
var buf strings.Builder
lines := strings.Split(str, "\n")
for i, line := range lines {
if !strings.HasPrefix(line, " ") {
line = wordwrap.WrapString(line, uint(width))
}
if i > 0 {
buf.WriteByte('\n') // reintroduce the newlines we skipped in Scan
}
buf.WriteString(line)
}
return buf.String()
}

View File

@ -629,6 +629,8 @@ func (m *Meta) showDiagnostics(vals ...interface{}) {
return
}
outputWidth := m.ErrorColumns()
diags = diags.ConsolidateWarnings(1)
// Since warning messages are generally competing
@ -654,11 +656,7 @@ func (m *Meta) showDiagnostics(vals ...interface{}) {
}
for _, diag := range diags {
// TODO: Actually measure the terminal width and pass it here.
// For now, we don't have easy access to the writer that
// ui.Error (etc) are writing to and thus can't interrogate
// to see if it's a terminal and what size it is.
msg := format.Diagnostic(diag, m.configSources(), m.Colorize(), 78)
msg := format.Diagnostic(diag, m.configSources(), m.Colorize(), outputWidth)
switch diag.Severity() {
case tfdiags.Error:
m.Ui.Error(msg)

View File

@ -166,7 +166,7 @@ func (c *ShowCommand) Run(args []string) int {
// package rather than in the backends themselves, but for now we're
// accepting this oddity because "terraform show" is a less commonly
// used way to render a plan than "terraform plan" is.
localBackend.RenderPlan(plan, stateFile.State, schemas, c.Ui, c.Colorize())
localBackend.RenderPlan(plan, stateFile.State, schemas, c.Ui, c.Colorize(), c.OutputColumns())
return 0
}