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
This commit is contained in:
Junegunn Choi 2025-01-24 00:54:53 +09:00 committed by GitHub
parent c71e4ddee4
commit 243a76002c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 127 additions and 28 deletions

View File

@ -1,6 +1,13 @@
CHANGELOG CHANGELOG
========= =========
0.59.0
------
- Prioritizing file name matches (#4192)
- Added a new tiebreak option `pathname` for prioritizing file name matches
- `--scheme=path` now sets `--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.
0.58.0 0.58.0
------ ------
_Release highlights: https://junegunn.github.io/fzf/releases/0.58.0/_ _Release highlights: https://junegunn.github.io/fzf/releases/0.58.0/_

View File

@ -83,6 +83,7 @@ Table of Contents
* [Custom fuzzy completion](#custom-fuzzy-completion) * [Custom fuzzy completion](#custom-fuzzy-completion)
* [Vim plugin](#vim-plugin) * [Vim plugin](#vim-plugin)
* [Advanced topics](#advanced-topics) * [Advanced topics](#advanced-topics)
* [Customizing for different types of input](#customizing-for-different-types-of-input)
* [Performance](#performance) * [Performance](#performance)
* [Executing external programs](#executing-external-programs) * [Executing external programs](#executing-external-programs)
* [Turning into a different process](#turning-into-a-different-process) * [Turning into a different process](#turning-into-a-different-process)
@ -718,6 +719,22 @@ See [README-VIM.md](README-VIM.md).
Advanced topics Advanced topics
--------------- ---------------
### Customizing for different types of input
Since fzf is a general-purpose text filter, its algorithm was designed to
"generally" work well with any kind of input. However, admittedly, there is no
true one-size-fits-all solution, and you may want to tweak the algorithm and
some of the settings depending on the type of the input. To make this process
easier, fzf provides a set of "scheme"s for some common input types.
| Scheme | Description |
| :--- | :--- |
| `--scheme=default` | Generic scheme designed to work well with any kind of input |
| `--scheme=path` | Suitable for file paths |
| `--scheme=history` | Suitable for command history or any input where chronological ordering is important |
(See `fzf --man` for the details)
### Performance ### Performance
fzf is fast. Performance should not be a problem in most use cases. However, fzf is fast. Performance should not be a problem in most use cases. However,
@ -727,6 +744,8 @@ you might want to be aware of the options that can affect performance.
makes the initial scanning slower. So it's not recommended that you add it makes the initial scanning slower. So it's not recommended that you add it
to your `$FZF_DEFAULT_OPTS`. to your `$FZF_DEFAULT_OPTS`.
- `--nth` makes fzf slower because it has to tokenize each line. - `--nth` makes fzf slower because it has to tokenize each line.
- A plain string `--delimiter` should be preferred over a regular expression
delimiter.
- `--with-nth` makes fzf slower as fzf has to tokenize and reassemble each - `--with-nth` makes fzf slower as fzf has to tokenize and reassemble each
line. line.

View File

@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf 1 "Jan 2025" "fzf 0.58.0" "fzf - a command-line fuzzy finder" .TH fzf 1 "Jan 2025" "fzf 0.59.0" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@ -76,7 +76,8 @@ Generic scoring scheme designed to work well with any type of input.
.RS .RS
Additional bonus point is only given to the characters after path separator. Additional bonus point is only given to the characters after path separator.
You might want to choose this scheme over \fBdefault\fR if you have many files You might want to choose this scheme over \fBdefault\fR if you have many files
with spaces in their paths. with spaces in their paths. This also sets \fB\-\-tiebreak=pathname,length\fR,
to prioritize matches occurring in the tail element of a file path.
.RE .RE
.RE .RE
@ -90,6 +91,13 @@ more weight to the chronological ordering. This also sets
.RE .RE
.RE .RE
.RS
fzf chooses \fBpath\fR scheme when the input is a TTY device, where fzf would
start its built-in walker or run \fB$FZF_DEFAULT_COMMAND\fR, and there is no
\fBreload\fR action bound to \fBstart\fR event. Otherwise, it chooses
\fBdefault\fR scheme.
.RE
.TP .TP
.BI "\-\-algo=" TYPE .BI "\-\-algo=" TYPE
Fuzzy matching algorithm (default: v2) Fuzzy matching algorithm (default: v2)
@ -140,15 +148,17 @@ Comma-separated list of sort criteria to apply when the scores are tied.
.br .br
.br .br
.BR length " Prefers line with shorter length" .BR length " Prefers line with shorter length"
.br .br
.BR chunk " Prefers line with shorter matched chunk (delimited by whitespaces)" .BR chunk " Prefers line with shorter matched chunk (delimited by whitespaces)"
.br .br
.BR begin " Prefers line with matched substring closer to the beginning" .BR pathname " Prefers line with matched substring in the file name of the path"
.br .br
.BR end " Prefers line with matched substring closer to the end" .BR begin " Prefers line with matched substring closer to the beginning"
.br .br
.BR index " Prefers line that appeared earlier in the input stream" .BR end " Prefers line with matched substring closer to the end"
.br
.BR index " Prefers line that appeared earlier in the input stream"
.br .br
.br .br
@ -1128,9 +1138,9 @@ Show man page
.SH ENVIRONMENT VARIABLES .SH ENVIRONMENT VARIABLES
.TP .TP
.B FZF_DEFAULT_COMMAND .B FZF_DEFAULT_COMMAND
Default command to use when input is tty. On *nix systems, fzf runs the command Default command to use when input is a TTY device. On *nix systems, fzf runs
with \fB$SHELL \-c\fR if \fBSHELL\fR is set, otherwise with \fBsh \-c\fR, so in the command with \fB$SHELL \-c\fR if \fBSHELL\fR is set, otherwise with \fBsh
this case make sure that the command is POSIX-compliant. \-c\fR, so in this case make sure that the command is POSIX-compliant.
.TP .TP
.B FZF_DEFAULT_OPTS .B FZF_DEFAULT_OPTS
Default options. Default options.

View File

@ -1081,7 +1081,7 @@ endfunction
function! s:cmd(bang, ...) abort function! s:cmd(bang, ...) abort
let args = copy(a:000) let args = copy(a:000)
let opts = { 'options': ['--multi'] } let opts = { 'options': ['--multi', '--scheme', 'path'] }
if len(args) && isdirectory(expand(args[-1])) if len(args) && isdirectory(expand(args[-1]))
let opts.dir = substitute(substitute(remove(args, -1), '\\\(["'']\)', '\1', 'g'), '[/\\]*$', '/', '') let opts.dir = substitute(substitute(remove(args, -1), '\\\(["'']\)', '\1', 'g'), '[/\\]*$', '/', '')
if s:is_win && !&shellslash if s:is_win && !&shellslash

View File

@ -188,6 +188,9 @@ func Run(opts *Options) (int, error) {
forward = false forward = false
case byBegin: case byBegin:
forward = true forward = true
case byPathname:
withPos = true
forward = false
} }
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
"github.com/junegunn/fzf/src/util"
"github.com/junegunn/go-shellwords" "github.com/junegunn/go-shellwords"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
@ -46,8 +47,8 @@ Usage: fzf [options]
--tail=NUM Maximum number of items to keep in memory --tail=NUM Maximum number of items to keep in memory
--disabled Do not perform search --disabled Do not perform search
--tiebreak=CRI[,..] Comma-separated list of sort criteria to apply --tiebreak=CRI[,..] Comma-separated list of sort criteria to apply
when the scores are tied [length|chunk|begin|end|index] when the scores are tied
(default: length) [length|chunk|pathname|begin|end|index] (default: length)
INPUT/OUTPUT INPUT/OUTPUT
--read0 Read input delimited by ASCII NUL characters --read0 Read input delimited by ASCII NUL characters
@ -241,6 +242,7 @@ const (
byLength byLength
byBegin byBegin
byEnd byEnd
byPathname
) )
type heightSpec struct { type heightSpec struct {
@ -653,7 +655,7 @@ func defaultOptions() *Options {
Man: false, Man: false,
Fuzzy: true, Fuzzy: true,
FuzzyAlgo: algo.FuzzyMatchV2, FuzzyAlgo: algo.FuzzyMatchV2,
Scheme: "default", Scheme: "", // Unknown
Extended: true, Extended: true,
Phony: false, Phony: false,
Case: CaseSmart, Case: CaseSmart,
@ -664,7 +666,7 @@ func defaultOptions() *Options {
Sort: 1000, Sort: 1000,
Track: trackDisabled, Track: trackDisabled,
Tac: false, Tac: false,
Criteria: []criterion{byScore, byLength}, Criteria: []criterion{}, // Unknown
Multi: 0, Multi: 0,
Ansi: false, Ansi: false,
Mouse: true, Mouse: true,
@ -804,16 +806,6 @@ func parseAlgo(str string) (algo.Algo, error) {
return nil, errors.New("invalid algorithm (expected: v1 or v2)") 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) { func parseBorder(str string, optional bool, allowLine bool) (tui.BorderShape, error) {
switch str { switch str {
case "line": case "line":
@ -1037,6 +1029,19 @@ func parseKeyChordsImpl(str string, message string) (map[tui.Event]string, error
return chords, nil 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) { func parseTiebreak(str string) ([]criterion, error) {
criteria := []criterion{byScore} criteria := []criterion{byScore}
hasIndex := false hasIndex := false
@ -1044,6 +1049,7 @@ func parseTiebreak(str string) ([]criterion, error) {
hasLength := false hasLength := false
hasBegin := false hasBegin := false
hasEnd := false hasEnd := false
hasPathname := false
check := func(notExpected *bool, name string) error { check := func(notExpected *bool, name string) error {
if *notExpected { if *notExpected {
return errors.New("duplicate sort criteria: " + name) return errors.New("duplicate sort criteria: " + name)
@ -1065,6 +1071,11 @@ func parseTiebreak(str string) ([]criterion, error) {
return nil, err return nil, err
} }
criteria = append(criteria, byChunk) criteria = append(criteria, byChunk)
case "pathname":
if err := check(&hasPathname, "pathname"); err != nil {
return nil, err
}
criteria = append(criteria, byPathname)
case "length": case "length":
if err := check(&hasLength, "length"); err != nil { if err := check(&hasLength, "length"); err != nil {
return nil, err return nil, err
@ -2261,7 +2272,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if err != nil { if err != nil {
return err return err
} }
opts.Scheme = strings.ToLower(str) if opts.Scheme, opts.Criteria, err = parseScheme(str); err != nil {
return err
}
case "--expect": case "--expect":
str, err := nextString("key names required") str, err := nextString("key names required")
if err != nil { if err != nil {
@ -3173,7 +3186,9 @@ func postProcessOptions(opts *Options) error {
return errors.New("failed to start pprof profiles: " + err.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) { func parseShellWords(str string) ([]string, error) {
@ -3223,7 +3238,26 @@ func ParseOptions(useDefaults bool, args []string) (*Options, error) {
return nil, err 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 { if err := validateOptions(opts); err != nil {
return nil, err return nil, err
} }
@ -3231,6 +3265,17 @@ func ParseOptions(useDefaults bool, args []string) (*Options, error) {
return opts, nil 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 { func (opts *Options) extractReloadOnStart() string {
cmd := "" cmd := ""
if actions, prs := opts.Keymap[tui.Start.AsEvent()]; prs { if actions, prs := opts.Keymap[tui.Start.AsEvent()]; prs {

View File

@ -69,6 +69,21 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
} }
case byLength: case byLength:
val = item.TrimLength() 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: case byBegin, byEnd:
if validOffsetFound { if validOffsetFound {
whitePrefixLen := 0 whitePrefixLen := 0