In this episode we dissect how modern Abstract Syntax Tree (AST)-based syntax highlighting works with nvim-treesitter, from parser installation, highlighting, incremental selection, code folding, to grammar-based textobjects.

After discussing navigation acceleration with Treesitter-based flash.nvim, marking favorite files with harpoon, and session management in episode 13 — in this episode we will cover the foundation that actually makes all of that work: nvim-treesitter, the high-speed code parsing engine that is the brain behind all the "smart" features of modern Neovim.
Now imagine working in a team that uses many languages: TypeScript for the frontend, Go for the backend, Python for the data pipeline. Every time you press a single key in the editor, the editor must recognize whether a line is a function declaration, a method call, or just a comment — in milliseconds, within a file that might be tens of thousands of lines long. The editor's ability to accurately recognize code structure is precisely what distinguishes a "blind" editor (one that merely colors text) from a "smart" editor (one that understands the language grammar). This episode will take you through the mechanisms behind the scenes, then build the foundation for the following episodes like LSP, autocompletion, and formatting.
Before Treesitter, Neovim (and Vim) colored code with a far more primitive approach: regex (regular expressions). The basic idea is simple — find matching text patterns, then color them. It seems reasonable, but this approach has three fatal flaws in the real world:
// is a comment, a URL inside a string, or a delimiter within the regex itself. As a result, highlighting is often wrong — for example a string is colored green even though it has already ended, or a comment does not stop where it should.Imagine regex as a translator who only knows word-for-word without understanding grammar. They can translate vocabulary, but will get the meaning wrong when the sentence is complex.
The solution used by all modern IDEs (VS Code, JetBrains, even web editors like Monaco) is real parsing: breaking code down into a tree structure called the Abstract Syntax Tree (AST).
An AST is a hierarchical representation of your code. Every language construct — function, if, for, class, expressions, string literals — becomes a node in the tree, complete with its start and end positions in the file. Think of it like a legal regulation document: instead of reading sentence by sentence, you have a chart showing "Article 1 paragraph 3 has several points, and that point contains sub-points". An editor that understands ASTs can answer structural questions very precisely: "what is the outermost function containing my cursor?" or "where does this if block start and end?"
Now, tree-sitter is an incremental parser. These are two keywords you must understand:
The combination of the two produces magic: highlighting that is structurally accurate and still responsive even on giant files, because the parsing cost while typing is very small.
Since Neovim 0.5, Treesitter is integrated directly into the kernel (the vim.treesitter API). What the nvim-treesitter plugin does is: manage the parsers (language grammars) compiled as native libraries, then provide feature modules on top of them — highlighting, incremental selection, indent, folding, etc.
Every language has a separate parser. This is why the :TSInstall <language> command exists — you install specific language grammars one by one. Parsers are compiled from grammar sources (written in C) into .so files on first install, so the C compiler toolchain must exist on your system — we will cover this in the common pitfalls section.
Since we already built lazy.nvim in episode 9, now is the time to add this plugin. Here is the complete spec:
return {
{
"nvim-treesitter/nvim-treesitter",
version = false,
build = ":TSUpdate",
event = { "BufReadPre", "BufNewFile" },
config = function()
require("nvim-treesitter").setup({
ensure_installed = {
"lua", "vim", "vimdoc", "query",
"javascript", "typescript", "tsx",
"python", "go", "rust", "bash",
"markdown", "markdown_inline", "yaml", "json", "toml",
"html", "css", "scss",
},
auto_install = true,
highlight = { enable = true },
indent = { enable = true },
incremental_selection = {
enable = true,
keymaps = {
init_selection = "<CR>",
node_incremental = "<CR>",
scope_incremental = "<S-CR>",
node_decremental = "<BS>",
},
},
})
end,
},
}Points to pay attention to:
build = ":TSUpdate" — after the plugin is finished being cloned by lazy.nvim, the :TSUpdate command is automatically run to compile the parsers registered in ensure_installed. This fixes the classic "parser not installed after bootstrapping a new config" problem.event = { "BufReadPre", "BufNewFile" } — the plugin is only loaded when you actually open a buffer. This is the event-based lazy-loading discussed in episode 9, keeping startup time low.highlight = { enable = true } — enables Treesitter-based highlighting as a replacement for regex.indent = { enable = true } — structure-based indentation; gg=G or pressing = on a selection adjusts indentation according to grammar, not merely whitespace calculation.Tip
If you often open files in languages that do not have a parser yet, set auto_install = true as in the example above. When a file in an unknown language is opened, the parser is automatically downloaded and compiled. Not all grammars support this automatic installation, but the most popular ones do.
:TSInstallEven though ensure_installed already covers common languages, you will surely encounter new languages in your next project. This is the command set you must memorize:
:TSInstall rust " satu bahasa
:TSInstall go python " beberapa bahasa sekaligusNote
Parser names are not always the same as language names. Example: TypeScript with JSX uses the tsx parser (separate from typescript), and Markdown requires two parsers — markdown for blocks and markdown_inline for the text inside them. Check :TSInstallInfo to see the complete list.
Here is a table of parsers for the most common languages used by engineering teams:
| Language | Parser Name | Notes |
|---|---|---|
| Lua & Neovim config | lua, vim, vimdoc, query | vimdoc for help files, query for query files |
| JavaScript / TypeScript | javascript, typescript, tsx | tsx required for React (JSX) |
| Python | python | Supports all syntax up to 3.13+ |
| Go | go | Very mature parser |
| Rust | rust | Includes macros and attributes |
| Shell | bash | Also for zsh/sh |
| Markdown | markdown, markdown_inline | Both required for accurate results |
| YAML / JSON / TOML | yaml, json, toml | Often used for config files |
| HTML / CSS | html, css, scss |
With highlighting enabled, you can immediately feel the difference. Strings inside comments are no longer colored green; keywords inside JS template literals are no longer mistaken for keywords. Beyond that, we can leverage the AST structure for three productivity features:
1. Structure-based Code Folding. With AST, folding uses real block boundaries (functions, classes, if blocks), not just indentation levels:
vim.opt.foldmethod = "expr"
vim.opt.foldexpr = "v:lua.vim.treesitter.foldexpr()"
vim.opt.foldlevel = 99
vim.opt.foldlevelstart = 99After the configuration above, standard Vim folding keys immediately work with Treesitter precision:
zc — close one foldzo — open one foldza — toggle the fold under the cursorzM / zR — close all / open all folds2. Incremental Selection. This is a signature feature for selecting code blocks gradually — starting from the smallest node then expanding following the AST structure. With the keymaps we defined in the spec earlier:
<CR> once — the cursor selects the smallest node (e.g. a variable name).<CR> again — the selection expands to the statement.<BS> to narrow back down if it is too much.Imagine this like borrowing a magnifying glass that can zoom out gradually: the selection always follows correct structural boundaries, never cutting in the middle of an expression.
3. Grammar-based Textobjects. In episode 3 we learned classic textobjects like ci" and dap. Those built-in textobjects only understand delimiter characters and words. With nvim-treesitter, we can select functions, classes, blocks, parameters as objects — far more meaningful for programmers.
The treesitter-textobjects plugin adds a/i keymaps for those objects:
{
"nvim-treesitter/nvim-treesitter-textobjects",
dependencies = { "nvim-treesitter/nvim-treesitter" },
config = function()
require("nvim-treesitter-textobjects").setup({
select = {
enable = true,
lookahead = true,
keymaps = {
["af"] = "@function.outer",
["if"] = "@function.inner",
["ac"] = "@class.outer",
["ic"] = "@class.inner",
["ab"] = "@block.outer",
["ib"] = "@block.inner",
["aa"] = "@parameter.outer",
["ia"] = "@parameter.inner",
},
},
move = {
enable = true,
goto_next_start = { ["]f"] = "@function.outer", ["]c"] = "@class.outer" },
goto_previous_start = { ["[f"] = "@function.outer", ["[c"] = "@class.outer" },
},
swap = {
enable = true,
swap_next = { [">a"] = "@parameter.inner" },
swap_previous = { ["<a"] = "@parameter.inner" },
},
})
end,
},Usage examples that will change your life:
yaf — yank (copy) the entire function where the cursor is.daf — delete the entire function at once.ci( — already built-in, but cia selects arguments with Treesitter precision.]f — jump to the start of the next function; [f back to the previous function.>a — shift a function's parameters one position to the right (swap).Important
Note the lookahead = true detail in select: this feature makes daf work even when the cursor is inside the function, not necessarily on the function's line. Without lookahead, the textobject is only recognized if the cursor is on the same line as the start of the object — often confusing for new users.
| Mistake | Symptom | Solution |
|---|---|---|
| C compiler toolchain missing | TSInstall fails with make/cc not found error | Install build-essential (Linux), Xcode Command Line Tools (macOS), or gcc (Windows WSL) |
| Outdated parser | Wrong highlighting for new syntax (e.g. the latest language features) | Run :TSUpdate periodically; build = ":TSUpdate" in the lazy spec helps automatically |
| First parser install is slow | The build process takes a long time for many parsers | Install only the parsers you actually use; use prebuilt binary parsers if available |
| Weird/missing highlighting | Colors do not change or look wrong | Check :checkhealth treesitter, make sure the colorscheme supports termguicolors |
auto_install fails for certain languages | The language is not highlighted | Install manually with :TSInstall <lang> |
Folds disappear after zR | All folds open and the file gets long | Normal! zc/za to lock them back; foldlevelstart = 99 makes folds open by default but still usable |
Conflict with indent-blankline | Indentation lines do not match blocks | Make sure indent-blankline reads vim.treesitter (recent versions do automatically) |
Warning
One of the most frustrating mistakes: after adding a new language to ensure_installed, the parser is not automatically compiled on an existing config — because lazy.nvim only runs build when the plugin is newly installed. Solution: run :TSInstall <language> once for the new language, or use :TSUpdate to make sure all parsers in ensure_installed are installed.
In episode 14 we dissected why regex highlighting has become an obsolete solution, understood the AST and incremental parser concepts that make Treesitter so fast, then built a complete nvim-treesitter configuration: per-language parser installation, structural highlighting, incremental selection, grammar-based code folding, and textobjects like yaf and ]f that understand functions and classes. All of this is the stage for a far bigger feature: the editor's ability to perceive code contextually.
In episode 15, we will build on this foundation the most anticipated topic: Built-in LSP (Language Server Protocol) with mason.nvim — connecting your Neovim to language servers to get go-to-definition, symbol rename, code actions, and hover documentation on par with commercial IDEs. Stay motivated!