From 8d3b3f527cafa8467501404504de8910af80367c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 1 Jun 2025 12:23:18 +0530 Subject: [PATCH] Let the user control recursion using the search pattern --- kittens/choose_files/main.go | 38 ++------ kittens/choose_files/main.py | 21 ---- kittens/choose_files/scan.go | 183 ++++++++++++++++++++--------------- 3 files changed, 116 insertions(+), 126 deletions(-) diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 288c4bd90..8a36b6564 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -28,14 +28,12 @@ type ScorePattern struct { } type State struct { - base_dir string - current_dir string - select_dirs bool - multiselect bool - max_depth int - exclude_patterns []*regexp.Regexp - score_patterns []ScorePattern - search_text string + base_dir string + current_dir string + select_dirs bool + multiselect bool + score_patterns []ScorePattern + search_text string current_idx int num_of_matches_at_last_render int @@ -45,7 +43,6 @@ type State struct { func (s State) BaseDir() string { return utils.IfElse(s.base_dir == "", default_cwd, s.base_dir) } func (s State) SelectDirs() bool { return s.select_dirs } func (s State) Multiselect() bool { return s.multiselect } -func (s State) MaxDepth() int { return utils.IfElse(s.max_depth < 1, 4, s.max_depth) } func (s State) String() string { return utils.Repr(s) } func (s State) SearchText() string { return s.search_text } func (s *State) SetSearchText(val string) { @@ -64,10 +61,9 @@ func (s *State) SetCurrentDir(val string) { s.current_dir = val } } -func (s State) ExcludePatterns() []*regexp.Regexp { return s.exclude_patterns } -func (s State) ScorePatterns() []ScorePattern { return s.score_patterns } -func (s State) CurrentIndex() int { return s.current_idx } -func (s *State) SetCurrentIndex(val int) { s.current_idx = max(0, val) } +func (s State) ScorePatterns() []ScorePattern { return s.score_patterns } +func (s State) CurrentIndex() int { return s.current_idx } +func (s *State) SetCurrentIndex(val int) { s.current_idx = max(0, val) } func (s State) CurrentDir() string { return utils.IfElse(s.current_dir == "", s.BaseDir(), s.current_dir) } @@ -184,21 +180,7 @@ func add(a, b float64) float64 { return a + b } func div(a, b float64) float64 { return a / b } func (h *Handler) set_state_from_config(conf *Config) (err error) { - h.state = State{max_depth: int(conf.Max_depth)} - h.state.exclude_patterns = make([]*regexp.Regexp, 0, len(conf.Exclude_directory)) - seen := map[string]*regexp.Regexp{} - for _, x := range conf.Exclude_directory { - if strings.HasPrefix(x, "!") { - delete(seen, x[1:]) - } else if seen[x] == nil { - if pat, err := regexp.Compile(x); err == nil { - seen[x] = pat - } else { - return fmt.Errorf("The exclude directory pattern %#v is invalid: %w", x, err) - } - } - } - h.state.exclude_patterns = utils.Values(seen) + h.state = State{} fmap := map[string]func(float64, float64) float64{ "*=": mult, "+=": add, "-=": sub, "/=": div} h.state.score_patterns = make([]ScorePattern, len(conf.Modify_score)) diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 7b9a64db2..7a00418ca 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -18,27 +18,6 @@ map = definition.add_map mma = definition.add_mouse_map agr('scan', 'Scanning the filesystem') -opt( - '+exclude_directory', - '^/proc$', - add_to_default=True, - long_text=''' -Regular expression to exclude directories. Matching directories will not be recursed into, but -you can still or change into them to inspect their contents. Can be specified multiple times. -Matches against the absolute path to the directory. -If the pattern starts with :code:`!`, the :code:`!` is removed and the remaining pattern is removed from the list of patterns. This -can be used to remove the default excluded directory patterns. -''', -) -opt('+exclude_directory', '^/dev$', add_to_default=True) -opt('+exclude_directory', '^/sys$', add_to_default=True) -opt('+exclude_directory', '/__pycache__$', add_to_default=True) - -opt('max_depth', '1', option_type='positive_int', long_text=''' -The maximum depth to which to scan the filesystem for matches. Using large values will slow things down considerably. The better -approach is to use a small value and first change to the directory of interest then actually select the file of interest. -''') - opt('+modify_score', r'(^|/)\.[^/]+(/|$) *= 0.5', add_to_default=True, long_text=''' Modify the score of items matching the specified regular expression (matches against the absolute path). Can be used to make certain files and directories less or more prominent in the results. diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index ca8a06891..aeedafd4e 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -22,13 +22,14 @@ type ResultItem struct { text, abspath string dir_entry os.DirEntry positions []int // may be nil + score float64 } func (r ResultItem) String() string { return fmt.Sprintf("{text: %#v, abspath: %#v, is_dir: %v, positions: %#v}", r.text, r.abspath, r.dir_entry.IsDir(), r.positions) } -type dir_cache map[string][]ResultItem +type dir_cache map[string][]os.DirEntry type ScanCache struct { dir_entries dir_cache @@ -38,64 +39,29 @@ type ScanCache struct { matches []*ResultItem } -func (sc *ScanCache) get_cached_entries(root_dir string) (ans []ResultItem, found bool) { +func (sc *ScanCache) get_cached_entries(root_dir string) (ans []os.DirEntry, found bool) { sc.mutex.Lock() defer sc.mutex.Unlock() ans, found = sc.dir_entries[root_dir] return } -func (sc *ScanCache) set_cached_entries(root_dir string, e []ResultItem) { +func (sc *ScanCache) set_cached_entries(root_dir string, e []os.DirEntry) { sc.mutex.Lock() defer sc.mutex.Unlock() sc.dir_entries[root_dir] = e } -func scan_dir(path, root_dir string) []ResultItem { - if ans, err := os.ReadDir(path); err == nil { - if rel, err := filepath.Rel(root_dir, path); err == nil { - return utils.Map(func(x os.DirEntry) ResultItem { - path := filepath.Join(path, x.Name()) - r := filepath.Join(rel, x.Name()) - if root_dir == "/" { - r = "/" + r - } - return ResultItem{dir_entry: x, abspath: path, text: r} - }, ans) - } - } - return []ResultItem{} -} - -func is_excluded(path string, exclude_patterns []*regexp.Regexp) bool { - for _, pattern := range exclude_patterns { - if pattern.MatchString(path) { - return true - } - } - return false -} - -func (sc *ScanCache) fs_scan(root_dir, current_dir string, max_depth int, exclude_patterns []*regexp.Regexp, seen map[string]bool) (ans []ResultItem) { +func (sc *ScanCache) readdir(current_dir string) (ans []os.DirEntry) { var found bool if ans, found = sc.get_cached_entries(current_dir); !found { - ans = scan_dir(current_dir, root_dir) + ans, _ = os.ReadDir(current_dir) sc.set_cached_entries(current_dir, ans) } - ans = slices.Clone(ans) - // now recurse into directories - if max_depth > 0 { - for _, x := range ans { - if x.dir_entry.IsDir() && !seen[x.abspath] && !is_excluded(x.abspath, exclude_patterns) { - seen[x.abspath] = true - ans = append(ans, sc.fs_scan(root_dir, x.abspath, max_depth-1, exclude_patterns, seen)...) - } - } - } return } -func sort_items_without_search_text(items []ResultItem) (ans []*ResultItem) { +func sort_items_without_search_text(items []*ResultItem) (ans []*ResultItem) { type s struct { ltext string num_of_slashes int @@ -104,8 +70,8 @@ func sort_items_without_search_text(items []ResultItem) (ans []*ResultItem) { r *ResultItem } hidden_pat := regexp.MustCompile(`(^|/)\.[^/]+(/|$)`) - d := utils.Map(func(x ResultItem) s { - return s{strings.ToLower(x.text), strings.Count(x.text, "/"), x.dir_entry.IsDir(), hidden_pat.MatchString(x.abspath), &x} + d := utils.Map(func(x *ResultItem) s { + return s{strings.ToLower(x.text), strings.Count(x.text, "/"), x.dir_entry.IsDir(), hidden_pat.MatchString(x.abspath), x} }, items) sort.SliceStable(d, func(i, j int) bool { a, b := d[i], d[j] @@ -126,9 +92,9 @@ func sort_items_without_search_text(items []ResultItem) (ans []*ResultItem) { return utils.Map(func(s s) *ResultItem { return s.r }, d) } -func get_modified_score(r *ResultItem, score float64, score_patterns []ScorePattern) float64 { +func get_modified_score(abspath string, score float64, score_patterns []ScorePattern) float64 { for _, sp := range score_patterns { - if sp.pat.MatchString(r.abspath) { + if sp.pat.MatchString(abspath) { score = sp.op(score, sp.val) } } @@ -145,41 +111,101 @@ func count_uppercase(s string) int { return count } -func (sc *ScanCache) scan(root_dir, search_text string, max_depth int, exclude_patterns []*regexp.Regexp, score_patterns []ScorePattern) (ans []*ResultItem) { - seen := make(map[string]bool, 1024) - matches := sc.fs_scan(root_dir, root_dir, max_depth, exclude_patterns, seen) +type pos_in_name struct { + name string + positions []int +} + +func (r *ResultItem) finalize(positions []pos_in_name) { + buf := strings.Builder{} + buf.Grow(256) + pos := 0 + for i, x := range positions { + before := buf.Len() + buf.WriteString(x.name) + if i != len(positions)-1 { + buf.WriteRune(os.PathSeparator) + } + for _, p := range x.positions { + r.positions = append(r.positions, p+pos) + } + pos += buf.Len() - before + } + r.text = buf.String() + if r.text == "" { + r.text = string(os.PathSeparator) + } +} + +func (sc *ScanCache) scan_dir(abspath string, patterns []string, positions []pos_in_name, score float64) (ans []*ResultItem) { + if entries := sc.readdir(abspath); len(entries) > 0 { + npos := make([]pos_in_name, len(positions)+1) + copy(npos, positions) + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + var scores []*subseq.Match + pattern := "" + if len(patterns) > 0 { + pattern = patterns[0] + } + if pattern != "" { + scores = subseq.ScoreItems(pattern, names, subseq.Options{}) + } else { + null := subseq.Match{} + scores = slices.Repeat([]*subseq.Match{&null}, len(names)) + } + is_last := pattern == "" || len(patterns) <= 1 + for i, n := range names { + child_abspath := filepath.Join(abspath, n) + if pattern == "" || scores[i].Score > 0 { + npos[len(positions)] = pos_in_name{name: n, positions: scores[i].Positions} + if is_last { + r := &ResultItem{score: score + scores[i].Score, dir_entry: entries[i], abspath: child_abspath} + r.finalize(npos) + ans = append(ans, r) + } else if entries[i].IsDir() { + ans = append(ans, sc.scan_dir(child_abspath, patterns[1:], npos, scores[i].Score+score)...) + } + } + } + } + return +} + +func (sc *ScanCache) scan(root_dir, search_text string, score_patterns []ScorePattern) (ans []*ResultItem) { + var patterns []string + switch search_text { + case "", "/": + default: + patterns = strings.Split(filepath.Clean(search_text), string(os.PathSeparator)) + } + if strings.HasPrefix(search_text, "/") { + root_dir = "/" + if len(patterns) > 0 { + patterns = patterns[1:] + } + } + matches := sc.scan_dir(root_dir, patterns, nil, 0) + for _, ri := range matches { + ri.score = get_modified_score(ri.abspath, ri.score, score_patterns) + } if search_text == "" { ans = sort_items_without_search_text(matches) return } - pm := make(map[string]*ResultItem, len(ans)) - for _, x := range matches { - nx := x - pm[x.text] = &nx - } - matches2 := utils.Filter(subseq.ScoreItems(search_text, utils.Keys(pm), subseq.Options{}), func(x *subseq.Match) bool { - return x.Score > 0 - }) - type s struct { - r *ResultItem - score float64 - } - ss := utils.Map(func(m *subseq.Match) s { - x := pm[m.Text] - x.positions = m.Positions - return s{x, get_modified_score(x, m.Score, score_patterns)} - }, matches2) - slices.SortStableFunc(ss, func(a, b s) int { + slices.SortStableFunc(matches, func(a, b *ResultItem) int { ans := cmp.Compare(b.score, a.score) if ans == 0 { - ans = cmp.Compare(len(a.r.text), len(b.r.text)) + ans = cmp.Compare(len(a.text), len(b.text)) if ans == 0 { - ans = cmp.Compare(count_uppercase(a.r.text), count_uppercase(b.r.text)) + ans = cmp.Compare(count_uppercase(a.text), count_uppercase(b.text)) } } return ans }) - return utils.Map(func(s s) *ResultItem { return s.r }, ss) + return matches } func (h *Handler) get_results() (ans []*ResultItem, in_progress bool) { @@ -189,21 +215,24 @@ func (h *Handler) get_results() (ans []*ResultItem, in_progress bool) { if sc.dir_entries == nil { sc.dir_entries = make(dir_cache, 512) } - if sc.root_dir == h.state.CurrentDir() && sc.search_text == h.state.SearchText() { + cd := h.state.CurrentDir() + st := h.state.SearchText() + if st != "" { + st = filepath.Clean(st) + } + if sc.root_dir == cd && sc.search_text == st { return sc.matches, sc.in_progress } sc.in_progress = true sc.matches = nil - root_dir := h.state.CurrentDir() - search_text := h.state.SearchText() - sc.root_dir = root_dir - sc.search_text = search_text - md, ep, sp := max(0, h.state.MaxDepth()-1), h.state.ExcludePatterns(), h.state.ScorePatterns() + sc.root_dir = cd + sc.search_text = st + sp := h.state.ScorePatterns() go func() { - results := sc.scan(root_dir, search_text, md, ep, sp) + results := sc.scan(cd, st, sp) sc.mutex.Lock() defer sc.mutex.Unlock() - if root_dir == sc.root_dir && search_text == sc.search_text { + if cd == sc.root_dir && st == sc.search_text { sc.matches = results sc.in_progress = false h.lp.WakeupMainThread()