VIM fzf with Devicons

Corey Alexander - Nov 18 '18 - - Dev Community

Example Screenshot

This was originally posted on coreyja.com

Background

When I wrote my last blog post VIM Spelling Suggestions with fzf I got a couple of comments on dev.to! One of them was from @maxdevjs. We exchanged a few comments, and I found vim-devicons in his dotfiles! It worked out of the box with both nerdtree and airline, and I really enjoyed it! @maxdevjs also had a link to this Github Issue about how people were able to add fzf support. I was on an fzf kick, so that got me interested!

Original Solution

The Github Issue already had a functional solution! Here is the comment that I used as my base for this, https://github.com/ryanoasis/vim-devicons/issues/106#issuecomment-354629715

Here is the source code from that comment:

" Files + devicons
function! Fzf_dev()
  let l:fzf_files_options = '--preview "rougify {2..-1} | head -'.&lines.'"'

  function! s:files()
    let l:files = split(system($FZF_DEFAULT_COMMAND), '\n')
    return s:prepend_icon(l:files)
  endfunction

  function! s:prepend_icon(candidates)
    let l:result = []
    for l:candidate in a:candidates
      let l:filename = fnamemodify(l:candidate, ':p:t')
      let l:icon = WebDevIconsGetFileTypeSymbol(l:filename, isdirectory(l:filename))
      call add(l:result, printf('%s %s', l:icon, l:candidate))
    endfor

    return l:result
  endfunction

  function! s:edit_file(item)
    let l:pos = stridx(a:item, ' ')
    let l:file_path = a:item[pos+1:-1]
    execute 'silent e' l:file_path
  endfunction

  call fzf#run({
        \ 'source': <sid>files(),
        \ 'sink':   function('s:edit_file'),
        \ 'options': '-m ' . l:fzf_files_options,
        \ 'down':    '40%' })
endfunction
Enter fullscreen mode Exit fullscreen mode

The above does three basic things:

  1. Uses the rouge gem to create a syntax highlighted preview within fzf
  2. Prepends the devicon to each line generated by the $FZF_DEFAULT_COMMAND and uses that as the fzf source
  3. Sets the sink so that when you select an option, the devicon is stripped out, and you edit the intended file

When I copied this to my dotfiles and played around with it, I had a few things I wanted to improve on!

Preview

The original solution used rogue to create syntax highlighted previews. I liked having the previews but felt the rogue rendering took too long. My use of fzf is all about speed, so having the rendering lag behind what option I had selected was noticeable and annoying.
Recently I (re)discovered bat, which is a cat replacement, and also includes syntax highlighting!

So the first thing I did was replace rougify with bat and had a line like the following.

let l:fzf_files_options = '--preview "bat {2..-1} | head -'.&lines.'"'
Enter fullscreen mode Exit fullscreen mode

This worked! And I was getting near-instantaneous results šŸŽ‰. Unfortunately, it wasn't syntax highlighted. I realized this was because bat tries to be compatible with cat, and if it detects you are piping the output (like was happening in the preview), it defaults to acting like a cat clone. Luckily fixing this was as easy as passing an option to bat, --color always did the trick! bat also includes line numbers and a header by default. I liked the line numbers but didn't like the header, so I hid it by explicitly saying I only wanted the line number with --style numbers. Now my preview line looks like the following:

let l:fzf_files_options = '--preview "bat --color always --style numbers {2..} | head -'.&lines.'"'
Enter fullscreen mode Exit fullscreen mode

After this, I was thrilled with the previews that fzf was providing.

Prepending the Devicons

I enjoy fzf because it is a streaming fuzzy finder, which means you can start fuzzy finding even before receiving all the input. This is important because I have my default fzf command ($FZF_DEFAULT_COMMAND) set to the following, which includes most hidden files and respects symlinks. As such, I often have tens of thousands of results in a typical Rails project, with a node_modules directory.

rg --files --no-ignore --hidden --follow --glob "!.git/*"
Enter fullscreen mode Exit fullscreen mode

When I first started testing these solutions, I was using directories that only had a limited number of files. In those directories, I didn't notice any slowdown when adding the devicons. But when I moved to some of my actual project directories, I realized that fzf would open empty before populating all the results at once. This delay was annoying me, and I was in the mood to optimize!

Streaming Bash

I knew from working with fzf in VIM recently that it accepted a few things for its source. The two big ones are a VIM list or a string indicating a shell command to call. In the original solution, we took the fzf default command and executed it with system in vim. This meant we would take the entire output of the fzf default command and process it in vim. We then split it on newlines to get a vim list. We loop through each filename in the list and prepend the correct devicon. Then we pass this list to fzf to be the source.
This works perfectly well, but this is where the lag I noticed was coming from. Before fzf could show me any results, the rg command would have to finish, THEN the filenames would have to be prepended with the devicons, and finally, fzf could render the list. This meant that fzf was no longer able to 'stream' and had to wait for all the input to be ready before it could start displaying results.

fzf.vim uses streaming by default if you use a bash command as a string for the fzf source. I realized that to enable streaming, I was going to need a solution outside of VIM. Since the input is a bash command, bash came to mind as the obvious first choice, and I worked on a proof of concept! The basic idea was that I would have a script that would take in lines via STDIN and prepend the correct devicon to each one. It would then output each line as it was processed to STDOUT. This way, it would 'stream' out the new lines with the prepended devicons.

I decided to look quick to make sure someone hadn't already done this and came across ryanoasis/devicons-shell, which is by the same person as the vim-devicons plugin! It didn't do exactly what I wanted, but it was pretty close. I took most of it to write my initial prototype, which ended up looking like the following:

function devicons_get_filetype_symbol {
  declare -A extensions=(
    [ai]=īž“
    ... (Shortened for brevity)
    [zsh]=īž•
  )

  local folder="ī—æ"
  local filetype
  local default=ī˜’
  local exist_check=1
  local input=$1
  local filename="$1"
  # using ## for possibly more than one "." (get after last one):
  local filetype="${filename##*.}"

  if [ -d "$filename" ]; then
    local symbol=$folder
  elif [ ! -z "$filetype" ] && [ ${extensions[$filetype]+$exist_check} ]; then
    local symbol=${extensions[$filetype]}
  else
    local symbol=$default
  fi
  echo "$symbol"
  return 0
}

while IFS= read -r line; do
  echo -e "$(devicons_get_filetype_symbol $line) $line"
done
Enter fullscreen mode Exit fullscreen mode

I tested this out with ls | ./protype_script and was really pleased with the results! Now just had to integrate it into VIM, which turned out to be pretty easy. We actually got to remove some of the .vimrc code!

Now to take advantage of fzf streaming we pass a String as the source for fzf. We pipe the results of the default command into our new script. This allows us to delete both files and the prepend_icon functions since we replaced their functionality with our script.

call fzf#run({
    \ 'source': $FZF_DEFAULT_COMMAND.' | prototype_script',
    \ 'sink':   function('s:edit_devicon_prepended_file'),
    \ 'options': '-m ' . l:fzf_files_options,
    \ 'down':    '40%' })
Enter fullscreen mode Exit fullscreen mode

I tried this out in my small sample directories, and it seemed to be working great! I moved onto my bigger directories, and everything was looking good! I could see that results were streaming in, as the total number of results in fzf kept rising, but I was still able to use it in the meantime!

After a few minutes of jumping between files, I realized that sometimes I would search for a file that hadn't been added to fzf yet, and I would have a delay before the result I was waiting for appeared. I didn't experience this before adding devicons, so I suspected that to be the issue. I did a side by side comparison with the stock :Files command, and the results were clear. The default completed orders of magnitude faster. I could see the fzf totals load, but it was in fractions of seconds. With my bash solution, it took tens of seconds to load all my files. There was room for improvement!

Rust Implementation

I have almost no experience with Rust, but it's been a language I've been interested in for a while. Coincidentally, bat is also written in Rust, and its speed for the preview part earlier gave me high hopes it would also help out here.

Being fairly new to Rust, my first step was to figure out how best to use a hash map for the extension to devicon mapping. One of the first things I stumbled upon was lazy_static, which appeared to do pretty much what I wanted. This was static at runtime; I was looking for something static at compile-time, but from my very, very quick glance, it seemed Rust didn't support that easily 1, so static at runtime would be good enough!

lazy_static! {
  static ref SYMBOL_MAP: HashMap<&'static str, &'static str> = {
    let mut m = HashMap::new();
    m.insert("ai", "īž“");
    ...
    m.insert("zsh", "īž•");

    m
  };
}
Enter fullscreen mode Exit fullscreen mode

After I got my HashMap setup, I needed to do the actual text streaming part. I found this Stack Overflow answer, which was 80% of the work! Here was their simple text streaming example.

fn main() {
    let stdin = io::stdin();
    for line in stdin.lock().lines() {
        println!("{}", line.unwrap());
    }
}
Enter fullscreen mode Exit fullscreen mode

Now I just needed to parse the extension and look up the symbol in my HashMap. Again, the Rust standard libraries came in handy as parsing the extension was a one-liner!

Path::new(filename).extension().and_then(OsStr::to_str)
Enter fullscreen mode Exit fullscreen mode

Throw in a little Rust type magic and šŸŽ‰, the Rust version was ready to test out!

#[macro_use]
extern crate lazy_static;

use std::collections::HashMap;
use std::ffi::OsStr;
use std::io::{self, BufRead};
use std::path::Path;

static DEFAULT_SYMBOL: &str = & "ī˜’";
lazy_static! {
  static ref SYMBOL_MAP: HashMap<&'static str, &'static str> = {
    let mut m = HashMap::new();
    m.insert("ai", "īž“");
    ...
    m.insert("zsh", "īž•");

    m
  };
}

fn get_extension_from_filename(filename: &str) -> Option<&str> {
  Path::new(filename)
    .extension()
    .and_then(OsStr::to_str)
}

fn main() {
  let stdin = io::stdin();
  for line in stdin.lock().lines() {
    let filename = line.unwrap();
    let extension = get_extension_from_filename(& filename);
    let symbol = match extension {
      Some(extension) => SYMBOL_MAP.get(& extension).unwrap_or(& DEFAULT_SYMBOL),
      None => DEFAULT_SYMBOL
    };
    println!("{} {}", symbol, filename);
  }
}
Enter fullscreen mode Exit fullscreen mode

I plugged this new version into vim, by simply changing the source to pipe to my new Rust built binary instead of the prototype bash script. And :drumroll:, it worked and was significantly faster than the bash version! It wasn't even noticeably slower than the default :Files command!

Performance

Here is a very unscientific single-trial test where I timed three different commands in a large directory.

  1. Using my default FZF command alone
  2. Using it with the prototype bash implementation
  3. Using it with my Rust implementation

And going in that order, here are my results! The Rust version is only barely slower than not doing any devicons. And it blows the bash implementation out of the water!

coreyja in ~/Projects on ca/master/devicons
ā˜…  time rg --files --no-ignore --hidden --follow > /dev/null

real    0m0.302s
user    0m0.627s
sys     0m1.515s

coreyja in ~/Projects on ca/master/devicons
ā˜…  time rg --files --no-ignore --hidden --follow | add_devicon_before_each_file > /dev/null

real    3m55.226s
user    2m8.614s
sys     1m45.380s

coreyja in ~/Projects on ca/master/devicons
ā˜…  time rg --files --no-ignore --hidden --follow | devicon-lookup > /dev/null

real    0m0.409s
user    0m0.743s
sys     0m1.535s
Enter fullscreen mode Exit fullscreen mode

I took the original implementation and the final rust version in VIM for an even less scientific test. Since I was doing the tests inside VIM and am timing until a window appears, there wasn't an easy way to time this automatically. So I broke out my stopwatch for this one. Using my rust implementation :call Fzf_files_with_dev_icons($FZF_DEFAULT_COMMAND), the fzf window came up pretty much instantaneously, and all the results were already populated! With the original VIM implementation :call Fzf_dev(), it took around 4.5 seconds for the window to load. And when it loaded, it was fully populated with all the files.

This was a huge improvement, and the performance is now definitely acceptable for me and has been added to my dotfiles!

Final Version

After I got this Rust version working, I also spent some time refactoring to reuse the same function for searching only the files under git. This was as easy as extracting the function that was called to a parameter. I did also integrate the same devicons into the :GFiles? which only shows files that have changed in git. This deserves its own blog post cause it took a bit of finagling!

And here we have it, my final (for now) fzf File Fuzzy Finding with Devicons Powered by Rust!

" Files + devicons
function! Fzf_files_with_dev_icons(command)
  let l:fzf_files_options = '--preview "bat --color always --style numbers {2..} | head -'.&lines.'"'
   function! s:edit_devicon_prepended_file(item)
    let l:file_path = a:item[4:-1]
    execute 'silent e' l:file_path
  endfunction
   call fzf#run({
        \ 'source': a:command.' | devicon-lookup',
        \ 'sink':   function('s:edit_devicon_prepended_file'),
        \ 'options': '-m ' . l:fzf_files_options,
        \ 'down':    '40%' })
endfunction
 function! Fzf_git_diff_files_with_dev_icons()
  let l:fzf_files_options = '--ansi --preview "sh -c \"(git diff --color=always -- {3..} | sed 1,4d; bat --color always --style numbers {3..}) | head -'.&lines.'\""'
   function! s:edit_devicon_prepended_file_diff(item)
    echom a:item
    let l:file_path = a:item[7:-1]
    echom l:file_path
    let l:first_diff_line_number = system("git diff -U0 ".l:file_path." | rg '^@@.*\+' -o | rg '[0-9]+' -o | head -1")
     execute 'silent e' l:file_path
    execute l:first_diff_line_number
  endfunction
   call fzf#run({
        \ 'source': 'git -c color.status=always status --short --untracked-files=all | devicon-lookup',
        \ 'sink':   function('s:edit_devicon_prepended_file_diff'),
        \ 'options': '-m ' . l:fzf_files_options,
        \ 'down':    '40%' })
endfunction
 " Open fzf Files " Open fzf Files
map <C-f> :call Fzf_files_with_dev_icons($FZF_DEFAULT_COMMAND)<CR> " :Files
map <C-d> :call Fzf_git_diff_files_with_dev_icons()<CR> " :GFiles?
map <C-g> :call Fzf_files_with_dev_icons("git ls-files \| uniq")<CR> " :GFiles
Enter fullscreen mode Exit fullscreen mode

I also released the Rust code as a crate, which you can install with

cargo install devicon-lookup
Enter fullscreen mode Exit fullscreen mode

This will give you the devicon-lookup binary that you can use in your VIM setup!

Acknowledgments

As I get ready to publish this blog post, I realized I didn't mention the fzf.vim plugin at all! I refer to this as the 'default' implementation for the fzf :Files view, and it is an amazing plugin! I basically tried to copy this plugin as closely as possible, especially when trying to reimplement the :GFiles? support.

Edit: Some of this is slightly misinformed. I follow up with another post where I tried out phf again! Check it out here!


  1. After that fact, I found and looked into phf, which DOES provide compile-time static maps. It doesn't use the Rust HashMap and instead implements its own hashing functionality. Their implementation of hashing also differs from the Rust HashMap in ways that weren't meaningful for this project. I wanted to use stable Rust for this, which meant I couldn't use phf_macros, which has a much prettier syntax. Being on Stable rust meant I would be forced to use the codegen option, which requires a preliminary 'compile' step, where you run a build script that outputs a rust source file containing the hash code. This seemed like a lot of extra work, and according to this blog the speed benefits of upgrading would likely exist but would not have been an order of magnitude increase, and I was already happy with the performance.Ā ā†©

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .