| // 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(ctx, 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(ctx context.Context, 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") |
| } |
| |
| prefix := name.Name[:cursor] |
| packageSuggestions, err := packageSuggestions(ctx, c.snapshot, fileURI, prefix) |
| if err != nil { |
| return err |
| } |
| |
| for _, pkg := range packageSuggestions { |
| if item, err := c.item(ctx, pkg); err == nil { |
| c.items = append(c.items, item) |
| } |
| } |
| return nil |
| } |
| |
| // packageSuggestions returns a list of packages from workspace packages that |
| // have the given prefix and are used in the 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, 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" { |
| 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 |
| } |