diff --git a/gopls/internal/regtest/completion/completion_test.go b/gopls/internal/regtest/completion/completion_test.go index 6abcd60b29d..c0b4736e761 100644 --- a/gopls/internal/regtest/completion/completion_test.go +++ b/gopls/internal/regtest/completion/completion_test.go @@ -599,3 +599,48 @@ func BenchmarkFoo() } }) } + +func TestGoWorkCompletion(t *testing.T) { + const files = ` +-- go.work -- +go 1.18 + +use ./a +use ./a/ba +use ./a/b/ +use ./dir/foo +use ./dir/foobar/ +-- a/go.mod -- +-- go.mod -- +-- a/bar/go.mod -- +-- a/b/c/d/e/f/go.mod -- +-- dir/bar -- +-- dir/foobar/go.mod -- +` + + Run(t, files, func(t *testing.T, env *Env) { + env.OpenFile("go.work") + + tests := []struct { + re string + want []string + }{ + {`use ()\.`, []string{".", "./a", "./a/bar", "./dir/foobar"}}, + {`use \.()`, []string{"", "/a", "/a/bar", "/dir/foobar"}}, + {`use \./()`, []string{"a", "a/bar", "dir/foobar"}}, + {`use ./a()`, []string{"", "/b/c/d/e/f", "/bar"}}, + {`use ./a/b()`, []string{"/c/d/e/f", "ar"}}, + {`use ./a/b/()`, []string{`c/d/e/f`}}, + {`use ./a/ba()`, []string{"r"}}, + {`use ./dir/foo()`, []string{"bar"}}, + {`use ./dir/foobar/()`, []string{}}, + } + for _, tt := range tests { + completions := env.Completion("go.work", env.RegexpSearch("go.work", tt.re)) + diff := compareCompletionResults(tt.want, completions.Items) + if diff != "" { + t.Errorf("%s: %s", tt.re, diff) + } + } + }) +} diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 9dded403602..5c88ed0e441 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -16,6 +16,7 @@ import ( "golang.org/x/tools/internal/lsp/source" "golang.org/x/tools/internal/lsp/source/completion" "golang.org/x/tools/internal/lsp/template" + "golang.org/x/tools/internal/lsp/work" "golang.org/x/tools/internal/span" ) @@ -32,6 +33,12 @@ func (s *Server) completion(ctx context.Context, params *protocol.CompletionPara candidates, surrounding, err = completion.Completion(ctx, snapshot, fh, params.Position, params.Context) case source.Mod: candidates, surrounding = nil, nil + case source.Work: + cl, err := work.Completion(ctx, snapshot, fh, params.Position) + if err != nil { + break + } + return cl, nil case source.Tmpl: var cl *protocol.CompletionList cl, err = template.Completion(ctx, snapshot, fh, params.Position, params.Context) diff --git a/internal/lsp/work/completion.go b/internal/lsp/work/completion.go new file mode 100644 index 00000000000..60b69f12f5a --- /dev/null +++ b/internal/lsp/work/completion.go @@ -0,0 +1,159 @@ +// 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 work + +import ( + "context" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/internal/event" + "golang.org/x/tools/internal/lsp/protocol" + "golang.org/x/tools/internal/lsp/source" + errors "golang.org/x/xerrors" +) + +func Completion(ctx context.Context, snapshot source.Snapshot, fh source.VersionedFileHandle, position protocol.Position) (*protocol.CompletionList, error) { + ctx, done := event.Start(ctx, "work.Completion") + defer done() + + // Get the position of the cursor. + pw, err := snapshot.ParseWork(ctx, fh) + if err != nil { + return nil, errors.Errorf("getting go.work file handle: %w", err) + } + spn, err := pw.Mapper.PointSpan(position) + if err != nil { + return nil, errors.Errorf("computing cursor position: %w", err) + } + rng, err := spn.Range(pw.Mapper.Converter) + if err != nil { + return nil, errors.Errorf("computing range: %w", err) + } + + // Find the use statement the user is in. + cursor := rng.Start - 1 + use, pathStart, _ := usePath(pw, cursor) + if use == nil { + return &protocol.CompletionList{}, nil + } + completingFrom := use.Path[:cursor-token.Pos(pathStart)] + + // We're going to find the completions of the user input + // (completingFrom) by doing a walk on the innermost directory + // of the given path, and comparing the found paths to make sure + // that they match the component of the path after the + // innermost directory. + // + // We'll maintain two paths when doing this: pathPrefixSlash + // is essentially the path the user typed in, and pathPrefixAbs + // is the path made absolute from the go.work directory. + + pathPrefixSlash := completingFrom + pathPrefixAbs := filepath.FromSlash(pathPrefixSlash) + if !filepath.IsAbs(pathPrefixAbs) { + pathPrefixAbs = filepath.Join(filepath.Dir(pw.URI.Filename()), pathPrefixAbs) + } + + // pathPrefixDir is the directory that will be walked to find matches. + // If pathPrefixSlash is not explicitly a directory boundary (is either equivalent to "." or + // ends in a separator) we need to examine its parent directory to find sibling files that + // match. + depthBound := 5 + pathPrefixDir, pathPrefixBase := pathPrefixAbs, "" + pathPrefixSlashDir := pathPrefixSlash + if filepath.Clean(pathPrefixSlash) != "." && !strings.HasSuffix(pathPrefixSlash, "/") { + depthBound++ + pathPrefixDir, pathPrefixBase = filepath.Split(pathPrefixAbs) + pathPrefixSlashDir = dirNonClean(pathPrefixSlash) + } + + var completions []string + // Stop traversing deeper once we've hit 10k files to try to stay generally under 100ms. + const numSeenBound = 10000 + var numSeen int + stopWalking := errors.New("hit numSeenBound") + err = filepath.Walk(pathPrefixDir, func(wpath string, info os.FileInfo, err error) error { + if numSeen > numSeenBound { + // Stop traversing if we hit bound. + return stopWalking + } + numSeen++ + + // rel is the path relative to pathPrefixDir. + // Make sure that it has pathPrefixBase as a prefix + // otherwise it won't match the beginning of the + // base component of the path the user typed in. + rel := strings.TrimPrefix(wpath[len(pathPrefixDir):], string(filepath.Separator)) + if info.IsDir() && wpath != pathPrefixDir && !strings.HasPrefix(rel, pathPrefixBase) { + return filepath.SkipDir + } + + // Check for a match (a module directory). + if filepath.Base(rel) == "go.mod" { + relDir := strings.TrimSuffix(dirNonClean(rel), string(os.PathSeparator)) + completionPath := join(pathPrefixSlashDir, filepath.ToSlash(relDir)) + + if !strings.HasPrefix(completionPath, completingFrom) { + return nil + } + if strings.HasSuffix(completionPath, "/") { + // Don't suggest paths that end in "/". This happens + // when the input is a path that ends in "/" and + // the completion is empty. + return nil + } + completion := completionPath[len(completingFrom):] + if completingFrom == "" && !strings.HasPrefix(completion, "./") { + // Bias towards "./" prefixes. + completion = join(".", completion) + } + + completions = append(completions, completion) + } + + if depth := strings.Count(rel, string(filepath.Separator)); depth >= depthBound { + return filepath.SkipDir + } + return nil + }) + if err != nil && !errors.Is(err, stopWalking) { + return nil, errors.Errorf("walking to find completions: %w", err) + } + + sort.Strings(completions) + + var items []protocol.CompletionItem + for _, c := range completions { + items = append(items, protocol.CompletionItem{ + Label: c, + InsertText: c, + }) + } + return &protocol.CompletionList{Items: items}, nil +} + +// dirNonClean is filepath.Dir, without the Clean at the end. +func dirNonClean(path string) string { + vol := filepath.VolumeName(path) + i := len(path) - 1 + for i >= len(vol) && !os.IsPathSeparator(path[i]) { + i-- + } + return path[len(vol) : i+1] +} + +func join(a, b string) string { + if a == "" { + return b + } + if b == "" { + return a + } + return strings.TrimSuffix(a, "/") + "/" + b +} diff --git a/internal/lsp/work/hover.go b/internal/lsp/work/hover.go index 7cf2b981a6b..1018fc153fc 100644 --- a/internal/lsp/work/hover.go +++ b/internal/lsp/work/hover.go @@ -41,24 +41,7 @@ func Hover(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, // Confirm that the cursor is inside a use statement, and then find // the position of the use statement's directory path. - var use *modfile.Use - var pathStart, pathEnd int - for _, u := range pw.File.Use { - dep := []byte(u.Path) - s, e := u.Syntax.Start.Byte, u.Syntax.End.Byte - i := bytes.Index(pw.Mapper.Content[s:e], dep) - if i == -1 { - // This should not happen. - continue - } - // Shift the start position to the location of the - // module directory within the use statement. - pathStart, pathEnd = s+i, s+i+len(dep) - if token.Pos(pathStart) <= hoverRng.Start && hoverRng.Start <= token.Pos(pathEnd) { - use = u - break - } - } + use, pathStart, pathEnd := usePath(pw, hoverRng.Start) // The cursor position is not on a use statement. if use == nil { @@ -87,3 +70,22 @@ func Hover(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, Range: rng, }, nil } + +func usePath(pw *source.ParsedWorkFile, pos token.Pos) (use *modfile.Use, pathStart, pathEnd int) { + for _, u := range pw.File.Use { + path := []byte(u.Path) + s, e := u.Syntax.Start.Byte, u.Syntax.End.Byte + i := bytes.Index(pw.Mapper.Content[s:e], path) + if i == -1 { + // This should not happen. + continue + } + // Shift the start position to the location of the + // module directory within the use statement. + pathStart, pathEnd = s+i, s+i+len(path) + if token.Pos(pathStart) <= pos && pos <= token.Pos(pathEnd) { + return u, pathStart, pathEnd + } + } + return nil, 0, 0 +}