github.com/jhump/golang-x-tools@v0.0.0-20220218190644-4958d6d39439/internal/lsp/source/completion/package.go (about) 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 5 package completion 6 7 import ( 8 "bytes" 9 "context" 10 "fmt" 11 "go/ast" 12 "go/parser" 13 "go/scanner" 14 "go/token" 15 "go/types" 16 "path/filepath" 17 "strings" 18 "unicode" 19 20 "github.com/jhump/golang-x-tools/internal/lsp/debug" 21 "github.com/jhump/golang-x-tools/internal/lsp/fuzzy" 22 "github.com/jhump/golang-x-tools/internal/lsp/protocol" 23 "github.com/jhump/golang-x-tools/internal/lsp/source" 24 "github.com/jhump/golang-x-tools/internal/span" 25 errors "golang.org/x/xerrors" 26 ) 27 28 // packageClauseCompletions offers completions for a package declaration when 29 // one is not present in the given file. 30 func packageClauseCompletions(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, pos protocol.Position) ([]CompletionItem, *Selection, error) { 31 // We know that the AST for this file will be empty due to the missing 32 // package declaration, but parse it anyway to get a mapper. 33 pgf, err := snapshot.ParseGo(ctx, fh, source.ParseFull) 34 if err != nil { 35 return nil, nil, err 36 } 37 38 cursorSpan, err := pgf.Mapper.PointSpan(pos) 39 if err != nil { 40 return nil, nil, err 41 } 42 rng, err := cursorSpan.Range(pgf.Mapper.Converter) 43 if err != nil { 44 return nil, nil, err 45 } 46 47 surrounding, err := packageCompletionSurrounding(ctx, snapshot.FileSet(), pgf, rng.Start) 48 if err != nil { 49 return nil, nil, errors.Errorf("invalid position for package completion: %w", err) 50 } 51 52 packageSuggestions, err := packageSuggestions(ctx, snapshot, fh.URI(), "") 53 if err != nil { 54 return nil, nil, err 55 } 56 57 var items []CompletionItem 58 for _, pkg := range packageSuggestions { 59 insertText := fmt.Sprintf("package %s", pkg.name) 60 items = append(items, CompletionItem{ 61 Label: insertText, 62 Kind: protocol.ModuleCompletion, 63 InsertText: insertText, 64 Score: pkg.score, 65 }) 66 } 67 68 return items, surrounding, nil 69 } 70 71 // packageCompletionSurrounding returns surrounding for package completion if a 72 // package completions can be suggested at a given position. A valid location 73 // for package completion is above any declarations or import statements. 74 func packageCompletionSurrounding(ctx context.Context, fset *token.FileSet, pgf *source.ParsedGoFile, pos token.Pos) (*Selection, error) { 75 // If the file lacks a package declaration, the parser will return an empty 76 // AST. As a work-around, try to parse an expression from the file contents. 77 filename := pgf.URI.Filename() 78 expr, _ := parser.ParseExprFrom(fset, filename, pgf.Src, parser.Mode(0)) 79 if expr == nil { 80 return nil, fmt.Errorf("unparseable file (%s)", pgf.URI) 81 } 82 tok := fset.File(expr.Pos()) 83 offset, err := source.Offset(pgf.Tok, pos) 84 if err != nil { 85 return nil, err 86 } 87 if offset > tok.Size() { 88 debug.Bug(ctx, "out of bounds cursor", "cursor offset (%d) out of bounds for %s (size: %d)", offset, pgf.URI, tok.Size()) 89 return nil, fmt.Errorf("cursor out of bounds") 90 } 91 cursor := tok.Pos(offset) 92 m := &protocol.ColumnMapper{ 93 URI: pgf.URI, 94 Content: pgf.Src, 95 Converter: span.NewContentConverter(filename, pgf.Src), 96 } 97 98 // If we were able to parse out an identifier as the first expression from 99 // the file, it may be the beginning of a package declaration ("pack "). 100 // We can offer package completions if the cursor is in the identifier. 101 if name, ok := expr.(*ast.Ident); ok { 102 if cursor >= name.Pos() && cursor <= name.End() { 103 if !strings.HasPrefix(PACKAGE, name.Name) { 104 return nil, fmt.Errorf("cursor in non-matching ident") 105 } 106 return &Selection{ 107 content: name.Name, 108 cursor: cursor, 109 MappedRange: source.NewMappedRange(fset, m, name.Pos(), name.End()), 110 }, nil 111 } 112 } 113 114 // The file is invalid, but it contains an expression that we were able to 115 // parse. We will use this expression to construct the cursor's 116 // "surrounding". 117 118 // First, consider the possibility that we have a valid "package" keyword 119 // with an empty package name ("package "). "package" is parsed as an 120 // *ast.BadDecl since it is a keyword. This logic would allow "package" to 121 // appear on any line of the file as long as it's the first code expression 122 // in the file. 123 lines := strings.Split(string(pgf.Src), "\n") 124 cursorLine := tok.Line(cursor) 125 if cursorLine <= 0 || cursorLine > len(lines) { 126 return nil, fmt.Errorf("invalid line number") 127 } 128 if fset.Position(expr.Pos()).Line == cursorLine { 129 words := strings.Fields(lines[cursorLine-1]) 130 if len(words) > 0 && words[0] == PACKAGE { 131 content := PACKAGE 132 // Account for spaces if there are any. 133 if len(words) > 1 { 134 content += " " 135 } 136 137 start := expr.Pos() 138 end := token.Pos(int(expr.Pos()) + len(content) + 1) 139 // We have verified that we have a valid 'package' keyword as our 140 // first expression. Ensure that cursor is in this keyword or 141 // otherwise fallback to the general case. 142 if cursor >= start && cursor <= end { 143 return &Selection{ 144 content: content, 145 cursor: cursor, 146 MappedRange: source.NewMappedRange(fset, m, start, end), 147 }, nil 148 } 149 } 150 } 151 152 // If the cursor is after the start of the expression, no package 153 // declaration will be valid. 154 if cursor > expr.Pos() { 155 return nil, fmt.Errorf("cursor after expression") 156 } 157 158 // If the cursor is in a comment, don't offer any completions. 159 if cursorInComment(fset, cursor, pgf.Src) { 160 return nil, fmt.Errorf("cursor in comment") 161 } 162 163 // The surrounding range in this case is the cursor except for empty file, 164 // in which case it's end of file - 1 165 start, end := cursor, cursor 166 if tok.Size() == 0 { 167 start, end = tok.Pos(0)-1, tok.Pos(0)-1 168 } 169 170 return &Selection{ 171 content: "", 172 cursor: cursor, 173 MappedRange: source.NewMappedRange(fset, m, start, end), 174 }, nil 175 } 176 177 func cursorInComment(fset *token.FileSet, cursor token.Pos, src []byte) bool { 178 var s scanner.Scanner 179 s.Init(fset.File(cursor), src, func(_ token.Position, _ string) {}, scanner.ScanComments) 180 for { 181 pos, tok, lit := s.Scan() 182 if pos <= cursor && cursor <= token.Pos(int(pos)+len(lit)) { 183 return tok == token.COMMENT 184 } 185 if tok == token.EOF { 186 break 187 } 188 } 189 return false 190 } 191 192 // packageNameCompletions returns name completions for a package clause using 193 // the current name as prefix. 194 func (c *completer) packageNameCompletions(ctx context.Context, fileURI span.URI, name *ast.Ident) error { 195 cursor := int(c.pos - name.NamePos) 196 if cursor < 0 || cursor > len(name.Name) { 197 return errors.New("cursor is not in package name identifier") 198 } 199 200 c.completionContext.packageCompletion = true 201 202 prefix := name.Name[:cursor] 203 packageSuggestions, err := packageSuggestions(ctx, c.snapshot, fileURI, prefix) 204 if err != nil { 205 return err 206 } 207 208 for _, pkg := range packageSuggestions { 209 c.deepState.enqueue(pkg) 210 } 211 return nil 212 } 213 214 // packageSuggestions returns a list of packages from workspace packages that 215 // have the given prefix and are used in the same directory as the given 216 // file. This also includes test packages for these packages (<pkg>_test) and 217 // the directory name itself. 218 func packageSuggestions(ctx context.Context, snapshot source.Snapshot, fileURI span.URI, prefix string) (packages []candidate, err error) { 219 workspacePackages, err := snapshot.ActivePackages(ctx) 220 if err != nil { 221 return nil, err 222 } 223 224 toCandidate := func(name string, score float64) candidate { 225 obj := types.NewPkgName(0, nil, name, types.NewPackage("", name)) 226 return candidate{obj: obj, name: name, detail: name, score: score} 227 } 228 229 matcher := fuzzy.NewMatcher(prefix) 230 231 // Always try to suggest a main package 232 defer func() { 233 if score := float64(matcher.Score("main")); score > 0 { 234 packages = append(packages, toCandidate("main", score*lowScore)) 235 } 236 }() 237 238 dirPath := filepath.Dir(fileURI.Filename()) 239 dirName := filepath.Base(dirPath) 240 if !isValidDirName(dirName) { 241 return packages, nil 242 } 243 pkgName := convertDirNameToPkgName(dirName) 244 245 seenPkgs := make(map[string]struct{}) 246 247 // The `go` command by default only allows one package per directory but we 248 // support multiple package suggestions since gopls is build system agnostic. 249 for _, pkg := range workspacePackages { 250 if pkg.Name() == "main" || pkg.Name() == "" { 251 continue 252 } 253 if _, ok := seenPkgs[pkg.Name()]; ok { 254 continue 255 } 256 257 // Only add packages that are previously used in the current directory. 258 var relevantPkg bool 259 for _, pgf := range pkg.CompiledGoFiles() { 260 if filepath.Dir(pgf.URI.Filename()) == dirPath { 261 relevantPkg = true 262 break 263 } 264 } 265 if !relevantPkg { 266 continue 267 } 268 269 // Add a found package used in current directory as a high relevance 270 // suggestion and the test package for it as a medium relevance 271 // suggestion. 272 if score := float64(matcher.Score(pkg.Name())); score > 0 { 273 packages = append(packages, toCandidate(pkg.Name(), score*highScore)) 274 } 275 seenPkgs[pkg.Name()] = struct{}{} 276 277 testPkgName := pkg.Name() + "_test" 278 if _, ok := seenPkgs[testPkgName]; ok || strings.HasSuffix(pkg.Name(), "_test") { 279 continue 280 } 281 if score := float64(matcher.Score(testPkgName)); score > 0 { 282 packages = append(packages, toCandidate(testPkgName, score*stdScore)) 283 } 284 seenPkgs[testPkgName] = struct{}{} 285 } 286 287 // Add current directory name as a low relevance suggestion. 288 if _, ok := seenPkgs[pkgName]; !ok { 289 if score := float64(matcher.Score(pkgName)); score > 0 { 290 packages = append(packages, toCandidate(pkgName, score*lowScore)) 291 } 292 293 testPkgName := pkgName + "_test" 294 if score := float64(matcher.Score(testPkgName)); score > 0 { 295 packages = append(packages, toCandidate(testPkgName, score*lowScore)) 296 } 297 } 298 299 return packages, nil 300 } 301 302 // isValidDirName checks whether the passed directory name can be used in 303 // a package path. Requirements for a package path can be found here: 304 // https://golang.org/ref/mod#go-mod-file-ident. 305 func isValidDirName(dirName string) bool { 306 if dirName == "" { 307 return false 308 } 309 310 for i, ch := range dirName { 311 if isLetter(ch) || isDigit(ch) { 312 continue 313 } 314 if i == 0 { 315 // Directory name can start only with '_'. '.' is not allowed in module paths. 316 // '-' and '~' are not allowed because elements of package paths must be 317 // safe command-line arguments. 318 if ch == '_' { 319 continue 320 } 321 } else { 322 // Modules path elements can't end with '.' 323 if isAllowedPunctuation(ch) && (i != len(dirName)-1 || ch != '.') { 324 continue 325 } 326 } 327 328 return false 329 } 330 return true 331 } 332 333 // convertDirNameToPkgName converts a valid directory name to a valid package name. 334 // It leaves only letters and digits. All letters are mapped to lower case. 335 func convertDirNameToPkgName(dirName string) string { 336 var buf bytes.Buffer 337 for _, ch := range dirName { 338 switch { 339 case isLetter(ch): 340 buf.WriteRune(unicode.ToLower(ch)) 341 342 case buf.Len() != 0 && isDigit(ch): 343 buf.WriteRune(ch) 344 } 345 } 346 return buf.String() 347 } 348 349 // isLetter and isDigit allow only ASCII characters because 350 // "Each path element is a non-empty string made of up ASCII letters, 351 // ASCII digits, and limited ASCII punctuation" 352 // (see https://golang.org/ref/mod#go-mod-file-ident). 353 354 func isLetter(ch rune) bool { 355 return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' 356 } 357 358 func isDigit(ch rune) bool { 359 return '0' <= ch && ch <= '9' 360 } 361 362 func isAllowedPunctuation(ch rune) bool { 363 return ch == '_' || ch == '-' || ch == '~' || ch == '.' 364 }