1

What I want

I want create a mapping which comments a line into a box, for example

textline

shall be transformed to

" ##############################################################################
" textline
" ############################################################################## 

Depending on the buffer filetype, the appropriate comment string shall be used. The solution should work too if the line is empty. Furthermore, the same mapping or and additional mapping should be able to transform a box back to the normal text from anywhere within the box. Ideally, I could give a count too so that will work for more than one line.

My current solution

I use tpope's vim-commentary plugin which recognizes the appropriate comment string and lets me toogle line comments with gcc. I created the following mappings:

map <leader>b O<Esc>78i#<Esc>gccjgcco<Esc>a  <Esc>78i#<Esc>k$
map <leader>u _ddgccj_ddk$

The shortcomings of my solution

  • If I create a box from an empty line, the second line in the box is not commented (as gcc doesn't change an empty line)
  • The mapping for unboxing just works from the first line of a given box. Of course I can change that by adding a k or a kk in the beginning of the mapping to work from the second or third line, but it will only work then from a specific line
  • I need a second mapping for unboxing, I would prefer to use only one mapping for both boxing and unboxing
  • The mapping does not accept a count (to example box two lines instead of just one)

Question

How can I improve these mappings and get rid of as many shortcomings as possible?

2

This should work :

function! CommentBox()

    " PREPARATIONS

    " prefix is the number we hit before <leader>b
        let prefix=v:count1

    " We define the Firstchar() function that returns the first character
    " of the current line.
        function! Firstchar()
            execute "normal! _"
            return matchstr(getline('.'), '\%' . col('.') . 'c.')
        endfunction

    " We define the Gcc() function that comments an uncommented line.
        function! Gcc()
            if Firstchar() !=# s:commentchar
            execute "normal gcc"
            endif
        endfunction

    " We define the InsertCommentChar() function that inserts a
    " comment character manually, because the gcc mapping is unable to
    " comment an empty line.
        function! InsertCommentChar()
            if getline('.') =~ '\v(\s*|^$)'
                execute "normal! i\<c-r>a\e"
            endif
        endfunction

    " We store the character under the cursor inside the s:firstchar variable.
        let s:firstchar = Firstchar()

    " We open a new line.
        execute "normal! o"

    " We check whether this new line is empty and add the character 'a' in
    " this case.
        if getline('.') =~ '\v(\s*|^$)'
            execute "normal! ia\e"
        endif

    " We comment this new line.
        execute "normal gcc"

    " We store the comment character inside the s:commentchar variable.
    " For example, if we are in a python file, then s:commentchar = #
        let s:commentchar = Firstchar()

    " We delete this new line and go back where we were.
        execute "normal! ddk"

    " CREATION OF A BOX

    " If firstchar and commentchar are different, it means that we were not on
    " a commented line when we invoked the function. So we create a box.
        if s:firstchar !=# s:commentchar

        " We call the gcc mapping to comment the lines.
            execute "normal ".prefix."gcc"

        " We create the upper border and put the mark a on the line.
            execute "normal! O\ea  \e78i#\ema"

        " We create the lower border and put the mark b on the line.
            execute "normal! ".prefix."jo\ea  \e78i#\emb"

        " We put the comment character inside the register a.
            call setreg("a", s:commentchar)

        " We ask the global command to insert the comment character on every
        " empty line inside the box.
            'a,'b g/^$/call InsertCommentChar()

        " We go on the upper border and comment it if it's not.
            execute "normal! 'a"
            call Gcc()

        " Same thing for lower border.
            execute "normal! 'b"
            call Gcc()

    else

    " DESTRUCTION OF A BOX

    " If firstchar and commentchar are different, it means we were not on a
    " commented line when we invoked the function, thus we want to undo
    " a box, not create one.

    " First, we delete the borders and put the marks a and b around it.
        execute "silent! normal! ?###\rddma/###\rddkmb"

    " Then, we uncomment every line that was inside the box.
        'a,'b g/^/execute "normal gcc"

    endif

endfunction

" We define <leader>b as a shortcut to create/delete a box.
    nnoremap <leader>b :<c-u>call CommentBox()<cr>

There may be rough edges. If someone knows how to improve the code, let me know, I'm interested.


Preparations

The first instruction of the CommentBox() function is :

    let prefix=v:count1

The prefix variable stores the number that precedes the mapping that will be assigned to this function, which will be <leader>b.
So if you hit 6<leader>b, prefix will store 6 and if you hit directly <leader>b without prefixing it with a count, prefix will store 1.

Subfunctions

Next we define 3 functions (inside the main one) :

  • the Firstchar() function that returns the first character of the current line.
  • the Gcc() function that comments a line by calling the gcc mapping but only if the line is empty or contains only whitespace (with this regex \v(\s*|^$)).
  • the InsertCommentChar() function that inserts a comment character manually at the beginning of a line, because the gcc mapping is unable to comment an empty line.

These functions use 2 special commands : execute and normal.

The execute command can take a string or a concatenation of strings (with the dot operator .), inside the string it interprets the \e as the escape key, the \r as the enter key, the \<c-r> as the Ctrl + r key combination, evaluates the prefix variable and sends the result to the normal command.

The normal command types every key it reads (in this case it receives the key from the execute command) as if we were in normal mode.

So, whenever inside the script there's something written after normal, it will be as if we typed a sequence of keystrokes while in normal mode.

For example, the InsertCommentChar() function will type : i\<c-r>a\e.
This will cause the switch to insert mode (i), then the insertion of the a register, then go back to normal mode by escaping (\e).

Indeed, to insert the content of a register in insert mode you can hit Ctrl + r followed by the name of the register.

What will be in the a register ? The comment character.

Variables

Now that we have defined those functions, we need to set two variables s:firstchar and s:commentchar.

Why do we prefix them with s: ?
It's a scope (if you know Python, I think it's a sort of namespace).
The s: scope is local to the script, without it, the variables would be accessible in the main function but not in the subfunctions that we've just defined.
Indeed when a subfunction calls a variable defined in the main one, vim must know where to look for it.

To define s:firstchar, we simply call the function Firstchar() :

let s:firstchar = Firstchar()

To define s:commentchar, it's a bit more tricky.
Inspired by your comment (thanks a lot by the way), we're going to open a new line, insert a character in it, comment the line with the gcc mapping and then grab the first character of the line.

The implementation is the following :

execute "normal! o"

if getline('.') =~ '\v(\s*|^$)'
    execute "normal! ia\e"
endif

execute "normal gcc"
let s:commentchar = Firstchar()
execute "normal! ddk"

Note that we must ensure that this new line is empty (hence the regex \v(\s*|^$)) before inserting the a character.
Besides at the end, once we've stored the comment character inside s:commentchar, we delete our new line (dd) and go back where we were (k).

Decision making

Now we need to decide if we want to create or destroy a box. To do so, we're going to compare the first character of the line we're on when invoking the function with the comment character.

If they are different, it means we weren't on a commented line and want to create a box.
If they are the same, it means we were on a commented line (probably inside a box) and want to undo a box instead of creating one.

That's what the main if condition inside the main function checks :

if s:firstchar !=# s:commentchar
[...]
else 
[...]
endif

Creation of a box

We create a box with these 9 instructions :

    execute "normal ".prefix."gcc"
    execute "normal! O\ea  \e78i#\ema"
    execute "normal! ".prefix."jo\ea  \e78i#\emb"

    call setreg("a", charb)
    'a,'b g/^$/execute "normal! i\<c-r>a\e"

    execute "normal! 'a"
    call Gcc()
    execute "normal! 'b"
    call Gcc()

Suppose we hit 6<leader>b.

In this scenario, the normal command will first receive 3 sequences of keystrokes consecutively :

6gcc

O\ea  \e78i#\ema

6jo\ea  \e78i#\emb

The normal command will type them as if we were in normal mode.

  • 6gcc = we comment the 6 next lines (because we prefix our mapping with 6 in our example)

  • O\ea \e78i#\e = we open a line above us (O), escape (\e), go into insert mode and type 2 spaces (a), escape again, insert 78 # characters (78i#), escape again and put the mark a (ma) on the line.
    At this point, we have inserted a line above the original one, that is commented and contains 78 # characters.

  • 6jo\ea \e78i#\emb = we go 6 lines downward and do the exact same thing. Insert 78 # characters and put the mark b on the line (mb).

The box is almost done, but there's still one problem that you mentioned in your initial post.
The gcc mapping doesn't comment an empty line.

To solve this problem, we're going to insert ourselves the comment character on every empty line, and that's what the fourth and fifth instructions do.

The fourth instruction uses the setreg() built-in function to put the content of the s:commentchar variable inside the register a :

    call setreg("a", s:commentchar)

And the fifth instruction uses the global command. The syntax of the global command is the following :

range g/pattern/command

On every line inside range (for example 10,20 means every line between 10 and 20) that matches pattern, the global command will execute command.

The range we're going to use is 'a,'b (which means every line between mark a and mark b), the pattern will be ^$ (empty line) and the command will be call InsertCommentChar().

So the fifth instruction we need is :

    'a,'b g/^$/call InsertCommentChar()

The last 4 instructions are there to comment the borders if they aren't already (in some cases it happen). To navigate between the two borders, again we use the a and b marks that we set previously.

That's it, the box should be created.

Destruction of a box

If we hit the mapping <leader>b on a line that is commented (s:firstchar = s:commentchar), we execute these 2 instructions :

    execute "silent! normal! ?###\rddma/###\rddkmb"
    'a,'b g/^/execute "normal gcc"

Let's say we're still in our example and we've got a box containing 8 lines (6 for the comments and 2 for the borders). In this case, the first instruction will send to the normal command the following keystrokes :

?###\rddma/###\rddkmb
  • ?###\r = we search for the string ### backward
  • dd = we delete the first line of the box (the upper border).
  • ma = we're now on the first line commented inside the box, we put the mark a on it
  • /###\r = we search for the string ### forward.
  • dd = we delete the last line of the box (the lower border).
  • kmb = we go one line upward (k) and then we put the mark b on the line.

Once we've put our marks around our block of text, we want to run the gcc mapping on each of its line to uncomment it. To do so, we use the global command again :

'a,'b g/^/execute "normal gcc"

This line can be read like this :

for each line between mark a and b, whenever the global command finds a line matching the pattern ^ (which is every line, since every line has a beginning), it must run the command execute "normal gcc" on it.


The last line of the code maps the CommentBox() function to <leader>b :

nnoremap <leader>b :<c-u>call CommentBox()<cr>

Finally, the silent! commands are used whenever a search is performed, in case there are errors, they won't show up on screen.

And the difference between the normal commands and the normal! commands is the recursiveness. Usually I prefer no recursiveness so I use normal!, because recursiveness means trouble. But sometimes it's necessary.
Here it's necessary when we call the gcc mapping, because we want vim to interpret it recursively, otherwise it would probably be interpreted as g and cc separately or something like that.

  • Thank you very much for your answer and the detailed comments. As you mentioned, your solution doesn't always work, but it often does. I want a solution which works all the time, so I created one using Python myself. Nevertheless I appreciate the effort you put in this answer a lot, therefore I am going to accept your answer :) I think it is funny to see that you used way less code lines with VimL than I did with Python. – cbaumhardt Nov 15 '15 at 2:26
  • 1
    If you reliably want to determine if a line begins with a comment character, just append a new line with some text to the buffer and comment it out. Read it back in and you know how your comment looks like. – cbaumhardt Nov 15 '15 at 2:39
  • Thank you very much for accepting my answer and for the solution to the problem of recognizing a comment character. I modified the code. No need of the :Vb command anymore, no dirty hack with the @ character anymore, and the comment character should be properly detected now (thanks to your idea). I would like to help you review your code, but I don't know Python, so I can't. Thanks for this question, it was a great way to learn a bit more about VimL. – saginaw Nov 15 '15 at 5:17
  • You should accept your answer as the right one. Mine still needs a lot of modifications. Plus you will get more chance to get help for your code because your post will appear on top. I will then delete my answer, and maybe ask for help on another topic. – saginaw Nov 15 '15 at 9:01
  • Ok, nevertheless a big thanks to you. I created a small repository on Github for this project so it can be plugged in with 2 lines. I think you should consider not deleting your solution, as it might be helpful for others in the future (including me) – cbaumhardt Nov 15 '15 at 20:20
1

I created a plugin which does exactly that.

Just plug it in into your .vimrc, e.g. using vim-plug:

Plug 'cbaumhardt/vim-commentary-boxed'

After installing, define a custom mapping in your .virmc, e.g. <leader>b, and you are all set:

nnoremap <leader>b :<C-u>call ToggleBox()<CR>

Calling <leader>b will toggle a boxed comment for a line. It accepts counts too, e.g. 4<leader>b. Please see the examples for further explanation.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.