null

Neovim в массы! Собираем IDE своими руками

Что-то не весело мне сегодня. Всё кругом грустное, всё некрасивое... Всё течет, всё поломалось, и ничего не меняется...

А раз ничего не меняется, срочно необходимо что-то поменять! Как на счет начать со своего рабочего окружения? Сегодня мы вместе пройдем путь конфигурации одного из самых противоречивых текстовых редакторов и постараемся сделать из него самую настоящую IDE!

Развернуто отвечать на вопрос "зачем?" я особо смысла не вижу, мотивы у всех свои, поэтому кратко: мне просто захотелось научиться использовать vim. Понятное дело, что в чистом виде он для написания кода (а это именно то, чем я занимаюсь на работе) не пригоден от слова совсем, поэтому пришлось немножко запариться.

Большая часть моего конфига нагло украдена у замечательного инженера (бывшего) из Netflix - ThePrimagen, - поэтому если вы хотите подробнее разобраться в вопросе и уверенно чувствуете себя во время просмотра роликов на английском языке - пожалуйте к нему на YT-канал: у него есть целая серия роликов, посвященная этой теме. Ну а мы поехали!

Устанавливаем Neovim

Первое, что нам необходимо сделать - установить neovim. Это форк всем известного vim, основной отличительной чертой которого является то, что для его конфигурации используется лаконичный язык программирования Lua, а не мерзкий vimscript. Об остальных отличиях искушенный читатель может узнать самостоятельно, они нам сегодня не интересны.

Для наших целей важно убедиться, что версия установленного neovim не ниже 0.10.* - на момент написания статьи это все еще не стабильный релиз, но кажется скоро он таковым станет. Для работы некоторых плагинов это будет иметь значение. С инструкцией по установке можно ознакомиться в репозитории neovim на GitHub.

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

$ nvim -v

NVIM v0.10.0-dev
Build type: RelWithDebInfo
LuaJIT 2.1.0-beta3
Run "nvim -V1 -v" for more info

Менеджер плагинов

Первое, что необходимо сделать - создать директорию, в которой мы будем складировать наши конфиги. Стандартное место, откуда nvim будет пытаться их достать - ~/.config/nvim. Создадим эту директорию, если до этого ее не было, перейдем в нее и создадим файл init.lua - это будет стартовым скриптом, который будет дергать все остальные, что мы создадим.

Чтобы наши конфиги не валялись одним скопом, попробуем организовать их в подобие проекта. В этой же директории создаем поддиректорию ./lua/lolikon - название нашего проекта. Размещаем там еще один init.lua - он будет менеджерить данный подпроект. В файле ~/.config/nvim/init.lua добавляем строчку:

require('lolikon')

Чтобы загрузить наш модуль.

Первым же плагином, который необходимо установить, будет менеджер плагинов. Чтобы не устанавливать все остальные плагины вручную! Вариантов этих менеджеров достаточно, мы воспользуемся Packer - классикой. Чтобы установить его, переходим в его репозиторий на GitHub. Копируем первую же команду, которую находим в README (для пользователей linux. Остальным соболезную предлагаю почитать этот самый README и найти то, что нужно), и пихаем в терминал:

git clone --depth 1 https://github.com/wbthomason/packer.nvim\
 ~/.local/share/nvim/site/pack/packer/start/packer.nvim

Как только все получилось, создаем новый файл: ~/config/nvim/lua/lolikon/packer.lua и пихаем в него следующий текст (тоже скопированый из гита):

vim.cmd [[packadd packer.nvim]]

return require('packer').startup(function(use)
  -- Packer can manage itself
  use 'wbthomason/packer.nvim'

end)

Выходим из nvim, если он был запущен, открываем этот файл через nvim снова и выполняем команду :so чтобы его подгрузить. Ура, теперь у нас есть менеджер плагинов! Чтобы убедиться в его работоспособности исполняем команду :PackerSync. В результате испольнения увидим что-то типа:

(После установки каждого плагина рекоммендую перезапускать nvim во избежание возможных ошибок, на разбирательство в которых можно потратить немало времени)

Нечеткий поиск

Начинаем превращать обычный текстовый редактор в IDE! А какая IDE без поиска по файлам внутри проекта? Это ж придется руками скакать по директориям и искать нужные исходники! Не круто. Установим плагин telescope, который позволит нам решить эту проблему. Как обычно, идем на GitHub и тырим оттуда конфиг для Packer'а (дополняем наш packer.lua):

vim.cmd [[packadd packer.nvim]]

return require('packer').startup(function(use)
  -- Packer can manage itself
  use 'wbthomason/packer.nvim'

  use {
          'nvim-telescope/telescope.nvim', tag = '0.1.6',
          -- or                            , branch = '0.1.x',
          requires = { {'nvim-lua/plenary.nvim'} }
  }

end)

Исполняем :so -> :PackerSync и радуемся... А чему радоваться-то? Установить мы его установили, но без своего конфига он ничего не умеет. Создаем директорию ~/.config/nvim/after/plugin и в ней размещаем файл telescope.lua со следующим содержанием:

local builtin = require('telescope.builtin')
vim.keymap.set('n', '<leader>pf', builtin.find_files, {})
vim.keymap.set('n', '<C-p>', builtin.git_files, {})
vim.keymap.set('n', '<leader>ps', function()
        builtin.grep_string({ search = vim.fn.input("Grep > ") });
end)

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

Смысл примерно такой: первый параметр - 'n' - это режим, в котором маппинг будет активен. В данном случае n = normal. У vim есть несколько режимов, среди которых, например: normal (режим перемещения по документу), insert (режим ввода текста), visual (режим выделения). Первопроходцу этого пока будет достаточно, а продвинутые пользователи и так знают побольше меня.

Короче все маппинги, которые мы здесь объявили, работают в нормальном режиме.

Второй параметр - это комбинация клавиш, которой необходимая команда будет вызываться. Третий параметр - функция, которая будет исполняться по данной комбинации клавиш.

Соответственно, сами команды, которые мы объявили, делают следующее:

  1. По последовательному нажатию клавиш <leader>, p и осуществляет поиск по названиям файлов внутри текущего проекта. Границы проекта обозначаются в момент, когда вы открываете nvim. Например, если я исполню команду nvim ., будучи внутри директории ~/.config/nvim, то проектом будут считаться все файлы и директории, вложенные в эту директорию (в том числе рекурсивно вглубь). (О том, что за клавиша это такая - <leader> - чуть позже).
  2. По одновременному нажатию клавиш Ctrl + p осуществляет поиск по названиям файлов внутри текущего репозитория git и только по файлам, зарегистрированным в git! Это значит, что игнорируются все файлы, размещенные в .gitignore, что бывает весьма удобно, а так же новые файлы, которые еще не были помечены git add.
  3. По последовательному нажатию клавиш <leader>, p и s осуществляет поиск по внутренностям файлов в текущем проекте на предмет содержания в них искомой подстроки (для работы данной команды необходимо наличие утилиты ripgrep установленной в системе). 

И так, добавили код куда нужно, сделали :so в этом файле, и вуаля - наши маппинги готовы к работе! Но что же все-таки за <leader>? Это специальный указатель на клавишу, которую каждый пользователь выбирает для себя сам и с которой ему удобно создавать новые команды для vim и исполнять их. Большинство пользователей по классике выбирают для этих целей пробел (spacebar), и мы остановимся на нем, но каждый может выбрать для себя то, что ему захочется. Главное не нарожать конфликтов, с которыми потом придется разбираться.

Вернемся в наш модуль конфигурации и создадим файл ~/.config/nvim/lua/lolikon/remap.lua со следующим содержимым:

vim.g.mapleader = " "

А в файл ~/.config/nvim/lua/lolikon/init.lua допишем:

require('lolikon.remap')

Выполняем :so будучи внутри первого и радуемся жизни! Чтобы проверить, что все работает, перейдем в ~/.config/nvim и исполним команду nvim .
В открывшемся проекте попробуем нажать последовательно Space, p и - ожидаем получить похожий результат:

Цветовая схема

Очередной шаг на пути становления повелителем своего окружения сделан. Но посмотрите на свой экран. И посмотрите на скриншот выше. Снова на свой экран. И снова на скриншот выше. Заметили разницу? Пользователи Windows, работающие из cmd, могли упустить, но остальные, скорее всего, обратили внимание на то, что цвет меню telescope у них сильно более вырвиглазный, чем на моем скриншоте, и даже могли подумать, что у них что-то сломалось по пути. Не беспокойтесь, это не так. Просто вы не установили тему, как у меня! 

Здесь наши пути почти наверняка расходятся, ведь предпочтения у всех свои. Я выбрал для себя тему под названием oxocarbon, вы можете найти для себя любую другую на просторах интернета. Однако устанавливаются они все +/- одинаково - находим на GitHub'е конфиг для менеджера плагинов (Packer в нашем случае) и пихаем его в packer.lua:

 use ({'nyoom-engineering/oxocarbon.nvim',
  config = function()
          vim.cmd('colorscheme oxocarbon')
  end})

Как обычно :so -> :PackerSync и вуаля, все готово!

Подсветка синтаксиса

А вы знали, что большая часть плагинов, которые вы используете в VSCode для подсветки синтаксиса, используют под капотом регулярные выражения? И я до недавнего времени не знал. Теперь попробуйте угадать, почему при открытии в ней файла с 10к+ строк кода вентиляторы системы охлаждения вашего ПК разгоняются до сверхзвука.

Мы пойдем более умным путем. Установим treesitter - плагин, строящий и анализирующий AST вашего кода!

Идем уже привычным путем - обновляем конфиг packer.lua:

 use( 'nvim-treesitter/nvim-treesitter', {run = ':TSUpdate'})

:so -> :PackerSync - плагин установлен. Создаем ~/.config/nvim/after/plugin/treesitter.lua:

require'nvim-treesitter.configs'.setup {
  -- A list of parser names, or "all" (the five listed parsers should always be installed)
  ensure_installed = { "json", "css", "html", "scss", "javascript", "typescript","c", "lua", "vim", "vimdoc", "query" },

  -- Install parsers synchronously (only applied to `ensure_installed`)
  sync_install = false,

  -- Automatically install missing parsers when entering buffer
  -- Recommendation: set to false if you don't have `tree-sitter` CLI installed locally
  auto_install = true,

  ---- If you need to change the installation directory of the parsers (see -> Advanced Setup)
  -- parser_install_dir = "/some/path/to/store/parsers", -- Remember to run vim.opt.runtimepath:append("/some/path/to/store/parsers")!

  highlight = {
    enable = true,

    -- Setting this to true will run `:h syntax` and tree-sitter at the same time.
    -- Set this to `true` if you depend on 'syntax' being enabled (like for indentation).
    -- Using this option may slow down your editor, and you may see some duplicate highlights.
    -- Instead of true it can also be a list of languages
    additional_vim_regex_highlighting = false,
  },
}

В массив ensure_installed можно поместить те парсеры, которые нужны лично вам. Это список парсеров, которые будут установлены всегда. Будьте внимательны - там всегда должны находиться как минимум следующие элементы: 

{ "c", "lua", "vim", "vimdoc", "query" }

Список поддерживаемых языков можно найти на странице на GitHub. Так же там, разумеется, подробно описаны возможные опции для конфигурирования (правда для любого из плагинов, про которые я сегодня рассказываю).

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

(В дополнение к treesitter рекоммендую почитать про treesitter-playground - совершенно не обязательный плагин, но возможно он будет интересен людям, изучающим компиляторы и смежные темы)

Вишенка на торте - LSP

Каких бы плагинов мы еще не наустанавливали, наш nvim - все еще просто крутой блокнот. Он не знает, что такое код и какие в нем существуют зависимости - максимум может отличить ключевое слово от названия переменной. Давайте это исправим. Установим же менеджер LSP!

Если вы уже смешарик, вы можете устанавливать ваши LSP руками (только тогда не совсем понятно, зачем вы читаете эту статью). Мы же воспользуемся плагином lsp-zero, который будет это делать за нас.

Все как обычно - packer.lua:

 use {
          'VonHeikemen/lsp-zero.nvim',
          branch = 'v3.x',
          requires = {
                  --- Uncomment the two plugins below if you want to manage the language servers from neovim
                  {'williamboman/mason.nvim'},
                  {'williamboman/mason-lspconfig.nvim'},

                  {'neovim/nvim-lspconfig'},
                  {'hrsh7th/nvim-cmp'},
                  {'hrsh7th/cmp-nvim-lsp'},
                  {'L3MON4D3/LuaSnip'},
          }
  }

:so -> :PackerSync -> создаем ~/.config/nvim/after/plugin/lsp.lua:

local lsp = require('lsp-zero')

lsp.preset('recommended')
require('mason').setup({})
require('mason-lspconfig').setup({

        ensure_installed = {
                'eslint',
                'tsserver',
                'cssls',
        'lua_ls',
        },
    handlers = {
        function(server_name)
            require('lspconfig')[server_name].setup({})
        end,

        lua_ls = function ()
            require('lspconfig').lua_ls.setup({
                settings = {
                    Lua = {
                        diagnostics = { globals = {'vim'} }
                    }
                }
            })

        end
    },
})

local cmp = require('cmp')
local cmp_select = {behaviour = cmp.SelectBehavior.Select}
local cmp_mappings = cmp.mapping.preset.insert({
        ['<C-p>'] = cmp.mapping.select_prev_item(cmp_select),
        ['<C-n>'] = cmp.mapping.select_next_item(cmp_select),
        ['<C-y>'] = cmp.mapping.confirm({ select = true }),
        ['<C-Space>'] = cmp.mapping.complete(),
})

cmp.setup({
        mapping = cmp_mappings
})

lsp.on_attach(function(client, bufnr)
        local opts = {buffer = bufnr, remap = false}

        vim.keymap.set("n", "gd", function() vim.lsp.buf.definition() end, opts)
        vim.keymap.set("n", "K", function() vim.lsp.buf.hover() end, opts)
        vim.keymap.set("n", "<leader>vws", function() vim.lsp.buf.workspace_symbol() end, opts)
        vim.keymap.set("n", "<leader>vd", function() vim.diagnostic.open_float() end, opts)
        vim.keymap.set("n", "[d", function() vim.diagnostic.goto_next() end, opts)
        vim.keymap.set("n", "]d", function() vim.diagnostic.goto_prev() end, opts)
        vim.keymap.set("n", "<leader>vca", function() vim.lsp.buf.code_action() end, opts)
        vim.keymap.set("n", "<leader>vrr", function() vim.lsp.buf.references() end, opts)
        vim.keymap.set("n", "<leader>vrn", function() vim.lsp.buf.rename() end, opts)
        vim.keymap.set("i", "<C-h>", function() vim.lsp.bug.signature_help() end, opts)
end)

lsp.setup()

:so -> Good to go! (Здесь у вас могут возникнуть проблемы, если у вас не установлен node или если установлена старая версия. Большинство LSP используют его, так что убедитесь, что у вас в PATH версия, которая хотя бы поддерживает оператор ??)

Но давайте остановимся здесь поподробнее и рассмотрим, что мы в этом конфиге вообще делаем.

ensure_installed = { 'eslint', 'tsserver', 'cssls', 'lua_ls', }

Как и в случае с treesitter, этот массив содержит в себе список LSP, которые должны быть установлены всегда. Здесь нет обязательных элементов, данный пример содержит те, что необходимы мне. Со списком доступных LSP можно ознакомиться в репозитории Mason - плагина, который устанавливается в составе lsp-zero и отвечающего за установку собственно LSP.

lua_ls = function ()
            require('lspconfig').lua_ls.setup({
                settings = {
                    Lua = {
                        diagnostics = { globals = {'vim'} }
                    }
                }
            })

        end

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

local cmp = require('cmp')
local cmp_select = {behaviour = cmp.SelectBehavior.Select}
local cmp_mappings = cmp.mapping.preset.insert({
        ['<C-p>'] = cmp.mapping.select_prev_item(cmp_select),
        ['<C-n>'] = cmp.mapping.select_next_item(cmp_select),
        ['<C-y>'] = cmp.mapping.confirm({ select = true }),
        ['<C-Space>'] = cmp.mapping.complete(),
})

cmp.setup({
        mapping = cmp_mappings
})

Данный кусочек кода особенно полезен, так как он отвечает за автодополнение! А именно - устанавливает комбинации клавиш для таких действий, как:

  1. выбрать предыдущий элемент списка;
  2. выбрать следующий элемент списка;
  3. подтвердить выбор элемента;
  4. завершить выбор.
lsp.on_attach(function(client, bufnr)
        local opts = {buffer = bufnr, remap = false}

        vim.keymap.set("n", "gd", function() vim.lsp.buf.definition() end, opts)
        vim.keymap.set("n", "K", function() vim.lsp.buf.hover() end, opts)
        vim.keymap.set("n", "<leader>vws", function() vim.lsp.buf.workspace_symbol() end, opts)
        vim.keymap.set("n", "<leader>vd", function() vim.diagnostic.open_float() end, opts)
        vim.keymap.set("n", "[d", function() vim.diagnostic.goto_next() end, opts)
        vim.keymap.set("n", "]d", function() vim.diagnostic.goto_prev() end, opts)
        vim.keymap.set("n", "<leader>vca", function() vim.lsp.buf.code_action() end, opts)
        vim.keymap.set("n", "<leader>vrr", function() vim.lsp.buf.references() end, opts)
        vim.keymap.set("n", "<leader>vrn", function() vim.lsp.buf.rename() end, opts)
        vim.keymap.set("i", "<C-h>", function() vim.lsp.bug.signature_help() end, opts)
end)

А эта часть конфига позволяет установить маппинги, которые будут активны только тогда, когда в текущем скоупе есть активный LSP. Названия функций говорящие, а подробнее с возможным функционалом lsp-zero можно ознакомиться по ссылке. Мой конфиг здесь полностью совпадает с ThePrimagen - мне было проще сделать так, чем придумывать свои комбинации.

Заключение

Ну вот и все! Все самое главное у нас есть, остальное - вкусовщина. Если вам нужны еще какие-то плагины - ищите интернет, различных решений уйма! Vim - очень популярная система, и почти все, что вы можете себе представить, для него уже кто-то написал. Успехов вам в познании! В качестве домашнего задания попробуйте добавить маппинг, который будет вызывать :EslintFixAll каждый раз, когда вы сохраняете файл. Не забывайте, что для этого в вашем контексте должен быть eslint!)