🦀

clapでシェルの補完スクリプトを生成する

2022/01/14に公開

はじめに

この記事では、2022年1月にリリースされたclapとclap_completeクレートを使って、シェルの補完スクリプトを生成する方法を紹介します。

clapはRustのコマンドライン引数パーサーです。
豊富な機能を備えており、Rustではデファクトスタンダートになっているライブラリです。
近年の他のプログラミング言語のコマンドライン引数パーサー——例えば、Pythonのargcompleteclickcleo、Goのgo-flags——同様に、補完スクリプトの生成に対応しています。

clapはBuilderパターンによるパーサーの構築が素の使い方ですが、バージョン3でderiveマクロによるパーサーの構築が安定化されました。
Builderパターンによるパーサーの構築——この機能はBuilder APIと呼ばれています——では、以下のようなコードでパーサーを記述します。構造体Appがビルダーであり、そのメソッドを呼び出すことで引数を追加していきます。

use clap::*;

let app = App::new("example")
    .arg(
        Arg::new("verbose")
            .short('v')
            .help("enable verbose mode")
    )
    .arg(
        Arg::new("file")
            .index(1)
    );

一方、deriveマクロによるパーサーの構築——この機能はDerive APIと呼ばれています——では、構造体に属性を付けることで引数を設定します。コマンドライン引数のパースに成功すると、deriveマクロを使った構造体にパースされた結果が格納されます。

use clap::Parser;

#[derive(Parser)]
struct Args {
    file: std::path::PathBuf,
    /// enable verbose mode
    #[clap(short, long)]
    verbose: bool,
}

clapにはclap_completeという関連するクレートがあります。clap_completeを使うことで、構築したパーサーから補完スクリプトを自動で生成することができます。

clapとclap_completeを使って補完スクリプトを生成する方法について、次の流れで説明します。

  1. 補完スクリプトの対象となる簡単なCLIプログラムを作成する。
  2. clap_completeの使い方を解説し、補完スクリプトを生成するサブコマンドを実装する。
  3. 生成した補完スクリプトをzshで使用する。

なお、この記事ではDerive APIの使い方について詳細な説明は行いません。structoptを使った経験があることを前提にします。

執筆に利用したRustやclap等のバージョンは以下の通りです。

Name Version
Rust 1.57.0
clap 3.0.5
clap_complete 3.0.2
zsh 5.8 (x86_64-apple-darwin21.0)

本文中で紹介するプログラムのソースコードはhoratos/clap-completion-exampleにあります。

メッセージを表示するプログラムを作成する

サンプルとして使うためのプログラムとして、メッセージを表示するだけのプログラムを作成します。この部分はこの記事の本題ではないので仕様とコードを掲載するだけにとどめます。

メッセージを表示するプログラムの仕様

greetサブコマンドでメッセージを表示することにします。後で補完スクリプトを生成するためのcompletionサブコマンドを追加するため、サブコマンドを分けることにします。

greetサブコマンドに引数が渡されなかったときは、Helloを表示します。

$ cargo run -- greet
Hello

greetサブコマンドに-l LANGUAGEまたは--language LANGUAGEオプションを渡すと、メッセージを表示する言語を変更します。このオプションの値LANGUAGEenjaのいずれかです。

$ cargo run -- greet -l en
Hello
$ cargo run -- greet -l ja
こんにちは

greetサブコマンドに-f FILEまたは--file FILEオプションを渡すと、ファイルFILEの内容を表示します。このオプションは-lオプションと併用できません。

$ cargo run -- greet -f hey.txt
(hey.txtの内容が表示される。ただし、末尾の空白文字は取り除かれる。)

メッセージを表示するプログラムの実装

実装は以下の通りになります。プロジェクト全体はこのリポジトリにあります。

use std::path::PathBuf;

use clap::{ArgEnum, Parser, Subcommand};

/// Greet command (example for clap_complete command).
#[derive(Parser,Debug)]
struct Cli {
    #[clap(subcommand)]
    action: Action,
}

#[derive(Subcommand,Debug)]
enum Action {
    /// Greet some message.
    Greet {
        /// Language in which messages are shown.
        #[clap(long,short,arg_enum)]
        language: Option<Language>,
        /// File whose content is printed.
        ///
        /// The trailing whitespaces of the content are trimmed.
        #[clap(long,short,conflicts_with("language"))]
        file: Option<PathBuf>,
    },
}

#[derive(ArgEnum,Clone,Debug)]
enum Language {
    En,
    Ja,
}

impl Action {
    fn handle(self) {
        use Action::Greet;

        match self {
            Greet { language: None, file: None } => {
                println!("Hello");
            },
            Greet { language: Some(Language::En), .. } => {
                println!("Hello");
            },
            Greet { language: Some(Language::Ja), .. } => {
                println!("こんにちは");
            },
            Greet { file: Some(file), .. } => {
                let s = std::fs::read_to_string(&file).unwrap();
                println!("{}", s.trim_end());
            },
        }
    }
}

fn main() {
    Cli::parse().action.handle();
}

clap_completeクレートを使って補完スクリプトを生成する

前節で作ったプログラムを拡張して補完スクリプトを生成するサブコマンドを追加します。サブコマンドの仕様を考える前にclap_completeが提供する関数やトレイトなどと見ておきましょう。

補完スクリプトの生成は関数generateが行います。関数generateは以下の四つの引数を受け取ります。

  • gen
  • app
  • bin_name
  • buf

まず、引数genトレイトGeneratorを実装したオブジェクトでなければなりません。clap_completeはこのトレイトを実装する列挙体Shellを提供しています。Shellは補完スクリプトの生成に対応しているシェルを表現する列挙体です。

次に、引数appclapの構造体Appのオブジェクトです。
Builder APIでパーサーを構築する場合は素直に手に入りますが、Derive APIを使う場合はトレイトIntoAppを使うことで構造体Appのオブジェクトを手に入れられます。

引数bin_nameはコマンドの名前です。Into<String>を実装しているオブジェクトを渡す必要があります。この引数で渡す名前とバイナリの名前を一致させないと、シェルが補完スクリプトを呼び出すことができません。
というのも、補完スクリプトの中で補完の対象となるコマンドの名前を指定する必要があるからです。

引数bufWriteを実装しているオブジェクトを渡す必要があります。スクリプトの書き込み先をこの引数で渡します。

completionサブコマンドの実装

それでは新しいサブコマンドの実装に移りましょう。--shellオプションで対応するシェルを指定することにします。

$ cargo run -- completion --shell zsh
(Zsh用の補完スクリプトが表示される)

以下に新しいソースコードを示します。GitHubで先ほどのコードとのdiffを見ることもできます。

use std::path::PathBuf;

use clap::{ArgEnum, IntoApp, Parser, Subcommand, ValueHint}; // (1)
use clap_complete::{generate, Generator, Shell}; // (2)

/// Greet command (example for clap_complete command).
#[derive(Parser,Debug)]
struct Cli {
    #[clap(subcommand)]
    action: Action,
}

#[derive(Subcommand,Debug)]
enum Action {
    /// Greet some message.
    Greet {
        /// Language in which messages are shown.
        #[clap(long,short,arg_enum)]
        language: Option<Language>,
        /// File whose content is printed.
        ///
        /// The trailing whitespaces of the content are trimmed.
        #[clap(long,short,conflicts_with("language"),value_hint(ValueHint::FilePath))] // (3)
        file: Option<PathBuf>,
    },
    /// Generate completion script
    Completion { // (4)
        #[clap(long,short,arg_enum)]
        shell: Shell,
    },
}

#[derive(ArgEnum,Clone,Debug)]
enum Language {
    En,
    Ja,
}

impl Action {
    fn handle(self) {
        use Action::*;

        match self {
            Greet { language: None, file: None } => {
                println!("Hello");
            },
            Greet { language: Some(Language::En), .. } => {
                println!("Hello");
            },
            Greet { language: Some(Language::Ja), .. } => {
                println!("こんにちは");
            },
            Greet { file: Some(file), .. } => {
                let s = std::fs::read_to_string(&file).unwrap();
                println!("{}", s.trim_end());
            },
            Completion { shell } => { // (5)
                print_completer(shell);
            },
        }
    }
}

fn print_completer<G: Generator>(generator: G) { // (6)
    let mut app = Cli::into_app();
    let name = app.get_name().to_owned();

    generate(generator, &mut app, name, &mut std::io::stdout());
}

fn main() {
    Cli::parse().action.handle();
}

上記のソースコードの変更箇所にコメントで番号を付けました。以下ではその番号ごとに説明していきます。

まず、(1)(2)で新しく必要となるトレイトなどをインポートします。

(3)ではバリアントGreetの属性にvalue_hint(ValueHint::FilePath)を追加しています。これを追加することでファイルパスのみが補完の候補として出てくるようにすることができます。

(4)ではバリアントCompletionを追加することでサブコマンドを追加しています。フィールドshellの属性にarg_enumを付けることで、入力可能な値がShellのバリアントに限定され、補完時の候補もバリアントの名前から選ばれることになります。

(5)ではバリアントCompletionを処理する手続きを呼び出しています。関数print_completerが補完スクリプトを標準出力に書き出します。

(6)では関数print_completerが、構造体CliからAppのオブジェクトを作成して関数generateを呼び出しています。

以上で補完スクリプト生成機能の追加は完了です。以下のコマンドを実行することで補完スクリプトが表示されます。

$ cargo run -- completion --shell zsh

生成した補完スクリプトをzshで使用する

clap_completeで生成した補完スクリプトをzshで使ってみましょう。補完スクリプトのインストールは環境変数fpathに設定されたディレクトリにスクリプトを置いてから、関数compinit呼び出すことで実現できます。

ただし、開発時には生成し直した補完スクリプトをリロードしたり、必要がなくなった補完スクリプトをアンロードしたりする必要があります。そのあたりをいちいちケアするのは面倒なので、以下の内容のスクリプトをsetupという名前で保存し、zshを新しく起動してsource setupを実行することで補完スクリプトをロードします。補完スクリプトが必要でなくなればexitでシェルから抜けるだけでよくなります。

setup
export fpath=($fpath $(pwd)/comp)
export PS1="${PS1:-} (comp)$ "
alias clap-completion-example=$(pwd)/target/debug/clap-completion-example

mkdir -p comp && \
cargo run -- completion --shell zsh > comp/_clap-completion-example && \
autoload -Uz compinit && \
compinit

まず、プロジェクトルート( Cargo.toml があるディレクトリ)で以下のコマンドを実行して補完スクリプトをロードしてください。

$ zsh
$ source setup

これでビルドしたプログラムをclap-completion-exampleという名前で呼び出せるようになります。

まずは、サブコマンドの補完を試してみましょう。コマンド名の後でタブを入力することで、サブコマンドの候補とヘルプメッセージが表示されます。(以下の例では^Iがタブを入力を表しています。)

$ clap-completion-example ^I
completion  -- Generate completion script
greet       -- Greet some message
help        -- Print this message or the help of the given subcommand(s)

次の例は、greetサブコマンドのオプションを補完する例です。

$ clap-completion-example greet -^I
--file      -f  -- File whose content is printed
--help      -h  -- Print help information
--language  -l  -- Language in which messages are shown

-lオプションや-fオプションの候補も入力可能な値が候補として表示されます。

$ clap-completion-example greet -l ^I
en  ja
$ clap-completion-example greet -f ^I
Cargo.lock  LICENSE     setup       target/     
Cargo.toml  comp/       src/        tests/      

さいごに

簡単な例を通して、clapを使うことでシェルの補完スクリプトを容易に生成できることを見ました。今回はzsh用の補完スクリプトを生成しましたが、clap_completeはbashやPowerShellなどの他のシェルにも対応しています。

ValueHintを使うことで補完する値の候補を指定することができることを見ました。ただし、この機能はあらかじめ決められた種類の候補しか指定できません。
例えば、Gitの補完でgit checkoutにブランチ名の候補を出すような、コマンドに固有な内容での補完は実現できません。このような補完のサポートは議論が行われているようですが、先に解決しなければならない課題があるようです。実装されるまでにはまだ時間がかかりそうです。

Discussion