Modern Neovim config from scratch

67 min read

The Neovim ecosystem changed with the native implementation of the Language Server Protocol and Lua as the primary extension language, supplanting our most beloved VimScript... These new capabilities led to the development of more complex plugins that can bring Neovim closer to a full-blown IDE, or just make it an even more powerful text editor.

Still, the core features of Neovim are pretty barebone, so unless you want to configure everything from scratch, you have to rely on plugins to turn it into a "modern" editor. There are basically three main approaches that I know of to build a modern configuration (leaving aside the pre-LSP VimScript plugins):

  • The all-in-one approach with CoC, that provides most features out of the box, dedicated plugins for each language, but it can almost be considered a separate ecosystem as it is mostly based on JavaScript (there is some interoperability with VSCode plugins).

  • The all-in-one approach with a modern Neovim distribution (LazyVim, AstroNvim, NvChad, LunarVim), that will probably include most of the plugins presented in this article, with a layer of abstraction over the raw plugin configuration, which is probably fine for simple tweaks, but can be annoying for more advanced customizations.

  • The "from scratch" approach, maybe with the help of a minimalistic distribution like kickstart.nvim. Unfortunately it crams everything into one file, which doesn't make it particularly readable or maintainable. Another drawback of this approach is that you will have to apply for a PhD in Neovimology, but this is a small price to pay if you spend most of your coding career inside Neovim.

This article will help you build a Neovim config from scratch, which means choosing and configuring each plugin. Since I don't want to make you write the 15th Neovim distribution that covers all previous 14 distributions use cases, and I also don't want to depend on an overly complex configuration for such a critical piece of software, I will try to stay as close as possible to the vanilla (Neo)Vim experience. The purpose is to give you a simple, understandable, maintainable base that you can further customize anyway you want.

§
Prerequisites

  • A terminal emulator with true colors and icon font support. If you don't know which terminal to choose, you cannot go wrong with Alacritty. You should also pick a variant of Nerd Fonts and configure it as your terminal font (nowadays, many CLI tools assume proper icon font support).

  • A decent understanding of Neovim. With enough plugins, you can turn Neovim into a completely different text editor, but I don't think you can truly benefit from it until you learn the core features inherited from Vim.

    Fortunately, Neovim has an excellent builtin help system and learning material:

    Knowing the core features is one thing, learning how to use them effectively is another. You could complement these resources with workflow-oriented tutorials (like this video).

  • A reasonable knowledge of Lua. It is not a difficult programming language, but it has some oddities. After reading Lua: Getting Started, I would recommend skimming through Programming in Lua, with special attention to key subjects like the general syntax, builtin types and methods, functions, and how to work with tables, which are Lua's main data structure.

  • A backup of your existing configuration, including the following directories:

    • ~/.cache/nvim/.
    • ~/.config/nvim/.
    • ~/.local/share/nvim/.

    You should also version your new configuration inside a Git repository, accidents happen...

  • Finally, I encourage you to test a battery-included distribution like LazyVim. The experience is different enough from vanilla Vim / NeoVim that it should give you a better idea of what's possible, and it will also serve as a hands-on introduction to the most popular plugins. Then you can come back to this article to build your config from scratch.

§
Core configuration

This section explains how to convert common configurations options from VimScript to Lua, organizing the configuration as follows:

~/.config/nvim
├── init.lua
├── colors
│   └── user.lua
└── lua
    └── user
        ├── init.lua
        ├── autocmds.lua
        ├── colors.lua
        ├── keymaps.lua
        ├── options.lua
        ├── utils.lua
        └── plugins
            ├── init.lua
            ├── plugin1.lua
            └── plugin2.lua

§
Init

Neovim looks for Lua modules in ~/.config/nvim/nvim/lua. The namespace is shared between builtin modules, plugins that we will add later, and your configuration files, which is why I recommend creating a dedicated user module.

There are two ways to create Lua modules that you can import with a statement like require('user'):

  • It can be a single file like ~/.config/nvim/nvim/lua/user.lua, which doesn't allow sub-modules.
  • Or you can create a user directory that contains an init.lua file, which allows sub-modules.

Let's create a directory-based module with an empty init.lua:

Console
$ mkdir -p ~/.config/nvim/lua/user && touch ~/.config/nvim/lua/user/init.lua

In place of the traditional init.vim, Neovim sources ~/.config/nvim/init.lua if it exists. Because we want most of our configuration inside ~/.config/nvim/lua/user, this file should only require the user module:

~/.config/nvim/init.lua
require('user')

§
Options

Create the module ~/.config/nvim/lua/user/options.lua that will contain our Neovim configuration:

Console
$ touch ~/.config/nvim/lua/user/options.lua

You can source this file from user/init.lua:

~/.config/nvim/lua/user/init.lua
require('user.options')

To set Vim options with Lua, you can assign values to the table vim.opt, which works like :set. For example:

~/.config/nvim/lua/user/options.lua
vim.opt.tabstop = 4
vim.opt.shiftwidth = 0
vim.opt.expandtab = false

vim.opt also allows to update list or map-style options in a convenient way:

~/.config/nvim/lua/user/options.lua
-- Disable startup message.
vim.opt.shortmess:append { s = true, I = true }

Note that under the hood, options are variables with different scopes (global, buffer, window), and some global options can be changed locally using vim.opt_local (equivalent of :setlocal). Usually, Neovim does sensible things with these options when switching buffers or windows, so using vim.opt is enough for most purposes.

In a few cases, we will explicitly set buffer-scoped variables using vim.b[bufnr].foo, where bufnr is the buffer ID (0 for the active buffer). You can also use vim.b as a shortcut for vim.b[0]). This is equivalent to using :let b:foo.

Finally, some configuration variables are not options, so they are not available under vim.opt, most notably the leader key mappings:

~/.config/nvim/lua/user/options.lua
-- Space as <Leader>.
vim.g.mapleader = ' '
-- \ as <LocalLeader>.
vim.g.maplocalleader = '\\'

§
Keymaps

Create the module ~/.config/nvim/lua/user/keymaps.lua that will contain our key mappings. You can source this file from user/init.lua:

~/.config/nvim/lua/user/init.lua
require('user.options')
require('user.keymaps')

When defining keymaps, the benefit of using Lua becomes clear. Instead of the convoluted variants of map, Neovim offers a single vim.keymap.set method with the following parameters:

  • mode: string | table like 'n' or {'n', 'v'}.
  • keymap: string like '<leader>f'.
  • action: string | function like vim.buf.format.
  • options like noremap, silent, expr.

For example:

~/.config/nvim/lua/user/keymaps.lua
-- Remap for dealing with word wrap
vim.keymap.set('n', 'k', 'v:count == 0 ? 'gk' : 'k'', { expr = true, silent = true })
vim.keymap.set('n', 'j', 'v:count == 0 ? 'gj' : 'j'', { expr = true, silent = true })

-- Move to window using the <C-hjkl> keys
vim.keymap.set('n', '<C-h>', '<C-w>h', { desc = 'Switch to left window' })
vim.keymap.set('n', '<C-j>', '<C-w>j', { desc = 'Switch to lower window' })
vim.keymap.set('n', '<C-k>', '<C-w>k', { desc = 'Switch to upper window' })
vim.keymap.set('n', '<C-l>', '<C-w>l', { desc = 'Switch to right window' })

-- Quickfix list
vim.keymap.set('n', '[q', vim.cmd.cprev, { desc = 'Previous quickfix item' })
vim.keymap.set('n', ']q', vim.cmd.cnext, { desc = 'Next quickfix item' })

-- Diagnostics
vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, { desc = 'Go to prev diagnostic message' })
vim.keymap.set('n', ']d', vim.diagnostic.goto_next, { desc = 'Go to next diagnostic message' })
vim.keymap.set('n', 'gl', vim.diagnostic.open_float, { desc = 'Open floating diagnostic message' })
vim.keymap.set('n', '<leader>q', vim.diagnostic.setqflist, { desc = 'Open diagnostic quickfix list' })

You can access VimScript commands such as :cnext and :cprev through vim.cmd. Linters and LSP servers can set diagnostics which are accessible through the vim.diagnostic API.

§
Autocmds

When you want to change an option based on a buffer's filetype, or react to specific events, you can define auto-commands. Create the module ~/.config/nvim/lua/user/autocmds.lua that will contain our auto-commands, and source it from ~/.config/nvim/lua/user/init.lua:

~/.config/nvim/lua/user/init.lua
require('user.options')
require('user.keymaps')
require('user.autocmds')

The Lua equivalents of autocmd and augroup are available in the vim.api module. The following autocmd automatically enables wrapping and spell check for Git messages and Markdown files:

~/.config/nvim/lua/user/autocmds.lua
-- Wrap and check for spell in text filetypes.
vim.api.nvim_create_autocmd('FileType', {
    group = vim.api.nvim_create_augroup('wrap_spell', { clear = true }),
    pattern = { 'gitcommit', 'markdown' },
    callback = function()
        vim.opt_local.wrap = true
        vim.opt_local.spell = true
    end,
})

Notice the use of vim.opt_local to change options only in the current buffer or window.

Another example is the following autocmd which returns the cursor to the last location when you open a buffer:

~/.config/nvim/lua/user/autocmds.lua
-- Go to last loc when opening a buffer.
vim.api.nvim_create_autocmd('BufReadPost', {
    group = vim.api.nvim_create_augroup('last_loc', { clear = true }),
    callback = function()
        local mark = vim.api.nvim_buf_get_mark(0, '"')
        local lcount = vim.api.nvim_buf_line_count(0)
        if mark[1] > 0 and mark[1] <= lcount then
            -- Protected call to catch errors.
            pcall(vim.api.nvim_win_set_cursor, 0, mark)
        end
    end,
})

Notice the use of pcall to catch eventual errors (the buffer may have been modified externally, so the mark may be out-of-range).

§
Plugin management

Now is the time to extend Neovim with plugins. This section introduces Lazy.nvim, a plugin manager design to load plugins only when they are required, which improves startup time and resource usage.

§
Installation

The plugins will be defined in the module ~/.config/nvim/lua/user/plugins/:

Console
$ mkdir -p ~/.config/nvim/lua/user/plugins/

Lazy.nvim will source any files (sub-modules) you put in ~/.config/nvim/lua/user/plugins/. It expects each module to return a table containing one or more plugin specifications. It is common practice to set plugin-dependent keymaps and autocmds alongside the plugin configuration. For now we will return an empty table from this module:

~/.config/nvim/lua/user/plugins/init.lua
return {}

Edit ~/.config/nvim/lua/user/init.lua to automatically download and setup Lazy.nvim, sourcing the plugins from user.plugins (you can do that after the core modules, since they don't rely on plugins):

~/.config/nvim/lua/user/init.lua
require('user.options')
require('user.keymaps')
require('user.autocmds')

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

require('lazy').setup(
    'user.plugins',
    {
        change_detection = { enabled = false }
    }
)

I also disabled the change detection because it will inevitably cause errors as you tweak your config. The downside is that you will have to restart Neovim for changes to take effect. After a restart, Lazy.nvim automatically installs the missing plugins. You can also do this from the Lazy UI that you can access with the command :Lazy. The main keybindings are listed at the top, such as U to update the plugins.

§
Configuration

To illustrate how to add a plugin, let's take mini.pairs, which automatically insert the closing parenthesis, quote, bracket, and similar closing delimiter after you insert the opening pair.

Since it is part of a family of "mini" plugins, you can add it to a dedicated module ~/.config/nvim/lua/user/plugins/mini.lua:

~/.config/nvim/lua/user/plugins/mini.lua
return {
    { 'echasnovski/mini.pairs' },
}

If you restart Neovim, you will see that Lazy.nvim has installed mini.pairs, but it doesn't seem work. Many Lua plugins require you to call their setup(opts: table) method to properly initialize them, where opts contains the plugin configuration options.

Lazy.nvim supports a few attributes related to plugin configuration:

  • init: func(_) always runs during startup whether the plugin is loaded immediately or not. This is where you should set the configuration for VimScript plugins that do not have a Lua API.
  • opts: table | func -> table is used to set plugins options from a table, or from a function that returns a table, which is how most Lua plugins represent their configuration.
  • config: func(_, opts:table) | bool can be defined as a function that accepts the opts table as its second argument to configure the plugin. If this property is unset, but opts is set, it defaults to a call to plugin.setup(opts), which is how most Lua plugins are setup.

If both opts and config are unset, which is the case here, there will be no call to the plugin's setup function, which explains why mini.pairs isn't working. As a shorthand for a call to the setup function, you can set config = true:

~/.config/nvim/lua/user/plugins/init.lua
return {
    {
        'echasnovski/mini.pairs',
        config = true,
    },
}

Alternatively, you can define an empty table of options:

~/.config/nvim/lua/user/plugins/init.lua
return {
    {
        'echasnovski/mini.pairs',
        opts = {},
    },
}

Or define a function that returns these options:

~/.config/nvim/lua/user/plugins/init.lua
return {
    {
        'echasnovski/mini.pairs',
        opts = function()
            return {}
        end,
    },
}

Or define both the options and the config function:

~/.config/nvim/lua/user/plugins/init.lua
return {
    {
        'echasnovski/mini.pairs',
        opts = {},
        config = function(_, opts)
            require('mini.pairs').setup(opts)
        end,
    },
}

Obviously, the most concise option is usually the better, depending on how much configuration each plugin needs.

§
Lazy loading

Now we get to the main benefit of Lazy.nvim. There is no need to load mini.pairs as soon as Neovim starts since it is only ever useful in insert mode when we insert an opening character. We can improve the configuration to load it only when the InsertEnter event gets triggered for the first time:

~/.config/nvim/lua/user/plugins/init.lua
return {
    {
        'echasnovski/mini.pairs',
        event = 'InsertEnter',
        config = true,
    },
}

If you restart your editor and go to the Lazy.nvim UI (:Lazy), you will see the InsertEnter trigger next to the plugin name, and most importantly, it is under the section "Not Loaded".

Lazy supports additional triggers:

  • Filetypes defined with the ft attribute, especially useful when some plugins only apply to specific programming languages.
  • Keymaps defined with the keys attribute, which is similar to defining them in the init function, except they are listed in Lazy.nvim UI.
  • Commands defined with the cmd key, again mostly for documentation purposes.

Note that regardless of these triggers, plugins are automatically loaded when explicitly required with a statement like require('mini-pairs'). So when you define a keymap before a plugin is loaded, you should always make sure to only require the plugin lazily, for example:

~/.config/nvim/lua/user/plugins/init.lua
vim.keymap.set(
    'n', '<leader>foo',
    function()
        require('foo').bar(),
    end,
    { desc = 'Lazy foo bar' },
)

Instead of this:

~/.config/nvim/lua/user/plugins/init.lua
vim.keymap.set(
    'n', '<leader>foo',
    require('foo').bar,
    { desc = 'Not lazy foo bar' },
)

§
Extensibility

You can specify a list of plugins specs as dependencies:

return {
    {
        'foo',
        dependencies = {
            'bar',
            'baz',
        },
    },
}

Dependency here means that the dependent plugins get loaded before the main plugin is loaded, which is mostly equivalent to:

  • Defining the dependent plugins alongside the main plugin.
  • Setting lazy = true on the dependant plugins.
  • Explicitly calling require() in the main plugin's setup function.

The important point is that these dependencies are lazy-loaded by default, contrary to top-level plugins.

As you've seen previously, many attributes of the plugin specs can be defined as functions, like config: func(_, opts). This is an extension mechanism that allows to define a parent plugin configuration, and then modify it when a dependant plugin is enabled:

return {
    {
        'foo',
        dependencies = {
            {
                'bar',
                config = function(_, opts)
                    opts.foo = true
                end,
            },
            'baz',
        },
    },
    {
        'bar',
        config = {
            foo = false,
        },
    },
}

I wouldn't expect you to use this a lot in a static configuration, but this mechanism is very useful for Neovim distributions that bundle optional plugins. The main use of this you will see in the rest of this article is to specify simple lazy-loaded dependencies in a concise way.

§
LSP (core)

The Language Server Protocol (LSP) provides many features to improve the programming experience. The client side is natively supported by Neovim, but each language has its own server and configuration options. A collection of LSP client configurations is maintained in the nvim-lspconfig repository, that you can use as a plugin, but it still requires extensive configuration from our end, which will be the focus of the next few sections.

§
Setup

Each language server has its own module and configuration options that you can setup as follows:

~/.config/nvim/lua/user/plugins/lspconfig.lua
return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    config = function(_, opts)
        local lspconfig = require('lspconfig')

        lspconfig.lua_ls.setup{}
        lspconfig.rust_analyzer.setup{}
    end
}

These setup methods register each server with Neovim and configure the necessary hooks so they can attach to a buffer of the matching type. Because you will likely have to configure multiple languages with their own set of options, we can refactor the setup code as follows:

~/.config/nvim/lua/user/plugins/lspconfig.lua
return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    opts = {
        servers = {
            lua_ls = {
                settings = {
                    Lua = {
                        workspace = { checkThirdParty = false },
                        telemetry = { enable = false },
                    },
                },
            },
            rust_analyzer = {
                settings = {
                    ['rust-analyzer'] = {
                        check = {
                            command = 'clippy',
                        },
                    },
                },
            },
        },
    },
    config = function(_, opts)
        local lspconfig = require('lspconfig');

        for name, conf in pairs(opts.servers) do
            lspconfig[name].setup {
                settings = conf.settings,
            }
        end
    end
}

The LSP settings are in their own table so we can add our own attributes later without sending them to lspconfig. Repeating the server name in the settings is annoying but required.

dev tip

Before continuing, I suggest you add lazydev.nvim (requires Neovim >= 0.10) to automatically configure LuaLS to get better completion and typing information when working with the Neovim Lua API and plugins:

~/.config/nvim/lua/user/plugins/luadev.lua
return {
    "folke/lazydev.nvim",
    ft = "lua",
    config = true,
}

§
Mason

If you tried to restart Neovim, you may have seen a message like "Spawning language server with cmd: lua-language-server failed. The language server is either not installed, missing from PATH, or not executable."

Indeed, in addition to the configuration of Neovim as an LSP client, you also have to install the servers binaries such as (luals or rust_analyzer), though it would be nice if we didn't have to look for how to install the various things we need to get a functional LSP setup. This is exactly what mason.nvim was designed for.

Mason is a cross-platform package manager that provides an interface inside Neovim to quickly install many coding related tools like LSPs, linters, etc. You configure the base plugin as follows:

~/.config/nvim/lua/user/plugins/mason.lua
return {
	'williamboman/mason.nvim',
	cmd = 'Mason',
	keys = {
		{ '<leader>cm', '<cmd>Mason<cr>', desc = 'Open Mason' },
	},
	config = true,
}

After restarting Neovim, you can open Mason with <leader>cm or :Mason. Binaries installed through Mason are stored in ~/.local/share/nvim/mason/bin/ on Linux, and this directory is automatically added to your $PATH, so most language servers and tools should just work™.

There is one more thing you have to configure though, it is adding Mason as a dependency of nvim-lspconfig, since the $PATH is changed only after Mason is setup:

~/.config/nvim/lua/user/plugins/lspconfig.lua
return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    dependencies = {
        'williamboman/mason.nvim',
    },
    opts = { ... },
    config = ...,
}

Now you can try to install lua-language-server by pressing i over its entry, open a Lua file, and run :LspInfo to check if the server is attached to the buffer.

There is an extra plugin that improves the integration with lspconfig called mason-lspconfig. It hooks into the LSP client configuration to make their installation with Mason work, and it can also automate the installation of LSP servers.

Beyond the languages servers distributed as standalone binaries, it is sometimes better to use the version distributed with the language's toolchain or installed inside a dedicated environment like a Python virtualenv.

§
On attach

The settings are server-specific, but there is a common set of options shared by all language servers, such as the on_attach callback invoked when a language server attaches to a buffer. This callback receives a handle to the LSP client and the buffer number as arguments:

~/.config/nvim/lua/user/plugins/lspconfig.lua
local on_attach = function(client, bufnr)
    print(client.name .. ' attached to buffer ' .. bufnr)
end

return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    dependencies = { ... },
    opts = { ... },
    config = function(_, opts)
        local lspconfig = require('lspconfig')

        for name, conf in pairs(opts.servers) do
            lspconfig[name].setup {
                on_attach = on_attach,
                settings = conf.settings,
            }
        end
    end,
}

The on_attach callback is where you can setup the buffer for use with a language server, enabling features like diagnostics, formatting, code actions. To make our life easier, we will immediately use pcall to log any errors that may arise when this callback is invoked:

~/.config/nvim/lua/user/plugins/lspconfig.lua
local on_attach = function(client, bufnr)
end

return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    dependencies = { ... },
    opts = { ... },
    config = function(_, opts)
        local lspconfig = require('lspconfig')

        for name, conf in pairs(opts.servers) do
            lspconfig[name].setup {
                on_attach = function(client, bufnr)
                    local _, err = pcall(on_attach, client, bufnr)
                    if err then
                        vim.notify('[on_attach] error: ' .. err, vim.log.levels.ERROR)
                    else
                        vim.notify('[on_attach] ' .. client.name .. ' attached to buffer ' .. bufnr, vim.log.levels.INFO)
                    end
                end,
                settings = conf.settings,
            }
        end
    end,
}

§
Keymaps

We will configure the standard LSP keymaps for the most common queries available under vim.lsp.buf. Most implementations seem to configure these in the on_attach callback, but even if you call these LSP functions in a buffer that has no client attached, they are basically a no-op.

All the keymaps we set in the on_attach callback should be scoped to the buffer the client is attached to. Generally you just have to pass the buffer option to vim.keymap.set, which can be done automatically by defining an auxiliary function:

~/.config/nvim/lua/user/plugins/lspconfig.lua
local on_attach = function(client, bufnr)
    local keymap = function(mode, keys, func, opts)
        opts.buffer = bufnr
        vim.keymap.set(mode, keys, func, opts)
    end

    keymap('n', 'gd', vim.lsp.buf.definition, { desc = 'Go to definition' })
    keymap('n', 'gD', vim.lsp.buf.declaration, { desc = 'Go to declaration' })
    keymap('n', 'gI', vim.lsp.buf.implementation, { desc = 'Go to implementation' })
    keymap('n', 'gy', vim.lsp.buf.type_definition, { desc = 'Go to type definition' })
    keymap('n', 'gr', vim.lsp.buf.references, { desc = 'List references' })

    keymap('n', '<leader>ds', vim.lsp.buf.document_symbol, { desc = 'List document symbols' })
    keymap('n', '<leader>ws', vim.lsp.buf.workspace_symbol, { desc = 'List workspace symbols' })

    keymap('n', 'K', vim.lsp.buf.hover, { desc = 'Show documentation' })
    keymap('n', 'gK', vim.lsp.buf.signature_help, { desc = 'Show signature' })
    keymap('i', '<C-k>', vim.lsp.buf.signature_help, { desc = 'Show signature' })

    keymap('n', '<leader>rn', vim.lsp.buf.rename, { desc = 'Rename symbol' })
    keymap('n', '<leader>ca', vim.lsp.buf.code_action, { desc = 'Code action' })

    keymap('n', '<leader>wa', vim.lsp.buf.add_workspace_folder, { desc = 'Add workspace folder' })
    keymap('n', '<leader>wr', vim.lsp.buf.remove_workspace_folder, { desc = 'Remove workspace folder' })
    keymap(
        'n',
        '<leader>wl',
        function() print(vim.inspect(vim.lsp.buf.list_workspace_folders())) end,
        { desc = 'List workspace folders' }
    )
end

These commands may ask for input with the vim.ui.input API and may output multiple results with an lsp-on-list-handler (by default, the quickfix list). It will be enough for now, but you will see later how we can improve this using Telescope.

§
Auto-completion

Let's tackle the subject of completion. By default, Neovim supports a number of sources for keyword completion, and customizable omni-completion, but they are all pretty limited in term of extensibility.

This section explains how to setup nvim-cmp, a completion plugin that supports the features you would expect like customizing the matching algorithm, the sort order, the formatting of the entries, and showing documentation on hover.

It is also easily extensible, so there is a number of third-party sources available. It can integrate with a snippet engine like LuaSnip, support LSP completions, and even suggestions from AI coding assistants. You can also tune the priorities of each source.

§
nvim-cmp

Let's start with the recommended configuration (with the exception of the cmdline mappings):

~/.config/nvim/lua/user/plugins/cmp.lua
return {
    'hrsh7th/nvim-cmp',
    event = { 'InsertEnter', 'CmdlineEnter' },
    dependencies = {
        'hrsh7th/cmp-buffer',
        'hrsh7th/cmp-cmdline',
        'hrsh7th/cmp-path',
    },
    opts = function()
        local cmp = require('cmp')

        return {
            mapping = cmp.mapping.preset.insert(),
            sources = cmp.config.sources({
                { name = 'nvim_lsp' },
            }, {
                { name = 'buffer' },
            }, {
                { name = 'path' },
            })
        }
    end,
}

The configuration above uses the standard Neovim insert-mode completion keymaps (see :h ins-completion):

  • Previous item: <C-p>.
  • Next item: <C-n>.
  • Confirm selection: <C-y>
  • Abort completion: <C-e>

I prefer to stick with the default keybindings, but there are ways to configure a supertab mapping. Note that you can still trigger the native Neovim completion with keymaps like <C-x><C-f> for file path completion even if you don't have the path source in nvim-cmp.

By default, nvim-cmp prioritizes entries from sources that are defined first (but this is configurable as you will see in the next section). Sources can also be grouped, this is why nvim_lsp is inside a sub-table. The effect of grouping is that nvim-cmp will only show the suggestions from the first group that matches.

If nvim_lsp returns any suggestions, you will not see suggestions based on words in the buffer, though you may get them while writing a comment, since the LSP is unlikely to return any match at this location. This fallback will also happen when no LSP is attached to the buffer.

To enable the integration with the LSP, you have to inform the language server which completion candidates are supported:

~/.config/nvim/lua/user/plugins/lspconfig.lua
return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    dependencies = {
        'williamboman/mason.nvim',
        'hrsh7th/cmp-nvim-lsp',
    },
    opts = { ... },
    config = function(_, opts)
        local lspconfig = require('lspconfig')
        local capabilities = require('cmp_nvim_lsp').default_capabilities()

        for name, conf in pairs(opts.servers) do
            lspconfig[name].setup {
                capabilities = capabilities,
                on_attach = function(client, bufnr)
                    local _, err = pcall(on_attach, client, bufnr)
                    if err then
                        vim.notify('[on_attach] error: ' .. err, vim.log.levels.ERROR)
                    else
                        vim.notify('[on_attach] ' .. client.name .. ' attached to buffer ' .. bufnr, vim.log.levels.INFO)
                    end
                end,
                settings = conf.settings,
            }
        end
    end,
}

§
LuaSnip

A complementary form of completion is snippet expansion. It inserts a pre-defined templates, like a function definition with placeholders for the arguments and the body, with the ability to jump between these placeholders and edit them. This feature relies on a snippet engine to interpret and expand snippets.

The first step is to configure the snippet engine (I prefer LuaSnip, but others are supported):

~/.config/nvim/lua/user/plugins/luasnip.lua
return {
    'L3MON4D3/LuaSnip',
    lazy = true,
    dependencies = { 'rafamadriz/friendly-snippets' },
    keys = {
        { '<C-h>', function() require('luasnip').jump(-1) end, mode = { 'i', 's' } },
        { '<C-l>', function() require('luasnip').jump(1) end,  mode = { 'i', 's' } },
    },
    config = function()
        require('luasnip.loaders.from_vscode').lazy_load()
    end,
}

This configuration defines two keybindings to jump between the fields of the expanded snippet. LSP servers may already provide some snippets, but using a plugin like friendly-snippets adds a lot more. These snippets are written in the VSCode format, so you have to configure the appropriate loader.

nvim-cmp relies on a snippet engine for both snippet expansion, regardless of their source, and for sourcing additional snippets, like our friendly snippets:

~/.config/nvim/lua/user/plugins/cmp.lua
return {
    'hrsh7th/nvim-cmp',
    event = { 'InsertEnter', 'CmdlineEnter' },
    dependencies = {
        'hrsh7th/cmp-buffer',
        'hrsh7th/cmp-cmdline',
        'hrsh7th/cmp-path',
        'saadparwaiz1/cmp_luasnip',
    },
    opts = function()
        local cmp = require('cmp')
        return {
            snippet = {
                expand = function(args)
                    require('luasnip').lsp_expand(args.body)
                end,
            },
            mapping = cmp.mapping.preset.insert(),
            sources = cmp.config.sources({
                { name = 'nvim_lsp' },
                { name = 'luasnip' },
            }, {
                { name = 'buffer' },
            }, {
                { name = 'path' },
            }),
        }
    end,
    config = function(_, opts)
        ...
    end
}

§
Suggestions order

The core of what makes a good autocompletion engine is how relevant the suggestions are. If you open a Rust file with our current configuration, you may have a bunch of unrelated snippets at the top or the suggestion list, called postfix snippets, which means they apply after any expression. It worth understanding why they appear first.

The items are sorted according to a sequence of comparators taking two elements as arguments and returning bool | nil, indicating whether one element is greater than the other, or whether they are considered equal.

The default sort configuration is as follows:

~/.config/nvim/lua/user/plugins/cmp.lua
return {
    ...
    opts = function()
        local cmp = require('cmp')

        return {
            ...,
            sorting = {
                comparators = {
                    cmp.config.compare.offset,
                    cmp.config.compare.exact,
                    --cmp.config.compare.scopes,
                    cmp.config.compare.score,
                    cmp.config.compare.recently_used,
                    cmp.config.compare.locality,
                    cmp.config.compare.kind,
                    --cmp.config.compare.sort_text,
                    cmp.config.compare.length,
                    cmp.config.compare.order,
                },
            },
        }
    end,
}

You may have to look at the source code to understand the purpose of the most obscure comparators:

  • offset: prefers items where the offset at which the input text matches the completion is the lowest.
  • exact: prefers items that contain the input text.
  • scopes: prefers narrower scopes (like local variables to global variables).
  • score: prefers higher scores, computed according to the index of the source and its weight.
  • recently_used: prefers items that were most recently used (based on their label, like into(), regardless of the context).
  • locality: prefers items that appear close to the current cursor location.
  • kind: prefers LSP entities with the smallest ordinal value (like Method before Class, with a few exceptions).
  • sort_text: lexicographic order.
  • length: prefers shorter suggestions.
  • order: prefers smallest index (derived from the original order of suggestions returned by each source).

Here's the source for cmp.config.compare.kind:

function kind(entry1, entry2)
  local kind1 = entry1:get_kind() --- @type lsp.CompletionItemKind | number
  local kind2 = entry2:get_kind() --- @type lsp.CompletionItemKind | number
  kind1 = kind1 == types.lsp.CompletionItemKind.Text and 100 or kind1
  kind2 = kind2 == types.lsp.CompletionItemKind.Text and 100 or kind2
  if kind1 ~= kind2 then
    if kind1 == types.lsp.CompletionItemKind.Snippet then
      return true
    end
    if kind2 == types.lsp.CompletionItemKind.Snippet then
      return false
    end
    local diff = kind1 - kind2
    if diff < 0 then
      return true
    elseif diff > 0 then
      return false
    end
  end
  return nil
end

It applies a penalty to Text items, and ensures Snippets appear first, which explains the behavior with Rust suggestions. Since postfix snippets match everything, they will appear first if there aren't any better suggestions according to the previous comparators. As you complete the input, the other comparators should kick in like recently_used and locality, which should improve the relevance.

§
LSP (plugins)

We are still missing one last key LSP feature: auto-formatting. This is where things get a little bit complicated because the LSP is not the only way to auto-format files, especially for markup languages which have their own set of of formatting tools. With a few plugins, the LSP can be configured as a fallback formatting source, allowing other formatting and linting tools when necessary. We will also install a plugin which prints status information from the LSP.

§
Auto-formatting

The easy way to setup LSP formatting is to define a keymap like we've done previously, but it's not as simple to make it work reliably:

  • Some formatting tools (especially for markup or configuration languages) do not support the LSP.
  • Some language servers like gopls do not include things like organizing imports in the code formatting action.
  • If you have multiple LSP servers attached to the same buffer, you will get a prompt asking you to choose which client should perform the formatting.
  • You may want to disable formatting for specific buffers or file types if it gets in the way.
  • You may want to enable auto-formatting on save, which requires an auto-command.
  • Formatting can mess up the buffer if done asynchronously.
  • Some servers do not support efficient formatting (like only formatting a range of lines and not the entire buffer).

Conform.nvim handles most of these issues:

  • It supports non-LSP code formatters, and can fallback to the LSP.
  • It doesn't replace the whole buffer, which maintains folds and extmarks.
  • It implements range formatting, even when unsupported by the language server.

The base configuration is pretty simple:

~/.config/nvim/lua/user/plugins/conform.lua
return {
    'stevearc/conform.nvim',
    event = { 'BufWritePre' },
    cmd = { 'ConformInfo' },
    keys = {
        {
            '<leader>cf',
            function()
                require('conform').format({ async = true, lsp_fallback = true })
            end,
            mode = '',
            desc = 'Format buffer',
        },
    },
    -- Everything in opts will be passed to setup()
    opts = {
        -- Set up format-on-save
        format_on_save = { timeout_ms = 500, lsp_fallback = true },
    },
    init = function()
        vim.o.formatexpr = 'v:lua.require'conform'.formatexpr()'
    end,
}

Since it uses the builtin autocmd, there isn't any room for customization, like organizing imports before formatting, or toggling auto-formatting on and off for a specific buffer.

To decouple the LSP config and conform.nvim, it is best to set per-buffer variables like autoformat and autoimport using a custom BufWritePre (replacing the default format_on_save option):

~/.config/nvim/lua/user/plugins/conform.lua
return {
    'stevearc/conform.nvim',
    event = { 'BufWritePre' },
    cmd = { 'ConformInfo' },
    keys = {
        {
            '<leader>cf',
            function()
                require('conform').format({ async = true, lsp_fallback = true })
            end,
            mode = '',
            desc = 'Format buffer',
        },
    },
    opts = {},
    init = function()
        local utils = require('user.utils')

        vim.o.formatexpr = 'v:lua.require'conform'.formatexpr()'

        vim.api.nvim_create_autocmd('BufWritePre', {
            desc = 'Format on save',
            pattern = '*',
            group = vim.api.nvim_create_augroup('format_on_save', { clear = true }),
            callback = function(args)
                if not vim.api.nvim_buf_is_valid(args.buf) or vim.bo[args.buf].buftype ~= '' then
                    return
                end

                if vim.b[args.buf].autoimport == true then
                    utils.organizeImports(args.buf)
                end

                if vim.b[args.buf].autoformat ~= false then
                    require('conform').format({
                        buf = args.buf,
                        async = false,
                        timeout_ms = 500,
                        lsp_fallback = true,
                    })
                end
            end,
        })
    end,
}

The organize import code, that we put in a new user.utils module, is a little bit more involved (source: gopls/doc/vim.md):

~/.config/nvim/lua/user/utils.lua
local M = {}

function M.organizeImports(bufnr)
    local params = vim.lsp.util.make_range_params()
    params.context = { only = { 'source.organizeImports' } }
    local result = vim.lsp.buf_request_sync(bufnr, 'textDocument/codeAction', params, 500)
    for cid, res in pairs(result or {}) do
        for _, r in pairs(res.result or {}) do
            if r.edit then
                local enc = (vim.lsp.get_client_by_id(cid) or {}).offset_encoding or 'utf-16'
                vim.lsp.util.apply_workspace_edit(r.edit, enc)
                return
            end
        end
    end
end

return M

With this custom autocmd in place, you can turn off the autoformatting with a command like :lua vim.b.autoformat = false, same for autoimport. You can update them automatically with a FileType autocmd:

~/.config/nvim/lua/user/autocmd.lua
vim.api.nvim_create_autocmd('FileType', {
    group = vim.api.nvim_create_augroup('go', { clear = true }),
    pattern = { 'go' },
    callback = function(args)
        vim.b[args.buf].autoimport = true
    end,
})

You can also update the LSP configuration to support per-server on_attach hooks that set these variables:

~/.config/nvim/lua/user/autocmd.lua
return {
    'neovim/nvim-lspconfig',
    opts = function(_)
        return {
            servers = {
                gopls = {
                    settings = {
                        gopls = {
                            diagnosticsDelay = '500ms',
                            gofumpt = true,
                        },
                    },
                    on_attach = function(_, bufnr)
                        vim.b[bufnr].autoimport = true
                    end,
                },
            },
        }
    end,
    config = function(_, opts)
        local lspconfig = require('lspconfig')
        local capabilities = require('cmp_nvim_lsp').default_capabilities()

        for name, conf in pairs(opts.servers) do
            local attach_cb = on_attach

            if conf.on_attach then
                attach_cb = function(client, bufnr)
                    on_attach(client, bufnr)
                    conf.on_attach(client, bufnr)
                end
            end

            lspconfig[name].setup({
                capabilities = capabilities,
                on_attach = function(client, bufnr)
                    local _, err = pcall(attach_cb, client, bufnr)
                    if err then
                        vim.notify('[LSP on_attach] ' .. err, vim.log.levels.ERROR)
                    else
                        vim.notify('[on_attach] ' .. client.name .. ' attached to buffer ' .. bufnr, vim.log.levels.INFO)
                    end
                end,
                settings = conf.settings,
            })
        end
    end
}

§
Linting

Some languages have additional tools that provide formatting and linting which are not bundled in their main LSP server. The deprecated plugin null-ls.nvim used to expose these tools through a general-purpose LSP server, making the LSP the common denominator. none-ls.nvim is a maintained fork that provides the same features.

The main drawback is that you have to handle multiple language servers attached to the same buffer, which wouldn't work with our generic on_attach hook since the keybindings, for example, do not specify which LSP client to use.

An alternative that seems to be the way to go at the moment for the formatting part is conform.nvim that we've already covered in the previous section. The equivalent for linting is nvim-lint, which is as easy to configure:

~/.config/nvim/lua/user/plugins/lint.lua
return {
    'mfussenegger/nvim-lint',
    event = { 'BufWritePost' },
    opts = {
        linters_by_ft = {
            yaml = { 'yamllint', }
        },
    },
    config = function(_, opts)
        require('lint').linters_by_ft = opts.linters_by_ft
    end,
    init = function()
        vim.api.nvim_create_autocmd('BufWritePost', {
            desc = 'Lint on save',
            pattern = '*',
            group = vim.api.nvim_create_augroup('lint_on_save', { clear = true }),
            callback = function()
                require('lint').try_lint()
            end,
        })
    end,
}

§
fidget

LSP servers may appear stuck while they are starting up or after some actions. The LSP provides a way to send status messages that indicate what the server is doing in the background (for instance, indexing your project). fidget.nvim is a plugin that can display these status messages in a non-intrusive way:

~/.config/nvim/lua/user/plugins/fidget.lua
return {
    'j-hui/fidget.nvim',
    tag = 'v1.4.1',
    lazy = true,
    opts = {
        progress = {
            display = {
                progress_icon = { pattern = 'line', period = 0.7 },
            },
        },
        notification = {
            window = {
                winblend = 0,
            },
        },
    },
}

Don't forget to add it as a dependency of nvim-lspconfig so it is started automatically:

~/.config/nvim/lua/user/plugins/lspconfig.lua
return {
    'neovim/nvim-lspconfig',
    ft = { 'lua', 'rust' },
    dependencies = {
        'williamboman/mason.nvim',
        'hrsh7th/cmp-nvim-lsp',
        'j-hui/fidget.nvim',
    },
    ...,
}

§
Syntax highlighting

Text and UI elements in Vim are associated to highlight groups. A colorscheme maps each highlight group to a set of colors (foreground, background) and styles (italic, underline, etc).

There are three main sources of syntax highlighting in Vim:

  • The "legacy" Vim syntax files based on regexes.
  • TreeSitter parsers which provides a more precise syntax tree for the supported languages.
  • The LSP that also provides some semantic highlights.
  • Plugins that define their own highlight groups.

Customizing the theme is just a matter of finding the name of the highlight group you want to change, and set its style in your colorscheme.

§
Colorscheme

If you don't want to build a colorscheme from scratch, you can choose among the high quality colorschemes like catppuccin that support the standard highlight groups and many plugins. For the DIY approach, you can start by creating a basic colorscheme module:

~/.config/nvim/lua/user/colors.lua
local colors = {
    foreground = '#dbd9ff',
    background = '#0e0d18',
}

local groups = {
    Normal = {
        fg = colors.foreground,
        bg = colors.background
    },
}

return function()
    vim.o.background = 'dark'

    if vim.g.colors_name then
        vim.cmd('highlight clear')
    end

    vim.g.colors_name = 'user'

    if vim.fn.exists('syntax_on') then
        vim.cmd('syntax reset')
    end

    for name, opts in pairs(groups) do
        vim.api.nvim_set_hl(0, name, opts)
    end
end

There are three main steps:

  • highlight clear resets the highlight groups to the default settings.
  • syntax reset resets the colors to their default values.
  • The loop uses nvim_set_hl to set the style for each highlight group based on groups and colors.

Next, create the "real" colorscheme in ~/.config/nvim/colors/, which will just import the previous module:

~/.config/nvim/colors/user.lua
package.loaded['user.colors'] = nil
require('user.colors')()

The first line makes sure the package user.colors is reloaded each time this file is sourced, so you can iterate on it quickly using :luafile colors/user.lua (:colorscheme user will do the same thing under the hood).

You can then enable this colorscheme in the options, along with termguicolors for 24-bit terminal colors:

~/.config/nvim/lua/user/options.lua
vim.opt.termguicolors = true
vim.cmd.colorscheme("user")

Except for the Normal group that we explicitly set, everything else uses the default values. So now you have to find the list of highlight groups:

  • UI: :help highlight-groups.
  • Syntax: :help group-name. You should start with the main groups prefixed by *, since all other highlight groups are linked to them.
  • Diagnostics: :help diagnostic-highlights.
  • Plugins: check the plugin documentation.

You can work iteratively: when you stumble upon some really ugly color, fix it in your colorscheme. If you don't know where it comes from, you can list all the defined highlight groups and their current colors with :highlight (or :hi for short). Alternatively, if you can put your cursor over it, you can run the command :Inspect.

To fix the style of an highlight group, you can:

  • Override the options fg (foreground color), bg (background color), sp (decoration color), and a number of styles like italic, bold, underline, undercurl, etc. :h nvim_set_hl for the full list of options. If you don't specify a color or set it to "none", it will be "transparent" (the color of the first UI element under it).

  • Link the highlight group to another highlight group, which is useful for some plugins like fugitive.vim:

    ~/.config/nvim/lua/user/colors.lua
    local groups = {
        --
        -- fugitive
        --
    
        diffAdded                   = { link = 'DiffAdd' },
        diffChanged                 = { link = 'DiffChanged' },
        diffRemoved                 = { link = 'DiffDelete' },
    }
    

Since we have the power of Lua, we can do interesting things like blending colors:

~/.config/nvim/lua/user/colors.lua
local function hex2rgb(hex)
    return tonumber(hex:sub(2, 3), 16),
        tonumber(hex:sub(4, 5), 16),
        tonumber(hex:sub(6, 7), 16)
end

local function rgb2hex(r, g, b)
    return string.format('#%02X%02X%02X', r, g, b)
end

local function blend(c1, c2, alpha)
    local r1, g1, b1 = hex2rgb(c1)
    local r2, g2, b2 = hex2rgb(c2)

    local r = (1 - alpha) * r1 + alpha * r2
    local g = (1 - alpha) * g1 + alpha * g2
    local b = (1 - alpha) * b1 + alpha * b2

    return rgb2hex(r, g, b)
end

Using these functions, you can add a halo effect to the diagnostics by mixing 10% of the diagnostic color with 90% of the background color:

~/.config/nvim/lua/user/colors.lua
local colors = {
    red = '#e464cb',
    orange = '#fab387',
    yellow = '#9e7ffe',
    cyan = '#52aae6',
}

local groups = {
    --
    -- :help diagnostic-highlights
    --

    DiagnosticVirtualTextError = {
        fg = colors.red,
        bg = blend(colors.red, colors.background, .9),
    },
    DiagnosticVirtualTextWarn  = {
        fg = colors.orange,
        bg = blend(colors.orange, colors.background, .9),
    },
    DiagnosticVirtualTextInfo  = {
        fg = colors.yellow,
        bg = blend(colors.yellow, colors.background, .9),
    },
    DiagnosticVirtualTextHint  = {
        fg = colors.cyan,
        bg = blend(colors.cyan, colors.background, .9),
    },
}

§
TreeSitter

TreeSitter provides a collection of incremental parsers for the most common programming languages. It is a library to build higher-level tools so it's not usable as-is, but there are plugins that provide features based on it. One of them is nvim-treesitter, which among other things provides more detailed syntax highlighting than Neovim's builtin regex-based syntax files.

You can configure it like so (note that you need a C compiler like gcc, clang, or zig):

~/.config/nvim/lua/user/plugins/treesitter.lua
return {
    'nvim-treesitter/nvim-treesitter',
    ft = { 'c', 'cpp', 'go', 'lua', 'rust' },
    build = ':TSUpdate',
    config = function()
        require('nvim-treesitter.configs').setup {
            -- Add languages to be installed here that you want installed for treesitter
            ensure_installed = { 'c', 'cpp', 'go', 'lua', 'python', 'rust', 'tsx', 'typescript', 'vimdoc', 'vim' },

            -- Install parsers synchronously (only applied to `ensure_installed`)
            sync_install = false,

            -- Automatically install missing parsers when entering buffer
            -- Recommendation: set to false if you don't have `tree-sitter` CLI installed locally
            auto_install = false,

            highlight = {
                enable = true,
                disable = function(_, bufnr) return vim.api.nvim_buf_line_count(bufnr) > 10000 end,
                additional_vim_regex_highlighting = false,
            },
        }
    end,
}

It relies on a number of extra highlight groups listed in :h treesitter-highlight-groups, that are either global, like @variable, or language-specific if you append the language name, like @variable.lua:

~/.config/nvim/lua/user/plugins/treesitter.lua
local groups = {
    --
    -- :help treesitter-highlight-groups
    --

    ['@variable']               = {},
    ['@constructor.lua']        = { link = 'Function' },
}

There are other use cases for TreeSitter like indentation, folding, additional text-objects, some of which are still experimental (see: available modules).

§
LSP

The LSP can also be a source of syntax highlighting. It defines semantic tokens, which are designed to provide refinements on top of the language grammar syntax highlighting.

Currently it causes flashes when it gets refreshed, so I opted-out of this feature with the following code:

~/.config/nvim/lua/user/colors.lua
for _, group in ipairs(vim.fn.getcompletion('@lsp', 'highlight')) do
    vim.api.nvim_set_hl(0, group, {})
end

If you choose to keep it active, you can find the available highlight groups with :h lsp-semantic-highlight.

§
UI

The last missing piece in the modern text editing experience is the UI. The most important plugin is telescope.nvim, which provides an interface to quickly filter arbitrary lists of items like files, buffers, diagnostics with a fuzzy finding algorithm. I will cover two additional plugins: heirline.nvim to allow simple customization of the status bar, and which-key.nvim to display the available key mappings.

§
Telescope

Telescope is a general purpose fuzzy finder over lists. It is helpful to go over some terminology:

  • Finders are the program that generate a list of items (list of buffers, list of files in the current directory, etc).
  • Sorters are the functions that order and filter the items according to a prompt.
  • Previewers are the functions that display a preview of the active item.
  • Actions are the operations that can be applied to one or more selected items, usually triggered by a key mapping.
  • Pickers are the builtin functions (presets) that tie together all the previous concepts.

Let's first add the plugin:

~/.config/nvim/lua/user/plugins/telescope.lua
return {
    'nvim-telescope/telescope.nvim',
    cmd = 'Telescope',
    dependencies = {
        'nvim-lua/plenary.nvim',
    },
    config = true,
}

To start a picker, you have to bind it to some key:

~/.config/nvim/lua/user/plugins/telescope.lua
return {
    'nvim-telescope/telescope.nvim',
    cmd = 'Telescope',
    dependencies = {
        'nvim-lua/plenary.nvim',
    },
    keys = function()
        local lazy_telescope = function(builtin)
            return function(...)
                require('telescope.builtin')[builtin](...)
            end
        end
        return {
            { '<leader>fb', lazy_telescope('buffers'),                   desc = 'Find buffers' },
            { '<leader>fd', lazy_telescope('diagnostics'),               desc = 'Find diagnostics' },
            { '<leader>ff', lazy_telescope('git_files'),                 desc = 'Find Git files' },
            { '<leader>fF', lazy_telescope('find_files'),                desc = 'Find files' },
            { '<leader>fg', lazy_telescope('live_grep'),                 desc = 'Find files by content' },
            { '<leader>fh', lazy_telescope('help_tags'),                 desc = 'Find help tags' },
            { '<leader>fo', lazy_telescope('oldfiles'),                  desc = 'Find recently opened files' },
            { '<leader>fw', lazy_telescope('grep_string'),               desc = 'Find word in buffer' },
            { '<leader>f/', lazy_telescope('current_buffer_fuzzy_find'), desc = 'Find fuzzy match in current buffer' },
        }
    end,
    config = true,
}

As we've seen previously, you cannot require('telescope.builtin') directly in the init function because it would load Telescope as soon as this function is invoked, which is when Neovim starts. Each keybinding defined here has to require it lazily, hence the lazy_telescope wrapper.

Note that the Telescope prompt has the same modes as regular Vim text editing. For instance, you can use j and k to move up or down in the list in normal mode. Since the default mode is insert, you may want to add a few movement and editing keybindings for this mode:

~/.config/nvim/lua/user/plugins/telescope.lua
return {
    'nvim-telescope/telescope.nvim',
    cmd = 'Telescope',
    dependencies = {
        'nvim-lua/plenary.nvim',
    },
    keys = ...,
    opts = function()
        local actions = require('telescope.actions')
        return {
            defaults = {
                mappings = {
                    i = {
                        ['<C-n>'] = actions.cycle_history_next,
                        ['<C-p>'] = actions.cycle_history_prev,
                        ['<C-j>'] = actions.move_selection_next,
                        ['<C-k>'] = actions.move_selection_previous,
                    },
                    n = { ['q'] = actions.close },
                },
            },
        }
    end,
}

You may find these builtin keybindings particularly useful:

  • ? (normal) and <C-/> (insert): show the list of keybindings.
  • <Tab>: select / unselect of the focused item (can be used to select multiple items).
  • <C-x> (split), <C-v> (vsplit), <C-t> (tab): open selection with the specified mode.
  • <C-q> send the selected items to the quickfix list.
  • <Esc> (normal) and <C-c>: exit Telescope.

Each picker accepts dedicated options in addition to the parameters from opts.defaults, like custom keybindings, layout options, and themes. For instance, you can hide the preview in the builtin current_buffer_fuzzy_find picker:

~/.config/nvim/lua/user/plugins/telescope.lua
return {
    'nvim-telescope/telescope.nvim',
    cmd = 'Telescope',
    dependencies = {
        'nvim-lua/plenary.nvim',
    },
    keys = function()
        local lazy_telescope = function(builtin)
            return function(...)
                require('telescope.builtin')[builtin](...)
            end
        end
        return {
            ...,
            {
                '<leader>f/',
                function()
                    lazy_telescope('current_buffer_fuzzy_find')(require('telescope.themes').get_dropdown {
                        previewer = false,
                    })
                end,
                desc = 'Find fuzzy match in current buffer'
            },
        }
    end,
    opts = ...,
}

Finally, Telescope supports extensions that provides alternative sorting algorithms, including a fast implementation of fzf that you can configure as follows:

~/.config/nvim/lua/user/plugins/telescope.lua
return {
    'nvim-telescope/telescope.nvim',
    cmd = 'Telescope',
    dependencies = {
        'nvim-lua/plenary.nvim',
        { 'nvim-telescope/telescope-fzf-native.nvim', build = 'make' },
    },
    opts = function()
        local actions = require('telescope.actions')
        return {
            ...,
            extensions = {
                fzf = {
                    fuzzy = true,
                    override_generic_sorter = true,
                    override_file_sorter = true,
                    case_mode = 'smart_case',
                }
            },
        }
    end,
    config = function(_, opts)
        local telescope = require('telescope')
        telescope.setup(opts)
        telescope.load_extension('fzf')
    end,
}

You should also run :checkhealth telescope which will indicate the status of the fzf extension and other possible optimizations, like installing rg and fd to improve the performance of the grep and file search pickers.

§
Heirline

If you ever attempted to change the default Neovim statusline, you may have encountered the mesmerizing printf-inspired format strings you can use in the statusline option. Here is what it looks like for the default status line:

%<%f\ %h%m%r%=%-14.(%l,%c%V%)\ %P
 ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑  ↑ ↑ ↑ ↑ ↑
 | | | | | | | | | | |  | | | | |
 | | | | | | | | | | |  | | | | Scroll percentage
 | | | | | | | | | | |  | | | Space
 | | | | | | | | | | |  | | Group end
 | | | | | | | | | | |  | Virtual column number
 | | | | | | | | | | |  Column number
 | | | | | | | | | | Line number
 | | | | | | | | | Group start (min width)
 | | | | | | | | 14 chars min-width
 | | | | | | | Left justify
 | | | | | | Section separator (fill space)
 | | | | | [RO] flag
 | | | | [+] flag
 | | | [help] flag
 | | Space
 | Relative file path
 Truncate here when it's too long

There are ways to insert the result of an expression so you can really put arbitrary things in there, add custom highlight groups, and even change it on the fly, except it is an absolute nightmare to deal with.

Heirline provide a high-level API based on reusable components to generate these format strings for you. Here is the configuration that matches the default status line:

~/.config/nvim/lua/user/plugins/heirline.lua
return {
    'rebelot/heirline.nvim',
    opts = function()
        local conditions = require('heirline.conditions')
        local hutils = require('heirline.utils')

        return {
            opts = {
                colors = {
                    StatusLine = hutils.get_highlight('StatusLine'),
                    StatusLineNC = hutils.get_highlight('StatusLineNC'),
                },
            },
            statusline = {
                hl = function()
                    if conditions.is_active() then
                        return 'StatusLine'
                    end
                    return 'StatusLineNC'
                end,
                {
                    provider = '%<%f %h%m%r%=%-14.(%l,%c%V%) %P'
                },
            },
        }
    end,
}

The first thing you may want to improve is %f, which doesn't work exactly like in the builtin status line since it doesn't shorten intermediate directories in long paths, and poorly handles relative paths (going to a different location with the LSP leaves the full absolute path).

The cookbook contains a lot of examples from which the following snippets are largely inspired. We can implement the FilePath provider as follows:

~/.config/nvim/lua/user/plugins/heirline.lua
local FilePath = function()
    local conditions = require('heirline.conditions')
    return {
        init = function(self)
            self.filename = vim.api.nvim_buf_get_name(0)
        end,
        provider = function(self)
            -- First, trim the pattern relative to the current
            -- directory. For other options, see `:h
            -- filename-modifiers`.
            local filename = vim.fn.fnamemodify(self.filename, ':.')
            if filename == '' then return '[No Name]' end
            -- Now, if the filename would occupy more than 1/4th of
            -- the available space, we trim the file path to its
            -- initials.
            if not conditions.width_percent_below(#filename, 0.25) then
                filename = vim.fn.pathshorten(filename)
            end
            return filename
        end,
    }
end

And use it for the status line:

~/.config/nvim/lua/user/plugins/heirline.lua
return {
    'rebelot/heirline.nvim',
    opts = function()
        local conditions = require('heirline.conditions')
        local hutils = require('heirline.utils')

        return {
            opts = {
                colors = {
                    StatusLine = hutils.get_highlight('StatusLine'),
                    StatusLineNC = hutils.get_highlight('StatusLineNC'),
                },
            },
            statusline = {
                hl = function()
                    if conditions.is_active() then
                        return 'StatusLine'
                    end
                    return 'StatusLineNC'
                end,
                {
                    provider = '%<'
                },
                FilePath(),
                {
                    provider = ' %h%m%r%=%-14.(%l,%c%V%) %P'
                },
            },
        }
    end,
}

We can continue with few additional providers like the git branch name:

~/.config/nvim/lua/user/plugins/heirline.lua
local GitBranch = function()
    local conditions = require('heirline.conditions')
    return {
        condition = conditions.is_git_repo,

        init = function(self)
            ---@diagnostic disable-next-line:undefined-field
            self.status_dict = vim.b.gitsigns_status_dict
        end,

        -- git branch name
        provider = function(self)
            local branch = self.status_dict.head
            if branch == nil or branch == '' then
                branch = 'master'
            end
            return '   ' .. branch
        end,
    }
end

The diagnostics info:

~/.config/nvim/lua/user/plugins/heirline.lua
local Diagnostics = function()
    local conditions = require('heirline.conditions')
    return {
        condition = conditions.has_diagnostics,
        update = { 'DiagnosticChanged', 'BufEnter', 'WinEnter', 'WinLeave' },

        init = function(self)
            self.errors = #vim.diagnostic.get(nil, {
                severity = vim.diagnostic.severity.ERROR,
            })
            self.warnings = #vim.diagnostic.get(nil, {
                severity = vim.diagnostic.severity.WARN,
            })
            self.hints = #vim.diagnostic.get(nil, {
                severity = vim.diagnostic.severity.HINT,
            })
            self.info = #vim.diagnostic.get(nil, {
                severity = vim.diagnostic.severity.INFO,
            })
        end,

        {
            provider = ' ',
        },
        {
            condition = function(self)
                return self.errors > 0
            end,
            provider = function(self)
                return '  ' .. self.errors
            end,
            hl = function()
                return conditions.is_active() and { fg = 'diag_error' } or {}
            end,
        },
        {
            condition = function(self)
                return self.warnings > 0
            end,
            provider = function(self)
                return '  ' .. self.warnings
            end,
            hl = function()
                return conditions.is_active() and { fg = 'diag_warn' } or {}
            end,
        },
        {
            condition = function(self)
                return self.info > 0
            end,
            provider = function(self)
                return '  ' .. self.info
            end,
            hl = function()
                return conditions.is_active() and { fg = 'diag_info' } or {}
            end,
        },
        {
            condition = function(self)
                return self.hints > 0
            end,
            provider = function(self)
                return '  ' .. self.hints
            end,
            hl = function()
                return conditions.is_active() and { fg = 'diag_hint' } or {}
            end,
        },
    }
end

The names of the LSP server(s) attached to the buffer:

~/.config/nvim/lua/user/plugins/heirline.lua
local LspServer = function()
    local conditions = require('heirline.conditions')
    return {
        condition = conditions.lsp_attached,
        update    = { 'LspAttach', 'LspDetach', 'WinEnter', 'WinLeave' },
        provider  = function()
            local names = {}
            for _, server in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do
                table.insert(names, server.name)
            end
            return ' ' .. table.concat(names, ', ') .. '  '
        end,
    }
end

And you can add them to the main config:

~/.config/nvim/lua/user/plugins/heirline.lua
return {
    'rebelot/heirline.nvim',
    opts = function()
        local conditions = require('heirline.conditions')
        local hutils = require('heirline.utils')

        return {
            opts = {
                colors = {
                    StatusLine = hutils.get_highlight('StatusLine'),
                    StatusLineNC = hutils.get_highlight('StatusLineNC'),
                    diag_error = hutils.get_highlight('DiagnosticError').fg,
                    diag_warn = hutils.get_highlight('DiagnosticWarn').fg,
                    diag_info = hutils.get_highlight('DiagnosticInfo').fg,
                    diag_hint = hutils.get_highlight('DiagnosticHint').fg,
                },
            },
            statusline = {
                hl = function()
                    if conditions.is_active() then
                        return 'StatusLine'
                    end
                    return 'StatusLineNC'
                end,
                {
                    provider = '%<'
                },
                FilePath(),
                {
                    provider = '%( %h%m%r%)'
                },
                GitBranch(),
                Diagnostics(),
                {
                    provider = '  %=',
                },
                LspServer(),
                {
                    provider = '%-11.(%l,%c%V%) %P'
                },
            }
        }
    end,
}

Note that default StatusLine styling reverses the colors, so you may have to change it in your colorscheme. Additionally, the colors should be evaluated again each time the colorscheme changes, which can be implemented using an autocommand:

~/.config/nvim/lua/user/plugins/heirline.lua
return {
    'rebelot/heirline.nvim',
    opts = function()
        local conditions = require('heirline.conditions')
        local hutils = require('heirline.utils')

        local get_colors = function()
            return {
                StatusLine = hutils.get_highlight('StatusLine'),
                StatusLineNC = hutils.get_highlight('StatusLineNC'),
                diag_error = hutils.get_highlight('DiagnosticError').fg,
                diag_warn = hutils.get_highlight('DiagnosticWarn').fg,
                diag_info = hutils.get_highlight('DiagnosticInfo').fg,
                diag_hint = hutils.get_highlight('DiagnosticHint').fg,
            }
        end

        vim.api.nvim_create_autocmd("ColorScheme", {
            group = vim.api.nvim_create_augroup("Heirline", { clear = true }),
            desc = "Refresh heirline colors",
            callback = function()
                hutils.on_colorscheme(get_colors)
            end,
        })

        return {
            opts = {
                colors = get_colors(),
            },
            statusline = {
                ...,
            }
        }
    end,
}

§
Which key

Which-key.nvim, inspired by the plugin of the same name in the doomed world of Emacs, displays the available keybindings when you start to press the leading keys. It can also show the values of the registers when you press ", the marks when you press ' or `, and the list of spelling suggestions after you press z=, and more.

Since it relies on the desc attributes that we've set to all our keybindings, it is pretty easy to configure:

~/.config/nvim/lua/user/plugins/which-key.lua
return {
    'folke/which-key.nvim',
    event = "VeryLazy",
    init = function()
        vim.o.timeout = true
        vim.o.timeoutlen = 300
    end,
    config = function(_, opts)
        local wk = require("which-key")
        wk.setup(opts)
        wk.register({
            ["g"] = { name = "+goto" },
            ["]"] = { name = "+next" },
            ["["] = { name = "+prev" },
            ["<leader>b"] = { name = "+buffer" },
            ["<leader>c"] = { name = "+code" },
            ["<leader>d"] = { name = "+diagnostics" },
            ["<leader>f"] = { name = "+find" },
            ["<leader>g"] = { name = "+git" },
            ["<leader>r"] = { name = "+refactor" },
            ["<leader>w"] = { name = "+workspace" },
        })
    end,
}

Note that the group names don't seem to work with buffer-local keybindings at the moment (they stay labeled with the default +prefix instead of the defined name). Hopefully that will be fixed in a future release.

§
Going further

The purpose of this article was to show you how to configure the core plugins so you can have a basis you can extend yourself. There would be a lot more to cover, but it is now a matter of preference, and you should be able to install and configure any other plugins on your own.

The best way to see the most popular plugins in action is to try one of the most configurable Neovim distributions like LazyVim, but here are some ideas to further improve the editing experience:

Happy Neovim-ing!