Ripgrep integration, a walkthrough #
The two pillars of fzf #
The interactive terminal interface and the fuzzy matching algorithm are the two pillars of fzf. However, the usefulness of the latter is rather limited in a non-interactive environment. This is because by its very nature, it generates irrelevant matches and user confirmation is almost always necessary.
fzf --filter lt < /usr/share/dict/words | head -5 # lat # let # lit # lot # Lot
“These are all good matches for ’lt’, but which one is truly the right one?”
fzf --filter lt < /usr/share/dict/words | tail -5 # philosophicojuristic # blepharoconjunctivitis # sulphureovirescent # blepharosyndesmitis # choledochoduodenostomy
“You’re definitely not interested in these. But are you?”
The interactive terminal interface, on the other hand, can still be useful even if you don’t want the fuzzy matching algorithm of fzf and want to use an external program or service to do the filtering.
Ripgrep is a good example. It’s obviously a much better choice for searching for a pattern in a large number of files. But it’s not an interactive program. You can’t change the search pattern on the fly.
We can get the best of both worlds by combining ripgrep with the interactive interface of fzf. This article will show you how to achieve that step by step.
Walkthrough #
1. --disabled
#
To prevent fzf from doing the filtering, we use --disabled
option.
fzf --disabled
Now fzf is a mere selector interface. You can move the cursor up and down and select an item from the list, but that’s it. Anything you type on the prompt is simply ignored.
2. Bind change
event
#
We need a way to relaunch ripgrep whenever the query string is changed. The
“event-action binding mechanism” of fzf is the answer. We’re going to bind
change
event, which is triggered when the query string is changed, to
reload
action that runs a command and replaces the current list with the
output of the command.
Run this command and type anything.
fzf --disabled --bind 'change:reload:echo you typed {q}'
{q}
is the placeholder expression for the current query, single-quoted.
See? Let’s replace echo
with rg
, run the command, and type in a search
pattern.
fzf --disabled --bind 'change:reload:rg {q}'
It’s a good start, but there’s a lot to be desired.
- No line and column numbers are shown.
- No colors.
- Search is case-sensitive, which is not what you’d expect from fzf.
- The initial list is a list of files. We need to start fzf with a ripgrep result for the initial query.
- No preview window.
- No action performed when you press enter. fzf just prints the selected line.
3. Set ripgrep options #
- Add
--column
to show both line and column numbers. - Add
--color=always
to show colors. This requires--ansi
option of fzf. - Add
--smart-case
to make the search case-insensitive by default, but case-sensitive if the query contains an uppercase letter.
fzf --disabled --ansi \
--bind 'change:reload:rg --column --color=always --smart-case {q}'
4. Fix initial list #
As mentioned above, the above command starts fzf with a list of files because no input is fed to it and fzf starts its built-in directory walker. Let’s fix it.
rg --column --color=always --smart-case '' |
fzf --disabled --ansi \
--bind 'change:reload:rg --column --color=always --smart-case {q}'
Okay, but we’re repeating the same command twice. Let’s put it in a variable
and refer to it. Instead of feeding the output of the command to fzf via
standard input, let’s bind start
event to reload
action for consistency.
(RELOAD='reload:rg --column --color=always --smart-case {q} || :'
fzf --disabled --ansi \
--bind "start:$RELOAD" --bind "change:$RELOAD")
- We start subshell
(...)
not to pollute the current shell environment with temporary variables. - Notice the
|| :
at the end of the command. This is to prevent the command from exiting with a non-zero status when there’s no match for the pattern. Otherwise, fzf will show[Command failed: rg --column --color=always --smart-case '...']
message on screen. start:reload
will immediately replace the initial list.
5. Add preview window #
We’re going to use bat to show syntax-highlighted preview of the line in the
file. You can install it with brew install bat
.
(RELOAD='reload:rg --column --color=always --smart-case {q} || :'
fzf --disabled --ansi \
--bind "start:$RELOAD" --bind "change:$RELOAD" \
--delimiter : \
--preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \
--preview-window '+{2}/2')
- Each line of ripgrep output is in the format of
FILEPATH:LINE:COLUMN:LINE_CONTENT
. We needFILEPATH
andLINE
to build a preview command. To do that, we set--delimiter :
and refer to the fields with{1}
and{2}
. --preview-window '+{2}/2'
specifies the scroll offset of the preview window.+{2}
means that the offset should be set according to the second token, which is the line number./2
means that the offset should be adjusted so that the line is shown in the middle of the window.
Nice, but we can still do better.
(RELOAD='reload:rg --column --color=always --smart-case {q} || :'
fzf
--disabled --ansi \
--bind "start:$RELOAD" --bind "change:$RELOAD" \
--delimiter : \
--preview 'bat --style=full --color=always --highlight-line {2} {1}' \
--preview-window '~4,+{2}+4/3,<80(up)')
- Now we’ve switched to
--style=full
which shows the file name and the size as the header.───────┬──────────────────────────────────────────────── │ File: LICENSE │ Size: 1.1 KB ───────┼──────────────────────────────────────────────── 1 │ The MIT License (MIT) 2 │ 3 │ Copyright (c) 2013-2024 Junegunn Choi
- Let’s break down the even more cryptic
--preview-window
expression.~4
makes the top four lines “sticky” header so that they are always visible regardless of the scroll offset. (Did I mention that you can scroll the preview window with your mouse/trackpad?)+{2}
— The base offset is extracted from the second token+4
— We add 4 lines to the base offset to compensate for the header/3
adjusts the offset so that the matching line is shown at a third position in the window<80(up)
— This expression specifies the alternative options for the preview window. By default, the preview window opens on the right side with 50% width. But if the width is narrower than 80 columns, it will open above the main window with 50% height.
6. Bind enter
to become
action
#
(RELOAD='reload:rg --column --color=always --smart-case {q} || :'
fzf --disabled --ansi \
--bind "start:$RELOAD" --bind "change:$RELOAD" \
--bind 'enter:become:vim {1} +{2}' \
--delimiter : \
--preview 'bat --style=full --color=always --highlight-line {2} {1}' \
--preview-window '~4,+{2}+4/3,<80(up)')
With the new binding, when you press enter, fzf will open the file ({1}
) in
vim
and move the cursor to the line ({2}
).
7. Add another execute
binding
#
Sometimes you may want to open the file in the editor and come back to fzf to
continue searching. Let’s add an execute
binding for that.
(RELOAD='reload:rg --column --color=always --smart-case {q} || :'
fzf --disabled --ansi \
--bind "start:$RELOAD" --bind "change:$RELOAD" \
--bind 'enter:become:vim {1} +{2}' \
--bind 'ctrl-o:execute:vim {1} +{2}' \
--delimiter : \
--preview 'bat --style=full --color=always --highlight-line {2} {1}' \
--preview-window '~4,+{2}+4/3,<80(up)')
Now you can press ctrl-o
to open the file in vim
without leaving fzf.
8. Handle multiple selections #
So far, we’ve been dealing with a single selection. Let’s add --multi
option
so you can select multiple lines with TAB
and SHIFT-TAB
.
(RELOAD='reload:rg --column --color=always --smart-case {q} || :'
OPENER='if [[ $FZF_SELECT_COUNT -eq 0 ]]; then
vim {1} +{2} # No selection. Open the current line in Vim.
else
vim +cw -q {+f} # Build quickfix list for the selected items.
fi'
fzf --disabled --ansi --multi \
--bind "start:$RELOAD" --bind "change:$RELOAD" \
--bind "enter:become:$OPENER" \
--bind "ctrl-o:execute:$OPENER" \
--bind 'alt-a:select-all,alt-d:deselect-all,ctrl-/:toggle-preview' \
--delimiter : \
--preview 'bat --style=full --color=always --highlight-line {2} {1}' \
--preview-window '~4,+{2}+4/3,<80(up)')
- fzf exports a number of environment variables to its child processes so that
they can behave differently depending on the context.
$FZF_SELECT_COUNT
is the number of selected items. $OPENER
holds a shell code that is run onenter
andctrl-o
. It behaves differently depending on$FZF_SELECT_COUNT
.- It builds the quickfix list for the selected items and open it only when the
user has selected any items using
TAB
orSHIFT-TAB
. vim +cw -q {+f}
needs some explanation.+cw
tells Vim to execute:cw
command to open the quickfix window.-q {+f}
makes Vim start in quickfix mode using the error file{+f}
- So what is
{+f}
? It’s a placeholder expression of fzf for a temporary file containing the selected items. It’s a combination of two flags,+
andf
. See the reference page for more information.
- We added three more bindings for convenience;
alt-a
andalt-d
, to select and deselect all items, andctrl-/
to toggle the preview window.
Wrap-up #
Let’s define it as a function so we can pass the initial query as an argument.
# ripgrep->fzf->vim [QUERY]
rfv() (
RELOAD='reload:rg --column --color=always --smart-case {q} || :'
OPENER='if [[ $FZF_SELECT_COUNT -eq 0 ]]; then
vim {1} +{2} # No selection. Open the current line in Vim.
else
vim +cw -q {+f} # Build quickfix list for the selected items.
fi'
fzf --disabled --ansi --multi \
--bind "start:$RELOAD" --bind "change:$RELOAD" \
--bind "enter:become:$OPENER" \
--bind "ctrl-o:execute:$OPENER" \
--bind 'alt-a:select-all,alt-d:deselect-all,ctrl-/:toggle-preview' \
--delimiter : \
--preview 'bat --style=full --color=always --highlight-line {2} {1}' \
--preview-window '~4,+{2}+4/3,<80(up)' \
--query "$*"
)
Isn’t it wonderful? With ripgrep, bat, and fzf, we have a fully functional, high performance code search interface with syntax-highlighted live preview that integrates with Vim in less than 20 lines of code. This is the beauty of the Unix philosophy. And fzf is a good citizen of the Unix world.