Vim, Part II: Matching Pairs
written by Ian McCracken
at Monday, July 6, 2009
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.- 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. - Function to check next character.
function! ClosePair(char) if getline('.')[col('.') - 1] == a:char return "\<Right>" else return a:char endif endf
- 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>
- 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 toClosePair
. 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.
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
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!
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!
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.
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.
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.
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.
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
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>"
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.
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!
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 ;-)