From 2b584586ed1caf15429625da981575ee35d407b8 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 9 Feb 2025 11:53:35 +0900 Subject: [PATCH] Add --accept-nth option to transform the output This option can be used to replace a sed or awk in the post-processing step. ps -ef | fzf --multi --header-lines 1 | awk '{print $2}' ps -ef | fzf --multi --header-lines 1 --accept-nth 2 This may not be a very "Unix-y" thing to do, so I've always felt that fzf shouldn't have such an option, but I've finally changed my mind because: * fzf can be configured with a custom delimiter that is a fixed string or a regular expression. * In such cases, you'd need to repeat the delimiter again in the post-processing step. * Also, tools like awk or sed may interpret a regular expression differently, causing mismatches. You can still use sed, cut, or awk if you prefer. Close #3987 Close #1323 --- man/man1/fzf.1 | 4 ++++ src/core.go | 2 +- src/options.go | 11 +++++++++++ src/pattern.go | 2 ++ src/terminal.go | 18 ++++++++++++++---- src/tokenizer.go | 33 +++++++++++++++++++++++++++++++-- src/tokenizer_test.go | 6 +++--- src/util/chars.go | 21 +++++++++++++++++++++ test/test_core.rb | 26 ++++++++++++++++++++++++++ 9 files changed, 113 insertions(+), 10 deletions(-) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 5ebb47da..ba3abaa5 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -120,6 +120,10 @@ fields. .BI "\-\-with\-nth=" "N[,..]" Transform the presentation of each line using field index expressions .TP +.BI "\-\-accept\-nth=" "N[,..]" +Define which fields to print on accept. The last delimiter is stripped from the +output. +.TP .B "+s, \-\-no\-sort" Do not sort the result .TP diff --git a/src/core.go b/src/core.go index cad139dd..8f4a6d84 100644 --- a/src/core.go +++ b/src/core.go @@ -128,7 +128,7 @@ func Run(opts *Options) (int, error) { } } trans := Transform(tokens, opts.WithNth) - transformed := joinTokens(trans) + transformed := JoinTokens(trans) if len(header) < opts.HeaderLines { header = append(header, transformed) eventBox.Set(EvtHeader, header) diff --git a/src/options.go b/src/options.go index 99d58634..2b310612 100644 --- a/src/options.go +++ b/src/options.go @@ -41,6 +41,7 @@ Usage: fzf [options] integer or a range expression ([BEGIN]..[END]). --with-nth=N[,..] Transform the presentation of each line using field index expressions + --accept-nth=N[,..] Define which fields to print on accept -d, --delimiter=STR Field delimiter regex (default: AWK-style) +s, --no-sort Do not sort the result --literal Do not normalize latin script letters @@ -544,6 +545,7 @@ type Options struct { Normalize bool Nth []Range WithNth []Range + AcceptNth []Range Delimiter Delimiter Sort int Track trackOption @@ -666,6 +668,7 @@ func defaultOptions() *Options { Normalize: true, Nth: make([]Range, 0), WithNth: make([]Range, 0), + AcceptNth: make([]Range, 0), Delimiter: Delimiter{}, Sort: 1000, Track: trackDisabled, @@ -2383,6 +2386,14 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { if opts.WithNth, err = splitNth(str); err != nil { return err } + case "--accept-nth": + str, err := nextString("nth expression required") + if err != nil { + return err + } + if opts.AcceptNth, err = splitNth(str); err != nil { + return err + } case "-s", "--sort": if opts.Sort, err = optionalNumeric(1); err != nil { return err diff --git a/src/pattern.go b/src/pattern.go index 29149fe7..8919ad87 100644 --- a/src/pattern.go +++ b/src/pattern.go @@ -403,6 +403,8 @@ func (p *Pattern) transformInput(item *Item) []Token { tokens := Tokenize(item.text.ToString(), p.delimiter) ret := Transform(tokens, p.nth) + // TODO: We could apply StripLastDelimiter to exclude the last delimiter from + // the search allowing suffix match with a string or a regex delimiter. item.transformed = &transformed{p.revision, ret} return ret } diff --git a/src/terminal.go b/src/terminal.go index b1b5251d..9a06ad61 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -305,6 +305,7 @@ type Terminal struct { nthAttr tui.Attr nth []Range nthCurrent []Range + acceptNth []Range tabstop int margin [4]sizeSpec padding [4]sizeSpec @@ -914,6 +915,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor nthAttr: opts.Theme.Nth.Attr, nth: opts.Nth, nthCurrent: opts.Nth, + acceptNth: opts.AcceptNth, tabstop: opts.Tabstop, hasStartActions: false, hasResultActions: false, @@ -1561,16 +1563,24 @@ func (t *Terminal) output() bool { for _, s := range t.printQueue { t.printer(s) } + transform := func(item *Item) string { + return item.AsString(t.ansi) + } + if len(t.acceptNth) > 0 { + transform = func(item *Item) string { + return JoinTokens(StripLastDelimiter(Transform(Tokenize(item.AsString(t.ansi), t.delimiter), t.acceptNth), t.delimiter)) + } + } found := len(t.selected) > 0 if !found { current := t.currentItem() if current != nil { - t.printer(current.AsString(t.ansi)) + t.printer(transform(current)) found = true } } else { for _, sel := range t.sortSelected() { - t.printer(sel.item.AsString(t.ansi)) + t.printer(transform(sel.item)) } } return found @@ -3847,7 +3857,7 @@ func replacePlaceholder(params replacePlaceholderParams) (string, []string) { elems, prefixLength := awkTokenizer(params.query) tokens := withPrefixLengths(elems, prefixLength) trans := Transform(tokens, nth) - result := joinTokens(trans) + result := JoinTokens(trans) if !flags.preserveSpace { result = strings.TrimSpace(result) } @@ -3897,7 +3907,7 @@ func replacePlaceholder(params replacePlaceholderParams) (string, []string) { replace = func(item *Item) string { tokens := Tokenize(item.AsString(params.stripAnsi), params.delimiter) trans := Transform(tokens, ranges) - str := joinTokens(trans) + str := JoinTokens(trans) // trim the last delimiter if params.delimiter.str != nil { diff --git a/src/tokenizer.go b/src/tokenizer.go index f5d1483b..057d7405 100644 --- a/src/tokenizer.go +++ b/src/tokenizer.go @@ -211,7 +211,36 @@ func Tokenize(text string, delimiter Delimiter) []Token { return withPrefixLengths(tokens, 0) } -func joinTokens(tokens []Token) string { +// StripLastDelimiter removes the trailing delimiter and whitespaces from the +// last token. +func StripLastDelimiter(tokens []Token, delimiter Delimiter) []Token { + if len(tokens) == 0 { + return tokens + } + + lastToken := tokens[len(tokens)-1] + + 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 tokens +} + +// JoinTokens concatenates the tokens into a single string +func JoinTokens(tokens []Token) string { var output bytes.Buffer for _, token := range tokens { output.WriteString(token.text.ToString()) @@ -229,7 +258,7 @@ func Transform(tokens []Token, withNth []Range) []Token { if r.begin == r.end { idx := r.begin if idx == rangeEllipsis { - chars := util.ToChars(stringBytes(joinTokens(tokens))) + chars := util.ToChars(stringBytes(JoinTokens(tokens))) parts = append(parts, &chars) } else { if idx < 0 { diff --git a/src/tokenizer_test.go b/src/tokenizer_test.go index 39f32dc8..a471a2fc 100644 --- a/src/tokenizer_test.go +++ b/src/tokenizer_test.go @@ -85,14 +85,14 @@ func TestTransform(t *testing.T) { { ranges, _ := splitNth("1,2,3") tx := Transform(tokens, ranges) - if joinTokens(tx) != "abc: def: ghi: " { + if JoinTokens(tx) != "abc: def: ghi: " { t.Errorf("%s", tx) } } { ranges, _ := splitNth("1..2,3,2..,1") tx := Transform(tokens, ranges) - if string(joinTokens(tx)) != "abc: def: ghi: def: ghi: jklabc: " || + if string(JoinTokens(tx)) != "abc: def: ghi: def: ghi: jklabc: " || len(tx) != 4 || tx[0].text.ToString() != "abc: def: " || tx[0].prefixLength != 2 || tx[1].text.ToString() != "ghi: " || tx[1].prefixLength != 14 || @@ -107,7 +107,7 @@ func TestTransform(t *testing.T) { { ranges, _ := splitNth("1..2,3,2..,1") tx := Transform(tokens, ranges) - if joinTokens(tx) != " abc: def: ghi: def: ghi: jkl abc:" || + if JoinTokens(tx) != " abc: def: ghi: def: ghi: jkl abc:" || len(tx) != 4 || tx[0].text.ToString() != " abc: def:" || tx[0].prefixLength != 0 || tx[1].text.ToString() != " ghi:" || tx[1].prefixLength != 12 || diff --git a/src/util/chars.go b/src/util/chars.go index 4b9cca01..dd037caa 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -189,6 +189,27 @@ func (chars *Chars) TrimTrailingWhitespaces() { chars.slice = chars.slice[0 : len(chars.slice)-whitespaces] } +func (chars *Chars) TrimSuffix(runes []rune) { + lastIdx := len(chars.slice) + firstIdx := lastIdx - len(runes) + if firstIdx < 0 { + return + } + + for i := firstIdx; i < lastIdx; i++ { + char := chars.Get(i) + if char != runes[i-firstIdx] { + return + } + } + + chars.slice = chars.slice[0:firstIdx] +} + +func (chars *Chars) SliceRight(last int) { + chars.slice = chars.slice[:last] +} + func (chars *Chars) ToString() string { if runes := chars.optionalRunes(); runes != nil { return string(runes) diff --git a/test/test_core.rb b/test/test_core.rb index 276fa03d..e15ab8ee 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1665,4 +1665,30 @@ class TestCore < TestInteractive assert_equal '', File.read(tempname).chomp end end + + def test_accept_nth + tmux.send_keys %((echo "foo bar baz"; echo "bar baz foo") | #{FZF} --multi --accept-nth 2,2 --sync --bind start:select-all+accept > #{tempname}), :Enter + wait do + assert_path_exists tempname + assert_equal ['bar bar', 'baz baz'], File.readlines(tempname, chomp: true) + end + end + + def test_accept_nth_string_delimiter + tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter + wait do + assert_path_exists tempname + # Last delimiter and the whitespaces are removed + assert_equal ['bar,bar,foo ,bazfoo'], File.readlines(tempname, chomp: true) + end + end + + def test_accept_nth_regex_delimiter + tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter='[:,]+' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter + wait do + assert_path_exists tempname + # Last delimiter and the whitespaces are removed + assert_equal ['bar,bar,foo :,:bazfoo'], File.readlines(tempname, chomp: true) + end + end end