diff --git a/gopls/doc/settings.md b/gopls/doc/settings.md index 5864e19fa2..79ed3d2dc9 100644 --- a/gopls/doc/settings.md +++ b/gopls/doc/settings.md @@ -188,4 +188,4 @@ Defines the algorithm that is used when calculating workspace symbol results. Mu * `"caseSensitive"` * `"caseInsensitive"` -Default: `"caseInsensitive"`. +Default: `"fuzzy"`. diff --git a/gopls/doc/user.md b/gopls/doc/user.md index c397b1ecc2..846503a05d 100644 --- a/gopls/doc/user.md +++ b/gopls/doc/user.md @@ -105,6 +105,22 @@ See [Settings](settings.md) for more information about the available configurati This contains exactly the same set of values that are in the global configuration, but it is fetched for every workspace folder separately. The editor can choose to respond with different values per-folder. +## Special Features + +### Symbol Queries + +Gopls supports some extended syntax for `workspace/symbol` requests, when using +the `fuzzy` symbol matcher (the default). Inspired by the popular fuzzy matcher +[FZF](https://github.com/junegunn/fzf), the following special characters are +supported within symbol queries: + +| Character | Usage | Match | +| --------- | --------- | ------------ | +| `'` | `'abc` | exact | +| `^` | `^printf` | exact prefix | +| `$` | `printf$` | exact suffix | + + ## Command line support Much of the functionality of `gopls` is available through a command line interface. diff --git a/internal/lsp/source/workspace_symbol.go b/internal/lsp/source/workspace_symbol.go index d60a54f941..2b9cc919b3 100644 --- a/internal/lsp/source/workspace_symbol.go +++ b/internal/lsp/source/workspace_symbol.go @@ -155,10 +155,7 @@ func newSymbolCollector(matcher SymbolMatcher, style SymbolStyle, query string) var m matcherFunc switch matcher { case SymbolFuzzy: - fm := fuzzy.NewMatcher(query) - m = func(s string) float64 { - return float64(fm.Score(s)) - } + m = parseQuery(query) case SymbolCaseSensitive: m = func(s string) float64 { if strings.Contains(s, query) { @@ -194,6 +191,84 @@ func newSymbolCollector(matcher SymbolMatcher, style SymbolStyle, query string) } } +// 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) diff --git a/internal/lsp/source/workspace_symbol_test.go b/internal/lsp/source/workspace_symbol_test.go index 0d3a9cd7cc..f3d9dbb9d4 100644 --- a/internal/lsp/source/workspace_symbol_test.go +++ b/internal/lsp/source/workspace_symbol_test.go @@ -9,6 +9,43 @@ import ( "testing" ) +func TestParseQuery(t *testing.T) { + tests := []struct { + query, s string + wantMatch bool + }{ + {"", "anything", false}, + {"any", "anything", true}, + {"any$", "anything", false}, + {"ing$", "anything", true}, + {"ing$", "anythinG", true}, + {"inG$", "anything", false}, + {"^any", "anything", true}, + {"^any", "Anything", true}, + {"^Any", "anything", false}, + {"at", "anything", true}, + // TODO: this appears to be a bug in the fuzzy matching algorithm. 'At' + // should cause a case-sensitive match. + // {"At", "anything", false}, + {"At", "Anything", true}, + {"'yth", "Anything", true}, + {"'yti", "Anything", false}, + {"'any 'thing", "Anything", true}, + {"anythn nythg", "Anything", true}, + {"ntx", "Anything", false}, + {"anythn", "anything", true}, + {"ing", "anything", true}, + {"anythn nythgx", "anything", false}, + } + + for _, test := range tests { + matcher := parseQuery(test.query) + if score := matcher(test.s); score > 0 != test.wantMatch { + t.Errorf("parseQuery(%q) match for %q: %.2g, want match: %t", test.query, test.s, score, test.wantMatch) + } + } +} + func TestBestMatch(t *testing.T) { tests := []struct { desc string