Использование bash completion в командной строке, собственных скриптах и приложениях. ...

:

Про bash completion на хабре я уже писал тут, и даже конце пообещал рассказать про настройку автодополнения для собственных скриптов.

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

Рассматривать я буду модельную задачу, близкую к той, на которой я постигал азы автодополнений. Суть такова:

Есть скрипт, у которого есть три подкоманды. Одна из этих команд — work — в примере не рассматривается, у оставшихся есть следующие подкоманды. history делает import или export, каждой из этих команд надо передать имя проекта и пару значений под флагами. help может рассказать про work, history и help.

Теперь о том, как, собственно, работает автодополнение. Для баша пишется функция, которой передаются уже введенные аргументы, и на основе их она генерирует возможные варианты дополнения. Эта функция (назовём её _my_command) регистрируется для конкретной команды (в данном случае — мы исполняем скрипт названный script, поэтому регистрация идет для script) волшебной командой complete:

complete -F _my_command script

Теперь самое интересное — написание этой функции, обрабатывающей аргументы, и выдающей список доступных параметров.
Для начала, из man bash можно узнать о существовании специальных переменных для работы с автодополнением. Мы будем использовать следующие:

COMPREPLY
Это массив, из которого bash получает возможные дополнения.

COMP_WORDS
Это массив, содержащий уже введённые аргументы. Анализируя их, мы можем понять, какие варианты дополнения надо предлагать.

COMP_CWORD
Это — индекс в предыдущем массиве, который показывает позицию в нем аргумента, редактируемого в данный момент.

Теперь попробуем на основе этих переменных проанализировать ввод, и если вводится первый аргумент — попробовать его дополнить

    _my_command(){ #объявляем функцию, которую будем привязывать к анализу
    
    COMPREPLY=() #пока что мы не знаем, что предложить пользователю, поэтому создадим пустой список.
    cur="${COMP_WORDS[COMP_CWORD]}" #получаем текущий вводимый аргумент
    subcommands_1="work history help" #массив подкоманд первого уровня - см. синтаксическое дерево в начале поста.

    if [[ ${COMP_CWORD} == 1 ]] ; then #если вводится первый аргумент, то попробуем его дополнить
        COMPREPLY=( $(compgen -W "${subcommands_1}" -- ${cur}) ) #some magic
        return 0 #COMPREPLY заполнен, можно выходить
    fi
    }

если теперь мы запишем эту функцию вместе с приведенным выше вызовом complete в какой-нибудь скрипт, например ./complete.sh, выполним его в текущей консоли (лучше, конечно, для экспериментов запускать новый баш, а потом его убивать) как. ./complete sh, и, введя «script », нажмем Tab 2 раза, bash предложит нам варианты дополнения:

$ script 
help     history  work

Соответственно, если начать вводить какую-то подкоманду, например wo и нажать Tab, то произойдёт автодополнение.

Однако я ещё не объяснил, как именно работает использованная в скрипте магия, а именно

        COMPREPLY=( $(compgen -W "${subcommands_1}" -- ${cur}) ) #some magic

Тут мы заполняем список возвращаемых вариантов с помощью встроенной утилиты bash compgen.

Данная утилита принимает на вход список всех возможных значений аргумента, а так же текущую введенную часть аргумента, и выбирает те значения, до которых введенную часть можно дополнить. Введенная часть аргумента передается после --, а со списком возможных значений всё интереснее. В приведенном случае, возможные значения берутся (как указывает флаг -W) и данного скрипту списка слов (т.е. в приведенном выше примере — из subcommands_1=«work history help»). Однако там можно указывать и другие флаги — например -d — и тогда compgen будет дополнять исходя из существующих на машине директорий, или -f — тогда он будет дополнять до файлов.

Можно посмотреть, что он выдаёт:

$ compgen -W "qwerty qweasd asdfgh" -- qwe
qwerty
qweasd

Соответственно можно генерировать различные списки кандидатов на автодополнение. Например, для решаемой задачи, нам (для импорта и экспорта истории) нужен список возможных проектов. В моём случае, каждому проекту соответствует директория в "${HOME}/projects", соответственно кандидатов можно подбирать как
COMPREPLY=($(compgen -W "`ls ${HOME}/projects`" -- ${cur}))

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

В завершение — моя топорная реализация автодополнения для модели, указанной в начале:

_my_command()
{
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    subcommands_1="work history help" #возможные подкоманды первого уровня
    subcommands_history="import export" #возможные подкоманды для history
    
    
    if [[ ${COMP_CWORD} == 1 ]] ; then # цикл определения автодополнения при вводе подкоманды первого уровня
        COMPREPLY=( $(compgen -W "${subcommands_1}" -- ${cur}) )
        return 0
    fi
    
    
    subcmd_1="${COMP_WORDS[1]}" #К данному моменту подкоманда первого уровня уже введена, и мы её выбираем в эту переменную
    case "${subcmd_1}" in #Дальше смотри, что она из себя представляет
    work)
        COMPREPLY=() #ничего дальше вводить не надо
        return 0
        ;;
    history)

        if [[ ${COMP_CWORD} == 2 ]] ; then #введены script history; надо подставить import или export
            COMPREPLY=( $(compgen -W "${subcommands_history}" -- ${cur}) )
            return 0
        fi

        #к данному моменту мы уже знаем, что делаем: импорт или экспорт
        subcmd_2="${COMP_WORDS[2]}"

        if [[ ${COMP_CWORD} == 3 ]] ; then #но в любом случае следующим аргументом идет имя проекта.
            COMPREPLY=($(compgen -W "`ls ${HOME}/projects`" -- ${cur}))
            return 0
        fi

        case "${subcmd_2}" in #а дальше у импорта и экспорта набор флагов разный. мы смотрим на предпоследний аргумент, и если он является флагом - подставляем соответствующие ему значения, иначе - выдаем на дополнение список флагов.
        import)
            case "${COMP_WORDS[COMP_CWORD-1]}" in
            -src) 
                COMPREPLY=($(compgen -d -- ${cur})) #тут должна быть директория с исходниками
                return 0
                ;;
            -file)
                COMPREPLY=($(compgen -f -- ${cur})) #тут должен быть импортируемый файл
                return 0
                ;;
            *)
                COMPREPLY=($(compgen -W "-src -file" -- ${cur})) #список возможных флагов
                return 0
                ;;
            esac
            ;;

        export) #у экспорта только один флаг -o, если был он - то мы предлагаем дополнение до файла, куда экспортировать, иначе - предлагаем дополнение до флага
            if [[ ${COMP_WORDS[COMP_CWORD-1]} == "-o" ]] ; then 
                COMPREPLY=($(compgen -f -- ${cur}))
                return 0
            fi
            
            COMPREPLY=($(compgen -W "-o" -- ${cur}))
            return 0
            ;;
        *)
            ;;
        esac
        ;;
    help) #список возможных дополнений после help совпадает со списком подкоманд первого уровня, их и исследуем.
	COMPREPLY=( $(compgen -W "${subcommands_1}" -- ${cur}))
	return 0
        ;;
    esac
    return 0
    
}

complete -F _my_command script

Ну и самое главное — подключение нашего скрипта к башу на постоянной основе. Это делается либо (топорно) прописыванием вызова нашего скрипта в .bashrc, либо (стандартно, если у вас есть такой файл: ) через /etc/bash_completion. Во втором случае мы должны положить наш скрипт в /etc/bash_completion.d, все скрипты откуда подцепляются из /etc/bash_completion.

P.S.: Напомню, что положительный фидбек стоит оставлять в карме пользователя infthi