Трюки, облегчающие жизнь в zsh
Zsh — одна из лучших командных оболочек, обладающая впечатляющим набором возможностей. Однако, из‐за большого количества возможностей нет ничего удивительного в том, что некоторые из них проходят мимо внимания или возможность их применения для решения повседневных задач неочевидна. В этой статье будут рассмотрены как несколько «встроенных» возможностей zsh, так и примеры непростого кода, облегчающие жизнь.
Содержание
Переменная READNULLCMD определяет команду, которая будет вызвана, если перенаправление stdin использовать без ввода команды: less, вводя намного меньше символов: просто установите READNULLCMD=less.
С редакторами вроде Vim часто используются дополнения, занимающиеся автоматических закрытием скобок при их вводе. Т.е. при вводе [ вы получаете [] с курсором посередине. В оболочках такое тоже возможно (даже в bash): просто нужно использовать что‐то вроде binkey -s "[" $'\C-v[]\C-b': эквивалент этой команды вполне может быть помещён в .inputrc. Более универсальное решение для zsh предполагает использование ZLE widget«ов:
insert-double-brackets() {
LBUFFER="${LBUFFER}[[ "
RBUFFER=" ]]${RBUFFER}"
}
zle -N insert-double-brackets
bindkey ',H' insert-double-brackets
Здесь в переменной LBUFFER содержится вся командная строка до курсора, а в переменной RBUFFER — вся после. Вторая команда создаёт widget, третья назначает его на сочетание ,H: таким образом ввод ,H превращается в [[ ]] с курсором посередине.
Вы все, наверное, знаете, что такое alias в оболочке и, возможно, использовали что‐то вроде alias hp='hg push'. Alias«ы в zsh имеют две дополнительные возможности: т.н. суффиксные alias«ы, позволяющие автоматически открывать файлы, не вводя программу (пример: alias -s txt=vim превратит команду foo.txt в vim foo.txt) и глобальные. Первые я никогда не использовал, а вторые нахожу весьма полезными.
Глобальные alias«ы используются для замены отдельно стоящих слов на своё значение. В отличие от суффиксных и обычных alias«ов, заменяемое слово не обязано находится в положении команды (т.е. первого слова в командной строке, или первого слова после разделителя команд). Так как alias«ы обрабатываются до того, как сработает основной парсер, то вы вполне можете иметь в глобальном alias«е всё, что угодно: перенаправление, if, разделители команд.
С моей точки зрения наиболее полезно перенаправление различных видов:
alias -g NN='&>/dev/null'
alias -g L='|less'
alias -g G='|grep'
В данном примере определяются три alias«а: один замалчивает команду, другой использует less для показа вывода команды, третий фильтрует ввод. Пример использования: запись hg cat -r default file.csv G 42 L эквивалентна hg cat -r default file.csv | grep 42 | less, но гораздо короче. Для подачи на вход команды G буквально необходимо использование экранирования: \G или 'G'. Замечу, что \G и 'G' также формируют слова, и на них тоже могут быть alias«ы: alias -g "'G'=|grep", но, надеюсь, вы находитесь в достаточно здравом уме, чтобы не использовать этот факт.
Несмотря на своё удобство, из‐за некоторых особенностей zsh глобальные alias«ы весьма опасны тем, что могут испортить дополнения zsh. Я видел в одном скрипте case, где было в том числе условие вида L), и оно не срабатывало из‐за превращение в совсем другое условие. Поэтому глобальные alias«ы должны определяться самыми последними, после того, как вы уже загрузили все дополнения. Для загрузки дополнений после определения, отключайте настройку ALIASES: используйте что‐то вроде
source() {
setopt localoptions
setopt noaliases
builtin source "${@[@]}"
}
.() {
setopt localoptions
setopt noaliases
builtin . "${@[@]}"
}
И так для каждого варианта загрузки дополнений (помимо source и . есть ещё, как минимум, autoload, насчёт действенности именно таких функций для которого я совершенное не уверен). Глобальные alias«ы, впрочем, опасны только в интерактивной сессии, скрипты с #!/bin/zsh затронуты не будут.
Ни для кого не секрет, что если написать cat /bin/test (точнее, cat any-binary-file), то можно получить различные странные эффекты: например, замену части вводимых далее символов на символы для рисования графики. Большинство эффектов устраняются написанием вслепую echo $'\ec', но это та вещь, которую хотелось бы автоматизировать. В этом нам поможет hook precmd, позволяющий запускать вашу функцию прямо перед отображением оболочки. Проблемы, которые я иногда вижу, если случайно вывожу в терминал бинарный файл, у меня валится редактор (Vim) или же я просто запускаю wine (он зачем‐то переключает режим ввода (keyboard transmit mode) и не возвращает обратно): графические символы вместо нормальных, alternate screen становится основным (= отсутствует scrollback (история ввода)), перестают работать как надо стрелки (именно здесь отметился keyboard transmit), не отображается курсор. Для их решения была создана следующая функция:
_echoti() {
emulate -L zsh
(( ${+terminfo[$1]} )) && echoti $1
}
term_reset() {
emulate -L zsh
[[ -n $TTY ]] && (( $+terminfo )) && {
_echoti rmacs # Отключает графический режим
_echoti sgr0 # Убирает цвет
_echoti cnorm # Показывает курсор
_echoti smkx # Включает «keyboard transmit mode»
echo -n $'\e[?47l' # Отключает alternate screen
# See https://github.com/fish-shell/fish-shell/issues/2139 for smkx
}
}
zmodload zsh/terminfo && precmd_functions+=( term_reset )
ttyctl -f
. После её введения набирать echo $'\ec' мне больше практически не приходится.
Также отмечу ttyctl -f: эта встроенная возможность zsh блокирует некоторые изменения настроек терминала: тех настроек, которые устанавливаются с помощью stty, а не тех, что можно установить с помощью специальных последовательностей (escape sequences).
Вы, возможно, сталкивались с командой rename для автоматического переименования множества файлов. Она существует даже в двух экземплярах: написанный на perl вариант и написанный на C. Zsh имеет что‐то подобное, но только более мощное: во‐первых, вы можете таким образом копировать файлы или запускать hg mv вместо простого перемещения по типу mv. Во‐вторых, можно использовать «интуитивно понятный» вариант вроде noglob zmv -W *.c *.cpp (чтобы избавиться от noglob, используйте alias; в дальнейших примерах noglob подразумевается). Zmv для работы использует не регулярные выражения, а более подходящие под задачу glob выражения. Также в качестве второго аргумента можно использовать фактически любое выражение: zmv -w test_*.c 'test/${1/_foo/_bar}' превратит test_foo_1.c в test_bar_1.c. Здесь параметры вида $N предоставляют доступ к аналогу «capturing groups» из регулярных выражений, а -w превращает test_*.c в test_(*).c.
Все аргументы:
-f: игнорирование наличия файла‐цели. Т.е. если файлtest.cppсуществует, то командаzmv -W *.c *.cppоткажется перемещать какие‐либо файлы, если среди них естьtest.c.-fзаставит zmv это сделать, но, однако, не передаст аргумент-fдляmv.-i: уточнение необходимости перед каждым перемещением. Для утвердительного ответа нужно нажатьyилиY, для отказа нужно нажать что‐либо ещё. Внимание: нажать нужно толькоyилиY. Нажимать ввод не нужно, он будет воспринят как отказ для следующего файла.-n: печать всех команды, которые zmv будет выполнять, без собственно выполнения.-Q: включение glob qualifier«ов. В связи с тем, что glob qualifier легко перепутать с capturing group, они отключены по‐умолчанию. Glob qualifier — это часть glob, которая уточняет результат: существуют qualifier«ы для определения порядка сортировки, включения некоторых настроек для одного glob«а, а также наиболее полезные в данных обстоятельствах фильтры вроде «раскрывать только символические ссылки».-s: передача дополнительного аргумента-sв команду. Используется в связке с-L, либо эквивалентным испольваниемzlnвместоzmv.-v: печать выполняемых команд по мере их выполнения.-oarg: указание дополнительных аргументов для команды. Так, чтобы передатьmvаргумент--forceнужно использоватьzmv -o--fore. Может быть использована только один раз.-pprog: использование данной программы вместо mv. Команда должна понимать--: она будет выполняться какprog -- source target.-Pprog: аналогично предыдущему аргументу, но для команд, не понимающих--. Программа будет вызываться какprog source target.-w: автоматическое добавление capturing groups для всех wildcard«ов, описанная выше.-W: тоже, что и предыдущий аргумент, но использование параметров$N, созданных для capturing groups происходит автоматически для wildcard«ов в правом аргументе.-C,-Lи-M: аналогично-pcp,-plnи-pmvсоответственно: позволяет использовать копирование, создание символических ссылок или перемещение независимо от названия функции (по‐умолчанию есть две дополнительных функции, использующих тот же код, что и zmv: zcp и zln).
Если вы когда‐либо качали сериалы с внешними субтитрами с torrent«ов, то, несомненно, заметили, что каждый человек, их выкладывающий, имеет собственное мнение относительно того, где должны находится субтитры. Основных вариантов два: в собственном каталоге и непосредственно рядом с видео, но под «собственным каталогом» может скрываться любое название каталога, и даже различные глубины вложения: я видел каталоги вида «subs {sub group}», «субтитры {sub group}», «subs/{sub group}» и даже просто »{sub group}». Дополнительной проблемой служит использование нестандартных шрифтов в субтитрах, с распространением их вместе с субтитрами.
Для того, чтобы субтитры были‐таки подхвачены и использовали корректные шрифты можно использовать разные способы. Я предпочёл создать функцию, которая автоматически делает нужную работу практически во всех случаях:
aplayer() {
emulate -L zsh
setopt extendedglob
setopt nullglob
local -a args
args=()
local -A mediadirs
mediadirs=()
for arg in $@ ; do
if [[ ${arg[0]} == '-' ]] ; then
continue
fi
if test -f $arg ; then
mediadirs[${arg:A:h}]=1
fi
done
local d
local -i found=0
for d in ${(k)mediadirs} ; do
local tail=$d:t
test -d ~/.fonts/aplayer/${tail}-1 && continue
local f
for f in $d/**/(#i)font* ; do
if test -d $f ; then
(( found++ ))
ln -s $f ~/.fonts/aplayer/${tail}-${found}
elif [[ $f == (#i)*.rar ]] || [[ $f == (#i)*.zip ]] ; then
(( found++ ))
mkdir ~/.fonts/aplayer/${tail}-${found}
pushd -q ~/.fonts/aplayer/${tail}-${found}
7z x $f
popd -q
fi
done
done
if (( found )) ; then
fc-cache -v ~/.fonts
fi
local -aT subpaths SUBPATHS
local -A SUBPATHS_MAP
SUBPATHS=( ${(k)^mediadirs}/(#i)*(sub|суб)*{,/**/*}(/) )
for sp in $SUBPATHS ; do
SUBPATHS_MAP[$sp]=1
done
local -a subarr
for d in ${(k)mediadirs} ; do
for subd in $d/**/ ; do
if ! test -z $SUBPATHS_MAP[$d] ; then
continue
fi
subarr=( $subd/*.(ass|ssa|srt) )
if (( $#subarr )) ; then
SUBPATHS_MAP[$subd]=1
SUBPATHS+=( $subd )
fi
done
done
if (( ${#SUBPATHS} )) ; then
args+=( --sub-paths $subpaths )
fi
mpv $args $@ &>/dev/tty
}
Наличие в zsh вещей вроде ассоциативных массивов очень помогает при создании таких функций.
Здесь первая часть функции проходится по всем аргументам и забивает каталоги, в которых находятся произведения в ассоциативный массив mediadirs. Он сделан ассоциативным исключительно, чтобы избежать дубликатов.
Далее функция проходится по всем каталогам с произведениями и находит в них или подкаталогах шрифты, которые могут находиться как в архиве, так и в подкаталоге. Шрифты определяются по характерному названию (наличию font в начале названия), setopt nullglob позволяет не беспекоиться об их отсутствии (по‐умолчанию отсутствие вызвало бы ошибку). Использование setopt extendedglob вкупе с (#i) позволяет не беспокоиться о регистре: (#i) позволяет шрифтам находиться как в каталоге FONTS, так и в Fonts. После нахождения и установки шрифтов в ~/.fonts обновляются индексы с помощью fc-cache: иначе даже скопированные в правильный каталог шрифты не будут использованы. ${(k)ASSOCIATIVE_ARRAY} превращает ассоциативный массив в простой массив, состоящий из ключей.
В третьем цикле находятся и забиваются в ассоциативный массив каталоги с субтитрами, имеющие «простые» названия вроде «subs» или «субтитры». Опять использовано игнорирование регистра и отдельно (/) в конце, ограничивающее glob только каталогами (пример glob qualifier«а). ${^array} используется для того, чтобы array=( a b c ); echo ${^array}* было эквивалентом echo {a,b,c}*.
Последний цикл находит каталоги с субтитрами, названные нестандартным способом. Каталогом с субтитрами считается любой подкаталог (по отношению к каталогам с видео), содержащий хотя бы один файл с расширением ass, ssa или srt.
Нужно отметить наличие довольно странного кода: переменную subpaths никто, вроде, не трогает, но в качестве аргумента --sub-paths используется именно её значение. Дело в том, что в zsh отметили довольно частый шаблон, когда массив значений (обычно, каталогов) является простой строкой, где различные значения отделяются друг от друга разделителем (обычно, двоеточием): примером такого «массива» может быть переменная PATH. Однако программистам было бы удобно работать с такими массивами именно как с массивами, поэтому были созданы «связанные» переменные, где одна из переменных массив (пример: path), а другая строка с заданным (по‐умолчанию двоеточие) разделителем (пример: PATH), и изменение одной из переменных автоматически отражается на другой. Именно таким способом был связан массив SUBPATHS со строкой subpaths.
Аргументами некоторых команд никогда не являются файлы. Однако этот факт не останавливает zsh от раскрытия шаблонов. В обычном случае достаточно написать alias mycmd='noglob mycmd' и mycmd *.foo станет эквивалентным mycmd '*.foo'. Но что, если вы хотите создать команду, на вход которой вы собираетесь подавать $VAR буквально и не хотите писать '$VAR'? Здесь я приведу пример кода, который делает запись zpy import zsh; print(zsh.getvalue("PATH")) эквивалентной zpython 'import zsh; print(zsh.getvalue("PATH"))'; разумеется, только в интерактивном режиме:
zshaddhistory() {
emulate -L zsh
if (( ${+_HISTLINE} && ${#_HISTLINE} )) ; then
print -sr -- "${_HISTLINE}"
unset _HISTLINE
elif (( ${#1} )) ; then
print -sr -- "${1%%$'\n'}"
fi
fc -p
}
accept-line() {
emulate -L zsh
if [[ ${BUFFER[1,4]} == "zpy " ]] ; then
_HISTLINE=$BUFFER
BUFFER="zpython ${(qqq)BUFFER[5,-1]}"
fi
zle .accept-line
}
zle -N accept-line
Основная часть функции: при вызове widget«а accept-line (вызывается, когда вы нажимаете ввод) определяется, не начинается ли строка с zpy и, если да, строка заменяется на zpython …, где … — экранированная часть строки после zpy и пробела. Функция zshaddhistory используется, чтобы в истории оказалась исходная строка, а не её замена.
Таким способом можно добавлять в zsh любой нестандартный синтаксис.
Представьте, что у вас есть редактор Vim и вы хотите использовать его, чтобы открыть все файлы из каталога (использовать шаблон *). Но помимо простых текстовых файлов в каталоге есть много бинарных вроде *.o (объектных) файлов, которые вы открывать не хотите. Для этого вы можете вместо просто звёздочки написать несколько шаблонов, соответствующих нужным файлам. Или использовать шаблон‐исключение (*~*.o, требует setopt extendedglob). Но с помощью относительно простого трюка это можно автоматизировать:
filterglob () {
local -r exclude_pat="$2"
shift
local -r cmd="$1"
shift
local -a args
args=( "${@[@]}" )
local -a new_args
local -i expandedglobs=0
local first_unexpanded_glob=
for ((I=1; I<=$#args; I++ )) do
if [[ $args[I] != ${${args[I]}/[*?]} ]]
then
local initial_arg=${args[I]}
args[I]+="~$exclude_pat(N)"
new_args=( $~args[I] )
if (( $#new_args )) ; then
expandedglobs=1
else
if [[ $options[cshnullglob] == off
&& $options[nullglob] == off ]] ; then
if [[ $options[nomatch] == on ]] ; then
: ${~${args[I]%\(N\)}} # Will error out.
else
new_args=( "$initial_arg" )
fi
fi
if [[ -z $first_unexpanded_glob ]] ; then
first_unexpanded_glob=${args[I]%\(N\)}
readonly first_unexpanded_glob
fi
fi
args[I,I]=( "${new_args[@]}" )
(( I += $#new_args - 1 ))
fi
done
if [[ $options[cshnullglob] == on && $options[nullglob] == off ]] ; then
if (( !expandedglob )) ; then
: $~first_unexpanded_glob # Will error out.
fi
fi
"$cmd" "${args[@]}"
}
alias vim='noglob filterglob "*.o" vim'
Здесь определяется alias, который запрещает раскрытие шаблона самой zsh (noglob), но использует для запуска vim функцию, которая раскрывает шаблоны сама (filterglob). Но не просто раскрывает их, а ещё и дополняет шаблоном‐исключением так, что vim * будет работать как vim *~*.o.
В функции задействованы следующие возможности zsh: ${~var} заставляет zsh использовать раскрытие шаблона применительно к значению переменной var и подставляет результат раскрытия шаблона вместо самой переменной. array[idx1,idx2]=( $new_array ) удаляет часть массива от idx1 до idx2 включительно, вставляя на место удалённых элементов значения массива new_array. При этом размер массива array может измениться. Конструкции вида : $~var с комментарием «Will error out» нужны, чтобы zsh показал ожидаемую ошибку. При этом выполнение функции завершиться. Особых причин использовать именно этот вариант вместо echo … >&2 нет, хотя мой вроде должен поддерживать перехват ошибки с использованием always (что вы вряд ли используете в интерактивной сессии).
