Make Neovim work on its own: learn autocommands that respond to editor events (auto-save, trim whitespace, yank highlight), create custom commands like :TrimWhitespace with nvim_create_user_command, and tailor behavior per language with filetype detection and modeline.

After building options.lua and keymaps.lua in episode 7 — the first two pillars of a Neovim config — you now have an editor that feels comfortable and shortcuts that are responsive. But notice one thing: everything we have built so far waits for your commands. You press the keys, you run the commands. Not a single behavior runs on its own.
In the real world, developers do not work like that. We want the editor to react to what happens: tidy up automatically when saving a file, remove trailing whitespace without being asked, restore the cursor position when reopening the same file, and expose custom commands that save repetitive steps. This is the topic of this episode — making Neovim work for you, not just with you.
Technically, we will master three complementary mechanisms:
vim.api.nvim_create_autocmd) — execute code when an event occurs in the editor.vim.api.nvim_create_user_command) — create new commands in the :Name style callable from the command-line.All three are "reactive programming" at editor scale: a living system that changes behavior based on context. As a DevOps/SRE, you are surely familiar with similar patterns — event-driven automation in Kubernetes (webhooks, controllers), CI/CD triggers, or monitoring that responds to metrics. Autocommands are how Neovim realizes the same pattern.
An autocommand works like a webhook: Neovim emits certain events during its lifecycle, and you register handlers that run when those events occur. The modern syntax is vim.api.nvim_create_autocmd(events, opts).
vim.api.nvim_create_autocmd(events, {
pattern = "*", -- pola buffer yang dipantau (bisa "*.go")
callback = function() -- handler yang dijalankan
-- kode Lua di sini
end,
group = augroup, -- wadah untuk mencegah duplikasi (lihat bawah)
once = false, -- true = hanya dijalankan sekali
desc = "Keterangan",
})The events most often used in production configs:
| Event | When It Happens |
|---|---|
VimEnter | After Neovim finishes startup |
BufRead / BufReadPost | A buffer has finished being read from a file |
BufNewFile | An empty buffer for a new file is created |
BufWritePre | Right before a buffer is written to disk |
BufWritePost | Right after a buffer is written to disk |
BufEnter | The cursor enters a buffer |
InsertLeave | Exiting insert mode |
TextYankPost | After text is yanked (copied) |
FileType | A buffer's filetype has been detected |
FocusGained / FocusLost | The window gains/loses focus |
CursorHold | The cursor stays still for a while |
lua/config/autocmds.luaHere is a complete file containing the three most useful autocommands for daily use:
-- 1. Buat augroup unik untuk config kita.
-- clear = true menjamin handler tidak terdaftar dua kali.
local augroup = vim.api.nvim_create_augroup("UserConfig", { clear = true })
-- 2. Hapus trailing whitespace otomatis setiap kali menyimpan.
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
pattern = "*",
callback = function()
local save = vim.fn.winsaveview() -- simpan posisi kursor & folds
vim.cmd([[%s/\s\+$//e]]) -- hapus spasi di akhir baris
vim.fn.winrestview(save) -- pulihkan posisi kursor
end,
desc = "Hapus trailing whitespace saat menyimpan",
})
-- 3. Sorot sementara teks yang baru disalin (yank).
vim.api.nvim_create_autocmd("TextYankPost", {
group = augroup,
pattern = "*",
callback = function()
vim.highlight.on_yank({ timeout = 150 })
end,
desc = "Sorot teks saat yank",
})
-- 4. Kembali ke posisi kursor terakhir saat membuka file.
vim.api.nvim_create_autocmd("BufReadPost", {
group = augroup,
pattern = "*",
callback = function()
local last = vim.fn.line("'\"")
if last > 1 and last <= vim.fn.line("$") then
vim.api.nvim_win_set_cursor(0, { last, 0 })
end
end,
desc = "Kembali ke posisi terakhir saat membuka file",
})augroup So Important?Notice the first line: vim.api.nvim_create_augroup("UserConfig", { clear = true }). This is the shield against the most classic autocommand bug — handlers that get registered repeatedly.
Warning
Every time the config is reloaded (for example with :luafile% or when Neovim restarts with a config that loads modules multiple times), new autocommands are registered on top of the old ones. Without an augroup, the trailing-whitespace handler runs twice, three times, even a dozen times — once for every config load. In episode 9, the plugin manager also reloads config routinely, so an augroup is mandatory, not optional. clear = true clears all autocommands belonging to that group before re-registering.
winsaveview() and winrestview()?This is a detail that is often ignored but determines quality of life. When %s/\s\+$//e runs in BufWritePre, the cursor position and fold state can shift. vim.fn.winsaveview() saves the view state (cursor position, top line, folds), and vim.fn.winrestview(save) restores it after the substitution. The result: the file gets tidied without you ever noticing anything happened — the cursor does not jump and the screen does not flicker.
patternAutocommands can be restricted to specific file patterns. pattern = "*" means all buffers; pattern = { "*.go", "*.py" } only applies to Go and Python files. This matters for events like BufWritePre, where we do not want to tidy files that intentionally contain significant whitespace (for example deliberate .md files, or auto-generated files).
: LanguageAutocommands work automatically; user commands work when you call them. With vim.api.nvim_create_user_command, you can add new commands to the Neovim command-line that behave exactly like built-in commands — complete with autocomplete.
vim.api.nvim_create_user_command(name, handler, opts)
-- name : nama perintah (wajib diawali huruf besar)
-- handler : fungsi Lua atau string perintah Ex
-- opts : nargs, range, complete, desc, bang, cmdtypeThe most commonly used options:
| Option | Value | Function |
|---|---|---|
nargs | "0", "1", "*", "+", "?" | The number of arguments allowed |
range | "", "%", "<line1,line2>", 0/1/2 | The command accepts a line range |
complete | "file", "buffer", "help", etc. | Argument autocomplete |
desc | string | Command description |
bang | true | Allows ! at the end of the command |
cmdtype | "", "!", ">", "=" | The command-line type used |
-- 1. Perintah sederhana: hapus trailing whitespace seluruh buffer
vim.api.nvim_create_user_command("TrimWhitespace", function()
vim.cmd([[%s/\s\+$//e]])
end, { desc = "Hapus trailing whitespace di seluruh buffer" })
-- 2. Perintah dengan argumen: sapa nama
vim.api.nvim_create_user_command("Greet", function(args)
print("Halo, " .. args.args .. "! Selamat datang di Neovim.")
end, { nargs = "*", desc = "Sapa nama", complete = "file" })
-- 3. Perintah dengan range: substitusi hanya pada baris yang dipilih
vim.api.nvim_create_user_command("ReplaceHello", function(args)
local start, finish = args.line1, args.line2
vim.api.nvim_buf_call(0, function()
vim.cmd(start .. "," .. finish .. "s/hello/halo/ge")
end)
end, { range = "%", desc = "Ganti hello dengan halo di range" })
-- 4. Perintah untuk menyalin path absolut ke clipboard
vim.api.nvim_create_user_command("CopyPath", function()
local path = vim.fn.expand("%:p")
vim.fn.setreg("+", path)
print("Path disalin: " .. path)
end, { desc = "Salin path absolut buffer ke clipboard" })Note
The args object received by the handler contains lots of useful information: args.args is the typed argument text, args.line1 and args.line2 are the applied range bounds, args.fargs splits the arguments into a list (useful for nargs = "*"), and args.bang is true if the command was called with !. This is the "API" that makes user commands feel as powerful as built-in commands.
Important
User command names must start with an uppercase letter (:TrimWhitespace), unless you enable force = true and use a name that is not a built-in command. Why? Because Neovim reserves lowercase for built-in commands, and this rule prevents you from accidentally overwriting an existing command. If you try to create :foo without force, Neovim rejects it with E183: User defined commands must start with an uppercase letter.
Both mechanisms work best when combined. Real example: the format-on-save we promised in episode 7. Instead of hard-coding the logic in an autocmd, we create the :Format command first:
vim.api.nvim_create_user_command("Format", function()
-- placeholder: di episode 17 kita ganti dengan conform.nvim
if vim.bo.filetype == "lua" then
vim.cmd("silent !stylua %")
end
end, { desc = "Format buffer dengan formatter sesuai bahasa" })
-- Panggil :Format otomatis sebelum menyimpan
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
pattern = { "*.lua" },
callback = function()
vim.cmd("Format")
end,
desc = "Format file Lua saat menyimpan",
})The pattern of command as a building block + autocmd as the trigger is very common in production-grade configs — we will see it again in episode 17 when we discuss real formatters.
So far all our autocommands are global. But one of Neovim's strengths is its ability to adapt behavior based on the type of file — this is the last topic of this episode.
Neovim enables filetype detection by default (filetype on). When opening a file, it determines the file type from the file's extension, name, or contents. Check the result with :set filetype? — for example filetype=lua for init.lua, filetype=go for main.go.
There are two main ways to tailor behavior per language: ftplugin and the FileType autocommand. The most idiomatic is ftplugin — Lua files placed in after/ftplugin/ that are automatically loaded whenever a specific filetype is detected.
~/.config/nvim/
└── after/
└── ftplugin/
├── lua.lua # dimuat untuk file .lua
├── go.lua # dimuat untuk file .go
├── python.lua # dimuat untuk file .py
├── typescript.lua
└── make.lua-- Opsi khusus Go: tab nyata (Go mewajibkan tab), indentasi 4 kolom
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.expandtab = false
vim.opt_local.smartindent = false-- Opsi khusus Python: indentasi 4 spasi, tampilkan nomor relatif
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.expandtab = true
vim.opt_local.relativenumber = trueTip
Note vim.opt_local — the Lua counterpart of setlocal. Options set with opt_local only apply to the current buffer, so Go settings do not leak into Python buffers. This is the answer to the common "all files became 2-space indented" mistake — which happens precisely when using vim.opt (global) in an ftplugin. The after/ftplugin/ folder is used because it is guaranteed to load after the built-in ftplugin, so our settings win.
The alternative to ftplugin is the FileType autocommand — suitable for logic beyond simple options:
vim.api.nvim_create_autocmd("FileType", {
group = augroup,
pattern = { "python", "lua", "javascript" },
callback = function()
vim.opt_local.expandtab = true
vim.opt_local.shiftwidth = 4
end,
desc = "Indentasi 4 spasi untuk bahasa tertentu",
})When to use which? Use ftplugin for simple option settings (lightweight, idiomatic, automatically loaded per buffer). Use the FileType autocmd when you need more complex logic or are already integrated with the same augroup.
Sometimes you open someone else's file that follows different conventions — for example a project using 2-space indentation while your default is 4. Modeline lets a file declare its own settings inside its first or last line:
# vim: set ts=2 sw=2 expandtab:/* vim: set ts=2 sw=2 expandtab: */When Neovim opens a file with a modeline like the above, the ts, sw, and expandtab options are automatically set for that buffer — the file "talks" to the editor about how it should be displayed. This is very useful for files with special conventions like Makefiles (which require tabs):
# vim: set noexpandtab ts=8 sw=8:Caution
Modeline is a feature that can be a security hole. A malicious file from the internet could carry a modeline like vim: set guioptions=... :execute shell('...') that executes commands on your machine. Neovim by default restricts modelines to certain "safe" options (:help 'modeline'), and modelineexpr is disabled. Still, best practice remains: for untrusted files, disable modeline with :set nomodeline, or enable the securemodelines (plugin). Never open a suspicious file with modeline active.
Note
The settings priority hierarchy, from most specific: modeline (inside the file) > ftplugin/FileType (per language) > options.lua (global). Modeline wins because it is closest to the context — the file itself. Understanding this hierarchy helps you guess why a buffer looks different from what you expect.
augroup with { clear = true }.pattern = "*" when it should be specific. A BufWritePre autocommand with pattern = "*" will tidy trailing whitespace in all buffers, including .log files or files that intentionally have spaces. Restrict with patterns or enable buffer-locally when needed.winsaveview() in BufWritePre so the cursor jumps to line 1 every time you save. Always save and restore the view for autocommands that modify buffers.desc. The command still works, but it is hard to debug when there are many. Get in the habit of writing desc from the start.E183. Always start with an uppercase letter, or deliberately use force = true.vim.opt (global) in an ftplugin so per-language settings leak into other buffers. Use vim.opt_local.:set filetype?; if empty, the file extension is not recognized. You can add vim.filetype.add({ extension = { mdx = "markdown" } }).modeline when opening suspicious files.Tip
Debug autocommands with :autocmd to see all registered handlers (and from which group), or :autocmd BufWritePre to filter per event. If there are duplicate handlers, that is a sign your augroup is broken. For user commands, :command lists all commands, and :help E183 explains the naming rule.
In episode 8 we turned Neovim from a passive editor into a reactive one. You mastered autocommands with vim.api.nvim_create_autocmd — from trimming trailing whitespace on BufWritePre, highlighting yanked text, to restoring the cursor position on BufReadPost — all neatly tied into a single augroup to prevent duplication. You built custom user commands with vim.api.nvim_create_user_command, complete with nargs, range, and complete. Finally, you understood filetype detection, set per-filetype options via after/ftplugin/, and leveraged modeline for files with special conventions.
Key points to take with you:
augroup with { clear = true } to prevent duplication.BufWritePre must use winsaveview() / winrestview().vim.opt_local in ftplugins so per-language settings do not leak into other buffers.You can now make Neovim respond to events and understand each file's context. But there is one big leap we have not taken: the plugin ecosystem. Every plugin we will discuss from episode 10 to the end of the series — Treesitter, Telescope, LSP, formatters, up to AI assistants — needs a foundation to be installed and managed cleanly. In episode 9, we will build that foundation: The Modern Plugin Manager with lazy.nvim — the bootstrap script, the lua/plugins/*.lua structure, and the beautiful :Lazy UI. Stay motivated!