| // 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" | |
| "io/fs" | |
| "os" | |
| "path/filepath" | |
| "sort" | |
| "strings" | |
| "golang.org/x/tools/gopls/internal/cache" | |
| "golang.org/x/tools/gopls/internal/file" | |
| "golang.org/x/tools/gopls/internal/protocol" | |
| "golang.org/x/tools/internal/event" | |
| ) | |
| func Completion(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, 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, 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(pw.URI.DirPath(), 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.WalkDir(pathPrefixDir, func(wpath string, entry fs.DirEntry, err error) error { | |
| if err != nil { | |
| // golang/go#64225: an error reading a dir is expected, as the user may | |
| // be typing out a use directive for a directory that doesn't exist. | |
| return nil | |
| } | |
| 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 entry.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) | |
| items := []protocol.CompletionItem{} // must be a slice | |
| 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 | |
| } |