Переводчик из меня совершенно никакой, но я просто не мог пройти мимо этой статьи, ибо она излучает волны крутости, а концентрация дзена в ней зашкаливает. Поэтому welcome.
Введение
Недавно я обнаружил интересную игру под названием VimGolf. Цель этой игры заключается в том, чтобы преобразовать кусок текста из одной формы в другую наименьшим возможным количеством нажатий клавиш. Пока я играл на этом сайте с разными пазлами, мне стало любопытно — а какие привычки редактирования текста есть у меня? Мне захотелось лучше понять способы манипулирования текстом в Vim и проверить, смогу ли я найти неэффективные моменты в моем рабочем процессе. Я провожу огромное количество времени в моем текстовом редакторе, поэтому устранение даже незначительных шероховатостей может привести к значительному увеличению производительности. В этом посте я расскажу о своем анализе и о том, как я уменьшил количество нажатий клавиш при использовании Vim. Я назвал эту игру Vim-крокет.
Сбор данных
Я начал мой анализ со сбора данных. Редактирование текста на моем компьютере всегда происходит с помощью Vim, так что в течении 45 дней я логировал любое нажание клавиши в нем с помощью флага scriptout. Для удобства я сделал alias для записи нажатий в лог:
alias vim='vim -w ~/.vimlog "$@"'
После этого необходимо было распарсить полученные данные, но это оказалось не так легко. Vim это модальный редактор, в котором одна команда может иметь несколько различных значений в разных режимах. Помимо этого команды зависят от контекста, когда их поведение может отличаться в зависимости от того, где внутри буфера vim они исполняются. Например, команда cib в нормальном режиме переведет пользователя в режим редактирования, если команда выполняется внутри скобок, но оставит пользователя в нормальном режиме, если она выполнена вне скобок. Если же cib будет выполнена в режиме редактирования, то она будет иметь совершенно другое поведение — запишет символы «cib» в текущий буфер.
Я рассмотрел несколько кандидатов для парсинга команд vim, включая промышленные библиотеки, такие как antler и parsec, а также специализирующийся на vim проект vimprint. После некоторых раздумий, я решил написать собственный инструмент, т.к. трата большого количества времени на изучение достаточно сложных парсеров казалось необоснованным для этой задачи.
Я написал сыроватый лексер на haskell'е для разбиения собранных мной нажатий клавиш на индивидуальные команды vim. Мой лексер использует monoids для извлечения команд нормального режима из лога для дальнейшего анализа. Вот исходник лексера:
import qualified Data.ByteString.Lazy.Char8 as LC
import qualified Data.List as DL
import qualified Data.List.Split as LS
import Data.Monoid
import System.IO
main = hSetEncoding stdout utf8 >>
LC.getContents >>= mapM_ putStrLn . process
process = affixStrip
. startsWith
. splitOnMode
. modeSub
. capStrings
. split mark
. preprocess
subs = appEndo . mconcat . map (Endo . sub)
sub (s,r) lst@(x:xs)
| s `DL.isPrefixOf` lst = sub'
| otherwise = x:sub (s,r) xs
where
sub' = r ++ sub (s,r) (drop (length s) lst)
sub (_,_) [] = []
preprocess = subs meta
. DL.intercalate " "
. DL.words
. DL.unwords
. DL.lines
. LC.unpack
splitOnMode = DL.concat $ map (el -> split mode el)
startsWith = filter (el -> mark `DL.isPrefixOf` el && el /= mark)
modeSub = map (subs mtsl)
split s r = filter (/= "") $ s `LS.splitOn` r
affixStrip = clean
. concat
. map (el -> split mark el)
capStrings = map (el -> mark ++ el ++ mark)
clean = filter (not . DL.isInfixOf "[M")
(mark, mode, n) = ("-(*)-","-(!)-", "")
meta = [(""",n),("\",n),("195130194128195131194189`",n),
("194128195189`",n),("194128kbESC",n),
("194128kb",n),("[>0;95;c",n), ("[>0;95;0c",n),
("ESC",mark),("ETX",mark),("r",mark)]
mtsl = [(":",mode),("A",mode), ("a",mode), ("I",mode), ("i",mode),
("O",mode),("o",mode),("v", mode),("/",mode),("ENQ","⌃e"),
("DLE","⌃p"),("NAK","⌃u"),("EOT","⌃d"),("ACK","⌃f"),
("STX","⌃f"),("EM","⌃y"),("SI","⌃o"),("SYN","⌃v"),
("DC2","⌃r")]
А вот пример данных до и после обработки:
cut -c 1-42 ~/.vimlog | tee >(cat -v;echo) | ./lexer
`Mihere's some text^Cyyp$bimore ^C0~A.^C:w^M:q
`M
yyp$b
0~
Лексер читает из стандартного потока ввода и отправляет обработанные команды в стандартный вывод. В примере выше примере необработанные данные расположены во второй строке, а результат обработки — на следующих. Каждая строка представляет собой группы команд нормального режима, выполненные в соответствующей последовательности. Лексер корректно определил, что я начал в нормальном режиме, перейдя в некоторый буфер с помощью метки `M, затем ввел here's some text в режиме редактирования, после чего скопировал/вставил строку и перешел на начало последнего слова в строке с помощью команды yyp$b. Затем ввел дополнительный текст и в итоге перешел в начало строки, заменив первый символ на прописной командой 0~.
Карта использования клавиш
После обработки залогированных данных, я форкнул замечательный проект heatmap-keyboard за авторством Patrick Wied, и добавил в него собственный кастомный слой для чтения вывода лексера. Этот проект не определял большинство мета-символов, например, ESC, Ctrl и Cmd, поэтому мне было необходимо написать загрузчик данных на JavaScript и внести некоторые другие модификации. Я транслировал мета-символы, используемые в vim, в юникод и спроецировал их на клавиатуру. Вот что у меня получилось на количестве команд, близком к 500 000 (интенсивность цвета указывает на частоту использования клавиш).
На полученной карте видно, что чаще всего используется клавиша Ctrl — я использую ее для многочисленных команд перемещения в vim. Например, ^p для ControlP, или цикл по открытым буферам через ^j ^k.
Другая особенность, которая бросилась в глаза при анализе карты — это частое использование ^E ^Y. Я повседневно использую эти команды для навигации вверх/вниз по коду, хотя вертикальное перемещение с помощью них неэффективно. Каждый раз, когда одна из этих команды исполняется, курсор перемещается только на несколько строк за раз. Более эффективно было бы использовать команды ^U ^D, т.к. они смещают курсор на половину экрана.
Частота использования команд
Карта использования клавиш дает хорошее представление о том, как используются отдельные клавиши, но мне хотелось узнать больше о том, как я использую различные последовательности клавиш. Я отсортировал строки в выводе лексера по частоте, чтобы увидеть наиболее используемые команды нормального режима с помощью однострочника:
$ sort normal_cmds.txt | uniq -c | sort -nr | head -10 |
awk '{print NR,$0}' | column -t
1 2542 j
2 2188 k
3 1927 jj
4 1610 p
5 1602 ⌃j
6 1118 Y
7 987 ⌃e
8 977 zR
9 812 P
10 799 ⌃y
Для меня было удивительно видеть zR на восьмом месте. После обдумывания этого факта, я осознал серьезную неэффективность в моем подходе к редактированию текста. Дело в том, что в моем .vimrc указано автоматически сворачивать блоки текста. Но проблема с данной конфигурацией была в том, что я почти сразу разворачивал весь текст, так что в этом не было смысла. Поэтому я просто удалил эту настройку из конфига, чтобы убрать необходимость частого использования zR.
Сложность команд
Другая оптимизация, на которую я хотел взглянуть — это сложность команд нормального режима. Мне было любопытно увидеть, смогу ли я найти команды, которые использую повседневно, но которые требуют излишне большого количества нажатий клавиш. Такие команды можно было бы заменить с помощью shortcut'ов, которые бы ускорили их выполнение. В качестве меры сложности команд я использовал энтропию, которую измерял следующим коротким скриптом на Python:
#!/usr/bin/env python
import sys
from codecs import getreader, getwriter
from collections import Counter
from operator import itemgetter
from math import log, log1p
sys.stdin = getreader('utf-8')(sys.stdin)
sys.stdout = getwriter('utf-8')(sys.stdout)
def H(vec, correct=True):
"""Calculate the Shannon Entropy of a vector
"""
n = float(len(vec))
c = Counter(vec)
h = sum(((-freq / n) * log(freq / n, 2)) for freq in c.values())
# impose a penality to correct for size
if all([correct is True, n > 0]):
h = h / log1p(n)
return h
def main():
k = 1
lines = (_.strip() for _ in sys.stdin)
hs = ((st, H(list(st))) for st in lines)
srt_hs = sorted(hs, key=itemgetter(1), reverse=True)
for n, i in enumerate(srt_hs[:k], 1):
fmt_st = u'{r}t{s}t{h:.4f}'.format(r=n, s=i[0], h=i[1])
print fmt_st
if __name__ == '__main__':
main()
Скрипт читает из стандартного потока ввода и выдает команды с наибольшей энтропией. Я использовал вывод лексера в качестве данных для расчета энтропии:
$ sort normal_cmds.txt | uniq -c | sort -nr | sed "s/^[ t]*//" |
awk 'BEGIN{OFS="t";}{if ($1>100) print $1,$2}' |
cut -f2 | ./entropy.py
1 ggvG$"zy 1.2516
Я отбираю команды, которые выполнялись более 100 раз, а затем нахожу среди них команду с наибольшей энтропией. В результате анализа была выделена команда ggvG$«zy, которая выполнялась 246 раз за 45 дней. Команда выполняется с помощью 11 достаточно неуклюжих нажатий клавиш и копирует весь текущий буфер в регистр z. Я обычно использую это команду для перемещения всего содержимого одного буфера в другой. Конечно, добавил в свой конфиг новый shortcut
nnoremap <leader>ya ggvG$"zy
Выводы
Мой матч в vim-крокет определил 3 оптимизации для уменьшения количества нажатий клавиш в vim:
- Использование команд навигации ^U ^D вместо ^E ^Y
- Предотвращение автоматического сворачивания текста в буфере для избежания zR
- Создание shortcut'а для многословной команды ggvG$»zy
Эти 3 простых изменения спасли меня от тысяч ненужных нажатий клавиш каждый месяц.
Части кода, которые я представил выше, немного изолированы и могут быть сложны для использования. Чтобы сделать шаги моего анализа понятнее, я привожу Makefile, который показывает, как код, содержащийся в моей статье, совмещается в единое целое.
SHELL := /bin/bash
LOG := ~/.vimlog
CMDS := normal_cmds.txt
FRQS := frequencies.txt
ENTS := entropy.txt
LEXER_SRC := lexer.hs
LEXER_OBJS := lexer.{o,hi}
LEXER_BIN := lexer
H := entropy.py
UTF := iconv -f iso-8859-1 -t utf-8
.PRECIOUS: $(LOG)
.PHONY: all entropy clean distclean
all: $(LEXER_BIN) $(CMDS) $(FRQS) entropy
$(LEXER_BIN): $(LEXER_SRC)
ghc --make $^
$(CMDS): $(LEXER_BIN)
cat $(LOG) | $(UTF) | ./$^ > $@
$(FRQS): $(H) $(LOG) $(CMDS)
sort $(CMDS) | uniq -c | sort -nr | sed "s/^[ t]*//" |
awk 'BEGIN{OFS="t";}{if ($$1>100) print NR,$$1,$$2}' > $@
entropy: $(H) $(FRQS)
cut -f3 $(FRQS) | ./$(H)
clean:
@- $(RM) $(LEXER_OBJS) $(LEXER_BIN) $(CMDS) $(FRQS) $(ENTS)
distclean: clean
Автор: erthalion