Skip to content

Commit

Permalink
Implement fuzzy-matching
Browse files Browse the repository at this point in the history
  • Loading branch information
Vladimir Yarotsky committed Dec 17, 2021
1 parent cb488dc commit 6afac93
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 21 deletions.
11 changes: 0 additions & 11 deletions alfred.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,10 @@ func (a AlfredOutput) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return nil
}

func (s AlfredOutput) Len() int {
return len(s)
}
func (s AlfredOutput) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s AlfredOutput) Less(i, j int) bool {
return s[i].Pos < s[j].Pos
}

type AlfredOutputItem struct {
UID string `xml:"uid,attr"`
Autocomplete string `xml:"autocomplete,attr"`
Title string `xml:"title"`
Icon string `xml:"icon"`
Arg string `xml:"arg"`
Pos int `xml:"-"`
}
10 changes: 9 additions & 1 deletion dawg.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type ServiceConfig struct {
Substitutions map[string]map[string]interface{} `json:"substitutions"`
}

func (s ServiceConfig) GetURL(shortcut string) (string, error) {
func (s *ServiceConfig) GetURL(shortcut string) (string, error) {
var shortcutVars map[string]interface{}
var ok bool
if shortcutVars, ok = s.Substitutions[shortcut]; !ok {
Expand All @@ -64,6 +64,14 @@ func (s ServiceConfig) GetURL(shortcut string) (string, error) {
}
}

func (s *ServiceConfig) Shortcuts() []string {
shortcuts := make([]string, 0, len(s.Substitutions))
for c, _ := range s.Substitutions {
shortcuts = append(shortcuts, c)
}
return shortcuts
}

func ReadConfig(path string) (Config, error) {
file, err := os.Open(path)
if err != nil {
Expand Down
12 changes: 3 additions & 9 deletions filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package dawg

import (
"fmt"
"sort"
"strconv"
"strings"
)

func Filter(c Config, svc, pat string) (AlfredOutput, error) {
Expand All @@ -14,11 +12,9 @@ func Filter(c Config, svc, pat string) (AlfredOutput, error) {
}

alfredOut := make(AlfredOutput, 0, 10)
for shortcut, _ := range serviceConfig.Substitutions {
matchPos := strings.Index(shortcut, pat)
if matchPos == -1 {
continue
}

filteredShortcuts := FilterChoices(serviceConfig.Shortcuts(), pat)
for _, shortcut := range filteredShortcuts {
url, err := serviceConfig.GetURL(shortcut)
if err != nil {
return AlfredOutput{}, err
Expand All @@ -29,10 +25,8 @@ func Filter(c Config, svc, pat string) (AlfredOutput, error) {
Autocomplete: shortcut,
Title: shortcut,
Arg: unquotedURL,
Pos: matchPos,
Icon: fmt.Sprintf("./%s.png", svc),
})
}
sort.Sort(alfredOut)
return alfredOut, nil
}
120 changes: 120 additions & 0 deletions fuzz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package dawg

import (
"bytes"
"errors"
"math"
"sort"
"strings"
)

func FilterChoices(choices []string, query string) []string {
scoredChoices := NewScoredChoices(choices, query)
sort.Sort(sort.Reverse(scoredChoices))
var filtered []string

for i, m := range scoredChoices.Choices {
if scoredChoices.Scores[i] > 0.0 {
filtered = append(filtered, m)
}
}

return filtered
}

type ScoredChoices struct {
Choices []string
Scores []float64
Query string
}

func NewScoredChoices(choices []string, query string) ScoredChoices {
scores := make([]float64, len(choices))

for i, c := range choices {
scores[i] = score(c, query)
}

return ScoredChoices{Choices: choices, Scores: scores, Query: query}
}

func (a ScoredChoices) Len() int { return len(a.Choices) }
func (a ScoredChoices) Less(i, j int) bool { return a.Scores[i] < a.Scores[j] }
func (a ScoredChoices) Swap(i, j int) {
a.Choices[i], a.Choices[j] = a.Choices[j], a.Choices[i]
a.Scores[i], a.Scores[j] = a.Scores[j], a.Scores[i]
}

func score(choice, query string) float64 {
if len(query) == 0 {
return 1.0
}

if len(choice) == 0 {
return 0.0
}

choice = strings.ToLower(choice)
query = strings.ToLower(query)

matchLength, err := computeMatchLength(choice, query)
if err != nil {
return 0.0
}

score := float64(len(query)) / float64(matchLength)
normalizationFactor := float64(len(query)) / float64(len(choice))
normalizedScore := score * normalizationFactor
return normalizedScore
}

func computeMatchLength(str, chars string) (int, error) {
runes := []rune(chars)
firstChar := runes[0]
restChars := string(runes[1:])

firstIndexes := findCharInString(firstChar, str)

matchLength := math.MaxInt32

for _, i := range firstIndexes {
lastIndex := findEndOfMatch(str, restChars, i)
if lastIndex != -1 {
newMatchLength := lastIndex - i + 1
if matchLength > newMatchLength {
matchLength = newMatchLength
}
}
}

if matchLength == math.MaxInt32 {
return -1, errors.New("did not match")
}
return matchLength, nil
}

func findCharInString(chr rune, str string) []int {
indexes := []int{}

for i, cur := range []rune(str) {
if chr == cur {
indexes = append(indexes, i)
}
}

return indexes
}

func findEndOfMatch(str, chars string, firstIndex int) int {
lastIndex := firstIndex
byteStr := []byte(str)
for _, chr := range chars {
i := bytes.IndexRune(byteStr[(lastIndex+1):], chr)
if i == -1 {
return -1
}
lastIndex += i
}

return lastIndex + 1
}
27 changes: 27 additions & 0 deletions fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dawg

import "testing"

var epsilon = 0.001

func TestFuzz(t *testing.T) {
tests := []struct {
query string
choices []string
}{
{"api", []string{"api", "my-api", "a-phrase-that-includes-all-letters-in-order", "angular-tooltips"}},
{"map", []string{"my-api", "api"}},
{"open", []string{"Open", "my-openthingy"}},
}

for _, tt := range tests {
for i := 0; i < len(tt.choices)-1; i++ {
choice0, choice1 := tt.choices[i], tt.choices[i+1]
score0 := score(choice0, tt.query)
score1 := score(choice1, tt.query)
if score0 < score1 {
t.Errorf("expected score for choice %s (%f) to be greater than score for choice %s (%f) for query %s", choice0, score0, choice1, score1, tt.query)
}
}
}
}

0 comments on commit 6afac93

Please sign in to comment.