JESii
11/12/2016 - 4:30 PM

Vim: pushing built-in features beyond their limits

Vim: pushing built-in features beyond their limits

The situation

Searching can be an efficient way to navigate the current buffer.

The first search commands we learn are usually / and ?. These are seriously cool, especially with the incsearch option enabled which lets us keep typing to refine our search pattern. / and ? really shine when all we want is to jump to something we already have our eyeballs on but they are not fit for every situation:

  • when we want to search something that's not directly there, those two commands can make us loose context very quickly,
  • when we need to compare the matches.

A better way

A better candidate for those situations would be the awesome :g[lobal]/pattern/command but it uses the :p[rint] command by default, which is not that useful:

:g/foo<CR>
fqjsfd foo
foo dhqdgqs
    // foo shdjfksgdf
Press ENTER or type command to continue

With :# (or :nu[mber]), we ask Vim to also display the line numbers:

:g/foo/#<CR>
  3 fqjsfd foo
 12 foo dhqdgqs
 13     // foo shdjfksgdf
Press ENTER or type command to continue

that we can use at the prompt:

:12<CR>

to jump to the desired line.

This doesn't look like much but :g/pattern/# is an immensely useful tool that deserves its place in everyone's toolbox. No setup, no dependency, no third-party plugin… it just works!

And will always do.

A better better way

As is, this command requires us to type:

  1. :g/,
  2. our pattern,
  3. /#<CR>,
  4. : followed by 1 to infinite digits,
  5. <CR>.

The pattern and the line number can't be known in advance, of course, but we can certainly simplify the rest.

Steps 1 and 3

This is the easiest (and thus most boring) part. Starting with :g/pattern/#<CR>, it's relatively easy to come up with a simple mapping:

nnoremap <key> :g//#<Left><Left>

that populates the command-line with a command stub and moves the cursor between the slashes, ready for us to type the pattern and press <CR>:

:g/|/#

Executing our search is now reduced to <key> + pattern + <CR>, which is not bad at all. But what about the prompt? What if we could reduce that : + digits + <CR> part?

Step 4

This problem is a bit more complex (and thus a lot more interesting) but it's very doable with a bit of straightforward vimscript:

function! CCR()
    " grab the current command-line
    let cmdline = getcmdline()
    
    " does it end with '#' or 'number' or one of its abbreviations?
    if cmdline =~ '\v\C/(#|nu|num|numb|numbe|number)$'
        " press '<CR>' then ':' to enter command-line mode
        return "\<CR>:"
    else
        " press '<CR>'
        return "\<CR>"
    endif
endfunction

" map '<CR>' in command-line mode to execute the function above
cnoremap <expr> <CR> CCR()

The idea is to automatize the <CR> then : sequence when the command we typed (or ran through a mapping) ends with /# or any abbreviation of /nu[mber].

We are now down to <key> + pattern + <CR> + digits + <CR>. Yes, that's a "core" of only three motherfucking keystrokes for the whole process!

Generalizing

Sure, streamlining that boring (but mighty) :g/foo/#<CR>:17<CR> was quite an achievement but we undoubtedly learned a lot of similar list-like commands in the meantime: :ls, :changes, :ilist and so on. Creating a common mapping for all those commands is obviously out of question but… what if we could add an automatic prompt for all of them?

Well, we already have a solid foundation, so let's augment it a bit:

function! CCR()
    let cmdline = getcmdline()
    if cmdline =~ '\v\C/(#|nu|num|numb|numbe|number)$'
        return "\<CR>:"
    elseif cmdline =~ '\v\C^(ls|files|buffers|dli|il|cli|lli|old|changes|ju|marks|undol)'
        return "\<CR>:"
    else
        return "\<CR>"
    endif
endfunction

This seems to work: pressing <CR> after any of those commands executes the command and populates the command-line with a minimalist prompt. Another achievement unloc… Wait! Wait! Wait! Nope. That's actually a super dumb way to treat our "problem"!

Why? Because those commands actually have different prompts. Sure :# being all about listing lines, the only reasonable prompt is a colon, but :ls lists buffers so the right prompt would be :b, :changes lists entries in the change list so the right prompt would be :norm! g;, and so on. Don't panic! We only have to add a bunch of conditions to our test. That's all.

Hmm… and figure out the right prompt for each command:

CommandPrompt (* marks the desired cursor position)
:#, :nu[mber]:*
:ls, :files, :buffers:b*
:il[ist]:ij[ump] * pattern
:dli[st]:dj[ump] * pattern
:cl[ist]:cc *
:lli[st]:ll *
:old[files]:e[dit] #<*
:changes:norm[al]! *g;
:ju[mps]:norm[al]! *<C-o>
:marks:norm[al]! '*
:undol[ist]:u[ndo] *

Yeah, some of those prompts are a bit unintuitive and/or require quite a lot of typing. That's one more reason for streamlining the whole thing, right?

One more thing to consider is how Vim orders items in those lists and how long the list can be. The lists generated by :#, :ls, :ilist, :dlist, :clist, :llist, or :marks are relatively short but those generated by :jumps, :oldfiles, or :changes can be 100 lines long and require paging. This can be really cumbersome, especially considering that the most recent items are near the end of the list. For this reason, I chose to temporarily :set nomore in order to jump directly to the end of the list. This is not as clean as I would like it to be but well…

And now, the glorious (and commented) result:

" make list-like commands more intuitive
function! CCR()
    let cmdline = getcmdline()
    if cmdline =~ '\v\C^(ls|files|buffers)'
        " like :ls but prompts for a buffer command
        return "\<CR>:b"
    elseif cmdline =~ '\v\C/(#|nu|num|numb|numbe|number)$'
        " like :g//# but prompts for a command
        return "\<CR>:"
    elseif cmdline =~ '\v\C^(dli|il)'
        " like :dlist or :ilist but prompts for a count for :djump or :ijump
        return "\<CR>:" . cmdline[0] . "j  " . split(cmdline, " ")[1] . "\<S-Left>\<Left>"
    elseif cmdline =~ '\v\C^(cli|lli)'
        " like :clist or :llist but prompts for an error/location number
        return "\<CR>:sil " . repeat(cmdline[0], 2) . "\<Space>"
    elseif cmdline =~ '\C^old'
        " like :oldfiles but prompts for an old file to edit
        set nomore
        return "\<CR>:sil se more|e #<"
    elseif cmdline =~ '\C^changes'
        " like :changes but prompts for a change to jump to
        set nomore
        return "\<CR>:sil se more|norm! g;\<S-Left>"
    elseif cmdline =~ '\C^ju'
        " like :jumps but prompts for a position to jump to
        set nomore
        return "\<CR>:sil se more|norm! \<C-o>\<S-Left>"
    elseif cmdline =~ '\C^marks'
        " like :marks but prompts for a mark to jump to
        return "\<CR>:norm! `"
    elseif cmdline =~ '\C^undol'
        " like :undolist but prompts for a change to undo
        return "\<CR>:u "
    else
        return "\<CR>"
    endif
endfunction
cnoremap <expr> <CR> CCR()

Basically, that mapping and its associated function don't really change anything fundamental. We still use :ilist or :oldfiles as we used to, but we don't have to type (and remember) a different prompt for every command anymore. All we did was reducing friction and have a lot of fun in the process.

OK, but we still have only one mapping for :g//#. What about the other commands?

Sure we could go on a hunt for available keys and create mappings for just about every list-like command but that will make a lot to remember for commands we don't use that much anyway. Instead, let's consider this function and this command-line mode mapping as "enablers" that make using those list-like commands a lot easier.

If we notice one such command creeping into our workflow, well… we will map it!