internal/lsp/source: support some fzf-like tokens in symbol matching

It's useful to be able to switch between case sensitive, case
insensitive, and fuzzy matching for symbol without having to reload
gopls. FZF has some nice syntax for this:
  https://github.com/junegunn/fzf#search-syntax

Adopt a subset of this syntax for our symbol search:
  ' for exact matching
  ^ for prefix matching
  $ for suffix matching

It would be straightforward to also support inversion, using
'!', but I deemed this unnecessary.

I think we should adopt this, since none of these symbols conflicts with
Go identifiers, or (AFAIK) with special syntax in major LSP clients.

Change-Id: If2e4d372d4a45ace5ab5d4e76c460f1dcca0bc2b
Reviewed-on: https://go-review.googlesource.com/c/tools/+/248418
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
This commit is contained in:
Rob Findley 2020-08-13 15:53:33 -04:00 committed by Robert Findley
parent 242af255f0
commit ea3a2cdbfb
4 changed files with 133 additions and 5 deletions

View File

@ -188,4 +188,4 @@ Defines the algorithm that is used when calculating workspace symbol results. Mu
* `"caseSensitive"`
* `"caseInsensitive"`
Default: `"caseInsensitive"`.
Default: `"fuzzy"`.

View File

@ -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.

View File

@ -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)

View File

@ -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