Reclaiming Neovim

The last time I used Vim extensively was in the mid-2010s, a little before Neovim really surged in popularity. Having done the rounds of JetBrains IDEs, VS Code, Zed, and, most recently, Helix, I held an unconscious estimation that Vim had been relegated to a former time. Yes, Neovim had come and modernised it, and yes, it kept being name-dropped, but these new editors did everything I wanted.

Until they didn’t. When my day job shifted off the JVM, IntelliJ IDEA stopped being an obvious choice. VS Code served me for a while but it felt sluggish. Zed served me for even longer, but it ended striking this weird balance between being opinionated while also needing quite a bit of configuration, while also lacking certain features just because it was newer editor; I remember the joy when Git support finally dropped! Helix, meanwhile, worked well almost out of the box: just configure which language server you want and get going. But the key bindings never really clicked for me (C, delete from cursor position to the end of the line, was one of my most-used Vim key bindings and sorely missed in Helix).

I stuck with Helix, but the issues mounted. The breaking point was me wanting to resolve the name of a Python function and the current file name and pass them to a shell script, and being unable to do this easily. A look at Helix’s plugin support story—which hasn’t felt like it’s moved in a long time—led me to right to Neovim. Plugin support? Check. Key bindings I’m accustomed to? Check. Easy to configure and understand? Not exactly, from what I can remember, but let’s see how the landscape is in 2026.

My memory of Vim is that it was necessary to have at least a few plugins installed. I also remember them being difficult to manage and configure. And so I was thrilled when I found that modern Neovim is really easy to configure. It’s a bit more work than Helix, but not much. Let’s take a look.

A Lazy start

lazy.nvim is a plugin manager for Neovim that handles download, installation, updates, load order, configuration… it does the lot. With over 20,000 stars on GitHub, it’s well regarded, and you can see why with how easy it is to set up. Lua-based Neovim configuration begins in the nvim/init.lua file in your config directory. Add this:

vim.g.mapleader = ' '
vim.g.maplocalleader = ' '

require("config.lazy")

And then to nvim/lua/config/lazy.lua, add:

local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"

-- Execute only if lazy.nvim directory isn't found
if not vim.uv.fs_stat(lazypath) then
  local lazyrepo = "https://github.com/folke/lazy.nvim.git"
  local out = vim.fn.system({
    "git", "clone", "--filter=blob:none", "--branch=stable",
    lazyrepo, lazypath
  })

  -- If clone fails, wait for user to read message, then exit neovim
  if vim.v.shell_error ~= 0 then
    vim.api.nvim_echo({
      { "Failed to clone lazy.nvim:\n", "ErrorMsg" },
      { out, "WarningMsg" },
      { "\nPress any key to exit..." },
    }, true, {})
    vim.fn.getchar()
    os.exit(1)
  end
end

-- Prepend lazy.nvim to runtime path so it runs before other plugins
vim.opt.rtp:prepend(lazypath)

require("lazy").setup("plugins")

How many plugins are needed to have a Helix-like experience? 8, by my count. This is my nvim directory:

.
├── init.lua
├── lua
│   ├── config
│   │   └── lazy.lua
│   └── plugins
│       ├── blink.lua
│       ├── catppuccin.lua
│       ├── gitsigns.lua
│       ├── mini-icons.lua
│       ├── nvim-lspconfig.lua
│       ├── nvim-treesitter.lua
│       ├── snacks.lua
│       └── which-key.lua

Let’s take a tour of these in the order I installed them.

The few plugins needed

The very first thing I wanted was solid syntax highlighting. Enter Tree-sitter, a tool to generate parsers for most programming and markup languages under the sun. While the functionality isn’t built into Neovim, the plugin is officially supported. This is my nvim/lua/plugins/nvim-treesitter.lua file:

local file_types = {
  'c',
  'html', 'css', 'javascript',
  'lua', 'luadoc', 'python',
  'bash', 'diff', 'vimdoc', 'markdown', 'markdown_inline',
}

return {
  {
    'nvim-treesitter/nvim-treesitter',
    ft = file_types,
    lazy = false,
    build = ':TSUpdate',

    config = function()
      require('nvim-treesitter').install(file_types)
      vim.api.nvim_create_autocmd('FileType', {
        pattern = file_types,
        callback = function() vim.treesitter.start() end,
      })
    end,
  },
}

This configuration will automatically install the parsers for the languages listed and start Tree-sitter whenever that a buffer of one of those file types is entered.

Next, I wanted language server protocol support so I get comprehensive code diagnostics. The second official plugin on the list takes care of that. Here’s nvim/lua/plugins/nvim-lspconfig.lua:

return {
  {
    'neovim/nvim-lspconfig',
    config = function()
      vim.lsp.config('ty', { cmd = { 'ty', 'server' } })
      vim.lsp.config('ruff', { cmd = { 'ruff', 'server' } })
      vim.lsp.enable('ty')
      vim.lsp.enable('ruff')
    end,
  }
}

As long as the language servers can be found in the path (run which ty to confirm, for example), this will add go-to-definition support (gti in Normal Mode), a documentation pop-up (K in Normal Mode), and the like. For Python projects, I tend to add ty and ruff as dev dependencies and source the Python virtual environment into my shell before starting Neovim, which inserts their binaries into the path.

The final big component was a file picker. snacks.nvim does that, and a whole lot more. I’ve disabled most of its other functionality; I’ll selectively enable each part as I learn more about them.

return {
  {
    'folke/snacks.nvim',
    priority = 1000,
    opts = {
      animate = { enabled = false },
      bigfile = { enabled = false },
      -- long list of other disabling lines...
      -- see repo link at the end of this post for the full list
      notify = { enabled = false },
      picker = { enabled = true, icons = { mappings = true } },
      profiler = { enabled = false },
      -- and more of them...
      zen = { enabled = false },
    },
    keys = {
      { "<leader><Space>", function() Snacks.picker.smart() end, desc = "Smart Find Files" },
      { "<leader>,", function() Snacks.picker.buffers() end, desc = "Buffers" },
      { "<leader>/", function() Snacks.picker.grep() end, desc = "Grep" },

      -- Find
      { '<leader>ff', function() Snacks.picker.files() end, desc = "[f]ind [f]iles" },

      -- Search
      { '<leader>sd', function() Snacks.picker.diagnostics_buffer() end, desc = "[s]earch Buffer [d]iagnostics" },
      { '<leader>sh', function() Snacks.picker.help() end, desc = "[s]earch [h]elp Pages" },
      { '<leader>sk', function() Snacks.picker.keymaps() end, desc = "[s]earch [k]eymaps" },
      { '<leader>s"', function() Snacks.picker.registers() end, desc = "[s]earch Registers" },
    },
  }
}

Snacks has a lot of really useful pickers which I’ll add key bindings for as and when the need arises for them. This setup will get me started and replicates the ones I most commonly used in Helix.

Snacks supports Nerd Font glyphs and icons. I installed the Departure Mono font (which I now adore) and the mini.icons icon provider:

return {
  {
    'nvim-mini/mini.icons',
    version = false,
    config = function() require('mini.icons').setup() end,
  }
}

Naturally, a colour scheme was needed. Catppuccin was right on-vibe:

return {
  {
    'catppuccin/nvim',
    name = "catppuccin",
    priority = 1000,
    config = function() vim.cmd.colorscheme('catppuccin') end,
  }
}

The setup is rounded off by three quality of life improvements. First, which-key.nvim shows a pop-up when in the middle of a key chord.

return {
  {
    'folke/which-key.nvim',
    event = "VeryLazy",
    opts = {
      icons = { mappings = true },
    },
    keys = {
      { "<leader>?",
        function() require('which-key').show({ global = false }) end,
        desc = 'Buffer Local Keymaps (which-key)',
      },
    },
  }
}

Second, gitsigns.nvim displays a Git gutter and inline Git blame at the cursor line.

return {
  {
    'lewis6991/gitsigns.nvim',
    opts = {
      current_line_blame = true,
    },
  }
}

Third, blink.cmp add auto-completions.

return {
  {
    'saghen/blink.cmp',
    version = '1.*',
    opts = {
      sources = { default = { 'lsp' }},
      fuzzy = { implementation = 'rust' },
      signature = { enabled = true },
    }
  }
}

Final touches

I change a default values on a few Vim options like setting the line number, enabling smart-case search, and disabling non-Lua language providers (I plan to only use Lua plugins). Most of these were based on kickstart.nvim’s overrides.

Check out my repository for the full config.

~K