Skip to content

Instantly share code, notes, and snippets.

@unphased
Last active March 13, 2024 17:35
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save unphased/6a3dd7c79d3920b74c7ea3f6c2902c7c to your computer and use it in GitHub Desktop.
Save unphased/6a3dd7c79d3920b74c7ea3f6c2902c7c to your computer and use it in GitHub Desktop.
(Neo-)Vim enhanced navigation

My vim/nvim secret sauce

Here is an overview of some of the core customizations I have built for my own vim/nvim setup over the years. Sometimes I try to explain and share to friends and people online, but it's very difficult to summarize what they are, so I am collecting an overview and code snippets of the current state of my tweaks at the time of writing, here. You can find my full and up to date vim config at my dotfiles repo

Along the way whenever I describe workflows in vim I will highlight the count of keystrokes. In a sort of pragmatic vim golf fashion many of my tweaks are made with an eye toward reducing the number of keystrokes required to achieve a given common task.

Enhanced dot: A search and repeat synergy

Dot . is a built-in vim command that repeats the last atomic edit action you performed in Vim. For example: if you type ciwABCD in normal mode in vim, what this will do is replace the word at your cursor with "ABCD". At this point you can go anywhere else in your buffer and type . to trigger a repeat of that action, replacing words (technically "inner" words, per iw) with "ABCD".

We also have repeat.vim, a Tim Pope vim plugin which is one of the GOATs of vim plugins, which extends . to work nicely with actions implemented in other plugins... Naturally my implementation will ensure compatibility with this.

I think it was a few years into heavy vim usage that I decided to try to streamline something I did very often which is performing a repeat on search matches, and also repeating a change on multiple lines.

Standard Vim Workflow A (Renaming a variable, as an example):

  1. n and N repetitively to hop around search results. If you want to go to the 3rd-next-search-match you can type 3n, etc.
  2. type . to repeat a stored change operation at cursor

Tweaked Augmented Workflow A:

  1. Type <M-.> (this is Alt+. or Cmd+.) to hop to the next search match and trigger . to repeat the stored operation there.
  • Type 3<M-.> to perform a repetition on the next three search hits.
  • Type <M->> (this is Alt+Shift+.) to perform that repetition on every single search hit.
  • Note: I did have to customize my Alacritty config to set these to the appropriate escape sequences \x1b. and \x1b> for neovim to recognize these key combos as those keys.

The behavior of my enhanced dot changes depending on the current state of the hlsearch option. That is to say, whether or not your search results are being highlighted by vim or not.

Task Example B: Editing a bunch of lines

Start with buffer

# ABC 123
# DEFGH 456
# IJ 789

Desired buffer:

  "ABC": "123",
  "DEFGH": "456",
  "IJ": "789",

Note I made this example with variable key length so you can't cheat by using visual block mode so it is representative of real world problems. Also, you should think of this example being, say 50 lines long. I show 3 as an illustrative minimal example.

Standard Vim Workflow for Task Example B:

  1. ysiw": using vim-surround to wrap an inner word with double quotes
  2. j.j. repeat that on the keys on the 2nd and 3rd lines
  3. W.kW.k. repeat that to wrap double quotes on the values
  4. Now we're at the value of the first line. Here it gets slightly aggravating... we need to insert colons... hi: This establishes an edit that inserts a colon, e.g. repeating with . now will repeat i:.
  5. We have to think some more. Want a way to insert the rest of the colons. OK, / " (that's a search for <Space> and double quote) n (it matched the front of the next key...) . (yay) nnn. we have to type n 3 times to do each repetition: It will match the value we just put the colon in front of, then it matches before the next key, and then the last time to put the cursor at the next position in front of the next value.
  6. Still got two more edits to make on each line. Tackling the comment chars first: gg (hop to top of file) I (insert mode, beginning of line) <Del> erases the # char and puts the space we need to replace it with. Repeat will repeat I<Del> , so j. is the repetition unit we can use the rest of the way. work your way down with that.
  7. Finally we append the required comma at the end of the line. This is very similar to step 6, we would just use A instead of I: ggA,<Esc> and j. is the repetition unit here.

Tweaked Augmented Workflow for Task Example B:

As I already mentioned my behavior behaves differently when hlsearch is unset (when you cannot see the matches for your active search register). Here, after performing the . repeat it will just proceed to the next line with j.

  1. I<Del><Del> "<Esc> Stores the edit to replace # at front of line with ".
  2. j to go to the next line
  3. With hlsearch switched off, type and hold <M-.> to repeat the action and go to the next line for each time the keystroke repeats and reaches the bottom of the file. Note that all Alt+. is doing here is a shortcut for j., but it's important that you can hold down the key and it will repeat, whereas no such thing is possible if you have to type j. for each.

File at this point looks like

  "ABC 123
  "DEFGH 456
  "IJ 789
  1. set search to digits to grab values: /\d\+, or /\v\d+
  2. i<BS>": "<Esc> Performs the edit to change 123 from this position into ": "123
  3. I think the way vim works (this may be a recent change) is that hlsearch becomes automatically enabled if you perform a search. Now we're in the workflow A regime from above and repeat the above change with <M-.> holding down the key if necessary to repeat till everything is done.

File at this point looks like

  "ABC": "123
  "DEFGH": "456
  "IJ": "789
  1. You know the drill now, we just round it out now by appending ", to each line. This can be done with A",<Esc> and then mashing <M-.>.

In retrospect I overengineered the example problem (and taking different approaches in the two workflow examples). This makes it less obvious how much streamlining my enhancement introduces.

Note: Many would suggest taking a macro approach for this. I do have a macro workflow enhancement coming up below. But the spoiler on that is I don't think macros are the way to go, at least not for the way my brain works. It takes too damn long to craft and debug a working macro and then you deploy it and there's no real way to reuse all that effort you spent on it. Even though we had to perform the edits on this example B in 3 passes, they are easy, each individual edit executes way faster by the vim engine than a macro invocation executes. Note 3 passes is the minimum possible without macros because the edits we make don't touch the content we want to preserve (the keys and values), so each line has 3 regions before, between, and after these 2 segments which we need to manipulate.

TODO: I have an issue with the hlsearch disabled branch of functionality here in that it does not properly take a number. In typical usage though I just hold down the key and have it repeat. That's been good for me.

Implementation:

-- combination next and repeat
function enhanced_dot(count)
  local poscursor = vim.fn.getpos('.')
  local hlsearchCurrent = vim.v.hlsearch
  local c = count
  local searchInfo = vim.fn.searchcount({maxcount = 9999})
  local ct = searchInfo.total

  vim.fn.setpos('.', poscursor)
  if (c == '' and hlsearchCurrent == 1) then
    c = ct
    vim.cmd('echom "running dot '..c..' times (all matches)..."')
  elseif c == '' then
    vim.cmd('echom "put search on to run dot on all matches. aborting"')
    return
  end
  if ct < c then
    vim.cmd('echom "found fewer matches than requested ('..c..'), running only '..ct..' times"')
    c = ct
  end
  while c > 0 do
    if hlsearchCurrent == 1 then
      vim.cmd("silent! normal n")
      vim.cmd("normal .l")
    else
      vim.cmd("normal .j")
    end
    c = c - 1
  end
end

vim.api.nvim_set_keymap('n', '<M-.>', ":lua enhanced_dot(vim.v.count1)<CR>", { noremap = true })
vim.api.nvim_set_keymap('n', '<M->>', ":lua enhanced_dot('')<CR>", { noremap = true })

Navigate around search matches

It cannot be understated how common it is that we want to see all the places referencing a variable. although it's true that when we have an environment with a LSP or otherwise providing us with go-to-definition and variable reference lookup we will use that instead, but it's still very good to have a streamlined way to quickly:

  • set the search register to what is currently under the cursor
  • toggle hlsearch. This is useful some of the functionality above, but also consider it can be visually distracting to show search matches all the time.

Sometimes you will edit files that don't have a powerful LSP available for them. Sometimes you want to find instances of a variable name including in your comments. I will say this functionality got me through years and years of furious code editing without using any fancy semantic lookups. Now that I do have semantic lookups you'd think this doesn't get used as often but it totally does because it is still much quicker to trigger on account of my choice of map.

Implementation:

I went through a lot of iterations of this one. The latest is leveraging Ingo Karkat's SearchHighlightingStar functionality. First I was using *. Then I wanted it to behave slightly differently. I wanted to have a bind that sets the word under cursor to the search register, but did not also hop to the next instance. After years of maintaining my own implementation based off of some vim wikia page or other that I can no longer retrieve, I found that the behavior I wanted is exactly supported by this particular (rather obscure) one of Ingo's libraries.

The code is for a Lazy plugin definition.

  { "inkarkat/vim-SearchHighlighting", init = function ()
    -- This is set to be disabled in favor of STS *maybe*
    vim.keymap.set('n', '<CR>', '<Plug>SearchHighlightingStar', { silent = true } )
    vim.cmd([[
      nmap <silent> <CR> <Plug>SearchHighlightingStar
      "" doesn't work
      " vmap <silent> <CR> <Plug>SearchHighlightingStar
    ]])
  end },

I have a similar thing for doing something similar from visual mode. This works well mentally since hitting <CR> from visual mode will take whatever is in the visual selection to set it as the search register, a sensible analogue to having <CR> set the search register with the word under the cursor in normal mode. This can be helpful for finding any phrases that might be repeated, but I generally use this as a quick way to confirm there are no unintended changes between regions of code that need to be duplicated. What is nice is that it will ignore whitespace differences, so it works well in that use case.

Note the following is vimscript, not lua.

  " Search for selected text.
  " http://vim.wikia.com/wiki/VimTip171
  let s:save_cpo = &cpo | set cpo&vim
  if !exists('g:VeryLiteral')
    let g:VeryLiteral = 0
  endif
  function! s:VSetSearch(cmd)
    let old_reg = getreg('"')
    let old_regtype = getregtype('"')
    normal! gvy
    if @@ =~? '^[0-9a-z,_]*$' || @@ =~? '^[0-9a-z ,_]*$' && g:VeryLiteral
      let @/ = @@
    else
      let pat = escape(@@, a:cmd.'\')
      if g:VeryLiteral
        let pat = substitute(pat, '\n', '\\n', 'g')
      else
        let pat = substitute(pat, '^\_s\+', '\\s\\+', '')
        let pat = substitute(pat, '\_s\+$', '\\s\\*', '')
        let pat = substitute(pat, '\_s\+', '\\_s\\+', 'g')
      endif
      let @/ = '\V'.pat
    endif
    normal! gV
    call setreg('"', old_reg, old_regtype)
  endfunction
  vnoremap <silent> <CR> :<C-U>call <SID>VSetSearch('/')<CR>/<C-R>/<CR>

Assorted surround.vim stuff!

I've got a lot of other legacy vimscript streamliner maps that are just too good. I don't know if I suggest people to blindly use these. You have to really weigh the reduction in keystrokes here with the consequences of taking your setup farther away from the defaults, and all of the little side effects that will cause (potentially more edits you have to make to plugin configs, more explaining or less possibility of a colleague being able to drive an instance of your vim, etc). OK, so that might apply to a lot of my vim tweaks, but honestly these following surround.vim streamlining ones I think are clear improvements on the original behavior.

  nmap ` %
  vmap ` %
  omap ` %

, because I don't like reaching for shift and 5 but i like to hop back and forth on matching elements. I use "andymass/vim-matchup".

  vmap ' S'
  vmap " S"
  vmap { S{
  vmap } S}
  vmap ( S(
  vmap ) S)
  vmap [ S[
  vmap ] S]
  " This is way too cool (auto lets you enter tags)
  vmap < S<
  vmap > S>

  " Set up c-style surround using * key. Useful in C/C++/JS
  let g:surround_{char2nr("*")} = "/* \r */"
  vmap * S*

  " shortcut for visual mode space wrapping makes it more reasonable than using 
  " default vim surround space maps which require hitting space twice or 
  " modifying wrap actions by prepending space to their triggers -- i am guiding 
  " the default workflow toward being more visualmode centric which involves less 
  " cognitive frontloading.
  vmap <Space> S<Space><Space>

, these are all vim-surround mapping sugars. For most of these, visual mode doesn't have a useful action on those keys, so I figured I would make maps to eliminate me having to type S to indicate a surround. It's quite natural to select some code and just hit a quote or bracket key straight away and get it to surround. I even did it for space, now my memory is fuzzy but I believe the way surround.vim works is if you are in visual-line mode surround with will surround the lines you selected with empty lines, rather than spaces (which would make no sense), otherwise it surrounds with spaces. All I did was replace having to type S<Space><Space> with... just <Space>. It works, because <Space> is not mapped to anything useful in visual mode!!!

  " restore the functions for shifting selections
  vnoremap << <
  vnoremap >> >

As you can see this is the one thing that already had a useful behavior in visual mode and i overrode it to do surround shit with tags and angle brackets. So I bring it back here with the normal mode mapping which is pretty intuitive, so now I can still in/de-dent via visual mode.

Streamlined macro replay

So back before I switched from a .vimrc with vim-plug to a nvim/init.lua with lazy.nvim for configuring (n)vim, I had made a pretty serious attempt to streamline usage of macros. But by the time I switched to lua it was already clear that macros are just not practical enough, at least for me. Too fiddly to be worthwhile at least 90% of the time. So I'll detail the tooling I built. But before I go in depth I will just say, it's very reminiscent of the above enhanced dot functionality in which I marry the search hopping with macro triggering (instead of :normal '.' triggering). So if you want to explore that leave a comment and I'll gladly go into detail there. But I personally don't use it and never even bothered to port it over to lua.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment