Optique: CLI パーサーコンビネーター

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

最近、Optiqueという実験的なCLIパーサーライブラリを作成しました。この記事を書いている2025年8月21日の時点ではまだ0.1.0もリリースされていませんが、面白いアイデアだと思いますので、この記事で紹介します。

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

Optiqueは、小さなパーサーとパーサーコンビネーターをレゴのパーツのように組み合わせて、CLIがどのような形であるべきかを表現します。例えば、最も小さなパーツの1つとして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()コンビネーターがラップする2つのパーサーがどちらも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()コンビネーターは、相互排他的なオプションだけでなく、サブコマンドも同じ原理で実装できます。1つのコマンドにマッチする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); 

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

さて、このようにOptiqueがCLIを表現する方法を紹介しましたが、どう思いますか?Optiqueの方法が複雑なCLIを表現するのに適していることが伝わりましたか?

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

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

12
2
2

2 comments

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0198c81f-6b6b-71e0-af6a-96c1d65af83f on your instance and reply to it.

3

@hongminhee洪 民憙 (Hong Minhee) optparse-applicative엔 여러 커맨드의 겹치는 인자를 처리하는데에 애로사항이 있잖아요? 그러니까 foo bar --baz=1foo qux --baz=1이 둘다 가능할때 baz를 필드로 갖는 레코드를 두개 만들어아야 하는 문제 말이에요. 근데 optique는 서브타이핑으로 이 문제를 좀더 잘 다룰수 있어 보이네요. 맞나요?

1