teaching machines

Quick Commenting in Vim

May 12, 2013 by . Filed under public, vim.

Speedy commenting is a major feature of any IDE like Vim. I’m sure there are good plugins that support it, but I don’t like other people’s code. I wrote my own, and here it is, ready for you to not like it.

It works on the premise that for each type of file you have, you register what the comment characters are for that type of file. The catch-all is the pound sign:

autocmd User,BufEnter *
\ let b:commentStart='#'|
\ let b:commentEnd=''

For HTML and XML, we’ve got:

autocmd User,BufEnter *.htm,*.html,*.xml,*.xsl,*.xsd
\ let b:commentStart='<!--'|
\ let b:commentEnd='-->'

Other languages are defined similarly. Only a few, like C, Haskell, and HTML require b:commentEnd to be a non-empty string.

Next I bind <Space>c to comment and -c to uncomment in both normal and visual mode. Hitting 5<Space>c in normal mode comments out the next five lines from the cursor. Or, I can hit V, select my five lines, and hit <Space>c.

These mappings lead to a couple of Vimscript functions, which do the real work.

map <silent> <Space>c :<C-U>call CommentNLines(1, v:count1)<CR>
map <silent> -c :<C-u>call CommentNLines(0, v:count1)<CR>

" Comments or uncomments the specified number of lines, starting from the line
" on which the cursor currently sits.
function! CommentNLines(wantsComment, nLines)
  " There's a companion function that will comment or uncomment a specified
  " range of lines, so our work here is just to determine the range and then
  " call that helper function.

  " We start at the cursor...
  let startAtLine = line(".")

  " And go nLines further, but going no farther than the end of the file.
  let endAtLine = startAtLine + a:nLines - 1
  if endAtLine > line("$")
    let endAtLine = line("$")
  endif

  call CommentLines(a:wantsComment, startAtLine, endAtLine)
endfunction

" --------------------------------------------------------------------------- "

vmap <silent> <Space>c :<C-U>call CommentSelectedLines(1)<CR>gv
vmap <silent> -c :<C-u>call CommentSelectedLines(0)<CR>gv

" Comments or uncomments the visually selected lines.
function! CommentSelectedLines(wantsComment)
  " There's a companion function that will comment or uncomment a specified
  " range of lines, so our work here is just to determine the range. The marks
  " '< and '> tell us the bounds of the visually selected range.
  silent call CommentLines(a:wantsComment, line("'<"), line("'>"))
endfunction

" --------------------------------------------------------------------------- " 

function! CommentLines(wantsComment, startAtLine, endAtLine)
  " Assert that comment characters have been registered for this buffer.
  if !exists("b:commentStart") || !exists("b:commentEnd")
    echo "No comment character registered for this filetype."
    return
  endif

  " We only want to allow a line to be commented once. This is particularly
  " true in C, where /* */ comments cannot legally nest. We also don't want to
  " try to uncomment a line that hasn't been commented. Let's find a pattern
  " that we can use later to ask a line its commented status.
  let commentedPattern = '^\(\s*\)\M' . b:commentStart . '\m \(.\{-}\) \M' . b:commentEnd . '\m\s*\(\\\)\?$'

  " There's one slightly funny thing in this pattern and the ones below. If I
  " have C preprocessor macro split across multiple lines, like this
  "   #define SQUARE(a) \
  "     a \
  "     * \
  "     a
  " I can't legally end the \-suffixed lines with the */ comment character. The
  " comment must be part of the macro, so the end comment character must appear
  " before the closing slash:
  "   #define SQUARE(a) \
  "     /* TODO: parenthesize a someday */\
  "     a \
  "     * \
  "     a
  " The patterns take into account closing backslashes and do not try to wrap
  " them inside the comment. Probably I should only handle this in C and C++,
  " but it's not manifested itself as an issue in other languages.

  " Comment and uncomment are handled with the same algorithm; the only
  " thing that changes is the pattern and substitution that we perform.
  if a:wantsComment
    " For a comment, we place the comment character after all leading
    " whitespace. I like my comments indented coherently, not up against the
    " left margin. \1 holds the leading space, and \2 everything else on
    " the line.
    let pattern = '^\(\s*\)\(.\{-}\)\(\\\)\?$'
    let substitution = '\1' . b:commentStart . ' \2 ' . b:commentEnd . '\3'
  else
    " For decommenting, we can reuse the comment pattern already defined. The
    " leading space is captured in \1, and the text between the comment start
    " and comment end is in \2.
    let pattern = commentedPattern
    let substitution = '\1\2\3'
  endif

  " Now, we're ready to visit each line and either comment or uncomment it.
  let i = a:startAtLine
  while i <= a:endAtLine
    let line = getline(i)

    " Is the line already a comment or a non-comment?
    let isAlreadyComment = line =~ commentedPattern

    " Don't operate on blank lines and make sure that the line isn't
    " already what the caller wants it to be. That is, we don't comment
    " a line twice, nor do we uncomment it twice. 
    if line !~ '^\s*$' && isAlreadyComment != a:wantsComment
      call setline(i, substitute(line, pattern, substitution, ''))
    endif

    let i = i + 1
  endwhile
endfunction

Comments with a wriggle of the nose!