I may be an oldie, but I’m a goodie too

– “Eighteen with a Bullet”, Pete Wingfield

This article is going to walk through a process of finding an interesting feature of Vim, and incorporating it into a workflow. This is a strong Vim tradition, and part of how we each make our Vim our own - whether it means a couple of mappings, or an over-engineered pile of vimscript as will be presented here.

A quick, quick quickfix introduction

Vim’s quickfix feature is a powerful central function of the editor. At its core, a quickfix list is a list of file locations, and a set of commands for navigating between them. It can be populated in many different ways, and is generally used for referencing errors and search results.

A simple way to start out with the quickfix list is to use Vim’s internal search tool, :vimgrep.

:vimgrep /^set/j $MYVIMRC

This command searches for string set occurring at the start of lines in file ~/.vimrc or ~/.vim/vimrc or whatever $MYVIMRC currently points at. The j flag at the end of the search string indicates that Vim should not jump to the first match.

In a clean Vim environment, the above doesn’t appear to actually do anything. We can verify that it has by navigating to the first match with :cfirst (assuming the vimrc actually does contain at least one set statement), and to other matches with :cnext, :cprevious and :clast.

But to get a real overview of your quickfix list, you can’t beat opening the quickfix window with :copen. This is a Vim buffer where each line represents a quickfix entry, and you can navigate to one by simply moving the cursor over the associated line and hitting <CR> (Enter/Return). The other command to open the quickfix window is :cwindow, which only opens the quickfix window if the quickfix list contains any valid record. Use :cclose to close the quickfix window, or just :q it when the quickfix window is focused.

Note the common c prefix of all of the preceding commands relating to the quickfix list and window.

What is your location?

In addition to the single quickfix list that Vim maintains, each window also has a “location list”. This is essentially the same as the quickfix list, except that there can be many of them—potentially as many as the number of windows that you have split and tabbed your Vim into. These can be populated in the same way as the quickfix list, but with l prefixed commands. So the :vimgrep command above becomes :lvimgrep when you want the results to go to the window’s location list:

:lvimgrep /^set/j $MYVIMRC

Navigate with :lfirst, :lnext, :lprevious, :llast, and open and close the location list window for the current window with :lopen/:lwindow and :lclose—all the l equivalent of their c quickfix counterparts.

I may be an oldie, but I’m a goodie too

After performing several searches with :vimgrep, you may realise that you want to see the results of an earlier search again. Of course you can recreate the search, but do you have to? Chances are that Vim still has your earlier quickfix list.

In fact, Vim remembers the last ten quickfix lists, and the last ten location lists for each window. The earlier quickfix and location lists can be accessed by using the :colder and :lolder commands respectively. This is always easiest to do while the quickfix or location windows are open, so the updated list is clearly visible. :cnewer and :lnewer navigate forward again through the quickfix/location list stack.

Time to customise

A little bit of vimscript never hurt anybody. If you know what I mean.

– nickspoons

Using commands to move back and forth through the quickfix lists is not a particularly nice experience. If we’re going to use thesse commands often, we can benefit from some mappings. :colder and :cnewer can be used from anywhere, but since they are most useful when the quickfix window is open, then creating mappings that apply only to the quickfix window make sense. And since only vertical cursor movement is necessary in the quickfix window, we have some nice keys available for remapping: <Left> and <Right>.

So let’s create an ftplugin script for filetype qf, the filetype Vim uses for both quickfix and location list windows:

" ~/.vim/after/ftplugin/qf.vim
nnoremap <buffer> <Left> :colder<CR>
nnoremap <buffer> <Right> :cnewer<CR>

Now close your quickfix window if it’s open, then open/re-open it and try hitting your keyboard’s <Left> and <Right> keys to move through the quickfix lists. Pretty good?

Of course the trouble is that this doesn’t do us any good in location windows. The <Left> and <Right> keys don’t appear to do anything in a location window … unless you still have the quickfix window open too, in which case you’ll be able to see the quickfix list changing … not the location list!

So to make the mappings work in location lists and quickfix lists, we need a way to tell which the current window is, and then decide which command to call. While it is possible to do all that in an <expr> mapping, this is already starting to get complicated, so lets refactor and create a function.

" ~/.vim/after/ftplugin/qf.vim
function! QFHistory(goNewer)
  " Get dictionary of properties of the current window
  let wininfo = filter(getwininfo(), {i,v -> v.winnr == winnr()})[0]
  let isloc = wininfo.loclist
  " Build the command: one of colder/cnewer/lolder/lnewer
  let cmd = (isloc ? 'l' : 'c') . (a:goNewer ? 'newer' : 'older')
  execute cmd
endfunction

nnoremap <buffer> <Left> :call QFHistory(0)<CR>
nnoremap <buffer> <Right> :call QFHistory(1)<CR>

Do these work?

I don’t know. Look nice though, don’t they?

– Bacon and Tom

This looks good, the mappings are simple with all the logic moved into function QFHistory, so let’s reopen a quickfix or location window and try it…

E127: Cannot redefine function QFHistory: It is in use

Oops. What does this mean? Well, the error message is actually explaining what’s happening here pretty well - when we use :colder etc., the quickfix/location window is being recreated with the new list—which means that the ftplugin script we’re currently executing is getting sourced. Because the script creates a function, the function is now being re-read and re-defined. This was not our intention but highlights why defining functions in an ftplugin script may not be such a good idea after all: even when it doesn’t cause an error, it’s a messy and unnecessary overhead in a script that may be sourced dozens or hundreds of times in a session.

So where should we put it? The script could go straight into our vimrc, but why not make it an autoload function instead? This is an ideal candidate for an autoload function; a function that may not ever be called in a Vim session, so doesn’t need to be read at all until we want to use it.

An autoload function needs to have a name that corresponds to its script filename. Let’s call this one quickfixed#history(), and put it in a new file ~/.vim/autoload/quickfixed.vim:

" ~/.vim/autoload/quickfixed.vim
function! quickfixed#history(goNewer)
  " Get dictionary of properties of the current window
  let wininfo = filter(getwininfo(), {i,v -> v.winnr == winnr()})[0]
  let isloc = wininfo.loclist
  " Build the command: one of colder/cnewer/lolder/lnewer
  let cmd = (isloc ? 'l' : 'c') . (a:goNewer ? 'newer' : 'older')
  execute cmd
endfunction
" ~/.vim/after/ftplugin/qf.vim
nnoremap <buffer> <Left> :call quickfixed#history(0)<CR>
nnoremap <buffer> <Right> :call quickfixed#history(1)<CR>

Very nice, Harry. What’s it for?

– Barry the Baptist

Now we’re getting somewhere. Close any open quickfix or location window and re-open it, and the <Left> and <Right> keys now move through the available lists.

But once again we quickly hit an issue: <Left> when we’re at the oldest list or <Right> when we’re at the newest raise errors:

E380: At bottom of quickfix stack

Well that’s easily fixed by wrapping the final command in a :try block:

try | execute cmd | catch | endtry

Note: Until this point we have been able to see the results of any changes just by closing and reopening a quickfix or location window. This was because, as has been noted, the ftplugin script gets re-sourced every time a quickfix list is opened. This is not the case for the autoload script we have just created. It will only ever be sourced once by Vim, unless we tell it otherwise. So to see changes to this script in the current session, source it manually with :source ~/.vim/autoload/quickfixed.vim, or the shorter form :so % from the quickfixed.vim buffer.

The :try wrapper got rid of the errors nicely, and we see an informative description of each list echoed to the command line:

error list 1 of 3; 11 errors      :lvimgrep /^set/j $MYVIMRC

This is good. This is usable. It could be a little flashier…

Cosmetics

That output line above is is a bit ugly. For one thing, it is calling whatever we have in the quickfix list an “error”. This is of course due to the history of the quickfix lists and their primary/original purpose of describing error locations, but it looks a bit silly when we are looking at :vimgrep results, or linter warnings etc.

The line is also getting echoed to Vim’s message-history - try running :messages and see. This isn’t very useful, we’re generating a lot of noise and making it harder to see more important messages.

What about empty quickfix lists? If we have a search result that didn’t include any matches, we’re not particularly interested in revisiting the results of that search later on. It’d be nice to skip past these.

Finally, the default 10-row quickfix list is wasting screen real estate when there are fewer than 10 results. We can resize it for smaller quickfix lists to maximise the screen space (idea inspired by romainl/vim-qf).

Let’s get to work.

We’re going to need some helper functions. First, let’s refactor that isloc functionality into a script-local (s:) function:

function! s:isLocation()
  " Get dictionary of properties of the current window
  let wininfo = filter(getwininfo(), {i,v -> v.winnr == winnr()})[0]
  return wininfo.loclist
endfunction

Now we can create some functions to read the getqflist() and getloclist() dictionaries to determine how many quickfix/location lists there are, how big each list is, which list we’re currently at, and the title of the quickfix. The getqflist() and getloclist() can take a dictionary argument to filter their output. Calling them with the special argument {'nr': '$'} will result in the quickfix stack size. Please consult the documentation, these functions can get a little hairy.

function! s:length()
  " Get the size of the current quickfix/location list
  return len(s:isLocation() ? getloclist(0) : getqflist())
endfunction

function! s:getProperty(key, ...)
  " getqflist() and getloclist() expect a dictionary argument.
  " If a 2nd argument has been passed in, use it as the value, else 0
  let l:what = {a:key : a:0 ? a:1 : 0}
  let l:listdict = s:isLocation() ? getloclist(0, l:what) : getqflist(l:what)
  return get(l:listdict, a:key)
endfunction

function! s:isFirst()
  return s:getProperty('nr') <= 1
endfunction

function! s:isLast()
  return s:getProperty('nr') == s:getProperty('nr', '$')
endfunction

With these in place, we can now update the main function to check the size of the list, and jump past it if it’s empty. We are now checking the quickfix position in a loop, which means that we won’t hit that E380 error from earlier and can drop the :try. We’re also going to use :silent to suppress the message-history output:

function! quickfixed#history(goNewer)
  " Build the command: one of colder/cnewer/lolder/lnewer
  let cmd = (s:isLocation() ? 'l' : 'c') . (a:goNewer ? 'newer' : 'older')

  " Apply the cmd repeatedly until we hit a non-empty list, or first/last list
  " is reached
  while 1
    if (a:goNewer && s:isLast()) || (!a:goNewer && s:isFirst()) | break | endif
    silent execute cmd
    if s:length() | break | endif
  endwhile
endfunction

Setting the height of the quickfix/location window can now be done using the s:length() helper function and some min/max magic:

  execute 'resize' min([ 10, max([ 1, s:length() ]) ])

It’s quiet. Too quiet.

We’ve removed the :colder output, now we need to add it back in again. We didn’t want it echoed to message-history but it is important information. The simple thing to is :echo it to the command line (not :echomsg, which is how it was being echoed to message-history). But that’s all bland and boring, let’s give it some colour!

We’ll make use of :echohl and :echon for this next section. :echohl sets a highlight group to use for the subsequent output. We’ll pick some standard ones—see a full list by running :highlight. :echon echoes its arguments without a trailing newline, which makes it handy for building up our rainbow:

  let l:nr = s:getProperty('nr')
  let l:last = s:getProperty('nr', '$')
  echohl MoreMsg | echon '('
  echohl Identifier | echon l:nr
  if l:last > 1
    echohl LineNr | echon ' of '
    echohl Identifier | echon l:last
  endif
  echohl MoreMsg | echon ') '
  echohl MoreMsg | echon '['
  echohl Identifier | echon s:length()
  echohl MoreMsg | echon '] '
  echohl Normal | echon s:getProperty('title')
  echohl None

Tidying up

As a final step, let’s refactor one last time - we’ll add autoload functions quickfixed#older() and quickfixed#newer() and rename quickfixed#history() to s:history(), allowing us to remove the 1 and 0 arguments from our mappings. This tidies up the autoload “interface” as described by Tom in his article, and hides implementation details like the goNewer parameter.

We can also use the <silent> map argument to suppress the :call quickfixed#older() message which flashes up before our rainbow output gets echoed.

" ~/.vim/autoload/quickfixed.vim
function! s:history(goNewer)
  ...
endfunction

function! quickfixed#older()
  call s:history(0)
endfunction

function! quickfixed#newer()
  call s:history(1)
endfunction
" ~/.vim/after/ftplugin/qf.vim
nnoremap <silent> <buffer> <Left> :call quickfixed#older()<CR>
nnoremap <silent> <buffer> <Right> :call quickfixed#newer()<CR>

What have we done??

Here are the final scripts when we put them together:

" ~/.vim/autoload/quickfixed.vim
function! s:isLocation()
  " Get dictionary of properties of the current window
  let wininfo = filter(getwininfo(), {i,v -> v.winnr == winnr()})[0]
  return wininfo.loclist
endfunction

function! s:length()
  " Get the size of the current quickfix/location list
  return len(s:isLocation() ? getloclist(0) : getqflist())
endfunction

function! s:getProperty(key, ...)
  " getqflist() and getloclist() expect a dictionary argument
  " If a 2nd argument has been passed in, use it as the value, else 0
  let l:what = {a:key : a:0 ? a:1 : 0}
  let l:listdict = s:isLocation() ? getloclist(0, l:what) : getqflist(l:what)
  return get(l:listdict, a:key)
endfunction

function! s:isFirst()
  return s:getProperty('nr') <= 1
endfunction

function! s:isLast()
  return s:getProperty('nr') == s:getProperty('nr', '$')
endfunction

function! s:history(goNewer)
  " Build the command: one of colder/cnewer/lolder/lnewer
  let l:cmd = (s:isLocation() ? 'l' : 'c') . (a:goNewer ? 'newer' : 'older')

  " Apply the cmd repeatedly until we hit a non-empty list, or first/last list
  " is reached
  while 1
    if (a:goNewer && s:isLast()) || (!a:goNewer && s:isFirst()) | break | endif
    " Run the command. Use :silent to suppress message-history output.
    " Note that the :try wrapper is no longer necessary
    silent execute l:cmd
    if s:length() | break | endif
  endwhile

  " Set the height of the quickfix window to the size of the list, max-height 10
  execute 'resize' min([ 10, max([ 1, s:length() ]) ])

  " Echo a description of the new quickfix / location list.
  " And make it look like a rainbow.
  let l:nr = s:getProperty('nr')
  let l:last = s:getProperty('nr', '$')
  echohl MoreMsg | echon '('
  echohl Identifier | echon l:nr
  if l:last > 1
    echohl LineNr | echon ' of '
    echohl Identifier | echon l:last
  endif
  echohl MoreMsg | echon ') '
  echohl MoreMsg | echon '['
  echohl Identifier | echon s:length()
  echohl MoreMsg | echon '] '
  echohl Normal | echon s:getProperty('title')
  echohl None
endfunction

function! quickfixed#older()
  call s:history(0)
endfunction

function! quickfixed#newer()
  call s:history(1)
endfunction
" ~/.vim/after/ftplugin/qf.vim
" Use <silent> so ":call quickfixed#older()" isn't output to the command line
nnoremap <silent> <buffer> <Left> :call quickfixed#older()<CR>
nnoremap <silent> <buffer> <Right> :call quickfixed#newer()<CR>

And after all that, this is how it looks (with Vim’s default colorscheme):

Please note that the scripts here require reasonably recent versions of Vim; lambdas were added in 7.4.204 and the loclist property of getwininfo() was added in 7.4.2215

Conclusion

Building up your Vim configuration in this way, step by step, is a great way to expand your knowledge of vimscript and the editor. You don’t need to set out to write a fully-fledged plugin—start with the mappings you need, and then begin polishing away the rough edges.

There is one more thing… It’s been emotional.

– Big Chris