luaguides

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
  • Modulesrequire() 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 command
  • noremap — don’t recursively resolve the mapping
  • desc — 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 options
  • vim.g, vim.b, vim.w — work with variables at different scopes
  • vim.keymap.set — create key mappings (Neovim 0.7+)
  • vim.api.create_autocmd — respond to events (Neovim 0.7+)
  • vim.api buffer and window functions — manipulate the editor programmatically
  • vim.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.