Pull to refresh

История одного плагина

Reading time12 min
Views7.1K

Все началось с того, что у меня перестал работать tagbar. Плагин падал с ошибкой, якобы текущая моя версия Exuberant Ctags вовсе не Exuberant. Покопавшись немного в исходниках, я понял, что последняя внешняя команда завершалась с ошибкой, а v:shell_error выдавал -1, что говорит о том, судя по документации vim'a, что "the command could not be executed". Я не стал копать дальше и установил fzf. Fzf, как и ctrlp, позволяет проводить нечеткий поиск по файлам, тегам, буферам, ..., но в отличии от последнего, работает гораздо шустрее, однако, не без минусов. Приложение работает напрямую с терминалом и каждый раз затирает мне историю вводимых команд. Это также означает, что мы не можем отобразить результаты поиска в буфере (neovim, судя по некоторым скринкастам, может), например, справа от основного буфера, когда ищем нужный тег. В отличие от sublime, fzf не придает больший вес имени файла, из — за чего я часто получал в топе вовсе не те результаты, которые ожидал увидеть. Ко всему прочему, отсутствие полной свободы в настройке цветовой схемы, что в общем-то не слишком важно для обычного пользователя, но только не для меня, с моим повышенным вниманием к мелочам. Под свободой я понимаю, как минимум, разграничение цвета для обычного (нормального) текста и строки запроса.


Всё это подтолкнуло меня к написанию своего плагина, внешний вид которого напоминает стандартный просмотрщик директорий — netrw. Я опишу проблемы, с которыми сталкивался, и пути их решения, полагая, что этот опыт может быть кому-то полезен.


Vim script language


Прежде всего хотел бы провести небольшую экскурсию для тех, кто делает первые шаги в vim script. Переменные имеют префиксы, некоторые из них вы уже видели и писали самостоятельно. Обычно настройка плагина происходит с помощью глобальных переменных с префиксом g:. При написании своего плагина уместно использовать префикс s:, который делает переменные доступными только в пределах скрипта. Чтобы обратиться к аргументу функции, используют префикс a:. Переменные без префикса локальны для функции, в которой они были объявлены. Полный перечень префиксов можно посмотреть командой :help internal-variables.


Для управления буфера существуют две очень простых функции: getline и setline. С их помощью можно вставить результаты поиска в буфер или получить значение запроса. Я не буду останавливаться на описании каждой функции, поскольку из названия, зачастую, и так понятно, что она делает. Почти любое ключевое слово из этой статьи можно искать в документации, поэтому :help getline или :help setline, а для полной картины советую посмотреть :help function-list со списком всех функций, сгруппированных по разделам.


События


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


// слушаем событие CustomEvent
autocmd User CustomEvent call ...

// если подписчиков нет, можно получить "No matching autocommands", так что возбуждаем событие только при наличии слушателей
if(exists("#User#CustomEvent"))
    doautocmd User CustomEvent
endif

Автозагрузка


Всем функциям в моём плагине я задаю префикс finder#. Это встроенный механизм автозагрузки vim, который ищет в runtimepath нужный файл с таким же именем. Функция finder#call должна быть расположена в файле runtimepath/finder.vim, функция finder#files#index — в файле runtimepath/finder/files.vim. Затем, нужно добавить плагин в runtimepath.


set runtimepath+=path/to/plugin

Но лучше для этих целей использовать менеджер плагинов, например, vim-plug.


Составные команды


Часто возникает ситуация, когда команду нужно комбинировать из разных кусочков или просто вставить значение переменной. Для этих целей, в vim существует команда execute, которую часто удобно использовать с функцией printf.


execute printf('syntax match finderPrompt /^\%%%il.\{%i\}/', b:queryLine, len(b:prompt))

Начнем


Итак, всё, что нам нужно — это строка запроса и результат поиска. За пользовательский ввод в vim отвечает функция input, но, насколько мне известно, она не позволяет разместить строку ввода наверху, а это довольно важно, если речь идет о поиске по тегам, поскольку теги удобнее отображать в том порядке, в каком они представлены в файле. Более того, со временем я решил сделать похожую шапку, какую показывает netrw. Строку ввода нужно было реализовывать в буфере, тут и появляются первые трудности.


Запрос


Чтобы получить значение запроса, нам нужно знать строку, на которой находится поле ввода и смещение относительно подсказки, а также задать обработчик для события TextChangedI. Поскольку для любого, кто ранее программировал, не должно быть ничего сложного на данном этапе, код я опущу; добавлю лишь, что обработчик нужно вешать с атрибутом <buffer>.


autocmd TextChangedI <buffer> call ...

Prompt


Поскольку подсказка находится на той же строке, что и пользовательский ввод, нужно каким-то образом зафиксировать её. Для этих целей можно было бы очистить значение опции backspace, которая отвечает за поведение таких клавиш, как <BS> и <Del>. В частности, меня интересовали только eol и start. Eol разрешает удаление символа конца строки и соответственно слияние строк, start же разрешает удаление только того текста, что был введен после начала режима вставки. Выходило достаточно удобно и просто: я вставляю подсказку "Files> ", например, затем начинаю вводить текст и при удалении текста, подсказка оставалась на месте. Я, правда, не учел один момент — для работы такого плагина нужно достаточно много логики и выход в нормальный режим был обыденной практикой. Любой маппинг мог запросто начать новую "сессию" и текст, что был введен ранее, переставал удаляться. Мне всего-лишь нужно было нажать <Esc>, например:


inoremap <C-j> <Esc>:call ...

Пришлось создать маппинг для <BS> и удалять текст вручную.


inoremap <buffer><BS> <Esc>:call finder#backspace()<CR>

Появилось какое-то странное мерцание курсора, которое со временем стало жутко раздражать. Прошло немало времени, прежде чем я понял, что виною тому — переход в режим ввода команд (command-line mode), который мы обычно инициируем, нажимая :. В этот самый момент курсор, что находится над текстом, исчезает. Эффект мерцания тем сильнее, чем "тяжелее" вызываемая функция. Были попытки повесить обработчик на событие TextChangedI, который проверял текущую позицию курсора, и если курсор находился в опасной близости от подсказки, то нужно было всего-лишь забиндить <BS> делать ничего. К сожалению, иногда 1 символ всё же удалялся. Спустя какое-то время было найдено решение — атрибут <expr>.


map {lhs} {rhs}

Где {rhs} — валидное выражение (:help expression-syntax), результат которого вставляется в буфер. Особые клавиши, такие как <BS> или <C-h> должны обрамляться двойными кавычками и экранироваться символом \ (:help expr-quote).


inoremap <expr><buffer><BS> finder#canGoLeft() ? "\<BS>" : ""
inoremap <expr><buffer><C-h> finder#canGoLeft() ? "\<BS>" : ""
inoremap <expr><buffer><Del> col(".") == col("$") ? "" : "\<Del>"
inoremap <expr><buffer><Left> finder#canGoLeft() ? "\<Left>" : ""

Выход


Для того чтобы выйти из буфера можно забиндить <Esc>. Неприятный момент состоит в том, что некоторые комбинации клавиш начинаются с той же последовательности символов, что и <Esc>. Если войти в режим ввода и нажать <C-v>, затем любую из стрелочек, то можно увидеть ^[ в качестве префикса. Например, для стрелки "влево" терминал посылает ^[OD vim'у. Поэтому, когда нажимается любая стрелка или <S-Tab>, то vim выполнит действие, назначенное <Esc>, затем попытается интерпретировать остальные символы: для стрелки влево это будет вставка пустой строки вверху (O) и заглавного литерала "D" на этой же строке. Опция esckeys указывает на то, стоит ли ожидать поступления новых символов, если последовательность начинается с <Esc>, то есть с ^[, в режиме ввода. Казалось бы, то, что надо, но работает только в том случае, если мы не меняем поведение <Esc>.


-

Здесь могла быть ваша шутка, дорогой пользователь IDE.


Возможно, я что-то упустил, но не зря на различных ресурсах советуют не менять поведение этой клавиши. Если <S-Tab> не так и важен, то стрелочки неплохо бы забиндить на выбор следующего/предыдущего вхождения. Поэтому, вместо <Esc>, используем событие InsertLeave. Это влечет за собой новые проблемы. Как вызвать функцию, не выходя из режима ввода? Читая документацию, я наткнулся на один интересный момент — комбинацию <Ctrl-c>, которая выходит из режима вставки, не вызывая событие InsertLeave, но, что довольно странно, если <C-c> присутствует в маппинге, это не работает и InsertLeave таки всплывает. Бороздя по просторам интернета, было найдено решение, общий вид которого:


inoremap <BS> <C-r>=expr<CR>

Из документации следует, что это — expression register. Это именно то, что мне нужно было, за исключением того, что результат выражения вставлялся в буфер. Собственно на этом и построен весь плагин, поскольку все телодвижения происходят в режиме вставки. Дабы не возвращать в каждой функции пустую строку (если этого не сделать, функция вернет 0), я решил использовать посредника, который вызывает нужную функцию.


function! finder#call(fn, ...)
    call call(a:fn, a:000)

    return ""
endfunction

Пространство имен a: отвечает за доступ к аргументам функции, а переменная a:000 содержит список необязательных параметров. Поскольку теперь стало возможным писать логику приложения не выходя из режима ввода, можно было бы воспользоваться опцией backspace. Однако, как я позже узнал, сброс значения этой опции приводил в негодование delimitMate, из — за чего тот не мог нормально функционировать, и я решил оставить эти попытки.


Бэкенд


Всего-то ничего и у нас уже есть пачка бесполезных пикселей. Самое время добавить немного жизни нашему буферу. Так как vim script сложно назвать быстрым языком или языком, на котором приятно писать что-то сложное, я решил бэкенд написать на D. Поскольку нечеткий поиск мне лень реализовывать не нужен, это будет поиск с учетом точного вхождения, и я решил, что буду посимвольно сравнивать исходную строку с запросом пользователя, посчитав, что так будет гораздо быстрее, нежели использование регулярных выражений. Учитывая то, что у меня фактически было 4 режима: ^query, query, query$, ^query$, код выглядел немного не привлекательным. Увидев, что я написал, появилось желание всё удалить и производить поиск регулярками. Через время я понял, что написанное можно сделать стандартными средствами Unix и решил вернуться к использованию grep, мысли о котором у меня появлялись с самого начала, но которые я отбрасывал ввиду наличия "сложной" логики. Сложность же была в том, что мне нужно было искать по имени файла, сортировать по длине пути файла и выводить не исходную строку, а её индекс. Стоит отметить, что Unix'овый grep оказался раза в 4 быстрее std.regex, что в D.


  1. Чтобы получить имя файла, можно воспользоваться программой basename, но, к сожалению, она не читает стандартный поток ввода и работает только непосредственно с параметрами. Можно также воспользоваться и sed 's!.*/!!', которая обрежет все до последнего /. Подойдет и встроенная функция vim'a — fnamemodify.


  2. Сортировку я решил делать средствами vim'a, поскольку проще в плане реализации и создании собственных расширений. За сортировку отвечает функция sort, для которой потребуется написать comparator.


  3. Чтобы вывести индекс, можно воспользоваться флагом -n в grep, который выводит номер строки, формат которой n:line и распарсить которую не составляет труда.



Мерцание курсора


Вообще, это довольно ненавистная мною вещь. Мерцание курсора можно увидеть, выставив опцию incsearch. Просто попробуйте поискать что-нибудь в буфере и следите за курсором, пока печатаете. Если с изменением поведения <BS> всё ясно, то писать <expr> повсюду, как оказалось, нельзя. Этот флаг запрещает изменение каких-либо строк, отличных от той, на которой находится курсор. Поэтому для остальной логики используется вышеупомянутый expression register, который подобно :, убирает курсор с текущей позиции на время выполнения выражения. Поскольку поиск по нескольким тысячам файлам занимает какое-то время, возникает эффект мигания курсора при печатании каждого символа. Должен сказать, что неблокируемый vim подоспел весьма вовремя, а конкретно функция timer_start. Когда буфер стал отрисовываться асинхронно, проблема ушла. Не лучшее решение, должен сказать, но ничего более подходящего не нашел. Это единственная причина, почему плагин требует vim 8-ой версии.


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


Заметаем следы


Так как мы постоянно меняем содержимое буфера, tabline сообщит нам, что буфер изменен, а работа в режиме ввода будет сопровождаться соответствующей надписью слева внизу. Не знаю как вам, но мне нравится минималистичный дизайн, и подобные вещи я хотел бы убрать. Также, было бы неплохо скрывать ruler и statusline. Чтобы vim не отслеживал изменения в буфере, можно использовать опцию buftype.


setlocal buftype=nofile

Со статусной строкой, линейкой и надписью -- INSERT -- немного сложнее, поскольку опции, которые отвечают за их отображение, глобальны, а значит, нам нужно восстанавливать прежнее значение при выходе из буфера. Для этого удобно слушать событие OptionSet.


set noshowmode
set laststatus=0
set rulerformat=%0(%)
redraw

Вместо rulerformat можно было бы использовать noruler, но последний требует перерисовки экрана с предварительной очисткой (redraw!), что вызывает неприятный для глаза эффект.


Синтаксис


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


Элемент Пример Описание
\c \c.* Игнорировать регистр при поиске.
.{-} .{-}p "Не жадный" аналог .*
\zs, \ze .{-}\zsfoo\ze.* Начало и конец вхождения соответственно.
\@=, \@<= \(hidden\)\@<=text Так называемые zero-width atoms — вырезают предыдущий атом из вхождения.
\%l \%1l Поиск на определенной строке.
\& p1\&p2\&.. Оператор конъюнкции.

Basename


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


\(^\|\%(\/\)\@<=\)[^\/]\+$

config/foobar.php
foobar.php


Теперь необходимо подсветить нужные символы. Для этих целей можно воспользоваться оператором конъюнкции \& (:help branch).


\(^\|\%(\/\)\@<=\)[^\/]\+$\&.\{-}f

config/foobar.php
foobar.php


Совет: так как это обычный pattern (:help pattern), то можно тестировать всё в отдельном буфере, нажав /.


Комментарии


В fzf есть полезная визуальная фича — наличие текста, который не влияет на результаты поиска, то есть комментарии. Поначалу я хотел использовать какой-то невидимый Unicode символ для обозначения начала комментария (пробел, по понятным причинам, не подходит), но позже наткнулся на полезное свойство для группы синтаксиса — conceal. Если вкратце, conceal скрывает любой текст, оставляя его в буфере. За поведение conceal отвечают две опции: conceallevel и concealcursor. При определенной настройке текст может и не скрываться, так что советую с ними ознакомиться. В моём плагине строки имеют следующий вид:


text#finderendline...

где ... — необязательный комментарий, а #finderendline — скрывается. Пример скрытого текста:


syntax match hidden /pattern/ conceal

Прокрутка


Работа плагина в режиме ввода доставляет немало проблем, одна из которых — прокрутка. Поскольку курсор нужен в месте ввода запроса, двигать его, чтобы подсветить нужную строку, мы не можем. Для того, чтобы перемещаться по результатам поиска, можно использовать синтаксис, создав соответствующую группу. Ordinary atom \%l подходит как нельзя лучше. К примеру, \^%2l.*$ выделит вторую строку.


Мой экран вмещает 63 строки текста, и, так как вхождений может быть намного больше, возникает вопрос, как добраться до 64-ой и последующих строк. Поскольку в видимой части экрана всегда должны находиться шапка и строка запроса, при приближении к концу экрана, мы будем вырезать (помещать во временный массив) первое (второе, третье, ...) вхождение до тех пор, пока не дойдем до конца. При движении вверх — всё с точностью до наоборот.


Резюме


Наличием данной статьи vim как бы намекает — нужно было использовать input, однако, когда всё уже позади, я рад, что пошёл нестандартным путем, и получил столь ценный опыт. На этом всё, информацию по установке, использованию и созданию собственных расширений можно найти в репозитории.


Полезные мелочи


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


Выход из режима вставки


Те, кто читал мою предыдущую статью, знают, что ранее я использовал sublime для редактирования текста и кода. Есть существенные отличия между sublime и vim в том, как они обрабатывают комбинации клавиш. Если sublime, при вводе комбинации, вставляет текст без задержки, то vim сперва ожидает определенное время, и лишь после вставляет нужный символ, если комбинация "обрывается". С самого начала использования vim-mode в целом и vim'а в частности, я использовал df для выхода из режима вставки. Это настолько вошло в привычку, что любые попытки переучивания на jj, например, не давали успеха. Каждый раз, печатая d и символ, отличный от f, я наблюдал неприятный рывок. Я решил повторить поведение из sublime.


let g:lastInsertedChar = ''

function! LeaveInsertMode()
    let reltime = reltime()
    let timePressed = reltime[0] * 1000 + reltime[1] / 1000

    if(g:lastInsertedChar == 'd' && v:char == 'f' && timePressed - g:lastTimePressed < 500)
        let v:char = ''
        call feedkeys("\<Esc>x")
    endif

    let g:lastInsertedChar = v:char
    let g:lastTimePressed = timePressed
endfunction

autocmd InsertCharPre * call LeaveInsertMode()

Может это и не лучший код, но свою задачу он выполняет. Суть в следующем: после нажатия d, у нас есть пол секунды, чтобы нажать f. Если последнее истинно, f не печатается, а d удаляется из буфера. После чего редактор переходит в нормальный режим.


Read-only files


Остальным незначительным дополнением будет запрет на редактирование определенных файлов.


function! PHPVendorFiles()
    let path = expand("%:p")

    if(stridx(path, "/vendor/") != -1)
        setlocal nomodifiable
    endif
endfunction

autocmd Filetype php call PHPVendorFiles()

Данный код запрещает редактирование .php файла, если он находится в директории vendor.


Постскриптум


Список изменений моего окружения с момента публикации первой статьи.


  • Перешел на XTerm, который по ощущению, раза в 2 быстрее gnome-terminal.
  • Airline удален за ненадобностью.
  • NERD Tree удален в пользу стандартного netrw.
  • Vundle удален в пользу многопоточного vim-plug.
  • CtrlP удален в пользу Finder.
  • Tagbar сломался удален в пользу Finder.
Tags:
Hubs:
Total votes 23: ↑21 and ↓2+19
Comments18

Articles