Customizing vim: formatting comments (part 2)

This article is a follow up of the previous one, with the aim to discuss a few more tips for writing custom vim functions and explain additional features of the vimscript language.

In this article, we continue with the subject of formatting comments. Today’s problem is to change multiple lines of adjacent // comments into a single multiline /* ... */ comment.

Example:

// some big comment
// detailing how some 
// big function works

We would like to transform the above comment to this:

/* some big comment
 * detailing how some
 * big function works
 */

Personal preference, but I dislike having the */ at the end of a line.

Like in the previous article, we want to be able to move to the problematic comment with the cursor, and then use a keybinding to automatically perform the correction.

Building the function

In this case, the cursor could be anywhere in the middle of a long comment, so simply using an if statement to check the next and previous lines like in the previous example won’t work. This gives us a chance to take a look at loops in vimscript.

In vimscript, there are two kinds of loops: for and while. Unfortunately vimscript doesn’t allow for C-style for loops, so you will observe that while loops are a lot more common. The most common uses cases for for loops is to loop through arrays (called lists in vimscript lingo)

Let’s take a look at a simple for loop through an array (list)

:let groceries = ["milk", "eggs", "flour"]

:for product in groceries
:  echo product
:endfor

Not particularly useful for our use case here, since we won’t even know how many lines long a comment would be, nor would it be practical to turn comments into lists and process them that way.

So, let’s take a look at a simple while loop:

:let i = 1
:let s = 0

:while (i <= 4)
:  let s = s + i
:  let i = i + 1
:endwhile

:echo s 

This basically works as you’d expect in any other programming language. Encasing the condition in parentheses () is not necessary, but it does help to make the code a bit readable.

A note about delimiters in regular expressions

As we will be dealing with a lot of // in this function, we would end up with some nasty looking regular expressions, since we’d be having to escape the forward slashes. It’s actually possible to use other characters than / for regex delimiters, a common choice is ~. If we do this, we no longer have to escape the forward slashes in the regular expression itself, since it does not conflict with the delimiter anymore. This actually can be done not only in vim, but also in other places that use regular expressions, such as sed. For example:

echo "//" | sed 's~/*~@~'

is a completely valid way to use sed and it will print @ as expected.

Outline

If the cursor is not on a line that beings with //, then bail

If it is, then pick up how many lines our change will affect:
using a while loop, keep going "up", and check if the line still has //
if it does, set the start line to this new line
otherwise, if it does not, quit the while loop

using another while loop, keep going "down" and check if the line still has //
if it does, set the end line to this new line,
if it does not, quit the while loop 

if the start line and end line are the same line, it means the //
comment is not a multiline comment, so tell the user and bail

So at the start line, we want to replace // with /*
then, for the remainder, we want to replace // with *
but we want to end the last line on a */ no matter what.

We also want to make sure we can deal with odd cases like
two multiline comments being close to each other.

Besides the points noted above, there is not much else to take away from here, so we can jump right into writing up the function.

The function

function ToMultiLineComment()
     let start_line = line(".")
     let double_slash = '^\s*\/\/'

     if (match(getline(start_line), double_slash) == -1)
      echohl WarningMsg | echo 'Cursor not in line with a multi-line "//" comment.' | echohl None
      return
     endif

     let end_line = start_line

     while (match(getline(start_line - 1), double_slash) >= 0)
      let start_line = start_line - 1
     endwhile
     while (match(getline(end_line + 1), double_slash) >= 0)
      let end_line = end_line + 1
     endwhile

     if (start_line == end_line)
      echohl WarningMsg | echo 'This comment is only a single line in length.' | echohl None
      return
     endif

     if (match(getline(end_line), '^\s*\/\/\s*$') >= 0)
      execute end_line
      let end_line = end_line - 1
     endif

     execute start_line
     execute 's~//~/*~'

     if (end_line >= (start_line+1))
       execute (start_line + 1) . ',' . end_line . 's~^\(\s*\)//~\1 *~'
       execute end_line + 1
     endif
endfunction

As with the example of the previous article, this function could be mapped to a keybinding using the if so desired.