I pondered upon this question for quite a bit. Using macros would be the most straightforward way to set this up quickly, but you explicitly called out you find this “not so heavy as to make it a macro.”
You mentioned something that could be repeated by the . command, so I thought of a mapping using operator-pending mode (similar to the d command, which takes a motion, such as de to delete to the end of the word, or a text object, such as diw to delete a whole word.)
A simple approach
It is possible to set up a fairly simple mapping using the g@ operator to accomplish this goal. The simplest case would be:
function! ReplaceUnderscores(_)
normal! `[v`]r_`]W
endfunction
nnoremap <silent> <leader>u :set operatorfunc=ReplaceUnderscores<CR>g@
The way you use it is by calling \ue to replace to the end of the word with underscores, or \uE to replace til the end of the WORD, or \uiW to replace the whole WORD under the cursor. At the end of the command, the cursor will be placed at the start of the next WORD, so you can repeat the same command using ..
For your specific example:
I make it a rule never to mix business with pleasure
If the cursor is at the beginning of the line, you could then use w to move to "make", then \uiW to turn that word into underscores, then ... to repeat that for the next three words, w to skip the "never" and finally . to replace the "to". End result:
I ____ __ _ ____ never __ mix business with pleasure
To understand what the opfunc is doing, what you need to know is it will set marks `[ and `] to the region that corresponds to the passed motion or text object. So `[v`] will turn that region into a Visual selection, then r_ will replace it with underscores, finally `] will move the cursor back to the end of the region and W will skip to the beginning of the next WORD (so that it's pretty convenient to repeat the command using repeated .s).
Improving on it
The simple approach is a clear improvement over using a macro, but since it takes quite a bit of work to set it up, it's only worth it if it's made permanent (i.e. adding it to your Vim configuration, such as your vimrc file.)
But if we're going to create a permanent mapping for it, can we make it better? I think we can! Thinking about your usage, I think one clear improvement would be to be able to act on multiple words at once, so that if you use \u4E on top of "make" then it will replace the four words "make it a rule" with underscores for you, in a single operation.
A naive implementation of this approach would be:
function! ReplaceUnderscores(_)
execute "normal! `[v`]\<Esc>"
'<,'>s/\%V\k/_/g
normal! `>W
endfunction
This already supports the aforementioned \u4E above. You can also use something like \u$ to replace all words with underscores all the way to the end of the line.
Let's break down how it works. Here we're also making a Visual selection, but we're following it with <Esc> to go back to Normal mode right away. That's what the execute "normal! `[v`]<Esc>" is doing. We're using :execute and a double quoted string so we can insert an encoded \<Esc> keystroke there.
Then we're using a :substitute command, using a range of '<,'>, which covers the line in the Visual selection we've just created, and using the special \%V pattern to only match characters that were inside the last Visual selection. (The range restricts to the lines of the last Visual selection and the \%V ensures we ignore any characters in the first/last line that were outside the Visual selection.) Then we use \k to match keyword characters, which should be letters or numbers (excludes spaces and punctuation), so that we'll only replace those. Using [\k] is also flexible in that it uses the 'iskeyword' option for the list of supported characters, so you can customize that list if you'd like to include/exclude to the list of those you want to replace with underscore. Finally we replace with _ and we use g to do this to all matches in each line.
Finally, we use the same `>W to place the cursor at the beginning of the next WORD once the command has finished.
One way you can act on your sample sentence is w\u4ww\uw. While you're not using the . operator here, you're using a count, which can be more efficient if you're replacing many words. Using sentences for motions and paragraphs would also yield a big win here.
One further improvement of this approach is to add support for the \u command in Visual mode, so that you can prepare a Visual selection and then press \u to replace the words inside it with underscores. You can simply extend this approach to do that with:
xnoremap <silent> <leader>u :<C-U>set operatorfunc=ReplaceUnderscores<CR>gvg@
This calls g@ from Visual mode, which works the same way (setting the `[ and `] marks), it needs an extra <C-U> after the : because going to Command-line from Visual mode automatically adds the '<,'> range, and also a gv to restore back the Visual mode selection before invoking the actual g@.
Making it robust
Unfortunately, here is where things gets complicated in Vimscript. It's fairly quick to work out a proof-of-concept like the above, but polishing it and handling corner cases takes the one-liner that turned into a three-liner to now go to ~35 lines of code...
The extra code is to:
- Ensure we're handling linewise selections, such as
\u2j to act on 3 lines of text (same as d3j would delete 3 lines of text, whole lines, not just from the cursor down.) Since we're there, let's do blockwise too.
- Save and restore Visual selection. Since we're using it to implement our feature, we should save and restore it, so that if you use the
gv command after invoking \u, you'll get back the Visual selection you had before running the command (the one that was the last from your point of view), and not the one used in the \u implementation.
- Ensure we're not depending on behavior of specific settings, in this case, the
'selection' setting, which defaults to inclusive (which is what we want), but would potentially break our behavior if the user changed it to something else... So let's set it explicitly to inclusive for the execution of our command, and later restore it to whatever it was set to.
- Finally, some neatness around setting up
'operatorfunc' and using an <expr> mapping to issue the g@ itself.
The final resulting code is:
function! ReplaceUnderscores(type)
if a:type ==# ''
set operatorfunc=ReplaceUnderscores
return 'g@'
endif
let save_sel = &selection
let save_vis_mode = visualmode()
let save_vis_start = getpos("'<")
let save_vis_end = getpos("'>")
try
set selection=inclusive
if a:type ==# 'char'
execute "normal! [v]<Esc>"
elseif a:type ==# 'line'
execute "normal! '[V']<Esc>"
elseif a:type ==# 'block'
execute "normal! [\<C-V>]<Esc>"
endif
'<,'>s/\%V\k/_/g
if a:type ==# 'line'
normal! '>+
else
normal! `>W
endif
finally
execute 'normal! '.save_vis_mode."<Esc>"
call setpos("'<", save_vis_start)
call setpos("'>", save_vis_end)
let &selection = save_sel
endtry
endfunction
nnoremap <expr> <leader>u ReplaceUnderscores('')
xnoremap <expr> <leader>u ReplaceUnderscores('')
nnoremap <expr> <leader>uu ReplaceUnderscores('').'_'
There's obviously quite a bit here, but a lot of it is pretty similar to what is done in the example in :help :map-operator, so for the most part I'll leave it as an exercise to the reader to understand exactly what each bit is doing...
Support for linewise makes it possible to use \uip and act on a paragraph (block of contiguous non-empty lines) and have it replace every word with underscores.
This also sets up a \uu mapping that you can use to replace the words in the current line with underscores, which even works with a count, so 4\uu would work too!
Hopefully this is useful enough for you that you'll consider adopting it? Either include it in your vimrc (or init.vim for NeoVim), or you can add it to a file with a *.vim extension inside ~/.vim/plugin/ (or ~/.config/nvim/plugin/ for NeoVim), in which case it will be loaded automatically during startup.
@='viwr_w'(followed by "Enter"), which allows for repeating with@@starting from the first repetition (so slightly better than with the:register...) – filbranden Dec 23 '21 at 02:48