diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cc324f9c..f4dea29cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ENHANCEMENTS: * Added `-show-sensitive` flag to tofu plan, apply, state-show and output commands to display sensitive data in output. ([#1554](https://github.com/opentofu/opentofu/pull/1554)) * Improved performance for large graphs when debug logs are not enabled. ([#1810](https://github.com/opentofu/opentofu/pull/1810)) * Improved performance for large graphs with many submodules. ([#1809](https://github.com/opentofu/opentofu/pull/1809)) +* Added mutli-line support to the `tofu console` command. ([#1307](https://github.com/opentofu/opentofu/issues/1307)) BUG FIXES: * Ensure that using a sensitive path for templatefile that it doesn't panic([#1801](https://github.com/opentofu/opentofu/issues/1801)) diff --git a/internal/command/console.go b/internal/command/console.go index 738e2ea4f8..1449ed38be 100644 --- a/internal/command/console.go +++ b/internal/command/console.go @@ -179,26 +179,31 @@ func (c *ConsoleCommand) Run(args []string) int { } func (c *ConsoleCommand) modePiped(session *repl.Session, ui cli.Ui) int { - var lastResult string scanner := bufio.NewScanner(os.Stdin) + + var consoleState consoleBracketState + for scanner.Scan() { - result, exit, diags := session.Handle(strings.TrimSpace(scanner.Text())) - if diags.HasErrors() { - // In piped mode we'll exit immediately on error. - c.showDiagnostics(diags) - return 1 - } - if exit { - return 0 - } + line := strings.TrimSpace(scanner.Text()) - // Store the last result - lastResult = result + // we check if there is no escaped new line at the end, or any open brackets + // if we have neither, then we can execute + fullCommand, bracketState := consoleState.UpdateState(line) + if bracketState <= 0 { + result, exit, diags := session.Handle(fullCommand) + if diags.HasErrors() { + // We're in piped mode, so we'll exit immediately on error. + c.showDiagnostics(diags) + return 1 + } + if exit { + return 0 + } + // Output the result + ui.Output(result) + } } - // Output the final result - ui.Output(lastResult) - return 0 } diff --git a/internal/command/console_interactive.go b/internal/command/console_interactive.go index 847d876df8..e37ad60555 100644 --- a/internal/command/console_interactive.go +++ b/internal/command/console_interactive.go @@ -6,9 +6,11 @@ package command import ( + "errors" "fmt" "io" "os" + "strings" "github.com/opentofu/opentofu/internal/repl" @@ -35,28 +37,46 @@ func (c *ConsoleCommand) modeInteractive(session *repl.Session, ui cli.Ui) int { } defer l.Close() + var consoleState consoleBracketState + for { // Read a line line, err := l.Readline() - if err == readline.ErrInterrupt { + if errors.Is(err, readline.ErrInterrupt) { if len(line) == 0 { break } else { continue } - } else if err == io.EOF { + } else if errors.Is(err, io.EOF) { break } + line = strings.TrimSpace(line) - out, exit, diags := session.Handle(line) - if diags.HasErrors() { - c.showDiagnostics(diags) - } - if exit { - break - } + // we update the state with the new line, so if we have open + // brackets we know not to execute the command just yet + fullCommand, openState := consoleState.UpdateState(line) - ui.Output(out) + switch { + case openState > 0: + // here there are open brackets somewhere, so we don't execute it + // as we are in a bracket we update the prompt. we use one . per layer pf brackets + l.SetPrompt(fmt.Sprintf("%s ", strings.Repeat(".", openState))) + default: + out, exit, diags := session.Handle(fullCommand) + if diags.HasErrors() { + c.showDiagnostics(diags) + } + if exit { + break + } + + // clear the state and buffer as we have executed a command + // we also reset the prompt + l.SetPrompt("> ") + + ui.Output(out) + } } return 0 diff --git a/internal/command/console_interactive_test.go b/internal/command/console_interactive_test.go new file mode 100644 index 0000000000..0c72b1d14c --- /dev/null +++ b/internal/command/console_interactive_test.go @@ -0,0 +1,121 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import ( + "bytes" + "strings" + "testing" + + "github.com/mitchellh/cli" + "github.com/opentofu/opentofu/internal/configs/configschema" + "github.com/opentofu/opentofu/internal/providers" + "github.com/zclconf/go-cty/cty" +) + +func TestConsole_multiline_interactive(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("console-multiline-vars"), td) + defer testChdir(t, td)() + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + } + ui := cli.NewMockUi() + view, _ := testView(t) + c := &ConsoleCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: ui, + View: view, + }, + } + + type testCase struct { + input string + expected string + } + + tests := map[string]testCase{ + "single_line": { + input: `var.counts.lalala`, + expected: "1\n", + }, + "basic_multi_line": { + input: ` + var.counts.lalala + var.counts.lololo`, + expected: "\n1\n2\n", + }, + "backets_multi_line": { + input: ` + var.counts.lalala + split( + "_", + "lalala_lolol_lelelele" + )`, + expected: "\n1\ntolist([\n \"lalala\",\n \"lolol\",\n \"lelelele\",\n])\n", + }, + "baces_multi_line": { + input: ` + { + for key, value in var.counts : key => value + if value == 1 + }`, + expected: "\n{\n \"lalala\" = 1\n}\n", + }, + "escaped_new_line": { + input: ` + 5 + 4 \ + + `, + expected: "\n9\n\n", + }, + "heredoc": { + input: ` + { + default = <<-EOT + lulululu + EOT + }`, + expected: "\n{\n \"default\" = <<-EOT\n lulululu\n \n EOT\n}\n", + }, + "quoted_braces": { + input: "{\ndefault = format(\"%s%s%s\",\"{\",var.counts.lalala,\"}\")\n}", + expected: "{\n \"default\" = \"{1}\"\n}\n", + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + var output bytes.Buffer + defer testStdinPipe(t, strings.NewReader(tc.input))() + outCloser := testStdoutCapture(t, &output) + + args := []string{} + code := c.Run(args) + outCloser() + + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + got := output.String() + if got != tc.expected { + t.Fatalf("unexpected output. For input: %s\ngot: %q\nexpected: %q", tc.input, got, tc.expected) + } + }) + } +} diff --git a/internal/command/console_state.go b/internal/command/console_state.go new file mode 100644 index 0000000000..8da8a2e096 --- /dev/null +++ b/internal/command/console_state.go @@ -0,0 +1,101 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import ( + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +type consoleBracketState struct { + openNewLine int + brace int + bracket int + parentheses int + buffer []string +} + +// commandInOpenState return an int to inform if brackets are open +// or if any escaped new lines +// in the console and we should hold off on processing the commands +// it returns 3 states: +// -1 is returned the is an incorrect amount of brackets. +// for example "())" has too many close brackets +// 0 is returned if the brackets are closed. +// for examples "()" or "" would be in a close bracket state +// >=1 is returned for the amount of open brackets. +// for example "({" would return 2. "({}" would return 1 +func (c *consoleBracketState) commandInOpenState() int { + switch { + case c.brace < 0: + fallthrough + case c.bracket < 0: + fallthrough + case c.parentheses < 0: + return -1 + } + + // we calculate open brackets, braces and parentheses by the diff between each count + var total int + total += c.openNewLine + total += c.brace + total += c.bracket + total += c.parentheses + return total +} + +// UpdateState updates the state of the console with the latest line data +func (c *consoleBracketState) UpdateState(line string) (string, int) { + defer c.checkStateAndClearBuffer() + // as new lines are a kind of "one off" we reset each update + c.openNewLine = 0 + + // escaped new lines are treated as a "one off" bracket + // the four \\\\ means we have a false positive for a new line, as it's just an escaped \.. + if strings.HasSuffix(line, "\\") && !strings.HasSuffix(line, "\\\\") { + c.openNewLine++ + } + + line = strings.TrimSuffix(line, "\\") + if len(line) == 0 { + // we can skip empty lines + return c.getCommand(), c.commandInOpenState() + } + c.buffer = append(c.buffer, line) + + tokens, _ := hclsyntax.LexConfig([]byte(line), "", hcl.Pos{Line: 1, Column: 1}) + for _, token := range tokens { + switch token.Type { //nolint:exhaustive // we only care about these specific types + case hclsyntax.TokenOBrace: + c.brace++ + case hclsyntax.TokenCBrace: + c.brace-- + case hclsyntax.TokenOBrack: + c.bracket++ + case hclsyntax.TokenCBrack: + c.bracket-- + case hclsyntax.TokenOParen: + c.parentheses++ + case hclsyntax.TokenCParen: + c.parentheses-- + } + } + return c.getCommand(), c.commandInOpenState() +} + +// getCommand joins the buffer and returns it +func (c *consoleBracketState) getCommand() string { + output := strings.Join(c.buffer, "\n") + return output +} + +func (c *consoleBracketState) checkStateAndClearBuffer() { + if c.commandInOpenState() <= 0 { + c.buffer = []string{} + } +} diff --git a/internal/command/console_state_test.go b/internal/command/console_state_test.go new file mode 100644 index 0000000000..2b911619ac --- /dev/null +++ b/internal/command/console_state_test.go @@ -0,0 +1,226 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import "testing" + +func Test_commandInOpenState(t *testing.T) { + type testCase struct { + input string + expected int + } + + tests := map[string]testCase{ + "plain braces": { + input: "{}", + expected: 0, + }, + "plain brackets": { + input: "[]", + expected: 0, + }, + "plain parentheses": { + input: "()", + expected: 0, + }, + "open braces": { + input: "{", + expected: 1, + }, + "open brackets": { + input: "[", + expected: 1, + }, + "open parentheses": { + input: "(", + expected: 1, + }, + "two open braces": { + input: "{{", + expected: 2, + }, + "two open brackets": { + input: "[[", + expected: 2, + }, + "two open parentheses": { + input: "((", + expected: 2, + }, + "open and closed braces": { + input: "{{}", + expected: 1, + }, + "open and closed brackets": { + input: "[[]", + expected: 1, + }, + "open and closed parentheses": { + input: "(()", + expected: 1, + }, + "mix braces and brackets": { + input: "{[]", + expected: 1, + }, + "mix brackets and parentheses": { + input: "[()", + expected: 1, + }, + "mix parentheses and braces": { + input: "({}", + expected: 1, + }, + "invalid braces": { + input: "{}}", + expected: -1, + }, + "invalid brackets": { + input: "[]]", + expected: -1, + }, + "invalid parentheses": { + input: "())", + expected: -1, + }, + "escaped new line": { + input: "\\", + expected: 1, + }, + "false positive new line": { + input: "\\\\", + expected: 0, + }, + "mix parentheses and new line": { + input: "(\\", + expected: 2, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + state := consoleBracketState{} + _, actual := state.UpdateState(tc.input) + if actual != tc.expected { + t.Fatalf("Actual: %d, expected %d", actual, tc.expected) + } + }) + } +} + +func Test_UpdateState(t *testing.T) { + type testCase struct { + inputs []string + expected int + } + + tests := map[string]testCase{ + "plain braces": { + inputs: []string{"{", "}"}, + expected: 0, + }, + "open brackets": { + inputs: []string{"[", "[", "]"}, + expected: 1, + }, + "invalid parenthesis": { + inputs: []string{"(", ")", ")"}, + expected: -1, + }, + "a fake brace": { + inputs: []string{"{", "\"}\"", "}"}, + expected: 0, + }, + "a mixed bag": { + inputs: []string{"{", "}", "[", "...", "()", "]"}, + expected: 0, + }, + "multiple open": { + inputs: []string{"{", "[", "("}, + expected: 3, + }, + "escaped new line": { + inputs: []string{"\\"}, + expected: 1, + }, + "false positive new line": { + inputs: []string{"\\\\"}, + expected: 0, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + actual := 0 + state := consoleBracketState{} + for _, input := range tc.inputs { + _, actual = state.UpdateState(input) + } + + if actual != tc.expected { + t.Fatalf("Actual: %d, expected %d", actual, tc.expected) + } + }) + } +} + +func Test_GetFullCommand(t *testing.T) { + type testCase struct { + inputs []string + expected []string + } + + tests := map[string]testCase{ + "plain braces": { + inputs: []string{"{", "}"}, + expected: []string{"{", "{\n}"}, + }, + "open brackets": { + inputs: []string{"[", "[", "]"}, + expected: []string{"[", "[\n[", "[\n[\n]"}, + }, + "invalid parenthesis": { + inputs: []string{"(", ")", ")"}, + expected: []string{"(", "(\n)", ")"}, + }, + "a fake brace": { + inputs: []string{"{", "\"}\"", "}"}, + expected: []string{"{", "{\n\"}\"", "{\n\"}\"\n}"}, + }, + "a mixed bag": { + inputs: []string{"{", "}", "[", "...", "", "()", "]"}, + expected: []string{"{", "{\n}", "[", "[\n...", "[\n...", "[\n...\n()", "[\n...\n()\n]"}, + }, + "multiple open": { + inputs: []string{"{", "[", "("}, + expected: []string{"{", "{\n[", "{\n[\n("}, + }, + "escaped new line": { + inputs: []string{"\\"}, + expected: []string{""}, + }, + "false positive new line": { + inputs: []string{"\\\\"}, + expected: []string{"\\"}, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + state := consoleBracketState{} + if len(tc.inputs) != len(tc.expected) { + t.Fatalf("\nthe length of inputs: %d\n and expected: %d don't match", len(tc.inputs), len(tc.expected)) + } + + for i, input := range tc.inputs { + actual, _ := state.UpdateState(input) + if actual != tc.expected[i] { + t.Fatalf("\nActual: %q\nexpected: %q", actual, tc.expected[i]) + } + } + }) + } +} diff --git a/internal/command/console_test.go b/internal/command/console_test.go index 9fd0cc204b..c9841bc7e2 100644 --- a/internal/command/console_test.go +++ b/internal/command/console_test.go @@ -15,6 +15,7 @@ import ( "github.com/mitchellh/cli" "github.com/opentofu/opentofu/internal/configs/configschema" "github.com/opentofu/opentofu/internal/providers" + "github.com/opentofu/opentofu/internal/terminal" "github.com/zclconf/go-cty/cty" ) @@ -241,3 +242,111 @@ func TestConsole_modules(t *testing.T) { } } } + +func TestConsole_multiline_pipe(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("console-multiline-vars"), td) + defer testChdir(t, td)() + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + } + + type testCase struct { + input string + expected string + } + + tests := map[string]testCase{ + "single_line": { + input: `var.counts.lalala`, + expected: "1\n", + }, + "basic_multi_line": { + input: ` + var.counts.lalala + var.counts.lololo`, + expected: "\n1\n2\n", + }, + "backets_multi_line": { + input: ` + var.counts.lalala + split( + "_", + "lalala_lolol_lelelele" + )`, + expected: "\n1\ntolist([\n \"lalala\",\n \"lolol\",\n \"lelelele\",\n])\n", + }, + "baces_multi_line": { + input: ` + { + for key, value in var.counts : key => value + if value == 1 + }`, + expected: "\n{\n \"lalala\" = 1\n}\n", + }, + "escaped_new_line": { + input: ` + 5 + 4 \ + + `, + expected: "\n9\n\n", + }, + "heredoc": { + input: ` + { + default = <<-EOT + lulululu + EOT + }`, + expected: "\n{\n \"default\" = <<-EOT\n lulululu\n \n EOT\n}\n", + }, + "quoted_braces": { + input: "{\ndefault = format(\"%s%s%s\",\"{\",var.counts.lalala,\"}\")\n}", + expected: "{\n \"default\" = \"{1}\"\n}\n", + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + streams, _ := terminal.StreamsForTesting(t) + + ui := cli.NewMockUi() + view, _ := testView(t) + c := &ConsoleCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: ui, + View: view, + Streams: streams, + }, + } + + var output bytes.Buffer + defer testStdinPipe(t, strings.NewReader(tc.input))() + outCloser := testStdoutCapture(t, &output) + + args := []string{} + code := c.Run(args) + outCloser() + + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + got := output.String() + if got != tc.expected { + t.Fatalf("unexpected output\ngot: %q\nexpected: %q", got, tc.expected) + } + }) + } +} diff --git a/internal/command/testdata/console-multiline-vars/main.tf b/internal/command/testdata/console-multiline-vars/main.tf new file mode 100644 index 0000000000..4d230ac348 --- /dev/null +++ b/internal/command/testdata/console-multiline-vars/main.tf @@ -0,0 +1,13 @@ +variable "bar" { + default = "baz" +} + +variable "foo" {} + +variable "counts" { + type = map(any) + default = { + "lalala" = 1, + "lololo" = 2, + } +}