🌈

CLIの補完は、入力済みのオプションを考慮してほしい

に公開

Gitの-Cオプションを指定すると、そのパスにあるリポジトリのブランチ名が補完される。

git -C /path/to/repo checkout <TAB>

つまり、あるオプションの補完候補が、別のオプションの値に依存している状態だ。

既存の多くのCLIパーサーでは、オプションを独立して扱うため、こうした「文脈に応じた補完」の実装は困難だった。--branchの補完時に--repoの値を参照する手段がない。結果として、全リポジトリのブランチを表示する(実用的ではない)か、補完を諦めるか、どちらかを選ぶしかなくなる。

Optique 0.10.0では、型安全性を保ちながらこの問題を解決する依存関係システムを導入した。

or()による静的な依存関係

Optiqueは以前から、or()コンビネータで特定の依存関係を扱うことができた。

import { or, object, flag, option, string } from "@optique/core";

const outputOptions = or(
  object({
    json: flag("--json"),
    pretty: flag("--pretty"),
  }),
  object({
    csv: flag("--csv"),
    delimiter: option("--delimiter", string()),
  }),
);

TypeScriptの型推論により、jsontrueならprettyフィールドが、csvtrueならdelimiterフィールドが存在することが保証される。パーサーは実行時にもこれを強制し、シェル補完は--jsonが入力されている場合にのみ--prettyを候補として表示する。

これは、有効な組み合わせが定義時に確定している場合にはうまく機能する。しかし、リポジトリによって異なるブランチ名のように、有効な値が実行時の入力に依存するケースは扱えない。

実行時の依存関係

よくあるケースとして:

  • --environmentによって利用可能なサービスが変わるデプロイCLI
  • --connectionによって補完されるテーブルが変わるデータベースツール
  • --projectによって表示されるリソースが変わるクラウドCLI

いずれも、依存元のオプションにユーザーが何を入力したかがわかるまで、有効な値を決定できない。Optique 0.10.0では、この問題を解決するためにdependency()derive()を追加した。

依存関係システム

あるオプションを*依存元(Dependency Source)としてマークし、その値を使ってパーサーを生成する派生パーサー(Derived Parser)*を定義する。

import { dependency, option, object, choice, string, message } from "@optique/core";

function getRefsFromRepo(repoPath: string): string[] {
  // 実際のコードでは、Gitリポジトリから読み取る
  return ["main", "develop", "feature/login"];
}

// 依存元としてマーク
const repoParser = dependency(string());

// 派生パーサーを作成
const refParser = repoParser.derive({
  metavar: "REF",
  factory: (repoPath) => {
    const refs = getRefsFromRepo(repoPath);
    return choice(refs);
  },
  defaultValue: () => ".",
});

const parser = object({
  repo: option("--repo", repoParser, {
    description: message`リポジトリのパス`,
  }),
  ref: option("--ref", refParser, {
    description: message`Git参照`,
  }),
});

factory関数は、ユーザーが入力した--repoの値を受け取り、そのリポジトリ用のパーサーを動的に生成する役割を担う。

仕組みとしては、以下の3段階のパース戦略を採用している:

  1. 最初のパスで全オプションをパースし、依存元の値を収集する
  2. 収集した値でfactory関数を呼び出し、具体的なパーサーを生成する
  3. 動的に生成されたパーサーを使って、派生オプションを再パースする

これにより、バリデーションと補完の両方が正しく動作する。ユーザーが既に--repo /some/pathと入力していれば、--refの補完はそのパスからrefを表示する。

@optique/gitによるリポジトリ対応の補完

@optique/gitパッケージは、Gitリポジトリから読み取る非同期バリューパーサーを提供している。依存関係システムと組み合わせることで、リポジトリを考慮した補完機能を持つCLIを構築できる。

import { dependency, option, command, object, string, message } from "@optique/core";
import { gitBranch } from "@optique/git";

const repoParser = dependency(string());

const branchParser = repoParser.deriveAsync({
  metavar: "BRANCH",
  factory: (repoPath) => gitBranch({ dir: repoPath }),
  defaultValue: () => ".",
});

const checkout = command(
  "checkout",
  object({
    repo: option("--repo", repoParser, {
      description: message`リポジトリのパス`,
    }),
    branch: option("--branch", branchParser, {
      description: message`チェックアウトするブランチ`,
    }),
  }),
);

これで、my-cli checkout --repo /path/to/project --branch <TAB>と入力すると、/path/to/projectのブランチが補完候補として表示される。defaultValue"."にしているので、--repoが指定されていない場合はカレントディレクトリにフォールバックする。

複数の依存関係

パーサーが複数のオプションの値を必要とする場合は、deriveFrom()を使う。

import { dependency, deriveFrom, option, object, choice, message } from "@optique/core";

function getAvailableServices(env: string, region: string): string[] {
  return [`${env}-api-${region}`, `${env}-web-${region}`];
}

const envParser = dependency(choice(["dev", "staging", "prod"] as const));
const regionParser = dependency(choice(["us-east", "eu-west"] as const));

const serviceParser = deriveFrom({
  dependencies: [envParser, regionParser] as const,
  metavar: "SERVICE",
  factory: (env, region) => {
    const services = getAvailableServices(env, region);
    return choice(services);
  },
  defaultValues: () => ["dev", "us-east"] as const,
});

const parser = object({
  env: option("--env", envParser, {
    description: message`デプロイ環境`,
  }),
  region: option("--region", regionParser, {
    description: message`クラウドリージョン`,
  }),
  service: option("--service", serviceParser, {
    description: message`デプロイするサービス`,
  }),
});

factoryは依存関係の配列と同じ順序で値を受け取る。一部の依存関係が提供されていない場合は、defaultValuesが使われる。

非同期対応

実際のアプリケーションでは、GitやAPIコールなど非同期処理が必要になるケースが多い。OptiqueはderiveAsyncによる非同期解決もサポートしている。

import { dependency, string } from "@optique/core";
import { gitBranch } from "@optique/git";

const repoParser = dependency(string());

const branchParser = repoParser.deriveAsync({
  metavar: "BRANCH",
  factory: (repoPath) => gitBranch({ dir: repoPath }),
  defaultValue: () => ".",
});

@optique/gitパッケージは内部でisomorphic-gitを使用しているため、gitBranch()gitTag()gitRef()はNode.jsとDenoの両方で動作する。

同期的な動作を明示したい場合はderiveSync()、複数の非同期依存関係にはderiveFromAsync()も用意されている。

まとめ

依存関係システムにより、オプション同士が互いを認識するCLIを構築できるようになった。バリデーションだけでなく、シェル補完も含めて。型安全性は全体を通して維持される。TypeScriptの型推論により依存元と派生パーサーの関係が把握され、不正な組み合わせはコンパイル時に検出される。

この機能は、Gitリポジトリやクラウドのリソースなど、実行時まで有効な値が決まらない外部システムと連携するCLIツールで特に有用だ。

Optique 0.10.0でリリース予定。プレリリース版で先に試すことができる:

deno add jsr:@optique/core@0.10.0-dev.311

npmの場合:

npm install @optique/core@0.10.0-dev.311

詳細はドキュメントを参照。

Discussion