Super-powered Vim, part III: Skeletons

Published on

This post is the third of a three-part series. If you're interested, you can start by checking out part I and part II.

Writing code is boring.

In the previous parts I showed how we could speed things up a bit, by having some powerful shortcuts for file navigation and inserting repeatable blocks of code (snippets).

Now, let's see how we can go one step further:

Let's stick with Ruby as our sample language. The common practice in the community is to have a class or module in each file, with the name matching the file path. So app/models/user.rb will have a class User, and app/models/foo/bar.rb will have a class Foo::Bar.

So wouldn't it be pretty cool if, when creating one of these files, it got be pre-populated by that class definition?

Wait... What?

In part II, I already mentioned a class snippet, which creates a Ruby class based on the path of the file.

Now I want this snippet to be automatically inserted in any new files created in the app directory.

Could we define some kind of attribute for these paths? Say... a projection?

Let's take the .projections.json example from part I and expand it a bit:

{
  "app/*.rb": {
    "alternate": "spec/{}_spec.rb",
    "skeleton": "class"
  },
  "spec/*_spec.rb": {
    "alternate": "app/{}.rb",
    "skeleton": "spec"
  }
}

We now have a skeleton attribute for both app and spec files. class and spec are snippet names, which insert a class definition, and some RSpec boilerplate, respectively.

Now let's look at some vimscript magic (I know... sorry):

augroup UltiSnips_custom
  autocmd!
  autocmd BufNewFile * silent! call skel#InsertSkeleton()
augroup END

This is defining an autogroup (more on what that is here) that listens to the BufNewFile event. This event is called when a buffer is created for a new file (i.e.: a file that is not yet persisted to disk). All of this happens when we do the :edit command for with a non-existing path.

That command will then call the skel#InsertSkeleton function (see the code snippet at the end of the post for its definition). In short, the function will:

  1. Make sure the buffer does not exist and is empty (we probably don't want to do anything in these cases)
  2. Loop through the existing projections, to look for a skeleton key matching the current file path
  3. If a skeleton is found, insert the snippet's name, and expand it using the appropriate function from UltiSnips.

For the example of app/models/foo.rb, this will literally insert the word class into the buffer, and call UltiSnips#ExpandSnippet() for us, creating the class definition.

magic

But wait, there's more

You just created a Ruby class by simply opening a new file.

Now you want to write some tests for it, so you use your newly learnt vim-projectionist skills, and type :A (or whatever shortcut you have) to go to it's alternate file, as defined by the projections. This should match to spec/models/foo_spec.rb

This file also does not exist, but vim-projectionist is aware of this (see part I for more on this). This will conveniently ask you if you want to create it on the fly.

Once you accept to do so, the same events will be triggered, and this time, the spec snippet will be inserted on this file.

You can then proceed to writing the interesting part of your app. Or you can take a break, enjoying those 15 seconds of work you just avoided. You deserve it.

Full code

I avoided going through the entire vim code in this post, as that would've been a bit offtopic, and probably hard as well, given how much VimL sucks. All the vimscript code needed to reproduce my setup is right here for those who want it.

Feel free to reach me out through twitter for any questions you might have, or bugs you might find, or checkout my dotfiles if you want to dig some more useful tips from there.

augroup UltiSnips_custom
  autocmd!
  " autocmd User ProjectionistActivate silent! call skel#InsertSkeleton()
  autocmd BufNewFile * silent! call skel#InsertSkeleton()
augroup END

function s:try_insert(skel)
  execute "normal! i" . a:skel . "\<C-r>=UltiSnips#ExpandSnippet()\<CR>"

  if g:ulti_expand_res == 0
    silent! undo
    return
  endif

  " enter insert mode and advance cursor (equivalent to typing `a` instead of `i`)
  execute "startinsert"
  call cursor( line('.'), col('.') + 1)

  return g:ulti_expand_res
endfunction

function! skel#InsertSkeleton() abort
  let filename = expand('%')

  " abort on non-empty buffer or exitant file
  if !(line('$') == 1 && getline('$') == '') || filereadable(filename)
    return
  endif

  if !empty('b:projectionist')
    " Loop through projections with 'skeleton' key and try each one until the
    " snippet expands
    for [root, value] in projectionist#query('skeleton')
      echo value
      if s:try_insert(value)
        return
      endif
    endfor
  endif

  call s:try_insert('skel')
endfunction

What's next?

If you haven't seen the prequels to this post, maybe now would be a good time to do so?

Check out part I and part II for more details on what projections and snippets are, and how to use them!

Or take a look at another post by one of our developers. Happy coding!