Vim, Part II: Matching Pairs

One of the more standard vim tweaks is to have parens, braces, brackets, and quotes auto-close—that is, you type (, and the ) is inserted automatically. This can be a big help in keeping track of nested groups. This is easy to do poorly; to do it well takes a little more thought. I took a look at TextMate's rules and mimicked them (mostly). Put all this stuff in your .vimrc (_vimrc on Windows).

Note: A couple of these tweaks are modified or copied outright from the Vim Scripts repository.

Auto-close pairs


The initial auto-close is quite easy; leaving the group is slightly more involved. If you're inside parens, at the end, and you want to leave them, instinct is just to type the closing ); without a little logic, however, that will in no way achieve the desired result. The rule is: if typing the closing character would end the block without auto-close (that is, because it's the character to the immediate right of the cursor), it should end it with auto-close. Here's how you achieve that.
  1. Map the auto-close for non-quotes. This is trivial:
    inoremap ( ()<Left>
    inoremap [ []<Left>
    inoremap { {}<Left>
    autocmd Syntax html,vim inoremap < <lt>><Left>

    No big deal; when the opening character is typed, insert the closing character and hit <Left> so the cursor is between them.
  2. Function to check next character.
    function! ClosePair(char)
    if getline('.')[col('.') - 1] == a:char
    return "\<Right>"
    else
    return a:char
    endif
    endf
  3. Map closing characters. Now, all you need to do is link the closing characters to the function:
    inoremap ) <c-r>=ClosePair(')')<CR>
    inoremap ] <c-r>=ClosePair(']')<CR>
    inoremap } <c-r>=ClosePair('}')<CR>
  4. Map the auto-close for quotes. This is only slightly more difficult than ()[]{}, because of the case where you might add an escaped quotation mark to a string. It requires a separate function: since the opening and closing characters are the same, we can't map the closing to ClosePair. So we put it all in one function:
    function! QuoteDelim(char)
    let line = getline('.')
    let col = col('.')
    if line[col - 2] == "\\"
    "Inserting a quoted quotation mark into the string
    return a:char
    elseif line[col - 1] == a:char
    "Escaping out of the string
    return "\<Right>"
    else
    "Starting a string
    return a:char.a:char."\<Left>"
    endif
    endf 
    

    Then just need to map that:
    inoremap " <c-r>=QuoteDelim('"')<CR>
    inoremap ' <c-r>=QuoteDelim("'")<CR>
    


Dump all that in to .vimrc, source it or restart vim, and try typing some stuff! You may notice a few places where it isn't quite what you'd want, though, which brings us to our next two tweaks.

Wrap visually selected text


One of the nicer minor features of TextMate is its treatment of highlighted text. If you have something highlighted and type a, it replaces the text, like other editors. If you type (, however, it wraps the selected text in parentheses. This is enormously useful. Luckily, it's very easy to recreate in Vim:
vnoremap (  <ESC>`>a)<ESC>`<i(<ESC>
vnoremap )  <ESC>`>a)<ESC>`<i(<ESC>
vnoremap {  <ESC>`>a}<ESC>`<i{<ESC>
vnoremap }  <ESC>`>a}<ESC>`<i{<ESC>
vnoremap "  <ESC>`>a"<ESC>`<i"<ESC>
vnoremap '  <ESC>`>a'<ESC>`<i'<ESC>
vnoremap `  <ESC>`>a`<ESC>`<i`<ESC>
vnoremap [  <ESC>`>a]<ESC>`<i[<ESC>
vnoremap ]  <ESC>`>a]<ESC>`<i[<ESC>

Backspace in empty pair deletes both


Now that you have auto-closing parens, let's say you type one by mistake. Unfortunately, deleting it now involves two extra keystrokes (<ESC>xx or <Right><Bksp><Bksp> instead of <Bksp>). TextMate gets around this by deleting both if you're in an empty block. I was tired of mapping the same thing for every pair, though, so I wrote a function that uses the matchpairs option, which exists to tell Vim what delineates a discrete block, to figure it out dynamically (thanks to Aristotle Pagaltzis for cleaning it up!). It checks whether the characters on either side of the cursor constitute a valid pair:
function! InAnEmptyPair()
let cur = strpart(getline('.'),getpos('.')[2]-2,2)
for pair in (split(&matchpairs,',') + ['":"',"':'"])
if cur == join(split(pair,':'),'')
return 1
endif
endfor
return 0
endfunc

And then the actual deletion function:
func! DeleteEmptyPairs()
if InAnEmptyPair()
return "\<Left>\<Del>\<Del>"
else
return "\<BS>"
endif
endfunc

Then map it to Backspace, so it's called whenever Backspace is:
inoremap <expr> <BS> DeleteEmptyPairs()

So, in plain English: when Backspace is hit, we check to see if the characters on the immediate left and right of the cursor are part of a matching pair, looking at the matchpairs option to see what counts of a valid pair. If we are in a pair, delete both characters; otherwise, send a normal Backspace.

Next (and probably last) in the series: Whatever's left in my .vimrc, namely, assorted tweaks to make one's coding life easier.

12 comments

  • Aristotle Pagaltzis  
    July 6, 2009 at 9:27 PM

    function! InEmptyPair()
      let cur = strpart(getline('.'),getpos('.')[2]-2,2)
      for pair in split(&matchpairs,',')
        if cur == join(split(pair,':'),'')
          return 1
        endif
        return 0
      endfor
    endfunc

  • Ian McCracken  
    July 7, 2009 at 7:23 AM

    That's much better. My Vimscript is pretty weak. I modified it a little bit, as it didn't quite work, and updated the post. It's way cleaner now. Thanks!

  • Anonymous  
    July 7, 2009 at 7:30 AM

    Great post, a couple issues though. In your QuoteDelim function it looks like you missed a & on lt;. Also, the InAnEmptyPair function doesn't seem to work for <>. Aside from that the only issue i have is that getpos() doesn't seem to be valid for vim6 which I'm stuck using at work, so the delete empy pairs function doesn't work there but that's no big deal. Thanks for the post!

  • Ian McCracken  
    July 7, 2009 at 7:35 AM

    Oh, good point. Forgot, I also have:

    set matchpairs=(:),[:],{:},<:>

    That'll fix the <> InAnEmptyPair. Not sure about the getpos() issue; I'll investigate.

  • Anonymous  
    July 7, 2009 at 7:36 AM

    I wish I could edit my previous post. <> isn't in matchpairs, which is why that failed. Please ignore.

  • Aristotle Pagaltzis  
    July 7, 2009 at 10:07 AM

    Yeah, I only spotted my error a while after I posted the comment, sorry.

    Thanks for the post, btw; I had tried to use the maps to insert complete pairs before but never stuck with it. This time it seems I will. The extra thought put into it by the TextMate folks really makes a difference. Thanks for explaining it.

    In other notes, I've also cut your backspace map down to this:

    inoremap <expr> <BS> InEmptyPair() ? "\<Right>\<BS>\<BS>" : "\<BS>"

    Also, you can write merely + [ '""', "''" ] to add the quotes to the list of pairs. Since there is no colon, split will just return its first argument as a single-element list, which join then dutifully returns verbatim, which is just fine. I find that this reduction in punctuation makes that bit of the code a little bit easier to read.

  • Aristotle Pagaltzis  
    July 7, 2009 at 10:10 AM

    Oh yeah, and the ClosePair maps can make use of <expr> instead of the cumbersome <C-R>=...<CR> pattern.

  • Aristotle Pagaltzis  
    July 7, 2009 at 10:26 AM

    Also, the parens, brackets and braces are already motions in visual mode – same as normal mode. (Try them! They’re very useful.) So I would use an unmapped combination for the wrapping maps; I chose to prepend a q.

    Now, if you have the auto-close maps already defined, you can define those maps very elegantly:

    vmap q( di(<ESC>p
    vmap q) di(<ESC>p
    vmap q[ di[<ESC>p
    vmap q[ di[<ESC>p
    vmap q{ di{<ESC>p
    vmap q} di{<ESC>p

    vmap q' di''<ESC>P
    vmap q" di""<ESC>P
    vmap q` di``<ESC>P

  • Aristotle Pagaltzis  
    July 9, 2009 at 10:07 AM

    Turns out the last comment was not quite right – it does not work right if the visual selection goes up to the end of a line because vim will position the cursor one character back in that case. The way to fix that is:

    vmap q( s(<C-R>"
    vmap q) s(<C-R>"
    vmap q[ s[<C-R>"
    vmap q[ s[<C-R>"
    vmap q{ s{<C-R>"
    vmap q} s{<C-R>"
    vmap q' s''<Left><C-R>"
    vmap q" s""<Left><C-R>"
    vmap q` s``<Left><C-R>"

  • Aristotle  
    July 10, 2009 at 1:15 PM

    So by now I have made a lot of changes to the script as I tried to make it better, and make it less annoying to me. If anyone reads this and still cares, they can see my version at http://gist.github.com/144619 (for now).

    The big difference is that I have it not insert closing delimiters automatically now. Instead, you have to type the closing delimiter explicitly – but when you type a closing delimiter it tries to be smart about your intent. Eg. if you type an empty pair like (), it will move the cursor back inside, saving you from having to move the cursor back manually – if you really wanted to type an empty pair, you can just type the closing delimiter twice to move outside. So far the approach seems to work very well for me: it almost never makes extra work for me, but it constantly saves me a little bit of it.

  • Israel Chauca F.  
    July 12, 2009 at 5:12 PM

    @Ian: Thanks for this great post, this small details are what make life easier, that auto-delete feature is very nice.

    @Aristotle: I prefer your approach on closing by hand, automatic matching gets in the way sometimes.

    Please guys, keep us posted on any updates!

    Thanks!

  • Benjamin  
    May 15, 2010 at 2:25 AM

    I made an improvement to your 'deleting empty pairs' code. Namely, yours only works if your cursor is between the empty pair. I wanted the pair to be deleted if either character of the pair was deleted, either with backspace or forward-delete:

    function! BackSpaceEmptyPair()
    let between = strpart(getline('.'),col('.')-2,2)
    let rightof = strpart(getline('.'),col('.')-3,2)
    for pair in (split(&matchpairs,',') + ['":"',"':'",'`:`'])
    let emptypair = join(split(pair,':'),'')
    if between == emptypair
    return "\<Right>\<BS>\<BS>"
    elseif rightof == emptypair
    return "\<BS>\<BS>"
    endif
    endfor
    return "\<BS>"
    endfunc
    inoremap <expr> <BS> BackSpaceEmptyPair()

    function! DeleteEmptyPair()
    let between = strpart(getline('.'),col('.')-2,2)
    let leftof = strpart(getline('.'),col('.')-1,2)
    for pair in (split(&matchpairs,',') + ['":"',"':'",'`:`'])
    let emptypair = join(split(pair,':'),'')
    if between == emptypair
    return "\<Left>\<DEL>\<DEL>"
    elseif leftof == emptypair
    return "\<DEL>\<DEL>"
    endif
    endfor
    return "\<DEL>"
    endfunc
    inoremap <expr> <DEL> DeleteEmptyPair()

    BTW, being able to use the a <code> tag instead of having to convert code would be handy ;-)

Post a Comment