internal/lsp/source/completion: suggest only valid package names

Before this change directory names were used "as is" for package completion.
It could lead to invalid suggestions (for example, 'package 1abc' or package 'ab-cd').

This change adds a check whether a directory name can be used in a package path.
If the directory name is invalid, only 'package main' will be suggested.
Otherwise, the directory name will be normalized and will be used as a package name.

Note: normalized directory names contain only lower case letters and digits.

Fixes golang/go#44680

Change-Id: I4b710f90d1723c512e29dc3c248a1e681f1cd37f
GitHub-Last-Rev: 8ae69f1c6fdf80831e5773bdb3507a8d51a4a0cf
GitHub-Pull-Request: golang/tools#310
Reviewed-on: https://go-review.googlesource.com/c/tools/+/313092
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
Trust: Rebecca Stambler <rstambler@golang.org>
Trust: Peter Weinberger <pjw@google.com>
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/gopls/internal/regtest/completion/completion_test.go b/gopls/internal/regtest/completion/completion_test.go
index 7984095..a88e861 100644
--- a/gopls/internal/regtest/completion/completion_test.go
+++ b/gopls/internal/regtest/completion/completion_test.go
@@ -70,6 +70,11 @@
 
 -- fruits/testfile3.go --
 pac
+-- 123f_r.u~its-123/testfile.go --
+package
+
+-- .invalid-dir@-name/testfile.go --
+package
 `
 	var (
 		testfile4 = ""
@@ -147,6 +152,21 @@
 			want:          []string{"package apple", "package apple_test", "package fruits", "package fruits_test", "package main"},
 			editRegexp:    `\*\/\n()`,
 		},
+		// Issue golang/go#44680
+		{
+			name:          "package completion for dir name with punctuation",
+			filename:      "123f_r.u~its-123/testfile.go",
+			triggerRegexp: "package()",
+			want:          []string{"package fruits123", "package fruits123_test", "package main"},
+			editRegexp:    "package\n",
+		},
+		{
+			name:          "package completion for invalid dir name",
+			filename:      ".invalid-dir@-name/testfile.go",
+			triggerRegexp: "package()",
+			want:          []string{"package main"},
+			editRegexp:    "package\n",
+		},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			Run(t, files, func(t *testing.T, env *Env) {
diff --git a/internal/lsp/source/completion/package.go b/internal/lsp/source/completion/package.go
index 483223a..aea414e 100644
--- a/internal/lsp/source/completion/package.go
+++ b/internal/lsp/source/completion/package.go
@@ -5,6 +5,7 @@
 package completion
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"go/ast"
@@ -14,6 +15,7 @@
 	"go/types"
 	"path/filepath"
 	"strings"
+	"unicode"
 
 	"golang.org/x/tools/internal/lsp/fuzzy"
 	"golang.org/x/tools/internal/lsp/protocol"
@@ -207,17 +209,12 @@
 // have the given prefix and are used in the same directory as the given
 // file. This also includes test packages for these packages (<pkg>_test) and
 // the directory name itself.
-func packageSuggestions(ctx context.Context, snapshot source.Snapshot, fileURI span.URI, prefix string) ([]candidate, error) {
+func packageSuggestions(ctx context.Context, snapshot source.Snapshot, fileURI span.URI, prefix string) (packages []candidate, err error) {
 	workspacePackages, err := snapshot.WorkspacePackages(ctx)
 	if err != nil {
 		return nil, err
 	}
 
-	dirPath := filepath.Dir(string(fileURI))
-	dirName := filepath.Base(dirPath)
-
-	seenPkgs := make(map[string]struct{})
-
 	toCandidate := func(name string, score float64) candidate {
 		obj := types.NewPkgName(0, nil, name, types.NewPackage("", name))
 		return candidate{obj: obj, name: name, detail: name, score: score}
@@ -225,9 +222,24 @@
 
 	matcher := fuzzy.NewMatcher(prefix)
 
+	// Always try to suggest a main package
+	defer func() {
+		if score := float64(matcher.Score("main")); score > 0 {
+			packages = append(packages, toCandidate("main", score*lowScore))
+		}
+	}()
+
+	dirPath := filepath.Dir(fileURI.Filename())
+	dirName := filepath.Base(dirPath)
+	if !isValidDirName(dirName) {
+		return packages, nil
+	}
+	pkgName := convertDirNameToPkgName(dirName)
+
+	seenPkgs := make(map[string]struct{})
+
 	// The `go` command by default only allows one package per directory but we
 	// support multiple package suggestions since gopls is build system agnostic.
-	var packages []candidate
 	for _, pkg := range workspacePackages {
 		if pkg.Name() == "main" || pkg.Name() == "" {
 			continue
@@ -239,7 +251,7 @@
 		// Only add packages that are previously used in the current directory.
 		var relevantPkg bool
 		for _, pgf := range pkg.CompiledGoFiles() {
-			if filepath.Dir(string(pgf.URI)) == dirPath {
+			if filepath.Dir(pgf.URI.Filename()) == dirPath {
 				relevantPkg = true
 				break
 			}
@@ -267,20 +279,80 @@
 	}
 
 	// Add current directory name as a low relevance suggestion.
-	if _, ok := seenPkgs[dirName]; !ok {
-		if score := float64(matcher.Score(dirName)); score > 0 {
-			packages = append(packages, toCandidate(dirName, score*lowScore))
+	if _, ok := seenPkgs[pkgName]; !ok {
+		if score := float64(matcher.Score(pkgName)); score > 0 {
+			packages = append(packages, toCandidate(pkgName, score*lowScore))
 		}
 
-		testDirName := dirName + "_test"
-		if score := float64(matcher.Score(testDirName)); score > 0 {
-			packages = append(packages, toCandidate(testDirName, score*lowScore))
+		testPkgName := pkgName + "_test"
+		if score := float64(matcher.Score(testPkgName)); score > 0 {
+			packages = append(packages, toCandidate(testPkgName, score*lowScore))
 		}
 	}
 
-	if score := float64(matcher.Score("main")); score > 0 {
-		packages = append(packages, toCandidate("main", score*lowScore))
-	}
-
 	return packages, nil
 }
+
+// isValidDirName checks whether the passed directory name can be used in
+// a package path. Requirements for a package path can be found here:
+// https://golang.org/ref/mod#go-mod-file-ident.
+func isValidDirName(dirName string) bool {
+	if dirName == "" {
+		return false
+	}
+
+	for i, ch := range dirName {
+		if isLetter(ch) || isDigit(ch) {
+			continue
+		}
+		if i == 0 {
+			// Directory name can start only with '_'. '.' is not allowed in module paths.
+			// '-' and '~' are not allowed because elements of package paths must be
+			// safe command-line arguments.
+			if ch == '_' {
+				continue
+			}
+		} else {
+			// Modules path elements can't end with '.'
+			if isAllowedPunctuation(ch) && (i != len(dirName)-1 || ch != '.') {
+				continue
+			}
+		}
+
+		return false
+	}
+	return true
+}
+
+// convertDirNameToPkgName converts a valid directory name to a valid package name.
+// It leaves only letters and digits. All letters are mapped to lower case.
+func convertDirNameToPkgName(dirName string) string {
+	var buf bytes.Buffer
+	for _, ch := range dirName {
+		switch {
+		case isLetter(ch):
+			buf.WriteRune(unicode.ToLower(ch))
+
+		case buf.Len() != 0 && isDigit(ch):
+			buf.WriteRune(ch)
+		}
+	}
+	return buf.String()
+}
+
+// isLetter and isDigit allow only ASCII characters because
+// "Each path element is a non-empty string made of up ASCII letters,
+// ASCII digits, and limited ASCII punctuation"
+// (see https://golang.org/ref/mod#go-mod-file-ident).
+
+func isLetter(ch rune) bool {
+	return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z'
+}
+
+func isDigit(ch rune) bool {
+	return '0' <= ch && ch <= '9'
+}
+
+func isAllowedPunctuation(ch rune) bool {
+	return ch == '_' || ch == '-' || ch == '~' || ch == '.'
+}
diff --git a/internal/lsp/source/completion/package_test.go b/internal/lsp/source/completion/package_test.go
new file mode 100644
index 0000000..6436984
--- /dev/null
+++ b/internal/lsp/source/completion/package_test.go
@@ -0,0 +1,77 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package completion
+
+import "testing"
+
+func TestIsValidDirName(t *testing.T) {
+	tests := []struct {
+		dirName string
+		valid   bool
+	}{
+		{dirName: "", valid: false},
+		//
+		{dirName: "a", valid: true},
+		{dirName: "abcdef", valid: true},
+		{dirName: "AbCdEf", valid: true},
+		//
+		{dirName: "1a35", valid: true},
+		{dirName: "a16", valid: true},
+		//
+		{dirName: "_a", valid: true},
+		{dirName: "a_", valid: true},
+		//
+		{dirName: "~a", valid: false},
+		{dirName: "a~", valid: true},
+		//
+		{dirName: "-a", valid: false},
+		{dirName: "a-", valid: true},
+		//
+		{dirName: ".a", valid: false},
+		{dirName: "a.", valid: false},
+		//
+		{dirName: "a~_b--c.-e", valid: true},
+		{dirName: "~a~_b--c.-e", valid: false},
+		{dirName: "a~_b--c.-e--~", valid: true},
+		{dirName: "a~_b--2134dc42.-e6--~", valid: true},
+		{dirName: "abc`def", valid: false},
+		{dirName: "тест", valid: false},
+		{dirName: "你好", valid: false},
+	}
+	for _, tt := range tests {
+		valid := isValidDirName(tt.dirName)
+		if tt.valid != valid {
+			t.Errorf("%s: expected %v, got %v", tt.dirName, tt.valid, valid)
+		}
+	}
+}
+
+func TestConvertDirNameToPkgName(t *testing.T) {
+	tests := []struct {
+		dirName string
+		pkgName string
+	}{
+		{dirName: "a", pkgName: "a"},
+		{dirName: "abcdef", pkgName: "abcdef"},
+		{dirName: "AbCdEf", pkgName: "abcdef"},
+		{dirName: "1a35", pkgName: "a35"},
+		{dirName: "14a35", pkgName: "a35"},
+		{dirName: "a16", pkgName: "a16"},
+		{dirName: "_a", pkgName: "a"},
+		{dirName: "a_", pkgName: "a"},
+		{dirName: "a~", pkgName: "a"},
+		{dirName: "a-", pkgName: "a"},
+		{dirName: "a~_b--c.-e", pkgName: "abce"},
+		{dirName: "a~_b--c.-e--~", pkgName: "abce"},
+		{dirName: "a~_b--2134dc42.-e6--~", pkgName: "ab2134dc42e6"},
+	}
+	for _, tt := range tests {
+		pkgName := convertDirNameToPkgName(tt.dirName)
+		if tt.pkgName != pkgName {
+			t.Errorf("%s: expected %v, got %v", tt.dirName, tt.pkgName, pkgName)
+			continue
+		}
+	}
+}