Трюки, облегчающие жизнь в 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 (что вы вряд ли используете в интерактивной сессии).

© Habrahabr.ru