Трюки, облегчающие жизнь в 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
: печать выполняемых команд по мере их выполнения.-o
arg: указание дополнительных аргументов для команды. Так, чтобы передатьmv
аргумент--force
нужно использоватьzmv -o--fore
. Может быть использована только один раз.-p
prog: использование данной программы вместо mv. Команда должна понимать--
: она будет выполняться какprog -- source target
.-P
prog: аналогично предыдущему аргументу, но для команд, не понимающих--
. Программа будет вызываться как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
(что вы вряд ли используете в интерактивной сессии).