Skip to content

Commit c05f764

Browse files
committed
feat: add fuzzy suggestion mode
Signed-off-by: y-rabie <[email protected]>
1 parent 67012e0 commit c05f764

File tree

9 files changed

+107
-23
lines changed

9 files changed

+107
-23
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ You can now override the context portForward default address configuration by se
463463
showTime: false
464464
# Define how suggestions are made.
465465
# "PREFIX": typed text is considered a prefix, sorts suggestions alphabetically. Default mode.
466+
# "FUZZY": typed text is fuzzy-matched against suggestions, sorted by Levenshtein distance (but only character additions are considered as possible string edits).
466467
# "LONGEST_PREFIX": typed text is considered a prefix, sorts suggestions by how much of the typed text they match; if equal, it falls back to alphabetical order.
467468
# "LONGEST_SUBSTRING": typed text is considered a substring, sorts suggestions by how much of the typed text they match; if equal, it falls back to alphabetical order.
468469
suggestionMode: PREFIX

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ func initK9sFlags() {
261261
k9sFlags.SuggestionMode,
262262
"suggestion-mode",
263263
string(config.DefaultSuggestionMode),
264-
"Set autocompletion suggestion mode (PREFIX, LONGEST_PREFIX, LONGEST_SUBSTRING)",
264+
"Set autocompletion suggestion mode (PREFIX, FUZZY, LONGEST_PREFIX, LONGEST_SUBSTRING)",
265265
)
266266
rootCmd.Flags()
267267
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/fvbommel/sortorder v1.1.0
1717
github.com/go-errors/errors v1.5.1
1818
github.com/itchyny/gojq v0.12.17
19+
github.com/lithammer/fuzzysearch v1.1.8
1920
github.com/lmittmann/tint v1.0.7
2021
github.com/mattn/go-colorable v0.1.14
2122
github.com/mattn/go-runewidth v0.0.16

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
13721372
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
13731373
github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
13741374
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
1375+
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
1376+
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
13751377
github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y=
13761378
github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
13771379
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=

internal/config/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type SuggestionMode string
1818

1919
const (
2020
SuggestionModePrefix SuggestionMode = "PREFIX"
21+
SuggestionModeFuzzy SuggestionMode = "FUZZY"
2122
SuggestionModeLongestPrefix SuggestionMode = "LONGEST_PREFIX"
2223
SuggestionModeLongestSubstring SuggestionMode = "LONGEST_SUBSTRING"
2324
)

internal/ui/prompt.go

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,37 @@ func (p *Prompt) update(text, suggestion string) {
234234
p.write(text, suggestion)
235235
}
236236

237+
func (p *Prompt) getSuggestionCharIdxRanges(lastWord, suggest string) [][]int {
238+
switch p.app.Config.K9s.SuggestionMode {
239+
case string(config.SuggestionModeFuzzy):
240+
idxRanges := make([][]int, 0, len(lastWord))
241+
latestIdx := -1
242+
for _, char := range lastWord {
243+
// Find the character within the suggest, but only starting after where the last character was found.
244+
foundIdx := strings.IndexRune(suggest[latestIdx+1:], char)
245+
// This should never happen if we reached here, but just guarding.
246+
if foundIdx == -1 {
247+
return nil
248+
}
249+
// Offset by the cutoff length of the string from previous iterations.
250+
foundIdx += latestIdx + 1
251+
idxRanges = append(idxRanges, []int{foundIdx, foundIdx + 1})
252+
latestIdx = foundIdx
253+
}
254+
return idxRanges
255+
default:
256+
// Get indices of where the last word of text lie within the suggestion.
257+
// Could be anywhere if LONGEST_SUBSTRING mode suggestion is used.
258+
// Otherwise at 0 for PREFIX/LONGEST_PREFIX suggestion modes.
259+
startIdx := strings.Index(suggest, lastWord)
260+
endIdx := startIdx + len(lastWord)
261+
if startIdx == -1 {
262+
return nil
263+
}
264+
return [][]int{{startIdx, endIdx}}
265+
}
266+
}
267+
237268
func (p *Prompt) write(text, suggest string) {
238269
p.mx.Lock()
239270
defer p.mx.Unlock()
@@ -250,20 +281,19 @@ func (p *Prompt) write(text, suggest string) {
250281
written = strings.Join(words[0:len(words)-1], " ") + " "
251282
lastWord := words[len(words)-1]
252283

253-
// Get indices of where the last word of text lie within the suggestion.
254-
// Could be anywhere if LONGEST_SUBSTRING mode suggestion is used.
255-
// Otherwise at 0 for PREFIX/LONGEST_PREFIX suggestion modes.
256-
startIdx := strings.Index(suggest, lastWord)
257-
endIdx := startIdx + len(lastWord)
258-
259-
if startIdx != -1 {
260-
written += fmt.Sprintf("[%s::-]%s[%s::-]%s[%s::-]%s",
261-
p.styles.Prompt().SuggestColor, suggest[0:startIdx],
262-
p.styles.Prompt().FgColor, suggest[startIdx:endIdx],
263-
p.styles.Prompt().SuggestColor, suggest[endIdx:],
264-
)
284+
if idxRanges := p.getSuggestionCharIdxRanges(lastWord, suggest); idxRanges != nil {
285+
fmt.Println(idxRanges)
286+
written += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().SuggestColor, suggest[0:idxRanges[0][0]])
287+
for i, idxRange := range idxRanges {
288+
if i > 0 {
289+
written += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().SuggestColor, suggest[idxRanges[i-1][1]:idxRanges[i][0]])
290+
}
291+
written += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().FgColor, suggest[idxRange[0]:idxRange[1]])
292+
}
293+
written += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().SuggestColor, suggest[idxRanges[len(idxRanges)-1][1]:])
265294
} else {
266-
// `lastWord` isn't found within suggest. Just append `lastWord` again.
295+
// `lastWord` isn't found within suggest as per the configured
296+
// `SuggestionMode`. Just append `lastWord` again.
267297
written += lastWord
268298
}
269299

internal/view/app.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,9 @@ func (a *App) suggestCommand() model.SuggestionFunc {
221221
entries = append(entries, k)
222222
}
223223

224-
// PREFIX suggestion mode:, all weights are set to 0, sort strings normally.
225-
// LONGEST_PREFIX,LONGEST_SUBSTRING suggestion modes: sort based on weights and fallback to string sorting for equal weights.
224+
// PREFIX suggestion mode: all weights are set to 0, sort strings normally.
225+
// FUZZY,LONGEST_PREFIX,LONGEST_SUBSTRING suggestion modes: sort based on weights
226+
// and fallback to string sorting for equal weights.
226227
sort.SliceStable(entries, func(i, j int) bool {
227228
if entriesMap[entries[i]] == entriesMap[entries[j]] {
228229
return entries[i] < entries[j]

internal/view/cmd/helpers.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"github.com/derailed/k9s/internal/client"
10+
"github.com/lithammer/fuzzysearch/fuzzy"
1011
)
1112

1213
func ToLabels(s string) map[string]string {
@@ -30,21 +31,25 @@ func ToLabels(s string) map[string]string {
3031

3132
// ShouldAddSuggest checks if a suggestion match the given command.
3233
func ShouldAddSuggest(suggestionMode, command, suggest string) float64 {
33-
var searchCondition bool
34+
var condition bool
3435
var weight float64 = 0
3536

3637
switch suggestionMode {
37-
case "LONGEST_SUBSTRING":
38-
searchCondition = strings.Contains(suggest, command)
39-
weight = float64(len(command)) / float64(len(suggest))
38+
case "FUZZY":
39+
// Weight is the inverse of the Levenshtein distance.
40+
weight = 1 / float64(fuzzy.RankMatch(command, suggest))
41+
condition = (weight != -1)
4042
case "LONGEST_PREFIX":
41-
searchCondition = strings.HasPrefix(suggest, command)
43+
condition = strings.HasPrefix(suggest, command)
44+
weight = float64(len(command)) / float64(len(suggest))
45+
case "LONGEST_SUBSTRING":
46+
condition = strings.Contains(suggest, command)
4247
weight = float64(len(command)) / float64(len(suggest))
4348
default:
44-
searchCondition = strings.HasPrefix(suggest, command)
49+
condition = strings.HasPrefix(suggest, command)
4550
}
4651

47-
if command != suggest && searchCondition {
52+
if condition {
4853
return weight
4954
}
5055

internal/view/cmd/helpers_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,49 @@ func TestSuggestSubCommandPrefix(t *testing.T) {
9393
}
9494
}
9595

96+
func TestSuggestSubCommandFuzzy(t *testing.T) {
97+
namespaceNames := map[string]struct{}{
98+
"kube-system": {},
99+
"kube-public": {},
100+
"default": {},
101+
"nginx-ingress": {},
102+
}
103+
contextNames := []string{"develop", "test", "pre", "prod"}
104+
105+
// The weights are: 1 / distance.
106+
// Distance is the number of needed character additions.
107+
tests := []struct {
108+
Command string
109+
Suggestions map[string]float64
110+
}{
111+
{Command: "q", Suggestions: nil},
112+
{Command: "xray dp", Suggestions: nil},
113+
{Command: "help k", Suggestions: nil},
114+
{Command: "ctx p", Suggestions: map[string]float64{"develop": 1.0 / 6.0, "pre": 1.0 / 2.0, "prod": 1.0 / 3.0}},
115+
{Command: "ctx p", Suggestions: map[string]float64{"develop": 1.0 / 6.0, "pre": 1.0 / 2.0, "prod": 1.0 / 3.0}},
116+
{Command: "ctx pr", Suggestions: map[string]float64{"pre": 1.0, "prod": 1.0 / 2.0}},
117+
{Command: "ctx pr", Suggestions: map[string]float64{"pre": 1.0, "prod": 1.0 / 2.0}},
118+
{Command: "ctx", Suggestions: map[string]float64{" develop": 1.0 / 7.0, " pre": 1.0 / 3.0, " prod": 1.0 / 4.0, " test": 1.0 / 4.0}},
119+
{Command: "ctx ", Suggestions: map[string]float64{"develop": 1.0 / 7.0, "pre": 1.0 / 3.0, "prod": 1.0 / 4.0, "test": 1.0 / 4.0}},
120+
{Command: "context d", Suggestions: map[string]float64{"develop": 1.0 / 6.0, "prod": 1.0 / 3.0}},
121+
{Command: "contexts t", Suggestions: map[string]float64{"test": 1.0 / 3.0}},
122+
{Command: "po ", Suggestions: nil},
123+
{Command: "po x", Suggestions: map[string]float64{"nginx-ingress": 1.0 / 12.0}},
124+
{Command: "po k", Suggestions: map[string]float64{"kube-public": 1.0 / 10.0, "kube-system": 1.0 / 10.0}},
125+
{Command: "po kube-", Suggestions: map[string]float64{"kube-public": 1.0 / 6.0, "kube-system": 1.0 / 6.0}},
126+
// Fuzzy-specific testcases.
127+
{Command: "ctx pd", Suggestions: map[string]float64{"prod": 1.0 / 2.0}},
128+
{Command: "ctx dp", Suggestions: map[string]float64{"develop": 1.0 / 5.0}},
129+
{Command: "ctx tt", Suggestions: map[string]float64{"test": 1.0 / 2.0}},
130+
{Command: "po dlt", Suggestions: map[string]float64{"default": 1.0 / 4.0}},
131+
}
132+
133+
for _, tt := range tests {
134+
got := SuggestSubCommand("FUZZY", tt.Command, namespaceNames, contextNames)
135+
assert.Equal(t, tt.Suggestions, got)
136+
}
137+
}
138+
96139
func TestSuggestSubCommandLongestPrefix(t *testing.T) {
97140
namespaceNames := map[string]struct{}{
98141
"kube-system": {},

0 commit comments

Comments
 (0)