diff --git a/go.sum b/go.sum index f192a1321..3dfa4e112 100644 --- a/go.sum +++ b/go.sum @@ -23,7 +23,6 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -52,13 +51,16 @@ github.com/mitchellh/cli v1.0.0 h1:iGBIsUe3+HZ/AD/Vd7DErOt5sU9fa8Uj7A2s1aggv1Y= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/radeksimko/go-lsp v0.1.0 h1:evTibJ/0G2QtamSdA6mi+Xov7t5RpoGmMcTK60bWp+E= github.com/radeksimko/go-lsp v0.1.0/go.mod h1:tpps84QRlOVVLYk5QpKYX8Tr289D1v/UTWDLqeguiqM= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= @@ -74,9 +76,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82 h1:vsphBvatvfbhlb4PO1BYSr9dzugGxJ/SQHoNufZJq1w= golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9 h1:L2auWcuQIvxz9xSEqzESnV/QN/gNRXNApHi3fYwl2w0= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/filesystem/file_test.go b/internal/filesystem/file_test.go index e6bb723a7..caa623726 100644 --- a/internal/filesystem/file_test.go +++ b/internal/filesystem/file_test.go @@ -2,6 +2,8 @@ package filesystem import ( "testing" + + "github.com/hashicorp/hcl/v2" ) func TestFile_ApplyChange_fullUpdate(t *testing.T) { @@ -23,3 +25,9 @@ type fileChange struct { func (fc *fileChange) Text() string { return fc.text } + +func (fc *fileChange) Range() hcl.Range { + return hcl.Range{ + // TODO: Implement partial updates + } +} diff --git a/internal/filesystem/filesystem_test.go b/internal/filesystem/filesystem_test.go index 31df93aec..9df6171d6 100644 --- a/internal/filesystem/filesystem_test.go +++ b/internal/filesystem/filesystem_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform-ls/internal/source" ) @@ -203,3 +204,9 @@ type testChange struct { func (ch *testChange) Text() string { return ch.text } + +func (ch *testChange) Range() hcl.Range { + return hcl.Range{ + // TODO: Implement partial updates + } +} diff --git a/internal/filesystem/types.go b/internal/filesystem/types.go index 779a51a3c..182956670 100644 --- a/internal/filesystem/types.go +++ b/internal/filesystem/types.go @@ -18,6 +18,7 @@ type FilePosition interface { type FileChange interface { Text() string + Range() hcl.Range } type VersionedFileHandler interface { diff --git a/internal/hcl/hcl.go b/internal/hcl/hcl.go index 28c0ab4e6..6b023d572 100644 --- a/internal/hcl/hcl.go +++ b/internal/hcl/hcl.go @@ -1,6 +1,9 @@ package hcl import ( + "fmt" + + "github.com/hashicorp/hcl/v2" hcllib "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/terraform-ls/internal/filesystem" @@ -8,6 +11,7 @@ import ( type File interface { BlockAtPosition(filesystem.FilePosition) (*hcllib.Block, hcllib.Pos, error) + Diff([]byte) (filesystem.FileChanges, error) } type file struct { @@ -45,6 +49,36 @@ func (f *file) BlockAtPosition(filePos filesystem.FilePosition) (*hcllib.Block, return b, pos, nil } +func (f *file) Diff(target []byte) (filesystem.FileChanges, error) { + var changes filesystem.FileChanges + + ast, _ := f.ast() + body, ok := ast.Body.(*hclsyntax.Body) + if !ok { + return nil, fmt.Errorf("invalid configuration format: %T", ast.Body) + } + + changes = append(changes, &fileChange{ + newText: target, + rng: body.Range(), + }) + + return changes, nil +} + +type fileChange struct { + newText []byte + rng hcl.Range +} + +func (ch *fileChange) Text() string { + return string(ch.newText) +} + +func (ch *fileChange) Range() hcl.Range { + return ch.rng +} + func (f *file) blockAtPosition(pos hcllib.Pos) (*hcllib.Block, error) { ast, _ := f.ast() diff --git a/internal/hcl/hcl_test.go b/internal/hcl/hcl_test.go index c47c8d4be..d4d4e75f5 100644 --- a/internal/hcl/hcl_test.go +++ b/internal/hcl/hcl_test.go @@ -150,6 +150,50 @@ func TestFile_BlockAtPosition(t *testing.T) { } } +func TestFile_diff(t *testing.T) { + targetCfg := []byte(` +provider "aws" { + first = "test" + very_long_attr = "xyz" +}`) + fsFile := filesystem.NewFile("/tmp/test.tf", []byte(` +provider "aws" { + first = "test" + very_long_attr = "xyz" +}`)) + f := NewFile(fsFile) + changes, err := f.Diff(targetCfg) + if err != nil { + t.Fatal(err) + } + + var expectedChanges filesystem.FileChanges + expectedChanges = append(expectedChanges, &fileChange{ + newText: []byte(targetCfg), + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Column: 1, + Line: 1, + Byte: 0, + }, + End: hcl.Pos{ + Column: 2, + Line: 5, + Byte: 62, + }, + }, + }) + + opts := cmp.Options{ + cmp.AllowUnexported(fileChange{}), + } + + if diff := cmp.Diff(expectedChanges, changes, opts...); diff != "" { + t.Fatalf("Changes don't match: %s", diff) + } +} + type testPosition struct { filesystem.FileHandler pos hcl.Pos diff --git a/internal/lsp/file_change.go b/internal/lsp/file_change.go index 461d91d5a..7c0a39eb1 100644 --- a/internal/lsp/file_change.go +++ b/internal/lsp/file_change.go @@ -3,12 +3,14 @@ package lsp import ( "fmt" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/sourcegraph/go-lsp" ) type fileChange struct { text string + rng hcl.Range } func FileChange(chEvent lsp.TextDocumentContentChangeEvent, f File) (*fileChange, error) { @@ -33,6 +35,36 @@ func FileChanges(events []lsp.TextDocumentContentChangeEvent, f File) (filesyste return changes, nil } +func TextEdits(changes filesystem.FileChanges) []lsp.TextEdit { + edits := make([]lsp.TextEdit, len(changes)) + + for i, change := range changes { + edits[i] = lsp.TextEdit{ + Range: hclRangeToLSP(change.Range()), + NewText: change.Text(), + } + } + + return edits +} + +func hclRangeToLSP(hclRng hcl.Range) lsp.Range { + return lsp.Range{ + Start: lsp.Position{ + Character: hclRng.Start.Column - 1, + Line: hclRng.Start.Line - 1, + }, + End: lsp.Position{ + Character: hclRng.End.Column - 1, + Line: hclRng.End.Line - 1, + }, + } +} + func (fc *fileChange) Text() string { return fc.text } + +func (fc *fileChange) Range() hcl.Range { + return fc.rng +} diff --git a/internal/terraform/exec/exec.go b/internal/terraform/exec/exec.go index 536665f0e..f6bba90ae 100644 --- a/internal/terraform/exec/exec.go +++ b/internal/terraform/exec/exec.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "log" "os" @@ -34,6 +35,14 @@ type Executor struct { cmdCtxFunc cmdCtxFunc } +type command struct { + Cmd *exec.Cmd + Context context.Context + CancelFunc context.CancelFunc + StdoutBuffer *bytes.Buffer + StderrBuffer *bytes.Buffer +} + func NewExecutor(ctx context.Context, path string) *Executor { return &Executor{ ctx: ctx, @@ -66,16 +75,15 @@ func (e *Executor) GetExecPath() string { return e.execPath } -func (e *Executor) run(args ...string) ([]byte, error) { +func (e *Executor) cmd(args ...string) (*command, error) { if e.workDir == "" { return nil, fmt.Errorf("no work directory set") } ctx := e.ctx + var cancel context.CancelFunc if e.timeout > 0 { - var cancel context.CancelFunc ctx, cancel = context.WithTimeout(e.ctx, e.timeout) - defer cancel() } var outBuf bytes.Buffer @@ -103,28 +111,44 @@ func (e *Executor) run(args ...string) ([]byte, error) { if e.execLogPath != "" { logPath, err := logging.ParseExecLogPath(cmd.Args, e.execLogPath) if err != nil { - return nil, fmt.Errorf("failed to parse log path: %w", err) + return &command{ + Cmd: cmd, + Context: ctx, + CancelFunc: cancel, + StdoutBuffer: &outBuf, + StderrBuffer: &errBuf, + }, fmt.Errorf("failed to parse log path: %w", err) } cmd.Env = append(cmd.Env, "TF_LOG=TRACE") cmd.Env = append(cmd.Env, "TF_LOG_PATH="+logPath) e.logger.Printf("Execution will be logged to %s", logPath) } + return &command{ + Cmd: cmd, + Context: ctx, + CancelFunc: cancel, + StdoutBuffer: &outBuf, + StderrBuffer: &errBuf, + }, nil +} - e.logger.Printf("Running %s %q in %q...", e.execPath, args, e.workDir) - err := cmd.Run() +func (e *Executor) waitCmd(command *command) ([]byte, error) { + args := command.Cmd.Args + e.logger.Printf("Waiting for command to finish ...") + err := command.Cmd.Wait() if err != nil { if tErr, ok := err.(*exec.ExitError); ok { exitErr := &ExitError{ Err: tErr, - Path: cmd.Path, - Stdout: outBuf.String(), - Stderr: errBuf.String(), + Path: command.Cmd.Path, + Stdout: command.StdoutBuffer.String(), + Stderr: command.StderrBuffer.String(), } - ctxErr := ctx.Err() + ctxErr := command.Context.Err() if errors.Is(ctxErr, context.DeadlineExceeded) { - exitErr.CtxErr = ExecTimeoutError(cmd.Args, e.timeout) + exitErr.CtxErr = ExecTimeoutError(args, e.timeout) } if errors.Is(ctxErr, context.Canceled) { exitErr.CtxErr = ExecCanceledError(args) @@ -136,11 +160,71 @@ func (e *Executor) run(args ...string) ([]byte, error) { return nil, err } - pc := cmd.ProcessState + pc := command.Cmd.ProcessState e.logger.Printf("terraform run (%s %q, in %q, pid %d) finished with exit code %d", e.execPath, args, e.workDir, pc.Pid(), pc.ExitCode()) - return outBuf.Bytes(), nil + return command.StdoutBuffer.Bytes(), nil +} + +func (e *Executor) runCmd(command *command) ([]byte, error) { + args := command.Cmd.Args + e.logger.Printf("Starting %s %q in %q...", e.execPath, args, e.workDir) + err := command.Cmd.Start() + if err != nil { + return nil, err + } + + return e.waitCmd(command) +} + +func (e *Executor) run(args ...string) ([]byte, error) { + cmd, err := e.cmd(args...) + defer cmd.CancelFunc() + if err != nil { + return nil, err + } + return e.runCmd(cmd) +} + +func (e *Executor) Format(input []byte) ([]byte, error) { + cmd, err := e.cmd("fmt", "-") + if err != nil { + return nil, err + } + + stdin, err := cmd.Cmd.StdinPipe() + if err != nil { + return nil, err + } + + err = cmd.Cmd.Start() + if err != nil { + return nil, err + } + + _, err = writeAndClose(stdin, input) + if err != nil { + return nil, err + } + + out, err := e.waitCmd(cmd) + if err != nil { + return nil, fmt.Errorf("failed to format: %w", err) + } + + return out, nil +} + +func writeAndClose(w io.WriteCloser, input []byte) (int, error) { + defer w.Close() + + n, err := w.Write(input) + if err != nil { + return n, err + } + + return n, nil } func (e *Executor) Version() (string, error) { diff --git a/internal/terraform/exec/exec_test.go b/internal/terraform/exec/exec_test.go index 21912c26a..56e66acb0 100644 --- a/internal/terraform/exec/exec_test.go +++ b/internal/terraform/exec/exec_test.go @@ -1,6 +1,7 @@ package exec import ( + "bytes" "errors" "testing" "time" @@ -46,6 +47,25 @@ func TestExec_Version(t *testing.T) { } } +func TestExec_Format(t *testing.T) { + expectedOutput := []byte("formatted config") + e := MockExecutor(&MockCall{ + Args: []string{"fmt", "-"}, + Stdout: string(expectedOutput), + ExitCode: 0, + }) + e.SetWorkdir("/tmp") + out, err := e.Format([]byte("unformatted")) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(out, expectedOutput) { + t.Fatalf("Expected output: %q\nGiven: %q\n", + string(expectedOutput), string(out)) + } +} + func TestExec_ProviderSchemas(t *testing.T) { e := MockExecutor(&MockCall{ Args: []string{"providers", "schema", "-json"}, diff --git a/langserver/handlers/formatting.go b/langserver/handlers/formatting.go new file mode 100644 index 000000000..a3419b148 --- /dev/null +++ b/langserver/handlers/formatting.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "context" + "os" + + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/hcl" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + lsp "github.com/sourcegraph/go-lsp" +) + +func (h *logHandler) TextDocumentFormatting(ctx context.Context, params lsp.DocumentFormattingParams) ([]lsp.TextEdit, error) { + var edits []lsp.TextEdit + + fs, err := lsctx.Filesystem(ctx) + if err != nil { + return edits, err + } + + tf, err := lsctx.TerraformExecutor(ctx) + if err != nil { + return edits, err + } + // Input is sent to stdin -> no need for a meaningful workdir + tf.SetWorkdir(os.TempDir()) + + fh := ilsp.FileHandler(params.TextDocument.URI) + file, err := fs.GetFile(fh) + if err != nil { + return edits, err + } + + output, err := tf.Format(file.Text()) + if err != nil { + return edits, err + } + + f := hcl.NewFile(file) + changes, err := f.Diff(output) + if err != nil { + return edits, err + } + + return ilsp.TextEdits(changes), nil +} diff --git a/langserver/handlers/formatting_test.go b/langserver/handlers/formatting_test.go new file mode 100644 index 000000000..63a8b2be8 --- /dev/null +++ b/langserver/handlers/formatting_test.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "testing" + + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/langserver" + "github.com/hashicorp/terraform-ls/langserver/session" +) + +func TestLangServer_formattingWithoutInitialization(t *testing.T) { + ls := langserver.NewLangServerMock(t, NewMock(nil)) + stop := ls.Start(t) + defer stop() + + ls.CallAndExpectError(t, &langserver.CallRequest{ + Method: "textDocument/formatting", + ReqParams: `{ + "textDocument": { + "version": 0, + "languageId": "terraform", + "text": "provider \"github\" {\n\n}\n", + "uri": "file:///var/main.tf" + } + }`}, session.SessionNotInitialized.Err()) +} + +func TestLangServer_formatting(t *testing.T) { + queue := validTfMockCalls() + queue.Q = append(queue.Q, &exec.MockItem{ + Args: []string{"fmt", "-"}, + Stdout: "provider \"test\" {\n\n}\n", + }) + ls := langserver.NewLangServerMock(t, NewMock(queue)) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: `{ + "capabilities": {}, + "rootUri": "file:///tmp", + "processId": 12345 + }`}) + ls.Notify(t, &langserver.CallRequest{ + Method: "initialized", + ReqParams: "{}", + }) + ls.Call(t, &langserver.CallRequest{ + Method: "textDocument/didOpen", + ReqParams: `{ + "textDocument": { + "version": 0, + "languageId": "terraform", + "text": "provider \"test\" {\n\n}\n", + "uri": "file:///tmp/main.tf" + } + }`}) + ls.CallAndExpectResponse(t, &langserver.CallRequest{ + Method: "textDocument/formatting", + ReqParams: `{ + "textDocument": { + "uri": "file:///tmp/main.tf" + } + }`}, `{ + "jsonrpc": "2.0", + "id": 3, + "result": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 3, "character": 0 } + }, + "newText": "provider \"test\" {\n\n}\n" + } + ] + }`) +} diff --git a/langserver/handlers/handlers_test.go b/langserver/handlers/handlers_test.go index 44ac03aa8..02e471e7e 100644 --- a/langserver/handlers/handlers_test.go +++ b/langserver/handlers/handlers_test.go @@ -28,7 +28,8 @@ func TestInitalizeAndShutdown(t *testing.T) { "openClose": true, "change": 1 }, - "completionProvider": {} + "completionProvider": {}, + "documentFormattingProvider":true } } }`) @@ -62,7 +63,8 @@ func TestEOF(t *testing.T) { "openClose": true, "change": 1 }, - "completionProvider": {} + "completionProvider": {}, + "documentFormattingProvider":true } } }`) diff --git a/langserver/handlers/initialize.go b/langserver/handlers/initialize.go index 5872ef87a..d7d169b58 100644 --- a/langserver/handlers/initialize.go +++ b/langserver/handlers/initialize.go @@ -22,6 +22,7 @@ func (lh *logHandler) Initialize(ctx context.Context, params lsp.InitializeParam CompletionProvider: &lsp.CompletionOptions{ ResolveProvider: false, }, + DocumentFormattingProvider: true, }, } diff --git a/langserver/handlers/service.go b/langserver/handlers/service.go index dc1016bb2..91d76a29a 100644 --- a/langserver/handlers/service.go +++ b/langserver/handlers/service.go @@ -146,6 +146,30 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { return handle(ctx, req, lh.TextDocumentComplete) }, + "textDocument/formatting": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { + err := session.CheckInitializationIsConfirmed() + if err != nil { + return nil, err + } + + ctx = lsctx.WithFilesystem(fs, ctx) + + tfPath, err := discovery.LookPath() + if err != nil { + return nil, err + } + + tf := svc.executorFunc(ctx, tfPath) + // Log path is set via CLI flag, hence the server context + if path, ok := lsctx.TerraformExecLogPath(svc.srvCtx); ok { + tf.SetExecLogPath(path) + } + tf.SetLogger(svc.logger) + + ctx = lsctx.WithTerraformExecutor(tf, ctx) + + return handle(ctx, req, lh.TextDocumentFormatting) + }, "shutdown": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { err := session.Shutdown(req) if err != nil {