1// Copyright 2020 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5package completion
6
7import (
8	"context"
9	"fmt"
10	"go/ast"
11	"go/parser"
12	"go/scanner"
13	"go/token"
14	"go/types"
15	"path/filepath"
16	"strings"
17
18	"golang.org/x/tools/internal/lsp/fuzzy"
19	"golang.org/x/tools/internal/lsp/protocol"
20	"golang.org/x/tools/internal/lsp/source"
21	"golang.org/x/tools/internal/span"
22	errors "golang.org/x/xerrors"
23)
24
25// packageClauseCompletions offers completions for a package declaration when
26// one is not present in the given file.
27func packageClauseCompletions(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, pos protocol.Position) ([]CompletionItem, *Selection, error) {
28	// We know that the AST for this file will be empty due to the missing
29	// package declaration, but parse it anyway to get a mapper.
30	pgf, err := snapshot.ParseGo(ctx, fh, source.ParseFull)
31	if err != nil {
32		return nil, nil, err
33	}
34
35	cursorSpan, err := pgf.Mapper.PointSpan(pos)
36	if err != nil {
37		return nil, nil, err
38	}
39	rng, err := cursorSpan.Range(pgf.Mapper.Converter)
40	if err != nil {
41		return nil, nil, err
42	}
43
44	surrounding, err := packageCompletionSurrounding(snapshot.FileSet(), fh, pgf, rng.Start)
45	if err != nil {
46		return nil, nil, errors.Errorf("invalid position for package completion: %w", err)
47	}
48
49	packageSuggestions, err := packageSuggestions(ctx, snapshot, fh.URI(), "")
50	if err != nil {
51		return nil, nil, err
52	}
53
54	var items []CompletionItem
55	for _, pkg := range packageSuggestions {
56		insertText := fmt.Sprintf("package %s", pkg.name)
57		items = append(items, CompletionItem{
58			Label:      insertText,
59			Kind:       protocol.ModuleCompletion,
60			InsertText: insertText,
61			Score:      pkg.score,
62		})
63	}
64
65	return items, surrounding, nil
66}
67
68// packageCompletionSurrounding returns surrounding for package completion if a
69// package completions can be suggested at a given position. A valid location
70// for package completion is above any declarations or import statements.
71func packageCompletionSurrounding(fset *token.FileSet, fh source.FileHandle, pgf *source.ParsedGoFile, pos token.Pos) (*Selection, error) {
72	src, err := fh.Read()
73	if err != nil {
74		return nil, err
75	}
76	// If the file lacks a package declaration, the parser will return an empty
77	// AST. As a work-around, try to parse an expression from the file contents.
78	expr, _ := parser.ParseExprFrom(fset, fh.URI().Filename(), src, parser.Mode(0))
79	if expr == nil {
80		return nil, fmt.Errorf("unparseable file (%s)", fh.URI())
81	}
82	tok := fset.File(expr.Pos())
83	cursor := tok.Pos(pgf.Tok.Offset(pos))
84	m := &protocol.ColumnMapper{
85		URI:       pgf.URI,
86		Content:   src,
87		Converter: span.NewContentConverter(fh.URI().Filename(), src),
88	}
89
90	// If we were able to parse out an identifier as the first expression from
91	// the file, it may be the beginning of a package declaration ("pack ").
92	// We can offer package completions if the cursor is in the identifier.
93	if name, ok := expr.(*ast.Ident); ok {
94		if cursor >= name.Pos() && cursor <= name.End() {
95			if !strings.HasPrefix(PACKAGE, name.Name) {
96				return nil, fmt.Errorf("cursor in non-matching ident")
97			}
98			return &Selection{
99				content:     name.Name,
100				cursor:      cursor,
101				MappedRange: source.NewMappedRange(fset, m, name.Pos(), name.End()),
102			}, nil
103		}
104	}
105
106	// The file is invalid, but it contains an expression that we were able to
107	// parse. We will use this expression to construct the cursor's
108	// "surrounding".
109
110	// First, consider the possibility that we have a valid "package" keyword
111	// with an empty package name ("package "). "package" is parsed as an
112	// *ast.BadDecl since it is a keyword. This logic would allow "package" to
113	// appear on any line of the file as long as it's the first code expression
114	// in the file.
115	lines := strings.Split(string(src), "\n")
116	cursorLine := tok.Line(cursor)
117	if cursorLine <= 0 || cursorLine > len(lines) {
118		return nil, fmt.Errorf("invalid line number")
119	}
120	if fset.Position(expr.Pos()).Line == cursorLine {
121		words := strings.Fields(lines[cursorLine-1])
122		if len(words) > 0 && words[0] == PACKAGE {
123			content := PACKAGE
124			// Account for spaces if there are any.
125			if len(words) > 1 {
126				content += " "
127			}
128
129			start := expr.Pos()
130			end := token.Pos(int(expr.Pos()) + len(content) + 1)
131			// We have verified that we have a valid 'package' keyword as our
132			// first expression. Ensure that cursor is in this keyword or
133			// otherwise fallback to the general case.
134			if cursor >= start && cursor <= end {
135				return &Selection{
136					content:     content,
137					cursor:      cursor,
138					MappedRange: source.NewMappedRange(fset, m, start, end),
139				}, nil
140			}
141		}
142	}
143
144	// If the cursor is after the start of the expression, no package
145	// declaration will be valid.
146	if cursor > expr.Pos() {
147		return nil, fmt.Errorf("cursor after expression")
148	}
149
150	// If the cursor is in a comment, don't offer any completions.
151	if cursorInComment(fset, cursor, src) {
152		return nil, fmt.Errorf("cursor in comment")
153	}
154
155	// The surrounding range in this case is the cursor except for empty file,
156	// in which case it's end of file - 1
157	start, end := cursor, cursor
158	if tok.Size() == 0 {
159		start, end = tok.Pos(0)-1, tok.Pos(0)-1
160	}
161
162	return &Selection{
163		content:     "",
164		cursor:      cursor,
165		MappedRange: source.NewMappedRange(fset, m, start, end),
166	}, nil
167}
168
169func cursorInComment(fset *token.FileSet, cursor token.Pos, src []byte) bool {
170	var s scanner.Scanner
171	s.Init(fset.File(cursor), src, func(_ token.Position, _ string) {}, scanner.ScanComments)
172	for {
173		pos, tok, lit := s.Scan()
174		if pos <= cursor && cursor <= token.Pos(int(pos)+len(lit)) {
175			return tok == token.COMMENT
176		}
177		if tok == token.EOF {
178			break
179		}
180	}
181	return false
182}
183
184// packageNameCompletions returns name completions for a package clause using
185// the current name as prefix.
186func (c *completer) packageNameCompletions(ctx context.Context, fileURI span.URI, name *ast.Ident) error {
187	cursor := int(c.pos - name.NamePos)
188	if cursor < 0 || cursor > len(name.Name) {
189		return errors.New("cursor is not in package name identifier")
190	}
191
192	c.completionContext.packageCompletion = true
193
194	prefix := name.Name[:cursor]
195	packageSuggestions, err := packageSuggestions(ctx, c.snapshot, fileURI, prefix)
196	if err != nil {
197		return err
198	}
199
200	for _, pkg := range packageSuggestions {
201		c.deepState.enqueue(pkg)
202	}
203	return nil
204}
205
206// packageSuggestions returns a list of packages from workspace packages that
207// have the given prefix and are used in the same directory as the given
208// file. This also includes test packages for these packages (<pkg>_test) and
209// the directory name itself.
210func packageSuggestions(ctx context.Context, snapshot source.Snapshot, fileURI span.URI, prefix string) ([]candidate, error) {
211	workspacePackages, err := snapshot.WorkspacePackages(ctx)
212	if err != nil {
213		return nil, err
214	}
215
216	dirPath := filepath.Dir(string(fileURI))
217	dirName := filepath.Base(dirPath)
218
219	seenPkgs := make(map[string]struct{})
220
221	toCandidate := func(name string, score float64) candidate {
222		obj := types.NewPkgName(0, nil, name, types.NewPackage("", name))
223		return candidate{obj: obj, name: name, detail: name, score: score}
224	}
225
226	matcher := fuzzy.NewMatcher(prefix)
227
228	// The `go` command by default only allows one package per directory but we
229	// support multiple package suggestions since gopls is build system agnostic.
230	var packages []candidate
231	for _, pkg := range workspacePackages {
232		if pkg.Name() == "main" || pkg.Name() == "" {
233			continue
234		}
235		if _, ok := seenPkgs[pkg.Name()]; ok {
236			continue
237		}
238
239		// Only add packages that are previously used in the current directory.
240		var relevantPkg bool
241		for _, pgf := range pkg.CompiledGoFiles() {
242			if filepath.Dir(string(pgf.URI)) == dirPath {
243				relevantPkg = true
244				break
245			}
246		}
247		if !relevantPkg {
248			continue
249		}
250
251		// Add a found package used in current directory as a high relevance
252		// suggestion and the test package for it as a medium relevance
253		// suggestion.
254		if score := float64(matcher.Score(pkg.Name())); score > 0 {
255			packages = append(packages, toCandidate(pkg.Name(), score*highScore))
256		}
257		seenPkgs[pkg.Name()] = struct{}{}
258
259		testPkgName := pkg.Name() + "_test"
260		if _, ok := seenPkgs[testPkgName]; ok || strings.HasSuffix(pkg.Name(), "_test") {
261			continue
262		}
263		if score := float64(matcher.Score(testPkgName)); score > 0 {
264			packages = append(packages, toCandidate(testPkgName, score*stdScore))
265		}
266		seenPkgs[testPkgName] = struct{}{}
267	}
268
269	// Add current directory name as a low relevance suggestion.
270	if _, ok := seenPkgs[dirName]; !ok {
271		if score := float64(matcher.Score(dirName)); score > 0 {
272			packages = append(packages, toCandidate(dirName, score*lowScore))
273		}
274
275		testDirName := dirName + "_test"
276		if score := float64(matcher.Score(testDirName)); score > 0 {
277			packages = append(packages, toCandidate(testDirName, score*lowScore))
278		}
279	}
280
281	if score := float64(matcher.Score("main")); score > 0 {
282		packages = append(packages, toCandidate("main", score*lowScore))
283	}
284
285	return packages, nil
286}
287