Part of Learn Lua with NeoVim

Lua for Neovim: The Neovim Lua API — vim.api, vim.opt, vim.keymap.set & Autocommands | Episode 31

Sandy LaneSandy Lane

Video: Lua for Neovim: The Neovim Lua API — vim.api, vim.opt, vim.keymap.set & Autocommands | Episode 31 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

The Neovim Lua API: vim.api, vim.opt, vim.keymap.set, Autocommands

vim.api.* for low-level operations. vim.opt.* for options. vim.keymap.set for keymaps. vim.api.nvim_create_user_command and nvim_create_autocmd for commands and autocmds. The bridge from Lua scripts to Neovim configuration.

For 30 episodes we've used Neovim as a Lua editor. Today we use Lua to configure Neovim. The vim global is the gateway — every customisation goes through it.

vim.opt: Neovim options

vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.cursorline = true

vim.opt.X is the modern Lua way to set option X. Equivalent vimscript:

set number
set relativenumber
set cursorline

vim.opt returns proxy objects with operator overloading (using __newindex/__index from episode 29 — yes, the Neovim API is metatable-based). Some options accept lists, sets, or maps:

vim.opt.shortmess:append("I")    -- add "I" to shortmess
vim.opt.listchars = { tab = "→ ", space = "·" }

For raw scalar access (no list/set magic), use vim.o:

vim.o.tabstop = 2

vim.opt is for "Lua-friendly" configuration; vim.o is for raw values.

vim.api: low-level functions

local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
print("This buffer has " .. #lines .. " lines")

vim.api.nvim_* functions are auto-generated bindings for Neovim's C-level API. Naming convention: nvim_<scope>_<action> — buffer, window, command, etc.

Common buffer functions:

  • vim.api.nvim_buf_get_lines(buf, start, end, strict_indexing) — read lines.
  • vim.api.nvim_buf_set_lines(buf, start, end, strict, replacement) — write lines.
  • vim.api.nvim_buf_get_name(buf) — file path.
  • vim.api.nvim_get_current_line() — line under cursor.

buf is a buffer handle; 0 means "current buffer."

vim.fn: vimscript function bridge

local filename = vim.fn.expand("%")
print("Current file: " .. filename)

vim.fn.X(...) calls the vimscript function X(...). Useful for vimscript functions without a vim.api equivalent — expand, getline, system, globpath, etc.

Slightly slower than vim.api calls (needs to go through the vimscript layer), but covers everything the old API does.

Creating a user command

vim.api.nvim_create_user_command("Hello", function(opts)
  local name = opts.args
  if name == "" then
    name = "World"
  end
  vim.notify("Hello, " .. name .. "!")
end, { nargs = "?" })

After running this, type :Hello Alice and you get a notification "Hello, Alice!". Type :Hello (no arg) → "Hello, World!".

nvim_create_user_command(name, callback, opts):

  • name — the command (must start with uppercase letter).
  • callback — function called when command runs. Receives opts table with .args, .fargs, .bang, etc.
  • opts.nargs0, 1, *, ?, +. How many arguments expected.

vim.notify(msg, level) shows a notification — replaces print for user-facing messages.

Setting a keymap

vim.keymap.set("n", "<leader>h", function()
  vim.notify("Hello from a keymap!")
end, { desc = "Say hello" })

vim.keymap.set(mode, lhs, rhs, opts):

  • mode"n" normal, "i" insert, "v" visual, "x" visual+select, "t" terminal. Or a list {"n", "v"}.
  • lhs — the keys to press ("<leader>h", "jk", "<C-s>").
  • rhs — a function (Lua) or string (vimscript). Functions are recommended.
  • opts.desc — description, shown in :map listings.
  • opts.silent — suppress command-line echo. Default false.
  • opts.noremap — don't recursively remap. Default true (the safe choice).

Modern config uses vim.keymap.set for everything; older code uses vim.api.nvim_set_keymap (more verbose, no Lua function support).

Autocommands

vim.api.nvim_create_autocmd("BufWritePost", {
  pattern = "*.lua",
  callback = function()
    vim.notify("Lua file saved!")
  end,
})

nvim_create_autocmd(events, opts):

  • events"BufWritePost", "BufRead", "InsertLeave", etc. Or a list.
  • opts.pattern — file pattern ("*.lua", "*.md").
  • opts.callback — function to run when event fires.
  • opts.command — vimscript command (alternative to callback).
  • opts.group — autocmd group (for cleanup).

After running this, save any .lua file and you'll see the notification.

For grouped autocmds (clean cleanup):

local group = vim.api.nvim_create_augroup("MyConfig", { clear = true })
vim.api.nvim_create_autocmd("BufWritePost", {
  group = group,
  pattern = "*.lua",
  callback = function() ... end,
})

augroup lets you re-source your config without duplicating autocmds.

vim.cmd: run vimscript

vim.cmd("colorscheme gruvbox")
vim.cmd([[
  set background=dark
  set termguicolors
]])

When there's no Lua API for what you need, vim.cmd(string) runs vimscript. The [[ ... ]] long string lets you embed multi-line vimscript naturally.

The leader key

vim.g.mapleader = " "          -- space as <leader>
vim.g.maplocalleader = ","

vim.g.X is the global vimscript variable equivalent. mapleader must be set before any keymap that uses <leader>.

Integrating with init.lua

Drop these snippets in ~/.config/nvim/init.lua:

vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.tabstop = 2
vim.opt.shiftwidth = 2
vim.opt.expandtab = true

vim.g.mapleader = " "

vim.keymap.set("n", "<leader>w", ":w<CR>", { desc = "Save" })
vim.keymap.set("n", "<leader>q", ":q<CR>", { desc = "Quit" })

vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = "*",
  callback = function()
    -- Strip trailing whitespace
    local save = vim.fn.winsaveview()
    vim.cmd([[silent! %s/\s\+$//e]])
    vim.fn.winrestview(save)
  end,
})

A small but real Neovim configuration: tabs as 2 spaces, line numbers, leader for save/quit, strip trailing whitespace on save.

Sourcing a Lua file

To run the snippet you're working on:

:luafile %

Same idea as :!lua % from episode 1, but inside Neovim's Lua state. Variables, functions, autocmds defined in the file persist for the session.

For a single line: :lua print("hi").

Common stumbles

Setting vim.opt.X to a list with =. Some options expect comma-separated strings (foldmethod); others expect lists. vim.opt handles both, but check the docs.

vim.opt vs vim.o confusion. Use vim.opt for new code. vim.o is fine for simple scalars.

Forgetting to set leader before keymaps. vim.g.mapleader must be set before any vim.keymap.set with <leader>. Otherwise the leader is \ (default).

Autocmd duplication on re-source. Re-running your config without an augroup adds another autocmd each time. Always group: augroup ... { clear = true }.

Running blocking system calls in callbacks. Freezes the editor. Use vim.fn.jobstart for async.

What's next

Episode 32: writing a Neovim plugin. Take everything we've learned — modules, classes, the API — and ship a plugin: word counter with commands. Plugin structure, the setup pattern, distributing via lazy.nvim or packer.

Recap

vim is the global gateway. vim.opt.X = value for options. vim.api.nvim_* for low-level operations. vim.fn.X(...) for vimscript functions. vim.keymap.set(mode, lhs, rhs, opts) for keymaps. vim.api.nvim_create_user_command(name, fn, opts) for :Commands. vim.api.nvim_create_autocmd(events, opts) for autocommands. vim.g.mapleader for the leader key. vim.cmd(...) to run raw vimscript. :luafile % to source the current Lua file.

Next episode: writing a Neovim plugin.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.