Neovim - Lua-конфиг редактора - полный разбор по файлам
Приветствую!

Довёл до готовности свой конфиг Neovim на Lua и lazy.nvim, и в статье расскажу: что даёт конфиг из коробки, как поставить его одной командой, ну и для любопытных разбор каждого файла - по порядку структуры проекта.

Предисловие

Про предыдущий конфиг на init.vim и vim-plug у меня есть целая прошлых старых заметок:

Тот конфиг вполне рабочий, но я давно собирался сесть и переписал всё на Lua вместо vimscript. Модульная структура файлов вместо одного файла-портянки.

Отдельно стоит упомянуть LazyVim. Это готовый дистрибутив конфигурации Neovim с уже подобранными плагинами, LSP, автодополнением, поиском и горячими клавишами. Внутри LazyVim использует менеджер плагинов lazy.nvim, а дополнительные плагины подключаются обычными Lua-таблицами - plugin specs.

В моём конфиге сам LazyVim как готовый дистрибутив не установлен, но используется тот же lazy.nvim и тот же формат описания плагинов. Поэтому каждый файл в lua/plugins возвращает стандартную spec-таблицу с полями вроде event, cmd, keys, dependencies, opts и config. Сделано это ради унификации конфига и некой “единости” формата. Заглянув в код вы поймёте о чём я.

И так, ниже - карта готового результата: что конфиг умеет, как его поставить и что содержит каждый файл.

Вводные данные

ПОВерсия
LMDE/Debian/Ubuntu7/13/24
Neovim0.11+
lazy.nvim11.17
ТемаNord (shaunsingh/nord.nvim)

Что даёт конфиг

Конфиг не пытается быть полноценной IDE - фокус на приятном UI Neovim, быстром старте, подсветке синтаксиса, опциональной диагностике через внешние CLI-линтеры и форматировании по требованию. + плагин для работы с ИИ агентами. Но про это будет отдельная статья.

Что идёт из коробки:

Почти быстрый старт

Установка Neovim из стандартных репозиторив

Актуальная версия конфига требует Neovim 0.11 или новее. Сначала проверяем установленную версию:

BASH
nvim --version
Нажмите, чтобы развернуть и увидеть больше

Если дистрибутив уже предоставляет подходящую версию, достаточно поставить базовые зависимости через пакетный менеджер. Для Debian/Ubuntu:

BASH
sudo apt update && sudo apt install -y neovim git ripgrep
Нажмите, чтобы развернуть и увидеть больше

В стабильных репозиториях некоторых дистрибутивов всё ещё может лежать Neovim 0.10. В таком случае редактор лучше собрать самостоятельно.

Сборка Neovim из исходников

Ставим инструменты сборки и необходимые библиотеки:

BASH
sudo apt install -y ninja-build gettext cmake unzip curl build-essential
Нажмите, чтобы развернуть и увидеть больше

Клонируем официальный репозиторий, собираем release-вариант:

BASH
git clone https://github.com/neovim/neovim /tmp/neovim

cd /tmp/neovim

git checkout stable

make CMAKE_BUILD_TYPE=Release
Нажмите, чтобы развернуть и увидеть больше

И устанавливаем его в системный каталог исполняемых файлов:

BASH
sudo make install
Нажмите, чтобы развернуть и увидеть больше

После установки переинициализируем оболочку и проверяем, что запускается новая версия:

BASH
exec $SHELL

# или

hash -r

command -v nvim

nvim --version
Нажмите, чтобы развернуть и увидеть больше

Обычно собранный редактор оказывается в /usr/local/bin/nvim.

Установка линтеров

Линтеры предпочтительно устанавливать из стандартных репозиториев, т.к. системные пакеты обновляются вместе с дистрибутивом.

Все линтеры, для которых в конфиге есть системные fallback-варианты, ставятся одной командой:

BASH
sudo apt install -y shellcheck lua-check pylint yamllint jsonlint
Нажмите, чтобы развернуть и увидеть больше

ruff конфиг тоже поддерживает, но в стандартном репозитории LMDE 7 / Debian 13 его сейчас нет. Поэтому для Python предусмотрен pylint: ставить Ruff через pip только ради работы F7 необязательно.

Доступные в стандартных репозиториях форматтеры устанавливаем отдельно:

BASH
sudo apt install -y shfmt jq black
Нажмите, чтобы развернуть и увидеть больше

stylua, yamlfmt и prettier остаются опциональными: их имеет смысл ставить отдельно при необходимости. Если какого-либо инструмента нет в $PATH, конфиг просто не включает его и продолжает работать.

Установка моего конфига

Бэкап текущего конфига, если он есть:

BASH
test -d ~/.config/nvim && mv -v ~/.config/nvim{,.backup}
Нажмите, чтобы развернуть и увидеть больше

Теперь клонируем сам конфиг через git прямо в ~/.config/nvim:

BASH
git clone --branch raven https://github.com/r4ven-me/neovim ~/.config/nvim
Нажмите, чтобы развернуть и увидеть больше

Первый запуск:

BASH
cd ~/.config/nvim

nvim ./init.lua
Нажмите, чтобы развернуть и увидеть больше

На первом старте конфиг сам клонирует lazy.nvim, ставит плагины из lua/plugins, создаёт служебные директории под ~/.local/state/nvim и подтягивает нужные парсеры Treesitter (займёт некоторое время:)

Кастомные горячие клавиши

Ниже приведены дополнительные/изменённые горячие клавиши редактора:

КлавишаДействие
Shift+F1Открыть встроенную справку по конфигурации
F13Выполнить git add, коммит с сообщением Upd и git push для директории текущего файла
F2Найти файл в директории текущего файла
Shift+F2Найти текст в файлах директории текущего файла
F3Открыть или скрыть Neo-tree
Shift+F3 / F15Отформатировать текущий буфер или visual-выделение
F4Открыть или скрыть нижний терминал
Shift+F4Открыть или скрыть историю сообщений Noice через Telescope
F5Открыть или скрыть AI-чат CodeCompanion
Shift+F5Сохранить и запустить текущий shell- или Python-файл
F6Показать диагностику workspace в Telescope
Shift+F6Показать диагностику текущего буфера в Telescope
F7Включить линтинг и сразу запустить проверку
Shift+F7 / F19Выключить линтинг и очистить диагностику
F8Показать Git status в Telescope
Shift+F8Показать Git-историю текущего файла
F9-F12Загрузить одну из четырёх нумерованных сессий
Shift+F9-Shift+F12Сохранить одну из четырёх нумерованных сессий
Shift+h / Shift+lПерейти к предыдущему или следующему буферу
Shift+MouseWheelПереместить текущий буфер влево или вправо
Ctrl+Up / Ctrl+DownУвеличить или уменьшить высоту нижнего терминала
Space fОтформатировать текущий буфер или visual-выделение
Space dПоказать диагностику текущей строки
[d / ]dПерейти к предыдущей или следующей диагностике
Space aiОткрыть или скрыть чат CodeCompanion
Space aaОткрыть палитру действий CodeCompanion
WWСохранить текущий файл
WSСохранить текущую сессию AutoSession
WRВосстановить последнюю сессию AutoSession
jkВыйти из insert-режима
Esc EscЗакрыть сообщения или убрать подсветку поиска

Структура конфига

Вывод
.
├── init.lua
├── lua
│   ├── config
│   │   ├── autocmds.lua
│   │   ├── keymaps.lua
│   │   ├── lazy.lua
│   │   └── options.lua
│   └── plugins
│       ├── auto-session.lua      # автосохранение и восстановление сессий
│       ├── autopairs.lua         # автоматическое закрытие скобок и кавычек
│       ├── bufferline.lua        # строка открытых буферов и UI-кнопки
│       ├── codecompanion.lua     # AI-чат с Claude Code и Codex
│       ├── conform.lua           # форматирование кода по требованию
│       ├── gitsigns.lua          # Git-изменения в колонке знаков
│       ├── indent-blankline.lua  # направляющие уровней отступов
│       ├── lint.lua              # асинхронный запуск внешних линтеров
│       ├── lsp.lua               # опциональная заготовка LSP
│       ├── lualine.lua           # нижняя статусная строка
│       ├── neo-tree.lua          # дерево файлов проекта
│       ├── noice.lua             # command line, уведомления и их история
│       ├── nord.lua              # тема Nord и дополнительные highlight-группы
│       ├── ruscmd.lua            # команды Neovim в русской раскладке
│       ├── scrollview.lua        # полоса прокрутки текущего окна
│       ├── startify.lua          # стартовый экран и недавние файлы
│       ├── telescope.lua         # поиск, диагностика и интерактивные списки
│       ├── toggleterm.lua        # нижний терминал на всю ширину
│       └── treesitter.lua        # синтаксическая подсветка и парсеры
Нажмите, чтобы развернуть и увидеть больше

Принцип простой: lua/config - это “движок” (опции, клавиши, автокоманды, загрузчик плагинов), а lua/plugins - один файл на один плагин, в обычном формате спеки lazy.nvim. Хочешь добавить плагин - создаёшь файл, возвращающий таблицу-спеку, lazy.nvim подхватит его сам через { import = "plugins" }. Никакого центрального списка плагинов, который приходится скроллить, чтобы понять, откуда взялась одна конкретная настройка.

В целом для работы с конфигом больше ничего не нужно. Дальше пойдёт подробный разбор каждого файла структуры по порядку, сверху вниз. Это уже для любопытных 😉.

init.lua

LUA
require("config.options")
require("config.keymaps")
require("config.autocmds")
require("config.lazy")
Нажмите, чтобы развернуть и увидеть больше

Тут только подключение четырёх модулей config. Именно они, в этом же порядке, задают опции, клавиши, автокоманды и запускают lazy.nvim.

Директория lua/config

lua/config/options.lua

LUA
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"

local state_path = vim.fn.stdpath("state")
local function state_dir(name)
  return table.concat({ state_path, name }, "/")
end

for _, dir in ipairs({ "swap", "undo", "backup", "sessions", "shada" }) do
  vim.fn.mkdir(state_dir(dir), "p")
end

local function clean_stale_shada_tmp()
  local shada_tmp_files = vim.fn.glob(state_dir("shada") .. "/main.shada.tmp.*", false, true)
  local now = os.time()

  for _, file in ipairs(shada_tmp_files) do
    local mtime = vim.fn.getftime(file)
    if mtime > 0 and now - mtime > 86400 then
      pcall(vim.fn.delete, file)
    end
  end
end

clean_stale_shada_tmp()

vim.opt.mouse = "a"
vim.opt.encoding = "utf-8"
vim.opt.number = true
vim.opt.scrolloff = 7
vim.opt.showmode = false
vim.opt.showcmd = false
vim.opt.more = false
vim.opt.cursorline = true
vim.opt.cursorlineopt = "both"
vim.opt.ignorecase = true
vim.opt.smartcase = true
vim.opt.laststatus = 2
vim.opt.cmdheight = 1
vim.opt.tabstop = 4
vim.opt.softtabstop = 4
vim.opt.shiftwidth = 4
vim.opt.expandtab = true
vim.opt.autoindent = true
vim.opt.smartindent = true
vim.opt.fileformats = { "unix", "dos", "mac" }
vim.opt.showtabline = 2
vim.opt.clipboard = "unnamedplus"
vim.opt.termguicolors = true
vim.opt.splitbelow = true
vim.opt.splitright = true
vim.opt.equalalways = true
vim.opt.updatetime = 250
vim.opt.timeoutlen = 500
vim.opt.ttimeoutlen = 200
vim.opt.completeopt = { "menu", "menuone", "noselect" }
vim.opt.signcolumn = "yes"

vim.opt.sessionoptions = {
  "buffers",
  "curdir",
  "folds",
  "help",
  "tabpages",
  "winsize",
  "winpos",
  "terminal",
  "localoptions",
}

vim.opt.shada = { "!", "'100", "<50", "s10", "h" }
vim.opt.shadafile = state_dir("shada") .. "/main.shada"

vim.opt.swapfile = true
vim.opt.directory = state_dir("swap") .. "//"
vim.opt.writebackup = true
vim.opt.backup = false
vim.opt.backupcopy = "auto"
vim.opt.backupdir = state_dir("backup") .. "//"
vim.opt.undofile = true
vim.opt.undodir = state_dir("undo") .. "//"

vim.opt.foldmethod = "indent"
vim.opt.foldnestmax = 10
vim.opt.foldenable = false
vim.opt.foldlevel = 2

vim.opt.guicursor = table.concat({
  "n-v-c:block-Cursor",
  "i-ci-ve:ver25-iCursor",
  "r-cr:hor20-rCursor",
  "o:hor50",
}, ",")

vim.diagnostic.config({
  virtual_text = {
    spacing = 2,
    prefix = "●",
    source = "if_many",
  },
  signs = true,
  underline = true,
  update_in_insert = false,
  severity_sort = true,
  float = {
    border = "rounded",
    source = "if_many",
    header = "",
  },
})

local diagnostic_signs = {
  Error = "E",
  Warn = "W",
  Hint = "H",
  Info = "I",
}

for type, icon in pairs(diagnostic_signs) do
  local hl = "DiagnosticSign" .. type
  vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = hl })
end
Нажмите, чтобы развернуть и увидеть больше

Тут заслуживают отдельного упоминания несколько мест.

Каталоги состояния (swap, undo, backup, сессии, ShaDa) вынесены из ~/.local/share/nvim в ~/.local/state/nvim. Отдельная функция clean_stale_shada_tmp чистит зависшие main.shada.tmp.* файлы старше суток, чтобы избежать ошибки E138 после аварийного завершения сеанса.

clipboard = "unnamedplus" - буфер обмена системы и безымянный регистр Neovim общие, p/P работают с тем, что скопировано вне редактора, без лишних "+.

more = false отключает встроенный pager -- More --. На первом запуске Treesitter печатает много строк при скачивании и компиляции парсеров; без этой настройки установка останавливалась после каждого экрана и ждала Enter, пробел или прокрутку вниз.

sessionoptions включает terminal - при сохранении сессии восстанавливаются и открытые терминалы toggleterm.

В конце файла - диагностические знаки заменены на буквы E/W/H/I вместо стандартных иконок-закорючек: на любом шрифте без иконок остаются читаемыми.

lua/config/keymaps.lua

LUA
local map = vim.keymap.set
local opts = { noremap = true, silent = true }

map("n", "<CR>", "o<Esc>", opts)
map("n", "<Space>", "a <Esc>", opts)
map("i", "jk", "<Esc>", opts)
map("n", ",<Space>", "<cmd>nohlsearch<CR>", opts)
map("n", "x", '"_x', opts)
map("x", "x", '"_x', opts)
map("x", "p", "P", opts)
map("x", "P", "p", opts)
map("n", "WW", "<cmd>w<CR>", opts)

local function git_commit_current_dir()
  local file = vim.fn.expand("%:p")
  local dir = vim.fn.fnamemodify(file ~= "" and file or vim.fn.getcwd(), ":h")

  vim.cmd("!" .. table.concat({
    "git add " .. vim.fn.shellescape(dir),
    "git commit -m Upd",
    "git push",
  }, " && "))
end

map("n", "<S-F1>", "<cmd>help nvim-config<CR>", vim.tbl_extend("force", opts, { desc = "Open Neovim config help" }))
map("n", "<F13>", git_commit_current_dir, vim.tbl_extend("force", opts, { desc = "Git add/commit/push current dir" }))

local session_prefix = vim.fn.stdpath("state") .. "/sessions/session"

local function save_session(num)
  vim.cmd("mksession! " .. vim.fn.fnameescape(session_prefix .. num .. ".vim"))
end

local function load_session(num)
  vim.cmd("source " .. vim.fn.fnameescape(session_prefix .. num .. ".vim"))
  vim.schedule(function()
    vim.cmd("Neotree show")
    vim.cmd("wincmd w")
  end)
end

for key, num in pairs({
  ["<S-F9>"] = "1",
  ["<S-F10>"] = "2",
  ["<S-F11>"] = "3",
  ["<S-F12>"] = "4",
  ["<F21>"] = "1",
  ["<F22>"] = "2",
  ["<F23>"] = "3",
  ["<F24>"] = "4",
}) do
  map("n", key, function()
    save_session(num)
  end, { desc = "Save session " .. num })
  map("i", key, function()
    vim.cmd.stopinsert()
    save_session(num)
    vim.cmd.startinsert()
  end, { desc = "Save session " .. num })
end

for key, num in pairs({
  ["<F9>"] = "1",
  ["<F10>"] = "2",
  ["<F11>"] = "3",
  ["<F12>"] = "4",
}) do
  map("n", key, function()
    load_session(num)
  end, { desc = "Load session " .. num })
end

map("", "<S-ScrollWheelUp>", "<cmd>BufferLineMovePrev<CR>", { desc = "Move buffer left" })
map("", "<S-ScrollWheelDown>", "<cmd>BufferLineMoveNext<CR>", { desc = "Move buffer right" })

map("n", "<leader>d", vim.diagnostic.open_float, { desc = "Line diagnostics" })
map("n", "[d", vim.diagnostic.goto_prev, { desc = "Previous diagnostic" })
map("n", "]d", vim.diagnostic.goto_next, { desc = "Next diagnostic" })

map("n", "<Esc><Esc>", function()
  local ok, noice = pcall(require, "noice")
  if ok then
    noice.cmd("dismiss")
  else
    vim.cmd.nohlsearch()
  end
end, { desc = "Dismiss UI messages/search" })
Нажмите, чтобы развернуть и увидеть больше

Базовые ремапы сверху: Enter в нормальном режиме вставляет строку ниже, Space - пробел без входа в insert, jk - выход из insert, x/X не затирают регистр ("_x), а вставка в visual-режиме меняет p/P местами - удобно, когда вставляешь поверх выделенного и хочешь, чтобы старое выделение не улетело в регистр.

Shift+F1 открывает локальную справку конфига (:help nvim-config), а F13 оставлен под мой персональный “быстрый коммит”, который добавляет, коммитит с сообщением Upd 🙃 и пушит директорию текущего файла. Для рабочих репозиториев так не делаем, но для личных дотфайлов и заметок - самое то, экономит секунд десять на каждое сохранение.

Дальше - четыре слота сессий на F9-F12 (сохранение - Shift-версии, плюс F21-F24 как дубль для терминалов без Shift+Fn): без плагина, просто mksession!/source по фиксированным путям в state-директории.

В конце - навигация по диагностике ([d/]d, leader+d) и Esc Esc, который закрывает всплывающие сообщения Noice; fallback на nohlsearch остаётся на случай запуска без плагина.

lua/config/autocmds.lua

LUA
local group = vim.api.nvim_create_augroup("UserConfig", { clear = true })

vim.api.nvim_create_autocmd("FileType", {
  group = group,
  pattern = "*",
  callback = function()
    vim.opt_local.formatoptions:remove({ "c", "r", "o" })
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  group = group,
  pattern = { "lua", "json", "yaml", "toml" },
  callback = function()
    vim.opt_local.shiftwidth = 2
    vim.opt_local.tabstop = 2
    vim.opt_local.softtabstop = 2
    vim.opt_local.expandtab = true
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  group = group,
  pattern = { "sh", "bash", "zsh", "python" },
  callback = function(event)
    local function run_current_file()
      local file = vim.api.nvim_buf_get_name(event.buf)
      if file == "" then
        return
      end

      vim.cmd.write()
      vim.fn.system({ "chmod", "ug+x", file })
      vim.cmd("!" .. vim.fn.shellescape(file))
    end

    vim.keymap.set({ "n", "i" }, "<S-F5>", run_current_file, {
      buffer = event.buf,
      desc = "Save and run current file",
    })
  end,
})

vim.api.nvim_create_autocmd("BufReadPost", {
  group = group,
  callback = function(event)
    vim.api.nvim_create_autocmd("BufWinEnter", {
      group = group,
      once = true,
      buffer = event.buf,
      callback = function()
        local ft = vim.bo[event.buf].filetype
        local last_line = vim.api.nvim_buf_get_mark(event.buf, '"')[1]

        if ft:match("commit") or ft:match("rebase") then
          return
        end

        if last_line > 1 and last_line <= vim.api.nvim_buf_line_count(event.buf) then
          pcall(vim.cmd.normal, { args = { 'g`"' }, bang = true })
        end
      end,
    })
  end,
})

vim.api.nvim_create_autocmd("SessionLoadPost", {
  group = group,
  command = "wincmd =",
})

local function is_editor_buffer(buf)
  if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" or vim.bo[buf].filetype == "neo-tree" then
    return false
  end

  if vim.api.nvim_buf_get_name(buf) ~= "" or vim.bo[buf].modified then
    return true
  end

  local lines = vim.api.nvim_buf_get_lines(buf, 0, 2, false)
  return #lines > 1 or lines[1] ~= ""
end

vim.api.nvim_create_autocmd("QuitPre", {
  group = group,
  callback = function()
    local current_win = vim.api.nvim_get_current_win()
    if not is_editor_buffer(vim.api.nvim_get_current_buf()) then
      return
    end

    local auxiliary_windows = {}
    for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
      if win ~= current_win then
        local buf = vim.api.nvim_win_get_buf(win)
        if is_editor_buffer(buf) then
          return
        end

        local filetype = vim.bo[buf].filetype
        if
          filetype == "neo-tree"
          or filetype == "codecompanion"
          or filetype == "codecompanion_input"
          or vim.bo[buf].buftype == "terminal"
        then
          table.insert(auxiliary_windows, win)
        end
      end
    end

    for _, win in ipairs(auxiliary_windows) do
      if vim.api.nvim_win_is_valid(win) then
        vim.api.nvim_win_close(win, true)
      end
    end
  end,
})

vim.api.nvim_create_autocmd("VimLeavePre", {
  group = group,
  callback = function()
    vim.fn.mkdir(vim.fn.stdpath("state") .. "/shada", "p")
  end,
})
Нажмите, чтобы развернуть и увидеть больше

Тут также стоит выделить две вещи.

Первая - обычное восстановление позиции курсора при открытии файла (аналог классического au BufReadPost * if line("'\"") > 1 ...), но переписанное через nvim_create_autocmd, с явным исключением commit/rebase буферов, чтобы курсор не улетал в конец сообщения коммита.

Вторая - поведение при :q (QuitPre). Если в текущей вкладке кроме “рабочего” буфера остались только Neo-tree и терминал, они закрываются автоматически вместе с ним.

Заодно тут же переопределяются отступы под lua/json/yaml/toml на 2 пробела (экосистемы этих языков де-факто того требуют) и вешается Shift+F5 на save-and-run для sh/bash/zsh/python (тут будьте осторожны!).

lua/config/lazy.lua

LUA
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"

if not (vim.uv or vim.loop).fs_stat(lazypath) then
  local lazyrepo = "https://github.com/folke/lazy.nvim.git"
  local out = vim.fn.system({
    "git",
    "clone",
    "--filter=blob:none",
    "--branch=stable",
    lazyrepo,
    lazypath,
  })

  if vim.v.shell_error ~= 0 then
    vim.api.nvim_echo({
      { "Failed to clone lazy.nvim:\n", "ErrorMsg" },
      { out, "WarningMsg" },
      { "\nPress any key to exit..." },
    }, true, {})
    vim.fn.getchar()
    os.exit(1)
  end
end

vim.opt.rtp:prepend(lazypath)

require("lazy").setup({
  spec = {
    { import = "plugins" },
  },
  install = {
    colorscheme = { "nord" },
  },
  checker = {
    enabled = false,
  },
  change_detection = {
    notify = false,
  },
  rocks = {
    enabled = false,
  },
  performance = {
    rtp = {
      disabled_plugins = {
        "gzip",
        "netrwPlugin",
        "tarPlugin",
        "tohtml",
        "tutor",
        "zipPlugin",
      },
    },
  },
})
Нажмите, чтобы развернуть и увидеть больше

Классический бутстрап lazy.nvim: если его ещё нет в stdpath("data"), клонируется с GitHub мелким --filter=blob:none клоном, дальше добавляется в runtimepath и запускается setup. spec = { { import = "plugins" } } - это место, откуда lazy.nvim подхватывает файлы из lua/plugins. install.colorscheme = { "nord" } гарантирует, что даже при первой установке, пока плагины ещё ставятся, редактор не мигает стандартной серой темой. checker.enabled = false отключает фоновую проверку обновлений и уведомления о них - я привык обновлять всё вручную. rocks.enabled = false выключает hererocks - без него lazy.nvim при каждом старте молча пытается поднять собственные Lua 5.1 и luarocks, а когда это не выходит - сыплет предупреждениями в лог; на данный момент ни один плагин в конфиге rocks-зависимостей не объявляет, так что это просто шум. В performance.rtp.disabled_plugins отключены штатные плагины Neovim, которые мне не нужны (netrw, tutor, архиваторы и т.д.) - чуть быстрее старт редактора.

Директория lua/plugins

lua/plugins/auto-session.lua

Горячая клавиша - WS сохраняет текущую сессию, WR восстанавливает последнюю:

LUA
local neo_tree_was_open = false
local window_before_save

local function close_neo_tree_before_save()
  neo_tree_was_open = false
  window_before_save = vim.api.nvim_get_current_win()

  for _, win in ipairs(vim.api.nvim_list_wins()) do
    if vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "neo-tree" then
      neo_tree_was_open = true
      vim.cmd("Neotree close")
      break
    end
  end
end

local function restore_neo_tree_after_save()
  if not neo_tree_was_open then
    return
  end

  vim.cmd("Neotree show")
  if window_before_save and vim.api.nvim_win_is_valid(window_before_save) then
    vim.api.nvim_set_current_win(window_before_save)
  end
end

return {
  "rmagatti/auto-session",
  lazy = false,
  opts = {
    auto_save = true,
    auto_restore = false,
    auto_session_root_dir = vim.fn.stdpath("state") .. "/sessions/",
    bypass_save_filetypes = { "neo-tree", "toggleterm" },
    pre_save_cmds = {
      close_neo_tree_before_save,
    },
    post_save_cmds = {
      restore_neo_tree_after_save,
    },
    post_restore_cmds = {
      "Neotree show",
      "wincmd w",
    },
  },
  keys = {
    { "WS", "<cmd>AutoSession save<CR>", desc = "Save current session" },
    { "WR", "<cmd>AutoSession restore<CR>", desc = "Restore last session" },
  },
}
Нажмите, чтобы развернуть и увидеть больше

Автосохранение при выходе включено, а автоматическое восстановление при старте намеренно выключено: случайный запуск nvim в другой директории не должен поднимать старый набор буферов. Сессию сохраняю через WS, восстанавливаю через WR; используются актуальные команды AutoSession save и AutoSession restore.

Перед записью сессии Neo-tree временно закрывается, чтобы его служебный буфер не попадал в layout. Состояние дерева и активное окно запоминаются: сразу после сохранения панель возвращается только в том случае, если была открыта до WS, а фокус остаётся в прежнем редакторском окне. После восстановления сессии Neo-tree открывается заново.

lua/plugins/autopairs.lua

LUA
return {
  "windwp/nvim-autopairs",
  event = "InsertEnter",
  opts = {},
}
Нажмите, чтобы развернуть и увидеть больше

Автозакрытие скобок и кавычек, без единой кастомной настройки - дефолтов nvim-autopairs достаточно. Загружается “лениво”, по входу в insert-режим.

lua/plugins/bufferline.lua

Горячая клавиша - Shift+h и Shift+l переключают предыдущий и следующий буфер:

LUA
return {
  "akinsho/bufferline.nvim",
  version = "*",
  event = "VeryLazy",
  dependencies = {
    "nvim-tree/nvim-web-devicons",
    "famiu/bufdelete.nvim",
  },
  keys = {
    { "<S-l>", "<cmd>BufferLineCycleNext<CR>", desc = "Next buffer" },
    { "<S-h>", "<cmd>BufferLineCyclePrev<CR>", desc = "Previous buffer" },
  },
  config = function()
    vim.cmd([[
      function! OpenFileExplorer(_1, _2, _3, _4)
        Neotree toggle
      endfunction
    ]])

    vim.cmd([[
      function! OpenAIChat(_1, _2, _3, _4)
        if exists(':CodeCompanionChat') == 2
          CodeCompanionChat Toggle
        else
          echohl ErrorMsg
          echom 'CodeCompanion requires Neovim 0.11+. Run ~/bin/install-neovim first.'
          echohl None
        endif
      endfunction

      function! OpenBottomTerminal(_1, _2, _3, _4)
        lua toggle_bottom_terminal()
      endfunction
    ]])

    local function open_buffer(bufnr)
      local current_buf = vim.api.nvim_get_current_buf()

      if vim.bo[current_buf].filetype == "neo-tree" then
        local editor_win = require("neo-tree").get_prior_window({
          "codecompanion",
          "codecompanion_input",
          "terminal",
          "toggleterm",
        }, true)

        if editor_win < 0 or not vim.api.nvim_win_is_valid(editor_win) then
          vim.notify("No editor window available", vim.log.levels.WARN)
          return
        end

        vim.api.nvim_set_current_win(editor_win)
      end

      vim.api.nvim_set_current_buf(bufnr)
    end

    require("bufferline").setup({
      options = {
        mode = "buffers",
        themable = true,
        numbers = "none",
        close_command = "Bdelete! %d",
        left_mouse_command = open_buffer,
        right_mouse_command = "Bdelete! %d",
        middle_mouse_command = "Bdelete! %d",
        diagnostics = "nvim_lsp",
        show_buffer_icons = true,
        show_buffer_close_icons = false,
        show_close_icon = false,
        show_tab_indicators = true,
        persist_buffer_sort = true,
        separator_style = "thick",
        always_show_bufferline = true,
        indicator = {
          icon = "",
          style = "icon",
        },
        offsets = {
          {
            filetype = "neo-tree",
            text = " Files",
            highlight = "Directory",
            separator = true,
          },
        },
        custom_areas = {
          left = function()
            return {
              { text = " %@OpenFileExplorer@  %X ", bg = "#5E81AC", fg = "#ECEFF4", gui = "bold" },
              { text = " ", fg = "#5E81AC" },
            }
          end,
          right = function()
            return {
              { text = " ", fg = "#5E81AC" },
              { text = " %@OpenAIChat@ 󰍹 %X ", bg = "#5E81AC", fg = "#ECEFF4", gui = "bold" },
              { text = "", bg = "#5E81AC", fg = "#81A1C1" },
              { text = " %@OpenBottomTerminal@  %X ", bg = "#5E81AC", fg = "#ECEFF4", gui = "bold" },
            }
          end,
        },
      },
      highlights = {
        fill = { fg = "#3B4252", bg = "#3B4252" },
        background = { fg = "#D8DEE9", bg = "#434C5E" },
        tab = { fg = "#D8DEE9", bg = "#434C5E" },
        tab_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        tab_separator = { fg = "#3B4252", bg = "#4C566A" },
        tab_separator_selected = { fg = "#3B4252", bg = "#5E81AC" },
        buffer_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        buffer_visible = { fg = "#D8DEE9", bg = "#434C5E" },
        separator = { fg = "#3B4252", bg = "#3B4252" },
        indicator_visible = { fg = "#D8DEE9", bg = "#434C5E" },
        modified = { fg = "#EBCB8B", bg = "#434C5E" },
        modified_visible = { fg = "#EBCB8B", bg = "#434C5E" },
        modified_selected = { fg = "#EBCB8B", bg = "#5E81AC" },
        duplicate_selected = { fg = "#E5E9F0", bg = "#5E81AC", italic = true },
        duplicate_visible = { bg = "#434C5E", italic = true },
        duplicate = { fg = "#D8DEE9", bg = "#434C5E", italic = true },
        error = { fg = "#BF616A", bg = "#434C5E" },
        error_visible = { fg = "#BF616A", bg = "#434C5E" },
        error_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        warning = { fg = "#EBCB8B", bg = "#434C5E" },
        warning_visible = { fg = "#EBCB8B", bg = "#434C5E" },
        warning_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        trunc_marker = { fg = "#81A1C1", bg = "#434C5E" },
        offset_separator = { bg = "#2E3440" },
      },
    })
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Помимо самих вкладок (Shift+h/Shift+l для переключения, закрытие через Bdelete!, чтобы не ломался layout окон) тут есть трюк с custom_areas: по краям строки табов рисуются кликабельные зоны, которые через %@FuncName@...%X вызывают vimscript-обёртки. Слева открывается Neo-tree, справа находятся кнопки AI-чата и нижнего терминала. Кнопки разделены символом ; компьютер 󰍹 вызывает CodeCompanionChat Toggle, терминал - toggleterm.

Bufferline с несколькими открытыми буферами и кликабельными областями по краям

lua/plugins/codecompanion.lua

Горячая клавиша - F5 или Space ai открывает и закрывает AI-чат, Space aa - палитру действий:

LUA
return {
  "olimorris/codecompanion.nvim",
  version = "*",
  enabled = vim.fn.has("nvim-0.11") == 1,
  cmd = {
    "CodeCompanion",
    "CodeCompanionActions",
    "CodeCompanionChat",
    "CodeCompanionCLI",
    "CodeCompanionCodeReview",
  },
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-treesitter/nvim-treesitter",
    {
      "MeanderingProgrammer/render-markdown.nvim",
      ft = { "markdown", "codecompanion" },
      opts = {
        file_types = { "markdown", "codecompanion" },
      },
    },
  },
  init = function()
    vim.api.nvim_create_autocmd("FileType", {
      pattern = { "codecompanion", "codecompanion_input" },
      callback = function()
        vim.schedule(function()
          if _G.keep_bottom_terminal_full_width then
            _G.keep_bottom_terminal_full_width(true)
          end
        end)
      end,
    })
  end,
  keys = {
    { "<F5>", "<cmd>CodeCompanionChat Toggle<CR>", mode = { "n", "i" }, desc = "Toggle AI chat" },
    { "<leader>ai", "<cmd>CodeCompanionChat Toggle<CR>", desc = "Toggle AI chat" },
    { "<leader>aa", "<cmd>CodeCompanionActions<CR>", mode = { "n", "v" }, desc = "AI actions" },
  },
  opts = {
    adapters = {
      acp = {
        extend = {
          codex = {
            defaults = {
              auth_method = "chat-gpt",
            },
          },
        },
      },
    },
    interactions = {
      chat = {
        adapter = "codex",
      },
      cli = {
        agent = "claude_code",
        agents = {
          claude_code = {
            cmd = "claude",
            args = {},
            description = "Claude Code CLI",
            provider = "terminal",
          },
          codex = {
            cmd = "codex",
            args = {},
            description = "OpenAI Codex CLI",
            provider = "terminal",
          },
        },
      },
    },
    display = {
      action_palette = {
        provider = "telescope",
      },
      chat = {
        show_context = false,
        show_header_separator = false,
        window = {
          layout = "vertical",
          position = "right",
          width = 0.4,
        },
        start_in_insert_mode = true,
      },
    },
    opts = {
      language = "Russian",
    },
  },
}
Нажмите, чтобы развернуть и увидеть больше

CodeCompanion.nvim даёт AI-чат в правой вертикальной панели. Открыть или скрыть его можно через F5, leader+ai, команду :CodeCompanionChat Toggle или кнопку 󰍹 в Bufferline; leader+aa открывает палитру действий для текущего буфера или visual-выделения. Ответы оформляет render-markdown.nvim.

Основной chat-адаптер - Codex ACP с авторизацией через аккаунт ChatGPT. При необходимости внутри чата через ga выбираются другой адаптер и модель, в том числе Claude Code; telescope-ui-select.nvim превращает этот выбор в нормальный Telescope dropdown. Отдельный CodeCompanionCLI запускает уже авторизованные команды claude и codex в терминальном окне.

Автокоманда FileType после открытия чата возвращает нижний ToggleTerm в самый низ, поэтому правая панель не уменьшает ширину терминала. Проверка vim.fn.has("nvim-0.11") не даёт несовместимому плагину загрузиться на старом Neovim.

lua/plugins/conform.lua

LUA
local function format_buffer()
  require("conform").format({ async = true, lsp_fallback = true })
end

return {
  "stevearc/conform.nvim",
  cmd = "ConformInfo",
  keys = {
    { "<leader>f", format_buffer, mode = { "n", "v" }, desc = "Format buffer" },
    { "<S-F3>", format_buffer, mode = { "n", "v" }, desc = "Format buffer" },
    { "<F15>", format_buffer, mode = { "n", "v" }, desc = "Format buffer" },
  },
  opts = {
    notify_on_error = false,
    formatters_by_ft = {
      lua = { "stylua" },
      sh = { "shfmt" },
      bash = { "shfmt" },
      zsh = { "shfmt" },
      python = { "ruff_format", "black" },
      json = { "jq" },
      yaml = { "yamlfmt", "prettier" },
    },
  },
}
Нажмите, чтобы развернуть и увидеть больше

Форматирование только по требованию - leader+f или Shift+F3/F15 (дубль на случай, если терминал шлёт именно этот код), никакого format-on-save. Для каждого языка задан приоритетный форматтер и запасной (например, для Python сначала ruff_format, при его отсутствии - black). notify_on_error = false - если нужный форматтер не установлен, conform.nvim просто молча пропускает шаг, а не сыплет уведомлениями об ошибке.

lua/plugins/gitsigns.lua

LUA
return {
  "lewis6991/gitsigns.nvim",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    signs = {
      add = { text = "+" },
      change = { text = "~" },
      delete = { text = "_" },
      topdelete = { text = "‾" },
      changedelete = { text = "~" },
      untracked = { text = "+" },
    },
    current_line_blame = false,
  },
}
Нажмите, чтобы развернуть и увидеть больше

Знаки изменений git в колонке слева от номеров строк. current_line_blame выключен намеренно - значков в gutter достаточно, blame по каждой строке только отвлекает.

Знаки gitsigns в колонке слева от номеров строк

lua/plugins/indent-blankline.lua

LUA
return {
  "lukas-reineke/indent-blankline.nvim",
  main = "ibl",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    scope = {
      show_start = false,
      show_end = true,
    },
  },
}
Нажмите, чтобы развернуть и увидеть больше

Вертикальные направляющие отступов. show_start = false убирает подсветку первой строки текущего блока (она и так видна по курсору), show_end = true оставляет подсветку последней - удобно видеть, где блок закрывается, особенно в глубоко вложенном коде.

lua/plugins/lint.lua

Горячая клавиша - F7 включает линтинг и сразу его запускает, Shift+F7/F19 выключает и очищает диагностику:

LUA
return {
  "mfussenegger/nvim-lint",
  event = { "BufReadPost", "BufNewFile" },
  config = function()
    local lint = require("lint")
    local enabled = false

    lint.linters.shellcheck.args = {
      "--format",
      "json1",
      "-",
    }

    local function executable(name)
      return vim.fn.executable(name) == 1
    end

    local function add_linters(target, ft, linters)
      local available = {}

      for _, name in ipairs(linters) do
        if executable(name) then
          table.insert(available, name)
        end
      end

      if #available > 0 then
        target[ft] = available
      end
    end

    local linters_by_ft = {}
    add_linters(linters_by_ft, "lua", { "luacheck" })
    add_linters(linters_by_ft, "sh", { "shellcheck" })
    add_linters(linters_by_ft, "bash", { "shellcheck" })
    add_linters(linters_by_ft, "zsh", { "zsh" })
    add_linters(linters_by_ft, "python", { "ruff", "pylint" })
    add_linters(linters_by_ft, "json", { "jsonlint" })
    add_linters(linters_by_ft, "yaml", { "yamllint" })

    lint.linters_by_ft = linters_by_ft

    local group = vim.api.nvim_create_augroup("UserLint", { clear = true })

    local timers = {}

    local function run_lint(notify_status, bufnr)
      bufnr = bufnr or vim.api.nvim_get_current_buf()
      if not vim.api.nvim_buf_is_valid(bufnr) then
        return
      end

      vim.api.nvim_buf_call(bufnr, function()
        local filetype = vim.bo.filetype
        local linters = lint.linters_by_ft[filetype]

        if not linters or #linters == 0 then
          if notify_status then
            vim.notify(
              "No installed linter configured for filetype: " .. (filetype ~= "" and filetype or "unknown"),
              vim.log.levels.WARN
            )
          end
          return
        end

        lint.try_lint()
        if notify_status then
          vim.notify("Lint started: " .. table.concat(linters, ", "))
        end
      end)
    end

    local function try_lint(bufnr)
      if enabled then
        run_lint(false, bufnr)
      end
    end

    local function try_lint_debounced(bufnr)
      local timer = timers[bufnr]
      if timer then
        timer:stop()
        timer:close()
      end

      timers[bufnr] = vim.defer_fn(function()
        timers[bufnr] = nil
        try_lint(bufnr)
      end, 500)
    end

    vim.api.nvim_create_autocmd({ "BufEnter", "BufWritePost", "InsertLeave", "TextChanged", "TextChangedI" }, {
      group = group,
      callback = function(event)
        try_lint_debounced(event.buf)
      end,
    })

    local initial_buf = vim.api.nvim_get_current_buf()
    vim.schedule(function()
      try_lint(initial_buf)
    end)

    vim.api.nvim_create_user_command("Lint", function()
      run_lint(true)
    end, {})

    local function toggle_linter()
      enabled = not enabled
      if enabled then
        run_lint(false)
      else
        vim.diagnostic.reset(nil, 0)
      end
      vim.notify("Lint " .. (enabled and "enabled" or "disabled"))
    end

    vim.api.nvim_create_user_command("LinterToggle", toggle_linter, {})
    vim.api.nvim_create_user_command("LintToggle", toggle_linter, {})

    vim.keymap.set({ "n", "i" }, "<F7>", function()
      if vim.api.nvim_get_mode().mode:match("^i") then
        vim.cmd.stopinsert()
      end
      enabled = true
      run_lint(true)
    end, { desc = "Enable and run lint" })

    vim.keymap.set({ "n", "i" }, "<S-F7>", function()
      enabled = false
      vim.diagnostic.reset(nil, 0)
      vim.notify("Lint disabled")
    end, { desc = "Disable lint" })

    vim.keymap.set({ "n", "i" }, "<F19>", function()
      enabled = false
      vim.diagnostic.reset(nil, 0)
      vim.notify("Lint disabled")
    end, { desc = "Disable lint" })
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Линтинг выключен по умолчанию. F7 включает его и сразу запускает первую проверку; пока режим активен, следующие проверки стартуют через 500 мс после BufEnter, сохранения, выхода из insert-режима или изменения текста. Debounce не запускает новый внешний процесс на каждое нажатие клавиши, но диагностика остаётся практически живой.

Таймер хранится отдельно для каждого буфера. Это важно при быстром переключении файлов: отложенная проверка всегда выполняется в том буфере, где произошло изменение, и не может случайно примениться к уже открытому соседнему файлу.

F7 явно включает линтинг и сразу запускает проверку; Shift+F7 или F19 отключает автоматические проверки и очищает diagnostics. Команды :LinterToggle и :LintToggle переключают тот же режим. При ручном запуске показывается имя запущенного линтера, а если для текущего filetype ни один установленный инструмент не найден - понятное предупреждение вместо молчаливого no-op.

Список линтеров собирается динамически через vim.fn.executable: используются только команды, реально доступные в $PATH. Для shell-файлов shellcheck читает текущий буфер из stdin в формате json1, поэтому видит ещё не сохранённые изменения.

Диагностика shellcheck от автоматически обновляемого nvim-lint

lua/plugins/lsp.lua

LUA
return {
  "neovim/nvim-lspconfig",
  enabled = false,
  event = { "BufReadPre", "BufNewFile" },
  config = function()
    local lspconfig = require("lspconfig")
    local capabilities = vim.lsp.protocol.make_client_capabilities()

    -- Flip enabled=true above and add servers here when full LSP is needed.
    lspconfig.lua_ls.setup({ capabilities = capabilities })
    lspconfig.pyright.setup({ capabilities = capabilities })
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Заготовка полноценного LSP, выключенная через enabled = false. Для 90% задач мне хватает лёгких линтеров и форматтеров выше, без запуска тяжёлых языковых серверов. Когда всё-таки нужен go-to-definition и прочие возможности LSP - ставлю нужные серверы, переключаю enabled на true и donастраиваю блок config: примеры для lua_ls и pyright уже внутри.

lua/plugins/lualine.lua

LUA
return {
  "nvim-lualine/lualine.nvim",
  event = "VeryLazy",
  dependencies = { "nvim-tree/nvim-web-devicons" },
  opts = {
    options = {
      theme = {
        normal = {
          a = { fg = "#ECEFF4", bg = "#5E81AC", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        insert = {
          a = { fg = "#2E3440", bg = "#A3BE8C", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        visual = {
          a = { fg = "#2E3440", bg = "#B48EAD", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        replace = {
          a = { fg = "#2E3440", bg = "#EBCB8B", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        command = {
          a = { fg = "#2E3440", bg = "#88C0D0", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        inactive = {
          a = { fg = "#D8DEE9", bg = "#3B4252" },
          b = { fg = "#D8DEE9", bg = "#3B4252" },
          c = { fg = "#81A1C1", bg = "#2E3440" },
        },
      },
      component_separators = { left = "", right = "" },
      section_separators = { left = "", right = "" },
      globalstatus = true,
    },
    sections = {
      lualine_a = { "mode" },
      lualine_b = { "branch", "diagnostics" },
      lualine_c = {
        { "filename", path = 1 },
      },
      lualine_x = { "encoding", "fileformat", "filetype" },
      lualine_y = { "progress" },
      lualine_z = { "location" },
    },
    inactive_sections = {
      lualine_a = {},
      lualine_b = {},
      lualine_c = { "filename" },
      lualine_x = { "location" },
      lualine_y = {},
      lualine_z = {},
    },
    extensions = { "neo-tree", "quickfix" },
  },
}
Нажмите, чтобы развернуть и увидеть больше

Тема прописана вручную под Nord-палитру, а не взята из готовых пресетов lualine - разные цвета a-секции под режим (normal/insert/visual/replace/command) сразу показывают, в каком режиме находится редактор, даже боковым зрением. globalstatus = true - одна статусная строка на все окна, а не по одной на каждое. path = 1 в filename - показывать путь относительно текущей директории, а не только имя файла.

lua/plugins/neo-tree.lua

Горячая клавиша - F3 открывает и закрывает Neo-tree:

LUA
return {
  "nvim-neo-tree/neo-tree.nvim",
  branch = "v3.x",
  cmd = "Neotree",
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-tree/nvim-web-devicons",
    "MunifTanjim/nui.nvim",
  },
  keys = {
    { "<F3>", "<cmd>Neotree toggle<CR>", desc = "Toggle Neo-tree" },
  },
  opts = {
    close_if_last_window = true,
    enable_git_status = true,
    event_handlers = {
      {
        event = "neo_tree_window_after_open",
        handler = function()
          local terminal_win = vim.t.toggleterm_restore_win
          vim.t.toggleterm_restore_win = nil

          vim.schedule(function()
            if _G.keep_bottom_terminal_full_width then
              _G.keep_bottom_terminal_full_width(false)
            end

            if type(terminal_win) == "number" and vim.api.nvim_win_is_valid(terminal_win) then
              vim.api.nvim_set_current_win(terminal_win)
              vim.cmd.startinsert()
            end
          end)
        end,
      },
    },
    window = {
      width = 35,
    },
    filesystem = {
      bind_to_cwd = true,
      cwd_target = "window",
      filtered_items = {
        hide_dotfiles = false,
        hide_gitignored = false,
      },
    },
  },
}
Нажмите, чтобы развернуть и увидеть больше

Боковая панель на F3, close_if_last_window = true - закрывается сама, если остаётся единственным окном (тот самый недостающий кусочек, который вместе с QuitPre из autocmds.lua убирает E444). Скрытые и игнорируемые git файлы не прячутся - hide_dotfiles/hide_gitignored оба false.

Отдельный event_handlers - это стык с toggleterm: если Neo-tree открывают из терминала (там есть свой биндинг на F3, см. ниже), после открытия панели фокус и ширина нижнего терминала восстанавливаются, чтобы он не “съёживался” под новую ширину окна.

lua/plugins/noice.lua

Горячая клавиша - Shift+F4/F16 открывает и закрывает историю сообщений через Telescope:

LUA
local function toggle_history()
  if vim.bo.filetype == "TelescopePrompt" then
    require("telescope.actions").close(vim.api.nvim_get_current_buf())
    return
  end

  require("noice").cmd("telescope")
end

return {
  "folke/noice.nvim",
  event = "VeryLazy",
  keys = {
    { "<S-F4>", toggle_history, mode = { "n", "i", "t" }, desc = "Toggle Noice history" },
    { "<F16>", toggle_history, mode = { "n", "i", "t" }, desc = "Toggle Noice history" },
  },
  dependencies = {
    "MunifTanjim/nui.nvim",
    {
      "rcarriga/nvim-notify",
      opts = {
        stages = "fade_in_slide_out",
        timeout = 4000,
        top_down = true,
      },
    },
  },
  opts = {
    lsp = {
      override = {
        ["vim.lsp.util.convert_input_to_markdown_lines"] = true,
        ["vim.lsp.util.stylize_markdown"] = true,
        ["cmp.entry.get_documentation"] = true,
      },
    },
    presets = {
      bottom_search = true,
      command_palette = false,
      long_message_to_split = true,
      inc_rename = false,
      lsp_doc_border = true,
    },
  },
}
Нажмите, чтобы развернуть и увидеть больше

noice.nvim заменяет стандартный вывод сообщений и command line на аккуратные всплывающие окна, а nvim-notify показывает уведомления в правом верхнем углу. Командная строка : открывается по центру; поиск / и ? благодаря bottom_search = true остаётся снизу. Двойной Esc закрывает текущие сообщения.

Shift+F4 (и терминальный дубль F16) открывает историю сообщений Noice через Telescope. Внутри списка работают фильтрация вводом, стрелки или Ctrl+n/Ctrl+p, Enter и Esc; повторный Shift+F4 тоже закрывает picker. Ту же историю можно вызвать командой :Noice telescope.

lua/plugins/nord.lua

LUA
return {
  "shaunsingh/nord.nvim",
  lazy = false,
  priority = 1000,
  config = function()
    vim.g.nord_contrast = true
    vim.g.nord_borders = false
    vim.g.nord_disable_background = false
    vim.g.nord_italic = false
    vim.g.nord_uniform_diff_background = true
    vim.g.nord_bold = false

    vim.cmd.colorscheme("nord")

    local function set_highlights()
      local hl = vim.api.nvim_set_hl

      hl(0, "Cursor", { fg = "#2E3440", bg = "#ECEFF4" })
      hl(0, "lCursor", { fg = "#2E3440", bg = "#ECEFF4" })
      hl(0, "TermCursor", { fg = "#2E3440", bg = "#ECEFF4" })
      hl(0, "iCursor", { fg = "#2E3440", bg = "#A3BE8C" })
      hl(0, "rCursor", { fg = "#2E3440", bg = "#EBCB8B" })
      hl(0, "CursorIM", { fg = "#2E3440", bg = "#A3BE8C" })
      hl(0, "CursorLine", { bg = "#3B4252" })
      hl(0, "CursorLineNr", { fg = "#ECEFF4", bg = "#3A4150", bold = true })
      hl(0, "WinSeparator", { fg = "#88C0D0", bg = "#2E3440" })
      hl(0, "ToggleTermSeparator", { fg = "#88C0D0", bg = "#2E3440" })
      hl(0, "MatchParen", { fg = "#8FBCBB", bg = "NONE", bold = true, underline = true })

      hl(0, "DiagnosticSignError", { fg = "#BF616A", bg = "NONE" })
      hl(0, "DiagnosticSignWarn", { fg = "#EBCB8B", bg = "NONE" })
      hl(0, "DiagnosticSignInfo", { fg = "#88C0D0", bg = "NONE" })
      hl(0, "DiagnosticSignHint", { fg = "#A3BE8C", bg = "NONE" })
      hl(0, "DiagnosticVirtualTextError", { fg = "#BF616A", bg = "#3B4252" })
      hl(0, "DiagnosticVirtualTextWarn", { fg = "#EBCB8B", bg = "#3B4252" })
      hl(0, "DiagnosticVirtualTextInfo", { fg = "#88C0D0", bg = "#3B4252" })
      hl(0, "DiagnosticVirtualTextHint", { fg = "#A3BE8C", bg = "#3B4252" })
      hl(0, "DiagnosticFloatingError", { fg = "#BF616A" })
      hl(0, "DiagnosticFloatingWarn", { fg = "#EBCB8B" })
      hl(0, "DiagnosticFloatingInfo", { fg = "#88C0D0" })
      hl(0, "DiagnosticFloatingHint", { fg = "#A3BE8C" })
    end

    set_highlights()
    vim.api.nvim_create_autocmd("ColorScheme", {
      pattern = "nord",
      callback = set_highlights,
    })
  end,
}
Нажмите, чтобы развернуть и увидеть больше

priority = 1000 и lazy = false - тема должна загрузиться раньше всех остальных плагинов, чтобы не было “мигания” стандартных цветов при старте. Поверх стандартной палитры Nord накатывается набор ручных правок: курсор перекрашен под каждый режим (Cursor/iCursor/rCursor/CursorIM) так, чтобы он был заметен на любом фоне, а не терялся, как это бывает у части colorscheme “из коробки”, плюс явные цвета для разделителей окон и диагностики. set_highlights вызывается не только сразу, но и повторно на автокоманде ColorScheme - если схема будет перезагружена, ручные правки не потеряются.

lua/plugins/ruscmd.lua

LUA
return {
  "powerman/vim-plugin-ruscmd",
  event = "VimEnter",
}
Нажмите, чтобы развернуть и увидеть больше

Позволяет выполнять команды : даже если забыл переключить раскладку с русской на английскую. Мелочь, но полезная.

lua/plugins/scrollview.lua

LUA
return {
  "dstein64/nvim-scrollview",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    scrollview_current_only = true,
    scrollview_excluded_filetypes = { "neo-tree" },
    hide_on_cursor_intersect = true,
    hide_on_text_intersect = true,
  },
}
Нажмите, чтобы развернуть и увидеть больше

Полоса прокрутки справа - только для текущего окна (scrollview_current_only), скрывается, когда пересекается с курсором или с текстом на экране (hide_on_cursor_intersect/hide_on_text_intersect), и не показывается в Neo-tree.

lua/plugins/startify.lua

LUA
return {
  "mhinz/vim-startify",
  event = "VimEnter",
  init = function()
    vim.g.startify_session_dir = vim.fn.stdpath("state") .. "/sessions"
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Стартовый экран со списком недавних файлов и сессий при запуске без аргументов. Единственная кастомизация - каталог сессий указывает туда же, куда пишут keymaps.lua (слоты F9-F12) и auto-session.lua.

lua/plugins/telescope.lua

Горячие клавиши - F2/Shift+F2 ищут файлы и текст в директории текущего файла, F6/Shift+F6 показывают диагностику workspace и буфера, F8/Shift+F8 - git status и историю текущего файла:

LUA
return {
  "nvim-telescope/telescope.nvim",
  tag = "0.1.8",
  cmd = "Telescope",
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-telescope/telescope-ui-select.nvim",
  },
  keys = {
    {
      "<F2>",
      function()
        local file = vim.api.nvim_buf_get_name(0)
        local cwd = file ~= "" and vim.fs.dirname(file) or vim.fn.getcwd()

        require("telescope.builtin").find_files({
          cwd = cwd,
          prompt_title = "Files: " .. vim.fn.fnamemodify(cwd, ":~"),
          sorter = require("telescope.sorters").get_substr_matcher(),
        })
      end,
      desc = "Find files in current file directory",
    },
    {
      "<S-F2>",
      function()
        local file = vim.api.nvim_buf_get_name(0)
        local cwd = file ~= "" and vim.fs.dirname(file) or vim.fn.getcwd()

        require("telescope.builtin").live_grep({
          cwd = cwd,
          prompt_title = "Grep: " .. vim.fn.fnamemodify(cwd, ":~"),
        })
      end,
      desc = "Search file contents in current file directory",
    },

    {
      "<F6>",
      function()
        require("telescope.builtin").diagnostics()
      end,
      desc = "Workspace diagnostics",
    },
    {
      "<S-F6>",
      function()
        require("telescope.builtin").diagnostics({ bufnr = 0 })
      end,
      desc = "Buffer diagnostics",
    },
    {
      "<F8>",
      function()
        require("telescope.builtin").git_status()
      end,
      desc = "Git status",
    },
    {
      "<S-F8>",
      function()
        require("telescope.builtin").git_bcommits()
      end,
      desc = "Current file Git history",
    },
  },
  config = function()
    local actions = require("telescope.actions")

    require("telescope").setup({
      extensions = {
        ["ui-select"] = {
          require("telescope.themes").get_dropdown({}),
        },
      },
      defaults = {
        mappings = {
          i = {
            ["<Esc>"] = actions.close,
          },
          n = {
            ["<Esc>"] = actions.close,
          },
        },
        layout_config = {
          horizontal = {
            width = 0.9,
            height = 0.7,
            preview_width = 0.5,
          },
          vertical = {
            width = 0.9,
            height = 0.9,
            preview_height = 0.5,
          },
        },
        layout_strategy = "horizontal",
      },

    })

    require("telescope").load_extension("ui-select")
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Поиск файлов в директории текущего файла на F2 и поиск по содержимому на Shift+F2.

Ключевое решение тут - cwd для find_files/live_grep берётся не из корня проекта и не из глобального cwd, а из директории текущего открытого файла (vim.fs.dirname). На монорепах из десятка сервисов глобальный поиск по всему дереву слишком шумный - обычно интересен поиск рядом с тем, над чем прямо сейчас работаешь. <Esc> закрывает Telescope и в insert-, и в normal-режиме prompt.

telescope-ui-select.nvim переопределяет стандартный vim.ui.select, поэтому выбор адаптера и модели CodeCompanion, а также другие системные списки получают поиск и нормальную навигацию стрелками. Кроме поиска, Telescope используется как единый интерфейс для диагностики и Git: F6 показывает диагностику всего workspace, Shift+F6 - только текущего буфера, F8 - изменённые файлы, Shift+F8 - историю текущего файла. Прежний список буферов на F5 убран: теперь эта клавиша переключает AI-чат CodeCompanion.

lua/plugins/toggleterm.lua

Горячая клавиша - F4 открывает и закрывает нижний терминал (или визуальная кнопка справа-вверху), растянутый на всю ширину; Ctrl+Up/Ctrl+Down меняют его высоту.

LUA
return {
  "akinsho/toggleterm.nvim",
  version = "*",
  lazy = false,
  init = function()
    _G.toggle_bottom_terminal = function()
      vim.cmd("1ToggleTerm direction=horizontal")
      vim.defer_fn(function()
        local term = require("toggleterm.terminal").get(1, true)
        if term and term:is_open() and vim.api.nvim_win_is_valid(term.window) then
          vim.api.nvim_set_current_win(term.window)
          vim.cmd("startinsert!")
        end
      end, 100)
    end

    _G.keep_bottom_terminal_full_width = function(restore_focus)
      local term = require("toggleterm.terminal").get(1, true)
      if not term or not term:is_open() or not vim.api.nvim_win_is_valid(term.window) then
        return
      end

      local current_win = vim.api.nvim_get_current_win()
      vim.api.nvim_set_current_win(term.window)
      vim.cmd("wincmd J")

      if restore_focus and vim.api.nvim_win_is_valid(current_win) then
        vim.api.nvim_set_current_win(current_win)
      end
    end
  end,
  opts = {
    size = 15,
    open_mapping = nil,
    direction = "horizontal",
    persist_size = true,
    persist_mode = false,
    start_in_insert = true,
    shade_terminals = false,
    shell = vim.o.shell,
    on_open = function(term)
      local previous_win = vim.fn.win_getid(vim.fn.winnr("#"))
      if previous_win ~= 0 and vim.api.nvim_win_is_valid(previous_win) then
        vim.t.toggleterm_last_editor_win = previous_win
      end

      _G.keep_bottom_terminal_full_width(false)

      vim.api.nvim_set_hl(0, "ToggleTermSeparator", { fg = "#88C0D0", bg = "#2E3440" })

      local winhighlight = vim.wo[term.window].winhighlight
      if not winhighlight:find("WinSeparator:", 1, true) then
        vim.wo[term.window].winhighlight = table.concat({
          winhighlight,
          "WinSeparator:ToggleTermSeparator",
        }, ","):gsub("^,", "")
      end

      local function resize(delta)
        vim.cmd(("resize %s%d"):format(delta > 0 and "+" or "", delta))
      end

      local opts = { buffer = term.bufnr, silent = true }
      local function enter_terminal_mode()
        if term:is_open() and vim.api.nvim_get_current_win() == term.window then
          vim.cmd("startinsert!")
        end
      end

      vim.keymap.set({ "n", "t" }, "<C-Up>", function()
        resize(2)
      end, vim.tbl_extend("force", opts, { desc = "Increase terminal height" }))
      vim.keymap.set({ "n", "t" }, "<C-Down>", function()
        resize(-2)
      end, vim.tbl_extend("force", opts, { desc = "Decrease terminal height" }))

      vim.defer_fn(enter_terminal_mode, 50)
    end,
  },
  config = function(_, opts)
    require("toggleterm").setup(opts)

    local toggle = "<cmd>1ToggleTerm direction=horizontal<CR>"
    local toggle_keys = { "<F4>" }

    for _, key in ipairs(toggle_keys) do
      vim.keymap.set("n", key, toggle, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Toggle bottom terminal",
      })
      vim.keymap.set("i", key, "<Esc>" .. toggle, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Toggle bottom terminal",
      })
      vim.keymap.set("t", key, [[<C-\><C-n>]] .. toggle, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Toggle bottom terminal",
      })
    end

    local function forward_to_editor(key)
      vim.cmd.stopinsert()

      local previous_win = vim.t.toggleterm_last_editor_win
      if type(previous_win) == "number" and previous_win ~= 0 and vim.api.nvim_win_is_valid(previous_win) then
        local previous_buf = vim.api.nvim_win_get_buf(previous_win)
        if vim.bo[previous_buf].buftype ~= "terminal" then
          vim.api.nvim_set_current_win(previous_win)
        end
      end

      vim.schedule(function()
        local mapping = vim.fn.maparg(key, "n", false, true)

        if type(mapping.callback) == "function" then
          local ok, err = pcall(mapping.callback)
          if not ok then
            vim.notify(err, vim.log.levels.ERROR)
          end
          return
        end

        if type(mapping.rhs) == "string" and mapping.rhs ~= "" then
          local rhs = vim.api.nvim_replace_termcodes(mapping.rhs, true, false, true)
          vim.api.nvim_feedkeys(rhs, "nx", false)
          return
        end

        vim.notify("No editor mapping for " .. key, vim.log.levels.WARN)
      end)
    end

    vim.keymap.set("t", "<F3>", function()
      local terminal_win = vim.api.nvim_get_current_win()
      vim.t.toggleterm_restore_win = terminal_win

      require("neo-tree.command").execute({
        action = "show",
        source = "filesystem",
        position = "left",
        toggle = true,
      })

      if vim.api.nvim_win_is_valid(terminal_win) then
        vim.api.nvim_set_current_win(terminal_win)
        vim.cmd.startinsert()
      end
    end, {
      noremap = true,
      silent = true,
      nowait = true,
      desc = "Toggle Neo-tree without leaving terminal",
    })

    local editor_function_keys = {
      "<S-F1>",
      "<F13>",
      "<F2>",
      "<S-F2>",
      "<F5>",
      "<S-F5>",
      "<F6>",
      "<S-F6>",
      "<F7>",
      "<S-F7>",
      "<F8>",
      "<S-F8>",
      "<F19>",
      "<F9>",
      "<F10>",
      "<F11>",
      "<F12>",
      "<S-F9>",
      "<S-F10>",
      "<S-F11>",
      "<S-F12>",
      "<F21>",
      "<F22>",
      "<F23>",
      "<F24>",
    }

    for _, key in ipairs(editor_function_keys) do
      local editor_key = key
      vim.keymap.set("t", editor_key, function()
        forward_to_editor(editor_key)
      end, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Run " .. editor_key .. " in the editor",
      })
    end
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Самый длинный файл конфига. Идея: пока фокус внутри терминала, все привычные функциональные клавиши (сессии, поиск, git-коммит, линт и так далее) должны продолжать работать, как если бы фокус был в обычном буфере. Для этого forward_to_editor перед выполнением клавиши переключается на последнее “редакторское” окно (toggleterm_last_editor_win, запомненное в on_open), находит её реальный маппинг (callback либо rhs) и выполняет его уже там - а весь список этих клавиш (editor_function_keys) перепривязан в режиме терминала на эту функцию.

Терминал переключается только обычным F4: Shift+F4/F16 теперь отданы истории Noice через Telescope. Отдельно - keep_bottom_terminal_full_width: терминал всегда растянут на всю ширину (wincmd J двигает его вниз через все колонки), даже если открыт вертикальный сплит или Neo-tree.

lua/plugins/treesitter.lua

LUA
return {
  "nvim-treesitter/nvim-treesitter",
  branch = "master",
  build = ":TSUpdate",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    auto_install = true,
    ensure_installed = {
      "lua",
      "vim",
      "vimdoc",
      "bash",
      "python",
      "json",
      "sql",
      "yaml",
      "toml",
      "markdown",
      "tmux",
      "ssh_config",
      "terraform",
      "nginx",
      "groovy",
      "pem",
      "dockerfile",
      "javascript",
      "css",
      "html",
    },
    -- Avoid concurrent installers racing over tree-sitter-<parser>-tmp
    -- while bootstrapping Neovim on a clean host.
    sync_install = true,
    highlight = {
      enable = true,
      additional_vim_regex_highlighting = false,
    },
    indent = {
      enable = true,
    },
  },
  config = function(_, opts)
    local ok, configs = pcall(require, "nvim-treesitter.configs")
    if not ok then
      vim.notify("nvim-treesitter legacy config module is unavailable", vim.log.levels.WARN)
      return
    end

    local function first_node(match, capture)
      local nodes = match[capture]
      return type(nodes) == "table" and nodes[1] or nodes
    end

    vim.treesitter.query.add_directive("set-lang-from-info-string!", function(match, _, bufnr, pred, metadata)
      local node = first_node(match, pred[2])
      if not node then
        return
      end

      local alias = vim.treesitter.get_node_text(node, bufnr):lower()
      local language_aliases = {
        ex = "elixir",
        pl = "perl",
        sh = "bash",
        ts = "typescript",
        uxn = "uxntal",
      }
      metadata["injection.language"] = vim.filetype.match({ filename = "a." .. alias })
        or language_aliases[alias]
        or alias
    end, { force = true })

    vim.treesitter.query.add_directive("set-lang-from-mimetype!", function(match, _, bufnr, pred, metadata)
      local node = first_node(match, pred[2])
      if not node then
        return
      end

      local mime = vim.treesitter.get_node_text(node, bufnr)
      local mime_languages = {
        importmap = "json",
        module = "javascript",
        ["application/ecmascript"] = "javascript",
        ["text/ecmascript"] = "javascript",
      }
      local parts = vim.split(mime, "/", { plain = true })
      metadata["injection.language"] = mime_languages[mime] or parts[#parts]
    end, { force = true })

    vim.treesitter.query.add_directive("downcase!", function(match, _, bufnr, pred, metadata)
      local capture = pred[2]
      local node = first_node(match, capture)
      if not node then
        return
      end

      metadata[capture] = metadata[capture] or {}
      local text = vim.treesitter.get_node_text(node, bufnr, { metadata = metadata[capture] }) or ""
      metadata[capture].text = text:lower()
    end, { force = true })

    -- GitHub's codeload archive endpoint sometimes 404s for pinned parser
    -- revisions that aren't a branch tip (e.g. tree-sitter-html), which
    -- surfaces as "gzip: stdin: not in gzip format". `git clone` of the
    -- same commit works fine, so prefer it over tarball downloads.
    require("nvim-treesitter.install").prefer_git = true

    configs.setup(opts)
  end,
}
Нажмите, чтобы развернуть и увидеть больше

Список ensure_installed покрывает языки и форматы, с которыми регулярно работаю - от lua/bash/python до terraform/nginx/dockerfile. auto_install = true автоматически ставит отсутствующий парсер при открытии файла поддерживаемого типа; если для языка нет готового парсера, Neovim просто продолжит работать без Treesitter-подсветки. sync_install = true ставит парсеры последовательно, а не параллельно - без этого на чистой машине несколько установщиков параллельно роняли друг друга гонкой за временную директорию tree-sitter-<parser>-tmp.

Три переопределённые директивы (set-lang-from-info-string!, set-lang-from-mimetype!, downcase!) - compatibility-слой для capture-массивов нового Treesitter API в Neovim 0.13-dev. Без него повторный разбор Markdown в render-markdown.nvim мог падать на attempt to call method range (a nil value).

prefer_git = true - отдельная история: codeload.github.com иногда отдаёт 404 на архив для запиненного коммита парсера, который не является кончиком ветки (ловил это на tree-sitter-html), а git clone того же коммита работает штатно. По умолчанию prefer_git включён только на Windows, здесь он явно включён и на Linux/Mac.

Послесловие

Переезд с init.vim на Lua затянулся, но с развитием ИИ дело пошло намного быстрее. LSP я специально оставил выключенным: для большинства задач хватает быстрых линтеров и форматтеров без запуска языковых серверов. Но при необходимости включить всё можно одной строкой.

Сперва я хотел всё также “минималистично” хранить конфигурацию в одном-двух файлах. Но при разрастании конфига модульная структура начинает полностью себя оправдывать: нужно выключить или изменить что-то одно - зашли в конкретный файл, поправили что нужно. Это конечно вкусовщина, лично мне такой конфиг банально удобнее поддерживать 🐧.

Спасибо, что читаете. Успехов в при выходе из Neovim!

Используемые материалы

Комментарии

Авторские права

Автор: Иван Чёрный

Ссылка: https://r4ven.me/dots/neovim-lua-konfig-redaktora-polnyy-razbor-po-faylam/

Лицензия: CC BY-NC-SA 4.0

Использование материалов блога разрешается при условии: указания авторства/источника, некоммерческого использования и сохранения лицензии.

Начать поиск

Введите ключевые слова для поиска статей

↑↓
ESC
⌘K Горячая клавиша