2019-07-01 15:08:29 -06:00
|
|
|
// Copyright 2019 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 fuzzy implements a fuzzy matching algorithm.
|
|
|
|
package fuzzy
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"fmt"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// MaxInputSize is the maximum size of the input scored against the fuzzy matcher. Longer inputs
|
|
|
|
// will be truncated to this size.
|
|
|
|
MaxInputSize = 127
|
|
|
|
// MaxPatternSize is the maximum size of the pattern used to construct the fuzzy matcher. Longer
|
|
|
|
// inputs are truncated to this size.
|
|
|
|
MaxPatternSize = 63
|
|
|
|
)
|
|
|
|
|
|
|
|
type scoreVal int
|
|
|
|
|
|
|
|
func (s scoreVal) val() int {
|
|
|
|
return int(s) >> 1
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s scoreVal) prevK() int {
|
|
|
|
return int(s) & 1
|
|
|
|
}
|
|
|
|
|
|
|
|
func score(val int, prevK int /*0 or 1*/) scoreVal {
|
|
|
|
return scoreVal(val<<1 + prevK)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Matcher implements a fuzzy matching algorithm for scoring candidates against a pattern.
|
|
|
|
// The matcher does not support parallel usage.
|
|
|
|
type Matcher struct {
|
|
|
|
pattern string
|
|
|
|
patternLower []byte // lower-case version of the pattern
|
|
|
|
patternShort []byte // first characters of the pattern
|
|
|
|
caseSensitive bool // set if the pattern is mix-cased
|
|
|
|
|
|
|
|
patternRoles []RuneRole // the role of each character in the pattern
|
|
|
|
roles []RuneRole // the role of each character in the tested string
|
|
|
|
|
|
|
|
scores [MaxInputSize + 1][MaxPatternSize + 1][2]scoreVal
|
|
|
|
|
|
|
|
scoreScale float32
|
|
|
|
|
|
|
|
lastCandidateLen int // in bytes
|
|
|
|
lastCandidateMatched bool
|
|
|
|
|
|
|
|
// Here we save the last candidate in lower-case. This is basically a byte slice we reuse for
|
|
|
|
// performance reasons, so the slice is not reallocated for every candidate.
|
|
|
|
lowerBuf [MaxInputSize]byte
|
|
|
|
rolesBuf [MaxInputSize]RuneRole
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Matcher) bestK(i, j int) int {
|
|
|
|
if m.scores[i][j][0].val() < m.scores[i][j][1].val() {
|
|
|
|
return 1
|
|
|
|
}
|
|
|
|
return 0
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewMatcher returns a new fuzzy matcher for scoring candidates against the provided pattern.
|
2019-10-21 22:07:21 -06:00
|
|
|
func NewMatcher(pattern string) *Matcher {
|
2019-07-01 15:08:29 -06:00
|
|
|
if len(pattern) > MaxPatternSize {
|
|
|
|
pattern = pattern[:MaxPatternSize]
|
|
|
|
}
|
|
|
|
|
|
|
|
m := &Matcher{
|
|
|
|
pattern: pattern,
|
|
|
|
patternLower: ToLower(pattern, nil),
|
|
|
|
}
|
|
|
|
|
|
|
|
for i, c := range m.patternLower {
|
|
|
|
if pattern[i] != c {
|
|
|
|
m.caseSensitive = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(pattern) > 3 {
|
|
|
|
m.patternShort = m.patternLower[:3]
|
|
|
|
} else {
|
|
|
|
m.patternShort = m.patternLower
|
|
|
|
}
|
|
|
|
|
2019-10-21 22:07:21 -06:00
|
|
|
m.patternRoles = RuneRoles(pattern, nil)
|
2019-07-01 15:08:29 -06:00
|
|
|
|
|
|
|
if len(pattern) > 0 {
|
|
|
|
maxCharScore := 4
|
|
|
|
m.scoreScale = 1 / float32(maxCharScore*len(pattern))
|
|
|
|
}
|
|
|
|
|
|
|
|
return m
|
|
|
|
}
|
|
|
|
|
|
|
|
// Score returns the score returned by matching the candidate to the pattern.
|
2019-09-11 00:14:36 -06:00
|
|
|
// This is not designed for parallel use. Multiple candidates must be scored sequentially.
|
2019-07-01 15:08:29 -06:00
|
|
|
// Returns a score between 0 and 1 (0 - no match, 1 - perfect match).
|
|
|
|
func (m *Matcher) Score(candidate string) float32 {
|
|
|
|
if len(candidate) > MaxInputSize {
|
|
|
|
candidate = candidate[:MaxInputSize]
|
|
|
|
}
|
|
|
|
lower := ToLower(candidate, m.lowerBuf[:])
|
|
|
|
m.lastCandidateLen = len(candidate)
|
|
|
|
|
|
|
|
if len(m.pattern) == 0 {
|
|
|
|
// Empty patterns perfectly match candidates.
|
|
|
|
return 1
|
|
|
|
}
|
|
|
|
|
|
|
|
if m.match(candidate, lower) {
|
|
|
|
sc := m.computeScore(candidate, lower)
|
|
|
|
if sc > minScore/2 && !m.poorMatch() {
|
|
|
|
m.lastCandidateMatched = true
|
|
|
|
if len(m.pattern) == len(candidate) {
|
|
|
|
// Perfect match.
|
|
|
|
return 1
|
|
|
|
}
|
|
|
|
|
|
|
|
if sc < 0 {
|
|
|
|
sc = 0
|
|
|
|
}
|
|
|
|
normalizedScore := float32(sc) * m.scoreScale
|
|
|
|
if normalizedScore > 1 {
|
|
|
|
normalizedScore = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
return normalizedScore
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
m.lastCandidateMatched = false
|
|
|
|
return -1
|
|
|
|
}
|
|
|
|
|
|
|
|
const minScore = -10000
|
|
|
|
|
|
|
|
// MatchedRanges returns matches ranges for the last scored string as a flattened array of
|
|
|
|
// [begin, end) byte offset pairs.
|
|
|
|
func (m *Matcher) MatchedRanges() []int {
|
|
|
|
if len(m.pattern) == 0 || !m.lastCandidateMatched {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
i, j := m.lastCandidateLen, len(m.pattern)
|
|
|
|
if m.scores[i][j][0].val() < minScore/2 && m.scores[i][j][1].val() < minScore/2 {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var ret []int
|
|
|
|
k := m.bestK(i, j)
|
|
|
|
for i > 0 {
|
|
|
|
take := (k == 1)
|
|
|
|
k = m.scores[i][j][k].prevK()
|
|
|
|
if take {
|
|
|
|
if len(ret) == 0 || ret[len(ret)-1] != i {
|
|
|
|
ret = append(ret, i)
|
|
|
|
ret = append(ret, i-1)
|
|
|
|
} else {
|
|
|
|
ret[len(ret)-1] = i - 1
|
|
|
|
}
|
|
|
|
j--
|
|
|
|
}
|
|
|
|
i--
|
|
|
|
}
|
|
|
|
// Reverse slice.
|
|
|
|
for i := 0; i < len(ret)/2; i++ {
|
|
|
|
ret[i], ret[len(ret)-1-i] = ret[len(ret)-1-i], ret[i]
|
|
|
|
}
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Matcher) match(candidate string, candidateLower []byte) bool {
|
|
|
|
i, j := 0, 0
|
|
|
|
for ; i < len(candidateLower) && j < len(m.patternLower); i++ {
|
|
|
|
if candidateLower[i] == m.patternLower[j] {
|
|
|
|
j++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if j != len(m.patternLower) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// The input passes the simple test against pattern, so it is time to classify its characters.
|
|
|
|
// Character roles are used below to find the last segment.
|
2019-10-21 22:07:21 -06:00
|
|
|
m.roles = RuneRoles(candidate, m.rolesBuf[:])
|
internal/lsp: add fuzzy completion matching
Make use of the existing fuzzy matcher to perform server side fuzzy
completion matching. Previously the server did exact prefix matching
for completion candidates and left fancy filtering to the
client. Having the server do fuzzy matching has two main benefits:
- Deep completions now update as you type. The completion candidates
returned to the client are marked "incomplete", causing the client
to refresh the candidates after every keystroke. This lets the
server pick the most relevant set of deep completion candidates.
- All editors get fuzzy matching for free. VSCode has fuzzy matching
out of the box, but some editors either don't provide it, or it can
be difficult to set up.
I modified the fuzzy matcher to allow matches where the input doesn't
match the final segment of the candidate. For example, previously "ab"
would not match "abc.def" because the "b" in "ab" did not match the
final segment "def". I can see how this is useful when the text
matching happens in a vacuum and candidate's final segment is the most
specific part. But, in our case, we have various other methods to
order candidates, so we don't want to exclude them just because the
final segment doesn't match. For example, if we know our candidate
needs to be type "context.Context" and "foo.ctx" is of the right type,
we want to suggest "foo.ctx" as soon as the user starts inputting
"foo", even though "foo" doesn't match "ctx" at all.
Note that fuzzy matching is behind the "useDeepCompletions" config
flag for the time being.
Change-Id: Ic7674f0cf885af770c30daef472f2e3c5ac4db78
Reviewed-on: https://go-review.googlesource.com/c/tools/+/190099
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
2019-08-13 14:45:19 -06:00
|
|
|
|
2019-07-01 15:08:29 -06:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Matcher) computeScore(candidate string, candidateLower []byte) int {
|
|
|
|
pattLen, candLen := len(m.pattern), len(candidate)
|
|
|
|
|
|
|
|
for j := 0; j <= len(m.pattern); j++ {
|
|
|
|
m.scores[0][j][0] = minScore << 1
|
|
|
|
m.scores[0][j][1] = minScore << 1
|
|
|
|
}
|
|
|
|
m.scores[0][0][0] = score(0, 0) // Start with 0.
|
|
|
|
|
|
|
|
segmentsLeft, lastSegStart := 1, 0
|
|
|
|
for i := 0; i < candLen; i++ {
|
|
|
|
if m.roles[i] == RSep {
|
|
|
|
segmentsLeft++
|
|
|
|
lastSegStart = i + 1
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// A per-character bonus for a consecutive match.
|
|
|
|
consecutiveBonus := 2
|
|
|
|
wordIdx := 0 // Word count within segment.
|
|
|
|
for i := 1; i <= candLen; i++ {
|
|
|
|
|
|
|
|
role := m.roles[i-1]
|
|
|
|
isHead := role == RHead
|
|
|
|
|
|
|
|
if isHead {
|
|
|
|
wordIdx++
|
|
|
|
} else if role == RSep && segmentsLeft > 1 {
|
|
|
|
wordIdx = 0
|
|
|
|
segmentsLeft--
|
|
|
|
}
|
|
|
|
|
|
|
|
var skipPenalty int
|
internal/lsp: fix fuzzy matcher inconsistency
Originally the fuzzy matcher required a match in the final candidate
segment. For example, to match the candidate "foo.bar", the input had
to have at least one character that matched "bar". I previously
removed this requirement as it is too restrictive for deep completions
to be useful.
However, there was still some lingering final-segment favoritism in
the matching algorithm. In particular, there were penalties for not
matching the final segment's first character and for not matching the
final segment's word initial characters. However, these penalties only
made sense when we also required a final segment match. Consider this
example:
User input: "U"
Candidate "ErrUnexpectedEOF" - with only a single segment, we got big
penalties for not matching the leading "E" (since it is the final
segment).
Candidate "ErrUnexpectedEOF.Error" - "ErrUnexpectedEOF" is no longer
the final segment, so we didn't get penalties. And we didn't get
penalties for the final segment "Error" because we finished matching
after the first "U". As a result, this candidate slips through with a
higher score.
Fix by simplifying the skip penalty. Now we only penalize for skipping
the first character of the first or final segment (and the penalty is
lower). For deep completions, the first and final segment are both
"important" segments, so I think it makes sense to focus on both of
them. We don't want to penalize all segment starts because that makes
it harder to match deeper candidates where you often "ignore"
intermediate segments.
I had to adjust a few scores in the tests, but I don't think the
impact will be too big other than fixing the bug.
Fixes golang/go#35062.
Change-Id: Id17a5c80bf0f80ce252fe990ccfbd51c1bac1c72
Reviewed-on: https://go-review.googlesource.com/c/tools/+/202638
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
2019-10-22 10:28:04 -06:00
|
|
|
if i == 1 || (i-1) == lastSegStart {
|
|
|
|
// Skipping the start of first or last segment.
|
|
|
|
skipPenalty += 1
|
2019-07-01 15:08:29 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
for j := 0; j <= pattLen; j++ {
|
|
|
|
// By default, we don't have a match. Fill in the skip data.
|
|
|
|
m.scores[i][j][1] = minScore << 1
|
|
|
|
|
|
|
|
// Compute the skip score.
|
|
|
|
k := 0
|
|
|
|
if m.scores[i-1][j][0].val() < m.scores[i-1][j][1].val() {
|
|
|
|
k = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
skipScore := m.scores[i-1][j][k].val()
|
|
|
|
// Do not penalize missing characters after the last matched segment.
|
|
|
|
if j != pattLen {
|
|
|
|
skipScore -= skipPenalty
|
|
|
|
}
|
|
|
|
m.scores[i][j][0] = score(skipScore, k)
|
|
|
|
|
|
|
|
if j == 0 || candidateLower[i-1] != m.patternLower[j-1] {
|
|
|
|
// Not a match.
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
pRole := m.patternRoles[j-1]
|
|
|
|
|
|
|
|
if role == RTail && pRole == RHead {
|
|
|
|
if j > 1 {
|
|
|
|
// Not a match: a head in the pattern matches a tail character in the candidate.
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
// Special treatment for the first character of the pattern. We allow
|
|
|
|
// matches in the middle of a word if they are long enough, at least
|
|
|
|
// min(3, pattern.length) characters.
|
|
|
|
if !bytes.HasPrefix(candidateLower[i-1:], m.patternShort) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Compute the char score.
|
|
|
|
var charScore int
|
|
|
|
// Bonus 1: the char is in the candidate's last segment.
|
|
|
|
if segmentsLeft <= 1 {
|
|
|
|
charScore++
|
|
|
|
}
|
|
|
|
// Bonus 2: Case match or a Head in the pattern aligns with one in the word.
|
|
|
|
// Single-case patterns lack segmentation signals and we assume any character
|
|
|
|
// can be a head of a segment.
|
|
|
|
if candidate[i-1] == m.pattern[j-1] || role == RHead && (!m.caseSensitive || pRole == RHead) {
|
|
|
|
charScore++
|
|
|
|
}
|
|
|
|
|
|
|
|
// Penalty 1: pattern char is Head, candidate char is Tail.
|
|
|
|
if role == RTail && pRole == RHead {
|
|
|
|
charScore--
|
|
|
|
}
|
|
|
|
// Penalty 2: first pattern character matched in the middle of a word.
|
|
|
|
if j == 1 && role == RTail {
|
|
|
|
charScore -= 4
|
|
|
|
}
|
|
|
|
|
|
|
|
// Third dimension encodes whether there is a gap between the previous match and the current
|
|
|
|
// one.
|
|
|
|
for k := 0; k < 2; k++ {
|
|
|
|
sc := m.scores[i-1][j-1][k].val() + charScore
|
|
|
|
|
|
|
|
isConsecutive := k == 1 || i-1 == 0 || i-1 == lastSegStart
|
2019-10-21 22:07:21 -06:00
|
|
|
if isConsecutive {
|
2019-07-01 15:08:29 -06:00
|
|
|
// Bonus 3: a consecutive match. First character match also gets a bonus to
|
|
|
|
// ensure prefix final match score normalizes to 1.0.
|
|
|
|
// Logically, this is a part of charScore, but we have to compute it here because it
|
|
|
|
// only applies for consecutive matches (k == 1).
|
|
|
|
sc += consecutiveBonus
|
|
|
|
}
|
|
|
|
if k == 0 {
|
|
|
|
// Penalty 3: Matching inside a segment (and previous char wasn't matched). Penalize for the lack
|
|
|
|
// of alignment.
|
|
|
|
if role == RTail || role == RUCTail {
|
|
|
|
sc -= 3
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if sc > m.scores[i][j][1].val() {
|
|
|
|
m.scores[i][j][1] = score(sc, k)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
result := m.scores[len(candidate)][len(m.pattern)][m.bestK(len(candidate), len(m.pattern))].val()
|
|
|
|
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
// ScoreTable returns the score table computed for the provided candidate. Used only for debugging.
|
|
|
|
func (m *Matcher) ScoreTable(candidate string) string {
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
|
|
var line1, line2, separator bytes.Buffer
|
|
|
|
line1.WriteString("\t")
|
|
|
|
line2.WriteString("\t")
|
|
|
|
for j := 0; j < len(m.pattern); j++ {
|
|
|
|
line1.WriteString(fmt.Sprintf("%c\t\t", m.pattern[j]))
|
|
|
|
separator.WriteString("----------------")
|
|
|
|
}
|
|
|
|
|
|
|
|
buf.WriteString(line1.String())
|
|
|
|
buf.WriteString("\n")
|
|
|
|
buf.WriteString(separator.String())
|
|
|
|
buf.WriteString("\n")
|
|
|
|
|
|
|
|
for i := 1; i <= len(candidate); i++ {
|
|
|
|
line1.Reset()
|
|
|
|
line2.Reset()
|
|
|
|
|
|
|
|
line1.WriteString(fmt.Sprintf("%c\t", candidate[i-1]))
|
|
|
|
line2.WriteString("\t")
|
|
|
|
|
|
|
|
for j := 1; j <= len(m.pattern); j++ {
|
|
|
|
line1.WriteString(fmt.Sprintf("M%6d(%c)\t", m.scores[i][j][0].val(), dir(m.scores[i][j][0].prevK())))
|
|
|
|
line2.WriteString(fmt.Sprintf("H%6d(%c)\t", m.scores[i][j][1].val(), dir(m.scores[i][j][1].prevK())))
|
|
|
|
}
|
|
|
|
buf.WriteString(line1.String())
|
|
|
|
buf.WriteString("\n")
|
|
|
|
buf.WriteString(line2.String())
|
|
|
|
buf.WriteString("\n")
|
|
|
|
buf.WriteString(separator.String())
|
|
|
|
buf.WriteString("\n")
|
|
|
|
}
|
|
|
|
|
|
|
|
return buf.String()
|
|
|
|
}
|
|
|
|
|
|
|
|
func dir(prevK int) rune {
|
|
|
|
if prevK == 0 {
|
|
|
|
return 'M'
|
|
|
|
}
|
|
|
|
return 'H'
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Matcher) poorMatch() bool {
|
|
|
|
if len(m.pattern) < 2 {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
i, j := m.lastCandidateLen, len(m.pattern)
|
|
|
|
k := m.bestK(i, j)
|
|
|
|
|
|
|
|
var counter, len int
|
|
|
|
for i > 0 {
|
|
|
|
take := (k == 1)
|
|
|
|
k = m.scores[i][j][k].prevK()
|
|
|
|
if take {
|
|
|
|
len++
|
|
|
|
if k == 0 && len < 3 && m.roles[i-1] == RTail {
|
|
|
|
// Short match in the middle of a word
|
|
|
|
counter++
|
|
|
|
if counter > 1 {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
j--
|
|
|
|
} else {
|
|
|
|
len = 0
|
|
|
|
}
|
|
|
|
i--
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|