Files
kitty/kittens/choose_files/scan_test.go
2025-11-11 17:09:37 +05:30

372 lines
11 KiB
Go

package choose_files
import (
"fmt"
"io/fs"
"math/rand"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/kovidgoyal/kitty/tools/ignorefiles"
"github.com/kovidgoyal/kitty/tools/utils"
)
var _ = fmt.Print
func TestAsLower(t *testing.T) {
buf := [512]byte{}
for _, q := range []string{
"abc", "aBc", "aBCCf83Dx", "mOoseÇa", "89ÇĞxxA", "", "23", "aIİBc",
} {
n := as_lower(q, buf[:])
actual := utils.UnsafeBytesToString(buf[:n])
if diff := cmp.Diff(strings.ToLower(q), actual); diff != "" {
t.Fatalf("Failed to lowercase: %#v\n%s", q, diff)
}
}
}
type node struct {
name string
children map[string]*node
data string
}
func (n node) Name() string {
return n.name
}
func (n node) IsDir() bool {
return n.children != nil
}
func (n node) String() string {
return fmt.Sprintf("{name: %s num_children: %d}", n.name, len(n.children))
}
func (n node) Type() fs.FileMode {
if n.children == nil {
return 0
}
return fs.ModeDir
}
func (n node) Info() (fs.FileInfo, error) {
return nil, fmt.Errorf("Info() not implemented")
}
func random_name(r *rand.Rand) string {
length := 3 + r.Intn(23)
bytes := make([]byte, length)
for i := range length {
bytes[i] = byte(r.Intn(26) + 'a')
}
return string(bytes)
}
func (n *node) generate_random_tree(depth, breadth int) {
r := rand.New(rand.NewSource(111))
n.children = make(map[string]*node)
for range breadth {
c := &node{name: random_name(r)}
n.children[c.name] = c
if depth > 0 && r.Intn(10) < 3 {
c.generate_random_tree(depth-1, breadth)
}
}
}
func (n node) dir_entries() []fs.DirEntry {
entries := make([]fs.DirEntry, 0, len(n.children))
for _, v := range n.children {
entries = append(entries, v)
}
return entries
}
func (n node) ReadFile(name string) ([]byte, error) {
if name == string(os.PathSeparator) {
return nil, fs.ErrNotExist
}
p := &n
for x := range strings.SplitSeq(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) {
c, found := p.children[x]
if !found {
return nil, fs.ErrNotExist
}
p = c
}
return []byte(p.data), nil
}
func (n node) ReadDir(name string) ([]fs.DirEntry, error) {
if name == string(os.PathSeparator) {
return n.dir_entries(), nil
}
p := &n
for x := range strings.SplitSeq(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) {
c, found := p.children[x]
if !found {
return nil, fs.ErrNotExist
}
if !c.IsDir() {
return nil, fs.ErrExist
}
p = c
}
return p.dir_entries(), nil
}
func TestChooseFilesIgnore(t *testing.T) {
root := node{name: string(os.PathSeparator), children: map[string]*node{
"a": {name: "a"},
"b": {name: "b"},
"c.png": {name: "c.png"},
".ignore": {name: ".ignore", data: "a\nx/s/n"},
".gitignore": {name: ".gitignore", data: "b"},
"x": {name: "x", children: map[string]*node{
"1": {name: "1"}, "2": {name: "2"}, "3": {name: "3"},
"s": {name: "s", children: map[string]*node{
"m": {name: "m"}, "n": {name: "n"},
}},
}},
"y": {name: "y", children: map[string]*node{
"3": {name: "3"}, "4": {name: "4"}, "5": {name: "5"},
"s": {name: "s", children: map[string]*node{
"3": {name: "3"}, "4": {name: "4"}, "5": {name: "5"}, "6": {name: "6"},
}},
".gitignore": {name: ".gitignore", data: "/s/5"},
".git": {name: ".git", children: map[string]*node{
"info": {name: "info", children: map[string]*node{
"exclude": {name: "exclude", data: "s/4"},
}},
}},
}},
}}
r := func(respect_ignores bool, expected string) {
ch := make(chan bool)
s := new_filesystem_scanner("/", ch, nil)
s.dir_reader = root.ReadDir
s.file_reader = root.ReadFile
s.global_gitignore = ignorefiles.NewGitignore()
s.global_ignore = ignorefiles.NewGitignore()
s.respect_ignores = respect_ignores
if err := s.global_gitignore.LoadLines("*.png", "s/3"); err != nil {
t.Fatal(err)
}
if err := s.global_ignore.LoadLines("x/3"); err != nil {
t.Fatal(err)
}
s.Start()
for range ch {
}
if s.err != nil {
t.Fatal(s.err)
}
ci := CollectionIndex{}
actual := utils.Map(func(x ResultItem) string { return x.text }, s.Batch(&ci))
if diff := cmp.Diff(strings.Split(expected, ` `), actual); diff != "" {
t.Fatalf("Incorrect ignoring:\n%s", diff)
}
}
r(true, `x y b c.png x/s x/1 x/2 y/s y/3 y/4 y/5 x/s/m y/s/6`)
r(false, `x y a b c.png x/s x/1 x/2 x/3 y/s y/3 y/4 y/5 x/s/m x/s/n y/s/3 y/s/4 y/s/5 y/s/6`)
}
func TestChooseFilesScoring(t *testing.T) {
root := node{name: string(os.PathSeparator), children: map[string]*node{
"b": {name: "b"},
"a": {name: "a"},
"c.png": {name: "c.png"},
"x": {name: "x", children: map[string]*node{
"1": {name: "1"}, "2": {name: "2"}, "3": {name: "3"},
"s": {name: "s", children: map[string]*node{
"m": {name: "m"}, "n": {name: "n"},
}},
}},
"y": {name: "y", children: map[string]*node{
"3": {name: "3"}, "4": {name: "4"}, "5": {name: "5"},
}},
}}
wg := sync.WaitGroup{}
wg.Add(1)
s := NewFileSystemScorer(string(os.PathSeparator), "", Filter{}, false, func(err error, is_complete bool) {
if is_complete {
wg.Done()
}
})
s.dir_reader = root.ReadDir
s.global_gitignore = ignorefiles.NewGitignore()
s.Start()
wg.Wait()
results := func() (ans []string) {
rr, _ := s.Results()
for _, r := range rr.RenderedMatches(CollectionIndex{}, -1) {
ans = append(ans, r.text)
}
return
}
ae := func(query string, expected ...string) {
wg.Add(1)
s.Change_query(query)
wg.Wait()
if diff := cmp.Diff(expected, results()); diff != "" {
t.Fatalf("Query less scoring failed\n%s", diff)
}
}
ae("a", "a")
ae("3", "x/3", "y/3")
ae("s", "x/s", "x/s/m", "x/s/n")
ae("sn", "x/s/n")
ae("", "x", "y", "a", "b", "c.png", "x/s", "x/1", "x/2", "x/3", "y/3", "y/4", "y/5", "x/s/m", "x/s/n")
af := func(filter string, expected ...string) {
f, _ := NewFilter(filter)
wg.Add(1)
s.Change_filter(*f)
wg.Wait()
if diff := cmp.Diff(expected, results()); diff != "" {
t.Fatalf("filter %s failed\n%s", filter, diff)
}
}
af("glob:a:A", "x", "y", "a", "x/s")
af("glob:[ab]:A", "x", "y", "a", "b", "x/s")
af("mime:image/png:A", "x", "y", "c.png", "x/s")
af("mime:image/*:A", "x", "y", "c.png", "x/s")
af("glob:*:All", "x", "y", "a", "b", "c.png", "x/s", "x/1", "x/2", "x/3", "y/3", "y/4", "y/5", "x/s/m", "x/s/n")
}
func TestSortedResults(t *testing.T) {
r := NewSortedResults()
idx := CollectionIndex{}
m := func(items ...int) []*ResultItem {
ans := make([]*ResultItem, len(items))
for i, x := range items {
ans[i] = &ResultItem{text: strconv.Itoa(x), score: CombinedScore(x)}
}
return ans
}
v := func(slice, pos, num int) []int {
if num == 0 {
num = r.Len()
}
return utils.Map(func(r *ResultItem) int { return int(r.score) }, r.RenderedMatches(CollectionIndex{slice, pos}, num))
}
tv := func(slice, pos, num int, expected ...int) {
if diff := cmp.Diff(expected, v(slice, pos, num)); diff != "" {
t.Fatalf("view failed for %v num:%d\n%s", CollectionIndex{slice, pos}, num, diff)
}
}
tci := func(increment int, expected int) {
orig := idx
idx = r.IncrementIndexWithWrapAround(idx, increment)
actual := int(r.At(idx).score)
if actual != expected {
t.Fatalf("increment: %d on %v failed\nexpected: %d actual: %d idx: %v", increment, orig, expected, actual, idx)
}
}
dt := func(a, b CollectionIndex, expected int) {
actual := r.distance(a, b)
if expected != actual {
t.Fatalf("distance on %v and %v failed\nexpected: %d actual: %d ", a, b, expected, actual)
}
if r.distance(b, a) != actual {
t.Fatalf("distance on %v and %v not commutative %d != %d", a, b, actual, r.distance(b, a))
}
}
tc := func(num_before, expected_new_before int, ci CollectionIndex, expected ...[]int) {
ac, new_num_before, _ := r.SplitIntoColumns(func(int) int { return 2 }, 2, num_before, ci)
actual := make([][]int, len(ac))
for i, x := range ac {
actual[i] = utils.Map(func(r *ResultItem) int { return int(r.score) }, x)
}
if expected_new_before != new_num_before {
t.Fatalf("new_num_before not as expected for num_before: %d ci: %v\n%d != %d", num_before, ci, expected_new_before, new_num_before)
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Fatalf("wrong columns for num_before: %d ci: %v\n%s", num_before, ci, diff)
}
}
r.AddSortedSlice(m(10, 20, 30))
r.AddSortedSlice(m(40, 50, 60))
r.AddSortedSlice(m(70, 80, 90))
tc(0, 0, CollectionIndex{}, []int{10, 20}, []int{30, 40})
tc(1, 1, CollectionIndex{Pos: 1}, []int{10, 20}, []int{30, 40})
tc(1, 1, CollectionIndex{Pos: 2}, []int{20, 30}, []int{40, 50})
tc(2, 2, CollectionIndex{Pos: 2}, []int{10, 20}, []int{30, 40})
tc(20, 2, CollectionIndex{Pos: 2}, []int{10, 20}, []int{30, 40})
for num_before := range 4 {
tc(num_before, 3, CollectionIndex{2, 2}, []int{60, 70}, []int{80, 90})
}
tc(1, 1, CollectionIndex{1, 1}, []int{40, 50}, []int{60, 70})
dt(CollectionIndex{Pos: 0}, CollectionIndex{Pos: 2}, 2)
dt(CollectionIndex{Pos: 0}, CollectionIndex{Slice: 1}, 3)
dt(CollectionIndex{Pos: 0}, CollectionIndex{Slice: 1, Pos: 1}, 4)
tv(0, 0, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90)
tv(0, 2, 3, 30, 40, 50)
tv(0, 3, 3, 40, 50, 60)
tv(1, 0, 4, 40, 50, 60, 70)
tci(1, 20)
tci(3, 50)
tci(-1, 40)
tci(-3, 10)
tci(-2, 80)
tci(3, 20)
tci(9, 20)
tci(-9, 20)
r.AddSortedSlice(m(100, 110, 120))
r.AddSortedSlice(m(41, 61, 71, 99))
tv(0, 0, 0, 10, 20, 30, 40, 41, 50, 60, 61, 70, 71, 80, 90, 99, 100, 110, 120)
r.AddSortedSlice(m(1, 2, 3))
tv(0, 0, 0, 1, 2, 3, 10, 20, 30, 40, 41, 50, 60, 61, 70, 71, 80, 90, 99, 100, 110, 120)
r.AddSortedSlice(m(1000, 2000))
tv(0, 0, 0, 1, 2, 3, 10, 20, 30, 40, 41, 50, 60, 61, 70, 71, 80, 90, 99, 100, 110, 120, 1000, 2000)
}
func run_scoring(b *testing.B, depth, breadth int, query string) {
root := node{name: string(os.PathSeparator)}
root.generate_random_tree(depth, breadth)
for b.Loop() {
b.StopTimer()
wg := sync.WaitGroup{}
wg.Add(1)
s := NewFileSystemScorer(string(os.PathSeparator), query, Filter{}, false, func(err error, is_complete bool) {
if is_complete {
wg.Done()
}
})
s.dir_reader = root.ReadDir
s.global_gitignore = ignorefiles.NewGitignore()
b.StartTimer()
s.scanner.Start()
s.Start()
wg.Wait()
}
fmt.Println("\nnumber of iterations: ", b.N)
fmt.Println("time per iteration:", b.Elapsed()/time.Duration(b.N))
}
// To run this benchmark with profiling use:
// go test -bench=FileNameScoringWithoutQuery -benchmem -cpuprofile=/tmp/cpu.prof -memprofile=/tmp/mem.prof github.com/kovidgoyal/kitty/kittens/choose_files -o /tmp/cfexe
func BenchmarkFileNameScoringWithoutQuery(b *testing.B) {
run_scoring(b, 5, 20, "")
}
// To run this benchmark with profiling use:
// go test -bench=FileNameScoringWithQuery -benchmem -cpuprofile=/tmp/cpu.prof -memprofile=/tmp/mem.prof github.com/kovidgoyal/kitty/kittens/choose_files -o /tmp/cfexe
func BenchmarkFileNameScoringWithQuery(b *testing.B) {
run_scoring(b, 5, 20, "abc")
}