A Powerful Way To Make Editing Code In Neovim Even Better (Treesitter & Text Objects)
Treesitter is an experimental feature of Neovim that, through the help of language parsers, constructs a syntax tree for the files you work with allowing improved features like better syntax highlighting, better indentation, incremental selection and advanced syntax aware text objects.
In this post, I’m gonna show you guys everything you need to know to set this up and take your setup to the next level.
You can also watch my detailed video on this.
Basic Folder Structure
I’m using the lazy.nvim plugin manager in my Neovim config and my folder structure looks something like the following:
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
Whenever I mention [name], replace it with your actual name (so ”[name]” => “josean” in my case)
If you’re on mac or linux the config should be in ~/.config/nvim
and on Windows it should be in ~/AppData/Local/nvim/
📹 If you’d like to learn more about using lazy.nvim, checkout my youtube video on that: How To Use lazy.nvim
init.lua
The ~/.config/nvim/init.lua
file is the most important as this runs whenever Neovim starts:
~/.config/nvim
├── init.lua
In this file I load the ”[name].lazy” module like so:
Remember to replace [name] with your actual name
require("[name].core")
require("[name].lazy")
This module is located in ~/.config/nvim/lua/[name]/lazy.lua
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
In this file I bootstrap lazy.nvim and setup the lazy.nvim plugin.
Setting up lazy.nvim
The lazy.lua
file should first bootstrap lazy.nvim to
install it if not already installed whenever neovim starts up:
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", -- latest stable release
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
After that we should call the setup function passing lua modules that will contain all of our plugin specs/configurations.
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", -- latest stable release
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({ { import = "[name].plugins" }, { import = "[name].plugins.lsp" } })
Replace [name] with your actual name (example: ”[name]” => “josean”)
The first module [name].plugins
corresponds to this folder
that contains files with plugin configurations within them and it’s where we’ll configure
nvim-treesitter
and nvim-treesitter-textobjects
.
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── ....
Setup Treesitter
We can use the nvim-treesitter plugin for a simple and convenient way to configure Neovim’s builtin treesitter and enable features for it.
Add a nvim-treesitter.lua
file under ~/.config/nvim/lua/[name]/plugins/
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── nvim-treesitter.lua
├── ....
Return a table from this file with the short plugin url for nvim-treesitter:
return {
"nvim-treesitter/nvim-treesitter",
}
Then add a config function to setup the plugin once it loads and require the plugin.
return {
"nvim-treesitter/nvim-treesitter",
config = function()
local treesitter = require("nvim-treesitter.configs")
end,
}
Enable Better Syntax Highlighting and Indentation
Now call it’s setup function and enable the highlight
and indentation
features of treesitter.
Enabling highlight
will improve syntax highlighting and enabling indent
will improve indentation
with the =
operator.
return {
"nvim-treesitter/nvim-treesitter",
config = function()
local treesitter = require("nvim-treesitter.configs")
-- configure treesitter
treesitter.setup({ -- enable syntax highlighting
highlight = {
enable = true,
},
-- enable indentation
indent = { enable = true },
})
end,
}
Add List of Language Parsers To Auto Install
Now add an ensure_installed
list to auto install the parsers you need for the languages you work with.
List of available language parsers see: Treesitter Parsers
return {
"nvim-treesitter/nvim-treesitter",
config = function()
local treesitter = require("nvim-treesitter.configs")
-- configure treesitter
treesitter.setup({ -- enable syntax highlighting
highlight = {
enable = true,
},
-- enable indentation
indent = { enable = true },
-- ensure these language parsers are installed
ensure_installed = {
"json",
"javascript",
"typescript",
"tsx",
"yaml",
"html",
"css",
"prisma",
"markdown",
"markdown_inline",
"svelte",
"graphql",
"bash",
"lua",
"vim",
"dockerfile",
"gitignore",
"query",
},
})
end,
}
Now add the following to make sure that whenever nvim-treesitter is installed or updated,
the language parsers in the ensure_installed
list update as well.
return {
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
config = function()
local treesitter = require("nvim-treesitter.configs")
-- configure treesitter
treesitter.setup({ -- enable syntax highlighting
highlight = {
enable = true,
},
-- enable indentation
indent = { enable = true },
-- ensure these language parsers are installed
ensure_installed = {
"json",
"javascript",
"typescript",
"tsx",
"yaml",
"html",
"css",
"prisma",
"markdown",
"markdown_inline",
"svelte",
"graphql",
"bash",
"lua",
"vim",
"dockerfile",
"gitignore",
"query",
},
})
end,
}
Enable Incremental Selection Through Treesitter
Now enable the incremental_selection
feature of treesitter.
return {
"nvim-treesitter/nvim-treesitter",
config = function()
local treesitter = require("nvim-treesitter.configs")
-- configure treesitter
treesitter.setup({ -- enable syntax highlighting
highlight = {
enable = true,
},
-- enable indentation
indent = { enable = true },
-- ensure these language parsers are installed
ensure_installed = {
"json",
"javascript",
"typescript",
"tsx",
"yaml",
"html",
"css",
"prisma",
"markdown",
"markdown_inline",
"svelte",
"graphql",
"bash",
"lua",
"vim",
"dockerfile",
"gitignore",
"query",
},
incremental_selection = {
enable = true,
keymaps = {
init_selection = "<C-space>",
node_incremental = "<C-space>",
scope_incremental = false,
node_decremental = "<bs>",
},
},
})
end,
}
With this enabled you can now use <C-space>
(Ctrl + space) to select the current node in the syntax
tree your cursor is in. If you keep executing this keymap, the selection will expand to the parent node
until you have selected the whole file. You can go backwards with <bs>
(backspace).
Add Event To Lazy Load The Plugin
Now add the following to load the plugin only when opening a buffer for an already existing file or for a new one. We don’t need treesitter outside of the context of a buffer.
return {
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
event = { "BufReadPre", "BufNewFile" },
config = function()
local treesitter = require("nvim-treesitter.configs")
-- configure treesitter
treesitter.setup({ -- enable syntax highlighting
highlight = {
enable = true,
},
-- enable indentation
indent = { enable = true },
-- ensure these language parsers are installed
ensure_installed = {
"json",
"javascript",
"typescript",
"tsx",
"yaml",
"html",
"css",
"prisma",
"markdown",
"markdown_inline",
"svelte",
"graphql",
"bash",
"lua",
"vim",
"dockerfile",
"gitignore",
"query",
},
incremental_selection = {
enable = true,
keymaps = {
init_selection = "<C-space>",
node_incremental = "<C-space>",
scope_incremental = false,
node_decremental = "<bs>",
},
},
})
end,
}
With this setup you can use :InspectTree
inside a file for a language you have installed a parser for
and see the syntax tree nodes generated by treesitter and the parser.
Typescript Syntax Tree Example:
Setup Syntax Aware Text Objects
Now let’s add nvim-treesitter-textobjects to setup syntax aware text objects.
The first thing you’ll want to do here is add a dependency to nvim-treesitter
for nvim-treesitter-textobjects
so that it loads whenever nvim-treesitter
loads (when opening a buffer).
return {
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
event = { "BufReadPre", "BufNewFile" },
dependencies = {
"nvim-treesitter/nvim-treesitter-textobjects",
},
config = function()
local treesitter = require("nvim-treesitter.configs")
-- configure treesitter
treesitter.setup({ -- enable syntax highlighting
highlight = {
enable = true,
},
-- enable indentation
indent = { enable = true },
-- ensure these language parsers are installed
ensure_installed = {
"json",
"javascript",
"typescript",
"tsx",
"yaml",
"html",
"css",
"prisma",
"markdown",
"markdown_inline",
"svelte",
"graphql",
"bash",
"lua",
"vim",
"dockerfile",
"gitignore",
"query",
},
incremental_selection = {
enable = true,
keymaps = {
init_selection = "<C-space>",
node_incremental = "<C-space>",
scope_incremental = false,
node_decremental = "<bs>",
},
},
})
end,
}
Now add a nvim-treesitter-textobjects.lua
file under ~/.config/nvim/lua/[name]/plugins/
to configure
this plugin.
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── nvim-treesitter.lua
├── nvim-treesitter-textobjects.lua
├── ....
Return a table from this file with the short plugin url for nvim-treesitter-textobjects:
return {
"nvim-treesitter/nvim-treesitter-textobjects",
}
Now set lazy=true
so that this plugin only loads when nvim-treesitter does as we specified it as a dependency
for it earlier.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
}
Then add a config function to enable the plugin through nvim-treesitter once it loads.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function()
require("nvim-treesitter.configs").setup({
})
end
}
Enable Syntax Aware Text Objects
Now enable the treesitter text objects module and set lookahead = true
. With lookahead
set to true
if you are before a text object and execute a keymap for it, it will find the next closest one.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function()
require("nvim-treesitter.configs").setup({
textobjects = {
select = {
enable = true,
-- Automatically jump forward to textobj, similar to targets.vim
lookahead = true,
},
},
})
end,
}
Setup Keymaps Syntax Aware Text Objects
If for example a keymap is something like a=
then you can do va=
, da=
, ca=
or ya=
on this text object.
For a list of valid capture groups for built in text objects you can target: Built-In Text Objects
Support will vary depending on the language you are working with.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function()
require("nvim-treesitter.configs").setup({
textobjects = {
select = {
enable = true,
-- Automatically jump forward to textobj, similar to targets.vim
lookahead = true,
keymaps = {
-- You can use the capture groups defined in textobjects.scm
["a="] = { query = "@assignment.outer", desc = "Select outer part of an assignment" },
["i="] = { query = "@assignment.inner", desc = "Select inner part of an assignment" },
["l="] = { query = "@assignment.lhs", desc = "Select left hand side of an assignment" },
["r="] = { query = "@assignment.rhs", desc = "Select right hand side of an assignment" },
["aa"] = { query = "@parameter.outer", desc = "Select outer part of a parameter/argument" },
["ia"] = { query = "@parameter.inner", desc = "Select inner part of a parameter/argument" },
["ai"] = { query = "@conditional.outer", desc = "Select outer part of a conditional" },
["ii"] = { query = "@conditional.inner", desc = "Select inner part of a conditional" },
["al"] = { query = "@loop.outer", desc = "Select outer part of a loop" },
["il"] = { query = "@loop.inner", desc = "Select inner part of a loop" },
["af"] = { query = "@call.outer", desc = "Select outer part of a function call" },
["if"] = { query = "@call.inner", desc = "Select inner part of a function call" },
["am"] = { query = "@function.outer", desc = "Select outer part of a method/function definition" },
["im"] = { query = "@function.inner", desc = "Select inner part of a method/function definition" },
["ac"] = { query = "@class.outer", desc = "Select outer part of a class" },
["ic"] = { query = "@class.inner", desc = "Select inner part of a class" },
},
},
},
})
end,
}
Above I’ve setup keymaps for assignments, parameters/arguments, conditionals/if statements, loops, function calls, method/function definitions and classes.
Setup Keymaps For Swapping Text Objects
Add a swap
field, enable this treesitter module and add keymaps for swapping a text object with the next/previous occurrence
of it.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function()
require("nvim-treesitter.configs").setup({
textobjects = {
select = {...},
swap = {
enable = true,
swap_next = {
["<leader>na"] = "@parameter.inner", -- swap parameters/argument with next
["<leader>nm"] = "@function.outer", -- swap function with next
},
swap_previous = {
["<leader>pa"] = "@parameter.inner", -- swap parameters/argument with prev
["<leader>pm"] = "@function.outer", -- swap function with previous
},
},
},
})
end,
}
Above I’ve added keymaps for swapping the current parameter/argument with the next and previous one as well as for method/function definitions
Setup Keymaps For Moving To Text Objects
Now add the move
module, enable it, and add keymaps to move to the start and ends of text objects.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function()
require("nvim-treesitter.configs").setup({
textobjects = {
select = {...},
swap = {...},
move = {
enable = true,
set_jumps = true, -- whether to set jumps in the jumplist
goto_next_start = {
["]f"] = { query = "@call.outer", desc = "Next function call start" },
["]m"] = { query = "@function.outer", desc = "Next method/function def start" },
["]c"] = { query = "@class.outer", desc = "Next class start" },
["]i"] = { query = "@conditional.outer", desc = "Next conditional start" },
["]l"] = { query = "@loop.outer", desc = "Next loop start" },
-- You can pass a query group to use query from `queries/<lang>/<query_group>.scm file in your runtime path.
-- Below example nvim-treesitter's `locals.scm` and `folds.scm`. They also provide highlights.scm and indent.scm.
["]s"] = { query = "@scope", query_group = "locals", desc = "Next scope" },
["]z"] = { query = "@fold", query_group = "folds", desc = "Next fold" },
},
goto_next_end = {
["]F"] = { query = "@call.outer", desc = "Next function call end" },
["]M"] = { query = "@function.outer", desc = "Next method/function def end" },
["]C"] = { query = "@class.outer", desc = "Next class end" },
["]I"] = { query = "@conditional.outer", desc = "Next conditional end" },
["]L"] = { query = "@loop.outer", desc = "Next loop end" },
},
goto_previous_start = {
["[f"] = { query = "@call.outer", desc = "Prev function call start" },
["[m"] = { query = "@function.outer", desc = "Prev method/function def start" },
["[c"] = { query = "@class.outer", desc = "Prev class start" },
["[i"] = { query = "@conditional.outer", desc = "Prev conditional start" },
["[l"] = { query = "@loop.outer", desc = "Prev loop start" },
},
goto_previous_end = {
["[F"] = { query = "@call.outer", desc = "Prev function call end" },
["[M"] = { query = "@function.outer", desc = "Prev method/function def end" },
["[C"] = { query = "@class.outer", desc = "Prev class end" },
["[I"] = { query = "@conditional.outer", desc = "Prev conditional end" },
["[L"] = { query = "@loop.outer", desc = "Prev loop end" },
},
},
},
})
end,
}
Doing set_jumps = true
will add these movements to the jump list when you execute them and allow you to
go forwards and backwards through them with <C-o>
(back) and <C-i
> (forwards).
In goto_next_start
define keymaps to go to the next start of a given text object.
In goto_next_end
define keymaps to go to the next end of a given text object.
In goto_next_previous_start
define keymaps to go to the previous start of a given text object.
In goto_next_previous_end
define keymaps to go to the previous end of a given text object.
If defined keymaps here for function calls, method/function definitions, classes, conditionals/if statements and loops.
Now you can also configure ;
and ,
to repeat these movements.
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function()
require("nvim-treesitter.configs").setup({
textobjects = {
select = {...},
swap = {...},
move = {...},
},
})
local ts_repeat_move = require("nvim-treesitter.textobjects.repeatable_move")
-- vim way: ; goes to the direction you were moving.
vim.keymap.set({ "n", "x", "o" }, ";", ts_repeat_move.repeat_last_move)
vim.keymap.set({ "n", "x", "o" }, ",", ts_repeat_move.repeat_last_move_opposite)
-- Optionally, make builtin f, F, t, T also repeatable with ; and ,
vim.keymap.set({ "n", "x", "o" }, "f", ts_repeat_move.builtin_f)
vim.keymap.set({ "n", "x", "o" }, "F", ts_repeat_move.builtin_F)
vim.keymap.set({ "n", "x", "o" }, "t", ts_repeat_move.builtin_t)
vim.keymap.set({ "n", "x", "o" }, "T", ts_repeat_move.builtin_T)
end,
}
The above will configure repeating movements with ;
and ,
and also maintain the built in vim
functionality to use these keys to repeat f
, F
, t
and T
movements. ;
will go the direction
you were moving previously and ,
will go the opposite direction you were moving in.
🚀 BONUS: Adding Your Own Capture Groups
Now let’s say that you want to target specific nodes in the syntax tree not already supported by nvim-treesitter-textobjects for the language that you’re working with.
Treesitter Text Objects Documentation: Adding Queries
To do this you’ll have to add a treesitter query file either in
~/.config/nvim/queries
(to create a new one) or ~/.config/nvim/after/queries
(to extend an existing one).
A query file is used to target specific nodes in a syntax tree, define capture groups
for them like @function.outer
, and then be able to define keymaps for these capture groups
as we did before.
Official Documentation On Writing Queries & Capture Groups: How To Write Queries & Define Capture Groups
Adding Keymaps For TS/JS Object Properties
Let’s say you want to add support for targeting typescript object properties to add some keymaps for them with nvim-treesitter-textobjects
.
The first thing you should do is take a look at the default typescript text objects query file.
The file is located here: Typescript Text Objects Query
You’ll see that the file is called textobjects.scm
and it looks something like this:
; inherits: ecma
(interface_declaration) @class.outer
(interface_declaration
body: (object_type . "{" . (_) @_start @_end _? @_end . "}"
(#make-range! "class.inner" @_start @_end)))
(type_alias_declaration) @class.outer
(type_alias_declaration
value: (object_type . "{" . (_) @_start @_end _? @_end . "}"
(#make-range! "class.inner" @_start @_end)))
(enum_declaration) @class.outer
(enum_declaration
body: (enum_body . "{" . (_) @_start @_end _? @_end . "}"
(#make-range! "class.inner" @_start @_end)))
Don’t worry too much about the particular details, this is so you get an idea on how treesitter queries are written.
Note that this typescript text objects query inherits from the ecma
text objects query file (this is also true for javascript)
This means that we need to extend the ecma
text objects query file so that our addition works with
both typescript
and javascript.
Take a look at the ecma
text objects query file:
You can take look at all of the text object query files for different languages here: All Query Files
To extend the ecma
queries, we’ll need to create this file:
~/.config/nvim
├── init.lua
├── lazy-lock.json
├── after/
└── queries/
└── ecma/
└── textobjects.scm
Write the query
What I would do is to first open a typescript file that has an object, run :InspectTree
and
place the cursor over an object property to see what the nodes in the tree look like.
Or you can also use the treesitter playground
For a javascript object like the following:
const obj = {
id,
title,
description: "This is the recipe description",
creationDate: new Date(),
ingredients: ["ingredient1", "ingredient2", "ingredient3"]
};
It’ll look something like this:
Let’s add our special query to our new query file to target object properties and define capture groups for them:
; extends
(object
(pair
key: (_) @property.lhs
value: (_) @property.inner @property.rhs) @property.outer)
Make sure to include ; extends
because we are extending the ecma
text objects query file.
The first line targets an object:
; extends
(object
(pair
key: (_) @property.lhs
value: (_) @property.inner @property.rhs) @property.outer)
Then inside, we target a pair:
; extends
(object
(pair
key: (_) @property.lhs
value: (_) @property.inner @property.rhs) @property.outer)
Then inside, we target a key and value and set capture groups for these:
; extends
(object
(pair
key: (_) @property.lhs
value: (_) @property.inner @property.rhs) @property.outer)
(_)
is a wildcard node and we use this here so that the key and value of an object property can
be any type of node.
Add keymaps for object properties
Now we can set keymaps for @property.lhs
, @property.inner
, @property.rhs
and @property.outer
to be able to select these, delete them, change them, copy them, swap them, etc…
In nvim-treesitter-textobjects.lua
:
return {
"nvim-treesitter/nvim-treesitter-textobjects",
lazy = true,
config = function() require("nvim-treesitter.configs").setup({
textobjects = {
select = {
enable = true,
-- Automatically jump forward to textobj, similar to targets.vim
lookahead = true,
keymaps = {
-- You can use the capture groups defined in textobjects.scm
["a="] = { query = "@assignment.outer", desc = "Select outer part of an assignment" },
["i="] = { query = "@assignment.inner", desc = "Select inner part of an assignment" },
["l="] = { query = "@assignment.lhs", desc = "Select left hand side of an assignment" },
["r="] = { query = "@assignment.rhs", desc = "Select right hand side of an assignment" },
-- works for javascript/typescript files (custom captures I created in after/queries/ecma/textobjects.scm)
["a:"] = { query = "@property.outer", desc = "Select outer part of an object property" },
["i:"] = { query = "@property.inner", desc = "Select inner part of an object property" },
["l:"] = { query = "@property.lhs", desc = "Select left part of an object property" },
["r:"] = { query = "@property.rhs", desc = "Select right part of an object property" },
["aa"] = { query = "@parameter.outer", desc = "Select outer part of a parameter/argument" },
["ia"] = { query = "@parameter.inner", desc = "Select inner part of a parameter/argument" },
["ai"] = { query = "@conditional.outer", desc = "Select outer part of a conditional" },
["ii"] = { query = "@conditional.inner", desc = "Select inner part of a conditional" },
["al"] = { query = "@loop.outer", desc = "Select outer part of a loop" },
["il"] = { query = "@loop.inner", desc = "Select inner part of a loop" },
["af"] = { query = "@call.outer", desc = "Select outer part of a function call" },
["if"] = { query = "@call.inner", desc = "Select inner part of a function call" },
["am"] = { query = "@function.outer", desc = "Select outer part of a method/function definition" },
["im"] = { query = "@function.inner", desc = "Select inner part of a method/function definition" },
["ac"] = { query = "@class.outer", desc = "Select outer part of a class" },
["ic"] = { query = "@class.inner", desc = "Select inner part of a class" },
["a/"] = { query = "@comment.outer", desc = "Select outer part of a comment" },
["i/"] = { query = "@comment.inner", desc = "Select inner part of a comment" },
},
},
swap = {
enable = true,
swap_next = {
["<leader>na"] = "@parameter.inner", -- swap parameters/argument with next
["<leader>n:"] = "@property.outer", -- swap object property with next
["<leader>nm"] = "@function.outer", -- swap function with next
},
swap_previous = {
["<leader>pa"] = "@parameter.inner", -- swap parameters/argument with prev
["<leader>p:"] = "@property.outer", -- swap object property with prev
["<leader>pm"] = "@function.outer", -- swap function with previous
},
},
},
})
end,
}