November 26, 2018

Fuzzy Finding in Bash with fzf

The fzf utility is a line-oriented fuzzy finding tool for the Unix command-line.

Fuzzy finding is a search technique that uses approximate pattern matching rather than exact matching. For some search tasks fuzzy finding will be highly effective at generating relevant results for a minimal amount of search effort. In practice this often means that just a few notable characters need be entered to quickly fuzzy find the item of interest.

Adhering to the Unix philosophy, fzf performs one primary task, fuzzy finding, whilst also handling and emitting text streams. Hence, it is quite easy to build useful search scripts built upon fzf.

Some notable characteristics of fzf:

Note, feel free to refer to my bashrc file to view my latest fzf configuration.

Installation

fzf is easily installed using Homebrew on macOS and Linux.

brew install fzf

For other platforms please consult these installation details.

When STDIN is not supplied, fzf, by default, will use the find command to fetch a list of files to filter through. I recommend installing and then using the fd utility instead.

brew install fd

Note, some key bindings and scripts in this post make use of the: ripgrep, bat and tree utilities. If using Brew please install those utilities as follows:

brew install ripgrep bat tree

Details about the fd tool are noted in this post, whilst ripgrep is noted here and bat is discussed here.

Configuration

Please consider adding the following optional configuration settings to your ~/.bashrc file.

Enable fzf key bindings in the Bash shell.

. $(brew --prefix)/opt/fzf/shell/key-bindings.bash

Note, if not using Brew installed fzf, please adjust the above listed path appropriately.

Use the fd command instead of the find command.

export FZF_DEFAULT_COMMAND='fd --type f --color=never'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d . --color=never'

The default fzf look and behaviour are overridden by the FZF_DEFAULT_COMMAND environment variable. I like a top-down 75% fzf window that enables multi-selection and also responds to control-f/control-b keys.

export FZF_DEFAULT_OPTS='
  --height 75% --multi --reverse
  --bind ctrl-f:page-down,ctrl-b:page-up
'

Please experiment and choose your own FZF_DEFAULT_OPTS.

Usage

Examples:

fzf                             # Fuzzy file lister
fzf --preview="head -$LINES {}" # Fuzzy file lister with file preview
vim $(fzf)                      # Launch Vim editor on fuzzy found file
history | fzf                   # Fuzzy find a command from history
cat /usr/share/dict/words | fzf # Fuzzy search a dictionary word

Commands:

Search syntax:

Bash Key Bindings

In the configuration section above the following Bash key bindings were enabled.

Search Scripts

Whilst the above bare and key-bound usages of are useful, fzf really is best utilized when scripting custom search commands.

Here are some that I use.

Find File and Edit

fzf_find_edit() {
    local file=$(
      fzf --query="$1" --no-multi --select-1 --exit-0 \
          --preview 'bat --color=always --line-range :500 {}'
      )
    if [[ -n $file ]]; then
        $EDITOR "$file"
    fi
}

alias ffe='fzf_find_edit'

Fuzzy find a file, with optional initial file name, and then edit:

Find Directory and Change

fzf_change_directory() {
    local directory=$(
      fd --type d | \
      fzf --query="$1" --no-multi --select-1 --exit-0 \
          --preview 'tree -C {} | head -100'
      )
    if [[ -n $directory ]]; then
        cd "$directory"
    fi
}

alias fcd='fzf_change_directory'

Fuzzy find a directory, with optional initial directory name, and then change to it:

Find File with Term and Edit

fzf_grep_edit(){
    if [[ $# == 0 ]]; then
        echo 'Error: search term was not provided.'
        return
    fi
    local match=$(
      rg --color=never --line-number "$1" |
        fzf --no-multi --delimiter : \
            --preview "bat --color=always --line-range {2}: {1}"
      )
    local file=$(echo "$match" | cut -d':' -f1)
    if [[ -n $file ]]; then
        $EDITOR "$file" +$(echo "$match" | cut -d':' -f2)
    fi
}

alias fge='fzf_grep_edit'

Fuzzy find a file, with colorful preview, that contains the supplied term, then once selected edit it in your preferred editor. Note, if your EDITOR is Vim or Neovim then you will be automatically scrolled to the selected line.

Find and Kill Process

fzf_kill() {
    local pid_col
    if [[ $(uname) = Linux ]]; then
        pid_col=2
    elif [[ $(uname) = Darwin ]]; then
        pid_col=3;
    else
        echo 'Error: unknown platform'
        return
    fi
    local pids=$(
      ps -f -u $USER | sed 1d | fzf --multi | tr -s [:blank:] | cut -d' ' -f"$pid_col"
      )
    if [[ -n $pids ]]; then
        echo "$pids" | xargs kill -9 "$@"
    fi
}

alias fkill='fzf_kill'

Fuzzy find a process or group of processes, then SIGKILL them. Multi-selection is enabled to allow multiple processes to be selected via the TAB key.

This script negates the need to run ps manually and all the related pain involved to kill a recalcitrant process :tada: :tada:

Git Stage Files

fzf_git_add() {
    local selections=$(
      git status --porcelain | \
      fzf --ansi \
          --preview 'if (git ls-files --error-unmatch {2} &>/dev/null); then
                         git diff --color=always {2}
                     else
                         bat --color=always --line-range :500 {2}
                     fi'
      )
    if [[ -n $selections ]]; then
        git add --verbose $(echo "$selections" | cut -c 4- | tr '\n' ' ')
    fi
}

alias gadd='fzf_git_add'

Selectively stage modified and untracked files, with preview, for committing. Note, modified and untracked files will be listed for staging.

Git Log Browser

fzf_git_log() {
    local selections=$(
      git ll --color=always "$@" |
        fzf --ansi --no-sort --no-height \
            --preview "echo {} | grep -o '[a-f0-9]\{7\}' | head -1 |
                       xargs -I@ sh -c 'git show --color=always @'"
      )
    if [[ -n $selections ]]; then
        local commits=$(echo "$selections" | sed 's/^[* |]*//' | cut -d' ' -f1 | tr '\n' ' ')
        git show $commits
    fi
}

alias gll='fzf_git_log'

The ll Git alias used above should be created with the following command.

git config --global alias.ll 'log --graph --format="%C(yellow)%h%C(red)%d%C(reset) - %C(bold green)(%ar)%C(reset) %s %C(blue)<%an>%C(reset)"'

The gll Bash alias displays a compact Git log list that can be filtered by entering a fuzzy term at the prompt. Navigation up and down the commit list will preview the changes of each commit.

Git Log Browser in action:

git_log_browser

Git RefLog Browser

fzf_git_reflog() {
    local selection=$(
      git reflog --color=always "$@" |
        fzf --no-multi --ansi --no-sort --no-height \
            --preview "git show --color=always {1}"
      )
    if [[ -n $selection ]]; then
        git show $(echo $selection | cut -d' ' -f1)
    fi
}

alias grl='fzf_git_reflog'

The grl Bash alias displays a Git reflog list that can be filtered by entering a fuzzy term at the prompt. Navigation up and down the hash list will preview the changes of each hash.

Git Pickaxe Browser

fzf_git_log_pickaxe() {
     if [[ $# == 0 ]]; then
         echo 'Error: search term was not provided.'
         return
     fi
     local selections=$(
       git log --oneline --color=always -S "$@" |
         fzf --ansi --no-sort --no-height \
             --preview "git show --color=always {1}"
       )
     if [[ -n $selections ]]; then
         local commits=$(echo "$selections" | cut -d' ' -f1 | tr '\n' ' ')
         git show $commits
     fi
 }

alias glS='fzf_git_log_pickaxe'

The glS Bash alias displays a Git log list that has been pickaxe (-S) filtered by the supplied search term. Navigation up and down the commit list will preview the changes of each hash.

Conclusion

The fzf tool has become an indispensable tool in my workflow. Please give it a try yourself, I am confident it will prove useful to you as well.