Throughout my career, I’ve at least tried most of the available programming editors. More than two decades ago, I heard about the vi-vs-emacs debate, and made a pact with myself to use both for at least a year before deciding which I preferred.

I started with vim, switched to emacs after a year, and decided I preferred vim. I joined the sublime-text bandwagon for a year or two in the early 2010s, switched back to vim in the middle of the decade, and eventually did the big switch to vscode.

Sadly, typing on vscode on a laptop keyboard crippled me and after voice coding for a while, and at the recommendation of my friend Lukasz Langa I decided to give the vim keybindings for VSCode a whirl. I tried all the vim plugins. The experience was amazing and horrible, and the key learning I took from it was that I would rather use a real vim and give up all the niceties that vscode had to offer.

Turns out, neovim has all the niceties that vscode has to offer, and I don’t miss vscode at all (I especially don’t miss the horrible startup time!). Said niceties aren’t set up out of the box in neovim but that wasn’t a huge turn-off for me since I’m the kind of developer who tweaks everything. My VSCode configuration file was actually longer than my vim configuration file.

I used to eagerly read the VSCode release notes to find out a) what my friend on the python-for-vscode team has built that month and b) what cool new configurations I can tweak. Nowadays, I follow the awesome-neovim repo for the same sense of satisfaction. What cool new plugins have people built?

Enter Lua

When I switched to neovim, I set up my config using tools that I was familiar with from my previous vim days. My config was written in vimscript rather than lua, and I installed all Tim Pope’s venerable plugins. But over the past two years I’ve found I have introduced more and more lua plugins and my config file has almost as much lua as vimscript code.

I decided that it’s time to rebuild my configuration from scratch on an init.lua, revisit all my existing plugins to see if there are better alternatives and spend a day or two pleasantly configuring my editor. (I know that doesn’t sound pleasant to most folks, and that’s ok. I like being me and if being me is weird, then so be it.)

So I’m currently writing this on a bare-bones vim configuration that hasn’t got a single line of configuration in it. Neovim seems to have figured out that I’m using a light theme for my terminal, Kitty which I highly recommend if you’re a neovim user, as the GPU rendering makes for a very smooth neovim experience. It’s lightning fast.

It’s been a bit interesting to get this far into this article because my keybindings aren’t set up yet, but hey, how many keybindings do you need to write Markdown, anyway?

I don’t know if having me document this configuration will be useful for anyone who isn’t me, but it only costs me a little time to write it down and if you’re thinking of joining the vim community or rewriting your own init.vim to init.lua, maybe it’ll be helpful.

I chose to start from scratch rather than using any existing starter templates because those starter templates are generally pretty opinionated, and… well, so am I. I already have a good idea of what plugins I want, and I find it’s much better to turn configuration options on than having to turn the ones I don’t like OFF (This is one of the reasons my old VSCode configuration was so damn long. Turning off useless things such as line numbers was a good chunk of the config).

Start with a plugin manager (Lazy.nvim)

Neovim ships with its own plugin manager, but it’s more or less just “clone the repo to a directory and it’ll get plugged in”. I don’t feel like managing a bunch of git repositories. I’ve been using vim-plug for many years. It has worked fine for my basic needs and I don’t have any complaints. However, I figured it’s time to switch to something lua-specific. I had a look at packer.nvim, which has been around for a long time, but decided that lazy.nvim would give me the more modern experience I am looking for.

I followed the instructions to bootstrap lazy.nvim. For now I just put the snippet in the main init.lua file. I notice most people split their lua config into different files and require (lua’s import) those files from init.lua. If the file gets too complicated I might go there, but I doubt it’ll be necessary.

The install guide for lazy.nvim indicates that I should set the mapleader before initializing the plugin. Since I use space as my mapleader, I added vim.g.mapleader = " " before require("lazy").setup({}) as the instructions suggested.

I restarted neovim and ran :checkhealth. The block of output for lazy.nvim shows that it thinks it is installed successfully.

Keybindings are driving me wild

As I type this article, I’m finding that I miss two of my most often-used keybindings. I have ctrl-S/cmd-S mapped to :w in both insert and normal mode, not because I think emacs style saving is better but because every other app I use has trained me to think compulsively hitting ctrl-S will save my work.

I’m also missing <leader>p which I have remapped to "*p to paste from the MacOS system clipboard. And I know I’m going to want <leader>vp pretty damn quick because that’s the one I use to reload my configuration (edit: turns out that’s not yet possible with lazy.nvim,

I could add these using the neovim lua commands for setting keyboard shortcuts, but I’ve been wanting to try out which-key.nvim. So I’m going to set that up first and use the process to test whether lazy.nvim is actually doing its thing.

I added folke/which-key.nvim to the lazy.nvim table and restarted vim. A fancy window popped up indicating that lazy.nvim was loaded and that which-key.nvim was installed. However, it isn’t configured. To solve that, I changed my config to match the lazy.nvim instructions on the which-key repository.

I ran :checkhealth which-key and everything looks good. I used the ' (apostrophe) in normal mode and confirmed that it popped up a window with my marks (including V which I have already set to take me to my vimrc file). and " pops up my list of registers. The window also pops up when I type da in normal mode (delete a text object) to show what text objects are available. I’m undecided if I like that; I already know most of the built-in text objects and use them all the time.

Now I can register the aforementioned keybindings with which-key:

            ["<leader>y"] = { '"*y', "Copy to System Clipboard" },
            ["<leader>p"] = { '"*p', "Paste from System Clipboard" },
            ["<leader>P"] = { '"*p', "Paste Before from System Clipboard" },
            ["<C-S>"] = { '<cmd>w<cr>', "Save" },
        }, {mode='n'})
            ["<C-S>"] = { '<cmd>w<cr>', "Save" },
        }, {mode='i'})
            ["<leader>y"] = { '"*y', "Copy to System Clipboard" },
        }, {mode='v'})

I restarted neovim (this is getting old; I hope that reload issue gets merged soon) and pressed the space key to see my new keybindings pop up.

I’m not going to share all my keybindings, but another one I like is a mapping of \ to C-w, which allows me to navigate windows with the \ key followed by the various things you can issue after C-w. However, when I made this remapping by default, whichkey didn’t pop up with all the handy operations you can issue on windows. The solution is to add noremap=false to the shortcut when you register it, like so:

        ["\\"] = {'<C-w>', "Window commands", noremap=false}

Note also the double slash; the first is to escape the second in lua.


The which-key window is barely readable. I fixed that by adding vim.o.background = "light" to my config. I use a light theme for my terminal because it works better with the glare from my window (out which I have an astonishingly gorgeous view). Now which-key is readable, but incredibly ugly.

adding vim.o.termguicolors = true is necessary but actually makes it uglier. What we really need is a complete colour scheme.

My preferred colour scheme lately is PaperColor. The light theme is reminiscent of Solarized-light but a little more modern feeling. (I have deep psychological attachments to various colour schemes I’ve used through the years).

I’m pretty sure PaperColor accidentally inverted the bg and fg settings on their light mode. The fix for this in vim-script is simple enough:

let g:PaperColor_Theme_Options = {
\  'theme': {
\    'default.light': {
\       'override': {
\         'vertsplit_fg': ['#eeeeee', '232'],
\         'vertsplit_bg': ['#005faf', '232']
\       }
\    }
\  }

However, porting this beast of a nested dictionary to a Lua table took more time than you would think. I even commented on one of those lonely issues that didn’t have a solution posted after I figured it out.

This is what I ended up putting in my lazy.nvim setup table to fully configure PaperColor:

        lazy = false,
        priority = 1000,
        config = function()
            vim.g.PaperColor_Theme_Options = {
                theme = {
                    ['default.light'] = {
                        override= {
                            vertsplit_fg= {'#eeeeee', '232'},
                            vertsplit_bg= {'#005faf', '232'}
            vim.cmd([[colorscheme PaperColor]])

Highlight Yank

I have been using Yanky as a sort of emacs style kill ring, but I’m going to experiment with using the which-keys registers window. One thing Yanky provided was highlighting text. Neovim ships with code to highlight the text you just yanked or put, but it’s so damn verbose to configure that it’s almost worth using a plugin for it!

vim.api.nvim_create_autocmd('TextYankPost', {
  group = vim.api.nvim_create_augroup('highlight_yank', {}),
  desc = 'Hightlight selection on yank',
  pattern = '*',
  callback = function()
    vim.highlight.on_yank { higroup = 'IncSearch', timeout = 500 }

Basic settings

Neovim, because it tries to maintain compatibility with vim, which tries to maintain compatibility with vi, tends to default to behaving like an editor from the 1970s. I’m sure there have been lots of flamewars over whether or not certain configurations should be set by default, but for now, there are a host of settings that most folks like to set. I ported mine over from the viml. I won’t describe them all, but you can use :help to look up any that look interesting.

vim.o.background = "light"
vim.o.termguicolors = true
vim.o.mouse = "a"
vim.o.cursorline = true
vim.o.nohlsearch = true
vim.o.inccommand = "split"
vim.o.updatetime = 50
vim.o.undofile = true
vim.o.splitright = true
vim.o.nobackup = true
vim.o.nowritebackup = true
vim.o.shortmess = "acIF"
vim.o.cmdheight = 2
vim.o.noshowcmd = true
vim.o.noshowmode = true
vim.o.signcolumn = 'yes'
vim.o.completeopt = "menuone,noselect"
vim.o.foldlevel = 20
vim.o.foldmethod = 'expr'
vim.o.foldexpr = 'nvim_treesitter#foldexpr()'
vim.o.wildmenu = true
vim.o.wildmode = "longest:list,full"
vim.o.expandtab = true
vim.o.sw = 2


Telescope as a file (and more) picker has really taken the Neovim community by storm. I’ve been using it for a while as a replacement for the older fzf plugin (I still use fzf with zsh and highly recommend it).

I am happy with the way I have Telescope configured, so this will just be an exercise in porting the configuration and keybindings over from my old config. My old config is already in lua except the keybindings.

Telescope depends on plenary, so the first step is to add that to the plugins array. I also use the Telescope symbols command a lot because emojii are the only way to write a commit message these days, so I add a dependency on that.

My Telescope config is pretty basic, as the defaults are exceedingly sane. I added a keybinding for closing a buffer from the buffers picker, but that’s it:

        config = function()
            require('telescope').setup {
              pickers = {
                buffers = {
                  mappings = {
                    n = {
                      ["dd"] = "delete_buffer",

I thought I’d be using which-key’s group names here, but it appears those are only used when grouping under a keybinding (makes sense), and most of my Telescope keybindings are accessible either under leader or at the top level because they are that important. So I just added a bunch of them to the whichkey config at the top level:

    ["l"] = {"<cmd>Telescope git_files show_untracked=true <cr>", "Show Git Files"},
    ["<C-l>"] = {
        "<cmd>lua require('telescope.builtin').git_files({git_command={'git', 'ls-files', '--modified', '--exclude-standard'}, show_untracked=true})<cr>",
        "Show Modified Files"},
    ["L"] = {"<cmd>Telescope find_files no_ignore=true <cr>", "Show All Files"},
    ["<leader>f"] = {"<cmd>Telescope live_grep<cr>", "Find in Files"},
    ["<leader>b"] = {"<cmd>Telescope git_branches show_remote_tracking_branches=false<cr>", "Git Branches"},
    ["<cr><cr>"] = {"<cmd>Telescope buffers<cr>", "Open Buffers"},

I use l for my file finder because I make the cardinal vim sin of using arrow keys for navigation. Don’t judge me: I have compelling reasons:

  • I’ve used the dvorak keyboard layout for over 20 years, so hjkl don’t map to home row on my keyboard. I once experimented with remapping those keys (d became k for kill IIRC) but I didn’t like how complicated it made my vim configuration.
  • I use a non-standard keyboard (a kinesis advantage 360 that I recently upgraded from a kinesis advantage that is old enough to vote) that makes the arrow keys more accessible than they are on normal keyboards. Further, in Dvorak, they place the j and k keys directly above the left and right keys on my left hand. So I use arrow keys and j and k for navigation in vim.


I’m finding the lua config to be quite verbose in comparison to vimscript, and I’m not sure I like it. I fear that it will be hard to maintain because I can’t navigate it. So dividing it up into separate files is starting to sound like a good idea. On the plus side, having my keybindings embedded in the which-key config helps to isolate and locate them very nicely.

A hint if you need to make identical same changes on multiple lines of code: The norm command is amazingly useful for this (:help norm). I used to use macros a lot, but learning about :norm really supercharged my coding efficiency.

Surround? Sandwich?

Because I’ve had to change a few parens to curly braces while editing my config, I want to add a surround plugin… but I’m not sure which one to use. I used tpope’s vim-surround for years and years, but switched to vim-sandwhich when I most recently switched back to vim (a few years ago, now…).

Neither of these is neovim-native, which isn’t strictly a problem, but there are probably more performant lua versions available now.

Indeed, awesome-neovim lists two of them. I’m going to go with mini.surround because the default keybindings match the ones I am used to (the vim-sandwich style).

I’ve been avoiding using any mini plugins because it feels like the kind of ecosystem that can trap you, but this one looks pretty stand-alone so it shouldn’t be a problem. Besides, maybe it’s the kind of ecosystem that it’s good to become trapped in! The lazy.nvim configuration is as simple as:

    {'echasnovski/mini.surround', version = false, config = true}

The config=true bit is a shortcut I discovered that as far as I can tell resolves to the same thing as config = function require('name-of-plugin').setup().


I’ve been using lualine as my status line for a while and don’t have any reason to switch to something else, so I’ll just port that configuration over directly:

    { "nvim-tree/nvim-web-devicons", lazy = true },
        "nvim-lualine/lualine.nvim", config = function()
            require('lualine').setup {
              options = {theme = 'papercolor_light'},
                sections = {
                    lualine_a = {{'mode', fmt = function(str) return str:sub(1,1) end }},
                    lualine_b = {},
                    lualine_c = {{'filename', path=1, symbols = { modified='⭑'}}},
                    lualine_x = {"filetype"},
                    lualine_y = {'diagnostics'},

Status lines are probably the most personal thing people have. I like to keep mine pretty simple. I don’t maintain any git information, for example, because my zsh prompt includes all of that already. I like status lines to be specific to the currently focused buffer and don’t generally contain filesystem-wide or editor-wide settings. I do like to have a mode indicator in there, but as you can see from the fmt function, I keep it limited to one letter so it doesn’t take up too much room.

Leap, the new Lightspeed, formerly the new EasyMotion/Sneak

Back in the day, I hated EasyMotion and only occasionally used vim-sneak, but when I was recovering from RSI, I found that easy-motion-like navigation was healthier, so I retrained myself. Most recently I’ve been using lightspeed.nvim, but I see the author has a note that Leap is the new hotness. I also worked briefly on a software product called Lightspeed and it wasn’t one of my better jobs, so I’m happy to not have that reminder every time I edit my config!

Leap has a couple of extension plugins, flit and spooky. The equivalent of flit was included in Lightspeed natively and I’ve come to rely on it, so I definitely want that. I’m not so sure about spooky, but it sounds interesting, so I’m going to give it a try.

Leap depends on vim-repeat, but neither requires any configuration:

    {"ggandor/flit.nvim", config = true},
    {"ggandor/leap-spooky.nvim", config = true},

flit and spooky set up their own keybindings, but Leap doesn’t, and I don’t use the defaults they let you use anyway. I do need to setup keybindings to Leap. I use the h key for this rather than the default s that would come with Leap’s default mappings. I was thinking of switching to s, but since I want the commands documented in which-key anyway, I decided to stick with my previous setup:

        ["h"] = {"<Plug>(leap-forward-to)", "Leap Forward"},
        ["H"] = {"<Plug>(leap-backward-to)", "Leap Backward"},
    }, {mode={'n', 'x', 'o'}})

I just tried out the spooky functionality, and I love it. I wonder how to remember to incorporate it into my flow…

Smart Splits

I don’t use neovim’s built-in terminal feature, preferring to use Kitty natively. One drawback of this is that navigating between windows in Kitty is a different keybinding from navigating between windows in vim. I’ve previously solved this with vim-kitty-navigator, but this time I’m going to use smart-splits because I suspect the issue with it working in SSH will be resolved sooner than in the former and it supports resizing as well as navigating windows.

    { 'mrjones2014/smart-splits.nvim', build = './kitty/install-kittens.bash' }


    local ss = require('smart-splits')
        ['<Space><Left>'] = {ss.resize_left, 'Resize Left'},
        ['<Space><Right>'] = {ss.resize_right, 'Resize Right'},
        ['<Space>j'] = {ss.resize_down, 'Resize Down'},
        ['<Space>k'] = {ss.resize_up, 'Resize Up'},
        ['\\<Left>'] = {ss.swap_buf_left, 'Swap With Left Buffer'},
        ['\\<Right>'] = {ss.swap_buf_right, 'Swap With Right Buffer'},
        ['\\j'] = {ss.swap_buf_down, 'Swap With Below Buffer'},
        ['\\k'] = {ss.swap_buf_up, 'Swap With Above Buffer'},

Oil instead of Tree

I’ve never got on well with the likes of NERD tree. I used CHADTree for a while and got used to it, but the thing that really clicked with me was the “edit your directory like it’s a buffer” paradigm. I’ve been using oil.nvim and I don’t see any reason to switch. Porting my config over is trivial as it’s already in lua:

    { 'stevearc/oil.nvim', config = function()
          keymaps = {
              ["<C-v>"] = "actions.select_vsplit",
              ["<C-n>"] = "actions.select_split",
              ["<C-h>"] = false,
              ["<C-s>"] = false,


    ["-"] = {require("oil").open, "List/Edit Directory"},

Language Server Provider… Ugh

LSPs and the fact that they work out of the box are the reason that Vscode is amazing. The neovim experience isn’t quite so nice, but it works. There are so many plugins for LSPs and I’m not sure which to use. They work well once you get them working well, but I’m nervous about this section because getting them working well is never trivial.

I think I’m going to stick with mason.nvim for the installer, which has been working well for me and has a UI similar to lazy.nvim. I also know I’ll stick with nullls for formatting because it’s historically been the least painful for me.

I kind of wanted to give the lsp-saga ui a try, but I want to have this config done by morning so I think I’ll stick with a variation of my existing configuration, which is mostly telescope based.

Setting up mason.nvim with lazy.nvim requires a bit of dependency management, and doesn’t seem to be well documented anywhere. The closest I could find was the mason.nvim maintainer saying he’d never used lazy.nvim and a random dotfiles repository that hasn’t been updated in years. So this section is probably going to be the most popular part of this article. 😉

        build = ":MasonUpdate",
        config = true
        dependencies = {"williamboman/mason.nvim"},
        config = function()
            require("mason-lspconfig").setup {
                automatic_installation = true
        dependencies = {"williamboman/mason.nvim", "williamboman/mason-lspconfig.nvim"},
        config = function()
            vim.cmd([[autocmd CursorHold   * :silent! lua vim.lsp.buf.document_highlight()]])
            vim.cmd([[autocmd CursorHoldI  * :silent! lua vim.lsp.buf.document_highlight()]])
            vim.cmd([[autocmd CursorMoved  * lua vim.lsp.buf.clear_references()]])
            vim.cmd([[autocmd CursorMovedI * lua vim.lsp.buf.clear_references()]])
        dependencies = {"williamboman/mason.nvim"},
        config = function()
                automatic_installation = true
        config = function()
            local null_ls = require("null-ls")
            null_ls.setup {
                sources = {
            vim.cmd [[autocmd BufWritePre * lua vim.lsp.buf.format({filter = function(client) return == "null-ls" end})]]


This is configured for my day job working in Python and Typescript because I want to be able to work in this configuration tomorrow. I’ll expand it to other languages I use when I get some free time.

The key to everything working is the dependencies. They have to be in the right order, as described above.

One thing I like about this over my previous configuration is the automatic_installation configs on both mason-lspconfig and null-ls. If I want to add new language providers, I only have to update the sources in null-ls and the config for nvim-lspconfig.

The last step for LSP is setting up keybindings. I mostly just bind directly to LSP built-in functionality, though I use telescope for listing symbols.

        ["<leader>o"] = {
            "<cmd>Telescope lsp_document_symbols ignore_symbols=variable<cr>",
            "Document Symbols"
        ["gr"] = {"<cmd>Telescope lsp_references<cr>", "List References"},

        ["<leader>."] = {vim.lsp.buf.code_action, "Fix"},
        ["K"] = {vim.lsp.buf.hover, "Hover Docs"},
        ["<C-K>"] = {vim.lsp.buf.signature_help, "Signature Help"},
        ["[d"] = {vim.diagnostic.goto_prev, "Previous Diagnostic"},
        ["]d"] = {vim.diagnostic.goto_next, "Next Diagnostic"},
        ["<leader>r"] = {vim.lsp.buf.rename, "Rename Symbol"},

You may notice that I don’t have “gd” for go to definition. This is because I prefer to use C-], which has been the vim standard for going to a declaration or source since before I was born, and I’m not about to argue with tradition. (If you didn’t know about this shortcut, it also makes it easy to quickly navigate “links” in vim help files).

Source Control

I’ve been using a combination of Graphite and lazygit for managing my commits and PRs lately, both of which happen on the terminal outside of my editor (Just because something CAN go in your editor doesn’t mean it SHOULD).

In-editor, I do use gitsigns.nvim which covers virtually all of my needs other than conflict resolution. I just noticed that it supports code actions if you set it up with null-ls, so I’m going to give that a shot now.

        config = function()
            require('gitsigns').setup {
                word_diff = true,
                current_line_blame = true,
                current_line_blame_opts = {
                    delay = 200,

and some keybindings:

        ["]h"] = {"<cmd>Gitsigns next_hunk<cr>", "Next Hunk,"},
        ["[h"] = {"<cmd>Gitsigns prev_hunk<cr>", "Previous Hunk"},
        ["<leader>ha"] = {"<cmd>Gitsigns stage_buffer<cr>", "Stage Buffer"},
        ["<leader>hs"] = {"<cmd>Gitsigns stage_hunk<cr>", "Stage Hunk"},
        ["<leader>hp"] = {"<cmd>Gitsigns preview_hunk<cr>", "Preview Hunk"},
        ["<leader>hr"] = {"<cmd>Gitsigns reset_hunk<cr>", "Reset Hunk"},
        ["<leader>hd"] = {"<cmd>Gitsigns diffthis<cr>", "Diff This"},
        ["<leader>hv"] = {"<cmd>Gitsigns select_hunk<cr>", "Select Hunk"},

but I have a feeling I won’t be using that much since I also added the following to my null-ls configuration above:

    sources = {... null_ls.builtins.code_actions.gitsigns}

One other git plugin I use all the time is conflict-marker. I haven’t had any trouble with it but since I’m going all-in on lua (for some reason), I’m going to try git-conflict.nvim instead. It uses the same keybindings that I am used to by default, so setup is trivial:

    {'akinsho/git-conflict.nvim', version = "*", config = true}

Tree Sitter

I was excited about neovim’s treesitter support but I don’t actually leverage it all that much. I always make sure my colour schemes support it, and I have a handful of plugins that I like to use:

            config = function()
                require('nvim-treesitter.configs').setup {
                    ensure_installed = {"c", "lua", "vim", "query", "python", "typescript"},
                    highlight = {enable = true},
                    rainbow = {
                        enable = true,
                        extended_mode = true,
                        max_file_lines = nil,
                    textobjects = {
                        select = {
                            enable = true,
                            lookahead = false,
                            keymaps = {
                                ["af"] = "@function.outer",
                                ["if"] = "@function.inner",
                                ["ac"] = "@class.outer",
                                ["ic"] = "@class.inner",
                                ["aa"] = "@parameter.outer",
                                ["ia"] = "@parameter.inner",
            config = function()
                    enable = true,
                    throttle = true,
                    max_lines = 0,
                    patterns = {
                        default = {

Text Case Plugins

I use CamelCaseMotion all the time. There doesn’t seem to be a neovim equivalent, so I’ll just stick with it.

I’ve loved tpope’s vim-abolish for a long time, but it’s probably time to try the modern lua version, text-case.nvim. I was intimidated by the keybindings it creates, but since it integrates with which-key, I should be able to pick them up quickly! It also features telescope integration to quickly search for a conversion.

        config = function()
            require('textcase').setup {}


There are several miscellaneous plugins that I don’t feel like writing up, so have a look at my complete config to see what they are.

This was a big post and I’m not sure how much value it’ll give folks. It was probably not the best way to invest a Sunday afternoon, but I enjoyed it and I’m looking forward to seeing how badly my configuration is broken when I start work tomorrow!