Treesitter решает эту проблему фундаментально иначе: он строит из исходного кода настоящее синтаксическое дерево (AST) - то же самое, с чем работает компилятор. А раз у редактора есть дерево, то ему доступна не только точная подсветка, но и совершенно новый класс операций: выделить функцию целиком, прыгнуть к следующему методу, поменять местами два аргумента, свернуть класс - оперируя структурой кода, а не строками и символами. Это логическое продолжение идеи текстовых объектов из главы 6, но теперь границы объектов определяются не паттерном, а грамматикой языка.
В 2026 году Treesitter - не экзотика, а часть ядра Neovim. И именно вокруг него произошёл один из самых заметных сдвигов экосистемы за последние годы. Разберёмся по порядку.
Что такое Treesitter и чем он лучше regex-подсветки
Tree-sitter - это библиотека-генератор парсеров, изначально созданная для Atom, а затем ставшая стандартом де-факто для редакторов. Её ключевые свойства определяют, почему она так хорошо подходит редактору:
- Инкрементальность. Парсер не перечитывает весь файл при каждом нажатии клавиши. Он знает, какой кусочек дерева изменился, и пересобирает только его. Поэтому подсветка остаётся быстрой даже на файлах в десятки тысяч строк, где regex-подсветка начинает тормозить или сдаваться (вспомните :syntax sync и его проблемы).
- Устойчивость к ошибкам. Дерево строится, даже когда код синтаксически не завершён - вы же редактируете его прямо сейчас. Парсер вставляет в дерево узлы-ошибки, но продолжает осмысленно работать вокруг них.
- Конкретность. Узел дерева - это не "слово, похожее на ключевое". Это именно function_declaration, parameter, string_content, comment. Подсветка по дереву не перепутает return внутри строки с оператором возврата.
Рассмотрим строку на JavaScript:
Код: Выделить всё
const re = /class\s+\w+/; // не настоящий class
Важно понимать разделение ответственности. Сам Tree-sitter лишь строит дерево. За то, какой узел в какой цвет красить, отвечают queries - специальные запросы на S-expression-синтаксисе (файлы highlights.scm), которые сопоставляют узлам дерева так называемые capture-группы (@keyword, @function, @string), а уже им тема редактора назначает цвета. Поэтому Treesitter-подсветка единообразна между языками: один и тот же @function в Python и в Rust получит один цвет.
nvim-treesitter в 2026: main против master
Здесь начинается самое важное практическое знание этой главы, потому что в 2025-2026 годах экосистема пережила слом.
Исторически плагин nvim-treesitter делал всё: ставил парсеры, вендорил их в репозитории, включал подсветку, indent и fold через большую таблицу setup{}. В 2025 году проект пережил полный несовместимый rewrite. Расклад на 2026 год такой:
- Ветка master заморожена (locked). Она остаётся только для обратной совместимости с Neovim 0.11 и не работает с Neovim 0.12.
- Ветка main (она же дефолтная) - это переписанный с нуля, более низкоуровневый плагин-обёртка. Требует Neovim 0.12+. На момент написания главы это активно развивающаяся ветка, и часть её возможностей помечена как экспериментальная - сверяйтесь с README репозитория, статус и состав модулей всё ещё меняются.
- Пошаговой миграции с master на main нет. Это не апдейт, а другой плагин с другим API. Treesitter-часть конфига переписывается заново.
Ключевые отличия main от masterКогда плагин всё ещё нужен. Ставить парсеры для языков, которых нет в бандле ядра; обновлять парсеры; пользоваться расширенными возможностями вроде nvim-treesitter-textobjects. Для этого и существует ветка main.
Код: Выделить всё
Что было в master | Что стало в main
----------------------------------------------+---------------------------------------------------------------------------------------------------------------------
Парсеры вендорились в репозиторий | Компилируются локально, нужны актуальный tree-sitter-cli, tar, curl, C-компилятор
ensure_installed = {...} | Удалено. Императивный require('nvim-treesitter').install({...})
auto_install = true | Удалено
highlight = { enable = true } | Удалено. Подсветка включается вручную через vim.treesitter.start()
indent = { enable = true } | Удалено. indentexpr ставится вручную
модуль incremental_selection | Удалён. Встроенной treesitter-замены нет: либо обёртка над LSP vim.lsp.buf.selection_range(), либо community-плагин
main = 'nvim-treesitter.configs' в lazy spec | main = 'nvim-treesitter', branch = 'main'
Установка парсеров: :TSInstall
Интерактивно всё осталось привычным:
Код: Выделить всё
:TSInstall python " поставить парсер Python
:TSInstall lua rust go " несколько сразу
:TSUpdate " обновить все установленные парсеры
:TSUninstall all " снести все (полезно при миграции)
Код: Выделить всё
local langs = { 'lua', 'python', 'rust', 'go', 'bash', 'markdown' }
local installed = require('nvim-treesitter.config').get_installed()
local to_install = vim.tbl_filter(function(lang)
return not vim.tbl_contains(installed, lang)
end, langs)
if #to_install > 0 then
require('nvim-treesitter').install(to_install)
end
После миграции со старой ветки обязательная последовательность: :TSUninstall all, перезапуск Neovim, :TSUpdate, затем :checkhealth nvim-treesitter - health-check честно скажет, не хватает ли tree-sitter-cli или компилятора. И отдельно: CLI ставьте системным пакетным менеджером (brew, apt, pacman), а не через npm - README прямо предупреждает о возможных конфликтах npm-версии.
Включаем подсветку и indent вручную
Раз main больше не включает фичи сам, делаем это сами через autocmd на событие FileType. Это безопасный паттерн, который не падает на файлах без парсера благодаря pcall:
Код: Выделить всё
vim.api.nvim_create_autocmd('FileType', {
callback = function()
-- подсветка через ядро Neovim
pcall(vim.treesitter.start)
-- indent через nvim-treesitter (main)
vim.bo.indentexpr = "v:lua.require'nvim-treesitter'.indentexpr()"
end,
})
Частая причина "нет подсветки": имя парсера != filetype. Ядро ищет парсер по имени, совпадающему с filetype буфера. Для стандартных языков это совпадает (python -> парсер python). Но если у вас кастомный или нестандартный filetype (скажем, буфер с ft bash, а парсер называется sh, или собственный ft mylang), vim.treesitter.start() молча не найдёт парсер. Решается явной привязкой языка к filetype:
Код: Выделить всё
-- сопоставить filetype 'bash' парсеру 'sh'
vim.treesitter.language.register('sh', 'bash')
-- одному парсеру можно отдать несколько filetype:
vim.treesitter.language.register('markdown', { 'markdown', 'rmd', 'quarto' })
Отладка дерева: :InspectTree и :Inspect
Когда подсветка "не та" или вы пишете собственные queries, два встроенных инструмента ядра незаменимы:
- :InspectTree (Lua-эквивалент vim.treesitter.inspect_tree()) открывает живое окно с синтаксическим деревом текущего буфера. Двигая курсор по дереву, вы видите, какой диапазон исходника подсвечивается, как называются узлы (function_declaration, parameter, ...) - именно эти имена нужны для написания запросов и textobject-капчуров.
- :Inspect показывает, какие highlight-группы и treesitter-капчуры (@function, @keyword.return и т.п.) применены к символу под курсором, и из какого источника они пришли (treesitter, syntax, semantic tokens LSP). Это первый инструмент, когда "слово красится не в тот цвет".
Свёртки (folding) на основе дерева
Treesitter даёт "умные" свёртки, которые сворачивают по логическим блокам (функции, классы), а не по отступам. Настраивается это опциями ядра (подробнее о свёртках - в главе 19):
Код: Выделить всё
vim.o.foldmethod = 'expr'
vim.o.foldexpr = 'v:lua.vim.treesitter.foldexpr()'
vim.o.foldlevel = 99 -- по умолчанию ничего не свёрнуто
Incremental selection: расширяем выделение по дереву
Одна из самых интуитивных возможностей Treesitter - пошаговое расширение визуального выделения вверх по дереву. Вы стоите внутри выражения, нажимаете клавишу - выделяется текущий узел; нажимаете ещё - выделяется родительский узел (всё выражение, затем оператор, затем функция, затем класс). Это "увеличение масштаба" по структуре.
Важное изменение 2026: модуль incremental_selection из nvim-treesitter удалён на ветке main и прямого встроенного аналога именно по дереву Neovim в ядре не поставляет. Никаких дефолтных клавиш van/an и автоматического "расширения по узлам" в ядре нет - это интерфейс community-плагинов, а не Neovim. Вариантов замены два.
Вариант 1: обёртка над LSP selection_range. В ядре есть функция vim.lsp.buf.selection_range() - это range/incremental selection через LSP, а не через treesitter-дерево. Она ничем не привязана к конкретным клавишам по умолчанию; вы сами вешаете её на удобный keymap, и для работы нужен LSP-сервер, поддерживающий метод textDocument/selectionRange (о настройке LSP - глава 25):
Код: Выделить всё
-- расширить выделение через LSP (не treesitter!)
vim.keymap.set({ 'n', 'x' }, '<leader>v', function()
vim.lsp.buf.selection_range('outer') -- расширить
end)
vim.keymap.set('x', '<leader>V', function()
vim.lsp.buf.selection_range('inner') -- сузить обратно
end)
Вариант 2: community-плагин с treesitter-based выделением. Если нужно расширение именно по AST-узлам без LSP, поставьте community-плагин incremental selection (плагин nvim-treesitter-textobjects такого модуля не содержит). Подбирайте актуальный поддерживаемый проект под свою конфигурацию - конкретные имена быстро устаревают, поэтому ищите по запросу "neovim treesitter incremental selection" и сверяйтесь с тем, что плагин совместим с веткой main и Neovim 0.12.
Тонкая настройка: runtime queries и language injection
Подсветка, folds, indent, injections - всё это задаётся не кодом, а запросами (.scm-файлы в каталогах queries/<язык>/): highlights.scm, folds.scm, indents.scm, injections.scm. Ядро Neovim и плагин поставляют свои наборы, но вы можете их дополнять и переопределять своими runtime-запросами, не трогая чужие файлы.
Расширение highlights своими запросами
Neovim собирает запрос для языка, сканируя весь runtimepath. Если положить свой файл в after/queries/<язык>/highlights.scm (каталог after/ подгружается последним), он применится поверх стандартного. Ключевой момент - директива ;extends: с ней ваш файл дополняет базовый запрос, без неё - заменяет его целиком.
Код: Выделить всё
; файл: ~/.config/nvim/after/queries/python/highlights.scm
;extends
; покрасить вызовы self.<method> особой группой
(call
function: (attribute
object: (identifier) @_obj (#eq? @_obj "self")
attribute: (identifier) @function.method.call))
Language injection: вложенные языки
Помните пример из вступления - SQL внутри строки, regex, код в Markdown? За это отвечает механизм language injection: treesitter умеет внутри узла одного языка запускать парсер другого и подсвечивать вложенный фрагмент по его собственной грамматике. Описывается это запросами injections.scm - нужно лишь, чтобы был установлен парсер вложенного языка.
Часть инъекций встроена (Markdown code blocks подсвечиваются по языку из информационной строки ограждения, например sql). Свою инъекцию добавляют тем же runtime-механизмом - after/queries/<хост-язык>/injections.scm с ;extends:
Код: Выделить всё
; файл: ~/.config/nvim/after/queries/lua/injections.scm
;extends
; строку, переданную в db:query("..."), парсить как SQL
((function_call
name: (_) @_fn (#eq? @_fn "db:query")
arguments: (arguments (string content: _ @injection.content)))
(#set! injection.language "sql"))
treesitter-textobjects: оперируем функциями и аргументами
Теперь главное "структурное" оружие. Плагин nvim-treesitter-textobjects (ставится с branch = 'main', официально остаётся experimental) добавляет синтаксически точные текстовые объекты и движения. В отличие от regex-объектов (см. mini.ai в главе 6), они берут границы из AST и не дают ложных срабатываний внутри строк и комментариев.
Плагин состоит из модулей select, move, swap, lsp_interop. Важное отличие main: плагин не навешивает никакие маппинги автоматически. Никаких дефолтных ftplugin-мап отключать не нужно - все биндинги вы задаёте сами, явно, через vim.keymap.set и вызовы модулей плагина. То есть "перебить ваши биндинги" плагину нечем: что не замапили - того и нет. (Старый приём с vim.g.no_plugin_maps относился к master и на main неактуален.)
Текстовые объекты (модуль select)
Соглашение об именах - то же, что у обычных текстовых объектов Vim: a (around, вместе с обрамлением) и i (inner, только содержимое). Типичная раскладка:
Код: Выделить всё
Объект | Что выделяет
---------+-------------------------------------------------
af / if | функция (вся / тело)
ac / ic | класс (весь / тело)
aa / ia | аргумент/параметр (с запятой / только значение)
al / il | цикл
ai / ii | условный блок (if)
a/ / i/ | комментарий
Код: Выделить всё
было: def calculate(x, y):
return x + y
cursor где угодно внутри функции
dif -> удалить тело функции (def calculate(x, y): остаётся)
daf -> удалить функцию целиком
yaf -> скопировать функцию
caf -> заменить функцию (стереть и войти в insert)
Код: Выделить всё
было: foo(alpha, beta, gamma)
^ курсор на beta
dia -> foo(alpha, gamma) (аргумент с обслуживающей запятой удалён)
cia -> foo(alpha, , gamma) (только значение beta стёрто, ввод)
Код: Выделить всё
vim.keymap.set({ 'x', 'o' }, 'af', function()
require('nvim-treesitter-textobjects.select')
.select_textobject('@function.outer', 'textobjects')
end)
Движения позволяют прыгать к следующей/предыдущей синтаксической конструкции. Классическая раскладка повторяет логику меток методов из IDE:
Код: Выделить всё
Клавиша | Прыжок
---------+--------------------------------------------------
]m / [m | к началу следующей / предыдущей функции (method)
]M / [M | к концу функции
]] / [[ | к началу следующего / предыдущего класса
][ / [] | к концу класса
]a / [a | к следующему / предыдущему аргументу
Код: Выделить всё
class Service:
def first(self): ... <- ]m ставит курсор сюда
def second(self): ... <- ещё ]m сюда
def third(self): ...
Повторяемые движения: ; и ,
Очень удобная деталь: движения по textobjects можно сделать повторяемыми теми же ; и ,, что повторяют f/t (глава 3). Модуль repeatable_move запоминает последнее move-движение, и ; повторяет его вперёд, , - назад. Рекомендуется обернуть в эти же клавиши и обычные f/F/t/T, чтобы поведение было единообразным:
Код: Выделить всё
local ts_repeat = require('nvim-treesitter-textobjects.repeatable_move')
vim.keymap.set({ 'n', 'x', 'o' }, ';', ts_repeat.repeat_last_move_next)
vim.keymap.set({ 'n', 'x', 'o' }, ',', ts_repeat.repeat_last_move_previous)
-- единообразие с f/t:
vim.keymap.set({ 'n', 'x', 'o' }, 'f', ts_repeat.builtin_f_expr, { expr = true })
vim.keymap.set({ 'n', 'x', 'o' }, 'F', ts_repeat.builtin_F_expr, { expr = true })
Структурное редактирование: swap
Модуль swap - это и есть "структурное редактирование" в чистом виде. Он меняет местами узлы дерева, а не строки. Самый частый кейс - переставить аргументы функции, не считая запятые и не выделяя вручную:
Код: Выделить всё
Клавиша (типовая) | Действие
-------------------+------------------------------------------------------------------------
<leader>a | поменять текущий аргумент со следующим (swap_next('@parameter.inner'))
<leader>A | поменять со предыдущим (swap_previous('@parameter.inner'))
Код: Выделить всё
было: render(width, height)
^ курсор на width
<leader>a -> render(height, width)
Лучшие практики 2026
- Сначала проверьте, нужен ли плагин вообще. На Neovim 0.12 подсветка для большинства языков уже в ядре. Если вам хватает её, не тащите nvim-treesitter по инерции - облегчите конфиг. Плагин берите ради парсеров редких языков и textobjects.
- На 0.12 - только ветка main, на 0.11 - только master. Несоответствие версии теперь даёт жёсткую ошибку. В lazy spec явно укажите branch = 'main' и main = 'nvim-treesitter' (не nvim-treesitter.configs).
- Не пытайтесь мигрировать конфиг построчно. main - это rewrite. Удалите старый блок целиком и напишите заново по новому API. Многие фичи master намеренно не имеют прямой замены.
- Включайте подсветку/indent сами через FileType-autocmd с pcall(vim.treesitter.start). На main они не включаются автоматически - это не баг, а дизайн.
- Нет подсветки при кастомном filetype - проверьте имя парсера. Ядро ищет парсер по имени filetype; если они не совпадают, привяжите явно через vim.treesitter.language.register('<парсер>', '<filetype>').
- Отлаживайте деревом, а не догадками. :InspectTree показывает структуру и имена узлов, :Inspect - какой капчур/highlight лёг под курсор и откуда. Это первые команды при "красится не так" и при написании своих queries.
- Тонкую настройку делайте runtime-запросами. Свои highlights/injections кладите в after/queries/<язык>/*.scm с директивой ;extends (дополнить, а не заменить базовый запрос). Для вложенных языков (SQL/regex/Markdown) - injections.scm плюс установленный парсер вложенного языка.
- tree-sitter-cli - системным пакетником. И помните про зависимости: tar, curl, C-компилятор в PATH. После миграции прогоняйте :checkhealth nvim-treesitter.
- Для точности текстовых объектов выбирайте AST. treesitter-textobjects или mini.ai с gen_spec.treesitter() точнее, чем чистые regex/Lua-pattern объекты, которые не отличают код от строки или комментария (см. главу 6).
- Сделайте move-движения повторяемыми через ;/, - это превращает прыжки по функциям в одну клавишу и снимает потребность считать.
- Готовый референс - пресет Treesitter из LazyVim. Если не хотите собирать spec руками, посмотрите, как LazyVim оформляет branch, build и autocmd под новый main-ориентированный мир.
- Оставили ensure_installed/auto_install на main. Поля удалены и игнорируются молча - парсеры не ставятся, подсветки нет. Используйте install() API.
- Ждёте, что подсветка/indent включатся сами после установки main. Нет. Без FileType-autocmd с vim.treesitter.start() подсветки не будет.
- Запустили main на Neovim 0.11 (или master на 0.12). Несовместимо. Сверяйтесь с версией.
- Ставите tree-sitter-cli через npm. README предупреждает: только системный пакетный менеджер, иначе возможны конфликты.
- Рассчитываете на incremental_selection из плагина. Модуль удалён, и встроенной treesitter-замены в ядре нет. Варианты: обёртка над vim.lsp.buf.selection_range() (нужен LSP-сервер с поддержкой selectionRange, расширение идёт по LSP, а не по дереву) либо community-плагин с treesitter-выделением. Дефолтных клавиш van/an в ядре не существует.
- Ждёте, что textobjects сам навесит мапы. На main плагин не задаёт биндинги автоматически: пока вы сами не вызвали vim.keymap.set и модули select/move/swap, никаких af/]m/swap не будет.
- Забыли :TSUninstall all + :TSUpdate при миграции. Старые вендоренные парсеры могут конфликтовать с локально скомпилированными.
- Держите nvim-treesitter-textobjects как стабильный. Он официально experimental и завязан на статус treesitter в ядре - закладывайтесь на возможные изменения API.
- Подсветка под контролем. На Neovim 0.12 откройте файл языка, которого нет в бандле ядра (например, *.zig или *.nix). Убедитесь, что подсветки нет, выполните :TSInstall <язык>, перезапустите буфер и сравните. Запустите :checkhealth nvim-treesitter и прочитайте, что он сообщает о tree-sitter-cli.
- Функция целиком. Возьмите файл с несколькими функциями. Встав курсором в середину одной, поочерёдно примените vif, vaf, dif, yaf, caf. Сформулируйте словами разницу между i и a для функции.
- Прыжки по методам. В файле с классом из 5+ методов настройте ]m/[m и пройдите по всем методам туда и обратно. Затем сделайте движение повторяемым через ;/, и пройдите тот же путь, нажимая только ;.
- Перестановка аргументов. Найдите вызов функции с тремя аргументами в неправильном порядке. Используя swap (<leader>a/<leader>A), приведите их в нужный порядок, ни разу не удаляя текст вручную и не считая запятые.
- Расширение выделения. Замапьте vim.lsp.buf.selection_range('outer') на удобную клавишу (нужен активный LSP-сервер с поддержкой selectionRange). Встаньте внутрь вложенного выражения (например, a + b * (c - d)) и несколько раз нажмите её, расширяя выделение шаг за шагом. Запишите, на каком шаге какой диапазон выделился, и отметьте: границы тут определяет LSP-сервер, а не treesitter-дерево.
- Чистый конфиг (для тех, кто на 0.12). Откройте свой treesitter-spec и проверьте: нет ли в нём мёртвых полей ensure_installed/highlight.enable; указан ли branch = 'main'; есть ли FileType-autocmd. Удалите всё лишнее и проверьте, что подсветка осталась.
- Дерево под микроскопом. В любом файле выполните :InspectTree и подвигайте курсор, сопоставляя узлы дерева с кодом. Затем поставьте курсор на ключевое слово и вызовите :Inspect - запишите, какой treesitter-капчур и highlight-группа на нём лежат. Найдите символ, у которого :Inspect не показывает treesitter-капчура, и объясните почему.
- Своя инъекция. Установите парсер sql (:TSInstall sql). Создайте after/queries/<ваш-язык>/injections.scm с ;extends, который помечает строку-аргумент выбранной функции как SQL, и убедитесь через :InspectTree, что внутри строки появилось вложенное SQL-дерево, а подсветка стала "как в SQL".
- Treesitter строит настоящее синтаксическое дерево (AST) инкрементально и устойчиво к ошибкам. Это даёт точную подсветку без ложных срабатываний regex-подхода и открывает структурное редактирование.
- Подсветкой теперь владеет ядро Neovim (vim.treesitter.start()), а плагин nvim-treesitter поставляет парсеры. На Neovim 0.12 базовая подсветка работает из коробки.
- Сейсмический сдвиг 2026: ветка master заморожена (только 0.11), main - переписанный плагин для 0.12+. Миграция = переписать конфиг заново; ensure_installed, авто-highlight и incremental_selection удалены.
- :TSInstall ставит парсеры (нужен системный tree-sitter-cli); подсветку и indent на main включаете вручную через FileType-autocmd.
- treesitter-textobjects дают AST-точные объекты af/if, ac/ic, aa/ia, движения ]m/[m, повторяемые ;/, и swap для перестановки узлов - это и есть структурное редактирование.
- Incremental selection из плагина удалён, встроенной treesitter-замены в ядре нет: либо обёртка над vim.lsp.buf.selection_range() (по LSP), либо community-плагин. Дефолтных van/an в ядре не существует.
- Отладка и тонкая настройка - через ядро. :InspectTree и :Inspect показывают дерево и капчуры; свои highlights/injections добавляются runtime-запросами в after/queries/.../*.scm с ;extends. При кастомном filetype не забывайте vim.treesitter.language.register().
- Language injection даёт подсветку вложенных языков (SQL/regex/Markdown) по их грамматике - через injections.scm и установленный парсер вложенного языка.
- Дальше: про пикеры и поиск - глава 28, про LSP - глава 25, про текстовые объекты mini.ai - глава 6.