本文へ移動
Hikari 仕様

std:cli — 起動引数の解析

標準ライブラリモジュール (index.md)。本書中の裸の §N は本書の節を指す。言語の意味論は ../language-spec.md を参照。

import cli := "std:cli"option / flag / parse / get / get_or / has の 6 slot を持つ namespace object を cli に束縛する。あわせて Spec (引数 1 個の宣言) と Parsed (解析結果) の 2 型を提供する。

起動引数の取得std:envargs が担い (env.md §3)、本モジュールはその List(String)受け取って解析するだけである。argv を引数で受けるため外部リソースに触れず、std:path / std:json と同じく 純粋・同期 (Future を返さない)。したがってこの import は capability 宣言にならず (index.md)、テストでは argv を直に組んで呼べる。

取得と解析を別モジュールに分けるのは、argv得ることと読むことが別の関心だからである — 取得はホスト能力に触れるが解析は触れず、宣言表を持たない呼び手は std:env だけで足りる。解析結果は「値・位置引数・エラー」の 3 つに割る (§2)。

import cli := "std:cli"
import env := "std:env"

p := cli.parse(env.args!, [cli.option("out"), cli.option("dir"), cli.flag("verbose")])

out := cli.get_or(p, "out", "web/generated/examples.json")
dir := cli.get_or(p, "dir", "examples")
loud := cli.has(p, "verbose")
名前 意味
option option(name) 値を 1 個取る option の宣言 (Spec) を返す
flag flag(name) 値を取らない flag の宣言 (Spec) を返す
parse parse(argv, specs) argvspecs に従って解析し Parsed を返す
get get(p, name) p から name の値を読む。無ければ None、有れば Some(値)
get_or get_or(p, name, default) p から name の値を読む。無ければ default を返す (String)
has has(p, name) pname が現れたかを返す (Bool)

本モジュールは効果を1 つも持たない../language-spec.md §17.9)。

全関数は値を 直接返す (Future でも Result でもない)。引数の型が合わない呼び出しは回復可能エラーではなく、呼び出し位置で Error (panic 型, ../language-spec.md §16) になる (§8)。

1. 用語

本書は POSIX の utility conventions と GNU の argument syntax が定める語をそのまま使う。この 3 語は以降の節と API 名の両方を貫く。

意味
option - で始まり、値を 1 個取る引数 -out a.json-out
flag - で始まり、値を取らない引数 (真偽の切り替え) -l
位置引数 - で始まらない引数 (POSIX の operand) docs/spec.md

option が取る値を option の値と呼ぶ (POSIX の option-argument)。

flag は「値を取らない option」だけを指す狭い語である。 両方をまとめて flag と呼ぶ流儀もあるが、値を取るか取らないかは走査の規則そのものを変える (§6) — 同じ語で呼ぶと、宣言表が次の引数を値として食うのかどうかが名前から読めなくなる。本モジュールは上表の狭い語に従い、cli.optioncli.flag を別の構築子として持つ。

モジュール名に option を使わないのは、Hikari では Option(T) / Some / None が prelude の中核語彙であり (../prelude.md)、同じ文書に 2 つの「option」が並ぶと読めなくなるためである。領域名 cli を採り、正しい語は API 名の側に置く。

2. 型

type Spec := { name: String, takes_value: Bool |}
type Pair := { name: String, value: String |}
type Parsed := { values: List(Pair), positionals: List(String), errors: List(String) |}

Spec は引数 1 個の宣言で、cli.option / cli.flag が組み立てる (§3)。レコードリテラルを直に書いてもよいが、構築子を通す方が綴りが短い。

Parsed は解析結果で、3 つのスロットはそれぞれ独立に読める:

  • values — 現れた option / flag の Pair (name / value)。name は接頭辞の - を外した綴り (§4)
  • positionals — 位置引数を与えられた順に並べたもの
  • errors — 解析中に見つかった不正の人間可読メッセージ (§8)

valuesname ごとに高々 1 組で、最初に出現した順に並ぶ。同じ name が複数回現れたときの値の決まり方は §6 を参照。

3. cli.option / cli.flag — 引数の宣言

argv の走査には「その引数が値を取るかどうか」の事前知識が要る。-l foo-l が値を取るなら値 foo、取らないなら -l と位置引数 foo の 2 つであり、宣言なしにはこの 2 つを区別できない。ゆえに本モジュールは Spec の List を宣言表として要求する。

  • cli.option(name) — 値を 1 個取る option。{ name := name, takes_value := true |} を返す
  • cli.flag(name) — 値を取らない flag。{ name := name, takes_value := false |} を返す
  • name は接頭辞の -含めずに書く ("out" であって "-out" ではない)。- は綴りの形であって名前の一部ではない (-out--out は同じ名前を指す。§6)
  • name が String でないときは呼び出し位置で Error (§8)
cli.option("out")   #> { name := "out", takes_value := true |}
cli.flag("verbose") #> { name := "verbose", takes_value := false |}

4. 引数の綴り

走査で option / flag として扱う綴りを定める。ここでいう name は接頭辞を外した部分を指す。

綴り 扱い
-name option / flag name
--name 同上 (-name と等価)
-name=v option name に値 vその場で与える
--name=v 同上
-- 終端記号。これ自身は捨て、以降の引数はすべて位置引数
- 位置引数 (慣例的に標準入出力を指すため、option として解釈しない)
"" 位置引数
上記以外 位置引数

-name--name を等価に受けるのは、本リポジトリの内部ツールが単一ダッシュの -out 形を、examples/tnotes が二重ダッシュの --folder 形をそれぞれ使っており、どちらも書き換えずに済ませるためである。

= より後ろは値としてそのまま採る。値が空文字列 (-name=) のときは Some("") になり、引数が無い場合の None と区別される。これは env.get が空文字列と未設定を区別するのと同じ規律である (env.md §1)。

- を 2 個より多く重ねた綴り (---name) は、- を 2 個だけ外して name-name になる。宣言表に無ければ未知の引数として errors に積まれる (§8)。

5. cli.parse

  • 引数: argv (String の List)、specs (Spec の List) の 2 個
  • argv を先頭から 1 個ずつ走査し、Parsed を返す (§6)
  • argv が非 List、または要素に非 String を含むときは呼び出し位置で Error (§8)
  • specs が非 List、または要素が Spec の形でないときは呼び出し位置で Error (§8)
  • specs に同じ name の宣言が複数あるときは最後の宣言が勝つ。宣言表は呼び手が書く定数であり、重複は書き間違いだが、解析を止めるほどのエラーではない
  • specs が空 List [] のときは、-- 終端より前のすべての - 始まりの綴りが未知の引数になる (§8)
cli.parse(["-out", "a.json", "src"], [cli.option("out")])
#> { values := [{ name := "out", value := "a.json" |}], positionals := ["src"], errors := [] |}

6. 走査の規則

argv を先頭から 1 個ずつ見て、次のように振り分ける。

  1. -- を見たら、それ自身を捨て、以降の引数をすべて positionals に積んで走査を終える
  2. - 始まりの綴り (§4) でなければ positionals に積む
  3. - 始まりで、namespecs に無ければ errors に未知の引数を積み、その引数は捨てる (§8)
  4. namecli.flag の宣言 (takes_value := false) なら values(name, "true") を積む。= 付きの綴りだったときは値を採らず errors に積む (§8)
  5. namecli.option の宣言 (takes_value := true) なら値を決める:
    • -name=v の綴りなら値は v
    • そうでなければ次の引数を値として採り、その引数を消費する。次の引数が無い (argv の末尾) ときは errors に積み、values には入れない (§8)

値として採る次の引数は、それが - 始まりでも値として採る。-out -dirout の値が "-dir" である。-out -dir を「値の欠けた -out」と読むには「- 始まりの文字列は値になれない」という規則が要るが、それは -out -1 のような正当な値を弾いてしまう。

位置引数との混在

option / flag と位置引数は任意の順に混ぜてよく、位置引数の後ろに現れたものも解析する。最初の位置引数で走査を打ち切る形は採らない — 本リポジトリの .hika はいずれも argv 全体を走査しており、既存の呼び出し方をそのまま保つためである。

-- 終端より後ろは、- 始まりでも位置引数である。

同じ name が複数回

同じ name が複数回現れたときは最後の値が勝つvalues にはその name の組が 1 つだけ入り、位置は最初の出現位置である。

p := cli.parse(["-o", "a", "-o", "b"], [cli.option("o")])
p.values   #> [{ name := "o", value := "b" |}]

すべての出現を集める形は持たない (§10)。

7. cli.get / cli.get_or / cli.has

ParsedvaluesPair の List なので p.values.find { kv | kv.name == "out" } と直に走査してもよいが、読み出しの綴りを 3 つ用意する。命名は env.get / env.get_or に揃えてある (env.md §1-2)。

  • cli.get(p, name)valuesname が有れば Some(値)、無ければ None (Option(String))
  • cli.get_or(p, name, default)valuesname が有ればその値、無ければ default (String)
  • cli.has(p, name)valuesname が有れば true、無ければ false (Bool)

pParsed の形でないとき、name / default が String でないときは呼び出し位置で Error (§8)。

get_or(p, n, d)match cli.get(p, n) { Some(v) => v None => d } と等価な糖衣である。hascli.flag で宣言したものを読むための綴りで、cli.get(p, n).is_some! と等価だが、真偽として読む意図が綴りに出る。

p := cli.parse(["-l", "src"], [cli.flag("l"), cli.option("out")])

cli.has(p, "l")                    #> true
cli.get(p, "out")                  #> None
cli.get_or(p, "out", "a.json")     #> "a.json"
p.positionals                      #> ["src"]

8. エラーモデル

不正な引数と、引数の型エラーは別の層である。

不正な引数 — errors に積む

解析中に見つかった不正は Parsederrors (List(String)) に人間可読メッセージとして積み、parse 自体は正常に返る。積むのは次の 3 つで、いずれも残りの引数の走査は続ける

状況 メッセージ
宣言表に無い name unknown option: -name
cli.option 宣言の値が argv 末尾で欠けた option needs a value: -name
cli.flag 宣言に = 付きの値を与えた flag takes no value: -name

メッセージ中の name は接頭辞を - 1 個に正規化した綴りで示す (--out と綴られていても -out)。

errors をどう扱うかは呼び手が決める。 空でなければ診断を出して終了するのが通常だが、無視すれば未知の引数を黙って読み飛ばす挙動になる。解析器が終了を決めないのは、usage の綴りも exit code も呼び手の領分だからである — 解析器がプロセスを終わらせると、テストから解析だけを呼べなくなる。

p := cli.parse(["-nope"], [cli.option("out")])
p.errors   #> ["unknown option: -nope"]

when (p.errors.length! > 0) {
  p.errors.each { m | eprintln "mytool: ${m}" }
  exit 2
}

引数の型エラー — Error (panic)

型が合わない呼び出しは回復可能エラーではなく、呼び出し位置で Error (panic 型, ../language-spec.md §16) になる。これは path.md §10json.md §5 と同じ規律である。

  • parseargv が非 List、または要素に非 String を含む
  • parsespecs が非 List、または要素が Spec の形でない
  • option / flagname が非 String
  • get / get_or / haspParsed の形でない、name / default が非 String

9. 使い方

内部ツールの典型は「既定値つきの option を数個読み、残りは見ない」である。宣言表に並べ、get_or で既定値を与える。

import cli := "std:cli"
import env := "std:env"

main := {
  p := cli.parse(env.args!, [cli.option("out"), cli.option("dir")])
  when (p.errors.length! > 0) {
    p.errors.each { m | eprintln "examplesgen: ${m}" }
    exit 2
  }
  out := cli.get_or(p, "out", "web/generated/examples.json")
  dir := cli.get_or(p, "dir", "examples")
  # … out / dir を使う …
}

位置引数を取るツールは positionals を読む。option / flag と混在してよい (§6)。

p := cli.parse(env.args!, [cli.flag("l"), cli.flag("w")])
paths := p.positionals
if (paths.length! == 0) {
  eprintln "doc-hikari-format: usage: doc-hikari-format [-w] [-l] <path>..."
  exit 2
} {
  paths.each { path | process_file(path, cli.has(p, "l")) }
}

サブコマンドを持つ CLI

git commit 形の階層は positionals の先頭を呼び手が match する (§10)。option / flag は
サブコマンドの前後どちらに書かれても解析されるので (§6)、tnotes list --json
tnotes --json list はどちらも通る。

p := cli.parse(env.args!, [cli.flag("json"), cli.flag("trash"), cli.option("folder")])
pos := p.positionals
if (pos.length! == 0) {
  print_help!
} {
  match pos.0 {
    "list" => cmd_list(p, cli.get_or(p, "folder", ""))
    "get" => cmd_get(p, cli.get_or(p, "folder", ""))
    _ => die ("unknown command: " + pos.0)
  }
}

サブコマンドごとに受け付ける option を変えたい場合も、宣言表は 1 つで足りる — 宣言に無い綴りは
errors に落ちるので (§8)、サブコマンドが見ない option は単に読まなければよい。宣言表を
サブコマンドごとに分けて 2 回 parse する形は、-- 終端の位置が 2 度解釈されるため採らない。

10. 意図して入れないもの

次のものは持たない。いずれも Spec にスロットを足すか namespace に slot を足す形で非破壊に載せられるが、下記のとおり薄い層に保つ方を採る。

  • 短縮名・別名-o--out の別名とする対応付け。別々の name として 2 個宣言し、cli.get(p, name) を 2 回引く
  • 結合した短縮 flag — POSIX が定める -la-l -a と読む形。-laname"la" であり、宣言が無ければ未知の引数になる
  • 型付きの値 — 宣言側で Int / Float を指定して変換させる形。値は常に String で、変換は呼び手が to_int 等で行う
  • 同名の複数回収集 — 同じ名前の出現をすべて集める形。最後の値が勝つ (§6)
  • cli.flag への = 綴り-b=true / -b=false で真偽を明示する形。errors に積む (§8)
  • 必須の option — 宣言側で「無ければエラー」と言う形。cli.get(p, name)None を返すのを呼び手が見て決める
  • usage / help の自動生成 — 宣言表から使い方を組み立てる形。Spec に説明文のスロットが要るため、綴りを決めてから入れる
  • サブコマンドgit commit 形の階層。positionals の先頭を呼び手が match する

いずれも std:cli が「宣言表を受け取り 3 つ組を返す薄い層」であることは変えない。凝った CLI 構築 (補完・色付き usage・型付き束縛) は標準ライブラリの外で組む領域である。