レポート: moonbit/ の modernize #
moonbit-individual-handbook の .agents/skills/moonbit-modernize skill を、moonbit/ のコードに適用した記録。
skill はこのリポジトリにも個人の環境にもインストールせず、handbook の手元の clone から SKILL.md を読み、その手順に沿って手で書き換えた(「仮想的に実行」)。
候補の洗い出しは、同じ handbook の moonbit-modernize-explore の手順に従った。
作業はブランチ moonbit-modernize で行い、書き換えの種類ごとに 1 コミットにした。どのような書き換えをしたかは、コミットを 1 つずつ見れば単独の diff として確かめられる。
結論 #
- 15 種類の書き換えを、それぞれ 1 コミットにして適用した。挙動は変えていない。各コミットの後で
moon fmt、wasm-gc と native の両方でのmoon check --deny-warn、moon test(24 件)を通し、最後にmake checkも通した。 - 最初のパスでは、handbook の
language-changes-catalogにある 2 つの参照文書を読んでいなかった。後から照合し、さらに 8 種類を適用した(「追記: language-changes-catalog との照合」の節)。 - コードは移植したばかりで、もともとかなり今風に書かれていた。opt-in の警告で見つかった書き換えは 2 種類(6 箇所)だけで、残りはすべて、警告の出ない慣習(skill の catalog と grep)から見つけた。
- 公開 API が変わったのは
compareパッケージ(引数のラベルとReportの可視性)とsiteパッケージ(Responseの可視性)だけ。pkg.generated.mbtiの diff がそのまま API の変更点になっている。 - 「適用できる」と「適用すべき」は別、という skill の判断基準に従い、読みにくくなるものは見送った(「見送ったもの」の節)。
環境 #
| バージョン | |
|---|---|
| moon | 0.1.20261012 |
| moonc | v0.10.15(2026-10-12) |
| moonbit-individual-handbook | a59f0d5(2026-09-28) |
手順 #
1. 対象のモジュールは moonbit/ の 1 つだけ(moon.mod は 1 つ)。posts/embedded.mbt は生成物なので対象から外した。
2. 変更前に moon fmt --check、moon check --deny-warn(wasm-gc、native)、moon test が通ることを確かめた。
3. 今の toolchain の off の警告を moonc check -warn-help から取り出し、missing_doc、missing_invariant、missing_reasoning、declaration_implemented を除いて --warn-list に渡した。
L=$(moonc check -warn-help | awk '$NF == "off" {print $1}' | sort -u \
| grep -v -E '^(missing_doc|missing_invariant|missing_reasoning|declaration_implemented)$' \
| paste -sd+ -)
moon check --target native --warn-list "+$L"結果は unnecessary_annotation が 128 件、unnecessary_view_op が 8 件、unqualified_record が 4 件(どれも wasm-gc と native で重複して数えた件数)。
4. 警告の出ない候補は、pkg.generated.mbti を読むことと grep(::new(、fn(、write_string、while、let mut)で探した。
5. 候補を種類ごとにまとめ、種類ごとに方針を決めた。1 箇所で動くことを確かめてから残りを書き換え、検証してからコミットした。
コミットの一覧 #
| commit | 種類 | 内容 |
|---|---|---|
| 85ed177 | 警告 unnecessary_view_op | view が期待される場所の [:] を削除 |
| d4c45c7 | 警告 unqualified_record | let に束縛する struct リテラルを LineWriter::{ .. } のように型名付きにした |
| e1e9f8c | 構文 | 匿名関数 fn(sb) { .. } をアロー関数 sb => .. に |
| d1e314e | <+ | builder に文字列リテラルやテンプレート文字列を書く write_string を sb <+ ".." に |
| 19afdde | core の関数 | join_url の末尾の / を落とすループを trim_end に、区切り行の判定を Iter::all に、行の長さ合わせを Array::truncate と range の for に |
| 163154c | Option | match x.strip_prefix(..) { Some(v) => v; None => x } を x.strip_prefix(..).unwrap_or(x) に |
| c5c2d32 | core の関数 | 先頭の # やバッククォートを数えるループを trim_start との長さの差に |
| 1b5db40 | functional-for | cells、nest、link、closing_run の let mut + while を、continue と nobreak を使う functional-for に |
| 8d70346 | is-pattern | c == '\n' || c == '>' を c is ('\n' | '>') に |
| 04766d7 | guard | parse_table と link の前提条件を guard .. else { return None } に |
| 6ac8b8a | core の関数 | filter(..).length() を count_if に |
| 2bfe25b | method | is_void、is_block(Tag)、is_attr、write_html、write_plain(Node)を method に。html_response は名前空間付きの constructor Response::html(status, body~) に |
| 9b7efdc | ラベル付き引数 | 取り違えやすい先頭以外の引数にラベルを付けた(compare(path, go~, moonbit~)、body_diff(go, moonbit~)、join_url(base, path~)、page(sb, title~, body~)、split_inlines(s, links~)、nest(flat, from~, level~)、closing_run(s, from~, n~)) |
| d1dbfb7 | 可視性 | 外のパッケージで構築していない Response と Report を pub(all) struct から pub struct(外からは読み取り専用)に |
| 057cb5a | 慣用表現 | xs.length() > 0 を !xs.is_empty() に(compare パッケージの書き方に揃えた) |
方針を決めたもの #
- **
@pkg.は残す。**unnecessary_annotationの 128 件(@el.Text(..)の@el.など)は、skill の方針どおり候補を見つけるためだけに使い、書き換えなかった。どのパッケージの名前かが見える方がよい。 - **
<+は文字列リテラルとテンプレート文字列にだけ使う。** 変数をそのまま書く箇所(Raw(s) => sb.write_string(s)、LineWriter::write)はwrite_stringのまま。sb <+ sはunused_valueの警告になる。write_string(line)とwrite_char('\n')が続く箇所は、まとめてsb <+ "\{line}\n"にした。 - **ラベルは、同じ型の引数が並ぶ箇所と Bool / Int の引数にだけ付ける。** skill は「先頭以外の引数にはすべてラベルを付ける」としているが、
el(name, children)やescape_to(sb, s)のように第 2 引数の役割が明らかなものは位置引数のままにした。とくにelは HTML を組み立てる DSL で、ノードごとにchildren=が入ると読みにくい。 - **
Fetchedはpub(all)のまま。**compare/cmdとテストが構築している。tinymarkdownのDocument、Block、Inlineと、elの型も、テストや利用側が値を作るのでpub(all)のまま。
見送ったもの #
- **
scanとsplit_inlinesのループ。**split_inlinesは、ローカル関数emitが可変のtextを書き換えている。scanはin_codeの状態を多くの分岐にまたがって持っている。functional-for にすると、continueのたびにすべての状態を書き並べることになり、元の方が読みやすい。 - **
compare/cmdのfetchのtry { .. } catch。** ブロックの中で@http.getとbody.text()の 2 つが raise する。postfix のf() catch { .. }にすると catch が 2 つに分かれ、冗長になる。 - **
is_headingを functional-for にすること。** 一度書いてみたが、本体が空のfor .. { } nobreak { n }になって読みにくかったので、trim_startとの長さの差で数える形(c5c2d32)にした。 - **
body_diffの 2 重ループのリスト内包表記化。** 内包表記は入れ子にできず(skill の pitfall にある)、flat_mapで書いても読みやすくならない。 - **
Type::new。** 該当する関数がなかった。
気づいたこと #
- **functional-for の 3 部形式で
continue xに値を渡すと、更新節(i = i + 1)は実行されない。**cellsの\|(2 文字進める)をcontinue i + 2と書けたのはこのため。もし更新節が実行されたら、\|を含むセルのテストが失敗するはずだが、通った。 - **
moon fmtは、長い<+の右辺を次の行に送る。**details <+で改行し、テンプレート文字列を次の行に字下げして置く。write_string(..)のときの括弧と末尾のカンマがなくなるので、行数は少し減る。 - **
StringはStringViewが期待される場所で暗黙に変換される。**line.strip_prefix("|").unwrap_or(line)は、strip_prefixがStringView?を返しlineはStringだが、そのまま型が合った。 - **core の関数の細かい挙動は、
~/.moon/lib/coreのpkg.generated.mbtiと実装を読んで確かめるのが早い。**Array::truncateは、指定した長さが今の長さ以上なら何もしないことを、実装で確かめてから使った。 - **
elとtinymarkdownで、deriveで生えるメソッドを打ち消す書き方が違う。**el/extends.mbtは#deprecatedを付けてpub extendしているが、tinymarkdownは付けていない。modernize の catalog の範囲外なので、modernize のコミットでは揃えていない。その後、tinymarkdownにも#deprecatedを付けてelに揃えた。deprecated になっているのは、deriveしたトレイトのメソッドが普通のメソッドとしても自動で生えるというコンパイラの挙動で、生えたメソッドを自分で消す方法はない。#deprecatedを付けたpub extendは、警告implicit_impl_as_methodが示す「メソッドとしては使わせない」側の書き方で、==やDebug::to_reprだけを使うことをコンパイラに確かめさせられる。
追記: moonbit-refactoring skill の前半による見直し #
moonbitlang/moonbit-agent-guide の moonbit-refactoring skill のうち、「Local refactoring」より前の節(Intent、Workflow、Improve Package Architecture、Minimize Public API and Modularize)に照らして、modernize の後のコード全体を見直した。 この節の内容は見直しの結果だけで、コードは変えていない。
skill の Workflow は「まず全体の構造を見て、構造が固まるまで局所的な書き換え(名前の変更やパターンマッチ)は避ける」としている。
一方、moonbit-modernize は局所的な書き換えのカタログで、今回はこれとは逆の順に進めた。
そこで、後からではあるが、全体の構造に問題がないかを確かめた。
結論 #
- パッケージの構成と依存関係に問題はなく、局所的な書き換えを先にしたことで困ることはなかった。
- 公開 API は、ほぼ呼び出し側が必要とするものだけになっている。
internal/パッケージに分ける必要もない。 - skill の Intent にある「view 型でコピーを避ける」は、
tinymarkdownにまだ余地がある。 - テストのカバレッジは 386/411 行。残りは主に
worker(テストなし)とcompareのエラー経路。公開 API に、doc コメント中のテスト(mbt checkのコードブロック)はない。
パッケージ構成と依存関係 #
| パッケージ | ソース(行) | テスト(行) | import しているもの | 使っているパッケージ |
|---|---|---|---|---|
el | 441 | 163 | なし | なし(テストのみ) |
tinymarkdown | 423 | 403 | core/debug(テストのみ) | なし(テストのみ) |
posts | 生成物 200 | 32 | なし | なし(テストのみ) |
site | 67 | 28 | なし | worker |
worker | 52 | 0 | site | Workers の JS |
compare | 155 | 119 | core/diff | compare/cmd |
compare/cmd | 73 | 0 | compare、core/argparse、core/string、moonbitlang/async | なし(実行ファイル) |
- 依存関係に循環はなく、上位のパッケージが下位を import する向きだけになっている。
- 規模は skill の目安(パッケージ 1 万行、ファイル 2 千行、関数 200 行)よりずっと小さい。一番長い関数は
tinymarkdownのscanで 84 行。 el、tinymarkdown、postsは、まだどのパッケージからも使われていない。siteがこれらを使うのは TODO.md の「ブログを MoonBit で作り直す」の残りのステップで、移植の途中だから。- そのため
siteには、elのエスケープと同じ役割のwrite_escapedが別にある。エスケープ結果も違い、"をelは Go のhtml.EscapeStringと同じ"に、siteは"にする。siteをelで書き直せば、この重複はなくなる。 - バックエンドの切り替えは、どちらも
moon.pkgのsupported_targetsで揃っている(workerは wasm-gc のみ、compare/cmdは native のみ)。ファイル名の接尾辞や#cfgは使っていない。
公開 API #
siteのResponse::html、elの各ヘルパー、tinymarkdownのscan、nest、cellsなどは、すべてパッケージ内に閉じている。workerのpath_pushとserveは、Wasm から export するためにpubが必要。compareのbody_diffを使っているのは、compare自身とテストだけ。pubを外すこともできる。ただしその場合、ブラックボックステストからはcompare経由でしか確かめられなくなる。skill はブラックボックステストを勧めているので、pubのままにした。elのconst.mbtにある要素名や属性名(div、hrefなど)は、ほとんどがまだ使われていない。HTML を組み立てる DSL の語彙なので、そのまま残す。- 呼び出し元の洗い出しには
moon ide find-referencesを使った。このコマンドは--targetを付けないとmoon.modのpreferred_target(wasm-gc)で解析する。そのため、native でしかビルドしないcompare/cmdからの参照が出てこない。たとえばdefault_pathsは、そのままだとテストの 1 件しか見つからず、--target nativeを付けて初めてcompare/cmd/main.mbtの参照が見つかった。
view 型とコピー #
skill の Intent は「view 型(StringView など)でコピーを避ける」としている。
modernize のコミットでは、[:] を削り、strip_prefix の結果を view のまま扱うところまでで止めた。tinymarkdown には、まだ to_owned() が 9 箇所ある。
trim_spaceはStringViewを受け取るが、Stringを返す。cells(line : String)とcode_text(s : String)もStringを受け取るので、呼び出し側が先にto_owned()している。split_inlinesとlinkは、Text、Code、Linkに入れる部分文字列を切り出すたびにコピーしている。
Block と Inline は String を持つので、木に入れる時点でのコピーは必要になる。
減らせるのは、途中のヘルパーが String を受け取っているために起きるコピーで、cells と code_text が StringView を受け取るようにすれば減る。
ただ、tinymarkdown の目的は Go 版と同じ挙動の移植で、性能の問題もまだない。そのため手を付けていない。
テストとドキュメント #
moon test --enable-coverage の結果は 386/411 行。主な残りは次のとおり。
worker(0/18 行): host の import を呼ぶので、moon testでは確かめられない。make devとmake compareで確かめている。compare(53/59 行): 改行も>もない末尾の断片、\rと\tの表示、MoonBit 側だけの取得失敗。tinymarkdown/inline.mbt(71/72 行): 1 行。
公開 API に、doc コメント中のテスト(mbt check のコードブロック)はない。
テストはすべて、Go のテストケースを移したブラックボックステスト(*_test.mbt)になっている。
modernize で変えた公開 API(compare のラベル、Response と Report の可視性)は README に書かれておらず、README を直す必要はなかった。
追記: language-changes-catalog との照合 #
最初のパスは、moonbit-modernize と moonbit-modernize-explore の SKILL.md にある catalog、opt-in の警告、grep だけを手がかりにしていた。
moonbit-modernize は自分の catalog を「前回うまくいったものの標本で、全体ではない」と書いている。それにもかかわらず、全体にあたる language-changes-catalog の 2 つの参照文書は読んでいなかった。
references/language-changes.md: release notes から集めた「問題意識 → 言語機能」の catalog。references/about-idiomatic-code.md: 知識の階層の整理と、個人ポリシー。
そこで、この 2 つを読み、テーマ別 catalog の項目と個人ポリシーをひとつずつコードに当てはめた。 構文は、スクラッチのモジュールで今の toolchain(moon 0.1.20261012)でも通ることを確かめてから使った。
適用したもの #
| commit | catalog の項目 | 内容 |
|---|---|---|
| 2037cdd | 型・値「const」 | tinymarkdown の fence、max_level、spaces を const の FENCE、MAX_LEVEL、SPACES に |
| 965597e | パターンマッチング「配列パターンの ..rest」 | parse_table の lines[0]、lines[1]、lines[2:] を guard lines is [header, delim, .. body] に |
| 36912bc | パターンマッチング「文字列パターン」 | リストの行を trimmed is [.. "- ", .. rest] で、両端の空白を落とすコードスパンを s is [' ', .. inner, ' '] で判定 |
| a955ed2 | ループ「for x in expr」 | テストの for c in cases { let (name, src, want) = c を for (name, src, want) in cases に |
| 8f443d2 | ループ「リスト内包」 | posts のテストの .iter().map(f => f.0).collect() を [for f in @posts.files => f.0] に |
| b81b058 | エラー処理「Result 化」と個人ポリシー「raise が既定」 | compare/cmd の fetch を raise する関数にし、失敗をデータとして残す try_fetch を Ok(fetch(url)) catch { e => Err(..) } の薄い層として分けた。閉じたポートに向けて CLI を実行し、両側の失敗が報告されることを確かめた |
| 1990fe1 | メソッド「外部型へのローカルメソッド」 | el の escape_to(sb, s) と site の write_escaped(sb, s) を、パッケージ内だけの StringBuilder::write_escaped に |
| 5a9d51b | 文字列「補間」 | compare の header + details.to_string() と、tinymarkdown のリスト項目の継続行の + 連結を、テンプレート文字列に |
当てはまらなかったもの、見送ったもの #
- **正規表現(
lexscan、=~):**tinymarkdownのインライン解析は、トークナイザとして書き直せる。ただし Go 版と同じ挙動の移植が目的で、書き直しが大きいので見送った。 - **
<?、カスケード..、ラベル付きブロック、[| .. |]、extenum、ローカル型、phantom type、ビット列パターン、defer/errdefer、suberror、#non_exhaustive、FFI の注釈:** どれも使う場面がなかった。 - **
fn Type::Type:** 構築用の関数は、名前空間付きのResponse::htmlしかない。 - **
#as_free_fn:** 最初のパスでメソッドにした関数は、どれもパッケージ内に閉じていて、移行させる呼び出し元がない。 - **
ReadOnlyArray/FixedArray:**prefer_readonly_array/prefer_fixed_arrayの警告は出なかった。 - **
trim_spaceのローカルメソッド化:**StringViewのメソッドにすると、Stringの値から呼ぶときに変換が要るので見送った。 - **
|と>の行の判定:** 束縛する値がないので、has_prefixの方が読みやすい。 - **個人ポリシー「主要な引数以外はラベル付きにする」との差:** 最初のパスでは、
el(name, children)のchildrenのような、役割の明らかな第 2 引数を位置引数のまま残した(escape_to(sb, s)はメソッドsb.write_escaped(s)になり、self の直後は位置引数でよいという精緻化の側に入った)。個人ポリシーはこれもラベル付きにする側なので、方針が分かれている。elは HTML を組み立てる DSL で、ノードごとにchildren=が入ると読みにくいため、そのままにしている。 - **
mbt checkの doc テスト:** 「テストとドキュメント」の節のとおり、まだない。
気づいたこと #
- catalog の多くは、警告ではなく「この問題にはこの機能がある」という当たりを付けるための知識だった。今回適用した 8 種類は、どれも警告が出ない。
- 文字列パターンは
Char単位で進み、s[i]やs[n:]は UTF-16 のコード単位で数える。リテラルで判定する形(今回の[.. "- ", .. rest]、[' ', .. inner, ' '])は、書き換えても結果が同じになる。一方、位置で数える形は変わる。s[2:]を[_, _, .. rest]にすると、BMP 外の文字が先頭にあるときに結果がずれ、警告も出ない。たとえば"😀ab"では、s[2:]は"ab"、パターンのrestは"b"になる(moon 0.1.20261012 で確認)。 - 配列への複合代入
items[i] += ".."も、今の toolchain で書けた。
追記: handbook に戻したもの #
このパスで得た知見のうち、skill に戻せるものを podhmo/moonbit-individual-handbook#63 に draft として出した。
handbook の modernize は、2 段階に分かれている。
- **浅い版:**
moonbit-modernizeの catalog と警告だけで進める。 - **深い版:**
language-changes-catalogを見て入念に見直す。
この使い分けはそのまま残し、浅い版には機械的に適用できるものだけを足した。
- **
moonbit-modernizeの Pitfalls:**- 3 部形式の functional-for で
continue eに値を渡すと、更新節は実行されない。 - 文字列パターンは
Char単位で進み、添字は UTF-16 のコード単位で数える。
- 3 部形式の functional-for で
- **
moonbit-modernizeの Step 2:** 深い版で見つけた書き換えのうち、設計の判断が要らないもの 6 種類(const、配列パターン、文字列パターン、for-in での分解、内包表記、テンプレート文字列)を足した。 - **
moonbit-modernizeの Step 3 とmoonbit-modernize-explore:**moon infoを毎回実行し、git diff --exit-code -- '*.mbti'で確かめる。moon ide find-referencesは、--targetを付けないとpreferred_targetしか解析しない。
深い版に残したのは、設計の判断が要る 2 つ。
- raise を既定にし、
Ok(..) catchの薄い層を分けること。 - 外部型へのローカルメソッド。
moon fmt が長い <+ の右辺を次の行に送る件は、見た目だけの話で Pitfalls の選定基準に合わないので、入れていない。
追記: opt-in の警告を moon.mod で有効にした #
moonc check -warn-help で off の警告を 1 つずつ moon.mod の warnings に入れ、moon check --target all で数えた(moon 0.1.20261012)。
件数があったのは unnecessary_annotation(148 件)と missing_doc(118 件)だけで、ほかは 0 件だった。
次の 8 つを warnings に入れた。今は 0 件なので、普段の moon check で新しく書いたコードの書き換えの候補だけが見える。
unnecessary_view_op、unqualified_record、unqualified_local_usingunused_optional_argument、unused_default_valueresult_error_return、ambiguous_range_direction、deprecated_for_in_method
入れなかったもの:
unnecessary_annotation:@pkg.を残す方針なので。missing_doc: 公開していない補助関数にも doc を求めることになるので。prefer_readonly_array、prefer_fixed_array: 移植では Go のスライスをArrayのまま持つので。missing_invariant、missing_reasoning、declaration_implemented: 手順の 3 と同じ理由で。
追記: 2 回目のパス #
1 回目のパスの後に、picoel、tinyfrontmatter、site の大部分(content.mbt、links.mbt、views.mbt)、repodocs、assets が加わった。
そこで、同じ skill を、同じく「仮想的に実行」する形でもう一度当てた。作業はブランチ moonbit-modernize-2 で行い、書き換えの種類ごとに 1 コミットにした。
| バージョン | |
|---|---|
| moon | 0.1.20261012 |
| moonbit-individual-handbook | 0c35763(#63 を取り込んだ後) |
結論 #
- 11 種類の書き換えを 12 コミット(for-in の分解は、拾い漏れを後から足したので 2 コミット)と、doc コメントの修正 1 コミットにした。挙動は変えていない。各コミットの前に
moon fmtとmoon infoを実行し、make check(wasm-gc と native のmoon check、moon test59 件、.mbtiの差分確認)を通した。 - opt-in の警告で見つかったのは
unnecessary_annotationの 90 件(すべて@el.)だけで、方針どおり書き換えなかった。今回の書き換えも、すべて skill の catalog と grep、.mbtiから見つけたもの。 - 公開 API が変わったのは
picoel(引数のラベル)とsite(Site::new→Site::Site、Siteを抽象型に)だけ。
Step 0: 全体の構造の確認 #
skill の Step 0 に従い、moonbit-refactoring の「Local refactoring」より前の観点で、新しいパッケージを先に見た。
- 依存関係は
worker→site→picoel/el/tinymarkdown/tinyfrontmatter/posts/repodocs/assetsの一方向で、循環はない。 siteは 4 ファイル、約 1,100 行。links.mbtは Go のnet/urlとpathの一部の移植を含むが、使うのはsiteだけなので、パッケージを分けるほどではない。- 公開 API のうち、
Siteのフィールド(by_slugなどの索引)は外から読まれていなかった。これは書き換えの 1 つとしてSiteを抽象型にした(9034696)。PostとDocのフィールドはテストが読むのでpub structのまま。
構造を変える必要はなかったので、そのまま局所的な書き換えに進んだ。
コミットの一覧 #
| commit | 種類 | 内容 |
|---|---|---|
| 6a30d6c | constructor | Site::new を Site::Site に。呼び出しは @site.Site(..) |
| e013948 | for-in の分解 | for c in xs { let (a, b) = c と、ヘッダの h.0 / h.1 を for (a, b) in xs に |
| 9255055 | functional-for | tinyfrontmatter の split(for rest, lineno と nobreak での raise)、unescape(continue i + 3 / continue end)、parse_ref の scheme の走査(break (scheme, rest))、post_name(for-in の累積変数 prev_dash) |
| 1d3b637 | core の関数 | check_date の桁の計算を Iter::fold に、制御文字の検査を Iter::any に、最小値を Int::min に、ホストの検査を contains_any(chars="/?#") に |
| a8e83bf | リスト内包 | load_docs の iter().filter(..).to_array() を [for f in files if .. => f] に |
| 9e99235 | 文字列パターン | Site のホストの取り出しを [.. "https://", .. h] | [.. "http://", .. h] に、parse_ref の rest[2:] を [.. "//", .. after] に |
| 2f1d019 | 範囲パターン | hex_value の to_int() の比較を '0'..<='9' などの範囲パターンに |
| 294221a | method | post_source、post_path、post_markdown_path、doc_path、doc_markdown_path を Post::source、Post::page_path、Post::markdown_path、Doc::page_path、Doc::markdown_path に。parse_ref は名前空間付きの constructor Ref::parse に |
| aea0dcb | ラベル付き引数 | meta_name(name, content~)、meta_property(property, content~)、icon(href, mime_type~)、download_link(href, filename~, children)、spread(start, end~)、page_header(info, href~, filename~) |
| 9034696 | 可視性 | pub struct Site を struct Site(抽象型)に |
| 09c2132 | for-in の分解 | e013948 で拾い漏れた、テストの c.0 / c.1 と、@posts.files / @repodocs.files の内包表記 |
| 3627e2a | 外部型へのローカルメソッド | escape と hex_escape_non_ascii で重複していた %XX の書き込みを StringBuilder::write_percent_encoded に |
| 569ae6b | doc コメント | front_matter_keys と doc_sources の doc コメントが FRONT_MATTER_KEYS / DOC_SOURCES と const のように書いていたのを、実際の名前に。配列は const にできないので let のまま |
方針を決めたもの #
1 回目のパスの方針(@pkg. は残す、ラベルは同じ型の引数が並ぶ箇所にだけ付ける、el の DSL の children は位置引数のまま)をそのまま使った。
- **ラベル:**
picoelのa(href, children)やtable(header, rows)は、引数の型が違うので取り違えようがなく、位置引数のままにした。 - **method の名前:**
Docにはフィールドpath(/docs/より下の URL のパス)があるので、ページの URL を返すメソッドはpathではなくpage_pathにした。Post::sourceは、Docのフィールドsourceと同じ意味(リポジトリ内のファイルのパス)になるように付けた。
見送ったもの #
- **
post_listとdoc_listのpushのループ。** 内包表記にすると[h1, ..[for p in posts => { .. }]]と入れ子が深くなり、元のループより読みにくかった。一度書き換えてから戻した。 - **
ogpのメソッド化。** 引数は呼び出し側で組み立てたレコード({ ..meta, title, description })で、メソッドにするとレコードリテラルにメソッドを続ける形になる。 - **
render_blocksのbs.map(b => ..)。** 内包表記と同じ意味で、書き換えても読みやすさは変わらない。 - **テストの複数行の文字列を
#|にすること。** Go のテストケースの文字列をそのまま移しているので、Go 版と見比べやすい今の形にした。 - **
workerのput_stringの添字のループ。** UTF-16 のコード単位を 1 つずつ host に渡すという意図が、添字のループで一番はっきり読める。
気づいたこと #
- **2 部形式の functional-for(
for i = 0; cond; { .. })で、本体の末尾までcontinueなしで落ちると、状態を変えないまま次の周回に入る。** ほかの分岐にcontinue eが 1 つでもあれば警告は出ず、無限ループになる(どこでも更新しないときだけunused_loop_variableの警告が出る)。splitを書き換えたとき、本体の最後にcontinue next, lineno + 1を置いたのはこのため(moon 0.1.20261012 で確認)。 - **
UInt16の値にも、Char のリテラルの範囲パターン('0'..<='9')がそのまま使える。**s[i] == '%'と同じく、リテラルの型がUInt16として推論される。hex_valueはto_char()を挟まずに書けた。 - **ラベル付きの引数を持つ関数は、ローカル変数に束縛して呼べない。**
let prop = @picoel.meta_propertyの後にprop(.., content=..)と書くと、エラー 4117「Function with labelled arguments can only be applied directly」になる。ogpは@picoel.meta_property(..)を直接呼ぶ形にした。 - **内包表記の
forも、for-in と同じくタプルのパターンを取れる。**[for (name, _) in @posts.files => name]と書けた。 - **for-in の分解は、1 つのコミットで拾い切れなかった。** 最初の grep は
for c in xs {の次の行のlet (..) = cだけを探していて、本体でc.0を直接読む形を見落とした。[^a-zA-Z_.][a-z_]+\.[0-9]\bで探し直して見つけた。
追記: language-changes-catalog を全項目当てはめたパス #
2 回目のパスの後、language-changes-catalog の references/language-changes.md(テーマ別カタログ)と references/about-idiomatic-code.md(個人ポリシー)を全項目コードに当てはめ、使えるものを適用した。
どこまで使えるかを試すための好奇心によるパスで、2 回目のパスと同じく書き換えの種類ごとに 1 コミットにした。
なお、2 回目のパスの「結論」に、書き換えはすべて skill の catalog、grep、.mbti から見つけたと書いた。これは正確ではない。3627e2a(外部型へのローカルメソッド)は、2 回目のパスの終わりに language-changes.md のテーマ別カタログを流し読みして見つけたもの。ただしそのときは、項目ごとの記録まではしていない。
適用したもの #
| commit | 出どころ | 内容 |
|---|---|---|
| add4ddc | 個人ポリシー「エラー設計」 | LoadError と SplitError の payload をラベル付きの message~ にし、メッセージだけを書く impl Show と derive(Debug) を足した。メッセージだけが欲しい catch は e => "\{e}" になった。自動で生えるメソッドには、el と同じく #deprecated を付けた pub extend を書いた |
| 7d31abc | パターンマッチング「Map パターン」 | match meta.get("title") { Some(t) if t != "" => .. } を match meta { { "title": t, .. } if t != "" => .. } に |
| 874c41b | パターンマッチング「正規表現マッチ」 | slug を判定する prev_dash のループを guard slug =~ re"^[a-z0-9]+(-[a-z0-9]+)*$" に |
| a7fd3fc | テスト・ドキュメント「mbt check」 | tinyfrontmatter.split の doc コメントに、moon test で実行される例を書いた |
| 0effb6d | 個人ポリシー「テストの置き場」 | clean、dir、join のテストを、site_wbtest.mbt から実装の隣(links.mbt)のインラインテストに移した |
| 0aae5c5 | 関数定義・呼び出し「カスケード ..」 | el の write_html で属性を書く 3 回の書き込みを sb..write_string(..)..write_escaped(v).write_char('"') に |
| 46ae520 | 文字列「複数行文字列 #|」 | site_test.mbt で共有している Markdown のフィクスチャを #| に |
どのコミットも、make check と moon check --target all --deny-warn を通した(テストは 60 件)。
当てはまらなかったもの、見送ったもの #
- **使う場面がなかった:**
defer/errdefer、<?、ラベル付きブロック、[| .. |]、ビット列パターン、or-pattern のwith、extenum、ローカル型、phantom type、using、#as_free_fn、#non_exhaustive。 - **
lexscanによるtinymarkdownのインライン解析の書き直し:** 1 回目のパスと同じく、書き直しが大きいので見送った。 - **
PostとDocの一部のフィールドをprivにすること:** 外から読まれていないフィールドもあるが、読み取り専用のデータとして公開しておく方が自然なので見送った。 - **個人ポリシー「主要な引数以外はラベル付きにする」:**
el/picoelの DSL の子要素は位置引数のまま、という 1 回目のパスの方針を変えていない。 - **表形式のテストケースの
#|化:** 1 行に 1 ケースを並べた方が入力を見比べやすいので、#|にしたのは文書まるごとのフィクスチャだけ。
気づいたこと #
- **
=~のコストは小さかった。** release のworker.wasmは 234,123 バイトから 234,164 バイトになり、41 バイトしか増えなかった。 - **Map パターンの値の位置には、値そのものを書く。**
{ "title": Some(t) }と書くと型エラー(4014)になり、{ "title": t, .. }と書く。 - **suberror の
impl Showで、logger <+ messageは parse error になる。** skill の Pitfalls にあるとおり、<+の右辺はテンプレート文字列でなければならない。logger.write_string(message)と書いた。Show のメソッドにも警告implicit_impl_as_methodが出るので、pub extend .. with Show::{to_string, output}も足した。 - **カスケードの最後の呼び出しを
..にすると、警告deprecated_syntaxが出る。** 結果を黙って捨てることになるため。最後だけ.にする。 - **doc コメントの
mbt checkの例は、moon testのテストとして数えられる。** 期待値をわざと壊すと失敗することも確かめた。 - **
moon fmtは、タプルの中の#|のブロックを括弧で包む。** 値は元の\nを使った文字列と同じで、最後の行の後に改行は付かない。一時的なテストで、元のリテラルと等しいことを確かめた。 - **作業の途中まで、検証用のスクリプトが警告を見落としていた。**
make checkのmoon checkは--deny-warnを付けていないので、警告があっても失敗しない。途中から、警告が出たら失敗するようにスクリプトを直した。直した後のスクリプトで、HEAD に警告がないことを確かめた。
handbook に戻したもの #
podhmo/moonbit-individual-handbook#64 に draft として出した。
2 回目のパスで得たものは、次の 3 つ。
- 更新節のない 2 部形式の functional-for で、
continueを書き忘れた経路があると、警告なしで無限ループになるという Pitfall。 UInt16のコード単位にも、Char のリテラルの範囲パターンが使えること。- 内包表記もタプルのパターンを取れること。
c.0/c.1を読む形も、for-in の分解の候補になること。
このパスからは、初めて読む人にも読みやすく、コードの意味もはっきりする 2 つを、moonbit-modernize の Step 2 に足した。
- 文字列の形を確かめるだけのループを、正規表現
=~に。 - 文書まるごとの
\n入りのリテラルを、#|に。
ほかは次の理由で入れていない。
- Map パターンとカスケード: 元の書き方より読みやすくならないか、初見で分かりにくい規則がある。
- suberror の
impl Show、インラインテスト、mbt check: 設計やテスト方針の判断を含むので、カタログと個人ポリシーの側に置いたままにした。
追記: 3 回目のパス #
2 回目のパスの後に、Worker のアダプタが汎用の http ライブラリ(pkg/http、pkg/http/wasm)に分かれ、その使用例の examples/echo が加わり、ライブラリが pkg/ に、ブログが examples/blog/ に移った。
そこで、同じ skill を、同じく「仮想的に実行」する形でもう一度当てた。作業はブランチ moonbit-modernize-3 で行い、書き換えの種類ごとに 1 コミットにした。
| バージョン | |
|---|---|
| moon | 0.1.20261012 |
| moonc | v0.10.15(2026-10-12) |
| moonbit-individual-handbook | 0c35763 |
結論 #
- Step 0 の構造の確認で公開 API を 2 コミット分減らし、そのあと 12 種類の書き換えを 1 コミットずつ適用した。挙動は変えていない。各コミットで
moon fmt、wasm-gc と native のmoon check --deny-warn、moon test、moon infoを通し、最後にmake checkと、Node 上で実際の Wasm を動かすmake test-echo(9 件)も通した。テストは 76 件から 77 件になった。 - 対象はほぼ
pkg/httpとpkg/http/wasmだった。tinymarkdownの入れ子のリスト(nest_items)など、ほかに加わったコードは、すでに functional-for や内包表記で書かれていた。 - opt-in の警告で見つかったのは
unnecessary_annotationの 188 件(wasm-gc と native で重複して数えた件数)だけで、方針どおり書き換えなかった。 - 公開 API の変更は
http(constructor の名前、value~のラベル、BadRequestの payload、parse_multipartを非公開に)とhttp/wasm(decode_requestとencode_headを非公開に)。pkg.generated.mbtiの diff がそのまま API の変更点になっている。docs/sketch/ja/moonbit-workers-http.mdの API の一覧も合わせて直した。 - release の Wasm(wasm-opt 後)は、ブログの
app.wasmが 330,935 バイトから 331,067 バイトに、echo のecho.wasmが 92,613 バイトから 92,750 バイトになった(どちらも 140 バイト弱の増加)。
Step 0: 全体の構造の確認 #
今回は Step 0 を省かずに行い、構造の問題があればコミットとして直すことにした。
- **パッケージの大きさ:** どのパッケージも 1,000 行前後以下、関数も 200 行を大きく下回る。分ける必要はない。
- **依存関係:**
pkg/の下のパッケージはexamples/を参照しない。examples/blog/worker→site→pkg/*と、examples/echo→pkg/http/wasm→pkg/httpの一方向で、循環はない。 - **公開 API:**
moon ideの代わりに grep でpubの呼び出し元を数えた(native 専用のcompare/cmdも含めるため)。http/wasmのdecode_requestとencode_headは、パッケージの外からはテストしか呼んでいなかった。アプリが使うのはhandleだけなので、非公開にし、テストを white-box テストにした(63a7e68)。httpのparse_multipartはRequest::formからしか呼ばれていなかったので非公開にした(c79bfd0)。media_typeとparse_valuesも外からはテストしか呼ばないが、Go のmime.ParseMediaTypeとurl.ParseQueryに当たる汎用の部品で、ハンドラが自分で Content-Type やクエリを読むときに使えるので公開のままにした。
- **
experiments/:** 別のモジュールで、計測を再現するためのスパイクなので、skill の判断基準(古い形そのものが目的のコードは書き換えない)に従って書き換えの対象から外した。ただしmoon.workでこのモジュールを今の API のまま import しているので、API を変えた結果、wasm-fetchの@http.Router::new()が通らなくなった。README の「今の API に合わせる必要のあるところだけ直す」に従い、constructor のコミットで@http.Router()に直した。
コミットの一覧 #
| commit | 種類 | 内容 |
|---|---|---|
| 63a7e68 | 公開 API | http/wasm の decode_request と encode_head を非公開に。テストは wire_wbtest.mbt に |
| c79bfd0 | 公開 API | http の parse_multipart を非公開に |
| d352669 | constructor | Headers::new、Request::new、Response::new、Router::new を Type::Type に。呼び出しは @http.Router(..) など |
| f427e68 | ラベル付き引数 | 文字列を 2 つ取る Headers::add、Headers::set、Response::header の値を value~ に |
| fc6f656 | functional-for | decode_request の head の終わりの探索(for-in の累積変数と break Some(i + 1))、parse_multipart のパートの走査(break を条件に移し、continue next_pos)。名前のないパートを飛ばす経路のテストを足した |
| 997af2f | 文字の反復 | http/wasm の write が手で組み合わせていたサロゲートペアを、for ch in s に任せた。書き換えの前に、孤立したサロゲートを含む出力のバイト列を固定する white-box テストを足した |
| dda8df1 | let mut の除去 | Request::Request の rest / host と、media_type の v を、match / if の式で束縛 |
| f82f03e | リスト内包 | get_all の filter(..).map(..)、media_type のパラメータ、Router::serve のセグメント、echo の values_json |
| 356dcc2 | 配列パターン | media_type の空の確認と parts[0] / parts[1:] を [ty, .. rest] に |
| 2e1f48e | core の関数 | search_by で添字を取ってから params[i].1 を読む形を、iter().find_first に |
| 3db37a8 | core の関数 | more_specific の手書きの最大値を Int::max に |
| 9dea2ca | <+ | encode_head の write_string / write_char を <+ に |
| 7146e9e | const | echo の let page(`# |
| fed60c9 | エラーの設計 | BadRequest(String) を BadRequest(message~ : String) derive(Debug) に。site の LoadError や tinyfrontmatter の SplitError と同じ形 |
| b8b5a36 | カスケード | Response::header を -> Self から -> Unit に。呼び出しは ..header(..) でつなぐ |
方針を決めたもの #
- **ラベル:** これまでのパスと同じく、同じ型の引数が並ぶ箇所にだけ付けた。
Response::text(status, body)やRouter::handle(pattern, handler)は型が違うので位置引数のまま。Response::typed(status, content_type, body)も、Bodyの値がText(..)/Binary(..)と書かれるので、content_typeとの取り違えは型で分かる。 - **constructor の呼び出し:** skill のとおり
@http.Router()と型名だけで書く。doc コメントとテスト名はRouter::Routerと書いた。Siteの private なメソッドSite::new_routerは「新しい router を作る」という意味の名前なので、そのままにした。
- **破壊的更新は
Unitを返し、カスケード..でつなぐ。**Response::headerはself.headersにpushしてから同じselfを返していたので、新しい値を返すように読めた(let b = a.header(..)でaも変わる)。新しい値を返すにはヘッダの配列を毎回複製する必要があり、ハンドラの中で 1 回組み立てるだけの使い方には合わないので、-> Unitにした。-> Selfのメソッドを..でつなぐと、警告invalid_cascadeが出る。この書き換えは、パスの後のレビューで指摘を受けて足した。
見送ったもの #
- **
decode_requestの、ヘッダと env を分けるループ。**let mut in_envが 1 つだけで、functional-for にするとすべての経路の最後にcontinue in_envを書くことになる。最初の空行の位置で配列を分ける書き方も考えたが、空行が必ず 2 つあることを読み手が知っていないと追えない。 - **
Router::handleのセグメントの組み立て。** 最後の空のセグメントはsubtreeを立てるだけで、配列には入らない。内包表記にすると、この特別扱いが別の場所に分かれる。 - **
Route::match_pathの末尾のif/else ifの連鎖。** 3 つの条件が別々のものを見ているので、matchにしても読みやすくならない。 - **
Headers::getとValues::getのreturnするループ。**find_first(..).map(p => p.1)と書けるが、for-in の分解で名前の付いた今の形の方が読みやすい。 - **echo の
form.files.map(f => { .. })。** 2 回目のパスのrender_blocksと同じく、内包表記にしても読みやすさは変わらない。 - **
Map([])。**let params : Map[String, String] = {}と書けるが、型の注釈が増えるだけ。
気づいたこと #
- **文字列の for-in は、サロゲートペアを 1 つの
Charにまとめ、孤立したサロゲートはそのまま 1 つのCharとして返す。**writeが手で行っていた処理と同じ結果になる(孤立した U+D800 はED A0 80の 3 バイトになる)。white-box テストで、書き換えの前と後の両方で確かめた(moon 0.1.20261012)。 - **3 部形式の functional-for の条件に、本体の先頭の
breakを移せる。**parse_multipartはwhile trueの先頭で--を見て抜けていたので、for pos = ..; !body[pos:].has_prefix(b"--"); { .. }と書けた。本体の最後のcontinue next_posを忘れると、2 回目のパスの Pitfall と同じく警告なしで無限ループになるので、名前のないパートを飛ばすcontinueの経路にテストを足した。 - **
matchの一方の腕がビューのとき、もう一方の腕のurl[:]にはunnecessary_view_opが出る。** 期待される型がビューになるので、urlだけでよい。 - **
experiments/は、ルートのmoon checkにもmake checkにも入らない。** 最初はこれに気づかず、全体を検証した後に README を読んでwasm-fetchが壊れていることを見つけた。moon.workでこのモジュールを参照する別のモジュールがあるときは、公開 API を変えたら、その中でもmoon checkを実行する必要がある。その後、make checkがexperiments/の各モジュールでもmoon checkを実行するようにした。 - **作業の途中まで、検証用のスクリプトが
moon checkの失敗を見落としていた。** 出力をtailに渡していたので、set -eがあっても失敗が伝わらなかった。上の警告で気づき、pipefailを付けて直した。それより前のコミットは、出力に警告もエラーもなかったことを確かめてある。