Skip to content

Commit 2adf828

Browse files
muirdmstamblerre
authored andcommitted
internal/lsp: add fuzzy completion matching
Make use of the existing fuzzy matcher to perform server side fuzzy completion matching. Previously the server did exact prefix matching for completion candidates and left fancy filtering to the client. Having the server do fuzzy matching has two main benefits: - Deep completions now update as you type. The completion candidates returned to the client are marked "incomplete", causing the client to refresh the candidates after every keystroke. This lets the server pick the most relevant set of deep completion candidates. - All editors get fuzzy matching for free. VSCode has fuzzy matching out of the box, but some editors either don't provide it, or it can be difficult to set up. I modified the fuzzy matcher to allow matches where the input doesn't match the final segment of the candidate. For example, previously "ab" would not match "abc.def" because the "b" in "ab" did not match the final segment "def". I can see how this is useful when the text matching happens in a vacuum and candidate's final segment is the most specific part. But, in our case, we have various other methods to order candidates, so we don't want to exclude them just because the final segment doesn't match. For example, if we know our candidate needs to be type "context.Context" and "foo.ctx" is of the right type, we want to suggest "foo.ctx" as soon as the user starts inputting "foo", even though "foo" doesn't match "ctx" at all. Note that fuzzy matching is behind the "useDeepCompletions" config flag for the time being. Change-Id: Ic7674f0cf885af770c30daef472f2e3c5ac4db78 Reviewed-on: https://go-review.googlesource.com/c/tools/+/190099 Run-TryBot: Rebecca Stambler <[email protected]> TryBot-Result: Gobot Gobot <[email protected]> Reviewed-by: Rebecca Stambler <[email protected]>
1 parent 9dba7ca commit 2adf828

File tree

7 files changed

+84
-48
lines changed

7 files changed

+84
-48
lines changed

internal/lsp/completion.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"context"
99
"fmt"
1010
"sort"
11-
"strings"
1211

1312
"golang.org/x/tools/internal/lsp/protocol"
1413
"golang.org/x/tools/internal/lsp/source"
@@ -40,7 +39,9 @@ func (s *Server) completion(ctx context.Context, params *protocol.CompletionPara
4039
log.Print(ctx, "no completions found", tag.Of("At", rng), tag.Of("Failure", err))
4140
}
4241
return &protocol.CompletionList{
43-
IsIncomplete: false,
42+
// When using deep completions/fuzzy matching, report results as incomplete so
43+
// client fetches updated completions after every key stroke.
44+
IsIncomplete: s.useDeepCompletions,
4445
Items: s.toProtocolCompletionItems(ctx, view, m, candidates, params.Position, surrounding),
4546
}, nil
4647
}
@@ -59,9 +60,7 @@ func (s *Server) toProtocolCompletionItems(ctx context.Context, view source.View
5960
Start: pos,
6061
End: pos,
6162
}
62-
var prefix string
6363
if surrounding != nil {
64-
prefix = strings.ToLower(surrounding.Prefix())
6564
spn, err := surrounding.Range.Span()
6665
if err != nil {
6766
log.Print(ctx, "failed to get span for surrounding position: %s:%v:%v: %v", tag.Of("Position", pos), tag.Of("Failure", err))
@@ -75,14 +74,12 @@ func (s *Server) toProtocolCompletionItems(ctx context.Context, view source.View
7574
}
7675
}
7776

78-
var numDeepCompletionsSeen int
77+
var (
78+
items = make([]protocol.CompletionItem, 0, len(candidates))
79+
numDeepCompletionsSeen int
80+
)
7981

80-
items := make([]protocol.CompletionItem, 0, len(candidates))
8182
for i, candidate := range candidates {
82-
// Match against the label (case-insensitive).
83-
if !strings.HasPrefix(strings.ToLower(candidate.Label), prefix) {
84-
continue
85-
}
8683
// Limit the number of deep completions to not overwhelm the user in cases
8784
// with dozens of deep completion matches.
8885
if candidate.Depth > 0 {
@@ -98,6 +95,7 @@ func (s *Server) toProtocolCompletionItems(ctx context.Context, view source.View
9895
if s.insertTextFormat == protocol.SnippetTextFormat {
9996
insertText = candidate.Snippet(s.usePlaceholders)
10097
}
98+
10199
item := protocol.CompletionItem{
102100
Label: candidate.Label,
103101
Detail: candidate.Detail,

internal/lsp/fuzzy/matcher.go

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,7 @@ func (m *Matcher) match(candidate string, candidateLower []byte) bool {
203203
// The input passes the simple test against pattern, so it is time to classify its characters.
204204
// Character roles are used below to find the last segment.
205205
m.roles = RuneRoles(candidate, m.input, m.rolesBuf[:])
206-
if m.input != Text {
207-
sep := len(candidateLower) - 1
208-
for sep >= i && m.roles[sep] != RSep {
209-
sep--
210-
}
211-
if sep >= i {
212-
// We are not in the last segment, check that we have at least one character match in the last
213-
// segment of the candidate.
214-
return bytes.IndexByte(candidateLower[sep:], m.patternLower[len(m.pattern)-1]) != -1
215-
}
216-
}
206+
217207
return true
218208
}
219209

@@ -267,12 +257,6 @@ func (m *Matcher) computeScore(candidate string, candidateLower []byte) int {
267257
// By default, we don't have a match. Fill in the skip data.
268258
m.scores[i][j][1] = minScore << 1
269259

270-
if segmentsLeft > 1 && j == pattLen {
271-
// The very last pattern character can only be matched in the last segment.
272-
m.scores[i][j][0] = minScore << 1
273-
continue
274-
}
275-
276260
// Compute the skip score.
277261
k := 0
278262
if m.scores[i-1][j][0].val() < m.scores[i-1][j][1].val() {

internal/lsp/fuzzy/matcher_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ var matcherTests = []struct {
111111
input: fuzzy.Filename,
112112
tests: []scoreTest{
113113
{"sub/seq", ge, 0},
114-
{"sub/seq/end", eq, -1},
114+
{"sub/seq/end", ge, 0},
115115
{"sub/seq/base", ge, 0},
116116
},
117117
},
@@ -120,7 +120,7 @@ var matcherTests = []struct {
120120
input: fuzzy.Filename,
121121
tests: []scoreTest{
122122
{"//sub/seq", ge, 0},
123-
{"//sub/seq/end", eq, -1},
123+
{"//sub/seq/end", ge, 0},
124124
{"//sub/seq/base", ge, 0},
125125
},
126126
},
@@ -242,10 +242,10 @@ var fuzzyMatcherTestCases = []struct {
242242
{p: "edt", str: "foo.Textedit", want: "", input: fuzzy.Symbol}, // short middle of the word match
243243
{p: "edit", str: "foo.Textedit", want: "foo.Text[edit]", input: fuzzy.Symbol},
244244
{p: "edin", str: "foo.TexteditNum", want: "foo.Text[edi]t[N]um", input: fuzzy.Symbol},
245-
{p: "n", str: "node.GoNodeMax", want: "node.Go[N]odeMax", input: fuzzy.Symbol},
246-
{p: "N", str: "node.GoNodeMax", want: "node.Go[N]odeMax", input: fuzzy.Symbol},
245+
{p: "n", str: "node.GoNodeMax", want: "[n]ode.GoNodeMax", input: fuzzy.Symbol},
246+
{p: "N", str: "node.GoNodeMax", want: "[n]ode.GoNodeMax", input: fuzzy.Symbol},
247247
{p: "completio", str: "completion", want: "[completio]n", input: fuzzy.Symbol},
248-
{p: "completio", str: "completion.None", want: "[completi]on.N[o]ne", input: fuzzy.Symbol},
248+
{p: "completio", str: "completion.None", want: "[completio]n.None", input: fuzzy.Symbol},
249249
}
250250

251251
func TestFuzzyMatcherRanges(t *testing.T) {

internal/lsp/source/completion.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"go/ast"
1010
"go/token"
1111
"go/types"
12+
"strings"
1213

1314
"golang.org/x/tools/go/ast/astutil"
1415
"golang.org/x/tools/internal/lsp/fuzzy"
@@ -112,6 +113,25 @@ const (
112113
lowScore float64 = 0.01
113114
)
114115

116+
// matcher matches a candidate's label against the user input.
117+
// The returned score reflects the quality of the match. A score
118+
// less than or equal to zero indicates no match, and a score of
119+
// one means a perfect match.
120+
type matcher interface {
121+
Score(candidateLabel string) (score float32)
122+
}
123+
124+
// prefixMatcher implements the original case insensitive prefix matching.
125+
// This matcher should go away once fuzzy matching is released.
126+
type prefixMatcher string
127+
128+
func (pm prefixMatcher) Score(candidateLabel string) float32 {
129+
if strings.HasPrefix(strings.ToLower(candidateLabel), string(pm)) {
130+
return 1
131+
}
132+
return 0
133+
}
134+
115135
// completer contains the necessary information for a single completion request.
116136
type completer struct {
117137
// Package-specific fields.
@@ -155,8 +175,8 @@ type completer struct {
155175
// deepState contains the current state of our deep completion search.
156176
deepState deepCompletionState
157177

158-
// matcher does fuzzy matching of the candidates for the surrounding prefix.
159-
matcher *fuzzy.Matcher
178+
// matcher matches the candidates against the surrounding prefix.
179+
matcher matcher
160180
}
161181

162182
type compLitInfo struct {
@@ -203,8 +223,15 @@ func (c *completer) setSurrounding(ident *ast.Ident) {
203223
Range: span.NewRange(c.view.Session().Cache().FileSet(), ident.Pos(), ident.End()),
204224
Cursor: c.pos,
205225
}
206-
if c.surrounding.Prefix() != "" {
207-
c.matcher = fuzzy.NewMatcher(c.surrounding.Prefix(), fuzzy.Symbol)
226+
227+
// Fuzzy matching shares the "useDeepCompletions" config flag, so if deep completions
228+
// are enabled then also enable fuzzy matching.
229+
if c.deepState.enabled {
230+
if c.surrounding.Prefix() != "" {
231+
c.matcher = fuzzy.NewMatcher(c.surrounding.Prefix(), fuzzy.Symbol)
232+
}
233+
} else {
234+
c.matcher = prefixMatcher(strings.ToLower(c.surrounding.Prefix()))
208235
}
209236
}
210237

@@ -243,13 +270,23 @@ func (c *completer) found(obj types.Object, score float64) error {
243270

244271
// Favor shallow matches by lowering weight according to depth.
245272
cand.score -= stdScore * float64(len(c.deepState.chain))
273+
246274
item, err := c.item(cand)
247275
if err != nil {
248276
return err
249277
}
250-
c.items = append(c.items, item)
278+
if c.matcher == nil {
279+
c.items = append(c.items, item)
280+
} else {
281+
score := c.matcher.Score(item.Label)
282+
if score > 0 {
283+
item.Score *= float64(score)
284+
c.items = append(c.items, item)
285+
}
286+
}
251287

252288
c.deepSearch(obj)
289+
253290
return nil
254291
}
255292

internal/lsp/source/source_test.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"golang.org/x/tools/go/packages/packagestest"
1818
"golang.org/x/tools/internal/lsp/cache"
1919
"golang.org/x/tools/internal/lsp/diff"
20+
"golang.org/x/tools/internal/lsp/fuzzy"
2021
"golang.org/x/tools/internal/lsp/source"
2122
"golang.org/x/tools/internal/lsp/telemetry/log"
2223
"golang.org/x/tools/internal/lsp/tests"
@@ -158,27 +159,43 @@ func (r *runner) Completion(t *testing.T, data tests.Completions, snippets tests
158159
t.Fatalf("failed to get token for %s: %v", src.URI(), err)
159160
}
160161
pos := tok.Pos(src.Start().Offset())
162+
deepComplete := strings.Contains(string(src.URI()), "deepcomplete")
161163
list, surrounding, err := source.Completion(ctx, r.view, f.(source.GoFile), pos, source.CompletionOptions{
162-
DeepComplete: strings.Contains(string(src.URI()), "deepcomplete"),
164+
DeepComplete: deepComplete,
163165
WantDocumentaton: true,
164166
})
165167
if err != nil {
166168
t.Fatalf("failed for %v: %v", src, err)
167169
}
168-
var prefix string
170+
var (
171+
prefix string
172+
fuzzyMatcher *fuzzy.Matcher
173+
)
169174
if surrounding != nil {
170175
prefix = strings.ToLower(surrounding.Prefix())
176+
if deepComplete && prefix != "" {
177+
fuzzyMatcher = fuzzy.NewMatcher(surrounding.Prefix(), fuzzy.Symbol)
178+
}
171179
}
172180
wantBuiltins := strings.Contains(string(src.URI()), "builtins")
173181
var got []source.CompletionItem
174182
for _, item := range list {
175183
if !wantBuiltins && isBuiltin(item) {
176184
continue
177185
}
178-
// We let the client do fuzzy matching, so we return all possible candidates.
179-
// To simplify testing, filter results with prefixes that don't match exactly.
180-
if !strings.HasPrefix(strings.ToLower(item.Label), prefix) {
181-
continue
186+
187+
// If deep completion is enabled, we need to use the fuzzy matcher to match
188+
// the code's behvaior.
189+
if deepComplete {
190+
if fuzzyMatcher != nil && fuzzyMatcher.Score(item.Label) <= 0 {
191+
continue
192+
}
193+
} else {
194+
// We let the client do fuzzy matching, so we return all possible candidates.
195+
// To simplify testing, filter results with prefixes that don't match exactly.
196+
if !strings.HasPrefix(strings.ToLower(item.Label), prefix) {
197+
continue
198+
}
182199
}
183200
got = append(got, item)
184201
}

internal/lsp/testdata/deepcomplete/deep_complete.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ func _() {
3434
}
3535

3636
func _() {
37-
type deepCircle struct {
37+
type deepCircle struct { //@item(deepCircleStruct, "deepCircle", "struct{...}", "struct")
3838
*deepCircle
3939
}
40-
var circle deepCircle //@item(deepCircle, "circle", "deepCircle", "var")
41-
circle.deepCircle //@item(deepCircleField, "circle.deepCircle", "*deepCircle", "field")
42-
var _ deepCircle = ci //@complete(" //", deepCircle, deepCircleField)
40+
var circle deepCircle //@item(deepCircle, "circle", "deepCircle", "var")
41+
circle.deepCircle //@item(deepCircleField, "circle.deepCircle", "*deepCircle", "field")
42+
var _ deepCircle = circ //@complete(" //", deepCircle, deepCircleStruct, deepCircleField)
4343
}
4444

4545
func _() {

internal/lsp/text_synchronization.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func fullChange(changes []protocol.TextDocumentContentChangeEvent) (string, bool
106106
func (s *Server) applyChanges(ctx context.Context, uri span.URI, changes []protocol.TextDocumentContentChangeEvent) (string, error) {
107107
content, _, err := s.session.GetFile(uri).Read(ctx)
108108
if err != nil {
109-
return "", jsonrpc2.NewErrorf(jsonrpc2.CodeInternalError, "file not found")
109+
return "", jsonrpc2.NewErrorf(jsonrpc2.CodeInternalError, "file not found (%v)", err)
110110
}
111111
fset := s.session.Cache().FileSet()
112112
for _, change := range changes {

0 commit comments

Comments
 (0)