Vim: Ansible go-to-role

The Problem

I'm writing a lot of Ansible these days (not loving the language, but that's a rant for another day). An Ansible playbook is commonly spread across dozens, and possibly hundreds of files. Ansible's file structures are complex and annoying, and moving around in them in (Neo)Vim can be a pain. Today I wrote a minor extension for Vim that allows you to open a (Netrw|NerdTree) view on a role folder by typing a key combination on the playbook line that specifies the role. This is far from a complete solution: it does exactly what it says, but it turns out to be even less use than expected because there are so many other types of file linkages in Ansible. Consider it more a proof of concept, or a base for further work.

The Solution

Here's a piece of an Ansible playbook. If you're on either of the role: lines below, pressing "gf" will open the role folder:

- name: set up nginx
  hosts: acctdev
  roles:
    - role: common
    - { role: nginx,    tags: sshkeys,  env: dev }

Here's how I did it:

" file: ~/.vim/ftplugin/yaml.vim

function! AnsibleGoRole()
    if getline('.') =~ 'role:'
        silent normal "xY
        let @x=substitute(@x, '.*\(\<role\>:\_s*\)\(\<\w\+\>\).*', '\2', '')
        let roledir=fnamemodify("roles/" . @x . "/.", ":p")
        silent execute ':tabnew '. roledir
    else
        echom "Couldn't find a role to go to."
    endif
endfunction

nnoremap <buffer> <silent> gf :call AnsibleGoRole()<CR>

The Pieces

  • if getline('.') =~ 'role:' - here, we check to see if the line actually has a reference to a "role:" in it: otherwise, let them know a role hasn't been detected.
  • silent normal "xY - let's start with "xY, which means "select register 'x', Yank this line into it." silent normal means "run it in normal mode, but don't echo any messages." This becomes important because this command would normally echo "1 line yanked" and then, when another action further along in the function echoes another message, Vim sees you have two lines of messages and insists you hit a key to continue. Very annoying.
  • let @x=substitute(@x, '.*\(\<role\>:\_s*\)\(\<\w\+\>\).*', '\2', '') - this was by far the worst line to sort out: I spent a lot of time reading about Vim regexes and typing :"xY followed by :let @x=substitute(@x, 'role:', '', ''), then :"xY and :let @x=substitute(@x, '.*\<role\>:_s*', '', '') etc. until I got it right. Who knew _s* was whitespace in Vim?
  • let roledir=fnamemodify("roles/" . @x . "/.", ":p") - a simpler call to fnamemodify would be :echo fnamemodify("bob", ":p"). If you run that as an ex command, you'll find it takes the directory of the current file and appends "bob" on the end. So this line assigns a value to roledir of the form /full/path/to/file/roles/<role_name>/.
  • silent execute ':tabnew '. roledir - now we're just (silently - no echoed messages) opening a new tab on the roledir folder - if you prefer buffers to tabs, substitute :e for :tabnew
  • nnoremap <buffer> <silent> gf :call AnsibleGoRole()<CR> - assign our new function to a key combo. It'll only be available in normal mode, don't allow remapping (never, ever allow remapping), only assign it for the current buffer, and be silent (it liked to complain that I was executing against a directory rather than a file: a valid complaint, but it was intentional).

The Caveats

  • "gf" overwrites an existing Vim mapping. Type :help gf to see: it's meant to be "goto file" but doesn't work in Ansible, so this ... partly ... fixes it
  • the regex could be better
  • this only works in top-level playbooks: as soon as you're in a task file, it's no use
  • role: declarations can be spread across multiple lines: this would immediately cease to work
  • I wrote it today: it's barely tested
  • I wrote it to work with my own Ansible workflow, it may be totally unsuitable for you
  • it should check for existence of the folder - and potentially, if it doesn't exist, offer to create it
  • 2016-07-09: I almost immediately found a piece of code that causes it to fail:
- name: install common packages
  hosts: router
  remote_user: root
  roles:
    - openwire
  • this is the problem with Ansible code from a Vim perspective: it's flexible enough that parsing it requires masses of exceptions and back-checks, which makes it hard to write