From 0fd2d649e656450a551dd49c43b57425dffd4b53 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 6 Feb 2020 19:50:37 -0500 Subject: [PATCH] internal/lsp/lsprpc: add an LSP forwarder and regtest environment Add a new Forwarder type to the lsprpc package, which implements the jsonrpc2.StreamServer interface. This will be used to establish some parity in the implementation of shared and singleton gopls servers. Much more testing is needed, as is handling for the many edge cases around forwarding the LSP, but since this is functionally equivalent to TCP forwarding (and the -remote flag was already broken), I went ahead and used the Forwarder to replace the forward method in the serve command. This means that we can now use the combination of -listen and -remote to chain together gopls servers... not that there's any reason to do this. Also, wrap the new regression tests with a focus on expressiveness when testing the happy path, as well as parameterizing them so that they can be run against different client/server execution environments. This started to be sizable enough to warrant moving them to a separate regtest package. The lsprpc package tests will instead focus on unit testing the client-server binding logic. Updates golang/go#36879 Updates golang/go#34111 Change-Id: Ib98131a58aabc69299845d2ecefceccfc1199574 Reviewed-on: https://go-review.googlesource.com/c/tools/+/218698 Run-TryBot: Robert Findley TryBot-Result: Gobot Gobot Reviewed-by: Heschi Kreinick --- internal/lsp/cmd/serve.go | 28 +- internal/lsp/fake/edit.go | 10 + internal/lsp/lsprpc/definition_test.go | 101 ------- internal/lsp/lsprpc/diagnostics_test.go | 304 -------------------- internal/lsp/lsprpc/lsprpc.go | 55 ++++ internal/lsp/lsprpc/lsprpc_test.go | 38 ++- internal/lsp/regtest/definition_test.go | 84 ++++++ internal/lsp/regtest/diagnostics_test.go | 116 ++++++++ internal/lsp/regtest/env.go | 336 +++++++++++++++++++++++ internal/lsp/regtest/reg_test.go | 19 ++ 10 files changed, 651 insertions(+), 440 deletions(-) delete mode 100644 internal/lsp/lsprpc/definition_test.go delete mode 100644 internal/lsp/lsprpc/diagnostics_test.go create mode 100644 internal/lsp/regtest/definition_test.go create mode 100644 internal/lsp/regtest/diagnostics_test.go create mode 100644 internal/lsp/regtest/env.go create mode 100644 internal/lsp/regtest/reg_test.go diff --git a/internal/lsp/cmd/serve.go b/internal/lsp/cmd/serve.go index 0e7d211631..7c41d79201 100644 --- a/internal/lsp/cmd/serve.go +++ b/internal/lsp/cmd/serve.go @@ -8,8 +8,6 @@ import ( "context" "flag" "fmt" - "io" - "net" "os" "golang.org/x/tools/internal/jsonrpc2" @@ -64,11 +62,13 @@ func (s *Serve) Run(ctx context.Context, args ...string) error { s.app.debug.Serve(ctx) s.app.debug.MonitorMemory(ctx) + var ss jsonrpc2.StreamServer if s.app.Remote != "" { - return s.forward() + ss = lsprpc.NewForwarder(s.app.Remote, true) + } else { + ss = lsprpc.NewStreamServer(cache.New(s.app.options), true) } - ss := lsprpc.NewStreamServer(cache.New(s.app.options), true) if s.Address != "" { return jsonrpc2.ListenAndServe(ctx, s.Address, ss) } @@ -82,23 +82,3 @@ func (s *Serve) Run(ctx context.Context, args ...string) error { } return ss.ServeStream(ctx, stream) } - -func (s *Serve) forward() error { - conn, err := net.Dial("tcp", s.app.Remote) - if err != nil { - return err - } - errc := make(chan error) - - go func(conn net.Conn) { - _, err := io.Copy(conn, os.Stdin) - errc <- err - }(conn) - - go func(conn net.Conn) { - _, err := io.Copy(os.Stdout, conn) - errc <- err - }(conn) - - return <-errc -} diff --git a/internal/lsp/fake/edit.go b/internal/lsp/fake/edit.go index 9d2c7a55c6..1eec5972dc 100644 --- a/internal/lsp/fake/edit.go +++ b/internal/lsp/fake/edit.go @@ -36,6 +36,16 @@ type Edit struct { Text string } +// NewEdit creates an edit replacing all content between +// (startLine, startColumn) and (endLine, endColumn) with text. +func NewEdit(startLine, startColumn, endLine, endColumn int, text string) Edit { + return Edit{ + Start: Pos{Line: startLine, Column: startColumn}, + End: Pos{Line: endLine, Column: endColumn}, + Text: text, + } +} + func (e Edit) toProtocolChangeEvent() protocol.TextDocumentContentChangeEvent { return protocol.TextDocumentContentChangeEvent{ Range: &protocol.Range{ diff --git a/internal/lsp/lsprpc/definition_test.go b/internal/lsp/lsprpc/definition_test.go deleted file mode 100644 index 5d70371d35..0000000000 --- a/internal/lsp/lsprpc/definition_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package lsprpc - -import ( - "fmt" - "path" - "testing" - "time" - - "golang.org/x/tools/internal/lsp/fake" -) - -const internalDefinition = ` --- go.mod -- -module mod - -go 1.12 --- main.go -- -package main - -import "fmt" - -func main() { - fmt.Println(message) -} --- const.go -- -package main - -const message = "Hello World." -` - -func TestGoToInternalDefinition(t *testing.T) { - t.Parallel() - ctx, env, cleanup := setupEnv(t, internalDefinition) - defer cleanup() - - if err := env.editor.OpenFile(ctx, "main.go"); err != nil { - t.Fatal(err) - } - name, pos, err := env.editor.GoToDefinition(ctx, "main.go", fake.Pos{Line: 5, Column: 13}) - if err != nil { - t.Fatal(err) - } - if want := "const.go"; name != want { - t.Errorf("GoToDefinition: got file %q, want %q", name, want) - } - if want := (fake.Pos{Line: 2, Column: 6}); pos != want { - t.Errorf("GoToDefinition: got position %v, want %v", pos, want) - } -} - -const stdlibDefinition = ` --- go.mod -- -module mod - -go 1.12 --- main.go -- -package main - -import ( - "fmt" - "time" -) - -func main() { - fmt.Println(time.Now()) -}` - -func TestGoToStdlibDefinition(t *testing.T) { - t.Parallel() - ctx, env, cleanup := setupEnv(t, stdlibDefinition) - defer cleanup() - - if err := env.editor.OpenFile(ctx, "main.go"); err != nil { - t.Fatal(err) - } - name, pos, err := env.editor.GoToDefinition(ctx, "main.go", fake.Pos{Line: 8, Column: 19}) - if err != nil { - t.Fatal(err) - } - fmt.Println(time.Now()) - if got, want := path.Base(name), "time.go"; got != want { - t.Errorf("GoToDefinition: got file %q, want %q", name, want) - } - - // Test that we can jump to definition from outside our workspace. - // See golang.org/issues/37045. - newName, newPos, err := env.editor.GoToDefinition(ctx, name, pos) - if err != nil { - t.Fatal(err) - } - if newName != name { - t.Errorf("GoToDefinition is not idempotent: got %q, want %q", newName, name) - } - if newPos != pos { - t.Errorf("GoToDefinition is not idempotent: got %v, want %v", newPos, pos) - } -} diff --git a/internal/lsp/lsprpc/diagnostics_test.go b/internal/lsp/lsprpc/diagnostics_test.go deleted file mode 100644 index 7cab2ae518..0000000000 --- a/internal/lsp/lsprpc/diagnostics_test.go +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package lsprpc - -import ( - "context" - "errors" - "fmt" - "testing" - "time" - - "golang.org/x/tools/internal/jsonrpc2/servertest" - "golang.org/x/tools/internal/lsp/cache" - "golang.org/x/tools/internal/lsp/fake" - "golang.org/x/tools/internal/lsp/protocol" -) - -const exampleProgram = ` --- go.mod -- -module mod - -go 1.12 --- main.go -- -package main - -import "fmt" - -func main() { - fmt.Println("Hello World.") -}` - -type testEnvironment struct { - editor *fake.Editor - ws *fake.Workspace - ts *servertest.Server - - dw diagnosticsWatcher -} - -func setupEnv(t *testing.T, txt string) (context.Context, testEnvironment, func()) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - - ws, err := fake.NewWorkspace("lsprpc", []byte(txt)) - if err != nil { - t.Fatal(err) - } - ss := NewStreamServer(cache.New(nil), false) - ts := servertest.NewServer(ctx, ss) - cc := ts.Connect(ctx) - - editor, err := fake.NewConnectedEditor(ctx, ws, cc) - if err != nil { - t.Fatal(err) - } - dw := newDiagWatcher(ws) - editor.Client().OnDiagnostics(dw.onDiagnostics) - cleanup := func() { - cancel() - ts.Close() - ws.Close() - } - return ctx, testEnvironment{ - editor: editor, - ws: ws, - ts: ts, - dw: dw, - }, cleanup -} - -type diagnosticsWatcher struct { - diagnostics chan *protocol.PublishDiagnosticsParams - ws *fake.Workspace -} - -func newDiagWatcher(ws *fake.Workspace) diagnosticsWatcher { - return diagnosticsWatcher{ - // Allow an arbitrarily large buffer, as we should never want onDiagnostics - // to block. - diagnostics: make(chan *protocol.PublishDiagnosticsParams, 1000), - ws: ws, - } -} - -func (w diagnosticsWatcher) onDiagnostics(_ context.Context, p *protocol.PublishDiagnosticsParams) error { - w.diagnostics <- p - return nil -} - -func (w diagnosticsWatcher) await(ctx context.Context, expected ...string) (map[string]*protocol.PublishDiagnosticsParams, error) { - expectedSet := make(map[string]bool) - for _, e := range expected { - expectedSet[e] = true - } - got := make(map[string]*protocol.PublishDiagnosticsParams) - for len(got) < len(expectedSet) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case d := <-w.diagnostics: - pth := w.ws.URIToPath(d.URI) - if expectedSet[pth] { - got[pth] = d - } - } - } - return got, nil -} - -func checkDiagnosticLocation(params *protocol.PublishDiagnosticsParams, filename string, line, col int) error { - if got, want := params.URI, filename; got != want { - return fmt.Errorf("got diagnostics for URI %q, want %q", got, want) - } - if len(params.Diagnostics) == 0 { - return errors.New("empty diagnostics") - } - diag := params.Diagnostics[0] - if diag.Range.Start.Line != float64(line) || diag.Range.Start.Character != float64(col) { - return fmt.Errorf("Diagnostics[0].Range.Start = %v, want (5,5)", diag.Range.Start) - } - return nil -} - -func TestDiagnosticErrorInEditedFile(t *testing.T) { - t.Parallel() - ctx, env, cleanup := setupEnv(t, exampleProgram) - defer cleanup() - - // Deleting the 'n' at the end of Println should generate a single error - // diagnostic. - edits := []fake.Edit{ - { - Start: fake.Pos{Line: 5, Column: 11}, - End: fake.Pos{Line: 5, Column: 12}, - Text: "", - }, - } - if err := env.editor.OpenFile(ctx, "main.go"); err != nil { - t.Fatal(err) - } - if err := env.editor.EditBuffer(ctx, "main.go", edits); err != nil { - t.Fatal(err) - } - diags, err := env.dw.await(ctx, "main.go") - if err != nil { - t.Fatal(err) - } - if err := checkDiagnosticLocation(diags["main.go"], env.ws.URI("main.go"), 5, 5); err != nil { - t.Fatal(err) - } -} - -func TestSimultaneousEdits(t *testing.T) { - t.Parallel() - ctx, env, cleanup := setupEnv(t, exampleProgram) - defer cleanup() - - // Set up a second editor session connected to the same server, using the - // same workspace. - conn2 := env.ts.Connect(ctx) - editor2, err := fake.NewConnectedEditor(ctx, env.ws, conn2) - if err != nil { - t.Fatal(err) - } - dw2 := newDiagWatcher(env.ws) - editor2.Client().OnDiagnostics(dw2.onDiagnostics) - // In editor #1, break fmt.Println as before. - edits1 := []fake.Edit{{ - Start: fake.Pos{Line: 5, Column: 11}, - End: fake.Pos{Line: 5, Column: 12}, - Text: "", - }} - if err := env.editor.OpenFile(ctx, "main.go"); err != nil { - t.Fatal(err) - } - if err := env.editor.EditBuffer(ctx, "main.go", edits1); err != nil { - t.Fatal(err) - } - - // In editor #2 remove the closing brace. - edits2 := []fake.Edit{{ - Start: fake.Pos{Line: 6, Column: 0}, - End: fake.Pos{Line: 6, Column: 1}, - Text: "", - }} - if err := editor2.OpenFile(ctx, "main.go"); err != nil { - t.Fatal(err) - } - if err := editor2.EditBuffer(ctx, "main.go", edits2); err != nil { - t.Fatal(err) - } - diags1, err := env.dw.await(ctx, "main.go") - if err != nil { - t.Fatal(err) - } - diags2, err := dw2.await(ctx, "main.go") - if err != nil { - t.Fatal(err) - } - if err := checkDiagnosticLocation(diags1["main.go"], env.ws.URI("main.go"), 5, 5); err != nil { - t.Fatal(err) - } - if err := checkDiagnosticLocation(diags2["main.go"], env.ws.URI("main.go"), 7, 0); err != nil { - t.Fatal(err) - } -} - -const brokenFile = `package main - -const Foo = "abc -` - -func TestDiagnosticErrorInNewFile(t *testing.T) { - t.Parallel() - ctx, env, cleanup := setupEnv(t, exampleProgram) - defer cleanup() - - if err := env.editor.CreateBuffer(ctx, "broken.go", brokenFile); err != nil { - t.Fatal(err) - } - _, err := env.dw.await(ctx, "broken.go") - if err != nil { - t.Fatal(err) - } -} - -// badPackage contains a duplicate definition of the 'a' const. -const badPackage = ` --- go.mod -- -module mod - -go 1.12 --- a.go -- -package consts - -const a = 1 --- b.go -- -package consts - -const a = 2 -` - -func TestDiagnosticClearingOnEdit(t *testing.T) { - t.Parallel() - ctx, env, cleanup := setupEnv(t, badPackage) - defer cleanup() - - if err := env.editor.OpenFile(ctx, "b.go"); err != nil { - t.Fatal(err) - } - _, err := env.dw.await(ctx, "a.go", "b.go") - if err != nil { - t.Fatal(err) - } - - // In editor #2 remove the closing brace. - edits := []fake.Edit{{ - Start: fake.Pos{Line: 2, Column: 6}, - End: fake.Pos{Line: 2, Column: 7}, - Text: "b", - }} - if err := env.editor.EditBuffer(ctx, "b.go", edits); err != nil { - t.Fatal(err) - } - diags, err := env.dw.await(ctx, "a.go", "b.go") - if err != nil { - t.Fatal(err) - } - for pth, d := range diags { - if len(d.Diagnostics) != 0 { - t.Errorf("non-empty diagnostics for %q", pth) - } - } -} - -func TestDiagnosticClearingOnDelete(t *testing.T) { - t.Skip("skipping due to golang.org/issues/37049") - - t.Parallel() - ctx, env, cleanup := setupEnv(t, badPackage) - defer cleanup() - - if err := env.editor.OpenFile(ctx, "a.go"); err != nil { - t.Fatal(err) - } - _, err := env.dw.await(ctx, "a.go", "b.go") - if err != nil { - t.Fatal(err) - } - env.ws.RemoveFile(ctx, "b.go") - - // TODO(golang.org/issues/37049): here we only get diagnostics for a.go. - diags, err := env.dw.await(ctx, "a.go", "b.go") - if err != nil { - t.Fatal(err) - } - for pth, d := range diags { - if len(d.Diagnostics) != 0 { - t.Errorf("non-empty diagnostics for %q", pth) - } - } -} diff --git a/internal/lsp/lsprpc/lsprpc.go b/internal/lsp/lsprpc/lsprpc.go index 863efce64c..78768c9f01 100644 --- a/internal/lsp/lsprpc/lsprpc.go +++ b/internal/lsp/lsprpc/lsprpc.go @@ -8,7 +8,10 @@ package lsprpc import ( "context" + "fmt" + "net" + "golang.org/x/sync/errgroup" "golang.org/x/tools/internal/jsonrpc2" "golang.org/x/tools/internal/lsp" "golang.org/x/tools/internal/lsp/protocol" @@ -51,3 +54,55 @@ func (s *StreamServer) ServeStream(ctx context.Context, stream jsonrpc2.Stream) } return conn.Run(protocol.WithClient(ctx, client)) } + +// A Forwarder is a jsonrpc2.StreamServer that handles an LSP stream by +// forwarding it to a remote. This is used when the gopls process started by +// the editor is in the `-remote` mode, which means it finds and connects to a +// separate gopls daemon. In these cases, we still want the forwarder gopls to +// be instrumented with telemetry, and want to be able to in some cases hijack +// the jsonrpc2 connection with the daemon. +type Forwarder struct { + remote string + withTelemetry bool +} + +// NewForwarder creates a new Forwarder, ready to forward connections to the +// given remote. +func NewForwarder(remote string, withTelemetry bool) *Forwarder { + return &Forwarder{ + remote: remote, + withTelemetry: withTelemetry, + } +} + +// ServeStream dials the forwarder remote and binds the remote to serve the LSP +// on the incoming stream. +func (f *Forwarder) ServeStream(ctx context.Context, stream jsonrpc2.Stream) error { + clientConn := jsonrpc2.NewConn(stream) + client := protocol.ClientDispatcher(clientConn) + + netConn, err := net.Dial("tcp", f.remote) + if err != nil { + return fmt.Errorf("forwarder: dialing remote: %v", err) + } + serverConn := jsonrpc2.NewConn(jsonrpc2.NewHeaderStream(netConn, netConn)) + server := protocol.ServerDispatcher(serverConn) + + // Forward between connections. + serverConn.AddHandler(protocol.ClientHandler(client)) + serverConn.AddHandler(protocol.Canceller{}) + clientConn.AddHandler(protocol.ServerHandler(server)) + clientConn.AddHandler(protocol.Canceller{}) + if f.withTelemetry { + clientConn.AddHandler(telemetryHandler{}) + } + + g, ctx := errgroup.WithContext(ctx) + g.Go(func() error { + return serverConn.Run(ctx) + }) + g.Go(func() error { + return clientConn.Run(ctx) + }) + return g.Wait() +} diff --git a/internal/lsp/lsprpc/lsprpc_test.go b/internal/lsp/lsprpc/lsprpc_test.go index 592139bedd..a36affc0c9 100644 --- a/internal/lsp/lsprpc/lsprpc_test.go +++ b/internal/lsp/lsprpc/lsprpc_test.go @@ -102,17 +102,33 @@ func TestRequestCancellation(t *testing.T) { }, } ctx := context.Background() - ts := servertest.NewServer(ctx, ss) - cc := ts.Connect(ctx) - cc.AddHandler(protocol.Canceller{}) - lensCtx, cancelLens := context.WithCancel(context.Background()) - go func() { - protocol.ServerDispatcher(cc).CodeLens(lensCtx, &protocol.CodeLensParams{}) - }() - <-server.started - cancelLens() - if got, want := <-server.finished, true; got != want { - t.Errorf("CodeLens was cancelled: %t, want %t", got, want) + tsDirect := servertest.NewServer(ctx, ss) + + forwarder := NewForwarder(tsDirect.Addr, false) + tsForwarded := servertest.NewServer(ctx, forwarder) + + tests := []struct { + serverType string + ts *servertest.Server + }{ + {"direct", tsDirect}, + {"forwarder", tsForwarded}, + } + + for _, test := range tests { + t.Run(test.serverType, func(t *testing.T) { + cc := test.ts.Connect(ctx) + cc.AddHandler(protocol.Canceller{}) + lensCtx, cancelLens := context.WithCancel(context.Background()) + go func() { + protocol.ServerDispatcher(cc).CodeLens(lensCtx, &protocol.CodeLensParams{}) + }() + <-server.started + cancelLens() + if got, want := <-server.finished, true; got != want { + t.Errorf("CodeLens was cancelled: %t, want %t", got, want) + } + }) } } diff --git a/internal/lsp/regtest/definition_test.go b/internal/lsp/regtest/definition_test.go new file mode 100644 index 0000000000..14a4ea1130 --- /dev/null +++ b/internal/lsp/regtest/definition_test.go @@ -0,0 +1,84 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package regtest + +import ( + "context" + "path" + "testing" + + "golang.org/x/tools/internal/lsp/fake" +) + +const internalDefinition = ` +-- go.mod -- +module mod + +go 1.12 +-- main.go -- +package main + +import "fmt" + +func main() { + fmt.Println(message) +} +-- const.go -- +package main + +const message = "Hello World." +` + +func TestGoToInternalDefinition(t *testing.T) { + t.Parallel() + runner.Run(t, internalDefinition, func(ctx context.Context, t *testing.T, env *Env) { + env.OpenFile("main.go") + name, pos := env.GoToDefinition("main.go", fake.Pos{Line: 5, Column: 13}) + if want := "const.go"; name != want { + t.Errorf("GoToDefinition: got file %q, want %q", name, want) + } + if want := (fake.Pos{Line: 2, Column: 6}); pos != want { + t.Errorf("GoToDefinition: got position %v, want %v", pos, want) + } + }) +} + +const stdlibDefinition = ` +-- go.mod -- +module mod + +go 1.12 +-- main.go -- +package main + +import ( + "fmt" + "time" +) + +func main() { + fmt.Println(time.Now()) +}` + +func TestGoToStdlibDefinition(t *testing.T) { + t.Parallel() + runner.Run(t, stdlibDefinition, func(ctx context.Context, t *testing.T, env *Env) { + env.OpenFile("main.go") + name, pos := env.GoToDefinition("main.go", fake.Pos{Line: 8, Column: 19}) + if got, want := path.Base(name), "time.go"; got != want { + t.Errorf("GoToDefinition: got file %q, want %q", name, want) + } + + // Test that we can jump to definition from outside our workspace. + // See golang.org/issues/37045. + newName, newPos := env.GoToDefinition(name, pos) + if newName != name { + t.Errorf("GoToDefinition is not idempotent: got %q, want %q", newName, name) + } + if newPos != pos { + t.Errorf("GoToDefinition is not idempotent: got %v, want %v", newPos, pos) + } + }) +} diff --git a/internal/lsp/regtest/diagnostics_test.go b/internal/lsp/regtest/diagnostics_test.go new file mode 100644 index 0000000000..a75b4b9f19 --- /dev/null +++ b/internal/lsp/regtest/diagnostics_test.go @@ -0,0 +1,116 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package regtest + +import ( + "context" + "testing" + + "golang.org/x/tools/internal/lsp/fake" +) + +const exampleProgram = ` +-- go.mod -- +module mod + +go 1.12 +-- main.go -- +package main + +import "fmt" + +func main() { + fmt.Println("Hello World.") +}` + +func TestDiagnosticErrorInEditedFile(t *testing.T) { + t.Parallel() + runner.Run(t, exampleProgram, func(ctx context.Context, t *testing.T, env *Env) { + // Deleting the 'n' at the end of Println should generate a single error + // diagnostic. + edit := fake.NewEdit(5, 11, 5, 12, "") + env.OpenFile("main.go") + env.EditBuffer("main.go", edit) + env.Await(DiagnosticAt("main.go", 5, 5)) + }) +} + +func TestSimultaneousEdits(t *testing.T) { + t.Parallel() + runner.Run(t, exampleProgram, func(ctx context.Context, t *testing.T, env1 *Env) { + // Create a second test session connected to the same workspace and server + // as the first. + env2 := NewEnv(ctx, t, env1.W, env1.Server) + + // In editor #1, break fmt.Println as before. + edit1 := fake.NewEdit(5, 11, 5, 12, "") + env1.OpenFile("main.go") + env1.EditBuffer("main.go", edit1) + // In editor #2 remove the closing brace. + edit2 := fake.NewEdit(6, 0, 6, 1, "") + env2.OpenFile("main.go") + env2.EditBuffer("main.go", edit2) + + // Now check that we got different diagnostics in each environment. + env1.Await(DiagnosticAt("main.go", 5, 5)) + env2.Await(DiagnosticAt("main.go", 7, 0)) + }) +} + +const brokenFile = `package main + +const Foo = "abc +` + +func TestDiagnosticErrorInNewFile(t *testing.T) { + t.Parallel() + runner.Run(t, brokenFile, func(ctx context.Context, t *testing.T, env *Env) { + env.CreateBuffer("broken.go", brokenFile) + env.Await(DiagnosticAt("broken.go", 2, 12)) + }) +} + +// badPackage contains a duplicate definition of the 'a' const. +const badPackage = ` +-- go.mod -- +module mod + +go 1.12 +-- a.go -- +package consts + +const a = 1 +-- b.go -- +package consts + +const a = 2 +` + +func TestDiagnosticClearingOnEdit(t *testing.T) { + t.Parallel() + runner.Run(t, badPackage, func(ctx context.Context, t *testing.T, env *Env) { + env.OpenFile("b.go") + env.Await(DiagnosticAt("a.go", 2, 6), DiagnosticAt("b.go", 2, 6)) + + // Fix the error by editing the const name in b.go to `b`. + edit := fake.NewEdit(2, 6, 2, 7, "b") + env.EditBuffer("b.go", edit) + env.Await(EmptyDiagnostics("a.go"), EmptyDiagnostics("b.go")) + }) +} + +func TestDiagnosticClearingOnDelete(t *testing.T) { + t.Skip("skipping due to golang.org/issues/37049") + + t.Parallel() + runner.Run(t, badPackage, func(ctx context.Context, t *testing.T, env *Env) { + env.OpenFile("a.go") + env.Await(DiagnosticAt("a.go", 2, 6), DiagnosticAt("b.go", 2, 6)) + env.RemoveFileFromWorkspace("b.go") + + // TODO(golang.org/issues/37049): here we only get diagnostics for a.go. + env.Await(EmptyDiagnostics("a.go"), EmptyDiagnostics("b.go")) + }) +} diff --git a/internal/lsp/regtest/env.go b/internal/lsp/regtest/env.go new file mode 100644 index 0000000000..8bc100f3b1 --- /dev/null +++ b/internal/lsp/regtest/env.go @@ -0,0 +1,336 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package regtest provides an environment for writing regression tests. +package regtest + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/tools/internal/jsonrpc2/servertest" + "golang.org/x/tools/internal/lsp/cache" + "golang.org/x/tools/internal/lsp/fake" + "golang.org/x/tools/internal/lsp/lsprpc" + "golang.org/x/tools/internal/lsp/protocol" +) + +// EnvMode is a bitmask that defines in which execution environments a test +// should run. +type EnvMode int + +const ( + // Singleton mode uses a separate cache for each test + Singleton EnvMode = 1 << iota + // Shared mode uses a Shared cache + Shared + // Forwarded forwards connections + Forwarded + // AllModes runs tests in all modes + AllModes = Singleton | Shared | Forwarded +) + +// A Runner runs tests in gopls execution environments, as specified by its +// modes. For modes that share state (for example, a shared cache or common +// remote), any tests that execute on the same Runner will share the same +// state. +type Runner struct { + ts *servertest.Server + modes EnvMode + timeout time.Duration +} + +// NewTestRunner creates a Runner with its shared state initialized, ready to +// run tests. +func NewTestRunner(modes EnvMode, testTimeout time.Duration) *Runner { + ss := lsprpc.NewStreamServer(cache.New(nil), false) + ts := servertest.NewServer(context.Background(), ss) + return &Runner{ + ts: ts, + modes: modes, + timeout: testTimeout, + } +} + +// Close cleans up resource that have been allocated to this workspace. +func (r *Runner) Close() error { + return r.ts.Close() +} + +// Run executes the test function in in all configured gopls execution modes. +// For each a test run, a new workspace is created containing the un-txtared +// files specified by filedata. +func (r *Runner) Run(t *testing.T, filedata string, test func(context.Context, *testing.T, *Env)) { + t.Helper() + + tests := []struct { + name string + mode EnvMode + makeServer func(context.Context, *testing.T) (*servertest.Server, func()) + }{ + {"singleton", Singleton, r.singletonEnv}, + {"shared", Shared, r.sharedEnv}, + {"forwarded", Forwarded, r.forwardedEnv}, + } + + for _, tc := range tests { + tc := tc + if r.modes&tc.mode == 0 { + continue + } + t.Run(tc.name, func(t *testing.T) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), r.timeout) + defer cancel() + ws, err := fake.NewWorkspace("lsprpc", []byte(filedata)) + if err != nil { + t.Fatal(err) + } + defer ws.Close() + ts, cleanup := tc.makeServer(ctx, t) + defer cleanup() + env := NewEnv(ctx, t, ws, ts) + test(ctx, t, env) + }) + } +} + +func (r *Runner) singletonEnv(ctx context.Context, t *testing.T) (*servertest.Server, func()) { + ss := lsprpc.NewStreamServer(cache.New(nil), false) + ts := servertest.NewServer(ctx, ss) + cleanup := func() { + ts.Close() + } + return ts, cleanup +} + +func (r *Runner) sharedEnv(ctx context.Context, t *testing.T) (*servertest.Server, func()) { + return r.ts, func() {} +} + +func (r *Runner) forwardedEnv(ctx context.Context, t *testing.T) (*servertest.Server, func()) { + forwarder := lsprpc.NewForwarder(r.ts.Addr, false) + ts2 := servertest.NewServer(ctx, forwarder) + cleanup := func() { + ts2.Close() + } + return ts2, cleanup +} + +// Env holds an initialized fake Editor, Workspace, and Server, which may be +// used for writing tests. It also provides adapter methods that call t.Fatal +// on any error, so that tests for the happy path may be written without +// checking errors. +type Env struct { + t *testing.T + ctx context.Context + + // Most tests should not need to access the workspace or editor, or server, + // but they are available if needed. + W *fake.Workspace + E *fake.Editor + Server *servertest.Server + + // mu guards the fields below, for the purpose of checking conditions on + // every change to diagnostics. + mu sync.Mutex + // For simplicity, each waiter gets a unique ID. + nextWaiterID int + lastDiagnostics map[string]*protocol.PublishDiagnosticsParams + waiters map[int]*diagnosticCondition +} + +// A diagnosticCondition is satisfied when all expectations are simultaneously +// met. At that point, the 'met' channel is closed. +type diagnosticCondition struct { + expectations []DiagnosticExpectation + met chan struct{} +} + +// NewEnv creates a new test environment using the given workspace and gopls +// server. +func NewEnv(ctx context.Context, t *testing.T, ws *fake.Workspace, ts *servertest.Server) *Env { + t.Helper() + conn := ts.Connect(ctx) + editor, err := fake.NewConnectedEditor(ctx, ws, conn) + if err != nil { + t.Fatal(err) + } + env := &Env{ + t: t, + ctx: ctx, + W: ws, + E: editor, + Server: ts, + lastDiagnostics: make(map[string]*protocol.PublishDiagnosticsParams), + waiters: make(map[int]*diagnosticCondition), + } + env.E.Client().OnDiagnostics(env.onDiagnostics) + return env +} + +// RemoveFileFromWorkspace deletes a file on disk but does nothing in the +// editor. It calls t.Fatal on any error. +func (e *Env) RemoveFileFromWorkspace(name string) { + e.t.Helper() + if err := e.W.RemoveFile(e.ctx, name); err != nil { + e.t.Fatal(err) + } +} + +// OpenFile opens a file in the editor, calling t.Fatal on any error. +func (e *Env) OpenFile(name string) { + e.t.Helper() + if err := e.E.OpenFile(e.ctx, name); err != nil { + e.t.Fatal(err) + } +} + +// CreateBuffer creates a buffer in the editor, calling t.Fatal on any error. +func (e *Env) CreateBuffer(name string, content string) { + e.t.Helper() + if err := e.E.CreateBuffer(e.ctx, name, content); err != nil { + e.t.Fatal(err) + } +} + +// EditBuffer applies edits to an editor buffer, calling t.Fatal on any error. +func (e *Env) EditBuffer(name string, edits ...fake.Edit) { + e.t.Helper() + if err := e.E.EditBuffer(e.ctx, name, edits); err != nil { + e.t.Fatal(err) + } +} + +// GoToDefinition goes to definition in the editor, calling t.Fatal on any +// error. +func (e *Env) GoToDefinition(name string, pos fake.Pos) (string, fake.Pos) { + e.t.Helper() + n, p, err := e.E.GoToDefinition(e.ctx, name, pos) + if err != nil { + e.t.Fatal(err) + } + return n, p +} + +func (e *Env) onDiagnostics(_ context.Context, d *protocol.PublishDiagnosticsParams) error { + e.mu.Lock() + defer e.mu.Unlock() + + pth := e.W.URIToPath(d.URI) + e.lastDiagnostics[pth] = d + + for id, condition := range e.waiters { + if meetsCondition(e.lastDiagnostics, condition.expectations) { + delete(e.waiters, id) + close(condition.met) + } + } + + return nil +} + +func meetsCondition(m map[string]*protocol.PublishDiagnosticsParams, expectations []DiagnosticExpectation) bool { + for _, e := range expectations { + if !e.IsMet(m) { + return false + } + } + return true +} + +// A DiagnosticExpectation is a condition that must be met by the current set +// of diagnostics. +type DiagnosticExpectation struct { + IsMet func(map[string]*protocol.PublishDiagnosticsParams) bool + Description string +} + +// EmptyDiagnostics asserts that diagnostics are empty for the +// workspace-relative path name. +func EmptyDiagnostics(name string) DiagnosticExpectation { + isMet := func(diags map[string]*protocol.PublishDiagnosticsParams) bool { + ds, ok := diags[name] + return ok && len(ds.Diagnostics) == 0 + } + return DiagnosticExpectation{ + IsMet: isMet, + Description: fmt.Sprintf("empty diagnostics for %q", name), + } +} + +// DiagnosticAt asserts that there is a diagnostic entry at the position +// specified by line and col, for the workspace-relative path name. +func DiagnosticAt(name string, line, col int) DiagnosticExpectation { + isMet := func(diags map[string]*protocol.PublishDiagnosticsParams) bool { + ds, ok := diags[name] + if !ok || len(ds.Diagnostics) == 0 { + return false + } + for _, d := range ds.Diagnostics { + if d.Range.Start.Line == float64(line) && d.Range.Start.Character == float64(col) { + return true + } + } + return false + } + return DiagnosticExpectation{ + IsMet: isMet, + Description: fmt.Sprintf("diagnostic in %q at (line:%d, column:%d)", name, line, col), + } +} + +// Await waits for all diagnostic expectations to simultaneously be met. +func (e *Env) Await(expectations ...DiagnosticExpectation) { + // NOTE: in the future this mechanism extend beyond just diagnostics, for + // example by modifying IsMet to be a func(*Env) boo. However, that would + // require careful checking of conditions around every state change, so for + // now we just limit the scope to diagnostic conditions. + + e.t.Helper() + e.mu.Lock() + // Before adding the waiter, we check if the condition is currently met to + // avoid a race where the condition was realized before Await was called. + if meetsCondition(e.lastDiagnostics, expectations) { + e.mu.Unlock() + return + } + met := make(chan struct{}) + e.waiters[e.nextWaiterID] = &diagnosticCondition{ + expectations: expectations, + met: met, + } + e.nextWaiterID++ + e.mu.Unlock() + + select { + case <-e.ctx.Done(): + // Debugging an unmet expectation can be tricky, so we put some effort into + // nicely formatting the failure. + var descs []string + for _, e := range expectations { + descs = append(descs, e.Description) + } + e.mu.Lock() + diagString := formatDiagnostics(e.lastDiagnostics) + e.mu.Unlock() + e.t.Fatalf("waiting on (%s):\nerr:%v\ndiagnostics:\n%s", strings.Join(descs, ", "), e.ctx.Err(), diagString) + case <-met: + } +} + +func formatDiagnostics(diags map[string]*protocol.PublishDiagnosticsParams) string { + var b strings.Builder + for name, params := range diags { + b.WriteString(name + ":\n") + for _, d := range params.Diagnostics { + b.WriteString(fmt.Sprintf("\t(%d, %d): %s\n", int(d.Range.Start.Line), int(d.Range.Start.Character), d.Message)) + } + } + return b.String() +} diff --git a/internal/lsp/regtest/reg_test.go b/internal/lsp/regtest/reg_test.go new file mode 100644 index 0000000000..58a9096030 --- /dev/null +++ b/internal/lsp/regtest/reg_test.go @@ -0,0 +1,19 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package regtest + +import ( + "os" + "testing" + "time" +) + +var runner *Runner + +func TestMain(m *testing.M) { + runner = NewTestRunner(AllModes, 30*time.Second) + defer runner.Close() + os.Exit(m.Run()) +}