🌈

Optique:型安全なCLIパーサーコンビネーター

に公開

最近、Optiqueというやや実験的なCLIパーサーライブラリを開発しました。現在は0.2.0までリリースされていますが、なかなか面白いアイデアだと思うので、この記事で紹介させていただきます。

Optiqueは大きく分けて二つの異なるライブラリから影響を受けています。一つはHaskellのoptparse-applicativeというライブラリで、このライブラリから得た教訓は、CLIパーサーもパーサーコンビネーターになれること、そしてそのように作ると非常に便利だということです。もう一つはTypeScriptユーザーにはすでにお馴染みのZodです。optparse-applicativeからアイデアの軸を得ましたが、HaskellとTypeScriptはあまりにも異なる言語なので、APIの構成方法には大きな違いがあります。そのため、APIの構成方法については、Zodをはじめとする様々なバリデーションライブラリを参考にしました。

Optiqueは、複数の小さなパーサーとパーサーコンビネーターをレゴブロックのように組み立てて、CLIがどのような形であるべきかを表現します。例えば、最も小さな部品の一つにoption()があります:

const parser = option("-a", "--allow", url());

このパーサーを実行するにはrun()というAPIを使います:
(ちなみにrun()関数は暗黙的にprocess.argv.slice(2)を読み込みます)

const allow: URL = run(parser);

上のコードで私はわざとURLという型を明示しましたが、特にそうしなくても自動的にURL型として推論されます。このパーサーは-a/--allow=URLオプションのみを受け入れます。他のオプションや引数を与えた場合はエラーになります。-a/--allow=URLオプションが与えられなくてもエラーになります。

もし-a/--allow=URLオプションを必須ではなく任意にしたい場合はどうすればよいでしょうか?その場合はoptional()コンビネーターでoption()パーサーをラップします。

const parser = optional(option("-a", "--allow", url()));

このパーサーを実行すると、結果としてどのような型が得られるでしょうか?

const allow: URL | undefined = run(parser);

はい、URL | undefined型になります。

あるいは、-a/--allow=URLオプションを複数受け取れるようにしてみましょう。次のように書けるようにしたいですね:

prog -a https://example.com/ -a https://hackers.pub/

このようにオプションを複数回使えるようにするには、optional()コンビネーターの代わりにmultiple()コンビネーターを使います:

const parser = multiple(option("-a", "--allow", url()));

そろそろ結果の型がどうなるか予想がつくようになってきましたか?

const allowList: readonly URL[] = run(parser);

はい、readonly URL[]型になります。

では、-a/--allow=URLオプションと同時に使えない相互排他的な-d/--disallow=URLというオプションを追加したい場合はどうすればよいでしょうか?どちらか一方のオプションのみを同時に使えるようにする必要があります。このような場合はor()コンビネーターを使います:

const parser = or(
  multiple(option("-a", "--allow", url())),
  multiple(option("-d", "--disallow", url())),
);

このパーサーは次のようなコマンドは正しく受け入れます:

prog -a https://example.com/ --allow    https://hackers.pub/
prog -d https://example.com/ --disallow https://hackers.pub/

しかし、次のように-a/--allow=URLオプションと-d/--disallow=URLオプションが混在している場合はエラーになります:

prog -a https://example.com/ --disallow https://hackers.pub/

さて、このパーサーの結果はどのような型になるでしょうか?

const result: readonly URL[] = run(parser);

おや、or()コンビネーターが包んでいる二つのパーサーが両方ともreadonly URL[]型の値を生成するため、readonly URL[] | readonly URL[]型となり、結果的にreadonly URL[]型になってしまいました。適切な判別共用体(discriminated union)形式に変更したいですね。以下のような型が理想的です。

const Result =
  | { mode: "allowList"; allowList: readonly URL[] }
  | { mode: "blockList"; blockList: readonly URL[] };

このようなオブジェクト形式の構造を作りたい場合はobject()コンビネーターを使います:

const parser = or(
  object({
    mode: constant("allowList"),
    allowList: multiple(option("-a", "--allow", url())),
  }),
  object({
    mode: constant("blockList"),
    blockList: multiple(option("-d", "--disallow", url())),
  }),
);

判別子(discriminator)を付与するためにconstant()パーサーも使用しました。このパーサーは少し特殊で、何も読まずに与えられた値を生成するだけです。つまり、常に成功するパーサーです。主に判別共用体を構成する際に使われますが、他の創造的な方法でも使えるでしょう。

これでこのパーサーは期待する型の結果値を生成するようになります:

const result:
  | { readonly mode: "allowList"; readonly allowList: readonly URL[] }
  | { readonly mode: "blockList"; readonly blockList: readonly URL[] }
  = run(parser);

or()コンビネーターやobject()コンビネーターは、相互排他的なオプションだけに使うわけではありません。サブコマンドも同じ原理で実装できます。一つのコマンドにマッチするcommand()パーサーと位置引数にマッチするargument()パーサーをご紹介します:

const parser = command(
  "download",
  object({
    targetDirectory: optional(
      option(
        "-t", "--target",
        file({ metavar: "DIR", type: "directory" })
      )
    ),
    urls: multiple(argument(url())),
  })
)

このパーサーは以下のようなコマンドにマッチします:

prog download --target=out/ https://example.com/ https://example.net/

パーサーの結果型は次のようになります:

const result: {
  readonly targetDirectory: string | undefined;
  readonly urls: readonly URL[];
} = run(parser); 

ここにuploadサブコマンドを追加するにはどうすればよいでしょうか?その通り、or()コンビネーターで繋げばよいのです:

const parser = or(
  command(
    "download",
    object({
      action: constant("download"),
      targetDirectory: optional(
        option(
          "-t", "--target",
          file({ metavar: "DIR", type: "directory" })
        )
      ),
      urls: multiple(argument(url())),
    })
  ),
  command(
    "upload",
    object({
      action: constant("upload"),
      url: option("-d", "--dest", "--destination", url()),
      files: multiple(
        argument(file({ metavar: "FILE", type: "file" })),
        { min: 1 },
      ),
    })
  ),
);

このパーサーは次のようなコマンドを受け入れられるようになりました:

prog upload ./a.txt ./b.txt -d https://example.com/
prog download -t ./out/ https://example.com/ https://hackers.pub/

このパーサーの結果型は次のようになります:

const result:
  | {
      readonly action: "download";
      readonly targetDirectory: string | undefined;
      readonly urls: readonly URL[];
    }
  | {
      readonly action: "upload";
      readonly url: URL;
      readonly files: readonly string[];
    }
  = run(parser); 

同じ方法を応用すれば、ネストされたサブコマンドも実装できますね。

さて、このようにOptiqueがCLIを表現する方法をご紹介しましたが、いかがでしょうか?Optiqueのアプローチが複雑なCLIを表現するのに適していることが伝わりましたでしょうか?

もちろん、Optiqueの方法も完璧ではありません。非常に典型的でシンプルなCLIを定義するには、かえって手間がかかることも事実です。また、OptiqueはCLIパーサーとしての役割だけを担っているため、一般的なCLIアプリケーションフレームワークが提供する様々な機能は提供していません。(今後Optiqueにより多くの機能を追加する予定ではありますが…)

それでもOptiqueのアプローチに興味を持たれた方は、紹介ドキュメント(英文)やチュートリアル(英文)もぜひご覧ください。

Discussion