fix fuzzy matching not working properly (#2321)

* fix fuzzy matching not working properly

* delete continuousRanges
mine
Jayson Wang 2023-12-08 23:10:40 +08:00 committed by GitHub
parent e94f991ad4
commit d0ec55737a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 169 additions and 168 deletions

View File

@ -96,3 +96,17 @@ func serviceAccountMatches(podSA, saName string) bool {
} }
return podSA == saName return podSA == saName
} }
// ContinuousRanges takes a sorted slice of integers and returns a slice of
// sub-slices representing continuous ranges of integers.
func ContinuousRanges(indexes []int) [][]int {
var ranges [][]int
for i, p := 1, 0; i <= len(indexes); i++ {
if i == len(indexes) || indexes[i]-indexes[p] != i-p {
ranges = append(ranges, []int{indexes[p], indexes[i-1] + 1})
p = i
}
}
return ranges
}

View File

@ -60,3 +60,35 @@ func TestServiceAccountMatches(t *testing.T) {
assert.Equal(t, u.expect, serviceAccountMatches(u.podTemplate.ServiceAccountName, u.saName)) assert.Equal(t, u.expect, serviceAccountMatches(u.podTemplate.ServiceAccountName, u.saName))
} }
} }
func TestContinuousRanges(t *testing.T) {
tests := []struct {
Indexes []int
Ranges [][]int
}{
{
Indexes: []int{0},
Ranges: [][]int{{0, 1}},
},
{
Indexes: []int{1},
Ranges: [][]int{{1, 2}},
},
{
Indexes: []int{0, 1, 2},
Ranges: [][]int{{0, 3}},
},
{
Indexes: []int{4, 5, 6},
Ranges: [][]int{{4, 7}},
},
{
Indexes: []int{0, 2, 4, 5, 6},
Ranges: [][]int{{0, 1}, {2, 3}, {4, 7}},
},
}
for _, tt := range tests {
assert.Equal(t, tt.Ranges, ContinuousRanges(tt.Indexes))
}
}

View File

@ -7,7 +7,6 @@ import (
"context" "context"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -69,28 +68,13 @@ func (d *Describe) filter(q string, lines []string) fuzzy.Matches {
if dao.IsFuzzySelector(q) { if dao.IsFuzzySelector(q) {
return d.fuzzyFilter(strings.TrimSpace(q[2:]), lines) return d.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
} }
return d.rxFilter(q, lines) return rxFilter(q, lines)
} }
func (*Describe) fuzzyFilter(q string, lines []string) fuzzy.Matches { func (*Describe) fuzzyFilter(q string, lines []string) fuzzy.Matches {
return fuzzy.Find(q, lines) return fuzzy.Find(q, lines)
} }
func (*Describe) rxFilter(q string, lines []string) fuzzy.Matches {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil
}
matches := make(fuzzy.Matches, 0, len(lines))
for i, l := range lines {
if loc := rx.FindStringIndex(l); len(loc) == 2 {
matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc})
}
}
return matches
}
func (d *Describe) fireResourceChanged(lines []string, matches fuzzy.Matches) { func (d *Describe) fireResourceChanged(lines []string, matches fuzzy.Matches) {
for _, l := range d.listeners { for _, l := range d.listeners {
l.ResourceChanged(lines, matches) l.ResourceChanged(lines, matches)

View File

@ -5,11 +5,13 @@ package model
import ( import (
"context" "context"
"regexp"
"time" "time"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/derailed/tview" "github.com/derailed/tview"
runewidth "github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
"github.com/sahilm/fuzzy"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
@ -37,3 +39,25 @@ func NewExpBackOff(ctx context.Context, start, max time.Duration) backoff.BackOf
bf.InitialInterval, bf.MaxElapsedTime = start, max bf.InitialInterval, bf.MaxElapsedTime = start, max
return backoff.WithContext(bf, ctx) return backoff.WithContext(bf, ctx)
} }
func rxFilter(q string, lines []string) fuzzy.Matches {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil
}
matches := make(fuzzy.Matches, 0, len(lines))
for i, l := range lines {
locs := rx.FindAllStringIndex(l, -1)
for _, loc := range locs {
indexes := make([]int, 0, loc[1]-loc[0])
for v := loc[0]; v < loc[1]; v++ {
indexes = append(indexes, v)
}
matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: indexes})
}
}
return matches
}

View File

@ -4,13 +4,12 @@
package model package model
import ( import (
"testing"
"github.com/sahilm/fuzzy" "github.com/sahilm/fuzzy"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing"
) )
func TestYAML_rxFilter(t *testing.T) { func Test_rxFilter(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
q string q string
lines []string lines []string
@ -32,7 +31,7 @@ func TestYAML_rxFilter(t *testing.T) {
{ {
Str: "foo", Str: "foo",
Index: 0, Index: 0,
MatchedIndexes: []int{0, 3}, MatchedIndexes: []int{0, 1, 2},
}, },
}, },
}, },
@ -43,26 +42,25 @@ func TestYAML_rxFilter(t *testing.T) {
{ {
Str: "foo", Str: "foo",
Index: 0, Index: 0,
MatchedIndexes: []int{0, 3}, MatchedIndexes: []int{0, 1, 2},
}, },
{ {
Str: "foo", Str: "foo",
Index: 2, Index: 2,
MatchedIndexes: []int{0, 3}, MatchedIndexes: []int{0, 1, 2},
}, },
{ {
Str: "foo", Str: "foo",
Index: 2, Index: 2,
MatchedIndexes: []int{8, 11}, MatchedIndexes: []int{8, 9, 10},
}, },
}, },
}, },
} }
var y YAML
for k := range uu { for k := range uu {
u := uu[k] u := uu[k]
t.Run(k, func(t *testing.T) { t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, y.rxFilter(u.q, u.lines)) assert.Equal(t, u.e, rxFilter(u.q, u.lines))
}) })
} }
} }

View File

@ -4,7 +4,6 @@
package model package model
import ( import (
"regexp"
"strings" "strings"
"github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/dao"
@ -115,24 +114,9 @@ func (t *Text) filter(q string, lines []string) fuzzy.Matches {
if dao.IsFuzzySelector(q) { if dao.IsFuzzySelector(q) {
return t.fuzzyFilter(strings.TrimSpace(q[2:]), lines) return t.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
} }
return t.rxFilter(q, lines) return rxFilter(q, lines)
} }
func (*Text) fuzzyFilter(q string, lines []string) fuzzy.Matches { func (*Text) fuzzyFilter(q string, lines []string) fuzzy.Matches {
return fuzzy.Find(q, lines) return fuzzy.Find(q, lines)
} }
func (*Text) rxFilter(q string, lines []string) fuzzy.Matches {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil
}
matches := make(fuzzy.Matches, 0, len(lines))
for i, l := range lines {
if loc := rx.FindStringIndex(l); len(loc) == 2 {
matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc})
}
}
return matches
}

View File

@ -226,7 +226,7 @@ func (t *Tree) reconcile(ctx context.Context) error {
root.Sort() root.Sort()
if t.query != "" { if t.query != "" {
t.root = root.Filter(t.query, rxFilter) t.root = root.Filter(t.query, rxMatch)
} }
if t.root == nil || t.root.Diff(root) { if t.root == nil || t.root.Diff(root) {
t.root = root t.root = root
@ -277,7 +277,7 @@ func (t *Tree) getMeta(ctx context.Context, gvr string) (ResourceMeta, error) {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...
func rxFilter(q, path string) bool { func rxMatch(q, path string) bool {
rx := regexp.MustCompile(`(?i)` + q) rx := regexp.MustCompile(`(?i)` + q)
tokens := strings.Split(path, "::") tokens := strings.Split(path, "::")

View File

@ -5,7 +5,6 @@ package model
import ( import (
"context" "context"
"regexp"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -93,28 +92,13 @@ func (v *Values) filter(q string, lines []string) fuzzy.Matches {
if dao.IsFuzzySelector(q) { if dao.IsFuzzySelector(q) {
return v.fuzzyFilter(strings.TrimSpace(q[2:]), lines) return v.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
} }
return v.rxFilter(q, lines) return rxFilter(q, lines)
} }
func (*Values) fuzzyFilter(q string, lines []string) fuzzy.Matches { func (*Values) fuzzyFilter(q string, lines []string) fuzzy.Matches {
return fuzzy.Find(q, lines) return fuzzy.Find(q, lines)
} }
func (*Values) rxFilter(q string, lines []string) fuzzy.Matches {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil
}
matches := make(fuzzy.Matches, 0, len(lines))
for i, l := range lines {
if loc := rx.FindStringIndex(l); len(loc) == 2 {
matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc})
}
}
return matches
}
func (v *Values) fireResourceChanged(lines []string, matches fuzzy.Matches) { func (v *Values) fireResourceChanged(lines []string, matches fuzzy.Matches) {
for _, l := range v.listeners { for _, l := range v.listeners {
l.ResourceChanged(lines, matches) l.ResourceChanged(lines, matches)

View File

@ -7,7 +7,6 @@ import (
"context" "context"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -78,28 +77,13 @@ func (y *YAML) filter(q string, lines []string) fuzzy.Matches {
if dao.IsFuzzySelector(q) { if dao.IsFuzzySelector(q) {
return y.fuzzyFilter(strings.TrimSpace(q[2:]), lines) return y.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
} }
return y.rxFilter(q, lines) return rxFilter(q, lines)
} }
func (*YAML) fuzzyFilter(q string, lines []string) fuzzy.Matches { func (*YAML) fuzzyFilter(q string, lines []string) fuzzy.Matches {
return fuzzy.Find(q, lines) return fuzzy.Find(q, lines)
} }
func (*YAML) rxFilter(q string, lines []string) fuzzy.Matches {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil
}
matches := make(fuzzy.Matches, 0, len(lines))
for i, l := range lines {
locs := rx.FindAllStringIndex(l, -1)
for _, loc := range locs {
matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc})
}
}
return matches
}
func (y *YAML) fireResourceChanged(lines []string, matches fuzzy.Matches) { func (y *YAML) fireResourceChanged(lines []string, matches fuzzy.Matches) {
for _, l := range y.listeners { for _, l := range y.listeners {
l.ResourceChanged(lines, matches) l.ResourceChanged(lines, matches)

View File

@ -102,19 +102,12 @@ func (d *Details) TextChanged(lines []string) {
// TextFiltered notifies when the filter changed. // TextFiltered notifies when the filter changed.
func (d *Details) TextFiltered(lines []string, matches fuzzy.Matches) { func (d *Details) TextFiltered(lines []string, matches fuzzy.Matches) {
d.currentRegion, d.maxRegions = 0, 0 d.currentRegion, d.maxRegions = 0, len(matches)
ll := linesWithRegions(lines, matches)
ll := make([]string, len(lines))
copy(ll, lines)
for _, m := range matches {
loc, line := m.MatchedIndexes, ll[m.Index]
ll[m.Index] = line[:loc[0]] + fmt.Sprintf(`<<<"search_%d">>>`, d.maxRegions) + line[loc[0]:loc[1]] + `<<<"">>>` + line[loc[1]:]
d.maxRegions++
}
d.text.SetText(colorizeYAML(d.app.Styles.Views().Yaml, strings.Join(ll, "\n"))) d.text.SetText(colorizeYAML(d.app.Styles.Views().Yaml, strings.Join(ll, "\n")))
d.text.Highlight() d.text.Highlight()
if d.maxRegions > 0 { if len(matches) > 0 {
d.text.Highlight("search_0") d.text.Highlight("search_0")
d.text.ScrollToHighlight() d.text.ScrollToHighlight()
} }

View File

@ -14,12 +14,14 @@ import (
"github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui"
"github.com/derailed/tcell/v2" "github.com/derailed/tcell/v2"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/sahilm/fuzzy"
) )
func clipboardWrite(text string) error { func clipboardWrite(text string) error {
@ -240,3 +242,22 @@ func decorateCpuMemHeaderRows(app *App, data *render.TableData) {
} }
} }
} }
func matchTag(i int, s string) string {
return `<<<"search_` + strconv.Itoa(i) + `">>>` + s + `<<<"">>>`
}
func linesWithRegions(lines []string, matches fuzzy.Matches) []string {
ll := make([]string, len(lines))
copy(ll, lines)
offsetForLine := make(map[int]int)
for i, m := range matches {
for _, loc := range dao.ContinuousRanges(m.MatchedIndexes) {
start, end := loc[0]+offsetForLine[m.Index], loc[1]+offsetForLine[m.Index]
regionStr := matchTag(i, ll[m.Index][start:end])
ll[m.Index] = ll[m.Index][:start] + regionStr + ll[m.Index][end:]
offsetForLine[m.Index] += len(regionStr) - (end - start)
}
}
return ll
}

View File

@ -14,6 +14,7 @@ import (
"github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/render"
"github.com/derailed/tcell/v2" "github.com/derailed/tcell/v2"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/sahilm/fuzzy"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
) )
@ -266,3 +267,61 @@ func TestContainerID(t *testing.T) {
}) })
} }
} }
func Test_linesWithRegions(t *testing.T) {
uu := map[string]struct {
lines []string
matches fuzzy.Matches
e []string
}{
"empty-lines": {
e: []string{},
},
"no-match": {
lines: []string{"bar"},
e: []string{"bar"},
},
"single-match": {
lines: []string{"foo", "bar", "baz"},
matches: fuzzy.Matches{
{Index: 1, MatchedIndexes: []int{0, 1, 2}},
},
e: []string{"foo", matchTag(0, "bar"), "baz"},
},
"single-character": {
lines: []string{"foo", "bar", "baz"},
matches: fuzzy.Matches{
{Index: 1, MatchedIndexes: []int{1}},
},
e: []string{"foo", "b" + matchTag(0, "a") + "r", "baz"},
},
"multiple-matches": {
lines: []string{"foo", "bar", "baz"},
matches: fuzzy.Matches{
{Index: 1, MatchedIndexes: []int{0, 1, 2}},
{Index: 2, MatchedIndexes: []int{0, 1, 2}},
},
e: []string{"foo", matchTag(0, "bar"), matchTag(1, "baz")},
},
"multiple-matches-same-line": {
lines: []string{"foosfoo baz", "dfbarfoos bar"},
matches: fuzzy.Matches{
{Index: 0, MatchedIndexes: []int{0, 1, 2}},
{Index: 0, MatchedIndexes: []int{4, 5, 6}},
{Index: 1, MatchedIndexes: []int{5, 6, 7}},
},
e: []string{
matchTag(0, "foo") + "s" + matchTag(1, "foo") + " baz",
"dfbar" + matchTag(2, "foo") + "s bar",
},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
t.Parallel()
assert.Equal(t, u.e, linesWithRegions(u.lines, u.matches))
})
}
}

View File

@ -100,36 +100,17 @@ func (v *LiveView) ResourceFailed(err error) {
v.text.SetText(cowTalk(err.Error(), x+w)) v.text.SetText(cowTalk(err.Error(), x+w))
} }
func (*LiveView) linesWithRegions(lines []string, matches fuzzy.Matches) []string {
ll := make([]string, len(lines))
copy(ll, lines)
offsetForLine := make(map[int]int)
for i, m := range matches {
loc, line := m.MatchedIndexes, ll[m.Index]
if len(loc) < 2 {
continue
}
offset := offsetForLine[m.Index]
loc[0], loc[1] = loc[0]+offset, loc[1]+offset
regionStr := `<<<"search_` + strconv.Itoa(i) + `">>>` + line[loc[0]:loc[1]] + `<<<"">>>`
ll[m.Index] = line[:loc[0]] + regionStr + line[loc[1]:]
offsetForLine[m.Index] += len(regionStr) - (loc[1] - loc[0])
}
return ll
}
// ResourceChanged notifies when the filter changes. // ResourceChanged notifies when the filter changes.
func (v *LiveView) ResourceChanged(lines []string, matches fuzzy.Matches) { func (v *LiveView) ResourceChanged(lines []string, matches fuzzy.Matches) {
v.app.QueueUpdateDraw(func() { v.app.QueueUpdateDraw(func() {
v.text.SetTextAlign(tview.AlignLeft) v.text.SetTextAlign(tview.AlignLeft)
v.maxRegions = len(matches) v.currentRegion, v.maxRegions = 0, len(matches)
if v.text.GetText(true) == "" { if v.text.GetText(true) == "" {
v.text.ScrollToBeginning() v.text.ScrollToBeginning()
} }
lines = v.linesWithRegions(lines, matches) lines = linesWithRegions(lines, matches)
v.text.SetText(colorizeYAML(v.app.Styles.Views().Yaml, strings.Join(lines, "\n"))) v.text.SetText(colorizeYAML(v.app.Styles.Views().Yaml, strings.Join(lines, "\n")))
v.text.Highlight() v.text.Highlight()
if v.currentRegion < v.maxRegions { if v.currentRegion < v.maxRegions {

View File

@ -5,18 +5,12 @@ package view
import ( import (
"context" "context"
"strconv"
"testing" "testing"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/sahilm/fuzzy"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func matchTag(i int, s string) string {
return `<<<"search_` + strconv.Itoa(i) + `">>>` + s + `<<<"">>>`
}
func TestLiveViewSetText(t *testing.T) { func TestLiveViewSetText(t *testing.T) {
s := ` s := `
apiVersion: v1 apiVersion: v1
@ -30,54 +24,3 @@ apiVersion: v1
assert.Equal(t, s, sanitizeEsc(v.text.GetText(true))) assert.Equal(t, s, sanitizeEsc(v.text.GetText(true)))
} }
func TestLiveView_linesWithRegions(t *testing.T) {
uu := map[string]struct {
lines []string
matches fuzzy.Matches
e []string
}{
"empty-lines": {
e: []string{},
},
"no-match": {
lines: []string{"bar"},
e: []string{"bar"},
},
"single-match": {
lines: []string{"foo", "bar", "baz"},
matches: fuzzy.Matches{
{Index: 1, MatchedIndexes: []int{0, 3}},
},
e: []string{"foo", matchTag(0, "bar"), "baz"},
},
"multiple-matches": {
lines: []string{"foo", "bar", "baz"},
matches: fuzzy.Matches{
{Index: 1, MatchedIndexes: []int{0, 3}},
{Index: 2, MatchedIndexes: []int{0, 3}},
},
e: []string{"foo", matchTag(0, "bar"), matchTag(1, "baz")},
},
"multiple-matches-same-line": {
lines: []string{"foosfoo baz", "dfbarfoos bar"},
matches: fuzzy.Matches{
{Index: 0, MatchedIndexes: []int{0, 3}},
{Index: 0, MatchedIndexes: []int{4, 7}},
{Index: 1, MatchedIndexes: []int{5, 8}},
},
e: []string{
matchTag(0, "foo") + "s" + matchTag(1, "foo") + " baz",
"dfbar" + matchTag(2, "foo") + "s bar",
},
},
}
var v LiveView
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
t.Parallel()
assert.Equal(t, u.e, v.linesWithRegions(u.lines, u.matches))
})
}
}