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