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