From c6dd079aec60e29c8c21f055cc8523eef0d3b809 Mon Sep 17 00:00:00 2001 From: Sandro Heinzelmann Date: Mon, 18 Oct 2021 23:36:27 +0200 Subject: [PATCH] Use vscode fuzzy search algorithm --- fuzzy/fuzzy.go | 136 +++++++++++++ fuzzy/fuzzy_test.go | 35 ++++ fuzzy/vscode_fuzzy.go | 380 +++++++++++++++++++++++++++++++++++++ fuzzy/vscode_fuzzy_test.go | 67 +++++++ go.mod | 2 - go.sum | 4 - screenshot.png | Bin 14153 -> 12882 bytes ui/searchWidget.go | 5 +- util/util.go | 139 -------------- util/util_test.go | 28 --- 10 files changed, 621 insertions(+), 175 deletions(-) create mode 100644 fuzzy/fuzzy.go create mode 100644 fuzzy/fuzzy_test.go create mode 100644 fuzzy/vscode_fuzzy.go create mode 100644 fuzzy/vscode_fuzzy_test.go diff --git a/fuzzy/fuzzy.go b/fuzzy/fuzzy.go new file mode 100644 index 0000000..3c738c6 --- /dev/null +++ b/fuzzy/fuzzy.go @@ -0,0 +1,136 @@ +package fuzzy + +import "sort" + +// MatchRange describes the start and end index of a match in the target string. +type MatchRange struct { + Start int + End int +} + +// Match describes a fuzzy search match. +type Match struct { + // The matched string. + Str string + // The index of the matched string in the supplied slice. + Index int + // The indexes of matched ranges. Useful for highlighting matches. + MatchedRanges []MatchRange + // Score used to rank matches + Score int +} + +// MultiMatch describes a match in one or both target lists by SearchFuzzyMulti. +type MultiMatch struct { + Index int + Score int + Match1 Match + Match2 Match +} + +type byIndex []Match +type byMultiScore []MultiMatch + +func (a byIndex) Len() int { return len(a) } +func (a byIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a byIndex) Less(i, j int) bool { return a[i].Index < a[j].Index } + +func (a byMultiScore) Len() int { return len(a) } +func (a byMultiScore) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a byMultiScore) Less(i, j int) bool { return a[i].Score >= a[j].Score } + +// SearchFuzzyMulti searches for source in multiple target lists using a fuzzy +// algorithm. Matches with the same index in both targets are merged. If not, a resulting +// match may only contain a match in the first or second target list. +// This is meant to be used to search a list of objects where multiple fields of an object +// should be searched. +// The result is ordered from best to worst fitting match. +func SearchFuzzyMulti(source string, targets1 []string, targets2 []string) []MultiMatch { + NullMatch := Match{Index: -1} + + matches1 := SearchFuzzy(source, targets1) + matches2 := SearchFuzzy(source, targets2) + sort.Stable(byIndex(matches1)) + sort.Stable(byIndex(matches2)) + var combined []MultiMatch + + k2 := 0 + for _, m1 := range matches1 { + for k2 < len(matches2) && matches2[k2].Index < m1.Index { + addMultiMatch(&combined, NullMatch, matches2[k2]) + k2++ + } + + if k2 < len(matches2) && matches2[k2].Index == m1.Index { + addMultiMatch(&combined, m1, matches2[k2]) + k2++ + } else { + addMultiMatch(&combined, m1, NullMatch) + } + } + + for ; k2 < len(matches2); k2++ { + addMultiMatch(&combined, NullMatch, matches2[k2]) + } + + sort.Stable(byMultiScore(combined)) + return combined +} + +func addMultiMatch(combined *[]MultiMatch, m1 Match, m2 Match) { + index := 0 + score := 0 + if m1.Index > -1 { + index = m1.Index + score += m1.Score + } + if m2.Index > -1 { + index = m2.Index + score += m2.Score + } + *combined = append(*combined, MultiMatch{ + Index: index, + Match1: m1, + Match2: m2, + Score: score, + }) +} + +// SearchFuzzy searches for source in the list of targets using a fuzzy +// algorithm. The result is ordered from best to worst fitting match. +func SearchFuzzy(source string, targets []string) []Match { + if source == "" { + var res []Match + for i, t := range targets { + res = append(res, Match{ + Str: t, + Index: i, + Score: 0, + }) + } + return res + } + + return FindFuzzy(source, targets) +} + +func mergeMatchPositions(positions []int) []MatchRange { + var ranges []MatchRange + var cur *MatchRange + for _, i := range positions { + if cur == nil { + cur = &MatchRange{Start: i, End: i} + } else { + if i == cur.End+1 { + cur.End = i + } else { + ranges = append(ranges, *cur) + cur = &MatchRange{Start: i, End: i} + } + } + } + if cur != nil { + ranges = append(ranges, *cur) + } + return ranges +} diff --git a/fuzzy/fuzzy_test.go b/fuzzy/fuzzy_test.go new file mode 100644 index 0000000..689c0f0 --- /dev/null +++ b/fuzzy/fuzzy_test.go @@ -0,0 +1,35 @@ +package fuzzy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSearchFuzzy(t *testing.T) { + cases := []struct { + source string + targets []string + expected []string + }{ + { + "cert", + []string{"docker bash: docker exec -ti container bash", "openssl view cert: openssl x509 -text -noout -in"}, + []string{"openssl view cert: openssl x509 -text -noout -in", "docker bash: docker exec -ti container bash"}, + }, + { + "", + []string{"banana", "apple", "pear"}, + []string{"banana", "apple", "pear"}, + }, + } + + for _, c := range cases { + ranked := SearchFuzzy(c.source, c.targets) + var actual []string + for _, r := range ranked { + actual = append(actual, r.Str) + } + assert.Equal(t, c.expected, actual) + } +} diff --git a/fuzzy/vscode_fuzzy.go b/fuzzy/vscode_fuzzy.go new file mode 100644 index 0000000..db014a8 --- /dev/null +++ b/fuzzy/vscode_fuzzy.go @@ -0,0 +1,380 @@ +package fuzzy + +/* + Most of this code is taken and translated to Golang from the vscode fuzzyScorer. + All credit and admiration to the original authors. + + The original license notice from vscode: + + MIT License + + Copyright (c) 2015 - present Microsoft Corporation + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +import ( + "sort" + "strings" +) + +const noMatch = 0 +const maxTargetLen = 512 + +type fuzzyScore struct { + Score int + MatchPositions []int +} + +type fuzzyScoreWithRanges struct { + Score int + MatchRanges []MatchRange +} + +type byScore []Match + +func (a byScore) Len() int { return len(a) } +func (a byScore) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a byScore) Less(i, j int) bool { return a[i].Score >= a[j].Score } + +type byRangeStart []MatchRange + +func (a byRangeStart) Len() int { return len(a) } +func (a byRangeStart) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a byRangeStart) Less(i, j int) bool { return a[i].Start <= a[j].Start } + +// FindFuzzy finds targets that fuzzily match the query. +func FindFuzzy(query string, targets []string) []Match { + var matches []Match + query = strings.TrimSpace(query) + queryLower := strings.ToLower(query) + for i, t := range targets { + score := scoreFuzzySingleOrMultiple(t, query, queryLower, true) + if score.Score > 0 { + m := Match{Str: t, Index: i, Score: score.Score, MatchedRanges: score.MatchRanges} + matches = append(matches, m) + } + } + sort.Stable(byScore(matches)) + return matches +} + +func scoreFuzzySingleOrMultiple(target string, query string, queryLower string, allowNonContiguousMatches bool) fuzzyScoreWithRanges { + queryParts := strings.Split(query, " ") + if len(queryParts) > 1 { + queryPartsLower := strings.Split(queryLower, " ") + return scoreFuzzyMultiple(target, queryParts, queryPartsLower, allowNonContiguousMatches) + } + + return scoreFuzzySingle(target, query, queryLower, allowNonContiguousMatches) +} + +func scoreFuzzyMultiple(target string, queryParts []string, queryPartsLower []string, allowNonContiguousMatches bool) fuzzyScoreWithRanges { + noScore := fuzzyScoreWithRanges{Score: 0} + var scores []int + var matchRanges []MatchRange + for i, q := range queryParts { + score := scoreFuzzySingle(target, q, queryPartsLower[i], allowNonContiguousMatches) + if score.Score == noMatch { + // if a single query value does not match, return with + // no score entirely, we require all queries to match + return noScore + } + scores = append(scores, score.Score) + matchRanges = append(matchRanges, score.MatchRanges...) + } + + mergedScore := fuzzyScoreWithRanges{Score: 0} + for _, s := range scores { + mergedScore.Score += s + } + mergedScore.MatchRanges = normalizeMatchRanges(matchRanges) + + return mergedScore +} + +func scoreFuzzySingle(target string, query string, queryLower string, allowNonContiguousMatches bool) fuzzyScoreWithRanges { + score := scoreFuzzy(target, query, queryLower, allowNonContiguousMatches) + return fuzzyScoreWithRanges{Score: score.Score, MatchRanges: mergeMatchPositions(score.MatchPositions)} +} + +func scoreFuzzy(target string, query string, queryLower string, allowNonContiguousMatches bool) fuzzyScore { + noScore := fuzzyScore{0, []int{}} + + if target == "" || query == "" { + return noScore // return early if target or query are undefined + } + + targetLength := minInt(maxTargetLen, len(target)) + queryLength := len(query) + + if targetLength < queryLength { + return noScore // impossible for query to be contained in target + } + + targetLower := strings.ToLower(target) + res := doScoreFuzzy(query, queryLower, queryLength, target, targetLower, targetLength, allowNonContiguousMatches) + + return res +} + +func doScoreFuzzy(query string, queryLower string, queryLength int, target string, targetLower string, targetLength int, allowNonContiguousMatches bool) fuzzyScore { + scores := make([]int, queryLength*targetLength) + matches := make([]int, queryLength*targetLength) + + // + // Build Scorer Matrix: + // + // The matrix is composed of query q and target t. For each index we score + // q[i] with t[i] and compare that with the previous score. If the score is + // equal or larger, we keep the match. In addition to the score, we also keep + // the length of the consecutive matches to use as boost for the score. + // + // t a r g e t + // q + // u + // e + // r + // y + // + for queryIndex := 0; queryIndex < queryLength; queryIndex++ { + queryIndexOffset := queryIndex * targetLength + queryIndexPreviousOffset := queryIndexOffset - targetLength + + queryIndexGtNull := queryIndex > 0 + + queryCharAtIndex := query[queryIndex] + queryLowerCharAtIndex := queryLower[queryIndex] + + for targetIndex := 0; targetIndex < targetLength; targetIndex++ { + targetIndexGtNull := targetIndex > 0 + + currentIndex := queryIndexOffset + targetIndex + leftIndex := currentIndex - 1 + diagIndex := queryIndexPreviousOffset + targetIndex - 1 + + leftScore := 0 + if targetIndexGtNull { + leftScore = scores[leftIndex] + } + diagScore := 0 + if queryIndexGtNull && targetIndexGtNull { + diagScore = scores[diagIndex] + } + + matchesSequenceLength := 0 + if queryIndexGtNull && targetIndexGtNull { + matchesSequenceLength = matches[diagIndex] + } + + // If we are not matching on the first query character any more, we only produce a + // score if we had a score previously for the last query index (by looking at the diagScore). + // This makes sure that the query always matches in sequence on the target. For example + // given a target of "ede" and a query of "de", we would otherwise produce a wrong high score + // for query[1] ("e") matching on target[0] ("e") because of the "beginning of word" boost. + var score int + if diagScore == 0 && queryIndexGtNull { + score = 0 + } else { + score = computeCharScore(queryCharAtIndex, queryLowerCharAtIndex, target, targetLower, targetIndex, matchesSequenceLength) + } + + // We have a score and its equal or larger than the left score + // Match: sequence continues growing from previous diag value + // Score: increases by diag score value + isValidScore := score > 0 && diagScore+score >= leftScore + if isValidScore && ( + // We don't need to check if it's contiguous if we allow non-contiguous matches + allowNonContiguousMatches || + // We must be looking for a contiguous match. + // Looking at an index higher than 0 in the query means we must have already + // found out this is contiguous otherwise there wouldn't have been a score + queryIndexGtNull || + // lastly check if the query is completely contiguous at this index in the target + strings.HasPrefix(targetLower[targetIndex:], queryLower)) { + matches[currentIndex] = matchesSequenceLength + 1 + scores[currentIndex] = diagScore + score + } else { + // We either have no score or the score is lower than the left score + // Match: reset to 0 + // Score: pick up from left hand side + matches[currentIndex] = noMatch + scores[currentIndex] = leftScore + } + } + } + + // Restore Positions (starting from bottom right of matrix) + var positions []int + queryIndex := queryLength - 1 + targetIndex := targetLength - 1 + for queryIndex >= 0 && targetIndex >= 0 { + currentIndex := queryIndex*targetLength + targetIndex + match := matches[currentIndex] + if match == noMatch { + targetIndex-- // go left + } else { + positions = append(positions, targetIndex) + + // go up and left + queryIndex-- + targetIndex-- + } + } + + reverse(positions) + return fuzzyScore{scores[queryLength*targetLength-1], positions} +} + +func computeCharScore(queryCharAtIndex byte, queryLowerCharAtIndex byte, target string, targetLower string, targetIndex int, matchesSequenceLength int) int { + score := 0 + + if !considerAsEqual(queryLowerCharAtIndex, targetLower[targetIndex]) { + return score // no match of characters + } + + // Character match bonus + score++ + + // Consecutive match bonus + if matchesSequenceLength > 0 { + score += (matchesSequenceLength * 5) + } + + // Same case bonus + if queryCharAtIndex == target[targetIndex] { + score++ + } + + // Start of word bonus + if targetIndex == 0 { + score += 8 + } else { + + // After separator bonus + separatorBonus := scoreSeparatorAtPos(target[targetIndex-1]) + if separatorBonus > 0 { + score += separatorBonus + } else if isUpper(target[targetIndex]) { + // Inside word upper case bonus (camel case) + score += 2 + } + } + + return score +} + +func considerAsEqual(a byte, b byte) bool { + if a == b { + return true + } + + // Special case path separators: ignore platform differences + if a == '/' || a == '\\' { + return b == '/' || b == '\\' + } + + return false +} + +func scoreSeparatorAtPos(charCode byte) int { + switch charCode { + case '/': + fallthrough + case '\\': + return 5 // prefer path separators... + case '_': + fallthrough + case '-': + fallthrough + case '.': + fallthrough + case ' ': + fallthrough + case '\'': + fallthrough + case '"': + fallthrough + case ':': + return 4 // ...over other separators + } + return 0 +} + +func isUpper(code byte) bool { + return 'A' <= code && code <= 'Z' +} + +func reverse(s []int) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + +func normalizeMatchRanges(matches []MatchRange) []MatchRange { + + // sort matches by start to be able to normalize + sort.Stable(byRangeStart(matches)) + + // merge matches that overlap + var normalizedMatches []MatchRange + var currentMatch *MatchRange + for _, match := range matches { + + // if we have no current match or the matches + // do not overlap, we take it as is and remember + // it for future merging + if currentMatch == nil || !matchOverlaps(currentMatch, &match) { + normalizedMatches = append(normalizedMatches, match) + currentMatch = &normalizedMatches[len(normalizedMatches)-1] + } else { + // otherwise we merge the matches + currentMatch.Start = minInt(currentMatch.Start, match.Start) + currentMatch.End = maxInt(currentMatch.End, match.End) + } + } + + return normalizedMatches +} + +func matchOverlaps(matchA *MatchRange, matchB *MatchRange) bool { + if matchA.End < matchB.Start { + return false // A ends before B starts + } + + if matchB.End < matchA.Start { + return false // B ends before A starts + } + + return true +} + +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a int, b int) int { + if a >= b { + return a + } + return b +} diff --git a/fuzzy/vscode_fuzzy_test.go b/fuzzy/vscode_fuzzy_test.go new file mode 100644 index 0000000..7fde523 --- /dev/null +++ b/fuzzy/vscode_fuzzy_test.go @@ -0,0 +1,67 @@ +package fuzzy + +/* + Most of this code is taken and translated to Golang from the vscode fuzzyScorer. + All credit and admiration to the original authors. + + The original license notice from vscode: + + MIT License + + Copyright (c) 2015 - present Microsoft Corporation + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestScoreFuzzy(t *testing.T) { + target := "HeLlo-World" + + scores := [...]fuzzyScore{ + _doScore(target, "HelLo-World", true), // direct case match + _doScore(target, "hello-world", true), // direct mix-case match + _doScore(target, "HW", true), // direct case prefix (multiple) + _doScore(target, "hw", true), // direct mix-case prefix (multiple) + _doScore(target, "H", true), // direct case prefix + _doScore(target, "h", true), // direct mix-case prefix + _doScore(target, "W", true), // direct case word prefix + _doScore(target, "Ld", true), // in-string case match (multiple) + _doScore(target, "ld", true), // in-string mix-case match (consecutive, avoids scattered hit) + _doScore(target, "w", true), // direct mix-case word prefix + _doScore(target, "L", true), // in-string case match + _doScore(target, "l", true), // in-string mix-case match + _doScore(target, "4", true), // no match + } + + lastScore := 100000 + for _, s := range scores { + assert.LessOrEqual(t, s.Score, lastScore) + lastScore = s.Score + } +} + +func _doScore(target string, query string, allowNonContiguousMatches bool) fuzzyScore { + return scoreFuzzy(target, query, strings.ToLower(query), allowNonContiguousMatches) +} diff --git a/go.mod b/go.mod index 5042b30..bb1e57e 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,7 @@ require ( fyne.io/fyne/v2 v2.1.0 github.com/fsnotify/fsnotify v1.4.9 github.com/go-vgo/robotgo v0.93.1 - github.com/kylelemons/godebug v1.1.0 // indirect github.com/robotn/gohook v0.30.6 - github.com/sahilm/fuzzy v0.1.0 github.com/sosedoff/ansible-vault-go v0.0.0-20201201002713-782dc5c40224 github.com/stretchr/testify v1.5.1 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 diff --git a/go.sum b/go.sum index d491da2..f4e9757 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,6 @@ github.com/josephspurrier/goversioninfo v0.0.0-20200309025242-14b0ab84c6ca/go.mo github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucor/goinfo v0.0.0-20210802170112-c078a2b0f08b/go.mod h1:PRq09yoB+Q2OJReAmwzKivcYyremnibWGbK7WfftHzc= github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= @@ -56,8 +54,6 @@ github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934/go.mod h1:SxQhJskUJ4rle github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770 h1:2uX8QRLkkxn2EpAQ6I3KhA79BkdRZfvugJUzJadiJwk= github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770/go.mod h1:svkDXUDQjUiWzLrA0OZgHc4lbOts3C+uRfP6/yjwYnU= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= -github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sandro-h/robotgo v0.99.0-linuxfix3 h1:byt4mpdZEp4/2f3Kpiw4IZv5pGVSru9CASzl3tzrHoc= github.com/sandro-h/robotgo v0.99.0-linuxfix3/go.mod h1:0+i2QWRmZtbIF02RwmiGfFj33Judprukd8ls5J6Eajg= github.com/shirou/gopsutil v3.21.5+incompatible h1:OloQyEerMi7JUrXiNzy8wQ5XN+baemxSl12QgIzt0jc= diff --git a/screenshot.png b/screenshot.png index 0aee2b4c42ea0ef85c064a0ee3fb4e3cce970453..3912b924c7c34eb3cc5207c4f4cd4f9aabdbd1bd 100644 GIT binary patch literal 12882 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 CTX-M< literal 14153 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 jJudbCKl*>R0wme=5W;XW1 -1 { segments = make([]snippetSegment, 0) diff --git a/util/util.go b/util/util.go index 08bb393..02b387e 100644 --- a/util/util.go +++ b/util/util.go @@ -1,11 +1,8 @@ package util import ( - "sort" "strings" "time" - - "github.com/sahilm/fuzzy" ) // SplitSpecials splits the string according to the individual special characters @@ -37,142 +34,6 @@ func SplitSpecials(str string, specials string) []string { return parts } -// MatchRange describes the start and end index of a match in the target string. -type MatchRange struct { - Start int - End int -} - -// Match describes a fuzzy search match. -type Match struct { - // The matched string. - Str string - // The index of the matched string in the supplied slice. - Index int - // The indexes of matched ranges. Useful for highlighting matches. - MatchedRanges []MatchRange - // Score used to rank matches - Score int -} - -// MultiMatch describes a match in one or both target lists by SearchFuzzyMulti. -type MultiMatch struct { - Index int - Score int - Match1 Match - Match2 Match -} - -type byIndex []Match -type byMultiScore []MultiMatch - -func (a byIndex) Len() int { return len(a) } -func (a byIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a byIndex) Less(i, j int) bool { return a[i].Index < a[j].Index } - -func (a byMultiScore) Len() int { return len(a) } -func (a byMultiScore) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a byMultiScore) Less(i, j int) bool { return a[i].Score >= a[j].Score } - -// SearchFuzzyMulti searches for source in multiple target lists using a fuzzy -// algorithm. Matches with the same index in both targets are merged. If not, a resulting -// match may only contain a match in the first or second target list. -// This is meant to be used to search a list of objects where multiple fields of an object -// should be searched. -// The result is ordered from best to worst fitting match. -func SearchFuzzyMulti(source string, targets1 []string, targets2 []string) []MultiMatch { - NullMatch := Match{Index: -1} - - matches1 := SearchFuzzy(source, targets1) - matches2 := SearchFuzzy(source, targets2) - sort.Stable(byIndex(matches1)) - sort.Stable(byIndex(matches2)) - var combined []MultiMatch - - k2 := 0 - for _, m1 := range matches1 { - for k2 < len(matches2) && matches2[k2].Index < m1.Index { - addMultiMatch(&combined, NullMatch, matches2[k2]) - k2++ - } - - if k2 < len(matches2) && matches2[k2].Index == m1.Index { - addMultiMatch(&combined, m1, matches2[k2]) - k2++ - } else { - addMultiMatch(&combined, m1, NullMatch) - } - } - - for ; k2 < len(matches2); k2++ { - addMultiMatch(&combined, NullMatch, matches2[k2]) - } - - sort.Stable(byMultiScore(combined)) - return combined -} - -func addMultiMatch(combined *[]MultiMatch, m1 Match, m2 Match) { - index := 0 - score := 0 - if m1.Index > -1 { - index = m1.Index - score += m1.Score - } - if m2.Index > -1 { - index = m2.Index - score += m2.Score - } - *combined = append(*combined, MultiMatch{ - Index: index, - Match1: m1, - Match2: m2, - Score: score, - }) -} - -// SearchFuzzy searches for source in the list of targets using a fuzzy -// algorithm. The result is ordered from best to worst fitting match. -func SearchFuzzy(source string, targets []string) []Match { - if source == "" { - var res []Match - for i, t := range targets { - res = append(res, Match{ - Str: t, - Index: i, - Score: 0, - }) - } - return res - } - - var res []Match - fuzzyRes := fuzzy.Find(source, targets) - for _, r := range fuzzyRes { - var ranges []MatchRange - var cur *MatchRange - for _, i := range r.MatchedIndexes { - if cur == nil { - cur = &MatchRange{Start: i, End: i} - } else { - if i == cur.End+1 { - cur.End = i - } else { - ranges = append(ranges, *cur) - cur = &MatchRange{Start: i, End: i} - } - } - } - if cur != nil { - ranges = append(ranges, *cur) - } - - res = append(res, Match{Str: r.Str, Index: r.Index, Score: r.Score, MatchedRanges: ranges}) - } - - return res -} - // MinDur returns the smaller of two durations. func MinDur(d1 time.Duration, d2 time.Duration) time.Duration { if d1 < d2 { diff --git a/util/util_test.go b/util/util_test.go index 30dbd2e..7557ffc 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -38,31 +38,3 @@ func TestSplitSpecialNoList(t *testing.T) { actual := SplitSpecials("hello/world", "") assert.Equal(t, []string{"hello/world"}, actual) } - -func TestSearchFuzzy(t *testing.T) { - cases := []struct { - source string - targets []string - expected []string - }{ - { - "cert", - []string{"docker bash: docker exec -ti container bash", "openssl view cert: openssl x509 -text -noout -in"}, - []string{"openssl view cert: openssl x509 -text -noout -in", "docker bash: docker exec -ti container bash"}, - }, - { - "", - []string{"banana", "apple", "pear"}, - []string{"banana", "apple", "pear"}, - }, - } - - for _, c := range cases { - ranked := SearchFuzzy(c.source, c.targets) - var actual []string - for _, r := range ranked { - actual = append(actual, r.Str) - } - assert.Equal(t, c.expected, actual) - } -}