From 243a76002c93b474cf8401b37670a43803a0a2d2 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Fri, 24 Jan 2025 00:54:53 +0900 Subject: Option to prioritize file name matches (#4192) * 'pathname' is a new tiebreak option for prioritizing matches occurring in the file name of the path. * `--scheme=path` will automatically set `--tiebreak=pathname,length`. * fzf will automatically choose `path` scheme when the input is a TTY device, where fzf would start its built-in walker or run `$FZF_DEFAULT_COMMAND` which is usually a command for listing files. Close #4191 --- src/core.go | 3 +++ src/options.go | 79 +++++++++++++++++++++++++++++++++++++++++++++------------- src/result.go | 15 +++++++++++ 3 files changed, 80 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/core.go b/src/core.go index b8851d79..71cf04da 100644 --- a/src/core.go +++ b/src/core.go @@ -188,6 +188,9 @@ func Run(opts *Options) (int, error) { forward = false case byBegin: forward = true + case byPathname: + withPos = true + forward = false } } diff --git a/src/options.go b/src/options.go index 4b5b25fa..d31b1001 100644 --- a/src/options.go +++ b/src/options.go @@ -11,6 +11,7 @@ import ( "github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/tui" + "github.com/junegunn/fzf/src/util" "github.com/junegunn/go-shellwords" "github.com/rivo/uniseg" @@ -46,8 +47,8 @@ Usage: fzf [options] --tail=NUM Maximum number of items to keep in memory --disabled Do not perform search --tiebreak=CRI[,..] Comma-separated list of sort criteria to apply - when the scores are tied [length|chunk|begin|end|index] - (default: length) + when the scores are tied + [length|chunk|pathname|begin|end|index] (default: length) INPUT/OUTPUT --read0 Read input delimited by ASCII NUL characters @@ -241,6 +242,7 @@ const ( byLength byBegin byEnd + byPathname ) type heightSpec struct { @@ -653,7 +655,7 @@ func defaultOptions() *Options { Man: false, Fuzzy: true, FuzzyAlgo: algo.FuzzyMatchV2, - Scheme: "default", + Scheme: "", // Unknown Extended: true, Phony: false, Case: CaseSmart, @@ -664,7 +666,7 @@ func defaultOptions() *Options { Sort: 1000, Track: trackDisabled, Tac: false, - Criteria: []criterion{byScore, byLength}, + Criteria: []criterion{}, // Unknown Multi: 0, Ansi: false, Mouse: true, @@ -804,16 +806,6 @@ func parseAlgo(str string) (algo.Algo, error) { return nil, errors.New("invalid algorithm (expected: v1 or v2)") } -func processScheme(opts *Options) error { - if !algo.Init(opts.Scheme) { - return errors.New("invalid scoring scheme (expected: default|path|history)") - } - if opts.Scheme == "history" { - opts.Criteria = []criterion{byScore} - } - return nil -} - func parseBorder(str string, optional bool, allowLine bool) (tui.BorderShape, error) { switch str { case "line": @@ -1037,6 +1029,19 @@ func parseKeyChordsImpl(str string, message string) (map[tui.Event]string, error return chords, nil } +func parseScheme(str string) (string, []criterion, error) { + str = strings.ToLower(str) + switch str { + case "history": + return str, []criterion{byScore}, nil + case "path": + return str, []criterion{byScore, byPathname, byLength}, nil + case "default": + return str, []criterion{byScore, byLength}, nil + } + return str, nil, errors.New("invalid scoring scheme: " + str + " (expected: default|path|history)") +} + func parseTiebreak(str string) ([]criterion, error) { criteria := []criterion{byScore} hasIndex := false @@ -1044,6 +1049,7 @@ func parseTiebreak(str string) ([]criterion, error) { hasLength := false hasBegin := false hasEnd := false + hasPathname := false check := func(notExpected *bool, name string) error { if *notExpected { return errors.New("duplicate sort criteria: " + name) @@ -1065,6 +1071,11 @@ func parseTiebreak(str string) ([]criterion, error) { return nil, err } criteria = append(criteria, byChunk) + case "pathname": + if err := check(&hasPathname, "pathname"); err != nil { + return nil, err + } + criteria = append(criteria, byPathname) case "length": if err := check(&hasLength, "length"); err != nil { return nil, err @@ -2261,7 +2272,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { if err != nil { return err } - opts.Scheme = strings.ToLower(str) + if opts.Scheme, opts.Criteria, err = parseScheme(str); err != nil { + return err + } case "--expect": str, err := nextString("key names required") if err != nil { @@ -3173,7 +3186,9 @@ func postProcessOptions(opts *Options) error { return errors.New("failed to start pprof profiles: " + err.Error()) } - return processScheme(opts) + algo.Init(opts.Scheme) + + return nil } func parseShellWords(str string) ([]string, error) { @@ -3223,7 +3238,26 @@ func ParseOptions(useDefaults bool, args []string) (*Options, error) { return nil, err } - // 4. Final validation of merged options + // 4. Change default scheme when built-in walker is used + if len(opts.Scheme) == 0 { + opts.Scheme = "default" + if len(opts.Criteria) == 0 { + // NOTE: Let's assume $FZF_DEFAULT_COMMAND generates a list of file paths. + // But it is possible that it is set to a command that doesn't generate + // file paths. + // + // In that case, you can either + // 1. explicitly set --scheme=default, + // 2. or replace $FZF_DEFAULT_COMMAND with an equivalent 'start:reload' + // binding, which is the new preferred way. + if !opts.hasReloadOnStart() && util.IsTty(os.Stdin) { + opts.Scheme = "path" + } + _, opts.Criteria, _ = parseScheme(opts.Scheme) + } + } + + // 5. Final validation of merged options if err := validateOptions(opts); err != nil { return nil, err } @@ -3231,6 +3265,17 @@ func ParseOptions(useDefaults bool, args []string) (*Options, error) { return opts, nil } +func (opts *Options) hasReloadOnStart() bool { + if actions, prs := opts.Keymap[tui.Start.AsEvent()]; prs { + for _, action := range actions { + if action.t == actReload || action.t == actReloadSync { + return true + } + } + } + return false +} + func (opts *Options) extractReloadOnStart() string { cmd := "" if actions, prs := opts.Keymap[tui.Start.AsEvent()]; prs { diff --git a/src/result.go b/src/result.go index 10e0c6d6..28d42e7d 100644 --- a/src/result.go +++ b/src/result.go @@ -69,6 +69,21 @@ func buildResult(item *Item, offsets []Offset, score int) Result { } case byLength: val = item.TrimLength() + case byPathname: + if validOffsetFound { + // lastDelim := strings.LastIndexByte(item.text.ToString(), '/') + lastDelim := -1 + s := item.text.ToString() + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '/' || s[i] == '\\' { + lastDelim = i + break + } + } + if lastDelim <= minBegin { + val = util.AsUint16(minBegin - lastDelim) + } + } case byBegin, byEnd: if validOffsetFound { whitePrefixLen := 0 -- cgit v1.2.3