Make --accept-nth and --with-nth support templates

This commit is contained in:
Junegunn Choi 2025-02-12 20:15:04 +09:00
parent 378137d34a
commit 84e2262ad6
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
9 changed files with 158 additions and 48 deletions

View File

@ -1,6 +1,40 @@
CHANGELOG CHANGELOG
========= =========
0.60.0
------
- Added `--accept-nth` for choosing output fields
```sh
ps -ef | fzf --multi --header-lines 1 | awk '{print $2}'
# Becomes
ps -ef | fzf --multi --header-lines 1 --accept-nth 2
git branch | fzf | cut -c3-
# Can be rewritten as
git branch | fzf --accept-nth -1
```
- `--accept-nth` and `--with-nth` now support a template that includes multiple field index expressions in curly braces
```sh
echo foo,bar,baz | fzf --delimiter , --accept-nth '{1}, {3}, {2}'
# foo, baz, bar
echo foo,bar,baz | fzf --delimiter , --with-nth '{1},{3},{2},{1..2}'
# foo,baz,bar,foo,bar
```
- Added `exclude` and `exclude-multi` actions for dynamically excluding items
```sh
seq 100 | fzf --bind 'ctrl-x:exclude'
# 'exclude-multi' will exclude the selected items or the current item
seq 100 | fzf --multi --bind 'ctrl-x:exclude-multi'
```
- Preview window now prints wrap indicator when wrapping is enabled
```sh
seq 100 | xargs | fzf --wrap --preview 'echo {}' --preview-window wrap
```
- Bug fixes and improvements
0.59.0 0.59.0
------ ------
_Release highlights: https://junegunn.github.io/fzf/releases/0.59.0/_ _Release highlights: https://junegunn.github.io/fzf/releases/0.59.0/_
@ -365,7 +399,7 @@ _Release highlights: https://junegunn.github.io/fzf/releases/0.54.0/_
- fzf will not start the initial reader when `reload` or `reload-sync` is bound to `start` event. `fzf < /dev/null` or `: | fzf` are no longer required and extraneous `load` event will not fire due to the empty list. - fzf will not start the initial reader when `reload` or `reload-sync` is bound to `start` event. `fzf < /dev/null` or `: | fzf` are no longer required and extraneous `load` event will not fire due to the empty list.
```sh ```sh
# Now this will work as expected. Previously, this would print an invalid header line. # Now this will work as expected. Previously, this would print an invalid header line.
# `fzf < /dev/null` or `: | fzf` would fix the problem, but then an extraneous # `fzf < /dev/null` or `: | fzf` would fix the problem, but then an extraneous
# `load` event would fire and the header would be prematurely updated. # `load` event would fire and the header would be prematurely updated.
fzf --header 'Loading ...' --header-lines 1 \ fzf --header 'Loading ...' --header-lines 1 \
--bind 'start:reload:sleep 1; ps -ef' \ --bind 'start:reload:sleep 1; ps -ef' \

View File

@ -117,12 +117,33 @@ transformed lines (unlike in \fB\-\-preview\fR where fields are extracted from
the original lines) because fzf doesn't allow searching against the hidden the original lines) because fzf doesn't allow searching against the hidden
fields. fields.
.TP .TP
.BI "\-\-with\-nth=" "N[,..]" .BI "\-\-with\-nth=" "N[,..] or TEMPLATE"
Transform the presentation of each line using field index expressions Transform the presentation of each line using the field index expressions.
For advanced transformation, you can provide a template containing field index
expressions in curly braces.
.RS
e.g.
# Single expression: drop the first field
echo foo bar baz | fzf --with-nth 2..
# Use template to rearrange fields
echo foo,bar,baz | fzf --delimiter , --with-nth '{1},{3},{2},{1..2}'
.RE
.TP .TP
.BI "\-\-accept\-nth=" "N[,..]" .BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
Define which fields to print on accept. The last delimiter is stripped from the Define which fields to print on accept. The last delimiter is stripped from the
output. output. For advanced transformation, you can provide a template containing
field index expressions in curly braces.
.RS
e.g.
# Single expression
echo foo bar baz | fzf --accept-nth 2
# Template
echo foo bar baz | fzf --accept-nth '1st: {1}, 2nd: {2}, 3rd: {3}'
.RE
.TP .TP
.B "+s, \-\-no\-sort" .B "+s, \-\-no\-sort"
Do not sort the result Do not sort the result

View File

@ -96,7 +96,7 @@ func Run(opts *Options) (int, error) {
var chunkList *ChunkList var chunkList *ChunkList
var itemIndex int32 var itemIndex int32
header := make([]string, 0, opts.HeaderLines) header := make([]string, 0, opts.HeaderLines)
if len(opts.WithNth) == 0 { if opts.WithNth == nil {
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
if len(header) < opts.HeaderLines { if len(header) < opts.HeaderLines {
header = append(header, byteString(data)) header = append(header, byteString(data))
@ -109,6 +109,7 @@ func Run(opts *Options) (int, error) {
return true return true
}) })
} else { } else {
nthTransformer := opts.WithNth(opts.Delimiter)
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
tokens := Tokenize(byteString(data), opts.Delimiter) tokens := Tokenize(byteString(data), opts.Delimiter)
if opts.Ansi && opts.Theme.Colored && len(tokens) > 1 { if opts.Ansi && opts.Theme.Colored && len(tokens) > 1 {
@ -127,15 +128,13 @@ func Run(opts *Options) (int, error) {
} }
} }
} }
trans := Transform(tokens, opts.WithNth) transformed := nthTransformer(tokens)
transformed := JoinTokens(trans)
if len(header) < opts.HeaderLines { if len(header) < opts.HeaderLines {
header = append(header, transformed) header = append(header, transformed)
eventBox.Set(EvtHeader, header) eventBox.Set(EvtHeader, header)
return false return false
} }
item.text, item.colors = ansiProcessor(stringBytes(transformed)) item.text, item.colors = ansiProcessor(stringBytes(transformed))
item.text.TrimTrailingWhitespaces()
item.text.Index = itemIndex item.text.Index = itemIndex
item.origText = &data item.origText = &data
itemIndex++ itemIndex++

View File

@ -544,8 +544,8 @@ type Options struct {
Case Case Case Case
Normalize bool Normalize bool
Nth []Range Nth []Range
WithNth []Range WithNth func(Delimiter) func([]Token) string
AcceptNth []Range AcceptNth func(Delimiter) func([]Token) string
Delimiter Delimiter Delimiter Delimiter
Sort int Sort int
Track trackOption Track trackOption
@ -667,8 +667,6 @@ func defaultOptions() *Options {
Case: CaseSmart, Case: CaseSmart,
Normalize: true, Normalize: true,
Nth: make([]Range, 0), Nth: make([]Range, 0),
WithNth: make([]Range, 0),
AcceptNth: make([]Range, 0),
Delimiter: Delimiter{}, Delimiter: Delimiter{},
Sort: 1000, Sort: 1000,
Track: trackDisabled, Track: trackDisabled,
@ -771,6 +769,62 @@ func splitNth(str string) ([]Range, error) {
return ranges, nil return ranges, nil
} }
func nthTransformer(str string) (func(Delimiter) func([]Token) string, error) {
// ^[0-9,-.]+$"
if match, _ := regexp.MatchString("^[0-9,-.]+$", str); match {
nth, err := splitNth(str)
if err != nil {
return nil, err
}
return func(Delimiter) func([]Token) string {
return func(tokens []Token) string {
return JoinTokens(Transform(tokens, nth))
}
}, nil
}
// {...} {...} ...
placeholder := regexp.MustCompile("{[0-9,-.]+}")
indexes := placeholder.FindAllStringIndex(str, -1)
if indexes == nil {
return nil, errors.New("template should include at least 1 placeholder: " + str)
}
type NthParts struct {
str string
nth []Range
}
parts := make([]NthParts, len(indexes))
idx := 0
for _, index := range indexes {
if idx < index[0] {
parts = append(parts, NthParts{str: str[idx:index[0]]})
}
if nth, err := splitNth(str[index[0]+1 : index[1]-1]); err == nil {
parts = append(parts, NthParts{nth: nth})
}
idx = index[1]
}
if idx < len(str) {
parts = append(parts, NthParts{str: str[idx:]})
}
return func(delimiter Delimiter) func([]Token) string {
return func(tokens []Token) string {
str := ""
for _, holder := range parts {
if holder.nth != nil {
str += StripLastDelimiter(JoinTokens(Transform(tokens, holder.nth)), delimiter)
} else {
str += holder.str
}
}
return str
}
}, nil
}
func delimiterRegexp(str string) Delimiter { func delimiterRegexp(str string) Delimiter {
// Special handling of \t // Special handling of \t
str = strings.ReplaceAll(str, "\\t", "\t") str = strings.ReplaceAll(str, "\\t", "\t")
@ -2387,7 +2441,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if err != nil { if err != nil {
return err return err
} }
if opts.WithNth, err = splitNth(str); err != nil { if opts.WithNth, err = nthTransformer(str); err != nil {
return err return err
} }
case "--accept-nth": case "--accept-nth":
@ -2395,7 +2449,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if err != nil { if err != nil {
return err return err
} }
if opts.AcceptNth, err = splitNth(str); err != nil { if opts.AcceptNth, err = nthTransformer(str); err != nil {
return err return err
} }
case "-s", "--sort": case "-s", "--sort":

View File

@ -305,7 +305,7 @@ type Terminal struct {
nthAttr tui.Attr nthAttr tui.Attr
nth []Range nth []Range
nthCurrent []Range nthCurrent []Range
acceptNth []Range acceptNth func([]Token) string
tabstop int tabstop int
margin [4]sizeSpec margin [4]sizeSpec
padding [4]sizeSpec padding [4]sizeSpec
@ -919,7 +919,6 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
nthAttr: opts.Theme.Nth.Attr, nthAttr: opts.Theme.Nth.Attr,
nth: opts.Nth, nth: opts.Nth,
nthCurrent: opts.Nth, nthCurrent: opts.Nth,
acceptNth: opts.AcceptNth,
tabstop: opts.Tabstop, tabstop: opts.Tabstop,
hasStartActions: false, hasStartActions: false,
hasResultActions: false, hasResultActions: false,
@ -961,6 +960,9 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
lastAction: actStart, lastAction: actStart,
lastFocus: minItem.Index(), lastFocus: minItem.Index(),
numLinesCache: make(map[int32]numLinesCacheValue)} numLinesCache: make(map[int32]numLinesCacheValue)}
if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter)
}
// This should be called before accessing tui.Color* // This should be called before accessing tui.Color*
tui.InitTheme(opts.Theme, renderer.DefaultTheme(), opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible()) tui.InitTheme(opts.Theme, renderer.DefaultTheme(), opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible())
@ -1570,9 +1572,11 @@ func (t *Terminal) output() bool {
transform := func(item *Item) string { transform := func(item *Item) string {
return item.AsString(t.ansi) return item.AsString(t.ansi)
} }
if len(t.acceptNth) > 0 { if t.acceptNth != nil {
transform = func(item *Item) string { transform = func(item *Item) string {
return JoinTokens(StripLastDelimiter(Transform(Tokenize(item.AsString(t.ansi), t.delimiter), t.acceptNth), t.delimiter)) tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
transformed := t.acceptNth(tokens)
return StripLastDelimiter(transformed, t.delimiter)
} }
} }
found := len(t.selected) > 0 found := len(t.selected) > 0

View File

@ -6,6 +6,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"unicode"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
) )
@ -211,32 +212,18 @@ func Tokenize(text string, delimiter Delimiter) []Token {
return withPrefixLengths(tokens, 0) return withPrefixLengths(tokens, 0)
} }
// StripLastDelimiter removes the trailing delimiter and whitespaces from the // StripLastDelimiter removes the trailing delimiter and whitespaces
// last token. func StripLastDelimiter(str string, delimiter Delimiter) string {
func StripLastDelimiter(tokens []Token, delimiter Delimiter) []Token { if delimiter.str != nil {
if len(tokens) == 0 { str = strings.TrimSuffix(str, *delimiter.str)
return tokens } else if delimiter.regex != nil {
} locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastToken := tokens[len(tokens)-1] lastLoc := locs[len(locs)-1]
str = str[:lastLoc[0]]
if delimiter.str == nil && delimiter.regex == nil {
lastToken.text.TrimTrailingWhitespaces()
} else {
if delimiter.str != nil {
lastToken.text.TrimSuffix([]rune(*delimiter.str))
} else if delimiter.regex != nil {
str := lastToken.text.ToString()
locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastLoc := locs[len(locs)-1]
lastToken.text.SliceRight(lastLoc[0])
}
} }
lastToken.text.TrimTrailingWhitespaces()
} }
return strings.TrimRightFunc(str, unicode.IsSpace)
return tokens
} }
// JoinTokens concatenates the tokens into a single string // JoinTokens concatenates the tokens into a single string

View File

@ -184,11 +184,6 @@ func (chars *Chars) TrailingWhitespaces() int {
return whitespaces return whitespaces
} }
func (chars *Chars) TrimTrailingWhitespaces() {
whitespaces := chars.TrailingWhitespaces()
chars.slice = chars.slice[0 : len(chars.slice)-whitespaces]
}
func (chars *Chars) TrimSuffix(runes []rune) { func (chars *Chars) TrimSuffix(runes []rune) {
lastIdx := len(chars.slice) lastIdx := len(chars.slice)
firstIdx := lastIdx - len(runes) firstIdx := lastIdx - len(runes)

View File

@ -1772,4 +1772,13 @@ class TestCore < TestInteractive
assert_equal ['bar,bar,foo :,:bazfoo'], File.readlines(tempname, chomp: true) assert_equal ['bar,bar,foo :,:bazfoo'], File.readlines(tempname, chomp: true)
end end
end end
def test_accept_nth_template
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth '1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed
assert_equal ['1st: foo, 3rd: baz, 2nd: bar'], File.readlines(tempname, chomp: true)
end
end
end end

View File

@ -59,6 +59,13 @@ class TestFilter < TestBase
`#{FZF} -f"^he hehe" -x -n 2.. --with-nth 2,1,1 < #{tempname}`.chomp `#{FZF} -f"^he hehe" -x -n 2.. --with-nth 2,1,1 < #{tempname}`.chomp
end end
def test_with_nth_template
writelines(['hello world ', 'byebye'])
assert_equal \
'hello world ',
`#{FZF} -f"^he he.he." -x -n 2.. --with-nth '{2} {1}. {1}.' < #{tempname}`.chomp
end
def test_with_nth_ansi def test_with_nth_ansi
writelines(["\x1b[33mhello \x1b[34;1mworld\x1b[m ", 'byebye']) writelines(["\x1b[33mhello \x1b[34;1mworld\x1b[m ", 'byebye'])
assert_equal \ assert_equal \