mirror of https://github.com/golang/go.git
287 lines
8.9 KiB
Go
287 lines
8.9 KiB
Go
// 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 completion
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/scanner"
|
|
"go/token"
|
|
"go/types"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/tools/internal/lsp/fuzzy"
|
|
"golang.org/x/tools/internal/lsp/protocol"
|
|
"golang.org/x/tools/internal/lsp/source"
|
|
"golang.org/x/tools/internal/span"
|
|
errors "golang.org/x/xerrors"
|
|
)
|
|
|
|
// packageClauseCompletions offers completions for a package declaration when
|
|
// one is not present in the given file.
|
|
func packageClauseCompletions(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, pos protocol.Position) ([]CompletionItem, *Selection, error) {
|
|
// We know that the AST for this file will be empty due to the missing
|
|
// package declaration, but parse it anyway to get a mapper.
|
|
pgf, err := snapshot.ParseGo(ctx, fh, source.ParseFull)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
cursorSpan, err := pgf.Mapper.PointSpan(pos)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
rng, err := cursorSpan.Range(pgf.Mapper.Converter)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
surrounding, err := packageCompletionSurrounding(snapshot.FileSet(), fh, pgf, rng.Start)
|
|
if err != nil {
|
|
return nil, nil, errors.Errorf("invalid position for package completion: %w", err)
|
|
}
|
|
|
|
packageSuggestions, err := packageSuggestions(ctx, snapshot, fh.URI(), "")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var items []CompletionItem
|
|
for _, pkg := range packageSuggestions {
|
|
insertText := fmt.Sprintf("package %s", pkg.name)
|
|
items = append(items, CompletionItem{
|
|
Label: insertText,
|
|
Kind: protocol.ModuleCompletion,
|
|
InsertText: insertText,
|
|
Score: pkg.score,
|
|
})
|
|
}
|
|
|
|
return items, surrounding, nil
|
|
}
|
|
|
|
// packageCompletionSurrounding returns surrounding for package completion if a
|
|
// package completions can be suggested at a given position. A valid location
|
|
// for package completion is above any declarations or import statements.
|
|
func packageCompletionSurrounding(fset *token.FileSet, fh source.FileHandle, pgf *source.ParsedGoFile, pos token.Pos) (*Selection, error) {
|
|
src, err := fh.Read()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// If the file lacks a package declaration, the parser will return an empty
|
|
// AST. As a work-around, try to parse an expression from the file contents.
|
|
expr, _ := parser.ParseExprFrom(fset, fh.URI().Filename(), src, parser.Mode(0))
|
|
if expr == nil {
|
|
return nil, fmt.Errorf("unparseable file (%s)", fh.URI())
|
|
}
|
|
tok := fset.File(expr.Pos())
|
|
cursor := tok.Pos(pgf.Tok.Offset(pos))
|
|
m := &protocol.ColumnMapper{
|
|
URI: pgf.URI,
|
|
Content: src,
|
|
Converter: span.NewContentConverter(fh.URI().Filename(), src),
|
|
}
|
|
|
|
// If we were able to parse out an identifier as the first expression from
|
|
// the file, it may be the beginning of a package declaration ("pack ").
|
|
// We can offer package completions if the cursor is in the identifier.
|
|
if name, ok := expr.(*ast.Ident); ok {
|
|
if cursor >= name.Pos() && cursor <= name.End() {
|
|
if !strings.HasPrefix(PACKAGE, name.Name) {
|
|
return nil, fmt.Errorf("cursor in non-matching ident")
|
|
}
|
|
return &Selection{
|
|
content: name.Name,
|
|
cursor: cursor,
|
|
MappedRange: source.NewMappedRange(fset, m, name.Pos(), name.End()),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// The file is invalid, but it contains an expression that we were able to
|
|
// parse. We will use this expression to construct the cursor's
|
|
// "surrounding".
|
|
|
|
// First, consider the possibility that we have a valid "package" keyword
|
|
// with an empty package name ("package "). "package" is parsed as an
|
|
// *ast.BadDecl since it is a keyword. This logic would allow "package" to
|
|
// appear on any line of the file as long as it's the first code expression
|
|
// in the file.
|
|
lines := strings.Split(string(src), "\n")
|
|
cursorLine := tok.Line(cursor)
|
|
if cursorLine <= 0 || cursorLine > len(lines) {
|
|
return nil, fmt.Errorf("invalid line number")
|
|
}
|
|
if fset.Position(expr.Pos()).Line == cursorLine {
|
|
words := strings.Fields(lines[cursorLine-1])
|
|
if len(words) > 0 && words[0] == PACKAGE {
|
|
content := PACKAGE
|
|
// Account for spaces if there are any.
|
|
if len(words) > 1 {
|
|
content += " "
|
|
}
|
|
|
|
start := expr.Pos()
|
|
end := token.Pos(int(expr.Pos()) + len(content) + 1)
|
|
// We have verified that we have a valid 'package' keyword as our
|
|
// first expression. Ensure that cursor is in this keyword or
|
|
// otherwise fallback to the general case.
|
|
if cursor >= start && cursor <= end {
|
|
return &Selection{
|
|
content: content,
|
|
cursor: cursor,
|
|
MappedRange: source.NewMappedRange(fset, m, start, end),
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the cursor is after the start of the expression, no package
|
|
// declaration will be valid.
|
|
if cursor > expr.Pos() {
|
|
return nil, fmt.Errorf("cursor after expression")
|
|
}
|
|
|
|
// If the cursor is in a comment, don't offer any completions.
|
|
if cursorInComment(fset, cursor, src) {
|
|
return nil, fmt.Errorf("cursor in comment")
|
|
}
|
|
|
|
// The surrounding range in this case is the cursor except for empty file,
|
|
// in which case it's end of file - 1
|
|
start, end := cursor, cursor
|
|
if tok.Size() == 0 {
|
|
start, end = tok.Pos(0)-1, tok.Pos(0)-1
|
|
}
|
|
|
|
return &Selection{
|
|
content: "",
|
|
cursor: cursor,
|
|
MappedRange: source.NewMappedRange(fset, m, start, end),
|
|
}, nil
|
|
}
|
|
|
|
func cursorInComment(fset *token.FileSet, cursor token.Pos, src []byte) bool {
|
|
var s scanner.Scanner
|
|
s.Init(fset.File(cursor), src, func(_ token.Position, _ string) {}, scanner.ScanComments)
|
|
for {
|
|
pos, tok, lit := s.Scan()
|
|
if pos <= cursor && cursor <= token.Pos(int(pos)+len(lit)) {
|
|
return tok == token.COMMENT
|
|
}
|
|
if tok == token.EOF {
|
|
break
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// packageNameCompletions returns name completions for a package clause using
|
|
// the current name as prefix.
|
|
func (c *completer) packageNameCompletions(ctx context.Context, fileURI span.URI, name *ast.Ident) error {
|
|
cursor := int(c.pos - name.NamePos)
|
|
if cursor < 0 || cursor > len(name.Name) {
|
|
return errors.New("cursor is not in package name identifier")
|
|
}
|
|
|
|
c.completionContext.packageCompletion = true
|
|
|
|
prefix := name.Name[:cursor]
|
|
packageSuggestions, err := packageSuggestions(ctx, c.snapshot, fileURI, prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, pkg := range packageSuggestions {
|
|
c.deepState.enqueue(pkg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// packageSuggestions returns a list of packages from workspace packages that
|
|
// have the given prefix and are used in the same directory as the given
|
|
// file. This also includes test packages for these packages (<pkg>_test) and
|
|
// the directory name itself.
|
|
func packageSuggestions(ctx context.Context, snapshot source.Snapshot, fileURI span.URI, prefix string) ([]candidate, error) {
|
|
workspacePackages, err := snapshot.WorkspacePackages(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dirPath := filepath.Dir(string(fileURI))
|
|
dirName := filepath.Base(dirPath)
|
|
|
|
seenPkgs := make(map[string]struct{})
|
|
|
|
toCandidate := func(name string, score float64) candidate {
|
|
obj := types.NewPkgName(0, nil, name, types.NewPackage("", name))
|
|
return candidate{obj: obj, name: name, detail: name, score: score}
|
|
}
|
|
|
|
matcher := fuzzy.NewMatcher(prefix)
|
|
|
|
// The `go` command by default only allows one package per directory but we
|
|
// support multiple package suggestions since gopls is build system agnostic.
|
|
var packages []candidate
|
|
for _, pkg := range workspacePackages {
|
|
if pkg.Name() == "main" || pkg.Name() == "" {
|
|
continue
|
|
}
|
|
if _, ok := seenPkgs[pkg.Name()]; ok {
|
|
continue
|
|
}
|
|
|
|
// Only add packages that are previously used in the current directory.
|
|
var relevantPkg bool
|
|
for _, pgf := range pkg.CompiledGoFiles() {
|
|
if filepath.Dir(string(pgf.URI)) == dirPath {
|
|
relevantPkg = true
|
|
break
|
|
}
|
|
}
|
|
if !relevantPkg {
|
|
continue
|
|
}
|
|
|
|
// Add a found package used in current directory as a high relevance
|
|
// suggestion and the test package for it as a medium relevance
|
|
// suggestion.
|
|
if score := float64(matcher.Score(pkg.Name())); score > 0 {
|
|
packages = append(packages, toCandidate(pkg.Name(), score*highScore))
|
|
}
|
|
seenPkgs[pkg.Name()] = struct{}{}
|
|
|
|
testPkgName := pkg.Name() + "_test"
|
|
if _, ok := seenPkgs[testPkgName]; ok || strings.HasSuffix(pkg.Name(), "_test") {
|
|
continue
|
|
}
|
|
if score := float64(matcher.Score(testPkgName)); score > 0 {
|
|
packages = append(packages, toCandidate(testPkgName, score*stdScore))
|
|
}
|
|
seenPkgs[testPkgName] = struct{}{}
|
|
}
|
|
|
|
// Add current directory name as a low relevance suggestion.
|
|
if _, ok := seenPkgs[dirName]; !ok {
|
|
if score := float64(matcher.Score(dirName)); score > 0 {
|
|
packages = append(packages, toCandidate(dirName, score*lowScore))
|
|
}
|
|
|
|
testDirName := dirName + "_test"
|
|
if score := float64(matcher.Score(testDirName)); score > 0 {
|
|
packages = append(packages, toCandidate(testDirName, score*lowScore))
|
|
}
|
|
}
|
|
|
|
if score := float64(matcher.Score("main")); score > 0 {
|
|
packages = append(packages, toCandidate("main", score*lowScore))
|
|
}
|
|
|
|
return packages, nil
|
|
}
|