diff --git a/fzf b/fzf index 69bf14f..1bc5673 100755 --- a/fzf +++ b/fzf @@ -206,9 +206,10 @@ class FZF @expect = true when /^--expect=(.*)$/ @expect = true - when '--toggle-sort' + when '--toggle-sort', '--tiebreak' argv.shift - when '--tac', '--sync', '--toggle-sort', /^--toggle-sort=(.*)$/ + when '--tac', '--no-tac', '--sync', '--no-sync', '--hscroll', '--no-hscroll', + /^--toggle-sort=(.*)$/, /^--tiebreak=(.*)$/ # XXX else usage 1, "illegal option: #{o}" diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 0afa4cd..ada0340 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -66,6 +66,20 @@ Reverse the order of the input .RS e.g. \fBhistory | fzf --tac --no-sort\fR .RE +.TP +.BI "--tiebreak=" "STR" +Sort criterion to use when the scores are tied +.br +.R "" +.br +.BR length " Prefers item with shorter length" +.br +.BR begin " Prefers item with matched substring closer to the beginning" +.br +.BR end " Prefers item with matched substring closer to the end"" +.br +.BR index " Prefers item that appeared earlier in the input stream" +.br .SS Interface .TP .B "-m, --multi" diff --git a/src/core.go b/src/core.go index 9f33b41..4a83424 100644 --- a/src/core.go +++ b/src/core.go @@ -55,6 +55,7 @@ func Run(options *Options) { opts := ParseOptions() sort := opts.Sort > 0 + rankTiebreak = opts.Tiebreak if opts.Version { fmt.Println(Version) diff --git a/src/item.go b/src/item.go index 9e2e1e7..996c5e1 100644 --- a/src/item.go +++ b/src/item.go @@ -1,6 +1,8 @@ package fzf import ( + "math" + "github.com/junegunn/fzf/src/curses" ) @@ -27,17 +29,21 @@ type Item struct { // Rank is used to sort the search result type Rank struct { matchlen uint16 - strlen uint16 + tiebreak uint16 index uint32 } +// Tiebreak criterion to use. Never changes once fzf is started. +var rankTiebreak tiebreak + // Rank calculates rank of the Item func (i *Item) Rank(cache bool) Rank { - if cache && (i.rank.matchlen > 0 || i.rank.strlen > 0) { + if cache && (i.rank.matchlen > 0 || i.rank.tiebreak > 0) { return i.rank } matchlen := 0 prevEnd := 0 + minBegin := math.MaxUint16 for _, offset := range i.offsets { begin := int(offset[0]) end := int(offset[1]) @@ -48,10 +54,30 @@ func (i *Item) Rank(cache bool) Rank { prevEnd = end } if end > begin { + if begin < minBegin { + minBegin = begin + } matchlen += end - begin } } - rank := Rank{uint16(matchlen), uint16(len(*i.text)), i.index} + var tiebreak uint16 + switch rankTiebreak { + case byLength: + tiebreak = uint16(len(*i.text)) + case byBegin: + // We can't just look at i.offsets[0][0] because it can be an inverse term + tiebreak = uint16(minBegin) + case byEnd: + if prevEnd > 0 { + tiebreak = uint16(1 + len(*i.text) - prevEnd) + } else { + // Empty offsets due to inverse terms. + tiebreak = 1 + } + case byIndex: + tiebreak = 1 + } + rank := Rank{uint16(matchlen), tiebreak, i.index} if cache { i.rank = rank } @@ -199,9 +225,9 @@ func compareRanks(irank Rank, jrank Rank, tac bool) bool { return false } - if irank.strlen < jrank.strlen { + if irank.tiebreak < jrank.tiebreak { return true - } else if irank.strlen > jrank.strlen { + } else if irank.tiebreak > jrank.tiebreak { return false } diff --git a/src/item_test.go b/src/item_test.go index 4eea8c1..2d375e4 100644 --- a/src/item_test.go +++ b/src/item_test.go @@ -42,7 +42,7 @@ func TestItemRank(t *testing.T) { strs := []string{"foo", "foobar", "bar", "baz"} item1 := Item{text: &strs[0], index: 1, offsets: []Offset{}} rank1 := item1.Rank(true) - if rank1.matchlen != 0 || rank1.strlen != 3 || rank1.index != 1 { + if rank1.matchlen != 0 || rank1.tiebreak != 3 || rank1.index != 1 { t.Error(item1.Rank(true)) } // Only differ in index diff --git a/src/options.go b/src/options.go index c186542..d13a53b 100644 --- a/src/options.go +++ b/src/options.go @@ -28,7 +28,8 @@ const usage = `usage: fzf [options] Search result +s, --no-sort Do not sort the result --tac Reverse the order of the input - (e.g. 'history | fzf --tac --no-sort') + --tiebreak=CRI Sort criterion when the scores are tied; + [length|begin|end|index] (default: length) Interface -m, --multi Enable multi-select with tab/shift-tab @@ -50,7 +51,6 @@ const usage = `usage: fzf [options] --expect=KEYS Comma-separated list of keys to complete fzf --toggle-sort=KEY Key to toggle sort --sync Synchronous search for multi-staged filtering - (e.g. 'fzf --multi | fzf --sync') Environment variables FZF_DEFAULT_COMMAND Default command to use when input is tty @@ -78,6 +78,16 @@ const ( CaseRespect ) +// Sort criteria +type tiebreak int + +const ( + byLength tiebreak = iota + byBegin + byEnd + byIndex +) + // Options stores the values of command-line options type Options struct { Mode Mode @@ -87,6 +97,7 @@ type Options struct { Delimiter *regexp.Regexp Sort int Tac bool + Tiebreak tiebreak Multi bool Ansi bool Mouse bool @@ -116,6 +127,7 @@ func defaultOptions() *Options { Delimiter: nil, Sort: 1000, Tac: false, + Tiebreak: byLength, Multi: false, Ansi: false, Mouse: true, @@ -238,6 +250,22 @@ func parseKeyChords(str string, message string) []int { return chords } +func parseTiebreak(str string) tiebreak { + switch strings.ToLower(str) { + case "length": + return byLength + case "index": + return byIndex + case "begin": + return byBegin + case "end": + return byEnd + default: + errorExit("invalid sort criterion: " + str) + } + return byLength +} + func checkToggleSort(str string) int { keys := parseKeyChords(str, "key name required") if len(keys) != 1 { @@ -265,6 +293,8 @@ func parseOptions(opts *Options, allArgs []string) { opts.Filter = &filter case "--expect": opts.Expect = parseKeyChords(nextString(allArgs, &i, "key names required"), "key names required") + case "--tiebreak": + opts.Tiebreak = parseTiebreak(nextString(allArgs, &i, "sort criterion required")) case "--toggle-sort": opts.ToggleSort = checkToggleSort(nextString(allArgs, &i, "key name required")) case "-d", "--delimiter": @@ -352,6 +382,8 @@ func parseOptions(opts *Options, allArgs []string) { opts.ToggleSort = checkToggleSort(value) } else if match, value := optString(arg, "--expect="); match { opts.Expect = parseKeyChords(value, "key names required") + } else if match, value := optString(arg, "--tiebreak="); match { + opts.Tiebreak = parseTiebreak(value) } else { errorExit("unknown option: " + arg) } diff --git a/test/test_go.rb b/test/test_go.rb index a9284b6..abf0496 100644 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -476,19 +476,66 @@ class TestGoFZF < TestBase def test_unicode_case tempname = TEMPNAME + Time.now.to_f.to_s - File.open(tempname, 'w') do |f| - f << %w[строКА1 СТРОКА2 строка3 Строка4].join($/) - f.sync - end - since = Time.now - while `cat #{tempname}`.split($/).length != 4 && (Time.now - since) < 10 - sleep 0.1 - end + writelines tempname, %w[строКА1 СТРОКА2 строка3 Строка4] assert_equal %w[СТРОКА2 Строка4], `cat #{tempname} | #{FZF} -fС`.split($/) assert_equal %w[строКА1 СТРОКА2 строка3 Строка4], `cat #{tempname} | #{FZF} -fс`.split($/) rescue File.unlink tempname end + + def test_tiebreak + tempname = TEMPNAME + Time.now.to_f.to_s + input = %w[ + --foobar-------- + -----foobar--- + ----foobar-- + -------foobar- + ] + writelines tempname, input + + assert_equal input, `cat #{tempname} | #{FZF} -ffoobar --tiebreak=index`.split($/) + + by_length = %w[ + ----foobar-- + -----foobar--- + -------foobar- + --foobar-------- + ] + assert_equal by_length, `cat #{tempname} | #{FZF} -ffoobar`.split($/) + assert_equal by_length, `cat #{tempname} | #{FZF} -ffoobar --tiebreak=length`.split($/) + + by_begin = %w[ + --foobar-------- + ----foobar-- + -----foobar--- + -------foobar- + ] + assert_equal by_begin, `cat #{tempname} | #{FZF} -ffoobar --tiebreak=begin`.split($/) + assert_equal by_begin, `cat #{tempname} | #{FZF} -f"!z foobar" -x --tiebreak begin`.split($/) + + assert_equal %w[ + -------foobar- + ----foobar-- + -----foobar--- + --foobar-------- + ], `cat #{tempname} | #{FZF} -ffoobar --tiebreak end`.split($/) + + assert_equal input, `cat #{tempname} | #{FZF} -f"!z" -x --tiebreak end`.split($/) + rescue + File.unlink tempname + end + +private + def writelines path, lines, timeout = 10 + File.open(path, 'w') do |f| + f << lines.join($/) + f.sync + end + since = Time.now + while `cat #{path}`.split($/).length != lines.length && (Time.now - since) < 10 + sleep 0.1 + end + end end module TestShell