Skip to content

Commit

Permalink
Accept comma-separated list of sort criteria
Browse files Browse the repository at this point in the history
  • Loading branch information
junegunn committed Jan 13, 2016
1 parent d635b3f commit 1d2d32c
Show file tree
Hide file tree
Showing 12 changed files with 298 additions and 120 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
CHANGELOG
=========

0.11.2
------

- `--tiebreak` now accepts comma-separated list of sort criteria.
- Each criterion should appear only once in the list
- `index` is only allowed at the end of the list
- `index` is implicitly appended to the list when not specified
- Default is `length` (or equivalently `length,index`)

0.11.1
------

Expand Down
15 changes: 12 additions & 3 deletions man/man1/fzf.1
Original file line number Diff line number Diff line change
Expand Up @@ -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 "Dec 2015" "fzf 0.11.1" "fzf - a command-line fuzzy finder"
.TH fzf 1 "Jan 2016" "fzf 0.11.2" "fzf - a command-line fuzzy finder"

.SH NAME
fzf - a command-line fuzzy finder
Expand Down Expand Up @@ -68,8 +68,8 @@ Reverse the order of the input
e.g. \fBhistory | fzf --tac --no-sort\fR
.RE
.TP
.BI "--tiebreak=" "CRI"
Sort criterion to use when the scores are tied
.BI "--tiebreak=" "CRI[,..]"
Comma-separated list of sort criteria to apply when the scores are tied.
.br
.R ""
.br
Expand All @@ -81,6 +81,15 @@ Sort criterion to use when the scores are tied
.br
.BR index " Prefers item that appeared earlier in the input stream"
.br
.R ""
.br
- Each criterion should appear only once in the list
.br
- \fBindex\fR is only allowed at the end of the list
.br
- \fBindex\fR is implicitly appended to the list when not specified
.br
- Default is \fBlength\fR (or equivalently \fBlength\fR,index)
.SS Interface
.TP
.B "-m, --multi"
Expand Down
12 changes: 9 additions & 3 deletions src/chunklist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
)

func TestChunkList(t *testing.T) {
// FIXME global
sortCriteria = []criterion{byMatchLen, byLength, byIndex}

cl := NewChunkList(func(s []byte, i int) *Item {
return &Item{text: []rune(string(s)), rank: Rank{0, 0, uint32(i * 2)}}
return &Item{text: []rune(string(s)), rank: buildEmptyRank(int32(i * 2))}
})

// Snapshot
Expand Down Expand Up @@ -36,8 +39,11 @@ func TestChunkList(t *testing.T) {
if len(*chunk1) != 2 {
t.Error("Snapshot should contain only two items")
}
if string((*chunk1)[0].text) != "hello" || (*chunk1)[0].rank.index != 0 ||
string((*chunk1)[1].text) != "world" || (*chunk1)[1].rank.index != 2 {
last := func(arr []int32) int32 {
return arr[len(arr)-1]
}
if string((*chunk1)[0].text) != "hello" || last((*chunk1)[0].rank) != 0 ||
string((*chunk1)[1].text) != "world" || last((*chunk1)[1].rank) != 2 {
t.Error("Invalid data")
}
if chunk1.IsFull() {
Expand Down
22 changes: 16 additions & 6 deletions src/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func Run(opts *Options) {
initProcs()

sort := opts.Sort > 0
rankTiebreak = opts.Tiebreak
sortCriteria = opts.Criteria

if opts.Version {
fmt.Println(version)
Expand Down Expand Up @@ -103,9 +103,9 @@ func Run(opts *Options) {
runes, colors := ansiProcessor(data)
return &Item{
text: runes,
index: uint32(index),
index: int32(index),
colors: colors,
rank: Rank{0, 0, uint32(index)}}
rank: buildEmptyRank(int32(index))}
})
} else {
chunkList = NewChunkList(func(data []byte, index int) *Item {
Expand All @@ -120,9 +120,9 @@ func Run(opts *Options) {
item := Item{
text: joinTokens(trans),
origText: &runes,
index: uint32(index),
index: int32(index),
colors: nil,
rank: Rank{0, 0, uint32(index)}}
rank: buildEmptyRank(int32(index))}

trimmed, colors := ansiProcessorRunes(item.text)
item.text = trimmed
Expand All @@ -141,9 +141,19 @@ func Run(opts *Options) {
}

// Matcher
forward := true
for _, cri := range opts.Criteria[1:] {
if cri == byEnd {
forward = false
break
}
if cri == byBegin {
break
}
}
patternBuilder := func(runes []rune) *Pattern {
return BuildPattern(
opts.Fuzzy, opts.Extended, opts.Case, opts.Tiebreak != byEnd,
opts.Fuzzy, opts.Extended, opts.Case, forward,
opts.Nth, opts.Delimiter, runes)
}
matcher := NewMatcher(patternBuilder, sort, opts.Tac, eventBox)
Expand Down
110 changes: 63 additions & 47 deletions src/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,35 @@ type Item struct {
text []rune
origText *[]rune
transformed []Token
index uint32
index int32
offsets []Offset
colors []ansiOffset
rank Rank
rank []int32
}

// Rank is used to sort the search result
type Rank struct {
matchlen uint16
tiebreak uint16
index uint32
// Sort criteria to use. Never changes once fzf is started.
var sortCriteria []criterion

func isRankValid(rank []int32) bool {
// Exclude ordinal index
for i := 0; i < len(rank)-1; i++ {
if rank[i] > 0 {
return true
}
}
return false
}

// Tiebreak criterion to use. Never changes once fzf is started.
var rankTiebreak tiebreak
func buildEmptyRank(index int32) []int32 {
len := len(sortCriteria)
arr := make([]int32, len)
arr[len-1] = index
return arr
}

// Rank calculates rank of the Item
func (item *Item) Rank(cache bool) Rank {
if cache && (item.rank.matchlen > 0 || item.rank.tiebreak > 0) {
func (item *Item) Rank(cache bool) []int32 {
if cache && isRankValid(item.rank) {
return item.rank
}
matchlen := 0
Expand All @@ -64,32 +74,37 @@ func (item *Item) Rank(cache bool) Rank {
}
}
if matchlen == 0 {
matchlen = math.MaxUint16
matchlen = math.MaxInt32
}
var tiebreak uint16
switch rankTiebreak {
case byLength:
// It is guaranteed that .transformed in not null in normal execution
if item.transformed != nil {
// If offsets is empty, lenSum will be 0, but we don't care
tiebreak = uint16(lenSum)
} else {
tiebreak = uint16(len(item.text))
}
case byBegin:
// We can't just look at item.offsets[0][0] because it can be an inverse term
tiebreak = uint16(minBegin)
case byEnd:
if prevEnd > 0 {
tiebreak = uint16(1 + len(item.text) - prevEnd)
} else {
// Empty offsets due to inverse terms.
tiebreak = 1
rank := make([]int32, len(sortCriteria))
for idx, criterion := range sortCriteria {
var val int32
switch criterion {
case byMatchLen:
val = int32(matchlen)
case byLength:
// It is guaranteed that .transformed in not null in normal execution
if item.transformed != nil {
// If offsets is empty, lenSum will be 0, but we don't care
val = int32(lenSum)
} else {
val = int32(len(item.text))
}
case byBegin:
// We can't just look at item.offsets[0][0] because it can be an inverse term
val = int32(minBegin)
case byEnd:
if prevEnd > 0 {
val = int32(1 + len(item.text) - prevEnd)
} else {
// Empty offsets due to inverse terms.
val = 1
}
case byIndex:
val = item.index
}
case byIndex:
tiebreak = 1
rank[idx] = val
}
rank := Rank{uint16(matchlen), tiebreak, item.index}
if cache {
item.rank = rank
}
Expand Down Expand Up @@ -254,18 +269,19 @@ func (a ByRelevanceTac) Less(i, j int) bool {
return compareRanks(irank, jrank, true)
}

func compareRanks(irank Rank, jrank Rank, tac bool) bool {
if irank.matchlen < jrank.matchlen {
return true
} else if irank.matchlen > jrank.matchlen {
return false
}

if irank.tiebreak < jrank.tiebreak {
return true
} else if irank.tiebreak > jrank.tiebreak {
return false
func compareRanks(irank []int32, jrank []int32, tac bool) bool {
lastIdx := len(irank) - 1
for idx, left := range irank {
right := jrank[idx]
if tac && idx == lastIdx {
left = left * -1
right = right * -1
}
if left < right {
return true
} else if left > right {
return false
}
}

return (irank.index <= jrank.index) != tac
return true
}
29 changes: 16 additions & 13 deletions src/item_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,30 @@ func TestOffsetSort(t *testing.T) {
}

func TestRankComparison(t *testing.T) {
if compareRanks(Rank{3, 0, 5}, Rank{2, 0, 7}, false) ||
!compareRanks(Rank{3, 0, 5}, Rank{3, 0, 6}, false) ||
!compareRanks(Rank{1, 2, 3}, Rank{1, 3, 2}, false) ||
!compareRanks(Rank{0, 0, 0}, Rank{0, 0, 0}, false) {
if compareRanks([]int32{3, 0, 5}, []int32{2, 0, 7}, false) ||
!compareRanks([]int32{3, 0, 5}, []int32{3, 0, 6}, false) ||
!compareRanks([]int32{1, 2, 3}, []int32{1, 3, 2}, false) ||
!compareRanks([]int32{0, 0, 0}, []int32{0, 0, 0}, false) {
t.Error("Invalid order")
}

if compareRanks(Rank{3, 0, 5}, Rank{2, 0, 7}, true) ||
!compareRanks(Rank{3, 0, 5}, Rank{3, 0, 6}, false) ||
!compareRanks(Rank{1, 2, 3}, Rank{1, 3, 2}, true) ||
!compareRanks(Rank{0, 0, 0}, Rank{0, 0, 0}, false) {
if compareRanks([]int32{3, 0, 5}, []int32{2, 0, 7}, true) ||
!compareRanks([]int32{3, 0, 5}, []int32{3, 0, 6}, false) ||
!compareRanks([]int32{1, 2, 3}, []int32{1, 3, 2}, true) ||
!compareRanks([]int32{0, 0, 0}, []int32{0, 0, 0}, false) {
t.Error("Invalid order (tac)")
}
}

// Match length, string length, index
func TestItemRank(t *testing.T) {
// FIXME global
sortCriteria = []criterion{byMatchLen, byLength, byIndex}

strs := [][]rune{[]rune("foo"), []rune("foobar"), []rune("bar"), []rune("baz")}
item1 := Item{text: strs[0], index: 1, offsets: []Offset{}}
rank1 := item1.Rank(true)
if rank1.matchlen != math.MaxUint16 || rank1.tiebreak != 3 || rank1.index != 1 {
if rank1[0] != math.MaxInt32 || rank1[1] != 3 || rank1[2] != 1 {
t.Error(item1.Rank(true))
}
// Only differ in index
Expand All @@ -63,10 +66,10 @@ func TestItemRank(t *testing.T) {
}

// Sort by relevance
item3 := Item{text: strs[1], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 3}, Offset{5, 7}}}
item4 := Item{text: strs[1], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 2}, Offset{6, 7}}}
item5 := Item{text: strs[2], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 3}, Offset{5, 7}}}
item6 := Item{text: strs[2], rank: Rank{0, 0, 2}, offsets: []Offset{Offset{1, 2}, Offset{6, 7}}}
item3 := Item{text: strs[1], rank: []int32{0, 0, 2}, offsets: []Offset{Offset{1, 3}, Offset{5, 7}}}
item4 := Item{text: strs[1], rank: []int32{0, 0, 2}, offsets: []Offset{Offset{1, 2}, Offset{6, 7}}}
item5 := Item{text: strs[2], rank: []int32{0, 0, 2}, offsets: []Offset{Offset{1, 3}, Offset{5, 7}}}
item6 := Item{text: strs[2], rank: []int32{0, 0, 2}, offsets: []Offset{Offset{1, 2}, Offset{6, 7}}}
items = []*Item{&item1, &item2, &item3, &item4, &item5, &item6}
sort.Sort(ByRelevance(items))
if items[0] != &item6 || items[1] != &item4 ||
Expand Down
2 changes: 1 addition & 1 deletion src/merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (mg *Merger) cacheable() bool {

func (mg *Merger) mergedGet(idx int) *Item {
for i := len(mg.merged); i <= idx; i++ {
minRank := Rank{0, 0, 0}
minRank := buildEmptyRank(0)
minIdx := -1
for listIdx, list := range mg.lists {
cursor := mg.cursors[listIdx]
Expand Down
2 changes: 1 addition & 1 deletion src/merger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func randItem() *Item {
}
return &Item{
text: []rune(str),
index: rand.Uint32(),
index: rand.Int31(),
offsets: offsets}
}

Expand Down
Loading

0 comments on commit 1d2d32c

Please sign in to comment.