internal/lsp/source: workspace symbol improvements for selectors

This CL adds various improvements for matching nested fields and
methods:
- Limit the symbols we produce to not show unqualified fields/methods,
  and not show partial package paths.
- Handle embedded selectors, by trimming the package path.
- Improve the internal API used by symbolizers to operate on named
  chunks.

Fixes golang/go#46997

Change-Id: I86cbe998adbb8e52549c937e330896134c375ed7
Reviewed-on: https://go-review.googlesource.com/c/tools/+/334531
Trust: Robert Findley <rfindley@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/internal/lsp/source/workspace_symbol.go b/internal/lsp/source/workspace_symbol.go
index c0aabf2..18583ae 100644
--- a/internal/lsp/source/workspace_symbol.go
+++ b/internal/lsp/source/workspace_symbol.go
@@ -57,79 +57,77 @@
 // See the comment for symbolCollector for more information.
 type matcherFunc func(name string) float64
 
-// A symbolizer returns the best symbol match for name with pkg, according to
-// some heuristic.
+// A symbolizer returns the best symbol match for a name with pkg, according to
+// some heuristic. The symbol name is passed as the slice nameParts of logical
+// name pieces. For example, for myType.field the caller can pass either
+// []string{"myType.field"} or []string{"myType.", "field"}.
 //
 // See the comment for symbolCollector for more information.
-type symbolizer func(name string, pkg Package, m matcherFunc) (string, float64)
+type symbolizer func(nameParts []string, pkg Package, m matcherFunc) (string, float64)
 
-func fullyQualifiedSymbolMatch(name string, pkg Package, matcher matcherFunc) (string, float64) {
-	_, score := dynamicSymbolMatch(name, pkg, matcher)
+func fullyQualifiedSymbolMatch(nameParts []string, pkg Package, matcher matcherFunc) (string, float64) {
+	_, score := dynamicSymbolMatch(nameParts, pkg, matcher)
+	path := append([]string{pkg.PkgPath() + "."}, nameParts...)
 	if score > 0 {
-		return pkg.PkgPath() + "." + name, score
+		return strings.Join(path, ""), score
 	}
 	return "", 0
 }
 
-func dynamicSymbolMatch(name string, pkg Package, matcher matcherFunc) (string, float64) {
-	// Prefer any package-qualified match.
-	pkgQualified := pkg.Name() + "." + name
-	if match, score := bestMatch(pkgQualified, matcher); match != "" {
-		return match, score
+func dynamicSymbolMatch(nameParts []string, pkg Package, matcher matcherFunc) (string, float64) {
+	var best string
+	fullName := strings.Join(nameParts, "")
+	var score float64
+	var name string
+
+	// Compute the match score by finding the highest scoring suffix. In these
+	// cases the matched symbol is still the full name: it is confusing to match
+	// an unqualified nested field or method.
+	if match := bestMatch("", nameParts, matcher); match > score {
+		best = fullName
+		score = match
 	}
-	fullyQualified := pkg.PkgPath() + "." + name
-	if match, score := bestMatch(fullyQualified, matcher); match != "" {
-		return match, score
+
+	// Next: try to match a package-qualified name.
+	name = pkg.Name() + "." + fullName
+	if match := matcher(name); match > score {
+		best = name
+		score = match
 	}
-	return "", 0
+
+	// Finally: consider a fully qualified name.
+	prefix := pkg.PkgPath() + "."
+	fullyQualified := prefix + fullName
+	// As with field/method selectors, consider suffixes from right to left, but
+	// always return a fully-qualified symbol.
+	pathParts := strings.SplitAfter(prefix, "/")
+	if match := bestMatch(fullName, pathParts, matcher); match > score {
+		best = fullyQualified
+		score = match
+	}
+	return best, score
 }
 
-func packageSymbolMatch(name string, pkg Package, matcher matcherFunc) (string, float64) {
-	qualified := pkg.Name() + "." + name
+func bestMatch(name string, prefixParts []string, matcher matcherFunc) float64 {
+	var score float64
+	for i := len(prefixParts) - 1; i >= 0; i-- {
+		name = prefixParts[i] + name
+		if match := matcher(name); match > score {
+			score = match
+		}
+	}
+	return score
+}
+
+func packageSymbolMatch(components []string, pkg Package, matcher matcherFunc) (string, float64) {
+	path := append([]string{pkg.Name() + "."}, components...)
+	qualified := strings.Join(path, "")
 	if matcher(qualified) > 0 {
 		return qualified, 1
 	}
 	return "", 0
 }
 
-// bestMatch returns the highest scoring symbol suffix of fullPath, starting
-// from the right and splitting on selectors and path components.
-//
-// e.g. given a symbol path of the form 'host.com/dir/pkg.type.field', we
-// check the match quality of the following:
-//  - field
-//  - type.field
-//  - pkg.type.field
-//  - dir/pkg.type.field
-//  - host.com/dir/pkg.type.field
-//
-// and return the best match, along with its score.
-//
-// This is used to implement the 'dynamic' symbol style.
-func bestMatch(fullPath string, matcher matcherFunc) (string, float64) {
-	pathParts := strings.Split(fullPath, "/")
-	dottedParts := strings.Split(pathParts[len(pathParts)-1], ".")
-
-	var best string
-	var score float64
-
-	for i := 0; i < len(dottedParts); i++ {
-		path := strings.Join(dottedParts[len(dottedParts)-1-i:], ".")
-		if match := matcher(path); match > score {
-			best = path
-			score = match
-		}
-	}
-	for i := 0; i < len(pathParts); i++ {
-		path := strings.Join(pathParts[len(pathParts)-1-i:], "/")
-		if match := matcher(path); match > score {
-			best = path
-			score = match
-		}
-	}
-	return best, score
-}
-
 // symbolCollector holds context as we walk Packages, gathering symbols that
 // match a given query.
 //
@@ -420,7 +418,13 @@
 // or named. path is the path of nested identifiers containing the field.
 func (sc *symbolCollector) walkField(field *ast.Field, unnamedKind, namedKind protocol.SymbolKind, path ...*ast.Ident) {
 	if len(field.Names) == 0 {
-		sc.match(types.ExprString(field.Type), unnamedKind, field, path...)
+		switch typ := field.Type.(type) {
+		case *ast.SelectorExpr:
+			// embedded qualified type
+			sc.match(typ.Sel.Name, unnamedKind, field, path...)
+		default:
+			sc.match(types.ExprString(field.Type), unnamedKind, field, path...)
+		}
 	}
 	for _, name := range field.Names {
 		sc.match(name.Name, namedKind, name, path...)
@@ -466,18 +470,14 @@
 	}
 
 	isExported := isExported(name)
-	if len(path) > 0 {
-		var nameBuilder strings.Builder
-		for _, ident := range path {
-			nameBuilder.WriteString(ident.Name)
-			nameBuilder.WriteString(".")
-			if !ident.IsExported() {
-				isExported = false
-			}
+	var names []string
+	for _, ident := range path {
+		names = append(names, ident.Name+".")
+		if !ident.IsExported() {
+			isExported = false
 		}
-		nameBuilder.WriteString(name)
-		name = nameBuilder.String()
 	}
+	names = append(names, name)
 
 	// Factors to apply to the match score for the purpose of downranking
 	// results.
@@ -501,7 +501,7 @@
 		// can be noisy.
 		fieldFactor = 0.5
 	)
-	symbol, score := sc.symbolizer(name, sc.current.pkg, sc.matcher)
+	symbol, score := sc.symbolizer(names, sc.current.pkg, sc.matcher)
 
 	// Downrank symbols outside of the workspace.
 	if !sc.current.isWorkspace {
diff --git a/internal/lsp/source/workspace_symbol_test.go b/internal/lsp/source/workspace_symbol_test.go
index f3d9dbb..def73ce 100644
--- a/internal/lsp/source/workspace_symbol_test.go
+++ b/internal/lsp/source/workspace_symbol_test.go
@@ -5,7 +5,6 @@
 package source
 
 import (
-	"strings"
 	"testing"
 )
 
@@ -45,53 +44,3 @@
 		}
 	}
 }
-
-func TestBestMatch(t *testing.T) {
-	tests := []struct {
-		desc      string
-		symbol    string
-		matcher   matcherFunc
-		wantMatch string
-		wantScore float64
-	}{
-		{
-			desc:      "shortest match",
-			symbol:    "foo/bar/baz.quux",
-			matcher:   func(string) float64 { return 1.0 },
-			wantMatch: "quux",
-			wantScore: 1.0,
-		},
-		{
-			desc:   "partial match",
-			symbol: "foo/bar/baz.quux",
-			matcher: func(s string) float64 {
-				if strings.HasPrefix(s, "bar") {
-					return 1.0
-				}
-				return 0.0
-			},
-			wantMatch: "bar/baz.quux",
-			wantScore: 1.0,
-		},
-		{
-			desc:   "longest match",
-			symbol: "foo/bar/baz.quux",
-			matcher: func(s string) float64 {
-				parts := strings.Split(s, "/")
-				return float64(len(parts))
-			},
-			wantMatch: "foo/bar/baz.quux",
-			wantScore: 3.0,
-		},
-	}
-
-	for _, test := range tests {
-		test := test
-		t.Run(test.desc, func(t *testing.T) {
-			gotMatch, gotScore := bestMatch(test.symbol, test.matcher)
-			if gotMatch != test.wantMatch || gotScore != test.wantScore {
-				t.Errorf("bestMatch(%q, matcher) = (%q, %.2g), want (%q, %.2g)", test.symbol, gotMatch, gotScore, test.wantMatch, test.wantScore)
-			}
-		})
-	}
-}