aboutsummaryrefslogtreecommitdiff
path: root/gopls/internal/lsp/work/completion.go
blob: bcdc2d1f42ea51ba3e8d602d99198ba3ab158e0f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// 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"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"golang.org/x/tools/gopls/internal/lsp/protocol"
	"golang.org/x/tools/gopls/internal/lsp/source"
	"golang.org/x/tools/internal/event"
)

func Completion(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, 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, fmt.Errorf("getting go.work file handle: %w", err)
	}
	cursor, err := pw.Mapper.PositionOffset(position)
	if err != nil {
		return nil, fmt.Errorf("computing cursor offset: %w", err)
	}

	// Find the use statement the user is in.
	use, pathStart, _ := usePath(pw, cursor)
	if use == nil {
		return &protocol.CompletionList{}, nil
	}
	completingFrom := use.Path[:cursor-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, fmt.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
}