From d72a64a960cdb33eaefd4fbbde3266424d314009 Mon Sep 17 00:00:00 2001 From: Robin Neatherway Date: Tue, 13 Dec 2022 10:33:44 +0000 Subject: [PATCH] gopls/internal/lsp: add selection range request selectionRange defines the textDocument/selectionRange feature, which, given a list of positions within a file, reports a linked list of enclosing syntactic blocks, innermost first. See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_selectionRange. This feature can be used by a client to implement "expand selection" in a language-aware fashion. Multiple input positions are supported to allow for multiple cursors, and the entire path up to the whole document is returned for each cursor to avoid multiple round-trips when the user is likely to issue this command multiple times in quick succession. Fixes golang/go#36679 Change-Id: I4852db4b40be24b3dc13e4d9d9238c1a9ac5f824 GitHub-Last-Rev: 0a117415b1675d5196cb84c953bfa42fb7854291 GitHub-Pull-Request: golang/tools#416 Reviewed-on: https://go-review.googlesource.com/c/tools/+/452315 Reviewed-by: Alan Donovan gopls-CI: kokoro TryBot-Result: Gopher Robot Reviewed-by: Robert Findley Run-TryBot: Robert Findley --- gopls/internal/lsp/cmd/test/cmdtest.go | 2 + gopls/internal/lsp/lsp_test.go | 65 +++++++++++++++++ gopls/internal/lsp/selection_range.go | 69 +++++++++++++++++++ gopls/internal/lsp/server_gen.go | 4 +- gopls/internal/lsp/source/source_test.go | 4 ++ .../lsp/testdata/selectionrange/foo.go | 13 ++++ .../lsp/testdata/selectionrange/foo.go.golden | 29 ++++++++ .../internal/lsp/testdata/summary.txt.golden | 1 + .../lsp/testdata/summary_go1.18.txt.golden | 1 + gopls/internal/lsp/tests/tests.go | 18 +++++ 10 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 gopls/internal/lsp/selection_range.go create mode 100644 gopls/internal/lsp/testdata/selectionrange/foo.go create mode 100644 gopls/internal/lsp/testdata/selectionrange/foo.go.golden diff --git a/gopls/internal/lsp/cmd/test/cmdtest.go b/gopls/internal/lsp/cmd/test/cmdtest.go index fed47497576..16497093883 100644 --- a/gopls/internal/lsp/cmd/test/cmdtest.go +++ b/gopls/internal/lsp/cmd/test/cmdtest.go @@ -118,6 +118,8 @@ func (r *runner) InlayHints(t *testing.T, spn span.Span) { // TODO: inlayHints not supported on command line } +func (r *runner) SelectionRanges(t *testing.T, spn span.Span) {} + func (r *runner) runGoplsCmd(t testing.TB, args ...string) (string, string) { rStdout, wStdout, err := os.Pipe() if err != nil { diff --git a/gopls/internal/lsp/lsp_test.go b/gopls/internal/lsp/lsp_test.go index 23941be0f6e..59f286b8d45 100644 --- a/gopls/internal/lsp/lsp_test.go +++ b/gopls/internal/lsp/lsp_test.go @@ -1282,6 +1282,71 @@ func (r *runner) AddImport(t *testing.T, uri span.URI, expectedImport string) { } } +func (r *runner) SelectionRanges(t *testing.T, spn span.Span) { + uri := spn.URI() + sm, err := r.data.Mapper(uri) + if err != nil { + t.Fatal(err) + } + loc, err := sm.Location(spn) + if err != nil { + t.Error(err) + } + + ranges, err := r.server.selectionRange(r.ctx, &protocol.SelectionRangeParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: protocol.URIFromSpanURI(uri), + }, + Positions: []protocol.Position{loc.Range.Start}, + }) + if err != nil { + t.Fatal(err) + } + + sb := &strings.Builder{} + for i, path := range ranges { + fmt.Fprintf(sb, "Ranges %d: ", i) + rng := path + for { + s, err := sm.Offset(rng.Range.Start) + if err != nil { + t.Error(err) + } + e, err := sm.Offset(rng.Range.End) + if err != nil { + t.Error(err) + } + + var snippet string + if e-s < 30 { + snippet = string(sm.Content[s:e]) + } else { + snippet = string(sm.Content[s:s+15]) + "..." + string(sm.Content[e-15:e]) + } + + fmt.Fprintf(sb, "\n\t%v %q", rng.Range, strings.ReplaceAll(snippet, "\n", "\\n")) + + if rng.Parent == nil { + break + } + rng = *rng.Parent + } + sb.WriteRune('\n') + } + got := sb.String() + + testName := "selectionrange_" + tests.SpanName(spn) + want := r.data.Golden(t, testName, uri.Filename(), func() ([]byte, error) { + return []byte(got), nil + }) + if want == nil { + t.Fatalf("golden file %q not found", uri.Filename()) + } + if diff := compare.Text(got, string(want)); diff != "" { + t.Errorf("%s mismatch\n%s", testName, diff) + } +} + func TestBytesOffset(t *testing.T) { tests := []struct { text string diff --git a/gopls/internal/lsp/selection_range.go b/gopls/internal/lsp/selection_range.go new file mode 100644 index 00000000000..314f2240799 --- /dev/null +++ b/gopls/internal/lsp/selection_range.go @@ -0,0 +1,69 @@ +// Copyright 2022 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 lsp + +import ( + "context" + + "golang.org/x/tools/go/ast/astutil" + "golang.org/x/tools/gopls/internal/lsp/protocol" + "golang.org/x/tools/gopls/internal/lsp/source" + "golang.org/x/tools/internal/event" +) + +// selectionRange defines the textDocument/selectionRange feature, +// which, given a list of positions within a file, +// reports a linked list of enclosing syntactic blocks, innermost first. +// +// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_selectionRange. +// +// This feature can be used by a client to implement "expand selection" in a +// language-aware fashion. Multiple input positions are supported to allow +// for multiple cursors, and the entire path up to the whole document is +// returned for each cursor to avoid multiple round-trips when the user is +// likely to issue this command multiple times in quick succession. +func (s *Server) selectionRange(ctx context.Context, params *protocol.SelectionRangeParams) ([]protocol.SelectionRange, error) { + ctx, done := event.Start(ctx, "lsp.Server.documentSymbol") + defer done() + + snapshot, fh, ok, release, err := s.beginFileRequest(ctx, params.TextDocument.URI, source.UnknownKind) + defer release() + if !ok { + return nil, err + } + + pgf, err := snapshot.ParseGo(ctx, fh, source.ParseFull) + if err != nil { + return nil, err + } + + result := make([]protocol.SelectionRange, len(params.Positions)) + for i, protocolPos := range params.Positions { + pos, err := pgf.Mapper.Pos(protocolPos) + if err != nil { + return nil, err + } + + path, _ := astutil.PathEnclosingInterval(pgf.File, pos, pos) + + tail := &result[i] // tail of the Parent linked list, built head first + + for j, node := range path { + rng, err := pgf.Mapper.PosRange(node.Pos(), node.End()) + if err != nil { + return nil, err + } + + // Add node to tail. + if j > 0 { + tail.Parent = &protocol.SelectionRange{} + tail = tail.Parent + } + tail.Range = rng + } + } + + return result, nil +} diff --git a/gopls/internal/lsp/server_gen.go b/gopls/internal/lsp/server_gen.go index 8f4ab10a71f..c6f618d6ef4 100644 --- a/gopls/internal/lsp/server_gen.go +++ b/gopls/internal/lsp/server_gen.go @@ -244,8 +244,8 @@ func (s *Server) ResolveWorkspaceSymbol(context.Context, *protocol.WorkspaceSymb return nil, notImplemented("ResolveWorkspaceSymbol") } -func (s *Server) SelectionRange(context.Context, *protocol.SelectionRangeParams) ([]protocol.SelectionRange, error) { - return nil, notImplemented("SelectionRange") +func (s *Server) SelectionRange(ctx context.Context, params *protocol.SelectionRangeParams) ([]protocol.SelectionRange, error) { + return s.selectionRange(ctx, params) } func (s *Server) SemanticTokensFull(ctx context.Context, p *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) { diff --git a/gopls/internal/lsp/source/source_test.go b/gopls/internal/lsp/source/source_test.go index 98bac6df894..ae5906fed01 100644 --- a/gopls/internal/lsp/source/source_test.go +++ b/gopls/internal/lsp/source/source_test.go @@ -497,6 +497,10 @@ func (r *runner) SemanticTokens(t *testing.T, spn span.Span) { t.Skip("nothing to test in source") } +func (r *runner) SelectionRanges(t *testing.T, spn span.Span) { + t.Skip("nothing to test in source") +} + func (r *runner) Import(t *testing.T, spn span.Span) { fh, err := r.snapshot.GetFile(r.ctx, spn.URI()) if err != nil { diff --git a/gopls/internal/lsp/testdata/selectionrange/foo.go b/gopls/internal/lsp/testdata/selectionrange/foo.go new file mode 100644 index 00000000000..1bf41340ce6 --- /dev/null +++ b/gopls/internal/lsp/testdata/selectionrange/foo.go @@ -0,0 +1,13 @@ +package foo + +import "time" + +func Bar(x, y int, t time.Time) int { + zs := []int{1, 2, 3} //@selectionrange("1") + + for _, z := range zs { + x = x + z + y + zs[1] //@selectionrange("1") + } + + return x + y //@selectionrange("+") +} diff --git a/gopls/internal/lsp/testdata/selectionrange/foo.go.golden b/gopls/internal/lsp/testdata/selectionrange/foo.go.golden new file mode 100644 index 00000000000..fe70b30b711 --- /dev/null +++ b/gopls/internal/lsp/testdata/selectionrange/foo.go.golden @@ -0,0 +1,29 @@ +-- selectionrange_foo_12_11 -- +Ranges 0: + 11:8-11:13 "x + y" + 11:1-11:13 "return x + y" + 4:36-12:1 "{\\n\tzs := []int{...ionrange(\"+\")\\n}" + 4:0-12:1 "func Bar(x, y i...ionrange(\"+\")\\n}" + 0:0-12:1 "package foo\\n\\nim...ionrange(\"+\")\\n}" + +-- selectionrange_foo_6_14 -- +Ranges 0: + 5:13-5:14 "1" + 5:7-5:21 "[]int{1, 2, 3}" + 5:1-5:21 "zs := []int{1, 2, 3}" + 4:36-12:1 "{\\n\tzs := []int{...ionrange(\"+\")\\n}" + 4:0-12:1 "func Bar(x, y i...ionrange(\"+\")\\n}" + 0:0-12:1 "package foo\\n\\nim...ionrange(\"+\")\\n}" + +-- selectionrange_foo_9_22 -- +Ranges 0: + 8:21-8:22 "1" + 8:18-8:23 "zs[1]" + 8:6-8:23 "x + z + y + zs[1]" + 8:2-8:23 "x = x + z + y + zs[1]" + 7:22-9:2 "{\\n\t\tx = x + z +...onrange(\"1\")\\n\t}" + 7:1-9:2 "for _, z := ran...onrange(\"1\")\\n\t}" + 4:36-12:1 "{\\n\tzs := []int{...ionrange(\"+\")\\n}" + 4:0-12:1 "func Bar(x, y i...ionrange(\"+\")\\n}" + 0:0-12:1 "package foo\\n\\nim...ionrange(\"+\")\\n}" + diff --git a/gopls/internal/lsp/testdata/summary.txt.golden b/gopls/internal/lsp/testdata/summary.txt.golden index cfe8e4a267d..3eac194f56d 100644 --- a/gopls/internal/lsp/testdata/summary.txt.golden +++ b/gopls/internal/lsp/testdata/summary.txt.golden @@ -28,4 +28,5 @@ WorkspaceSymbolsCount = 20 SignaturesCount = 33 LinksCount = 7 ImplementationsCount = 14 +SelectionRangesCount = 3 diff --git a/gopls/internal/lsp/testdata/summary_go1.18.txt.golden b/gopls/internal/lsp/testdata/summary_go1.18.txt.golden index 2b7bf976b2f..98e4bd2d653 100644 --- a/gopls/internal/lsp/testdata/summary_go1.18.txt.golden +++ b/gopls/internal/lsp/testdata/summary_go1.18.txt.golden @@ -28,4 +28,5 @@ WorkspaceSymbolsCount = 20 SignaturesCount = 33 LinksCount = 7 ImplementationsCount = 14 +SelectionRangesCount = 3 diff --git a/gopls/internal/lsp/tests/tests.go b/gopls/internal/lsp/tests/tests.go index cab96e0e82c..3b677653e50 100644 --- a/gopls/internal/lsp/tests/tests.go +++ b/gopls/internal/lsp/tests/tests.go @@ -93,6 +93,7 @@ type Signatures = map[span.Span]*protocol.SignatureHelp type Links = map[span.URI][]Link type AddImport = map[span.URI]string type Hovers = map[span.Span]string +type SelectionRanges = []span.Span type Data struct { Config packages.Config @@ -128,6 +129,7 @@ type Data struct { Links Links AddImport AddImport Hovers Hovers + SelectionRanges SelectionRanges fragments map[string]string dir string @@ -177,6 +179,7 @@ type Tests interface { Link(*testing.T, span.URI, []Link) AddImport(*testing.T, span.URI, string) Hover(*testing.T, span.Span, string) + SelectionRanges(*testing.T, span.Span) } type Definition struct { @@ -499,6 +502,7 @@ func load(t testing.TB, mode string, dir string) *Data { "incomingcalls": datum.collectIncomingCalls, "outgoingcalls": datum.collectOutgoingCalls, "addimport": datum.collectAddImports, + "selectionrange": datum.collectSelectionRanges, }); err != nil { t.Fatal(err) } @@ -947,6 +951,15 @@ func Run(t *testing.T, tests Tests, data *Data) { } }) + t.Run("SelectionRanges", func(t *testing.T) { + t.Helper() + for _, span := range data.SelectionRanges { + t.Run(SpanName(span), func(t *testing.T) { + tests.SelectionRanges(t, span) + }) + } + }) + if *UpdateGolden { for _, golden := range data.golden { if !golden.Modified { @@ -1039,6 +1052,7 @@ func checkData(t *testing.T, data *Data) { fmt.Fprintf(buf, "SignaturesCount = %v\n", len(data.Signatures)) fmt.Fprintf(buf, "LinksCount = %v\n", linksCount) fmt.Fprintf(buf, "ImplementationsCount = %v\n", len(data.Implementations)) + fmt.Fprintf(buf, "SelectionRangesCount = %v\n", len(data.SelectionRanges)) want := string(data.Golden(t, "summary", summaryFile, func() ([]byte, error) { return buf.Bytes(), nil @@ -1240,6 +1254,10 @@ func (data *Data) collectDefinitions(src, target span.Span) { } } +func (data *Data) collectSelectionRanges(spn span.Span) { + data.SelectionRanges = append(data.SelectionRanges, spn) +} + func (data *Data) collectImplementations(src span.Span, targets []span.Span) { data.Implementations[src] = targets }