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  }