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  }