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:env の args が担い (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) |
argv を specs に従って解析し Parsed を返す |
get |
get(p, name) |
p から name の値を読む。無ければ None、有れば Some(値) |
get_or |
get_or(p, name, default) |
p から name の値を読む。無ければ default を返す (String) |
has |
has(p, name) |
p に name が現れたかを返す (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.option と cli.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)
values は name ごとに高々 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 個ずつ見て、次のように振り分ける。
--を見たら、それ自身を捨て、以降の引数をすべてpositionalsに積んで走査を終える-始まりの綴り (§4) でなければpositionalsに積む-始まりで、nameがspecsに無ければerrorsに未知の引数を積み、その引数は捨てる (§8)nameがcli.flagの宣言 (takes_value := false) ならvaluesに(name, "true")を積む。=付きの綴りだったときは値を採らずerrorsに積む (§8)nameがcli.optionの宣言 (takes_value := true) なら値を決める:-name=vの綴りなら値はv- そうでなければ次の引数を値として採り、その引数を消費する。次の引数が無い (argv の末尾) ときは
errorsに積み、valuesには入れない (§8)
値として採る次の引数は、それが - 始まりでも値として採る。-out -dir は out の値が "-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
Parsed の values は Pair の List なので p.values.find { kv | kv.name == "out" } と直に走査してもよいが、読み出しの綴りを 3 つ用意する。命名は env.get / env.get_or に揃えてある (env.md §1-2)。
cli.get(p, name)—valuesにnameが有ればSome(値)、無ければNone(Option(String))cli.get_or(p, name, default)—valuesにnameが有ればその値、無ければdefault(String)cli.has(p, name)—valuesにnameが有ればtrue、無ければfalse(Bool)
p が Parsed の形でないとき、name / default が String でないときは呼び出し位置で Error (§8)。
get_or(p, n, d) は match cli.get(p, n) { Some(v) => v None => d } と等価な糖衣である。has は cli.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 に積む
解析中に見つかった不正は Parsed の errors (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 §10・json.md §5 と同じ規律である。
parseのargvが非 List、または要素に非 String を含むparseのspecsが非 List、または要素がSpecの形でないoption/flagのnameが非 Stringget/get_or/hasのpがParsedの形でない、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と読む形。-laはnameが"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・型付き束縛) は標準ライブラリの外で組む領域である。