Learn Neovim - Advanced Syntax Highlighting & Parsing (nvim-treesitter)
Series/Learn Neovim/Episode 14
Episode 14 of 28

Learn Neovim - Advanced Syntax Highlighting & Parsing (nvim-treesitter)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
7 min read

Introduction

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.

Main Discussion

The Problem with Regex-Based Syntax Highlighting

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:

  1. Slow on large files. Regex has to rescan the lines that changed, and sometimes rescan the entire buffer. On files with thousands of lines, the editor starts to feel heavy while typing.
  2. Does not understand context. Regex does not know whether // 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.
  3. Hard to use for advanced features. You cannot ask a regex "which outermost function scope contains my cursor?" — a question that is precisely what is needed for code folding, structure-based selection, or navigation between functions.

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 Abstract Syntax Tree (AST) Concept

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:

  • Parser: produces an AST from source text using the language's official grammar.
  • Incremental: when you type a single character, the parser does not re-parse the whole file from scratch. It only updates the changed part of the tree — exactly like updating one branch of a family tree without rewriting the entire genealogy.

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.

Getting to Know nvim-treesitter

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.

Configuring nvim-treesitter with lazy.nvim

Since we already built lazy.nvim in episode 9, now is the time to add this plugin. Here is the complete spec:

lua/plugins/treesitter.lua
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.

Installing Language Parsers with :TSInstall

Even 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 sekaligus

Note

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:

LanguageParser NameNotes
Lua & Neovim configlua, vim, vimdoc, queryvimdoc for help files, query for query files
JavaScript / TypeScriptjavascript, typescript, tsxtsx required for React (JSX)
PythonpythonSupports all syntax up to 3.13+
GogoVery mature parser
RustrustIncludes macros and attributes
ShellbashAlso for zsh/sh
Markdownmarkdown, markdown_inlineBoth required for accurate results
YAML / JSON / TOMLyaml, json, tomlOften used for config files
HTML / CSShtml, css, scss

Highlighting, Code Folding & Incremental Selection

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:

lua/config/options.lua
vim.opt.foldmethod = "expr"
vim.opt.foldexpr = "v:lua.vim.treesitter.foldexpr()"
vim.opt.foldlevel = 99
vim.opt.foldlevelstart = 99

After the configuration above, standard Vim folding keys immediately work with Treesitter precision:

  • zc — close one fold
  • zo — open one fold
  • za — toggle the fold under the cursor
  • zM / zR — close all / open all folds

2. 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:

  1. Move the cursor inside a function.
  2. Press <CR> once — the cursor selects the smallest node (e.g. a variable name).
  3. Press <CR> again — the selection expands to the statement.
  4. Continue until the selection covers the entire function or class.
  5. Press <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:

lua/plugins/treesitter.lua
  {
    "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.

Common nvim-treesitter Pitfalls

MistakeSymptomSolution
C compiler toolchain missingTSInstall fails with make/cc not found errorInstall build-essential (Linux), Xcode Command Line Tools (macOS), or gcc (Windows WSL)
Outdated parserWrong 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 slowThe build process takes a long time for many parsersInstall only the parsers you actually use; use prebuilt binary parsers if available
Weird/missing highlightingColors do not change or look wrongCheck :checkhealth treesitter, make sure the colorscheme supports termguicolors
auto_install fails for certain languagesThe language is not highlightedInstall manually with :TSInstall <lang>
Folds disappear after zRAll folds open and the file gets longNormal! zc/za to lock them back; foldlevelstart = 99 makes folds open by default but still usable
Conflict with indent-blanklineIndentation lines do not match blocksMake 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.

Closing

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!

Learn Neovim - Advanced Syntax Highlighting & Parsing (nvim-treesitter) | Learn Neovim