diff --git a/internal/langserver/handlers/code_action.go b/internal/langserver/handlers/code_action.go index e062eec7..ae1861e8 100644 --- a/internal/langserver/handlers/code_action.go +++ b/internal/langserver/handlers/code_action.go @@ -74,6 +74,21 @@ func (svc *service) textDocumentCodeAction(ctx context.Context, params lsp.CodeA }, }, }) + case ilsp.ExtractPropertyToOutput: + edits, err := svc.ExtractPropToOutput(ctx, params) + if err != nil { + return ca, err + } + + ca = append(ca, lsp.CodeAction{ + Title: "Extract Property to Output", + Kind: action, + Edit: lsp.WorkspaceEdit{ + Changes: map[lsp.DocumentURI][]lsp.TextEdit{ + lsp.DocumentURI(dh.FullURI()): edits, + }, + }, + }) } } diff --git a/internal/langserver/handlers/extract_prop_to_output.go b/internal/langserver/handlers/extract_prop_to_output.go new file mode 100644 index 00000000..25c1c820 --- /dev/null +++ b/internal/langserver/handlers/extract_prop_to_output.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package handlers + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/hcl/v2/hclwrite" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + lsp "github.com/hashicorp/terraform-ls/internal/protocol" +) + +func (svc *service) ExtractPropToOutput(ctx context.Context, params lsp.CodeActionParams) ([]lsp.TextEdit, error) { + var edits []lsp.TextEdit + + dh := ilsp.HandleFromDocumentURI(params.TextDocument.URI) + + doc, err := svc.stateStore.DocumentStore.GetDocument(dh) + if err != nil { + return edits, err + } + + mod, err := svc.stateStore.Modules.ModuleByPath(dh.Dir.Path()) + if err != nil { + return edits, err + } + + file, ok := mod.ParsedModuleFiles.AsMap()[dh.Filename] + if !ok { + return edits, err + } + + pos, err := ilsp.HCLPositionFromLspPosition(params.Range.Start, doc) + if err != nil { + return edits, err + } + + blocks := file.BlocksAtPos(pos) + if len(blocks) > 1 { + return edits, fmt.Errorf("found more than one block at pos: %v", pos) + } + if len(blocks) == 0 { + return edits, fmt.Errorf("can not find block at position %v", pos) + } + + attr := file.AttributeAtPos(pos) + if attr == nil { + return edits, fmt.Errorf("can not find attribute at position %v", pos) + } + + tfAddr := append(blocks[0].Labels, attr.Name) + + insertPos := lsp.Position{ + Line: uint32(len(doc.Lines)), + Character: uint32(len(doc.Lines)), + } + + edits = append(edits, lsp.TextEdit{ + Range: lsp.Range{ + Start: insertPos, + End: insertPos, + }, + NewText: outputBlock(strings.Join(tfAddr, "_"), strings.Join(tfAddr, ".")), + }) + return edits, nil +} + +func outputBlock(name, tfAddr string) string { + f := hclwrite.NewFile() + b := hclwrite.NewBlock("output", []string{name}) + b.Body().SetAttributeRaw("value", hclwrite.TokensForIdentifier(tfAddr)) + f.Body().AppendNewline() + f.Body().AppendBlock(b) + return string(f.Bytes()) +} diff --git a/internal/langserver/handlers/extract_prop_to_output_test.go b/internal/langserver/handlers/extract_prop_to_output_test.go new file mode 100644 index 00000000..e4e9e9a4 --- /dev/null +++ b/internal/langserver/handlers/extract_prop_to_output_test.go @@ -0,0 +1,361 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package handlers + +import ( + "errors" + "fmt" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-ls/internal/langserver" + "github.com/hashicorp/terraform-ls/internal/langserver/session" + "github.com/hashicorp/terraform-ls/internal/state" + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/walker" + "github.com/stretchr/testify/mock" +) + +func TestLangServer_extractPropToOutput_withoutInitialization(t *testing.T) { + ls := langserver.NewLangServerMock(t, NewMockSession(nil)) + stop := ls.Start(t) + defer stop() + + ls.CallAndExpectError(t, &langserver.CallRequest{ + Method: "textDocument/codeAction", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "uri": "%s/main.tf" + }, + "range": { + "start": { + "line": 4, + "character": 17 + }, + "end": { + "line": 4, + "character": 17 + } + }, + "context": { + "only": [ + "refactor.extract.propToOut" + ] + } +}`, TempDir(t).URI), + }, session.SessionNotInitialized.Err()) +} + +func TestLangServer_ExtractPropToOutput_basic(t *testing.T) { + tmpDir := TempDir(t) + + ss, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + wc := walker.NewWalkerCollector() + + ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ + StateStore: ss, + WalkerCollector: wc, + TerraformCalls: &exec.TerraformMockCalls{ + PerWorkDir: map[string][]*mock.Call{ + tmpDir.Path(): { + { + Method: "Version", + Repeatability: 1, + Arguments: []interface{}{ + mock.AnythingOfType(""), + }, + ReturnArguments: []interface{}{ + version.Must(version.NewVersion("0.12.0")), + nil, + nil, + }, + }, + { + Method: "GetExecPath", + Repeatability: 1, + ReturnArguments: []interface{}{ + "", + }, + }, + }, + }, + }, + })) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: fmt.Sprintf(`{ + "capabilities": {}, + "rootUri": %q, + "processId": 12345 + }`, tmpDir.URI), + }) + waitForWalkerPath(t, ss, wc, tmpDir) + ls.Notify(t, &langserver.CallRequest{ + Method: "initialized", + ReqParams: "{}", + }) + ls.Call(t, &langserver.CallRequest{ + Method: "textDocument/didOpen", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "version": 0, + "languageId": "terraform", + "text": "provider \"test\"{}\n\nresource \"test_resource\" \"test\"{\n name = \"test\"\n}", + "uri": "%s/main.tf" + } + }`, tmpDir.URI), + }) + waitForAllJobs(t, ss) + + ls.CallAndExpectResponse(t, &langserver.CallRequest{ + Method: "textDocument/codeAction", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "uri": "%s/main.tf" + }, + "range": { + "start": { + "line": 3, + "character": 14 + }, + "end": { + "line": 3, + "character": 14 + } + }, + "context": { + "only": [ + "refactor.extract.propToOut" + ] + } +}`, tmpDir.URI), + }, fmt.Sprintf(`{ + "jsonrpc": "2.0", + "id": 3, + "result": [ + { + "title": "Extract Property to Output", + "kind": "refactor.extract.propToOut", + "edit": { + "changes": { + "%s/main.tf": [ + { + "range": { + "start": { + "line": 6, + "character": 6 + }, + "end": { + "line": 6, + "character": 6 + } + }, + "newText": "\noutput \"test_resource_test_name\" {\n value = test_resource.test.name\n}\n" + } + ] + } + } + } + ] +}`, tmpDir.URI)) +} + +func TestLangServer_ExtractPropToOutput_oldVersion(t *testing.T) { + tmpDir := TempDir(t) + + ss, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + wc := walker.NewWalkerCollector() + + ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ + StateStore: ss, + WalkerCollector: wc, + TerraformCalls: &exec.TerraformMockCalls{ + PerWorkDir: map[string][]*mock.Call{ + tmpDir.Path(): { + { + Method: "Version", + Repeatability: 1, + Arguments: []interface{}{ + mock.AnythingOfType(""), + }, + ReturnArguments: []interface{}{ + version.Must(version.NewVersion("0.7.6")), + nil, + nil, + }, + }, + { + Method: "GetExecPath", + Repeatability: 1, + ReturnArguments: []interface{}{ + "", + }, + }, + { + Method: "Format", + Repeatability: 1, + Arguments: []interface{}{ + mock.AnythingOfType(""), + []byte("provider \"test\" {\n\n}\n"), + }, + ReturnArguments: []interface{}{ + nil, + errors.New("not implemented"), + }, + }, + }, + }, + }, + })) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: fmt.Sprintf(`{ + "capabilities": {}, + "rootUri": %q, + "processId": 12345 + }`, tmpDir.URI), + }) + waitForWalkerPath(t, ss, wc, tmpDir) + + ls.Notify(t, &langserver.CallRequest{ + Method: "initialized", + ReqParams: "{}", + }) + ls.Call(t, &langserver.CallRequest{ + Method: "textDocument/didOpen", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "version": 0, + "languageId": "terraform", + "text": "provider \"test\" {\n\n}\n", + "uri": "%s/main.tf" + } + }`, tmpDir.URI), + }) + waitForAllJobs(t, ss) + + ls.CallAndExpectError(t, &langserver.CallRequest{ + Method: "textDocument/formatting", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "uri": "%s/main.tf" + } + }`, tmpDir.URI), + }, jrpc2.SystemError.Err()) +} + +func TestLangServer_extractPropToOutput_variables(t *testing.T) { + tmpDir := TempDir(t) + + ss, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + wc := walker.NewWalkerCollector() + + ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ + StateStore: ss, + WalkerCollector: wc, + TerraformCalls: &exec.TerraformMockCalls{ + PerWorkDir: map[string][]*mock.Call{ + tmpDir.Path(): { + { + Method: "Version", + Repeatability: 1, + Arguments: []interface{}{ + mock.AnythingOfType(""), + }, + ReturnArguments: []interface{}{ + version.Must(version.NewVersion("0.12.0")), + nil, + nil, + }, + }, + { + Method: "GetExecPath", + Repeatability: 1, + ReturnArguments: []interface{}{ + "", + }, + }, + { + Method: "Format", + Repeatability: 1, + Arguments: []interface{}{ + mock.AnythingOfType(""), + []byte("test = \"dev\""), + }, + ReturnArguments: []interface{}{ + []byte("test = \"dev\""), + nil, + }, + }, + }, + }, + }, + })) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: fmt.Sprintf(`{ + "capabilities": {}, + "rootUri": %q, + "processId": 12345 + }`, tmpDir.URI), + }) + waitForWalkerPath(t, ss, wc, tmpDir) + + ls.Notify(t, &langserver.CallRequest{ + Method: "initialized", + ReqParams: "{}", + }) + ls.Call(t, &langserver.CallRequest{ + Method: "textDocument/didOpen", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "version": 0, + "languageId": "terraform-vars", + "text": "test = \"dev\"", + "uri": "%s/terraform.tfvars" + } + }`, tmpDir.URI), + }) + waitForAllJobs(t, ss) + + ls.CallAndExpectResponse(t, &langserver.CallRequest{ + Method: "textDocument/formatting", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "uri": "%s/terraform.tfvars" + } + }`, tmpDir.URI), + }, `{ + "jsonrpc": "2.0", + "id": 3, + "result": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 13 } + }, + "newText": "test = \"dev\"" + } + ] + }`) +} diff --git a/internal/lsp/code_actions.go b/internal/lsp/code_actions.go index b4dc6bf6..15cdecd2 100644 --- a/internal/lsp/code_actions.go +++ b/internal/lsp/code_actions.go @@ -5,6 +5,7 @@ package lsp import ( "sort" + "strings" lsp "github.com/hashicorp/terraform-ls/internal/protocol" ) @@ -12,6 +13,7 @@ import ( const ( // SourceFormatAllTerraform is a Terraform specific format code action. SourceFormatAllTerraform = "source.formatAll.terraform" + ExtractPropertyToOutput = "refactor.extract.propToOut" ) type CodeActions map[lsp.CodeActionKind]bool @@ -54,8 +56,10 @@ func (ca CodeActions) Only(only []lsp.CodeActionKind) CodeActions { wanted := make(CodeActions, 0) for _, kind := range only { - if v, ok := ca[kind]; ok { - wanted[kind] = v + for n, v := range ca { + if strings.HasPrefix(string(n), string(kind)) { + wanted[n] = v + } } }