Repository: junegunn/fzf (language: go)

Issue:
Consider 'pathname' tiebreak
https://github.com/junegunn/fzf/issues/3608#issuecomment-1925509323


Discussion / hints:
This was a proof-of-concept patch introducing a `pathname` tiebreak, enabling fzf to prioritize matches occurring in the tail element of a file path. I haven't experimented with it enough to tell if it's good or not.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87898044744..5cd85934d78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,13 @@
 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
 ------
 _Release highlights: https://junegunn.github.io/fzf/releases/0.58.0/_
diff --git a/README.md b/README.md
index 4d235998d8e..5164c4b1949 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@ Table of Contents
     * [Custom fuzzy completion](#custom-fuzzy-completion)
 * [Vim plugin](#vim-plugin)
 * [Advanced topics](#advanced-topics)
+    * [Customizing for different types of input](#customizing-for-different-types-of-input)
     * [Performance](#performance)
     * [Executing external programs](#executing-external-programs)
     * [Turning into a different process](#turning-into-a-different-process)
@@ -718,6 +719,22 @@ See [README-VIM.md](README-VIM.md).
 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
 
 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
   to your `$FZF_DEFAULT_OPTS`.
 - `--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
   line.
 
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index 16efa85b346..20b0003cf8f 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -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
 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
 fzf - a command-line fuzzy finder
@@ -76,7 +76,8 @@ Generic scoring scheme designed to work well with any type of input.
 .RS
 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
-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
 
@@ -90,6 +91,13 @@ more weight to the chronological ordering. This also sets
 .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
 .BI "\-\-algo=" TYPE
 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 length "  Prefers line with shorter length"
+.BR length "   Prefers line with shorter length"
+.br
+.BR chunk "    Prefers line with shorter matched chunk (delimited by whitespaces)"
 .br
-.BR chunk "   Prefers line with shorter matched chunk (delimited by whitespaces)"
+.BR pathname " Prefers line with matched substring in the file name of the path"
 .br
-.BR begin "   Prefers line with matched substring closer to the beginning"
+.BR begin "    Prefers line with matched substring closer to the beginning"
 .br
-.BR end "     Prefers line with matched substring closer to the end"
+.BR end "      Prefers line with matched substring closer to the end"
 .br
-.BR index "   Prefers line that appeared earlier in the input stream"
+.BR index "    Prefers line that appeared earlier in the input stream"
 .br
 
 .br
@@ -1122,9 +1132,9 @@ Show man page
 .SH ENVIRONMENT VARIABLES
 .TP
 .B FZF_DEFAULT_COMMAND
-Default command to use when input is tty. On *nix systems, fzf runs the command
-with \fB$SHELL \-c\fR if \fBSHELL\fR is set, otherwise with \fBsh \-c\fR, so in
-this case make sure that the command is POSIX-compliant.
+Default command to use when input is a TTY device. On *nix systems, fzf runs
+the command with \fB$SHELL \-c\fR if \fBSHELL\fR is set, otherwise with \fBsh
+\-c\fR, so in this case make sure that the command is POSIX-compliant.
 .TP
 .B FZF_DEFAULT_OPTS
 Default options.
diff --git a/plugin/fzf.vim b/plugin/fzf.vim
index 7f72199851e..b865162a4c1 100644
--- a/plugin/fzf.vim
+++ b/plugin/fzf.vim
@@ -1081,7 +1081,7 @@ endfunction
 
 function! s:cmd(bang, ...) abort
   let args = copy(a:000)
-  let opts = { 'options': ['--multi'] }
+  let opts = { 'options': ['--multi', '--scheme', 'path'] }
   if len(args) && isdirectory(expand(args[-1]))
     let opts.dir = substitute(substitute(remove(args, -1), '\\\(["'']\)', '\1', 'g'), '[/\\]*$', '/', '')
     if s:is_win && !&shellslash
diff --git a/src/core.go b/src/core.go
index b8851d79d30..71cf04dab88 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 2168284b6b7..e9a6181332a 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
@@ -238,6 +239,7 @@ const (
 	byLength
 	byBegin
 	byEnd
+	byPathname
 )
 
 type heightSpec struct {
@@ -649,7 +651,7 @@ func defaultOptions() *Options {
 		Man:          false,
 		Fuzzy:        true,
 		FuzzyAlgo:    algo.FuzzyMatchV2,
-		Scheme:       "default",
+		Scheme:       "", // Unknown
 		Extended:     true,
 		Phony:        false,
 		Case:         CaseSmart,
@@ -660,7 +662,7 @@ func defaultOptions() *Options {
 		Sort:         1000,
 		Track:        trackDisabled,
 		Tac:          false,
-		Criteria:     []criterion{byScore, byLength},
+		Criteria:     []criterion{}, // Unknown
 		Multi:        0,
 		Ansi:         false,
 		Mouse:        true,
@@ -800,16 +802,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":
@@ -1033,6 +1025,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
@@ -1040,6 +1045,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)
@@ -1061,6 +1067,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
@@ -2257,7 +2268,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 {
@@ -3156,7 +3169,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) {
@@ -3206,7 +3221,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
 	}
@@ -3214,6 +3248,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 10e0c6d6446..28d42e7d06b 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


Test patch: