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()) +}