ghq コマンドの zsh 補完ファイルを修正したので、その過程を解説する

  • 47
    Like
  • 0
    Comment
More than 1 year has passed since last update.

初めに

ghq というコマンドがある。GitHub のリポジトリをクローンしていい感じに管理するコマンドだ。

これには zsh 補完ファイルも含まれているんだけど、補完できないオプションあったりして、いまいちだった。なのでそれを直すことにした。

何日か前に修正して Pull Request を送ったら無事取り込まれたので、どういう感じで修正したのか、その過程を紹介してみる。補完関数の書き方が分からない人でも雰囲気は伝わると思うので参考にしてみてほしい。

ちなみに、ghq 自体の詳しい使い方は作った人の紹介記事を見るのが良いと思う。

どこを直すか

まず、ghq コマンドの基本的な使い方はこんな感じ。

# GitHub の mollifier/config リポジトリをローカルに clone する
% ghq get mollifier/config

# clone したリポジトリを一覧表示する
% ghq list
github.com/mollifier/config

# clone したリポジトリの場所に移動(cd)する
% ghq look mollifier/config

# help でヘルプメッセージを表示する
% ghq help
NAME:
   ghq - Manage GitHub repository clones

COMMANDS:
   get    Clone/sync with a remote repository
   list   List local repositories
   ...

コマンド自体の使い方が分からないと書けないので、あらかじめ確認しておく。特にヘルプメッセージが大事なのでよく読んでおく。

それで、元の補完ファイルの内容はこうだった。

#compdef ghq

function _ghq () {
    _arguments -C \
        '1:: :__ghq_commands' \
        '2:: :->args' \
        && return 0

    case $state in
        (args)
            case $line[1] in
                (look)
                    _repos=( ${(f)"$(ghq list --unique)":gs/:/\\:/} )
                    _describe Repositories _repos
                    ;;
            esac
    esac

    return 1
}

__ghq_commands () {
    _c=(
        ${(f)"$(ghq help | perl -nle 'print qq(${1}[$2]) if /^COMMANDS:/.../^\S/ and /^ (\w+)(?:, \w+)?\t+(.+)/')"}
    )

    _values Commands $_c
}

compdef _ghq ghq

この中で特に直したほうが良い点は以下の2つ。

1. 補完できないオプションがある

ghq は --help--version というオプションを付けることができるんだけど、それに対応できていない。それに、例えば ghq list-p-e オプションを付けれたりとかサブコマンドにさらにオプションを付けれるんだけど、それにも対応できていない。せっかくなので全部のオプションを補完できるように直すことにした。

2. 無駄なグローバル変数が使われている

__ghq_commands 関数の中で _c って変数を使ってるんだけど、これがグローバル変数になっている。なのでこれで補完した後に echo $_c とかすると _c 変数の内容が表示される。あと _arguments って関数を呼び出してるんだけど、実はこいつも中でグローバル変数を使ってる。グローバル変数を使うと他のスクリプトと衝突したりするかもしれないので、これを修正する。

直す

それでは実際に修正作業に入ってみる。

まずはいろいろと直すところがあって、元のコードが残ってるとややこしいので一旦中身を全部消す。

#compdef ghq

そして、基本的な枠組みを書く。

#compdef ghq

function _ghq () {
    local context curcontext=$curcontext state line
    declare -A opt_args
    local ret=1

    _arguments -C \
        '*:: :->args' \
        && ret=0

    case $state in
        (args)
          # TODO
    esac

    return ret
}

_ghq "$@"

変数を local で宣言してるけど、これは _arguments 関数が中で使ってる変数をローカル変数にするために必要になる。こうしておくと変数の影響範囲がこの関数内だけになる。

今回のようにサブコマンドを引数にとるコマンドの場合は基本的にこういう形で書くので、次に似たようなのを書くときはこれをコピペすれば早いと思う。

それでは具体的に補完の中身を書いていく。まずは全体で使用可能なオプションとサブコマンドの補完から。

function _ghq () {
    local context curcontext=$curcontext state line
    declare -A opt_args
    local ret=1

    _arguments -C \
        '(-h --help)'{-h,--help}'[show help]' \
        '(-v --version)'{-v,--version}'[print the version]' \
        '1: :__ghq_commands' \
        '*:: :->args' \
        && ret=0

    case $state in
        (args)
          # TODO
    esac

    return ret
}

__ghq_commands () {
    # TODO
}

サブコマンド名の補完は __ghq_commands 関数で受けることにした(修正前と同じ名前)。こんな感じで中身を書く。

__ghq_commands () {
    local -a _c
    _c=(
        'get:Clone/sync with a remote repository'
        'list:List local repositories'
        'look:Look into a local repository'
        'import:Import repositories from other web services'
        'help:Shows a list of commands or help for one command'
    )

    _describe -t commands Commands _c
}

local -a _c として、ローカル変数として宣言してる。これで _c 変数のスコープがこの関数内だけになる。

この時点で --help とかのオプションと、ghq list とかのサブコマンドが補完できるようになった。ここまではいい感じ。

その次にサブコマンド以降の補完を書く。

_arguments のところに ->args って書いてるけど、ここが大事。

    _arguments -C \
        # ...
        '*:: :->args' \
        && ret=0

こうしておくと state 変数に args という値を入れてた状態で次に進む、という動きになる。(args という名前は別に何でも良い)

今回は状態を1つしか指定していないけど、2つ以上指定することもできる。例えば2つ指定する場合の例は以下。

_arguments -C \
    '1:: :->files' \
    '*:: :->args' \
    && ret=0

case $state in
    (files)
        # ->files の場合
        ;;
    (args)
        # ->args の場合
        ;;
esac
}

今回は1つで良いのでargsだけにする。args に進んだ時は $words[1] 変数にサブコマンド名が入っているので分岐を書く。

function _ghq () {
    local context curcontext=$curcontext state line
    declare -A opt_args
    local ret=1

    _arguments -C \
        '(-h --help)'{-h,--help}'[show help]' \
        '(-v --version)'{-v,--version}'[print the version]' \
        '1: :__ghq_commands' \
        '*:: :->args' \
        && ret=0

    case $state in
        (args)
            case $words[1] in
                (get)
                    # TODO
                    ;;
                (list)
                    # TODO
                    ;;
                (look)
                    # TODO
                    ;;
                (import)
                    # TODO
                    ;;
                (help|h)
                    # TODO
                    ;;
            esac
            ;;
    esac

    return ret
}

それで、ここがポイントなんだけど、_arguments 関数を使っていて -> で次の状態に進んだ時はメインのコマンド名(ghq)とかいらない部分が削られた状態になっている。つまり、ghq のことは一旦忘れて get とか list という名前のコマンドを補完しているつもりで改めて _arguments を使って補完を書ける。

例えば ghq get 以下の補完はこう書く。

function _ghq () {
    case $state in
        (args)
            case $words[1] in
                (get)
                    _arguments -C \
                        '(-u --update)'{-u,--update}'[Update local repository if cloned already]' \
                        '(-)*:: :->null_state' \
                        && ret=0
                    ;;
                (list)
                    # TODO
                    ;;
                (look)
                    # TODO
                    ;;
                (import)
                    # TODO
                    ;;
                (help|h)
                    # TODO
                    ;;
            esac
            ;;
    esac
}

これで ghq get の後に -u--update オプションが補完できるようになる。

それ以外のサブコマンドについても同じように書いていく。

function _ghq () {
    case $state in
        (args)
            case $words[1] in
                (get)
                    _arguments -C \
                        '(-u --update)'{-u,--update}'[Update local repository if cloned already]' \
                        '(-)*:: :->null_state' \
                        && ret=0
                    ;;
                (list)
                    _arguments -C \
                        '(-e --exact)'{-e,--exact}'[Perform an exact match]' \
                        '(-p --full-path)'{-p,--full-path}'[Print full paths]' \
                        '--unique[Print unique subpaths]' \
                        '(-)*:: :->null_state' \
                        && ret=0
                    ;;
                (look)
                    _arguments -C \
                        '1: :__ghq_repositories' \
                        && ret=0
                    ;;
                (import)
                    _arguments -C \
                        '(-u)-u[]' \
                        '(- :)*: :(starred pocket)' \
                        && ret=0
                    ;;
                (help|h)
                    __ghq_commands && ret=0
                    ;;
            esac
            ;;
    esac
}

__ghq_repositories () {
    # ghq look の後にここが呼び出される
    # TODO
}

ghq help の後はサブコマンドを補完できるんだけど、それは __ghq_commands として関数にしてたのでそれを呼びだせば OK。(このために関数にしてた)

look の引数としてはローカルに clone しているリポジトリ一覧を補完する。こういう外部のコマンドを呼んだりするようなまとまった処理は別の関数として定義したほうがよい。今回は __ghq_repositories という名前で関数を作った。

__ghq_repositories () {
    local -a _repos
    _repos=( ${(@f)"$(_call_program repositories ghq list --unique)"} )
    _describe -t repositories Repositories _repos
}

これでサブコマンドのオプションも全部補完できるようになった。最終的な出来上がりは以下の通り。

#compdef ghq

function _ghq () {
    local context curcontext=$curcontext state line
    declare -A opt_args
    local ret=1

    _arguments -C \
        '(-h --help)'{-h,--help}'[show help]' \
        '(-v --version)'{-v,--version}'[print the version]' \
        '1: :__ghq_commands' \
        '*:: :->args' \
        && ret=0

    case $state in
        (args)
            case $words[1] in
                (get)
                    _arguments -C \
                        '(-u --update)'{-u,--update}'[Update local repository if cloned already]' \
                        '(-)*:: :->null_state' \
                        && ret=0
                    ;;
                (list)
                    _arguments -C \
                        '(-e --exact)'{-e,--exact}'[Perform an exact match]' \
                        '(-p --full-path)'{-p,--full-path}'[Print full paths]' \
                        '--unique[Print unique subpaths]' \
                        '(-)*:: :->null_state' \
                        && ret=0
                    ;;
                (look)
                    _arguments -C \
                        '1: :__ghq_repositories' \
                        && ret=0
                    ;;
                (import)
                    _arguments -C \
                        '(-u)-u[]' \
                        '(- :)*: :(starred pocket)' \
                        && ret=0
                    ;;
                (help|h)
                    __ghq_commands && ret=0
                    ;;
            esac
            ;;
    esac

    return ret
}

__ghq_repositories () {
    local -a _repos
    _repos=( ${(@f)"$(_call_program repositories ghq list --unique)"} )
    _describe -t repositories Repositories _repos
}

__ghq_commands () {
    local -a _c
    _c=(
        'get:Clone/sync with a remote repository'
        'list:List local repositories'
        'look:Look into a local repository'
        'import:Import repositories from other web services'
        'help:Shows a list of commands or help for one command'
    )

    _describe -t commands Commands _c
}

_ghq "$@"

最後に

というわけで、無事修正できた。さくさく補完できていい感じ。

今回は基本的な補完関数の書き方とか _arguments 関数の使い方なんかは説明しなかったんだけど、なんとなく流れはつかめたと思う。

これを機に自分でも書いてみようという人は次の記事とかが参考になる。

「zsh の本」という本は補完についてすごく詳しく書いてあるので役に立つ。

あと、補完関数を書いて直してというのを繰り返してる時に簡単に再読み込みできるプラグインを以前作った。

これがあると確認しやすいので使ってみるといいと思う。

zsh の補完関数はいろいろ分かりにくいし決して書きやすいとは言えないんだけど、やっぱりちゃんと書くと便利になるので、興味を持った人はぜひ挑戦してみてください。