Skip to content

Commit a33bb79

Browse files
terraform console: Multi-line entry support
The console command, when running in interactive mode, will now detect if the input seems to be an incomplete (but valid enough so far) expression, and if so will produce another prompt to accept another line of expression input. This is primarily to make it easier to paste in multi-line expressions taken from elsewhere, but it could also be used for manual input. The support for multi-line _editing_ is limited by the fact that the readline dependency we use doesn't support multiline input and so we're currently doing this in spite of that library. Hopefully we'll be able to improve on that in future either by contributing multi-line editing support upstream or by switching to a different readline dependency. The delimiter-counting heuristic employed here is similar to the one used by HCL itself to decide whether a newline should end the definition of an attribute, but this implementation is simpler because it doesn't need to produce error messages or perform any recovery. Instead, it just bails out if it encounters something strange so that the console session can return a parse error. Because some invalid input may cause a user to become "stuck" in a multi- line sequence, we consider a blank line as intent to immediately try to evaluate what was entered, and also interpret SIGINT (e.g. Ctrl+C) as cancellation of multi-line input, assuming that at least one line was already entered, extending the previous precedent that SIGINT cancels when at least one character was already entered at the prompt.
1 parent cf3bbb8 commit a33bb79

File tree

3 files changed

+443
-4
lines changed

3 files changed

+443
-4
lines changed

internal/command/console_interactive.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import (
1313
"fmt"
1414
"io"
1515
"os"
16-
17-
"github.com/hashicorp/terraform/internal/repl"
16+
"strings"
1817

1918
"github.com/chzyer/readline"
2019
"github.com/hashicorp/cli"
20+
21+
"github.com/hashicorp/terraform/internal/repl"
2122
)
2223

2324
func (c *ConsoleCommand) modeInteractive(session *repl.Session, ui cli.Ui) int {
@@ -39,20 +40,55 @@ func (c *ConsoleCommand) modeInteractive(session *repl.Session, ui cli.Ui) int {
3940
}
4041
defer l.Close()
4142

43+
// TODO: Currently we're handling multi-line input largely _in spite of_
44+
// the readline library, because it doesn't support that. This means that
45+
// in particular the history treats each line as a separate history entry,
46+
// and doesn't allow editing of previous lines after the user's already
47+
// pressed enter.
48+
//
49+
// Hopefully we can do better than this one day, but having some basic
50+
// support for multi-line input is at least better than none at all:
51+
// this is mainly helpful when pasting in expressions from elsewhere that
52+
// already have newline characters in them, to avoid pre-editing it.
53+
54+
lines := make([]string, 0, 4)
4255
for {
4356
// Read a line
57+
if len(lines) == 0 {
58+
l.SetPrompt("> ")
59+
} else {
60+
l.SetPrompt(": ")
61+
}
4462
line, err := l.Readline()
4563
if err == readline.ErrInterrupt {
46-
if len(line) == 0 {
64+
if len(lines) == 0 && line == "" {
4765
break
66+
} else if line != "" {
67+
continue
4868
} else {
69+
// Reset the entry buffer to start a new expression
70+
lines = lines[:0]
71+
ui.Output("(multi-line entry canceled)")
4972
continue
5073
}
5174
} else if err == io.EOF {
5275
break
5376
}
77+
lines = append(lines, line)
78+
// The following implements a heuristic for deciding if it seems likely
79+
// that the user was intending to continue entering more expression
80+
// characters on a subsequent line. This should get the right answer
81+
// for any valid expression, but might get confused by invalid input.
82+
// The user can always hit enter one more time (entering a blank line)
83+
// to break out of a multi-line sequence and force interpretation of
84+
// what was already entered.
85+
if repl.ExpressionEntryCouldContinue(lines) {
86+
continue
87+
}
5488

55-
out, exit, diags := session.Handle(line)
89+
input := strings.Join(lines, "\n") + "\n"
90+
lines = lines[:0] // reset for next iteration
91+
out, exit, diags := session.Handle(input)
5692
if diags.HasErrors() {
5793
c.showDiagnostics(diags)
5894
}

internal/repl/continuation.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package repl
5+
6+
import (
7+
"strings"
8+
9+
"github.com/hashicorp/hcl/v2"
10+
"github.com/hashicorp/hcl/v2/hclsyntax"
11+
)
12+
13+
// ExpressionEntryCouldContinue is a helper for terraform console's interactive
14+
// mode which serves as a heuristic for whether it seems like the author might
15+
// be trying to split an expression over multiple lines of input.
16+
//
17+
// The current heuristic is whether there's at least one bracketing delimiter
18+
// that isn't closed, but only if any closing brackets already present are
19+
// properly balanced.
20+
//
21+
// This function also always returns false if the last line entered is empty,
22+
// because that seems likely to represent a user trying to force Terraform to
23+
// accept something that didn't pass the heuristic for some reason, at which
24+
// point Terraform can try to evaluate the expression and return an error if
25+
// it's invalid syntax.
26+
func ExpressionEntryCouldContinue(linesSoFar []string) bool {
27+
if len(linesSoFar) == 0 || strings.TrimSpace(linesSoFar[len(linesSoFar)-1]) == "" {
28+
// If there's no input at all or if the last line is empty other than
29+
// spaces, we assume the user is trying to force Terraform to evaluate
30+
// what they entered so far without any further continuation.
31+
return false
32+
}
33+
34+
// We use capacity 8 here as a compromise assuming that most reasonable
35+
// input entered at the console prompt will not use more than eight
36+
// levels of nesting, but even if it does then we'll just reallocate the
37+
// slice and so it's not a big deal.
38+
delimStack := make([]hclsyntax.TokenType, 0, 8)
39+
push := func(typ hclsyntax.TokenType) {
40+
delimStack = append(delimStack, typ)
41+
}
42+
pop := func() hclsyntax.TokenType {
43+
if len(delimStack) == 0 {
44+
return hclsyntax.TokenInvalid
45+
}
46+
ret := delimStack[len(delimStack)-1]
47+
delimStack = delimStack[:len(delimStack)-1]
48+
return ret
49+
}
50+
// We need to scan this all as one string because the HCL lexer has a few
51+
// special cases where it tracks open/close state itself, such as in heredocs.
52+
all := strings.Join(linesSoFar, "\n") + "\n"
53+
toks, diags := hclsyntax.LexExpression([]byte(all), "", hcl.InitialPos)
54+
if diags.HasErrors() {
55+
return false // bail early if the input is already invalid
56+
}
57+
for _, tok := range toks {
58+
switch tok.Type {
59+
case hclsyntax.TokenOBrace, hclsyntax.TokenOBrack, hclsyntax.TokenOParen, hclsyntax.TokenOHeredoc, hclsyntax.TokenTemplateInterp, hclsyntax.TokenTemplateControl:
60+
// Opening delimiters go on our stack so that we can hopefully
61+
// match them with closing delimiters later.
62+
push(tok.Type)
63+
case hclsyntax.TokenCBrace:
64+
open := pop()
65+
if open != hclsyntax.TokenOBrace {
66+
return false
67+
}
68+
case hclsyntax.TokenCBrack:
69+
open := pop()
70+
if open != hclsyntax.TokenOBrack {
71+
return false
72+
}
73+
case hclsyntax.TokenCParen:
74+
open := pop()
75+
if open != hclsyntax.TokenOParen {
76+
return false
77+
}
78+
case hclsyntax.TokenCHeredoc:
79+
open := pop()
80+
if open != hclsyntax.TokenOHeredoc {
81+
return false
82+
}
83+
case hclsyntax.TokenTemplateSeqEnd:
84+
open := pop()
85+
if open != hclsyntax.TokenTemplateInterp && open != hclsyntax.TokenTemplateControl {
86+
return false
87+
}
88+
}
89+
}
90+
91+
// If we get here without returning early then all of the closing delimeters
92+
// were matched by opening delimiters. If our stack still contains at least
93+
// one opening bracket then it seems like the user is intending to type
94+
// more.
95+
return len(delimStack) != 0
96+
}

0 commit comments

Comments
 (0)