This is the moment of transition from "user" to "builder": understand the modern configuration directory structure, master the basics of the Lua language, and get to know the Neovim Lua API (vim.opt, vim.g, vim.keymap.set, vim.api) along with Vimscript interoperability through vim.fn and vim.cmd.

After covering how to manage buffers, windows, and tabs in episode 5 — from split windows and buffer navigation to closing buffers without breaking the layout — you can now work with many files at once efficiently. But there is a feeling that inevitably appears after a few days of using Neovim: "I want to make this editor truly mine."
Maybe you want to change defaults like :set number, add shortcuts that feel natural, or save your favorite settings so you do not have to retype them every time. This is where you start writing configuration. And in this episode, we take the path that no one serious about Neovim can avoid: leaving Vimscript behind and switching fully to Lua.
Why is this important in the real world? Because since version 0.5, Neovim has made Lua a first-class citizen — a real programming language embedded directly into the editor. Most of the modern plugin ecosystem (Telescope, Treesitter, LSP, lazy.nvim) is written in Lua, and popular distros like LazyVim or NvChad are 100% Lua-based. Mastering Lua configuration means you can read and modify almost any Neovim setup in the world — from a senior engineer's dotfiles repo to a distro's default config. This is a skill with immediate impact on your work as a software engineer or DevOps.
Before writing the first line of code, let's understand why Neovim chose Lua over Vimscript.
Vimscript (Vim's scripting language) is decades old and has several structural weaknesses: its syntax is unique and feels "foreign", arrays start at index 1, data structures are limited, performance is slow for complex logic, and debugging is painful. Vimscript is like learning a second language that is only useful in one place.
Lua, on the other hand, is a 30-year-old scripting language known for being lightweight, fast, and easy to embed — it is used in games (World of Warcraft, Roblox) and embedded systems. The Lua runtime is only a few hundred kilobytes, yet it is expressive and familiar to any programmer. Neovim chose Lua 5.1 (LuaJIT-compatible) and integrated it so deeply that the entire editor API — buffers, windows, options, keymaps, LSP, Treesitter — can be accessed directly from Lua.
| Aspect | Vimscript | Lua |
|---|---|---|
| Performance | Slow for complex logic | Very fast (LuaJIT) |
| Data structures | Limited | Table (array + dict in one type) |
| Syntax | Unique, feels foreign | Familiar, like a modern language |
| Modern plugin ecosystem | Legacy | De-facto standard |
| Debugging | Difficult | Easier (clear error messages) |
| Modularization | source files | require with namespaces |
Note
Vimscript is not gone and is still fully supported — in fact we will learn how to call Vimscript functions from Lua in this episode. But Neovim's development direction is clear: all new features and plugins are written in Lua, and your configuration career is much lighter if you start directly with Lua.
All Neovim configuration lives in the ~/.config/nvim/ directory on Linux/macOS (on Windows: %localappdata%\nvim). This directory is inside Neovim's runtimepath, meaning the files in it are automatically recognized by Neovim at startup.
The main file is init.lua — the modern replacement for init.vim. But a tidy config does not put everything in a single file. The community-agreed architecture is a modular structure like this:
~/.config/nvim/
├── init.lua # Entry point utama
├── lazy-lock.json # Lockfile plugin (episode 9)
└── lua/
├── config/ # Konfigurasi inti milik kita sendiri
│ ├── options.lua # Pengaturan opsi editor
│ ├── keymaps.lua # Shortcut kustom
│ ├── autocmds.lua # Autocommand (episode 8)
│ └── lazy.lua # Setup plugin manager
└── plugins/ # Spec plugin, satu file per plugin
├── telescope.lua
├── treesitter.lua
└── lsp.luaThe main magic lives in the lua/ folder. This folder is automatically added to Neovim's package.path, so every file in it can be loaded with the require function. This gives us a clean way to break configuration into small modules, each with a single responsibility — exactly like modularization principles in a production codebase.
Do not worry if you have never touched Lua — the language is small and can be mastered in 30 minutes. Let's break down its data types and basic constructs.
Lua only has eight basic types, and for Neovim configuration you only need to know six:
| Type | Example | Description |
|---|---|---|
nil | local x = nil | "No value"; deletes a variable |
boolean | true, false | Logical truth |
number | 42, 3.14 | All numbers, no int/float distinction |
string | "halo", 'halo' | Text |
table | {} | The only data structure (array & dict) |
function | function() end | A callable value (first-class) |
localVariables in Lua are global by default. To avoid namespace pollution, the strong convention is to always declare them with local:
local editor = "neovim" -- lokal, hanya berlaku di file ini
global_variable = "oops" -- global, mencemari _G — hindari!Table is the heart of Lua. It can behave as an array and a dictionary at the same time. One thing you must remember: Lua array indices start at 1, not 0.
-- Array (list)
local hobbies = { "neovim", "docker", "k8s" }
print(hobbies[1]) -- "neovim" (indeks mulai dari 1!)
print(#hobbies) -- 3 (operator # = panjang array)
-- Dictionary (key-value)
local config = {
leader = " ",
verbose = 0,
name = "Arman",
}
print(config.leader) -- akses dengan titik
print(config["name"]) -- atau dengan indeks stringFunctions in Lua are ordinary values — they can be stored in variables, passed as arguments, and returned from other functions. This matters because many Neovim APIs accept callbacks (functions called when an event occurs):
local function tambah(a, b)
return a + b
end
-- Fungsi anonim (tanpa nama) — dipakai untuk callback
local cb = function()
print("dipanggil!")
endTip
A convention to remember when reading other people's configs: a Lua file that is require-d gets executed, and the last return value of that file becomes the result of require. That is why configuration module files usually end with return { ... } — a pattern we will use very often for plugin specs in episode 9.
vim.*)After mastering Lua basics, this is the most valuable part: the vim.* namespace that unlocks the editor's full capabilities. Here is the map of the most commonly used namespaces:
| Namespace | Function | Example |
|---|---|---|
vim.opt | Set editor options (:set style) | vim.opt.number = true |
vim.o | Set global options directly | vim.o.tabstop = 4 |
vim.opt_local | Set buffer/window-specific options | vim.opt_local.spell = true |
vim.g | Global variables (let g: style) | vim.g.mapleader = " " |
vim.env | Environment variables | vim.env.HOME |
vim.keymap.set | Define shortcuts | vim.keymap.set("n", "<leader>w", ":w<CR>") |
vim.api | Neovim low-level API (nvim_*) | vim.api.nvim_create_user_command(...) |
vim.fn | Call Vimscript functions | vim.fn.expand("%:p") |
vim.cmd | Run Ex commands (:command) | vim.cmd("set number") |
vim.lsp | Language Server Protocol integration | vim.lsp.start(...) |
vim.opt: Setting Editor Optionsvim.opt returns an object that lets us set options in a :set-like way. There is one important difference: vim.opt supports the full :set semantics (including +=, -=, ^= operations) through methods like :append() and :remove():
vim.opt.number = true -- set number
vim.opt.relativenumber = true -- set relativenumber
vim.opt.tabstop = 4 -- set tabstop=4
vim.opt.expandtab = true -- set expandtab
vim.opt.ignorecase = false -- set noignorecase
-- Opsi berbentuk daftar (list) — pakai :append / :remove
vim.opt.path:append("**") -- set path+=**
vim.opt.wildignore:append({ "node_modules", ".git" })vim.g: Global VariablesThis is the Lua equivalent of let g:name = value in Vimscript. The most famous example is the leader key, which we will study in detail in episode 7:
vim.g.mapleader = " " -- set leader key jadi spasi
vim.g.maplocalleader = " " -- leader lokal buffer
vim.g.my_plugin_enabled = true -- contoh variabel untuk pluginvim.api: The Low-Level APIvim.api contains hundreds of nvim_* functions that bridge Lua and the Neovim kernel. We will use some of them in episode 8 (autocommands & user commands), but the following short example shows direct interaction with buffers and windows:
local buf = vim.api.nvim_get_current_buf() -- buffer aktif
local name = vim.api.nvim_buf_get_name(buf) -- path absolut buffer
local lines = vim.api.nvim_buf_get_lines(0, 0, 5, false) -- baris 1-5
vim.api.nvim_win_set_cursor(0, { 10, 0 }) -- pindah kursor ke baris 10Note
The parameter 0 in many nvim_* functions means "the active buffer/window" — this abbreviation is so common that you must know it. Can vim.api.nvim_get_current_buf() be written as vim.api.nvim_get_current_buf(0)? No — that function takes no arguments. But for functions like nvim_buf_get_name(buf), passing 0 means "the currently active buffer".
vim.fn and vim.cmd)You do not need to leave Vimscript behind entirely — sometimes a function only exists in Vimscript. Neovim provides two main bridges.
vim.fn: Calling Vimscript FunctionsAll built-in Vimscript functions (functions from :help functions) can be called from Lua via vim.fn.<function_name>(...). Function names use underscores, not uppercase letters:
local filename = vim.fn.expand("%:t") -- nama file aktif
local cwd = vim.fn.getcwd() -- direktori kerja
local has_file = vim.fn.filereadable("Makefile") -- 1 atau 0 (perhatikan!)
local lines = vim.fn.line("$") -- jumlah baris bufferCaution
Common mistake #1: assuming vim.fn{:lua} always returns true/false. Many Vimscript functions return 1 or 0 (not booleans). In Lua, 0 is truthy — meaning if vim.fn.filereadable("x") then evaluates to true even when the file does not exist! Compare with == 1 or use functions that actually return booleans. This is the bug that most often ambushes beginners migrating from Vimscript.
vim.cmd: Running Ex CommandsTo run command-line commands (:set, :e, etc.) from Lua, use vim.cmd:
vim.cmd("set number")
vim.cmd(":vsplit") -- tanda titik dua boleh, tidak wajib
vim.cmd("silent! %s/foo/bar/g")
-- String multiline dengan kurung siku ganda (long string)
vim.cmd([[
set number
set relativenumber
set tabstop=4
]])For compatibility purposes, Neovim also provides the reverse direction. This is useful when you still have old Vimscript lines in init.vim during the transition:
:lua print("halo dari lua")
:lua vim.opt.number = true
:lua require("config.options") -- require modul
:lua <<EOF
local x = 42
print(x)
EOF
:luafile ~/.config/nvim/lua/config/options.luainit.vim → init.luaTime to put it all together. Here is a before-after comparison of the most common configuration. Note the migration patterns: set → vim.opt, let → vim.g, nnoremap → vim.keymap.set.
set number
set relativenumber
set tabstop=4 shiftwidth=4 expandtab
set mouse=a
let mapleader = " "
nnoremap <leader>w :w<CR>
set clipboard=unnamedplus
A quick conversion table you will use very often:
| Vimscript | Lua |
|---|---|
set number | vim.opt.number = true |
set noignorecase | vim.opt.ignorecase = false |
set path+=** | vim.opt.path:append("**") |
setlocal spell | vim.opt_local.spell = true |
let g:mapleader = " " | vim.g.mapleader = " " |
nnoremap x y | vim.keymap.set("n", "x", "y") |
set nu + set rnu | vim.opt.number, vim.opt.relativenumber = true, true |
requireThe beauty of the lua/config/ structure shows up when we activate it from init.lua. The require function maps paths with a simple rule: dots (.) replace slashes (/) and the .lua extension is omitted.
require("config.options") -- memuat lua/config/options.lua
require("config.keymaps") -- memuat lua/config/keymaps.lua
require("config.autocmds") -- memuat lua/config/autocmds.lua
-- atau, jika kita ingin mengekspos variabel dari modul:
local opts = require("config.options")Important
Common mistake #2: writing the require{:lua} path wrong. Note the three rules: use dots (config.options), not slashes (config/options); omit the extension (.lua); and make sure the file is inside the same lua/ folder as the file calling it. The error usually reads module 'config.options' not found — the moment you see this error, the require path does not match the file location.
Besides the two traps of vim.fn and require above, here are other mistakes you will most often encounter:
vim.opt but expecting vim.o behavior. vim.opt.number = true returns an options object; you must access its value (vim.opt.number:get()) to read it back. For a quick read, use vim.o.number.hobbies[0] will be nil in Lua. Always start at 1.local so variables become global and collide with plugin variables. Make local a reflex.vim.g.mapleader after a keymap that uses <leader>. The leader value is read when the keymap is defined. If <leader> is not set yet, the keymap binds to the wrong key. (We discuss this in detail in episode 7.)set noX vim.opt.X = false? Yes, that is right — but some options (e.g. compatible) have no direct boolean counterpart. Check :help options when in doubt.init.vim too soon. During migration, Neovim reads init.lua first and only reads init.vim if init.lua does not exist. Move configuration over block by block, then delete init.vim once everything works.vim.fn.getcwd (without ()) returns the function itself, not its result. vim.fn.getcwd() is correct.Tip
The fastest way to test a Lua line without restarting Neovim: type :lua vim.opt.number = true right in the command-line, or press : and type lua to open an interactive Lua prompt. To reload a config file, :luafile % executes the file currently open. This speeds up your trial-and-error loop dramatically.
In episode 6 we built the foundation of modern Neovim configuration. You understand the ~/.config/nvim/ directory structure with init.lua as the entry point and the lua/ folder as the home of modules. You mastered Lua basics — data types, variables with local, tables (array & dictionary), functions, and modularization with require. Finally, you got to know the Neovim Lua API map: vim.opt, vim.g, vim.keymap.set, vim.api, plus the bridge to Vimscript through vim.fn and vim.cmd.
Key points to take with you:
vim.fn and vim.cmd.local.require("config.options") loads lua/config/options.lua — dots replace slashes, no extension.vim.fn returning 1/0 needs an explicit == 1 comparison, because 0 is truthy in Lua.vim.g.mapleader must be set before any keymap that uses <leader>.Now you are ready to build a real config. In episode 7, we will go straight into practice: building basic Options & Keymaps in init.lua — writing complete lua/config/options.lua and lua/config/keymaps.lua files, understanding the leader key, and creating custom shortcuts you will use every day. Stay motivated!