// Copyright 2020 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 source import ( "context" "fmt" "go/ast" "go/token" "go/types" "sort" "strings" "unicode" "unicode/utf8" "golang.org/x/tools/internal/event" "golang.org/x/tools/internal/lsp/fuzzy" "golang.org/x/tools/internal/lsp/protocol" "golang.org/x/tools/internal/span" ) // maxSymbols defines the maximum number of symbol results that should ever be // sent in response to a client. const maxSymbols = 100 // WorkspaceSymbols matches symbols across all views using the given query, // according to the match semantics parameterized by matcherType and style. // // The workspace symbol method is defined in the spec as follows: // // The workspace symbol request is sent from the client to the server to // list project-wide symbols matching the query string. // // It is unclear what "project-wide" means here, but given the parameters of // workspace/symbol do not include any workspace identifier, then it has to be // assumed that "project-wide" means "across all workspaces". Hence why // WorkspaceSymbols receives the views []View. // // However, it then becomes unclear what it would mean to call WorkspaceSymbols // with a different configured SymbolMatcher per View. Therefore we assume that // Session level configuration will define the SymbolMatcher to be used for the // WorkspaceSymbols method. func WorkspaceSymbols(ctx context.Context, matcherType SymbolMatcher, style SymbolStyle, views []View, query string) ([]protocol.SymbolInformation, error) { ctx, done := event.Start(ctx, "source.WorkspaceSymbols") defer done() if query == "" { return nil, nil } sc := newSymbolCollector(matcherType, style, query) return sc.walk(ctx, views) } // A matcherFunc determines the matching score of a symbol. // // 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. // // See the comment for symbolCollector for more information. type symbolizer func(name string, pkg Package, m matcherFunc) (string, float64) func fullyQualifiedSymbolMatch(name string, pkg Package, matcher matcherFunc) (string, float64) { _, score := dynamicSymbolMatch(name, pkg, matcher) if score > 0 { return pkg.PkgPath() + "." + name, 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 } fullyQualified := pkg.PkgPath() + "." + name if match, score := bestMatch(fullyQualified, matcher); match != "" { return match, score } return "", 0 } func packageSymbolMatch(name string, pkg Package, matcher matcherFunc) (string, float64) { qualified := pkg.Name() + "." + name 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. // // How we match symbols is parameterized by two interfaces: // * A matcherFunc determines how well a string symbol matches a query. It // returns a non-negative score indicating the quality of the match. A score // of zero indicates no match. // * A symbolizer determines how we extract the symbol for an object. This // enables the 'symbolStyle' configuration option. type symbolCollector struct { // These types parameterize the symbol-matching pass. matcher matcherFunc symbolizer symbolizer // current holds metadata for the package we are currently walking. current *pkgView curFile *ParsedGoFile res [maxSymbols]symbolInformation } func newSymbolCollector(matcher SymbolMatcher, style SymbolStyle, query string) *symbolCollector { var m matcherFunc switch matcher { case SymbolFuzzy: m = parseQuery(query) case SymbolCaseSensitive: m = func(s string) float64 { if strings.Contains(s, query) { return 1 } return 0 } case SymbolCaseInsensitive: q := strings.ToLower(query) m = func(s string) float64 { if strings.Contains(strings.ToLower(s), q) { return 1 } return 0 } default: panic(fmt.Errorf("unknown symbol matcher: %v", matcher)) } var s symbolizer switch style { case DynamicSymbols: s = dynamicSymbolMatch case FullyQualifiedSymbols: s = fullyQualifiedSymbolMatch case PackageQualifiedSymbols: s = packageSymbolMatch default: panic(fmt.Errorf("unknown symbol style: %v", style)) } return &symbolCollector{ matcher: m, symbolizer: s, } } // parseQuery parses a field-separated symbol query, extracting the special // characters listed below, and returns a matcherFunc corresponding to the AND // of all field queries. // // Special characters: // ^ match exact prefix // $ match exact suffix // ' match exact // // In all three of these special queries, matches are 'smart-cased', meaning // they are case sensitive if the symbol query contains any upper-case // characters, and case insensitive otherwise. func parseQuery(q string) matcherFunc { fields := strings.Fields(q) if len(fields) == 0 { return func(string) float64 { return 0 } } var funcs []matcherFunc for _, field := range fields { var f matcherFunc switch { case strings.HasPrefix(field, "^"): prefix := field[1:] f = smartCase(prefix, func(s string) float64 { if strings.HasPrefix(s, prefix) { return 1 } return 0 }) case strings.HasPrefix(field, "'"): exact := field[1:] f = smartCase(exact, func(s string) float64 { if strings.Contains(s, exact) { return 1 } return 0 }) case strings.HasSuffix(field, "$"): suffix := field[0 : len(field)-1] f = smartCase(suffix, func(s string) float64 { if strings.HasSuffix(s, suffix) { return 1 } return 0 }) default: fm := fuzzy.NewMatcher(field) f = func(s string) float64 { return float64(fm.Score(s)) } } funcs = append(funcs, f) } return comboMatcher(funcs).match } // smartCase returns a matcherFunc that is case-sensitive if q contains any // upper-case characters, and case-insensitive otherwise. func smartCase(q string, m matcherFunc) matcherFunc { insensitive := strings.ToLower(q) == q return func(s string) float64 { if insensitive { s = strings.ToLower(s) } return m(s) } } type comboMatcher []matcherFunc func (c comboMatcher) match(s string) float64 { score := 1.0 for _, f := range c { score *= f(s) } return score } // walk walks views, gathers symbols, and returns the results. func (sc *symbolCollector) walk(ctx context.Context, views []View) (_ []protocol.SymbolInformation, err error) { toWalk, release, err := sc.collectPackages(ctx, views) defer release() if err != nil { return nil, err } // Make sure we only walk files once (we might see them more than once due to // build constraints). seen := make(map[span.URI]bool) for _, pv := range toWalk { sc.current = pv for _, pgf := range pv.pkg.CompiledGoFiles() { if seen[pgf.URI] { continue } seen[pgf.URI] = true sc.curFile = pgf sc.walkFilesDecls(pgf.File.Decls) } } return sc.results(), nil } func (sc *symbolCollector) results() []protocol.SymbolInformation { var res []protocol.SymbolInformation for _, si := range sc.res { if si.score <= 0 { return res } res = append(res, si.asProtocolSymbolInformation()) } return res } // collectPackages gathers the packages we are going to inspect for symbols. // This pre-step is required in order to filter out any "duplicate" // *types.Package. The duplicates arise for packages that have test variants. // For example, if package mod.com/p has test files, then we will visit two // packages that have the PkgPath() mod.com/p: the first is the actual package // mod.com/p, the second is a special version that includes the non-XTest // _test.go files. If we were to walk both of of these packages, then we would // get duplicate matching symbols and we would waste effort. Therefore where // test variants exist we walk those (because they include any symbols defined // in non-XTest _test.go files). // // One further complication is that even after this filtering, packages between // views might not be "identical" because they can be built using different // build constraints (via the "env" config option). // // Therefore on a per view basis we first build up a map of package path -> // *types.Package preferring the test variants if they exist. Then we merge the // results between views, de-duping by *types.Package. func (sc *symbolCollector) collectPackages(ctx context.Context, views []View) ([]*pkgView, func(), error) { gathered := make(map[string]map[*types.Package]*pkgView) var releaseFuncs []func() release := func() { for _, releaseFunc := range releaseFuncs { releaseFunc() } } var toWalk []*pkgView for _, v := range views { seen := make(map[string]*pkgView) snapshot, release := v.Snapshot(ctx) releaseFuncs = append(releaseFuncs, release) knownPkgs, err := snapshot.KnownPackages(ctx) if err != nil { return nil, release, err } workspacePackages, err := snapshot.WorkspacePackages(ctx) if err != nil { return nil, release, err } isWorkspacePkg := make(map[Package]bool) for _, wp := range workspacePackages { isWorkspacePkg[wp] = true } var forTests []*pkgView for _, pkg := range knownPkgs { toAdd := &pkgView{ pkg: pkg, snapshot: snapshot, isWorkspace: isWorkspacePkg[pkg], } // Defer test packages, so that they overwrite seen for this package // path. if pkg.ForTest() != "" { forTests = append(forTests, toAdd) } else { seen[pkg.PkgPath()] = toAdd } } for _, pkg := range forTests { seen[pkg.pkg.PkgPath()] = pkg } for _, pkg := range seen { pm, ok := gathered[pkg.pkg.PkgPath()] if !ok { pm = make(map[*types.Package]*pkgView) gathered[pkg.pkg.PkgPath()] = pm } pm[pkg.pkg.GetTypes()] = pkg } } for _, pm := range gathered { for _, pkg := range pm { toWalk = append(toWalk, pkg) } } // Now sort for stability of results. We order by // (pkgView.isWorkspace, pkgView.p.ID()) sort.Slice(toWalk, func(i, j int) bool { lhs := toWalk[i] rhs := toWalk[j] switch { case lhs.isWorkspace == rhs.isWorkspace: return lhs.pkg.ID() < rhs.pkg.ID() case lhs.isWorkspace: return true default: return false } }) return toWalk, release, nil } func (sc *symbolCollector) walkFilesDecls(decls []ast.Decl) { for _, decl := range decls { switch decl := decl.(type) { case *ast.FuncDecl: kind := protocol.Function var recv *ast.Ident if decl.Recv != nil { kind = protocol.Method switch typ := decl.Recv.List[0].Type.(type) { case *ast.StarExpr: recv = typ.X.(*ast.Ident) case *ast.Ident: recv = typ } } if recv != nil { sc.match(decl.Name.Name, kind, decl.Name, recv) } else { sc.match(decl.Name.Name, kind, decl.Name) } case *ast.GenDecl: for _, spec := range decl.Specs { switch spec := spec.(type) { case *ast.TypeSpec: sc.match(spec.Name.Name, typeToKind(sc.current.pkg.GetTypesInfo().TypeOf(spec.Type)), spec.Name) sc.walkType(spec.Type, spec.Name) case *ast.ValueSpec: for _, name := range spec.Names { kind := protocol.Variable if decl.Tok == token.CONST { kind = protocol.Constant } sc.match(name.Name, kind, name) } } } } } } // walkType processes symbols related to a type expression. path is path of // nested type identifiers to the type expression. func (sc *symbolCollector) walkType(typ ast.Expr, path ...*ast.Ident) { switch st := typ.(type) { case *ast.StructType: for _, field := range st.Fields.List { sc.walkField(field, protocol.Field, protocol.Field, path...) } case *ast.InterfaceType: for _, field := range st.Methods.List { sc.walkField(field, protocol.Interface, protocol.Method, path...) } } } // walkField processes symbols related to the struct field or interface method. // // unnamedKind and namedKind are the symbol kinds if the field is resp. unnamed // 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...) } for _, name := range field.Names { sc.match(name.Name, namedKind, name, path...) sc.walkType(field.Type, append(path, name)...) } } func typeToKind(typ types.Type) protocol.SymbolKind { switch typ := typ.Underlying().(type) { case *types.Interface: return protocol.Interface case *types.Struct: return protocol.Struct case *types.Signature: if typ.Recv() != nil { return protocol.Method } return protocol.Function case *types.Named: return typeToKind(typ.Underlying()) case *types.Basic: i := typ.Info() switch { case i&types.IsNumeric != 0: return protocol.Number case i&types.IsBoolean != 0: return protocol.Boolean case i&types.IsString != 0: return protocol.String } } return protocol.Variable } // match finds matches and gathers the symbol identified by name, kind and node // via the symbolCollector's matcher after first de-duping against previously // seen symbols. // // path specifies the identifier path to a nested field or interface method. func (sc *symbolCollector) match(name string, kind protocol.SymbolKind, node ast.Node, path ...*ast.Ident) { if !node.Pos().IsValid() || !node.End().IsValid() { return } 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 } } nameBuilder.WriteString(name) name = nameBuilder.String() } // Factors to apply to the match score for the purpose of downranking // results. // // These numbers were crudely calibrated based on trial-and-error using a // small number of sample queries. Adjust as necessary. // // All factors are multiplicative, meaning if more than one applies they are // multiplied together. const ( // nonWorkspaceFactor is applied to symbols outside of any active // workspace. Developers are less likely to want to jump to code that they // are not actively working on. nonWorkspaceFactor = 0.5 // nonWorkspaceUnexportedFactor is applied to unexported symbols outside of // any active workspace. Since one wouldn't usually jump to unexported // symbols to understand a package API, they are particularly irrelevant. nonWorkspaceUnexportedFactor = 0.5 // fieldFactor is applied to fields and interface methods. One would // typically jump to the type definition first, so ranking fields highly // can be noisy. fieldFactor = 0.5 ) symbol, score := sc.symbolizer(name, sc.current.pkg, sc.matcher) // Downrank symbols outside of the workspace. if !sc.current.isWorkspace { score *= nonWorkspaceFactor if !isExported { score *= nonWorkspaceUnexportedFactor } } // Downrank fields. if len(path) > 0 { score *= fieldFactor } // Avoid the work below if we know this score will not be sorted into the // results. if score <= sc.res[len(sc.res)-1].score { return } mrng := NewMappedRange(sc.current.snapshot.FileSet(), sc.curFile.Mapper, node.Pos(), node.End()) rng, err := mrng.Range() if err != nil { return } si := symbolInformation{ score: score, name: name, symbol: symbol, container: sc.current.pkg.PkgPath(), kind: kind, location: protocol.Location{ URI: protocol.URIFromSpanURI(mrng.URI()), Range: rng, }, } insertAt := sort.Search(len(sc.res), func(i int) bool { return sc.res[i].score < score }) if insertAt < len(sc.res)-1 { copy(sc.res[insertAt+1:], sc.res[insertAt:len(sc.res)-1]) } sc.res[insertAt] = si } // isExported reports if a token is exported. Copied from // token.IsExported (go1.13+). // // TODO: replace usage with token.IsExported once go1.12 is no longer // supported. func isExported(name string) bool { ch, _ := utf8.DecodeRuneInString(name) return unicode.IsUpper(ch) } // pkgView holds information related to a package that we are going to walk. type pkgView struct { pkg Package snapshot Snapshot isWorkspace bool } // symbolInformation is a cut-down version of protocol.SymbolInformation that // allows struct values of this type to be used as map keys. type symbolInformation struct { score float64 name string symbol string container string kind protocol.SymbolKind location protocol.Location } // asProtocolSymbolInformation converts s to a protocol.SymbolInformation value. // // TODO: work out how to handle tags if/when they are needed. func (s symbolInformation) asProtocolSymbolInformation() protocol.SymbolInformation { return protocol.SymbolInformation{ Name: s.symbol, Kind: s.kind, Location: s.location, ContainerName: s.container, } }