整形 (hikari format) 仕様
本書は hikari format の仕様を定める。起動ディスパッチ全体は hikari-command.md、言語の意味論は language-spec.md を参照。
Hikari ソースを正規形へ整形する。AST を再構成して出力するため、整形後のソースは元と構文木が等価 (同じ意味) になる。CI・保存時フック・エディター連携での自動整形を想定する。
構文
hikari format [-w] [-l] [path...]
引数の並びは フラグ → パス の順で書く (フラグは位置を問わず読む)。path はファイルまたはディレクトリ (ディレクトリは配下を再帰して .hika を収集)。収集ファイルはパス昇順で処理する。
| フラグ | 意味 |
|---|---|
| (なし) | 整形結果を stdout に書く |
-w |
ファイルを整形結果で上書き (内容が変わる場合のみ書き込む) |
-l |
整形すると変わるファイルのパス名のみを列挙する (書き込みはしない) |
path を 1 つも与えない場合は stdin を読み stdout に整形結果を書く。このとき -w / -l は対象ファイルが無いため引数不正 (exit code 2)。
整形規則
個々の規則は次の共通概念を前提に簡潔に記す。
- 値位置 / 値を消費しない位置: 「値位置」は 代入 RHS (
:=/=)・呼び出し引数・中置/前置の被演算子・後置!/?の対象・条件式。これら以外 (文・ボディ位置) を「値を消費しない位置」と呼ぶ。ブロック本体の文 (末尾の返り値位置も含む) は値を消費しない位置とみなす。 - slot-list 縦並び規則: 複数行へ展開した slot-list は、スロットを改行区切りで縦に並べ、スロット間にカンマを付けない (改行が区切りを兼ねる。language-spec.md §1.3)。1 行 inline 形は従来どおり
,区切り。 - 行幅: 既定 120 桁。
hikari.tomlで変更可 (「設定ファイル」)。
インデントと区切り
- インデント: 半角スペース 2。タブ・末尾空白は除去する。
- 文区切り: セミコロンではなく改行に正規化する (1 行に収める inline 形を除く)。
括弧の除去と呼び出し形の正規化
- 冗長括弧の除去: 中置/前置式は優先順位に基づき意味維持に必要な括弧のみ残す (冗長な括弧は除去)。
- 文/ボディ位置のタプルは括弧を外す:
{x | (a, b)}→{x | a, b}、多値返却(q, r)→q, r。曖昧さの出る代入の右辺 (r := (a, b)) や式中 (f((a, b))) では維持する。 - 1 引数の呼び出しは並置形
f xに正規化する:f(x)→f x、obj.m(x)→obj.m x。ただし引数が適用より弱い式 (中置/前置など) のときは結合が変わらないよう括弧を残す (f(a + b)→f (a + b)、f(-x)→f (-x))。後置チェーンの基底が並置になる場合も全体を括る (print(r.step(2).to_list())→print ((r.step 2).to_list()))。この並置化には次の 2 つの例外がある。 - 0 引数と多引数は形を保つ: 0 引数
f()と 2 引数以上のタプル形f(a, b)は元の形を保つ。!postfix も保つ。
例外 1: 値コンストラクターと型コンストラクター (大文字始まりの callee)
値コンストラクターと型コンストラクターは単一引数でも括弧を保つ。
- 対象: 値コンストラクター (
Some(x)/Ok(v)/Err(e)・ユーザー定義タグ) と型コンストラクター (List(T)/Option(T))。 - 適用位置: 値位置・
matchの値コンストラクターパターン (Ok(amt) => …)・型位置のいずれでも並置化しない (Ok 5→Ok(5)、x: List Int→x: List(Int))。 - 理由: spec / prelude がこれらを
Ok(v)/Some(x) => …/List(T)と括弧付きで記す表記 (language-spec.md §8.4 / §17、prelude.md §12) に揃え、複数引数OneOf(A, B)とも一貫させる。 - 対象外: 小文字始まりの関数適用 (
op(5)→op 5) は従来どおり並置化する。
例外 2: 後置 ! / ? で直接 kick / bail される単一引数適用
後置 ! / ? で直接 kick / bail される単一引数適用は括弧呼び出し形 f(x)! を保つ。
- 理由: 並置形
f xは後置演算子より弱く結合する (language-spec.md §8.3) ため、その結果を kick するには囲み括弧(f x)!が要る。この形では並置化せず括弧呼び出し形に正規化する ((http.get "x")!→http.get("x")!、(fs.read p)!.unwrap!→fs.read(p)!.unwrap!、(store.get k)?→store.get(k)?)。future の await をはじめ kick / bail は頻出し、囲み括弧より読みやすく、元から並置化しない複数引数fs.write(a, b)!とも一貫する。 - 対象外: callee 自身が並置適用のとき (
(f g x)!のf g) は囲み括弧を保つ。大文字始まりの値/型コンストラクターは例外 1 のとおり括弧を保つため本例外の対象外。
文字列リテラル
- 再クォート: 文字列はエスケープを正規化して
"..."で再クォートする。 - 三連引用符の保存: 元ソースが三連引用符
"""..."""で書いた文字列は、安全に往復 (round-trip) できるかぎりその表記を保存する。次のいずれかで安全に往復できない場合は"..."の単一行エスケープへフォールバックする: 改行を含まない (三連引用符にする意味が無い)・"""を値に含む (終端衝突)・非空の空白のみ行を含む (dedent が空行に潰す)・\t/\r以外の非表示文字を含む。 - 行幅超過での三連引用符化: 改行を含む文字列 (素の
"..."・補間付き"...${e}..."の両方) を単一行のエスケープ形で書くとその行が行幅 (既定 120 桁、「設定ファイル」で変更可) を超える場合、安全に往復できるかぎり三連引用符ブロック"""..."""へ展開する (上記のフォールバック条件に当たる値は単一行のまま)。行頭からの桁と同じ行の後続文脈を勘定に入れて判定し、開始桁が既知の位置 (代入 RHS・並置/呼び出しの引数・タプル要素など) で効く。幅内に収まる短い複数行文字列は単一行のまま (表記の選択は作者に委ねる)。補間付き文字列も同じ規則で三連引用符化し、本文中の${e}はそのまま補間として残す。
オブジェクト・データオブジェクト・リストのレイアウト
- 複数行展開の条件:
{ slot-list | body }などのオブジェクトは、元ソースが複数行に跨る (または内部にコメントを含む) 場合のみ複数行に展開し、そうでなければ 1 行にまとめる。 - header の配置: 本体付きオブジェクトの仮引数 (スロット列) は開き括弧と同じ行に置く (
{ amt |のようなラムダ形)。複数行でも開き括弧と同じ行に header (スロット/inner-name) が続く波括弧は{の後に 1 スペースを入れる。 - 省略した body はまとめない・広げない: body を省略した綴り (
{ x, y |}) とinner_boundを裸で返す綴り ({ inner_bound b, x, y | b }) は同じ値だが (language-spec.md §2.2)、整形はどちらの綴りもそのまま保つ。まとめると書き手が置いたinner_bound宣言まで消すことになり、整形の役目を越える。省略形で書けることの指摘はhikari lintのexplicit-inner-bound-bodyが担う (lint.md)。 - 構文木に残らない区切り記号は正準化する: 整形は抽象構文木から印字を組み直すので、構文木に残らない区切り記号の違いは正準形へ寄る。冗長な括弧 (
(b)→b) と 1 引数呼び出しの並置化 (f(x)→f x) がこれにあたる。保存するには構文木の側にその区切りを載せることになる。これは body のトークン列を変える整形である — 等価は body のトークン列を読むので (language-spec.md §9.1)、整形の前後で==の答えが変わりうる。空白・改行・コメントは等価が最初から無視し、文字列のエスケープと三連引用符は字句解析が解決済みの値をまとめるので、いずれも影響しない。区切り記号の違いで値を区別しないこと (§9.1.1)。 - inner-name の位置: inner-name は
inner name(型注釈があればinner name: T) として slot-list 先頭スロットに置く。inner_bound name(language-spec.md §4.2.1) も同じ扱いで、両方あるときはinner→inner_boundの順に並べる。他スロットと同じ,区切り (複数行では改行) で連結し、2 本目の|は出さない ({ inner self, n | n })。複数行データオブジェクトではinner self,が開き括弧と同じ行の先頭スロットになる。 - 1 行まとめの余白: 1 行にまとめた波括弧
{...}は内側を 1 スペースで余白を取る ({ a, b | a + b }、{ running = false }、{ i < 10 })。余白は中身のためにあるので、区切りの|の側に中身が無ければ置かない — slot-list が空なら先頭を詰め ({| body })、body を省いたデータオブジェクトなら末尾を詰める ({ mutable x := 1, mutable y := 2 |})。空オブジェクト{}は余白なし。 - 複数行の slot-list: 複数行へ展開したデータオブジェクト
{}とリスト[]は「slot-list 縦並び規則」に従う。末尾スロットの種別 (値つき slotname := value・bare な default-less slot) を問わず閉じ|}を単独行に置く。 - 角括弧:
[...]は 1 行に収まるなら従来どおりタイトに保つ ([1, 2, 3])。 type/enumの右辺: 型式 /OneOf(...)は通常の型式として整形する (トップレベルの束縛文でも slot-list 内の型スロットでも同じ)。したがって複数行に書かれた record 型{ … |}は上記のデータオブジェクト規則で縦展開し、閉じ|}を単独行に置く。1 行に収まる record 型は{ text: String |}のように内側 1 スペースの余白を取る (トップレベル束縛文でも slot-list 内でも一貫)。- 前置予約語と開き括弧の間の空白:
conduit/loop/refinement/refinement_opaque(language-spec.md §1.4) と続く開き括弧の間には常に 1 スペースを入れる (conduit {...}/loop {...}/refinement {...}/refinement_opaque {...})。
制御構造の展開
- 文・ボディ位置の
if/when/whileは展開: 制御構造が値を消費しない位置 (文・ボディ位置) にあるときは、分岐/本体ブロックを 1 行ソースでも複数行へ展開する (改行尊重の例外)。値位置では 1 行を保つ (ただし行幅超過時は下記のとおり展開する)。 - ブロック本体の文 (末尾の返り値位置も含む) は値を消費しない位置とみなす (
run := {| if c {a} {b} }は展開)。 whileの条件ブロックは値位置として 1 行を保ち、本体のみ展開する。- タプル形
if(c, {t}, {e})はタプル形のまま分岐ブロックだけ展開する (AST 保存)。 - 値位置でも行幅超過なら展開: 代入 RHS などの値位置にある
if/when/whileは原則 1 行に保つが、1 行に収めるとその行が行幅 (既定 120 桁、「設定ファイル」で変更可) を超える場合は、文・ボディ位置と同じく分岐/本体ブロックを縦展開する (条件式は 1 行を保ち分岐/本体のみ展開)。幅内に収まる短い値位置の制御構造は 1 行のまま。 - 性質: この規則は AST を保存し冪等。
hikari lint --fixの並置化経路も同じ展開を通す。
行幅超過・複数行維持による展開
- 行幅超過での slot-list 展開: 行幅 (既定 120 桁、「設定ファイル」で変更可) を超える行は slot-list を複数行へ展開する (展開専用 — 既存の改行尊重は不変で、短い複数行を 1 行へまとめることはしない)。
- 対象: オブジェクト
{}/ リストリテラル[]/ 分割代入の左辺[a, b, c]/export { name, … }の識別子リスト。加えて、type/ 型エイリアスの右辺の record 型{ … |}、および末尾に型注釈が付く slot-list{ … |}: Tも同じ規則で展開する (後者は内側 slot-list を縦展開し、型注釈は閉じ|}に付けて|}: Tを単独行に置く)。さらに、束縛/代入の型注釈位置の record 型name: { … |}も同じ規則で展開する (型注釈を縦展開し、続く:= valueは閉じ|}の後に|} := valueとして置く)。 - 判定: 1 行に収めるとその行が設定された行幅 (既定 120 桁・文字数) を超える場合に複数行へ展開する。行頭からの桁と同じ行の後続文脈 (例
import { ... } := "..."の:= "..."部・末尾型注釈|}: Tの: T部) を勘定に入れ、まず最も外側を展開し、子は深いインデントで再測定してなお収まらないものだけをさらに展開する (最小限の展開)。 - レイアウト: 展開後は「slot-list 縦並び規則」に従う。
- 行幅超過での呼び出し・タプルの引数列展開: 複数引数の括弧呼び出し
f(a, b, …)/ タプルリテラル(a, b, …)は、1 行に収めるとその行が行幅 (既定 120 桁、「設定ファイル」で変更可) を超える場合、引数列を縦展開する (レイアウトは下記「元ソースが複数行のタプル/引数列は改行を維持」と同一 — 要素を改行区切りで並べ・カンマなし・閉じ括弧は開いた行のインデントに置く)。判定は行頭からの桁と同じ行の後続文脈を勘定に入れ、まず最も外側の引数列を縦展開し、各引数は 1 段深いインデントで再測定してなお収まらないものだけをさらに展開する (最小限の展開)。単一引数の並置呼び出しf xは引数列がないため対象外。中置式の骨格 (演算子位置での改行) も対象外だが、その被演算子にある折り返し可能構造は展開する (下記「中置の被演算子・matchアーム本体でも展開」)。 - 呼び出し連鎖内のブロック引数の展開: メソッド呼び出し連鎖
recv.m { … }では、連鎖・並置の骨格 (.mの連なり) は 1 行に保つが、その行が行幅を超えるときは、超過の原因になっているブロック引数{ … }の本体を縦展開する (連鎖そのものは折り返さない)。 - 判定: 各ブロック引数について「開始桁 + そのブロックのインライン幅 + 同じ行の後続文脈 (
).map { … }等) の幅」が行幅を超えるかで判定し、左 (レシーバー側) から順に展開する。あるブロックを展開すると後続はその閉じ括弧より下の行へ移るので、残りが収まればそれ以降のブロックは 1 行に保つ (最小限の展開・best effort)。 - 対象: 単一のブロック/データオブジェクト引数
{ … }を取る並置・呼び出し (x.sort_with { … }/x.map { … })。展開後のブロックは通常のオブジェクトレイアウト (本体を 1 段インデント、閉じ}は連鎖行のインデント) に従う。 - 中置の被演算子・
matchアーム本体でも展開: 上記各規則の折り返し可能構造 (オブジェクト{}・複数引数の呼び出し/タプルの引数列・呼び出し連鎖のブロック引数) が、中置式の被演算子やmatchアームの body 位置にあるときも、その開始桁を勘定して展開する。中置の骨格 (a + bの演算子まわり) やmatchアームの頭 (pattern =>) は 1 行に保ち、超過の原因になっている被演算子/body の構造だけを縦展開する (best effort)。例:st := st0 + { … |}は+を頭行に残し object を縦展開する。被演算子がさらに中置式のとき (a == false && b == falseの各項など) は骨格の一部として 1 行に保ち、その内部の呼び出し引数列などは縦展開しない (中置チェーンを不用意に崩さない)。 - 行幅超過での中置連鎖の展開 (chain wrap): 被演算子を折ってもなお中置の骨格 (演算子まわり) の行が行幅を超える連鎖 (
a && b && c …・x + y + z …など、折り先の無い被演算子が並ぶ) は、連鎖全体を括弧( … )で囲んで縦展開する。開始桁に(を置いて改行し、被演算子を greedy に (幅に収まる限り同じ行へ) 詰め、演算子は各行の末尾 (trailing) に付けて改行し、閉じ)は開いた行のインデントに単独行で置く ([ ]・{ }の複数行整形と同じ骨格)。対象は左結合の|| && + - * / %の同優先度連鎖に限り、右結合**や比較==などは骨格 1 行を保つ。被演算子は平坦化前の左結合グルーピングを保つよう必要な括弧を付ける。折り先を持つ被演算子 (オブジェクト・引数列) がある連鎖は、まず上記「中置の被演算子…でも展開」で被演算子を折り、それで各行が収まるなら chain wrap しない (最小限の展開)。グルーピング括弧は AST にトークンを残さない (単一要素グループの( … )は解析時に外れる) ため、単独行)の終端行はフォーマッターが先頭被演算子の直前の(から辿って数える (空行保持が終端を過小評価しないようにする内部処理)。 - 行幅超過での関数型の展開: 型位置の関数型
{ params | result }は、1 行に収めるとその行が行幅を超える場合、{ slot-list | body }統一に倣って縦展開する — パラメーター slot-list と結果型を縦に並べ、両者を区切る|を単独行に置き、閉じ}を開いた行のインデントに置く。パラメーター slot-list・結果型が record 型など折り返し可能なら、1 段深いインデントで再測定してなお収まらないものだけをさらに展開する (最小限の展開)。束縛注釈位置なら続く:= valueは閉じ}の後に置く。 - 行幅超過での関数リテラルの header (パラメーター列) 展開: 値位置の関数リテラル
{ name: Type, … | body }は、header (パラメーター列) を 1 行に収めるとその行 ({ … |) が行幅を超える場合、header の各スロットを 1 スロット 1 行に縦展開する (「slot-list 縦並び規則」)。開始桁に{を置いて改行し、スロットを縦に並べ、パラメーターと body を区切る|を単独行 (開き括弧のインデント) に置き、body を 1 段インデントで並べ、閉じ}を開いた行のインデントに置く。inner-name 付き ({ inner self, … | body }) は対象外 (header はインラインを保つ)。 - 元ソースが複数行のタプル/引数列は改行を維持: 元ソースが複数行で書かれたタプル
()/ 括弧呼び出しの引数列は、その改行を維持して縦展開する (要素を改行区切りで並べ・末尾カンマなし・閉じ括弧は開いた行のインデントに置く)。1 行で書かれたものは 1 行のまま。冗長な「カンマ + 改行」はカンマを落として改行区切りに正規化する。
整形前:
(1, 2)
整形後:
( 1 2 )
match の展開
- 常に複数行へ展開:
matchは位置を問わず常に複数行へ展開する (アームは 1 行 1 アーム、match行から 1 段インデント。閉じ}はmatch行のインデントに置く)。1 行で書かれたmatchもアームごとの改行区切りへ正規化する — 制御構造の「値位置では 1 行を保つ」例外はmatchには適用しない。
整形前:
match r { Ok(v) => v; Err(e) => e }
整形後:
match r { Ok(v) => v Err(e) => e }
- 帰結:
matchを部分木に含むオブジェクト{}/ リスト[]は 1 行にまとめられないため複数行へ展開する。 - グルーピング括弧の保持: 値位置の
matchは、裸で出すと再パースで結合・意味が変わる位置 — 並置引数 (f (match …))・後置!/?の対象・中置/前置の被演算子・slot-list 要素 — でグルーピング括弧を保つ。
コメントと空行
- コメントは保持する: 独立行コメントは直後の要素の前に、行末コメントは同じ行の末尾に付け直す。
- 空行は 1 行だけ保持する: 意味のある区切りとして 1 行だけ保持する (連続空行は 1 行にまとめる)。
インポート・エクスポートの集約
- 集約の順序:
export/ top-levelimport束縛はヘッダー直後に続く先頭コメント/#@ディレクティブ/doc ブロックの後へ集約する。順序は「ヘッダー → 先頭コメント/ディレクティブ/doc ブロック (ヘッダーに固定) →export→ imports (ソース順は保持) → body」。先頭ブロックとexport/imports の間の空行は元ソースにあれば 1 行だけ保持する。 - 直上コメントの追従:
export/ 各 import の直上に空行なしで直結する own-line コメントは、ヘッダー固定ブロックではなくそのexport/ import の直上コメントとみなし、一緒に移動する。
ファイル末尾と冪等性
- ファイル末尾: 改行 1 個で終える。
- 冪等: 整形は冪等 (整形済みソースを再度整形しても変化しない)。
出力と終了コード
| 状況 | code |
|---|---|
全ファイル整形成功 (-l で差分なしを含む) |
0 |
-l で差分あり / いずれかのファイルに構文エラー |
1 |
| I/O エラー / 引数不正 | 2 |
構文エラーのあるファイルは整形せず、診断を stderr に出してそのファイルをスキップする (他ファイルの処理は継続)。-l が差分ありで 1 を返すのは、CI で hikari format -l . を整形チェックに使えるようにするため。
設定ファイル
Hikari の整形・検査・調査は 1 つの設定ファイル hikari.toml で制御する。hikari は単一バイナリのサブコマンド (format / lint / check / inspect) 構成なので、設定もサブコマンドごとに分けず 1 ファイルに集約する (Deno の deno.json ・Biome の biome.json と同じ方針 — 共通の除外を一箇所に書き、必要な箇所だけツール別に上書きする)。設定ファイルが無ければ既定の挙動になる。
設定ファイルは TOML で書く。設定は宣言データであり Hikari の表現力を必要としないので、言語そのものではなく汎用のデータ形式を使う。指定できるキーは次のとおり。
width(正の整数):hikari formatの行折り返し幅 (既定 120)。skip(真偽値): 全サブコマンド (format / lint / check / inspect) 共通の除外。[format]/[lint]/[check]/[inspect](テーブル。skip(真偽値) を持つ): サブコマンド個別の上書き。
width = 100 skip = true # 既定で全サブコマンドから外し [format] skip = false # format だけ対象に戻す (Deno の否定 glob 相当)
- 探索: 対象の各ファイルのあるディレクトリから上位へ遡り、最初に見つかった
hikari.tomlを使う (per-file 解決・nearest-wins)。パスを与えない (stdin) ときはカレントディレクトリから遡る。 - 既定:
hikari.tomlが見つからない /widthキーが無い場合はwidth = 120。 - 未知キーは無視する (前方互換。将来の設定項目追加に備える)。ただし無視したキーは警告として stderr に出す (後述)。
- 不正な設定 (TOML の構文エラー /
widthが非整数 /width <= 0/skip系が非真偽値 / ツール別キーがテーブルでない) は引数不正扱い (exit code2) とし、診断を stderr に出してそのファイルは処理しない。診断には TOML パーサーが返す行番号を含める。
未知キーの警告
TOML ではトップレベルのキーはテーブルヘッダーより前に置かなければならない。[format] の後ろに width = 100 を書くと、エラーにならず format.width として読まれ、width の設定が黙って消える。これを検出するため、処理しなかったキーはすべて警告として stderr に出す。
hikari.toml: ignoring unknown key 'format.width'
- 警告であり失敗させない。未知キーを無視するという前方互換の約束は変わらない。
- 上の吸い込みに加え、キー名の綴り間違い (
widht = 100) も同じ経路で拾える。
除外 skip
各サブコマンドの実効 skip は ツール別テーブル ([format]/[lint]/[check]/[inspect] の skip) → 共通 skip → 既定 false の順で最初に指定されたものを採る。skip = true に当たるファイルは、そのサブコマンドが読み込みも処理もせず原文のまま扱う (対象ツリーに含めたまま特定のサブツリーだけ外せる)。
用途はツールにより異なる (詳細は各仕様: lint・inspect)。format では特定のサブツリーを非正規形のまま保つとき (例: 手書きハイライト見本) に使う。format の skip = true は -w で書き換えず・-l で差分報告せず・既定 (stdout) は原文をそのまま出す。stdin 経路 (パス無し) は明示入力のため skip を見ない。
# tools/vscode-hikari/testdata/hikari.toml — 手書きハイライト見本を全サブコマンドから外す skip = true
- 解決は per-file の nearest-wins (
widthと同じ)。除外したいディレクトリ直下に置くのが素直で、除外理由が対象の隣に自己文書化される。 - サブコマンド共通の 1 ファイル: lint / check / inspect の除外も同じ
hikari.tomlで扱う。全サブコマンドから外すなら共通skip = true、一部だけ外すならツール別[lint]のskip = true等で上書きする (format だけ残すなら[format]のskip = false)。 - 設定ファイル自身は走査対象にならない。
hikari.tomlは.hikaではないので、format / lint / check / inspect のどれもソースとして読まない。