The Sharat's

Automating the Vim workplace — Chapter Ⅲ

This is the third installment of my Automate the Vim workplace article series. As always, feel free to grab the ideas in this article or, better yet, take inspiration and inspect your workflow to identify such opportunities.

This article is part of a series:

  1. Chapter Ⅰ.
  2. Chapter Ⅱ.
  3. Chapter Ⅲ (this article).

Please note that all that I share below is what I’m using with Vim (more specifically, GVim on Windows). I don’t use Neovim (yet) and I can’t speak for any of the below for Neovim.

Copy file full path

I work with CSV files quite a bit. I spend a lot of time grooming them, fixing them etc. in Vim and then once they’re ready, I need to upload it to an internal tool. For that, the following command has proven to be super useful.

" Command to copy the current file's full absolute path.
command CopyFilePath let @+ = expand(has('win32') ? '%:p:gs?/?\\?' : '%:p')

This is one of those commands that feel super-simple and super-obvious once we add it to our workflow. Running this command places the full path of the current buffer’s file into the system clipboard. Then, I just go to my browser, click on the upload button and paste the file location. This is much quicker than having to navigate to the folder and selecting the file. It also helps avoid selecting the wrong file (which happened more than once to me).

Squeeze / Expand contiguous blank lines

When building or editing large CSV files, I often end up with several (read: hundreds) of blank lines. This is usually because I select those lines in visual block mode, cut them, and then paste as a new column to some existing rows. Solving that problem is for another day I suppose.

Nonetheless, I needed a quick way to condense several blank lines into a single blank line. The following is the result of that:

nnoremap <silent> dc :<C-u>call <SID>CleanupBlanks()<CR>
fun s:CleanupBlanks() abort
    if !empty(getline('.'))
        return
    endif
    let l:curr = line('.')

    let l:start = l:curr
    while l:start > 1 && empty(getline(l:start - 1))
        let l:start -= 1
    endwhile

    let l:end = l:curr
    let l:last_line_num = line('$')
    while l:end < l:last_line_num && empty(getline(l:end + 1))
        let l:end += 1
    endwhile

    if l:end >= l:start + v:count1
        exe l:start . '+' . v:count1 . ',' . l:end . 'd_'
    else
        call append(l:end, repeat([''], v:count1 - (l:end - l:start) - 1))
    endif
    call cursor(l:start, 1)
endfun

This defines the dc mapping, which will condense multiple blank lines under the cursor into a single one.

Then, on a weekend when I was feeling particularly silly, I extended this to accept a number in front of dc which specifies the number of newlines to end up with. So now, this mapping can both condense, and expand vertical blank space to any size I want! Yay, silly weekends!

Duplicate Text in Motion

Copy-pasta is a legitimate writing and coding technique. But I do it so mindlessly and often, I started to think of duplicating as a distinct operation, and not as a combination of yanking and then pasting. But if that is so, duplicating some text should not mess with my registers. This was messing with the nice semantic pool my thoughts were swimming in (!).

So I built a mapping that would let me duplicate the text over any motion (like text objects), without touching the registers. Following is how it’s built:

" Duplicate text, selected or over motion.
nnoremap <silent> <Leader>uu :t.\|silent! call repeat#set('duu', v:count)<CR>
nnoremap <silent> <Leader>u :set opfunc=DuplicateText<CR>g@
vnoremap <silent> <Leader>u :<C-u>call DuplicateText('vis')<CR>
fun DuplicateText(type) abort
    let marks = a:type ==? 'vis' ? '<>' : '[]'
    let [_, l1, c1, _] = getpos("'" . marks[0])
    let [_, l2, c2, _] = getpos("'" . marks[1])

    if l1 == l2
        let text = getline(l1)
        call setline(l1, text[:c2 - 1] . text[c1 - 1:c2] . text[c2 + 1:])
        call cursor(l2, c2 + 1)
        if a:type ==? 'vis'
            exe 'normal! v' . (c2 - c1) . 'l'
        endif

    else
        call append(l2, getline(l1, l2))
        call cursor(l2 + 1, c1)
        if a:type ==? 'vis'
            exe 'normal! V' . (l2 - l1) . 'j'
        endif

    endif
endfun

Now, what used to be yap}p has become ,uap. That’s just one key reduced but a reduction in keys is not what I’m aiming at here. It’s cognitive load of “duplicate this text” over “copy this text, go to end of text, paste text”. This works in visual mode as well, though I don’t use it as often.

Additionally, if triggered in visual mode, the duplicated text is selected again in visual mode. This quickly highlights the newly inserted text, so I can continue with operating on the duplicated text.

Now, if you’re aware of the :t (or :copy) command, then what I’m doing above may seem pointlessly elaborate. To an extent, I agree. In fact, I’m using the :t command for the ,uu mapping which is for duplicating a single line. The difference is that where :t only works line-wise, my implementation above can work character wise as well as line wise. For example, ,uaw (or just ,uw) will duplicate a single word, just like ,uap will duplicate a paragraph.

Transpose

This is another mapping I created to help me with CSV files. Specifically, this one works with tab-separated files, which are even more awesome to edit in Vim, thanks to the vartabstop option. The next section describes how I use this when editing tab separated files.

This mapping, when applied over lines with tab separated values, will transpose the matrix made of lines and tabs. Check out the GIF below to get a better understanding of how this works.

" Transpose tab separated values in selection or over motion.
nnoremap <silent> gt :set opfunc=Transpose<CR>g@
vnoremap <silent> gt :<C-u>call Transpose(1)<CR>
fun Transpose(...) abort
    let vis = get(a:000, 0, 0)
    let marks = vis ? '<>' : '[]'
    let [_, l1, c1, _] = getpos("'" . marks[0])
    let [_, l2, c2, _] = getpos("'" . marks[1])
    let l:lines = map(getline(l1, l2), 'split(v:val, "\t")')
    py3 <<EOPYTHON
import vim
from itertools import zip_longest
out = list(zip_longest(*vim.eval('l:lines'), fillvalue=''))
EOPYTHON
    let out = map(py3eval('out'), 'join(v:val, "\t")')
    call append(l2, out)
    exe l1 . ',' . l2 . 'delete _'
endfun

Needs +python3.

Demo of transpose mapping

The keys I’m hitting in the GIF is gtip. I’m transposing the lines in the inner paragraph.

Note that I’m using :py3 for this, so, +python3 would be required for this to work. I might port it to Vimscript one of these days, hopefully.

Using vartabstop to Line Up

The moment I learnt about the vartabstop option, I jumped on it right away, considering I worked with tab separated files a lot. I created the following command that would scan the file’s contents and set the value of this option such that all the columns would line up perfectly, almost like a spreadsheet.

The vartabstop option is not available in Neovim, which is one of the reasons I don’t use it yet. I just got too used to vartabstop.

command TabsLineUp call <SID>TabsLineUp()
fun s:TabsLineUp() abort
    py3 <<EOPYTHON
import vim
lengths = []
for parts in (l.split('\t') for l in vim.current.buffer if '\t' in l):
    lengths.append([len(c) for c in parts])
vim.current.buffer.options['vartabstop'] = ','.join(str(max(ls) + 3) for ls in zip(*lengths))
EOPYTHON
endfun

Needs +python3.

Here’s a nice GIF showing this off! Note that although it looks like we’re just adding a lot of white space to align stuff, no new space characters are inserted. The document remains unchanged. It’s just the display size of tab characters is what we’re changing with vartabstop.

Tabs line up demo

Finally, tab separated files are easier to deal with than comma separated files.

Also, if you’re into CSV and tab separated files, I recommend checking out the amazing csv.vim plugin. It makes similar use of the vartabstop option.

Strip Trailing Spaces

I know trailing whitespace doesn’t bother a lot of people much, but it does upset me. Most of the solutions I found online to remove trailing whitespace operate on the whole file. I wanted it to work with the lines over a motion, like inner paragraph etc. Of course, I could just visually select the text object and then do a :s/\s\+$//, but that’s too much effort!

" Strip all trailing spaces in the selection, or over motion.
nnoremap <silent> <Leader>x :set opfunc=StripRight<CR>g@
vnoremap <silent> <Leader>x :<C-u>call StripRight(1)<CR>
fun StripRight(...) abort
    let cp = getcurpos()
    let marks = get(a:000, 0, 0) ? '<>' : '[]'
    let [_, l1, c1, _] = getpos("'" . marks[0])
    let [_, l2, c2, _] = getpos("'" . marks[1])
    exe 'keepjumps ' . l1 . ',' . l2 . 's/\s\+$//e'
    call setpos('.', cp)
endfun

The above snippet defines a mapping, ,x which operates on a motion and removes trailing whitespace. There’s some nice additions to this, in that it works in visual mode as well, and that the cursor doesn’t move as a result of this operation.

Removing trailing whitespace inside current paragraph is now ,xip!

Append character over motion

This mapping lets me add a character at the end of all lines over a motion. So, like, ga;ip would add a semicolon to every line inside the paragraph.

I use this mostly to add commas or tab characters when working with CSV (or tab-separated files).

" Append a letter to all lines in motion.
nnoremap <silent> <expr> ga <SID>AppendToLines('n')
xnoremap <silent> ga :<C-u>call <SID>AppendToLines(visualmode())<CR>

fun s:AppendToLines(mode) abort
    let c = getchar()
    while c == "\<CursorHold>" | let c = getchar() | endwhile
    let g:_append_to_lines = nr2char(c)
    if a:mode ==? 'n'
        exe 'set opfunc=' . s:SID() . 'AppendToLinesOpFunc'
        return 'g@'
    else
        call s:AppendToLinesOpFunc('v')
    endif
endfun

fun s:AppendToLinesOpFunc(type) abort
    let marks = a:type ==? 'v' ? '<>' : '[]'
    for l in range(line("'" . marks[0]), line("'" . marks[1]))
        call setline(l, getline(l) . g:_append_to_lines)
    endfor
    unlet g:_append_to_lines
endfun

This may seem pointless in that, it’s not very hard to do this with visual block mode. Sure. On that note, even A is pretty pointless, it can be done with just $a, right? No. The point here is not about having a shorter key sequence to do this, but a more semantic one. Just like A spells “append at end of line”, to me, ga;ip spells “adding semicolon to every line in the paragraph”. Personally, I think better this way.

Conclusion

Text objects in Vim (and motions, for the most part) have effectively solved the problem of being able expressively select a piece of text to work on. However, in my opinion, the kind of work that can be done on such text is equally (if not more) important. Try to identify what you often do after selecting text with text objects and see if you can turn it into an operator mapping like those in this write-up.

This one is shorter than usual and that’s not because of lack of content, it’s more because of terrible planning on my part. Nevertheless, stay tuned for more in this series!

Read the previous article in this series.

Discuss on: Reddit.