Scripting Neovim with Lua
Neovim ships with a first-class Lua interpreter — embedded LuaJIT — and a rich API that makes it the editor of choice for developers who want programmable text editing without fighting their tool. This tutorial walks you through scripting Neovim with Lua, from your first init.lua to working with buffers, windows, autocmds, and keymaps.
Why Script Neovim with Lua?
Vimscript served Vi well for decades, but Lua offers concrete advantages inside Neovim:
- Speed — LuaJIT executes Lua code much faster than Vimscript in most benchmarks
- Modules —
require()gives you a proper package system for organising code - Standard library — tables, coroutines, and string manipulation are native
- Interoperability — call Vimscript functions, set options, and interact with buffers all from Lua
If you’ve ever written a Vimscript function to automate a repetitive editing task, you’ll find the equivalent in Lua far more expressive and maintainable.
Your First init.lua
Vim’s traditional config file is init.vim. Neovim also looks for init.lua at the config path (typically ~/.config/nvim/init.lua). Create it now:
-- ~/.config/nvim/init.lua
print("Hello from Lua!")
Restart Neovim and you should see Hello from Lua! printed at startup. That confirms Lua is executing.
Every Lua config you write runs in the context of Neovim’s embedded Lua environment, which provides the global vim object. This is your primary interface to everything Neovim — options, buffers, windows, autocmds, and more.
The vim Global Object
The vim global is the core of Neovim’s Lua API. It exposes several namespaces:
vim.o -- global options (:set)
vim.go -- global options (:setglobal)
vim.bo -- buffer-local options (:setlocal on buffer)
vim.wo -- window-local options (:setlocal on window)
vim.g -- global variables (g:)
vim.b -- buffer variables (b:)
vim.w -- window variables (w:)
vim.t -- tabpage variables (t:)
vim.v -- predefined Vim variables (v:true, v:count, etc.)
vim.env -- environment variables
vim.fn -- call Vimscript functions
vim.api -- raw Neovim API functions
vim.cmd -- execute Ex commands
vim.keymap -- manage key mappings
Setting Options
You can set options directly through vim.o:
vim.o.number = true -- show line numbers
vim.o.expandtab = true -- use spaces, not tabs
vim.o.shiftwidth = 2 -- indent by 2 spaces
vim.o.wrap = false -- no line wrapping
For buffer and window-local options:
vim.bo.expandtab = true -- buffer-local (current buffer only)
vim.wo.number = true -- window-local (current window only)
The vim.opt accessor (Neovim 0.8+) gives you convenient methods:
vim.opt.expandtab = true -- set option
vim.opt.shiftwidth:get() -- get current value
vim.opt.shiftwidth:append(4) -- :set sw+=4
vim.opt.shiftwidth:remove(2) -- :set sw-=2
vim.opt_global.expandtab = true -- :setglobal
vim.opt_local.expandtab = true -- :setlocal
Working with Variables
Global, buffer, window, and tabpage variables map directly:
vim.g.my_plugin_config = { theme = "dark", debug = false }
vim.b.current_language = "lua"
-- Access predefined variables
print(vim.v.count) -- count register (from a count before an operator)
print(vim.v.true) -- always true — useful in expressions
print(vim.v.null) -- null value
Environment variables are accessed via vim.env:
vim.env.HOME -- your home directory
vim.env.PATH -- the PATH variable
A common pattern is using vim.env to set the runtime path for plugins written in Lua:
vim.env.XDG_CONFIG_HOME = vim.env.HOME .. "/.config"
Executing Vimscript
You don’t have to convert everything at once. vim.cmd executes Vimscript:
vim.cmd([[
set wrap
set linebreak
]])
Or mix Lua and Vimscript:
vim.cmd("set " .. vim.o.columns .. " columns")
To call Vimscript functions and get their return values:
local result = vim.fn.printf("Hello, %s!", "world")
local line_count = vim.fn.line("$")
local cwd = vim.fn.getcwd()
vim.fn is the equivalent of Vimscript’s built-in functions. You can also call autoload functions:
vim.fn["myplugin#setup"]({ theme = "dark" })
Keymaps
The modern way to set keymaps is vim.keymap.set (Neovim 0.7+):
-- Normal mode keymap
vim.keymap.set('n', '<leader>ff', ':FzfLua files<CR>', { silent = true })
-- Insert mode keymap
vim.keymap.set('i', '<C-u>', '<C-o>u', { desc = 'Undo in insert mode' })
-- Lua function as the rhs
vim.keymap.set('n', '<leader>cc', function()
local line = vim.api.nvim_get_current_line()
print(line)
end, { desc = 'Print current line' })
-- Buffer-local keymap
vim.keymap.set('n', '<leader>d', vim.lsp.buf.definition, {
buffer = bufnr,
desc = 'Go to definition',
})
Options include:
silent— don’t echo the commandnoremap— don’t recursively resolve the mappingdesc— human-readable description (Neovim 0.10+)buffer— make the mapping buffer-local
To delete a keymap:
vim.keymap.del('n', '<leader>ff')
Autocommands
Autocommands let you run code in response to events like buffer writes, window events, or file loads. The modern API (Neovim 0.7+) uses vim.api.nvim_create_autocmd:
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.lua",
callback = function(args)
-- args.buf is the buffer number that triggered the event
print("Saving buffer " .. args.buf)
end,
desc = "Called before writing a Lua file",
})
Group your autocmds in an augroup for clean management:
local mygroup = vim.api.nvim_create_augroup("my_config", { clear = true })
vim.api.nvim_create_autocmd("TextYankPost", {
group = mygroup,
callback = function()
vim.highlight.on_yank({ higroup = "IncSearch", timeout = 200 })
end,
desc = "Highlight yanked text briefly",
})
vim.api.nvim_create_autocmd("TermOpen", {
group = mygroup,
pattern = "term://*",
callback = function(args)
vim.api.nvim_buf_set_keymap(args.buf, "t", "<Esc>", "<C-\\><C-n>", {
noremap = true,
silent = true,
})
end,
desc = "Set terminal escape key",
})
Delete autocmds by ID or augroup by name:
vim.api.nvim_del_autocmd(autocmd_id)
vim.api.nvim_del_augroup("my_config")
Buffers
Every open file is a buffer. The API gives you fine-grained control:
local bufnr = vim.api.nvim_get_current_buf() -- get current buffer number
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) -- get all lines
local name = vim.api.nvim_buf_get_name(bufnr) -- get full path
local ft = vim.api.nvim_buf_get_filetype(bufnr) -- get filetype
-- Modify buffer content
vim.api.nvim_buf_set_lines(bufnr, 0, 3, false, {
"First line",
"Second line",
"Third line",
})
-- Set filetype
vim.api.nvim_buf_set_filetype(bufnr, "lua")
-- Create a new scratch buffer
local new_buf = vim.api.nvim_create_buf(false, true) -- listed=false, scratch=true
vim.api.nvim_buf_set_name(new_buf, "scratch")
vim.api.nvim_buf_set_lines(new_buf, 0, -1, false, { "Hello, scratch buffer!" })
Open the scratch buffer in a split:
vim.api.nvim_command("split")
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, new_buf)
Delete a buffer (Neovim 0.9+):
vim.api.nvim_buf_delete(bufnr, { force = false })
Windows
Windows are viewports onto buffers. Get and set their properties:
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win) -- buffer in this window
local cursor = vim.api.nvim_win_get_cursor(win) -- {row, col}, 0-indexed
-- Set cursor position (row is 1-indexed, col is 0-indexed in the API)
vim.api.nvim_win_set_cursor(win, { cursor[1], cursor[2] })
-- Get window dimensions
local height = vim.api.nvim_win_get_height(win)
local width = vim.api.nvim_win_get_width(win)
Create a floating window (great for UI elements like popups and notifications):
local float_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(float_buf, 0, -1, false, {
"This is a floating window.",
"Press a key to close.",
})
local config = {
relative = "win",
width = 40,
height = 10,
row = 5,
col = 10,
anchor = "NW",
border = "rounded",
style = "minimal",
noautocmd = false,
}
local float_win = vim.api.nvim_open_win(float_buf, true, config)
Putting It Together: A Practical init.lua
Here’s a complete init.lua that demonstrates the concepts covered in this tutorial:
-- Create an augroup for our config
local mygroup = vim.api.nvim_create_augroup("my_config", { clear = true })
-- Set global options
vim.o.number = true
vim.o.relativenumber = true
vim.o.expandtab = true
vim.o.shiftwidth = 2
vim.o.tabstop = 2
vim.o.softtabstop = 2
vim.o.ignorecase = true
vim.o.smartcase = true
vim.o.termguicolors = true
-- Autocmd: highlight yanked text
vim.api.nvim_create_autocmd("TextYankPost", {
group = mygroup,
callback = function()
vim.highlight.on_yank({ higroup = "IncSearch", timeout = 150 })
end,
desc = "Brief highlight on yank",
})
-- Autocmd: strip trailing whitespace on save
vim.api.nvim_create_autocmd("BufWritePre", {
group = mygroup,
pattern = "*",
callback = function(args)
local buf = args.buf
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
for i, line in ipairs(lines) do
local stripped = line:gsub("%s+$", "")
if stripped ~= line then
lines[i] = stripped
end
end
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
end,
desc = "Strip trailing whitespace before save",
})
-- Keymap: save with Ctrl-s
vim.keymap.set("n", "<C-s>", ":w<CR>", { silent = true, desc = "Save file" })
-- Keymap: quit with Ctrl-q
vim.keymap.set("n", "<C-q>", ":q<CR>", { silent = true, desc = "Quit" })
-- Keymap: open a terminal in a vertical split
vim.keymap.set("n", "<leader>tv", function()
vim.api.nvim_command("vsplit | terminal")
end, { silent = true, desc = "Open terminal in vertical split" })
Common Pitfalls
require() Caching
Lua’s require() caches loaded modules. Editing a module and calling require() again returns the cached version. To reload during development:
package.loaded["my_module"] = nil
require("my_module")
Boolean Confusion
Vimscript returns 1 and 0 for true and false, not Lua booleans. Both are truthy in Lua:
-- WRONG: vim.fn.has() always returns truthy
if vim.fn.has("nvim") then print("always runs") end
-- RIGHT: explicit comparison
if vim.fn.has("nvim") == 1 then print("runs only in Neovim") end
Indexing: 0 vs 1
The Lua API uses 0-indexed rows in nvim_buf_get_lines and cursor positions, but Vimscript is 1-indexed. Always double-check the indexing convention when mixing APIs.
Keycodes in Strings
<CR> and <Esc> are not automatically interpreted in Lua strings passed to nvim_set_keymap. Use vim.keymap.set with a Lua function, or call vim.api.nvim_replace_termcodes manually.
Global by Default
Lua variables are global unless declared local. Always use local:
local my_config = {}
my_config.value = 10 -- correct
Omitting local creates a global that persists and can shadow other variables.
Summary
Neovim’s Lua integration gives you a real programming language for configuring and extending your editor:
vim.o,vim.opt— set and get optionsvim.g,vim.b,vim.w— work with variables at different scopesvim.keymap.set— create key mappings (Neovim 0.7+)vim.api.create_autocmd— respond to events (Neovim 0.7+)vim.apibuffer and window functions — manipulate the editor programmaticallyvim.cmd,vim.fn— bridge to Vimscript when needed
Start small: replace one Vimscript block in your config with a Lua equivalent. As you grow comfortable with the vim global, you’ll find yourself reaching for Lua first — not just for plugins, but for everything from quick one-liners to complex custom workflows.