Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Format on save code action #625

Merged
merged 1 commit into from
Aug 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/language-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ Client is expected to always launch a single instance of the server and check fo
It is assumed that paths to these folders will be provided as part of `workspaceFolders`
in the `initialize` request per LSP.

## Code Actions

The server implements a set of opt-in code actions which perform different actions for the user. The code action request is sent from the client to the server to compute commands for a given text document and range. These commands are typically code fixes to either fix problems or to beautify/refactor code.

### Format Document

The server will format a given document according to Terraform formatting conventions.

This action is available as `source.formatAll.terraform-ls` for clients which configure actions globally (such as Sublime Text LSP) and as `source.formatAll` for clients which allow languageID or server specific configuration (such as VS Code).

## Code Lens

### Reference Counts (opt-in)
Expand Down
75 changes: 75 additions & 0 deletions internal/langserver/handlers/code_action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package handlers

import (
"context"
"fmt"

lsctx "github.com/hashicorp/terraform-ls/internal/context"
"github.com/hashicorp/terraform-ls/internal/langserver/errors"
ilsp "github.com/hashicorp/terraform-ls/internal/lsp"
lsp "github.com/hashicorp/terraform-ls/internal/protocol"
"github.com/hashicorp/terraform-ls/internal/terraform/module"
)

func (h *logHandler) TextDocumentCodeAction(ctx context.Context, params lsp.CodeActionParams) []lsp.CodeAction {
ca, err := h.textDocumentCodeAction(ctx, params)
if err != nil {
h.logger.Printf("code action failed: %s", err)
}

return ca
}

func (h *logHandler) textDocumentCodeAction(ctx context.Context, params lsp.CodeActionParams) ([]lsp.CodeAction, error) {
var ca []lsp.CodeAction

wantedCodeActions := ilsp.SupportedCodeActions.Only(params.Context.Only)
if len(wantedCodeActions) == 0 {
return nil, fmt.Errorf("could not find a supported code action to execute for %s, wanted %v",
params.TextDocument.URI, params.Context.Only)
}

fh := ilsp.FileHandlerFromDocumentURI(params.TextDocument.URI)

fs, err := lsctx.DocumentStorage(ctx)
if err != nil {
return ca, err
}
file, err := fs.GetDocument(fh)
if err != nil {
return ca, err
}
original, err := file.Text()
if err != nil {
return ca, err
}

for action := range wantedCodeActions {
switch action {
case lsp.Source, lsp.SourceFixAll, ilsp.SourceFormatAll, ilsp.SourceFormatAllTerraformLs:
tfExec, err := module.TerraformExecutorForModule(ctx, fh.Dir())
if err != nil {
return ca, errors.EnrichTfExecError(err)
}

h.logger.Printf("formatting document via %q", tfExec.GetExecPath())

edits, err := formatDocument(ctx, tfExec, original, file)
if err != nil {
return ca, err
}

ca = append(ca, lsp.CodeAction{
Title: "Format Document",
Kind: lsp.SourceFixAll,
Edit: lsp.WorkspaceEdit{
Changes: map[string][]lsp.TextEdit{
string(fh.URI()): edits,
},
},
})
}
}

return ca, nil
}
147 changes: 147 additions & 0 deletions internal/langserver/handlers/code_action_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package handlers

import (
"fmt"
"testing"

"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/terraform/exec"
"github.com/stretchr/testify/mock"
)

func TestLangServer_codeActionWithoutInitialization(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": {
"version": 0,
"languageId": "terraform",
"text": "provider \"github\" {}",
"uri": "%s/main.tf"
}
}`, TempDir(t).URI())}, session.SessionNotInitialized.Err())
}

func TestLangServer_codeAction_basic(t *testing.T) {
tmpDir := TempDir(t)

ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{
TerraformCalls: &exec.TerraformMockCalls{
PerWorkDir: map[string][]*mock.Call{
tmpDir.Dir(): {
{
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("provider \"test\" {\n\n }\n"),
},
ReturnArguments: []interface{}{
[]byte("provider \"test\" {\n\n}\n"),
nil,
},
}},
},
},
}))
stop := ls.Start(t)
defer stop()

ls.Call(t, &langserver.CallRequest{
Method: "initialize",
ReqParams: fmt.Sprintf(`{
"capabilities": {},
"rootUri": %q,
"processId": 12345
}`, tmpDir.URI())})
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())})
ls.CallAndExpectResponse(t, &langserver.CallRequest{
Method: "textDocument/codeAction",
ReqParams: fmt.Sprintf(`{
"textDocument": { "uri": "%s/main.tf" },
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 1, "character": 0 }
},
"context": { "diagnostics": [], "only": ["source.fixAll"] }
}`, tmpDir.URI())}, fmt.Sprintf(`{
"jsonrpc": "2.0",
"id": 3,
"result": [
{
"title": "Format Document",
"kind": "source.fixAll",
"edit":{
"changes":{
"%s/main.tf": [
{
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "provider \"test\" {\n"
},
{
"range": {
"start": {
"line": 2,
"character": 0
},
"end": {
"line": 3,
"character": 0
}
},
"newText": "}\n"
}
]
}
}
}
]
}`, tmpDir.URI()))
}
13 changes: 13 additions & 0 deletions internal/langserver/handlers/formatting.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"context"

lsctx "github.com/hashicorp/terraform-ls/internal/context"
"github.com/hashicorp/terraform-ls/internal/filesystem"
"github.com/hashicorp/terraform-ls/internal/hcl"
"github.com/hashicorp/terraform-ls/internal/langserver/errors"
ilsp "github.com/hashicorp/terraform-ls/internal/lsp"
lsp "github.com/hashicorp/terraform-ls/internal/protocol"
"github.com/hashicorp/terraform-ls/internal/terraform/exec"
"github.com/hashicorp/terraform-ls/internal/terraform/module"
)

Expand Down Expand Up @@ -38,6 +40,17 @@ func (h *logHandler) TextDocumentFormatting(ctx context.Context, params lsp.Docu

h.logger.Printf("formatting document via %q", tfExec.GetExecPath())

edits, err = formatDocument(ctx, tfExec, original, file)
if err != nil {
return edits, err
}

return edits, nil
}

func formatDocument(ctx context.Context, tfExec exec.TerraformExecutor, original []byte, file filesystem.Document) ([]lsp.TextEdit, error) {
var edits []lsp.TextEdit

formatted, err := tfExec.Format(ctx, original)
if err != nil {
return edits, err
Expand Down
3 changes: 3 additions & 0 deletions internal/langserver/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func initializeResponse(t *testing.T, commandPrefix string) string {
"definitionProvider": true,
"referencesProvider": true,
"documentSymbolProvider": true,
"codeActionProvider": {
"codeActionKinds": ["source", "source.fixAll", "source.formatAll", "source.formatAll.terraform-ls"]
},
"codeLensProvider": {},
"documentLinkProvider": {},
"workspaceSymbolProvider": true,
Expand Down
4 changes: 4 additions & 0 deletions internal/langserver/handlers/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams)
ResolveProvider: false,
TriggerCharacters: []string{".", "["},
},
CodeActionProvider: lsp.CodeActionOptions{
CodeActionKinds: ilsp.SupportedCodeActions.AsSlice(),
ResolveProvider: false,
},
DeclarationProvider: lsp.DeclarationOptions{},
DefinitionProvider: true,
CodeLensProvider: lsp.CodeLensOptions{},
Expand Down
13 changes: 13 additions & 0 deletions internal/langserver/handlers/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,19 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) {

return handle(ctx, req, lh.TextDocumentHover)
},
"textDocument/codeAction": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) {
err := session.CheckInitializationIsConfirmed()
if err != nil {
return nil, err
}

ctx = lsctx.WithClientCapabilities(ctx, cc)
ctx = lsctx.WithDocumentStorage(ctx, svc.fs)
ctx = exec.WithExecutorOpts(ctx, svc.tfExecOpts)
ctx = exec.WithExecutorFactory(ctx, svc.tfExecFactory)

return handle(ctx, req, lh.TextDocumentCodeAction)
},
"textDocument/codeLens": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) {
err := session.CheckInitializationIsConfirmed()
if err != nil {
Expand Down
52 changes: 52 additions & 0 deletions internal/lsp/code_actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package lsp

import (
"sort"

lsp "github.com/hashicorp/terraform-ls/internal/protocol"
)

const (
SourceFormatAll = "source.formatAll"
SourceFormatAllTerraformLs = "source.formatAll.terraform-ls"
)

type CodeActions map[lsp.CodeActionKind]bool

var (
SupportedCodeActions = CodeActions{
lsp.Source: true,
lsp.SourceFixAll: true,
SourceFormatAll: true,
SourceFormatAllTerraformLs: true,
}
)

func (c CodeActions) AsSlice() []lsp.CodeActionKind {
s := make([]lsp.CodeActionKind, 0)
for v := range c {
s = append(s, v)
}

sort.SliceStable(s, func(i, j int) bool {
return string(s[i]) < string(s[j])
})
return s
}

func (ca CodeActions) Only(only []lsp.CodeActionKind) CodeActions {
// if only is empty, assume that the client wants all code actions
// else build mapping of requested and determine if supported
if len(only) == 0 {
return ca
}

wanted := make(CodeActions, 0)
for _, kind := range only {
if v, ok := ca[kind]; ok {
wanted[kind] = v
}
}

return wanted
}