📝

デコレータを使ったコマンドラインパーサ「classopt」を作った

2022/01/04に公開

classoptというdeno用のコマンドラインパーサを作りました。
デコレータを活用し、型安全なパーサが直観的にかけることを目指しました。

https://github.com/stsysd/classopt

サンプル

example.ts
import {
  Arg,
  Command,
  Flag,
  Help,
  Opt,
  Version,
} from "https://deno.land/x/classopt@v0.1.1/mod.ts";

@Help("Help Text for Command")
@Version("0.0.0")
class Program extends Command {
  @Arg({ about: "user to login" })
  username!: string;

  @Opt({ about: "password for user, if required", short: true })
  password = "";

  @Flag({ about: "debug mode" })
  debug = false;

  async execute() {
    console.log(`<username> = ${this.username}`);
    console.log(`-p, --password = ${this.password}`);
    console.log(`--debug = ${this.debug}`);
    await void 0;
  }
}

await Program.run(Deno.args);

このコードによって、以下のようなコマンドが生成されます。

$ deno run example.ts --help
program - Help Text for Command

USAGE
    program [OPTIONS] <username>

OPTIONS
    -p, --password <string>    password for user, if required
    --debug                    debug mode
    -V, --version              Prints version information
    -h, --help                 Prints help information

ARGS
    <username>    user to login

$ deno run example.ts --version
program 0.0.0

$ deno run example.ts -p pass --debug stsysd
<username> = stsysd
-p, --password = pass
--debug = true

Command クラス

コマンドの定義に使う基底クラスとしてCommandを用意しています。

@Help("Help Text for Command")
@Version("0.0.0")
class Program extends Command {
  async execute() {
    // コマンドの実行内容をここに書く
  }

  // 省略
}

await Program.run(Deno.args);

HelpVersionというクラスデコレータで修飾することで、それぞれ--help--versionがオプションとして指定されます。
これは必須ではありません。

Commandを継承したクラスには、executeメソッドを必ず実装する必要があります。
executeの中で同期的な処理しかしない場合であっても、返り値の型は必ずPromise<void>にしなければいけないことに注意してください。

Commandから継承されるクラスメソッドrunを実行することで、コマンドライン引数がパースされ、exeuteに書いた処理が実行されます。このとき、runの引数にはコマンドライン引数が入った文字列の配列を渡してください。

オプションの定義

オプションの定義には、プロパティデコレータを使います。

Arg

位置引数(--keyのようなキーを伴わない引数)の定義には、Argを使います。

class Program extends Command {
  @Arg({ about: "requied" /* optional: false */ })
  foo!: string;

  @Arg({ about: "optional", optional: true })
  bar = "";

  // 省略
}

それぞれのプロパティには、実行時に渡されたコマンドライン引数の文字列がそのまま代入されます。

位置引数の順番はプロパティが定義された順番によって決まります。上の例だとprogram foo barの順で引数を与える必要があります。

デフォルトでは位置引数は省略できません。省略可能にしたい場合は、optionalオプションをtrueにしてください。
定義する際には、省略可能な引数の後に続けて必須な引数は定義できないので注意してください。

Opt

キーワード引数(--keyのようなキーを伴う引数)の定義には、Optを使います。

class Program extends Command {
  @Opt({
    /* type: "string", */ about: "string option",
    long: "text",
    short: "T",
  })
  str = "";

  @Opt({ type: "number", about: "number option" })
  num = 0;

  // 省略
}

キーワード引数は必ず省略可能です。 typeオプションによって文字列と数値のどちらを受け取るかを指定できます(デフォルトでは文字列)。

デフォルトではプロパティ名をケバブケース(ex.
foo-bar)に変換したものがキーとなります。longオプションによって明示的に指定することも可能です。
また、shortオプションで1文字の短いキーも指定できます。

Flag

フラグ(--keyのようなキー単体で使う引数)の定義には、Flagを使います。

class Program extends Command {
  @Flag({ about: "debug mode" })
  debug = false;

  // 省略
}

プロパティの値は、フラグが渡されている場合にtrue、渡されていない場合にfalseになります。

Optと同様に、long/shortオプションを使ってフラグのキーは明示的に指定できます。 また、短いキーを持つフラグは -abc
のような形式でまとめて渡すことができます。

サブコマンド

サブコマンドの定義には、プロパティデコレータCmdを使います。
例えば、以下のように書くことで、listgetのふたつのサブコマンドを定義できます。

subcommand.ts
import {
  Opt,
  Cmd,
  Command,
  Flag,
  Help,
  Name,
  Version,
} from "https://deno.land/x/classopt@v0.1.1/mod.ts";

interface PathOpt {
  path: string;
}

@Help("Help Text for 'list' Command")
class List extends Command<PathOpt> {
  @Flag({ about: "Prints full path" })
  fullPath = false;

  async execute(opt: PathOpt) {
    console.log(`List.fullPath = ${this.fullPath}`);
    console.log(`PathOpt.path = ${opt.path}`);
    await void 0; // avoid `requrie-await`
  }
}

@Name("get")
@Help("Help Text for 'get' Command")
class GetCommand extends Command<PathOpt> {
  async execute(opt: PathOpt) {
    console.log(`PathOpt.path = ${opt.path}`);
    await void 0; // avoid `requrie-await`
  }
}

@Version("0.0.0")
@Help("Help Text for Top Command")
class Program extends Command implements PathOpt {
  @Opt({ about: "Specify path to get" })
  path = "";

  @Cmd(List, GetCommand)
  command?: Command<PathOpt>;

  async execute() {
    if (this.command == null) {
      console.log(this.help());
    } else {
      await this.command?.execute(this);
    }
  }
}

await Program.run(Deno.args);

このコードによって、以下のようなコマンドが生成されます。

$ deno run subcommand.ts --help
program - Help Text for Top Command

USAGE
    program [OPTIONS]

OPTIONS
    --path <string>    Specify path to get
    -h, --help         Prints help information
    -V, --version      Prints version information

COMMANDS
    list    Help Text for 'list' Command
    get     Help Text for 'get' Command

$ deno run example.ts get --help
get - Help Text for 'get' Command

USAGE
    program get [OPTIONS]

OPTIONS
    -h, --help    Prints help information

$ deno run example.ts list --help
list - Help Text for 'list' Command

USAGE
    program list [OPTIONS]

OPTIONS
    --full-path    Prints full path
    -h, --help     Prints help information

Discussion