Build your first Neovim configuration in lua

Last updated: 2024-03-20

Neovim is a tool both powerful and extensible. With some effort it can do more than just modify text in a file. Today I hope I can teach you enough about Neovim's lua api to be able to build your own configuration.

We will create a configuration file called init.lua, add a couple of plugins and I'll tell you how to make your own commands.

This tutorial is meant for people totally new to Neovim. If you already have a configuration written in vimscript and want to migrate to lua, you might find this other article more useful: Everything you need to know to configure neovim using lua.

Some advice

Before we start, I suggest you install the latest stable version of Neovim. You can go to the release page in the github repository and download it from there. From now on I will assume you are using version 0.8 or greater.

If you don't feel comfortable using Neovim as an editor, follow the tutorial that comes bundled with it. You can start it using this command in the terminal.

nvim +Tutor

I will assume you know all the features Tutor teaches.

The entry point

First things first, we need to create a configuration file, the famous init.lua. And where that might be? Well, it depends on your operating system and also your environment variables. I can tell you a way of creating it using Neovim, that way we don't have to worry about those details.

Fun fact: Some articles online call the configuration file vimrc. That is the name it has in Vim.

For this task we won't be using lua, we'll use the language created specifically for Vim: vimscript.

Let's open Neovim and execute this command.

:call mkdir(stdpath("config"), "p")

It'll create the folder where the configuration file needs to be. If you want to know what folder it created use this.

:echo stdpath("config")

Now we are going to edit the configuration file.

:exe "edit" stdpath("config") . "/init.lua"

After doing that we'll be in a "blank page". At this point the file doesn't exists on the system just yet. We need to save it with this command.

:write

Once the file actually exists we can edit it anytime we want using this.

:edit $MYVIMRC

If you are the kind of person that likes to automate things in scripts you'll be happy to know you can do all of it with one command.

nvim --headless -c 'call mkdir(stdpath("config"), "p") | exe "edit" stdpath("config") . "/init.lua" | write | quit'

Editor settings

To access the editor's setting we need to use the global variable vim. Okay, more than a variable this thing is a module, you'll find all sorts of utilities in there. Right now we are going to focus on the opt property, with it we can modify all 351 options Neovim has (in version 0.7).

This is the syntax you should follow.

vim.opt.option_name = value

Where option_name can be anything in this list. And value must be whatever that option expects.

You can see the list in Neovim using :help option-list.

One thing you should know is that every option has a scope. Some options are global, some only apply in the current window or file. The scope of every option is mentioned in their help page. To navigate to the help page of an option follow this pattern.

:help 'option_name'

Useful options

This option expects a boolean value. This means it can only have two possible values: true or false. If we assign true we enable it, false does the opposite.

When we enable number Neovim starts showing the line number in the gutter.

vim.opt.number = true

Neovim (and Vim) can let you use the mouse for some things, like select text or change the size of window. mouse expects a data type called a string (a piece of text wrapped in quotes) with a combination of modes. We are not going to worry about those modes now, we can just enable it for every mode.

vim.opt.mouse = 'a'

With this we can tell Neovim to ignore uppercase letters when executing a search. For example, if we search the word two the results can contain any variations like Two, tWo or two.

vim.opt.ignorecase = true

Makes our search ignore uppercase letters unless the search term has an uppercase letter. Most of the time this is used in combination with ignorecase.

vim.opt.smartcase = true

Highlights the results of the previous search. It can get annoying really fast, this is how we disable it.

vim.opt.hlsearch = false

Makes the text of long lines always visible. Long lines are those that exceed the width of the screen. The default value is true.

vim.opt.wrap = true

Preserve the indentation of a virtual line. These "virtual lines" are the ones only visible when wrap is enabled.

vim.opt.breakindent = true

The amount of space on screen a Tab character can occupy. The default value is 8. I think 2 is fine.

vim.opt.tabstop = 2

Amount of characters Neovim will use to indent a line. This option influences the keybindings << and >>. The default value is 8. Most of the time we want to set this with same value as tabstop.

vim.opt.shiftwidth = 2

Controls whether or not Neovim should transform a Tab character to spaces. The default value is false.

vim.opt.expandtab = false

There are a few other things in the vim module we can use to modify variables, but we have other things to do right now. I talk about this topic in more detail here: Configuring Neovim - Editor Settings.

Keybindings

Because Neovim clearly doesn't have enough, we need to create more. To do it we need to learn about vim.keymap.set. Here is a basic usage example.

vim.keymap.set('n', '<space>w', '<cmd>write<cr>', {desc = 'Save'})

After executing this, the sequence Space + w will call the write command. Basically, we can save changes made to a file with Space + w.

Now let me explain vim.keymap.set parameters.

vim.keymap.set({mode}, {lhs}, {rhs}, {opts})

The leader key

When creating keybindings we can use the special sequence <leader> in the {lhs} parameter, it'll take the value of the global variable mapleader.

So mapleader is a global variable in vimscript that can be string. For example.

vim.g.mapleader = ','

After defining it we can use it as a prefix in our keybindings.

vim.keymap.set('n', '<leader>w', '<cmd>write<cr>')

This will make , + w save the current file.

What happens if we don't define it? The default value is \, clearly not the best choice. I can recommend using the space key as leader. Like this.

vim.g.mapleader = ' '

Mappings

I'll show you just a few keybindings that you might find useful.

The default behavior in Neovim (and Vim) doesn't take into account the system clipboard. It has its own mechanism to store text. When we copy something using the y keybinding that text goes to an internal register. I prefer to keep it that way, and what I do is create dedicated bindings to interact with the clipboard.

Copy to clipboard.

vim.keymap.set({'n', 'x'}, 'gy', '"+y')

Paste from clipboard.

vim.keymap.set({'n', 'x'}, 'gp', '"+p')

When we delete text in normal mode or visual mode using c, d or x that text goes to a register. This affects the text we paste with the keybinding p. What I do is modify x and X to delete text without changing the internal registers.

vim.keymap.set({'n', 'x'}, 'x', '"_x')
vim.keymap.set({'n', 'x'}, 'X', '"_d')

The lower case x will delete one character in normal mode, in visual mode it will delete the current selection. Upper case X will act like d.

vim.keymap.set('n', '<leader>a', ':keepjumps normal! ggVG<cr>')

Plugin manager

We are going to use lazy.nvim. This plugin manager allows for "simple" configurations but also supports splitting your plugin config in modules. Today I'll just show some basic usage examples.

First step is to install it from github. It just so happens we can do this using lua. In lazy.nvim's documentation they show us how to do it.

local lazy = {}

function lazy.install(path)
  if not vim.loop.fs_stat(path) then
    print('Installing lazy.nvim....')
    vim.fn.system({
      'git',
      'clone',
      '--filter=blob:none',
      'https://github.com/folke/lazy.nvim.git',
      '--branch=stable', -- latest stable release
      path,
    })
  end
end

function lazy.setup(plugins)
  if vim.g.plugins_ready then
    return
  end

  -- You can "comment out" the line below after lazy.nvim is installed
  lazy.install(lazy.path)

  vim.opt.rtp:prepend(lazy.path)

  require('lazy').setup(plugins, lazy.opts)
  vim.g.plugins_ready = true
end

For the moment this piece of code doesn't do anything. They are functions waiting to be called. Why do this? Is my personal preference. This way I can isolate the boilerplate needed to get the plugin manager working.

Now we are free to place our configuration. We need to specify the path where our plugins are going to live, that is lazy.path. If you want to configure lazy.nvim itself, use the variable lazy.opts (I don't do anything with it so for me is an empty table). Finally we add the list of plugins as an argument to lazy.setup.

lazy.path = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
lazy.opts = {}

lazy.setup({
  ---
  -- List of plugins
  ---
})

Notice in lazy.path we use stdpath('data'), this will return the path to Neovim's data folder. So now we don't need to worry changing our paths depending on the operating system, Neovim will do that for us. If you want to inspect the path, use this command.

:echo stdpath('data') . '/lazy/lazy.nvim'

Now let's download a plugin, a colorscheme to make Neovim look better. We are going to add this in lazy.setup.

{'folke/tokyonight.nvim'},

This is the minimum data lazy.nvim needs to download a plugin from github. Which is just the name of the user in github and the name of the repository.

Your configuration should look like this.

lazy.path = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
lazy.opts = {}

lazy.setup({
  {'folke/tokyonight.nvim'},
})

Now let's add the code to apply the theme. Add the end of the file put this.

vim.opt.termguicolors = true
vim.cmd.colorscheme('tokyonight')

We enable termguicolors so Neovim can show the "best" version of the colorscheme. Each colorscheme can have two versions: one that works for terminals which only support 256 colors and another that specifies colors in hexadecimal code (has way more colors).

We tell Neovim which theme we want using the colorscheme command. And yes, it looks like a lua function (it is). But under the hood is executing this vim command.

colorscheme tokyonight

We save these changes and restart Neovim, to trigger the download of lazy.nvim. When Neovim starts it should show a message telling us is cloning the plugin manager. After it's done another window will show up, it'll tell us the progress of the plugins download. After plugins are installed they will be loaded.

Plugin configuration

Each plugin author has the freedom to create the configuration method they want. But then how do we know what to do? We have to rely on the documentation the plugin provides, we have no other choice.

Most plugins have a file called README.md in their repository, github is kind enough to render that file in the main page. It's the first place you should look for configuration instructions.

If for some reason the README doesn't have the information we are after, look for a folder called doc. Inside there should be a txt file, this is the help page. We can read it using Neovim executing the command :help name-of-file.

Conventions of lua plugins

Lucky for us a huge amount of plugins written in lua follow a certain pattern. They use a function called .setup, and that function expects a lua table with some options. If there is something you should learn about the syntax of lua is how to create tables.

Let's configure a plugin. For this example I'll use lualine, a plugin that can give us a good looking statusline. First step, add nvim-lualine/lualine.nvim to the list of plugins.

lazy.setup({
  {'folke/tokyonight.nvim'},
  {'nvim-lualine/lualine.nvim'},
})

In lualine's repository we can find a doc folder and inside there is a file called lualine.txt. We can read it in Neovim using this.

:help lualine

This documentation shows we can make the plugin work just by calling setup on the lualine module. Like this.

require('lualine').setup()

Add that to the end of your configuration.

If we save the changes and restart Neovim, lazy.nvim will install the plugin and apply the default configuration.

But now lets pretend we want to change some options. For example, say we hate icons, we want them gone. In the documentation there is a section called lualine-Default-configuration, in there I can see some code that says icon_enabled = true. Great, let's copy all the necessary properties to modify that.

require('lualine').setup({
  options = {
    icons_enabled = false,
  }
})

We can save the changes and reload the config using :source $MYVIMRC.

Now we don't like "component separators", how do we get rid of them? Check the docs again, there is a section lualine-Disabling-separators, it shows this.

options = { section_separators = '', component_separators = '' }

Looks promising but we should not copy/paste that code as is. We need to really read it. It shows an options property, we already have one of those, what we should do is add the new properties to the thing we have.

require('lualine').setup({
  options = {
    icons_enabled = false,
    section_separators = '',
    component_separators = ''
  }
})

Save and reload to verify the changes.

So... what did we learned? To configure some plugins we need know: how to navigate to the documentation and the syntax used to create lua tables.

Vimscript plugins

There are a lot of useful plugins written in vimscript. Most of them we can configure modifying global variables. In lua we change global variables of vimscript using vim.g.

Did you know Neovim has a file explorer? Yeah, it's a plugin that comes bundled in Neovim. We can use it with the command :Lexplore. It is written in vimscript, so there is no .setup function. To know how to configure it we need to check the documentation.

:help netrw

If you check the table of content in the help page, you'll notice a section called netrw-browser-settings. Once there we get a list of variables and their descriptions. Let's focus on the ones that start with g:.

For example, if we want to hide the help text in the banner we use netrw_banner.

vim.g.netrw_banner = 0

Another thing we can do is change the size of the window. For that we need netrw_winsize.

vim.g.netrw_winsize = 30

That's it... well no, there are more variables, but like this is the basic stuff you need to know. You check the docs, see the thing you want to change and use vim.g.

Bonus content

Recursive mappings

If you are familiar with the word "recursive" you might be able to guess what kind of consequences they can have. If not, let me try to explain with an example.

Let's say we have a keybinding that opens the file explorer.

vim.keymap.set('n', '<F2>', '<cmd>Lexplore<cr>')

Now let's add a recursive keybinding that uses F2.

vim.keymap.set('n', '<space><space>', '<F2>', {remap = true})

If we press Space twice the file explorer will show up. But if we change remap to false then nothing happens.

With recursive mappings we can use previous keybindings in the {rhs} argument. Those keybindings could be created by us or by other plugins. Non recursive mappings only give us access to keybindings defined by Neovim.

Probably the only time when you want a recursive mapping is when you want to use a feature defined by a plugin.

Why is it that recursive mappings can cause conflicts? Consider this.

vim.keymap.set('n', '*', '*zz')

Notice here we are using * in {lhs} and also {rhs}. If we make this recursive we create an endless cycle. Neovim will try to call * and never executes zz.

User commands

Yes, we can create our own commands. In lua we use this function.

vim.api.nvim_create_user_command({name}, {command}, {opts})

So we could create a function that "reloads" our configuration.

vim.api.nvim_create_user_command('ReloadConfig', 'source $MYVIMRC', {})

User commands are a fairly advance topic so if you want to know more details you can check the documentation.

:help nvim_create_user_command()
:help user-commands

Autocommands

With autocommands we can execute actions when Neovim triggers an event. You can check the complete list of events with this command :help events.

We can create autocommands with this function.

vim.api.nvim_create_autocmd({event}, {opts})

Here is an example. I'll create a group called user_cmds and add two autocommands to it.

local augroup = vim.api.nvim_create_augroup('user_cmds', {clear = true})

vim.api.nvim_create_autocmd('FileType', {
  pattern = {'help', 'man'},
  group = augroup,
  desc = 'Use q to close the window',
  command = 'nnoremap <buffer> q <cmd>quit<cr>'
})

vim.api.nvim_create_autocmd('TextYankPost', {
  group = augroup,
  desc = 'Highlight on yank',
  callback = function(event)
    vim.highlight.on_yank({higroup = 'Visual', timeout = 200})
  end
})

Creating the group is optional by the way.

The first autocommand will make a keymap q to close the current window, but only if the filetype is help or man. In this example I'm using vimscript but I could have done it with a lua function.

The second autocommand will highlight the text we copy using y. If you want to test the effect try copying a line using yy.

To know more about autocommands in general check the documentation.

:help autocmd-intro

User modules

We can use lua modules to split our configuration into smaller pieces.

A common convention is to put every module we create into a single folder. We do this to avoid any potential conflict with a plugin. Lots of people call this module user (you can use another name). To make this module we need to create a couple of folders inside our config folder. First create a lua folder, and inside that create a user folder. You can do it with this command.

:call mkdir(stdpath("config") . "/lua/user", "p")

Inside /lua/user we create our lua scripts.

Let's pretend we have one called settings.lua. Neovim doesn't know it exists, it won't be executed automatically. We need to call it from init.lua.

require('user.settings')

If you want to know more details about require's behavior inside Neovim checkout.

:help lua-require

The require function

There is something you should know about require, it only executes code once. What does mean?

Consider this code.

require('user.settings')
require('user.settings')

In here the script settings.lua will only be executed once. If you want to create plugins or create a feature that depends on a plugin, this behavior is good. The bad news is if want to use :source $MYVIMRC to reload our config the results might not be what we expect.

There is a very simple hack we can do to make it source friendly. We can empty require's cache before using it. Like this.

local load = function(mod)
  package.loaded[mod] = nil
  require(mod)
end

load('user.settings')
load('user.keymaps')

If we do this in init.lua the source command will be able to execute all the files in our config.

WARNING. Be careful with this. Some plugins might act weird if you configure them twice. What do I mean? If we use source and call the .setup function of a plugin a second time it might have unexpected effects.

init.lua

If we apply (almost) everything we learned here in a single configuration file this would be the result.

What's next?

Create a development environment that feels comfortable. Find out what plugins the Neovim community likes to use.

I know is difficult to start from scratch so here is a "starter template" you can check, or even use as your own base configuration.

Another great option is kickstart.nvim. The author of this configuration has a video that explain every line of code in it. It's a good resource to learn about popular plugins and how to configure them.

And if you want, you can check my personal configuration.

Conclusion

Now we know how to configure some basic options in Neovim. We learned how to create our very own keybindings. We know how to get plugins from github. We manage to configure a couple of plugins, a lua plugin and one written in vimscript. We took a brief look at some advance topics like recursive mappings, user commands, autocommands and lua modules.

I'd say you have everything you need to start exploring plugins, check other people's config and learn from them.


Have any question? Feel free to leave a comment in one of these platform where I have shared this:

You can reach out to me on social media:

Thank you for reading. If you find this article useful and want to support my efforts, buy me a coffee ☕

Buy Me A Coffee