Редакторы и IDE Neovim и конфигурация: Lua, LSP, Treesitter, плагины, разбор реальных dotfiles
0%

Neovim и конфигурация: Lua, LSP, Treesitter, плагины, разбор реальных dotfiles

Neovim и конфигурация: Lua, LSP, Treesitter, плагины, разбор реальных dotfiles

В прошлой статье мы разобрали Vim как язык редактирования: регистры, макросы, текстовые объекты, quickfix. Всё это работает в Neovim без единой правки — модель редактирования осталась той же. Различие лежит ниже: в том, как редактор устроен внутри и на чём его настраивают.

Эта статья — про инженерную часть. Что именно форкнули в 2014 году и зачем. Почему Lua. Как работает встроенный LSP-клиент и чем он отличается от coc.nvim. Что Treesitter даёт сверх подсветки. Как собрать конфигурацию, которая стартует за 50 мс и не разваливается через год. И в конце — разбор настоящего публичного репозитория dotfiles, где видно и хорошие решения, и типичное наслоение.

Что произошло в 2014 году

Vim к тому моменту был монолитом на C, который писал и поддерживал по сути один человек — Брам Моленар. Архитектурно у него было два больных места:

  1. Всё синхронное. Vim 7 работал в одном потоке с одним циклом событий. Запуск линтера или сборки блокировал редактор. Плагины обходили это костылями через vimproc, скрытые терминалы и питоновские треды — каждый по-своему, все хрупко.
  2. Vim script. Язык, выросший из формата конфигурации: медленный, без нормальной модели модулей. Тяжёлые плагины писали на Python/Ruby через встроенные интерпретаторы, что делало Vim заложником версии Python в системе.

Тьяго де Арруда предложил серию патчей на асинхронность, они не были приняты, и он собрал средства на Bountysource и форкнул проект. Цели форка формулировались инженерно: убрать легаси под мёртвые платформы, вынести UI из ядра, сделать API вместо монолита, поддерживать многих контрибьюторов вместо одного мейнтейнера.

Важно: это не была война редакторов. Vim в ответ ускорился. Vim 8.0 (2016) получил собственные jobs, channels и штатные пакеты — во многом под давлением форка. Vim 9.0 (2022) принёс Vim9script, компилируемый диалект, местами в десятки раз быстрее старого. Оба проекта живы, и оба выиграли от конкуренции.

Архитектура: ядро, RPC, клиенты

Ключевое архитектурное решение — UI не является частью редактора. Ядро запускается как процесс, говорящий с внешним миром по msgpack-RPC. Терминальный nvim — просто один из клиентов этого протокола, встроенный для удобства.

Практические следствия, важные даже если вы никогда не откроете :h api:

  • Любой процесс может управлять редактором. Neovide рисует ту же сессию с GPU-рендерингом, Firenvim подставляет Neovim в текстовые поля браузера, расширение VS Code Neovim использует настоящее ядро вместо эмуляции модальности.
  • Плагины могут быть написаны на чём угодно. Плагин на Rust — просто процесс, подключённый к RPC. Так устроен fuzzy-матчинг в blink.cmp.
  • Ничто не блокирует редактор. Языковой сервер, форматтер и ripgrep живут в цикле событий libuv. Пока gopls индексирует монорепозиторий, вы продолжаете печатать.
  • Эмуляция vs. встраивание. Плагины «Vim mode» реализуют модальность заново и всегда отстают. Встраивание настоящего ядра даёт полную совместимость, но тащит за собой модель буферов Neovim, которая плохо стыкуется с вкладками хост-редактора. Это разменные варианты, а не «правильный» и «неправильный» — подробнее в статье про VS Code.

Почему Lua

Vim script в Neovim продолжает работать: любой .vimrc подхватится. Но конфигурация на Lua — другой класс инструмента.

LuaJIT с семантикой Lua 5.1 — один из самых быстрых динамических рантаймов вообще. На горячих участках (подсветка, статуслайн, обработчики событий) разница со старым Vim script измеряется в десятки раз.

Модули. require("plugins.lsp") кеширует результат и даёт структуру из десятка маленьких файлов вместо тысячестрочного монолита. У Vim script для этого нет ничего, кроме source и соглашения autoload/.

Настоящие данные — таблицы, замыкания, функции первого класса:

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "typescript", "typescriptreact" },
  callback = function(args)
    vim.bo[args.buf].shiftwidth = 2   -- vim.bo — буферная опция, а не глобальная
  end,
})

callback здесь — функция, которую можно протестировать, переиспользовать, обернуть в pcall и передать в другой модуль. В Vim script на её месте была бы строка, которую интерпретатор разберёт когда-нибудь потом.

Честная оговорка. Lua 5.1 — не самый приятный язык: индексация с единицы, неочевидное поведение nil в таблицах, нет continue, нет типов (частично компенсируют аннотации LuaLS). Vim9script на микробенчмарках местами обгоняет LuaJIT. Но вся экосистема плагинов последних пяти лет — на Lua, и практически это решает вопрос.

Скелет конфигурации

Neovim читает ~/.config/nvim/init.lua; всё, что лежит в ~/.config/nvim/lua/, доступно через require.

~/.config/nvim/
├── init.lua                 -- 15 строк: порядок загрузки и больше ничего
├── lua/core/{options,keymaps,autocmds}.lua
├── lua/plugins/{lsp,completion,treesitter,editor,ui}.lua
└── lazy-lock.json           -- зафиксированные версии плагинов, В ГИТ ОБЯЗАТЕЛЬНО

Правило, экономящее больше всего времени: один файл — одна ответственность, длиной не больше двух экранов. Конфиг растёт монотонно; если он растёт в одном файле, через год вы перестанете понимать, какая строка что делает, и начнёте бояться его трогать.

-- init.lua
vim.g.mapleader = " "        -- ДО загрузки lazy.nvim, иначе маппинги плагинов уедут
vim.g.maplocalleader = "\\"

require("core.options"); require("core.keymaps"); require("core.autocmds")

-- bootstrap: конфиг должен разворачиваться на чистой машине сам
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
  vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable",
    "https://github.com/folke/lazy.nvim.git", lazypath })
end
vim.opt.rtp:prepend(lazypath)

require("lazy").setup("plugins", {
  performance = { rtp = { disabled_plugins = { "gzip", "zipPlugin", "tutor", "netrwPlugin" } } },
})
-- lua/core/options.lua
local o = vim.opt
o.number, o.relativenumber = true, true  -- относительная нумерация окупается вместе с 5j / 12k
o.signcolumn = "yes"        -- фиксируем колонку знаков, иначе текст прыгает при диагностике
o.termguicolors = true
o.expandtab, o.shiftwidth, o.tabstop = true, 2, 2
o.ignorecase, o.smartcase = true, true   -- без учёта регистра, пока в запросе нет заглавных
o.inccommand = "split"      -- предпросмотр :s вживую — фича Neovim, которой нет в Vim
o.undofile = true           -- undo переживает перезапуск; вместе с undo-tree это суперсила
o.swapfile = false
o.updatetime = 250          -- влияет на CursorHold: подсказки и подсветка ссылок
o.timeoutlen = 400          -- сколько ждать продолжения последовательности клавиш
o.scrolloff = 8
o.splitright, o.splitbelow = true, true

Отдельно про clipboard = "unnamedplus" — соединение безымянного регистра с системным буфером. Удобно, но цена: любое x, d, c затирает системный буфер. Многие вместо этого оставляют регистры раздельными и вешают явные маппинги:

-- lua/core/keymaps.lua
local map = vim.keymap.set
map({ "n", "v" }, "<leader>y", '"+y', { desc = "Копировать в системный буфер" })
map("x", "<leader>p", [["_dP]], { desc = "Вставить без затирания регистра" })
map({ "n", "v" }, "<leader>d", [["_d]], { desc = "Удалить в чёрную дыру" })
map("v", "J", ":m '>+1<CR>gv=gv")   -- двигать выделение, сохраняя отступы
map("n", "n", "nzzzv")              -- центрировать при прыжках: глаза не теряют позицию
map("n", "<Esc>", "<cmd>nohlsearch<CR>", { desc = "Снять подсветку поиска" })
map("t", "<Esc><Esc>", "<C-\\><C-n>", { desc = "Выход из терминального режима" })

desc — не косметика: which-key и :Telescope keymaps показывают именно его. Без описаний через полгода вы не вспомните, что делает <leader>gh.

-- lua/core/autocmds.lua — clear = true обязателен, иначе при :source обработчики дублируются
local group = vim.api.nvim_create_augroup("UserConfig", { clear = true })

vim.api.nvim_create_autocmd("TextYankPost", {   -- дешёвый фидбэк, что yank сработал
  group = group,
  callback = function() vim.hl.on_yank({ timeout = 150 }) end,
})

vim.api.nvim_create_autocmd("BufReadPost", {    -- вернуться на позицию, где закрыли файл
  group = group,
  callback = function(args)
    local mark = vim.api.nvim_buf_get_mark(args.buf, '"')
    if mark[1] > 0 and mark[1] <= vim.api.nvim_buf_line_count(args.buf) then
      pcall(vim.api.nvim_win_set_cursor, 0, mark)
    end
  end,
})

Менеджеры плагинов и ленивая загрузка

Менеджер Язык Ленивая загрузка Лок-файл Статус
lazy.nvim Lua богатая: event/ft/cmd/keys lazy-lock.json де-факто стандарт
mini.deps Lua ручная, через later() снапшот минималистичный
paq-nvim Lua нет нет ~300 строк, читается целиком
vim-plug Vim script примитивная: for, on нет работает и в Vim
packer.nvim Lua да да не поддерживается, мигрируйте
встроенный vim.pack новый, см. :h vim.pack

Идея lazy.nvim: плагин не грузится, пока не понадобится. Триггером служит событие, тип файла, команда или нажатие клавиши.

Бюджет старта Neovim: жадная загрузка против ленивой

Измерять, а не гадать:

nvim --startuptime /tmp/start.log +q && sort -k2 -rn /tmp/start.log | head -25
nvim +"Lazy profile"     # время каждого плагина
nvim +checkhealth        # чего не хватает в окружении

Порог, за которым старт начинает раздражать, — около 100 мс; ниже 60 мс редактор ощущается мгновенным. Выше 300 мс появляется соблазн держать один экземпляр открытым постоянно, и вы теряете главное преимущество терминального редактора — дешёвый запуск.

Три главных пожирателя бюджета: жадно загруженные Treesitter-парсеры (ensure_installed = "all" почти всегда ошибка), движок автодополнения без event = "InsertEnter" и плагины «на всякий случай». Тест на последнее простой: удалите плагин и поживите неделю.

LSP: как работает встроенный клиент

Language Server Protocol придумали в Microsoft, чтобы разорвать комбинаторику «N редакторов × M языков». Языковой сервер — отдельный процесс, который понимает язык и отвечает на JSON-RPC-запросы: где определение, какие ссылки, что не так с файлом, как его отформатировать. Редактор про язык не знает ничего.

Канонический способ на 0.9–0.11 — через nvim-lspconfig, коллекцию готовых описаний серверов:

-- lua/plugins/lsp.lua
return {
  {
    "neovim/nvim-lspconfig",
    event = { "BufReadPre", "BufNewFile" },
    dependencies = { { "mason-org/mason.nvim", config = true }, "mason-org/mason-lspconfig.nvim" },
    config = function()
      -- маппинги вешаем ТОЛЬКО когда сервер реально прицепился к буферу
      vim.api.nvim_create_autocmd("LspAttach", {
        group = vim.api.nvim_create_augroup("UserLspAttach", { clear = true }),
        callback = function(ev)
          local opts, map = { buffer = ev.buf }, vim.keymap.set
          map("n", "gd", vim.lsp.buf.definition, opts)
          map("n", "gr", vim.lsp.buf.references, opts)
          map("n", "K", vim.lsp.buf.hover, opts)
          map("n", "<leader>rn", vim.lsp.buf.rename, opts)
          map({ "n", "v" }, "<leader>ca", vim.lsp.buf.code_action, opts)
          map("n", "]d", function() vim.diagnostic.jump({ count = 1 }) end, opts)

          -- фичу включаем, только если сервер объявил её в capabilities
          local client = vim.lsp.get_client_by_id(ev.data.client_id)
          if client and client:supports_method("textDocument/inlayHint") then
            vim.lsp.inlay_hint.enable(true, { bufnr = ev.buf })
          end
        end,
      })

      vim.diagnostic.config({
        virtual_text = { spacing = 2, prefix = "●" },
        severity_sort = true,
        float = { border = "rounded", source = true },
      })

      local lspconfig = require("lspconfig")
      local caps = require("blink.cmp").get_lsp_capabilities()
      for server, cfg in pairs({
        gopls = { settings = { gopls = { staticcheck = true } } },
        ts_ls = {}, elixirls = {},
        lua_ls = { settings = { Lua = { diagnostics = { globals = { "vim" } } } } },
      }) do
        cfg.capabilities = caps
        lspconfig[server].setup(cfg)
      end
    end,
  },
}

Начиная с 0.11 то же самое делается штатным API — описание сервера кладётся в отдельный файл ~/.config/nvim/lsp/gopls.lua, а в init.lua остаётся vim.lsp.enable({ "gopls", "lua_ls" }):

return {
  cmd = { "gopls" },
  filetypes = { "go", "gomod", "gowork" },
  root_markers = { "go.work", "go.mod", ".git" },
  settings = { gopls = { staticcheck = true } },
}

nvim-lspconfig при этом не умирает — он остаётся ценной базой выверенных команд запуска и root_markers для сотен серверов. Просто теперь это данные, а не движок. Заодно 0.11 принёс дефолтные маппинги grn/gra/grr/gri и K: мнемоника «g + r + буква» непривычна первые дни, зато не конфликтует с пользовательскими биндами и работает на чужой машине.

coc.nvim: честное сравнение

coc.nvim появился раньше встроенного клиента и решал реальную проблему — приносил в Vim инфраструктуру расширений VS Code.

Встроенный vim.lsp coc.nvim
Зависимости нет Node.js обязателен
Расширения плагины Neovim свои coc-*
Конфигурация Lua, в общем конфиге JSON в coc-settings.json, отдельный слой
Работает в Vim нет да
Память процесс на сервер плюс постоянный Node-процесс
Настройка «из коробки» собирается самому быстрее стартовать
Интеграция с Telescope, trouble, dap полная через мосты

Правило простое: на Neovim берите встроенный клиент, он давно перерос детские болезни. На Vim coc.nvim — абсолютно рабочее решение, стыдиться нечего. Чего делать точно не стоит — держать оба одновременно; почему, видно в разборе dotfiles ниже.

Автодополнение

vim.lsp даёт источник кандидатов, но не UI. Варианты: blink.cmp (современный дефолт, fuzzy-матчинг на Rust), nvim-cmp (многолетний стандарт, гибче, но собирается из десятка плагинов) и встроенное vim.lsp.completion.enable() — без плагинов вообще, UI аскетичный.

-- lua/plugins/completion.lua
return {
  {
    "saghen/blink.cmp",
    event = "InsertEnter",              -- ключевое: не грузим при старте
    version = "*",
    dependencies = { "rafamadriz/friendly-snippets" },
    opts = {
      keymap = { preset = "default" },  -- <C-y> подтвердить, <C-n>/<C-p> навигация
      sources = { default = { "lsp", "path", "snippets", "buffer" } },
      completion = { accept = { auto_brackets = { enabled = true } } },
      signature = { enabled = true },
    },
  },
}

Одна настройка сильно влияет на ощущения: не подтверждайте дополнение по Enter. Enter должен вставлять перенос строки. Иначе вы будете регулярно получать случайно вставленный идентификатор вместо новой строки. Подтверждение — <C-y> или <Tab>.

Treesitter: синтаксис, который редактор понимает

Классическая подсветка в Vim — набор регулярных выражений. Регулярки не умеют считать вложенность, ломаются на длинных файлах и не отличают переменную от свойства объекта. Отсюда знакомая боль: подсветка «уезжает» в большом JSX-файле, и приходится жать :syntax sync fromstart.

Tree-sitter — генератор инкрементальных парсеров, рождённый в Atom и ставший стандартом. Он строит настоящее синтаксическое дерево и обновляет его при каждой правке, перестраивая только затронутые поддеревья.

Инкрементальный разбор в Tree-sitter

Стоимость правки пропорциональна размеру правки и глубине дерева, а не длине файла — поэтому подсветка не деградирует на файле в двадцать тысяч строк. Плюс парсер устойчив к ошибкам: недописанная функция не ломает дерево, поддерево помечается как ERROR, остальное разбирается дальше. Это критично — код в редакторе синтаксически невалиден большую часть времени.

-- lua/plugins/treesitter.lua
return {
  {
    "nvim-treesitter/nvim-treesitter",
    build = ":TSUpdate",
    event = { "BufReadPost", "BufNewFile" },
    dependencies = { "nvim-treesitter/nvim-treesitter-textobjects" },
    opts = {
      -- только используемые языки: каждый парсер — это компиляция и мегабайты
      ensure_installed = { "lua", "vim", "vimdoc", "query", "go", "typescript", "tsx",
                           "elixir", "heex", "json", "yaml", "markdown", "bash" },
      highlight = {
        enable = true,
        disable = function(_, buf)   -- на файлах >1 МБ парсинг дороже пользы
          local ok, stats = pcall(vim.uv.fs_stat, vim.api.nvim_buf_get_name(buf))
          return ok and stats and stats.size > 1024 * 1024
        end,
      },
      indent = { enable = true },
      -- расширять выделение по узлам дерева: <CR> вверх, <BS> обратно
      incremental_selection = { enable = true, keymaps = {
        init_selection = "<CR>", node_incremental = "<CR>", node_decremental = "<BS>" } },
      textobjects = {
        select = {
          enable = true,
          lookahead = true,          -- прыгнет к следующему объекту, если курсор вне него
          keymaps = {
            ["af"] = "@function.outer", ["if"] = "@function.inner",
            ["aa"] = "@parameter.outer", ["ia"] = "@parameter.inner",
          },
        },
        move = { enable = true, set_jumps = true,
          goto_next_start = { ["]f"] = "@function.outer" },
          goto_previous_start = { ["[f"] = "@function.outer" } },
      },
    },
    config = function(_, opts) require("nvim-treesitter.configs").setup(opts) end,
  },
}

Текстовые объекты по дереву — то, ради чего стоит ставить Treesitter, даже если подсветка вас устраивала. daf удаляет функцию целиком независимо от языка и стиля отступов, cia меняет аргумент, ]f прыгает к следующей функции. Сравните с классическими текстовыми объектами Vim: di( работает на скобках, но «функция» и «аргумент» — понятия языка, а не скобок.

Второй недооценённый механизм — инъекции языков: SQL внутри Go-строки, CSS внутри шаблонного литерала JS, HEEx-шаблон внутри Elixir-модуля подсвечиваются своими парсерами. Регулярками это не выражается в принципе. Свои запросы пишутся на языке запросов Tree-sitter и кладутся в ~/.config/nvim/after/queries/<язык>/highlights.scm; отлаживать удобно через :InspectTree (дерево буфера) и :Inspect (какая группа подсветки применена под курсором).

Осторожно: в 2025 году nvim-treesitter переехал на ветку main с изменённым API — гайды из интернета могут не совпадать с тем, что установлено у вас. Сверяйтесь с README той версии, которую зафиксировал ваш lazy-lock.json.

Отладка внутри редактора

Историческое слабое место терминальных редакторов закрывает nvim-dap: он реализует Debug Adapter Protocol — тот же, что использует VS Code, поэтому адаптеры переиспользуются напрямую.

{
  "mfussenegger/nvim-dap",
  dependencies = { "rcarriga/nvim-dap-ui", "nvim-neotest/nvim-nio", "leoluz/nvim-dap-go" },
  keys = {   -- keys = ленивая загрузка: dap не грузится, пока не начали отлаживать
    { "<F5>", function() require("dap").continue() end, desc = "Debug: продолжить" },
    { "<leader>b", function() require("dap").toggle_breakpoint() end, desc = "Точка останова" },
  },
  config = function()
    local dap, dapui = require("dap"), require("dapui")
    dapui.setup(); require("dap-go").setup()
    -- панель отладки открывается и закрывается вместе с сессией
    dap.listeners.before.launch.dapui_config = dapui.open
    dap.listeners.before.event_terminated.dapui_config = dapui.close
  end,
}

Честная оценка: работает, но настраивается заметно дольше, чем в JetBrains, где отладчик просто есть. Если вы отлаживаетесь степпером каждый день — это аргумент в пользу IDE, и его не надо рационализировать.

Разбор реальных dotfiles

Теория без живого примера плохо усваивается, поэтому разберём публичный репозиторий github.com/the-homeless-god/dotfiles. Он ценен именно тем, что это не вылизанная витрина, а рабочая конфигурация, которую ведут годами.

dotfiles/
├── configs/                  -- конфиги, раскладываемые в $HOME
│   ├── .vimrc                -- 998 строк Vim script, ~50 плагинов
│   ├── .zshrc, .alacritty.toml, .gitconfig, .editorconfig
│   ├── .config/tmux/.tmux.conf, .config/lf/, .config/vifm/
│   ├── tools.json            -- декларативный каталог устанавливаемых инструментов
│   └── fonts/                -- FiraCode и MesloLGS NF, закоммиченные бинарниками
├── scripts/install-tools.sh  -- ~38 КБ установщика, есть режим --interactive
├── ci/test-install.sh
├── docs/ru.md, docs/en.md
├── Dockerfile, docker-compose.yml, Makefile
└── .github/workflows/        -- CI и публикация образа в ghcr.io

Первое наблюдение, важное для темы статьи: это Vim-конфигурация, а не Neovim. Vim script, vim-plug, LSP-слой на coc.nvim. И это живой, нормальный выбор в 2026 году. Смотреть полезно именно на такой конфиг: большинство мигрирует не с нуля, а вот отсюда.

Что сделано хорошо

Установка автоматизирована и проверяема. Большинство dotfiles-репозиториев — набор файлов и README в стиле «скопируйте себе». Здесь есть установщик, Makefile, Dockerfile и GitHub Actions, публикующий образ в ghcr.io. Практический эффект: конфиг проверяется в CI на чистой системе. Это ровно тот класс проблем, где dotfiles ломаются чаще всего — «у меня работает, потому что три года назад я что-то поставил руками и забыл».

Каталог инструментов вынесен в данные. configs/tools.json описывает устанавливаемое по категориям (essential, development, file_management, media) с локализованными названиями, а установщик читает JSON и строит интерактивное меню. Правильное разделение: список инструментов меняется часто, логика установки — редко.

Конфиг документирован сверху. Первые 55 строк .vimrc — карта горячих клавиш блоками: навигация, файлы и поиск, редактирование, git, терминал. Плюс подключён vim-which-key. Через полгода эти строки экономят часы.

Окружение целостное, а не только редактор. tmux с tmux-resurrect и tmux-continuum (сессии переживают перезагрузку), lf и vifm с превью, ripgrep/fd/fzf, привязанные к маппингам редактора — то, о чём отдельная статья про терминальный workflow.

Шрифты закоммичены в репозиторий. Спорно по весу, но решает реальную проблему: patched Nerd Fonts — самая частая причина «у меня вместо иконок квадратики». Сознательный размен.

Что бы я поменял

Здесь честность важнее вежливости — эти паттерны встречаются почти в каждом долгоживущем конфиге, включая мои собственные.

Четыре конкурирующих движка автодополнения и диагностики. В списке плагинов есть coc.nvim, YouCompleteMe, ALE и asyncomplete.vim. Каждый — самодостаточный слой, и каждый хочет владеть popup-меню, omnifunc, знаками в signcolumn и обработкой <Tab>. Симптомы предсказуемы: дублирующиеся диагностики, гонки за popup, «иногда автодополнение не срабатывает», лишние сотни миллисекунд на старте, постоянный Node-процесс от coc плюс скомпилированный ycmd от YCM. Это классика накопления: инструмент добавлялся, когда предыдущий чем-то не устроил, но предыдущий не удалялся — «вдруг сломается».

vim-polyglot вместе с отдельными синтаксическими плагинами (vim-javascript, typescript-vim, vim-jsx-pretty, vim-graphql). Polyglot по своей природе — сборник тех же самых плагинов. Двойная загрузка синтаксиса даёт трудноуловимые баги подсветки и отступов, причём именно те, которые потом «лечат» новыми плагинами.

set paste в конфиге. Один из самых известных футганов Vim: режим paste отключает автоотступ, аббревиатуры и все маппинги insert-режима — включая маппинги автодополнения, которые настраиваются ниже по этому же файлу. В современных терминалах с bracketed paste строка не нужна.

Противоречащие настройки в разных частях файла. set hidden в одном блоке и set nohidden с bufhidden=wipe через четыреста строк; set number! (переключатель) и set number (установка) в разных местах. Побеждает последняя выполненная строка, но понять какая можно, только прочитав файл целиком. Прямое следствие монолита на тысячу строк: дублирование не видно. Той же природы дубликаты в списке плагинов (vim-floaterm и vim-fugitive объявлены дважды) и два тайлинг-плагина в tmux (tmux-tilit и tmux-tilish), перехватывающих одни клавиши.

Нет фиксации версий. vim-plug не создаёт лок-файла, значит :PlugUpdate может привезти ломающее изменение, а откатиться к «как было вчера» нечем. Пожалуй, самая дорогая проблема из перечисленных: проявляется в худший момент — когда вы срочно настраиваете машину.

Как это выглядело бы после переезда

Было (Vim script) Стало (Neovim + Lua) Комментарий
coc + YCM + ALE + asyncomplete vim.lsp + blink.cmp + conform.nvim один слой вместо четырёх, без Node
vim-polyglot + 5 syntax-плагинов nvim-treesitter настоящий парсер вместо регулярок
nerdtree + git-плагин к нему oil.nvim или neo-tree.nvim oil редактирует каталог как буфер
fzf.vim + vim-ripgrep telescope.nvim или fzf-lua единый интерфейс поиска
vim-which-key which-key.nvim берёт описания из desc маппингов
vim-fugitive vim-fugitive (он же) лучший git-плагин, менять не на что
ultisnips + vim-snippets friendly-snippets без зависимости от Python
vim-airline lualine.nvim заметно дешевле по отрисовке
нет лок-файла lazy-lock.json воспроизводимость
998 строк в одном файле 8–10 модулей по 40–80 строк правится без страха

Оценка трудозатрат по опыту: две-три вечерних сессии до рабочего паритета и месяц-полтора, чтобы перестать спотыкаться о мышечную память. Не мигрируйте перед дедлайном — классическая ошибка, после которой откатываются и делают вывод «Neovim не взлетел».

Типичные ошибки

  • Копирование чужого конфига целиком. Самый частый способ получить редактор, который вы не понимаете и не можете починить. kickstart.nvim — один документированный файл, который вам предлагают прочитать и разобрать; LazyVim — фреймворк со своими абстракциями, где кастомизация означает изучение ещё одного слоя. Начинать с kickstart честнее.
  • Конфиг как хобби вместо работы. Настраивать редактор приятнее, чем писать код. Здоровая практика — копить список изменений и вносить их пачкой раз в месяц. Ориентир: больше дня за квартал — это уже не оптимизация, а прокрастинация.
  • lazy-lock.json не в гите. Без него dotfiles не воспроизводимы — весь смысл лок-файла в том, чтобы через год на другой машине получить те же версии.
  • vim.opt там, где нужен vim.bo / vim.wo. Установка глобальной опции из FileType-автокоманды меняет её для всех буферов.
  • Автокоманды без augroup ... clear = true. При каждом :source обработчики дублируются, и один и тот же коллбэк начинает срабатывать дважды, трижды, десять раз.
  • Смешивание менеджеров плагинов. Остатки packer в ~/.local/share/nvim/site/pack после переезда на lazy продолжают загружаться штатным механизмом пакетов и дают призрачные плагины, которых нет в конфиге. Чистите каталог данных.
  • Вера, что :checkhealth покажет всё. Он найдёт отсутствующие бинарники и кривые парсеры, но не логические конфликты плагинов — их ловят только удалением по одному.

Когда Neovim — правильный выбор, а когда нет

Скорее да, если вы много работаете по SSH и в контейнерах; окружение должно разворачиваться скриптом; ваши языки имеют хорошие LSP-серверы (Go, Rust, TypeScript, Python, Elixir); терминал уже ваш дом, и там живут tmux, git и ripgrep; вы готовы вложить 20–40 часов в первые месяцы.

Скорее нет, если основной стек — крупный Java/Kotlin- или .NET-монолит, где рефакторинги JetBrains экономят часы, а не минуты; вы отлаживаетесь степпером ежедневно; нужны GUI-инструменты профилирования и работы с БД в одном окне.

Промежуточный вариант, который недооценивают: оставить основную IDE и включить в ней качественную vim-эмуляцию (IdeaVim, VSCodeVim или расширение с настоящим ядром Neovim). Это даёт 80% выгоды от модального редактирования при 5% стоимости перехода. Подробное сравнение — в заключительной статье трека.

Мини-итог

  • Neovim — не «Vim с плагинами получше», а другая архитектура: ядро с msgpack-RPC API, циклом событий libuv, встроенным LuaJIT и Treesitter; UI отделён от редактора.
  • Lua даёт модульность, скорость и настоящие данные вместо строк. Vim script продолжает работать, мигрировать «всё и сразу» не обязательно.
  • LSP превратил «поддержку языка» из свойства редактора в свойство экосистемы. С 0.11 клиент настраивается штатным vim.lsp.config/vim.lsp.enable, а nvim-lspconfig остаётся базой готовых описаний серверов.
  • Treesitter — не про красивые цвета, а про структуру: текстовые объекты по функциям и аргументам, инъекции языков, устойчивость к синтаксическим ошибкам.
  • Ленивая загрузка и lazy-lock.json превращают конфиг из хрупкого артефакта в воспроизводимый. Измеряйте старт, а не угадывайте.
  • Разбор чужих dotfiles полезнее чтения гайдов. Повторяющиеся грабли: несколько движков автодополнения сразу, дублирование синтаксических плагинов, set paste, монолитный файл и отсутствие фиксации версий.

Источники

Что дальше

Мы посмотрели на редактор, который довели до состояния IDE, оставив его текстовым. Следующий шаг — семейство, которое пошло ровно в противоположную сторону: не редактор с расширениями, а среда исполнения Lisp, в которой редактор оказался одним из приложений.

Emacs: философия, org-mode, elisp и почему он всё ещё жив

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов