The article below has been republished from /home/siddhi with the permission of the author.


Apart from VSCode and PyCharm, NeoVim (and Vim more generally) is probably the third most popular programming editor / IDE. One reason why developers like NeoVim is that it is very customisable. You can make it behave like a pure text editor, or customise it to a full blown IDE with debugging support and other features.

While other IDEs tend to be heavyweight and take up a lot of memory / CPU, you can configure NeoVim to have just the features that you want, so it loads really fast, while also fits to your development style.

All that comes with a cost: You have to learn and configure everything. You don't get a default starting experience.

In this article I am going to go through and explain my configuration step-by-step. I have a terrible memory, so this post will also serve as a guide when I inevitably need to look through this file in the future.

A couple of notes:

  • I am using NeoVim version 0.9.0. Many of the features here require at least this version of NeoVim
  • My setup is for windows. Most of the configuration is common for all operating systems, but I have some windows specific bits in there. I will mention these when I get to those parts
  • This is my setup. You may have different requirements and may not want the exact same setup that I do. Thats the point of NeoVim. For example, I don't use a debugger, so I haven't configured it here. Still, it is fairly easy to take this config as a base and add make the required changes.

There are some starter configurations available that give a good out-of-the-box development setup. You can try them out. Here are some of the popular ones:

If you have never used NeoVim before, I'd suggest looking through some of those to get started with using NeoVim. You can start using NeoVim without messing around with the configuration.

Eventually, you will want a configuration that is customised to your preferred way of working. In this article, I'll go through my configuration step by step, explaining it for someone who is new to customising NeoVim.

You can find my configuration file on GitHub here: my neovim configuration for python.

Setup

The repository contains a file init.lua. This is the main configuration file that is needed. In addition, there are a few other files. To use this configuration, you would copy all these files into your nvim directiory (in windows that is located at C:\Users\<your user>\AppData\Local\nvim)

The configuration is written in Lua programming language. You don't need to go and learn Lua as it's a simple language and most of the code you see here should be understandable even without knowing Lua. If you need to refer something, check out the Programming in Lua reference.

Right, let us open up init.lua. It starts out with a set of comments.

-- vim: ts=2 sts=2 sw=2 et
-- External tools required
-- Windows Terminal + pwsh
-- mingw64 toolchain: https://www.msys2.org/
-- ripgrep: https://github.com/BurntSushi/ripgrep
-- win32yank for clipboard integration
-- sharkdp/fd

The first comment is for setting up the tabstop, shiftwidth and expandtab settings for this file. These settings override the indentation settings for this particular file alone.

After that I have some comments to remind me what all external tools I need to install.

  • I use Windows as my primary OS. Windows Terminal for the terminal and pwsh for the shell
  • Some of the NeoVim plugins require C compilation. Windows does not have C compilers by default, so I install mingw toolchain distributed by msys2
  • Next are ripgrep and fd. These tools are used by plugins later in the configuration
  • Finally, win32yank integrates NeoVim with the windows clipboard

There are many plugin managers for NeoVim. This following code block loads the lazy.nvim plugin manager.

local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
  -- bootstrap lazy.nvim
  vim.fn.system({ "git", "clone", "--filter=blob:none", "https://github.com/folke/lazy.nvim.git", "--branch=stable", lazypath })
end
vim.opt.rtp:prepend(vim.env.LAZY or lazypath)

Options

The next section sets a bunch of Vim options.

I have space set as my leader key. I used to use forward slash as the leader key for a long time. When I tried out Kickstart it set leader to space by default and I found that very convenient. I also disable space to have no effect in normal and visual mode (in case I press space and dont follow it up with a key sequence)

-- set leader key to space
vim.g.mapleader = ' '
vim.g.maplocalleader = ' '
vim.keymap.set({ 'n', 'v' }, '<Space>', '<Nop>')

Then come a set of options specific to configuring NVim terminal for pwsh

-- terminal settings
local powershell_options = {
  shell = vim.fn.executable "pwsh" == 1 and "pwsh" or "powershell",
  shellcmdflag = "-NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command [Console]::InputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;",
  shellredir = "-RedirectStandardOutput %s -NoNewWindow -Wait",
  shellpipe = "2>&1 | Out-File -Encoding UTF8 %s; exit $LastExitCode",
  shellquote = "",
  shellxquote = "",
}

for option, value in pairs(powershell_options) do
  vim.opt[option] = value
end

Normally, you have to come out of escape mode in the terminal by pressing <Ctrl-\\><Ctrl-n>, which is quite inconvenient. So I remap that to Esc. Similarly I remap Ctrl-w to directly switch out of the terminal split. The next few lines configure this

vim.keymap.set('t', '<Esc>', "<C-\\><C-n>")
vim.keymap.set('t', '<C-w>', "<C-\\><C-n><C-w>")

-- minimize terminal split
vim.keymap.set('n', '<C-g>', "3<C-w>_")

I also have a mapping to minimise the terminal split. I use this when I am running pytest in watch mode. It allows me to see the test success / failure state at the bottom of the screen. (For an example of this workflow, check out this video)

Plugins

Next comes all the plugin configuration. lazy.nvim allows us to create separate plugin files, which is a good idea for organising a complex configuration. For now, everything is configured in this one file.

require("lazy").setup({
  -- all plugins go here
})

Theme

For years I used solarized dark mode for both Vim and NeoVim. Right now I'm using catppuccin. I also load devicons here as its used by many plugins.

{ "catppuccin/nvim", lazy = true, name = "catppuccin", priority=1000 },
{ "nvim-tree/nvim-web-devicons", lazy = true },

Here is a look at the catppuccin theme

NeoVim catppuccin theme

Snippets

Next are Snippets. I use LuaSnip

{ "L3MON4D3/LuaSnip", event = "VeryLazy",
  config = function()
    require("luasnip.loaders.from_lua").load({paths = "./snippets"})
  end
},

LuaSnip supports a few different formats: SnipMate, VSCode or pure Lua. I've gone with pure Lua snippets which I put in the ./snippets folder.

I mainly use snippets for writing in RST format. Here is an example

LuaSnip in action

And here is the Lua snippet that I configured for that (s means add snippet, t stands for text node, i is an insert node where the user can edit the template)

local link = s({
  trig = "link",
  name = "Link",
  dscr = "Web link"
}, {
  t({"`"}),
  i(1, "Title"),
  t(" <"),
  i(2, "link"),
  t(">`_"),
  i(0)
})

Language Server Protocol

Now comes the big one: Language Server Protocol (LSP). The LSP server will analyse our code. NeoVim will communicate with the LSP server to get autocomplete suggestions and code diagnostics. I am using pyright for the LSP server. This open source LSP server is developed by Microsoft. A closed source derivative is used in VS Code.

{ "neovim/nvim-lspconfig",
  dependencies = {
    "williamboman/mason.nvim",
    "williamboman/mason-lspconfig.nvim"
  },
  config = function()
    local capabilities = vim.lsp.protocol.make_client_capabilities()
    capabilities = require('cmp_nvim_lsp').default_capabilities(capabilities)

    require('mason').setup()
    local mason_lspconfig = require 'mason-lspconfig'
    mason_lspconfig.setup {
        ensure_installed = { "pyright" }
    }
    require("lspconfig").pyright.setup {
        capabilities = capabilities,
    }
  end
},

This part is a little complicated.

First, we are installing Mason. Mason allows us to install / uninstall / manage all our LSP servers from within NeoVim. So we don't need to install pyright separately on the terminal. Next we install nvim-lspconfig and its Mason interface mason-lspconfig.

After that we load cmp_nvim_lsp. This is our autocomplete plugin (more about it in the next section). We ask it what capabilities it requires from the LSP server. Then we tell Mason to make sure pyright is installed, and it gets started with the required list of capabilities.

Once set up, you will get LSP diagnostics in NeoVim

LSP Diagnostics

Autocomplete

Well, if you thought the LSP configuration was complicated, wait till we get to autocompletion. I am using hrsh7th/ncim-cmp as the autocomplete plugin. This plugin can autocomplete from many different sources. I have it configured to autocomplete from LSP (via cmp-nvim-lsp) as well as LuaSnip snippets (via cmp_luasnip).

{ "hrsh7th/nvim-cmp",
  dependencies = {
    "hrsh7th/cmp-nvim-lsp",
    "L3MON4D3/LuaSnip",
    "saadparwaiz1/cmp_luasnip"
  },
  config = function()
    local has_words_before = function()
      unpack = unpack or table.unpack
      local line, col = unpack(vim.api.nvim_win_get_cursor(0))
      return col ~= 0 and vim.api.nvim_buf_get_lines(0, line - 1, line, true)[1]:sub(col, col):match("%s") == nil
    end

    local cmp = require('cmp')
    local luasnip = require('luasnip')

    cmp.setup({
      snippet = {
        expand = function(args)
          luasnip.lsp_expand(args.body)
        end
      },
      completion = {
        autocomplete = false
      },
      mapping = cmp.mapping.preset.insert ({
        ["<Tab>"] = cmp.mapping(function(fallback)
          if cmp.visible() then
            cmp.select_next_item()
          elseif luasnip.expand_or_jumpable() then
            luasnip.expand_or_jump()
          elseif has_words_before() then
            cmp.complete()
          else
            fallback()
          end
        end, { "i", "s" }),
        ["<s-Tab>"] = cmp.mapping(function(fallback)
          if cmp.visible() then
            cmp.select_prev_item()
          elseif luasnip.jumpable(-1) then
            luasnip.jump(-1)
          else
            fallback()
          end
        end, { "i", "s" }),
        ["<c-e>"] = cmp.mapping.abort(),
        ["<CR>"] = cmp.mapping.confirm({ select=true }),
      }),
      sources = {
        { name = "nvim_lsp" },
        { name = "luasnip" },
      }
    })
  end
},

Most of this configuration is setting up the keys for autocomplete. I use TAB to trigger autocompletion. Since TAB is also a normal key used for indentation and moving between sections of a LuaSnip snippet, we need to write some code so it triggers correctly. Essentially, if you press TAB immediately after any text, the plugin will pop up the autocomplete menu. If the menu is already open, then it will cycle through the items in the autocomplete menu. Otherwise it will fall back to the default behaviour. Same for Shift-TAB in reverse order.

This is what it looks like in action

LSP Autocomplete

Treesitter

We configure Treesitter next. Treesitter is a fast, incremental code parser. It can parse the code as we type and give the results to NeoVim. NeoVim mainly uses it for syntax highlighting, though there are a few other use cases as well. The syntax highlighting is superior to the usual regex based highlighting. Treesitter can differentiate between a local and global variable for example, even though both have the same regex.

I have treesitter configured for a bunch of languages.

{ "nvim-treesitter/nvim-treesitter", version = false,
  build = function()
    require("nvim-treesitter.install").update({ with_sync = true })
  end,
  config = function()
    require("nvim-treesitter.configs").setup({
      ensure_installed = { "c", "lua", "vim", "vimdoc", "query", "python", "javascript" },
      auto_install = false,
      highlight = { enable = true, additional_vim_regex_highlighting = false },
      incremental_selection = {
        enable = true,
        keymaps = {
          init_selection = "<C-n>",
          node_incremental = "<C-n>",
          scope_incremental = "<C-s>",
          node_decremental = "<C-m>",
        }
      }
    })
  end
},

In addition, I have enabled incremental selection on Treesitter. Pressing Ctrl-n will select the innermost syntactical piece of code based on the cursor location. Press Ctrl-n again and it expands the selection to the next scope in the parse tree. Take a look

Incremental selection via Treesitter

Here the cursor starts on the return word. Had the cursor beein inside the list comprehension, then it would have selected the list comprehension first, then the whole line and so on. This is such a great feature.

Telescope

The last of the big configurations is Telescope. Telescope is a fuzzy finder that can search for different things. You can search for files in the current project, switch between open buffers, search text within all the files, symbols in the document – almost anything really. Check the docs for a full list of capabilities.

The two most common that I use are <leader>sf to fuzzy search files in the project, and <leader><space> to swap between buffers

{ "nvim-telescope/telescope.nvim", cmd = "Telescope", version = false,
  dependencies = { "nvim-lua/plenary.nvim" },
  keys = {
    { "<leader>sf", "<cmd>Telescope git_files<cr>", desc = "Find Files (root dir)" },
    { "<leader><space>", "<cmd>Telescope buffers<cr>", desc = "Find Buffers" },
    { "<leader>sg", "<cmd>Telescope live_grep<cr>", desc = "Search Project" },
    { "<leader>ss", "<cmd>Telescope lsp_document_symbols<cr>", desc = "Search Document Symbols" },
    { "<leader>sw", "<cmd>Telescope lsp_dynamic_workspace_symbols<cr>", desc = "Search Workspace Symbols" },
  },
  opts = {
    extensions = {
      fzf = {
        fuzzy = true,
        override_generic_sorter = true,
        override_file_sorter = true,
        case_mode = "smart_case"
      }
    }
  }
},
{ "nvim-telescope/telescope-fzf-native.nvim",
  build = "make",
  config = function()
    require('telescope').load_extension('fzf')
  end
},

Here is a screenshot using Telescope to fuzzy find a file in the project

Type a few characters to fuzzy find a file in the project

Linting & Formatting

I use the null-ls to lint and reformat the file everytime I save it. I use ruff as the linter and Black for formatting Python code.

null-ls supports both, as well as many others.

{ "jose-elias-alvarez/null-ls.nvim",
  dependencies = { "nvim-lua/plenary.nvim" },
  config = function()
    local null_ls = require("null-ls")

    null_ls.setup({
      sources = {
        null_ls.builtins.diagnostics.ruff,
        null_ls.builtins.formatting.black,
      }
    })
  end
},

Terminal

Nothing much to say about this really. toggleterm.nvim will open the terminal when I press Ctrl-s. Most of the interesting terminal configuration happened earlier in the config. toggleterm is nice because it allows you to have multiple terminals open. Show / hide them all or show / hide a single one. Useful when you want to run a server on one terminal, hide it and open another terminal for running commands.

An interesting feature is you can select some code and send it to run on a terminal. So if you have a Python REPL session open in a terminal, you can select code in the buffer and have it run in the REPL.

{ "akinsho/toggleterm.nvim", event = "VeryLazy", version = "*",
  opts = {
    size = 10,
    open_mapping = "<c-s>",
  }
},

Other Editor Plugins

The rest of the file is standard editor configuration plugins. My configuration is

One nice thing about bufferline is that it allows us to pin buffers, and close all unpinned buffers. Often I find myself working on one or two files, but I temporarily need to open many other files. You can pin the buffers you are working on and just close everything else when you are done.

You can also hook it up to LSP diagnostics, so it will show an icon if the file has any warnings or errors.

Other Stuff

I added an autocommand to highlight text when it is yanked, so you know what was yanked. This is just copied over from Kickstart.

local highlight_group = vim.api.nvim_create_augroup('YankHighlight', { clear = true })
  vim.api.nvim_create_autocmd('TextYankPost', {
    callback = function()
      vim.highlight.on_yank()
    end,
  group = highlight_group,
  pattern = '*',
})

Kickstart also has a convenience remapping for using navigation keys with lines that are wrapped (I use arrow keys for navigation in NeoVim)

vim.keymap.set('n', '<Up>', "v:count == 0 ? 'gk' : 'k'", { expr = true, silent = true })
vim.keymap.set('n', '<Down>', "v:count == 0 ? 'gj' : 'j'", { expr = true, silent = true })

Last, there are some useful LSP related keymaps. These should be self explanatory. Note that pyright does not support code actions. But I put a keymap for it anyway 😆

vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, {desc = 'Rename Symbol'})
vim.keymap.set('n', '<leader>gd', vim.lsp.buf.definition, {desc = 'Goto Definition'})
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, {desc = 'Code Action'})
vim.keymap.set('n', 'K', vim.lsp.buf.hover, {desc = 'Hover Documentation'})
vim.keymap.set('n', '<leader>ff', vim.lsp.buf.format, {desc = 'Format Code'})

Summary

Thats a walkthrough of the entire config.

It covers all the basics and should be a good starting point for anyone wanting to setup their NeoVim from scratch for Python coding.

By the end of it all, you should have a fast, lightweight editor that can do everything that the heavier IDEs do.

So whats next from here? Well you might want to add more plugins or swap some of my plugins with other alternatives. Here are some things that I don't have yet

  • Plugin to comment / uncomment code. I just don't do it all that often
  • Support for Python virtual environments. I just activate the virtual environment on the terminal before opening nvim and that works fine for me
  • Debugging support. I don't do much debugging so I left it out. But if you need it, Mason has support for adding debuggers via Debug Adapter Protocol (DAP)
  • File Explorer: I don't include a file explorer plugin as I tend to use Telescope's fuzzy find. If you want one, nvim-tree is quite popular

Apart from that, there are many other plugins so you can customise NeoVim to your preferred way of working. Check out This Week in NeoVim to see whats the latest and greatest.

Happy Editing 📖

Did you like this article?

If you liked this article, consider subscribing to this site. Subscribing is free.

Why subscribe? Here are three reasons:

  1. You will get every new article as an email in your inbox, so you never miss an article
  2. You will be able to comment on all the posts, ask questions, etc
  3. Once in a while, I will be posting conference talk slides, longer form articles (such as this one), and other content as subscriber-only