Slide 1

Slide 1 text

Xcodeの「Apply」ボタンの裏側 〜Swiftコンパイラの fix-it の仕組み〜 株式会社ZOZO ZOZOTOWN開発本部 ZOZOTOWN開発1部 iOSブロック 小松 悟 Copyright © ZOZO, Inc.

Slide 2

Slide 2 text

小松 悟 @tosh_3 ZOZOTOWNのiOSアプリを作っています 最近は開発環境の基盤整備をメインで担当しています iOSDC 登壇履歴 2020年 LT「100人以上の中高大学生にiOSアプリ開発を教えていて感じたこと」 2023年 LT「WWDC Labは怖くない。labの準備とコツ、完全公開します」 © ZOZO, Inc. 2

Slide 3

Slide 3 text

例1: protocol 準拠のスタブ生成 protocol HogeProtocol { func hoge() } class Hoge: HogeProtocol { } 本資料のデモは全てXcode26.6 - Swift 6.3.3で行ったものになります。 © ZOZO, Inc. 3

Slide 4

Slide 4 text

Xcodeではこう見える © ZOZO, Inc. 4

Slide 5

Slide 5 text

Apply を押すとこうやって直る protocol HogeProtocol { func hoge() } class Hoge: HogeProtocol { func hoge() { code } } © ZOZO, Inc. 5

Slide 6

Slide 6 text

例2: Optional のアンラップ func greet(name: String?) { let message: String = name print("Hello, \(message)") } © ZOZO, Inc. 6

Slide 7

Slide 7 text

「2択」のApply ボタン 2つの選択肢の文言の意味 1 Coalesce using '??' — name ?? <#default value#> に書き換える nil だったときの代替値を用意して、処理を続行する修正 Force-unwrap using '!' — name! に書き換える 2 nil ではないと断言し、nil だったら実行時にクラッシュする修正 同じエラーに対して、意味の異なる直し方が2つ 提示されている © ZOZO, Inc. 7

Slide 8

Slide 8 text

では、Applyボタンの裏側を 見てみましょう © ZOZO, Inc. 8

Slide 9

Slide 9 text

コンパイラはこれを「fix-it」と呼んでいる Apply ボタンの修正提案 = fix-it (コンパイラが生成するデータ) — Apply ボタンで出てくる修正提案は、Xcode の独自機能ではない — コンパイラが警告・エラーに添えて生成するデータで、fix-it と呼ばれる — Xcode はそれを受け取って表示・適用しているだけ © ZOZO, Inc. 9

Slide 10

Slide 10 text

エディタは何を受け取っているのか key.diagnostics: [ { key.id: "unwrap_with_default_value", key.description: "coalesce using '??' to provide a default ...", key.fixits: [ { key.offset: 487, key.length: 0, key.sourcetext: " ?? <#default value#>" } ] }, { key.id: "unwrap_with_force_value", ... key.sourcetext: "!" } ] エディタに見えているもの ボタンのラベル = key.description その裏で届いている実データ ボタンの動作 = key.fixits fix-it 付き note が複数届くと、Apply ボタンも複数表示される © ZOZO, Inc. 10

Slide 11

Slide 11 text

fix-it = source range + replacement text FixIt = { どこを: offset / length, 何に: sourcetext } Swift コンパイラでの実際の定義 /// Represents a fix-it, a replacement of one range of text with another. class FixIt { CharSourceRange Range; ← どこを (source range) std::string Text; ← 何に (replacement text) ... }; Apply ボタンが押されたとき Xcode がやるのは、この Range をこの Text で置き換えるだけ 参照: https://github.com/swiftlang/swift/blob/3fdf46f7ea815a7e7afda6dca4437a33bbca09f3/include/swift/AST/DiagnosticConsumer.h#L65 © ZOZO, Inc. 11

Slide 12

Slide 12 text

コンパイルのフェーズ ① Parse ★ ソーステキスト → AST (構文木) ② Sema ★ 型チェック — fix-it の主産地 ③ SILGen 中間表現 (SIL) の生成 ④ SIL Optimizer ★ SIL の解析・最適化 コードの並びだけで直せる(例: 'hello' → "hello" のクォート修正) 型を見ないと決められない(例: Optional の ??) 実行経路を追わないと分からない(例: return 忘れ) © ZOZO, Inc. ⑤ IRGen LLVM IR の生成 ⑥ LLVM 機械語生成 12

Slide 13

Slide 13 text

具体例: Sema が fix-it を生成するまで let message: String = name 1 名前解決 — name は引数 (String?) だと判明 2 制約生成 — 「String? は String に変換可能」という制約を作る 3 制約充足 — 解けない (T? → T の変換は存在しない) 4 再挑戦 — 「修理 (fix)」を許可してもう一度解く → アンラップすれば解けると発見 5 修理内容がそのまま fix-it になる © ZOZO, Inc. 13

Slide 14

Slide 14 text

コンパイルしていないのに、なぜ? fix-it はコンパイラの機能のはず。なのに Xcode でコードを書いているとき、 コンパイルを実行していないのに fix-it が出てくる © ZOZO, Inc. 14

Slide 15

Slide 15 text

Xcode に SourceKit が常駐している エディタに入力する ↓ 編集差分だけを送信 ────────▶ Xcode ◀──────── SourceKit └─ Parse → Sema → SILGen → SILOptimizerの診断部分 を実行 診断 + fix-it を返却 ↓ エディタに赤線と Fix ボタンが出る — SourceKit が独自の解析エンジンを持っているのではなく、コンパイラ本体と同じコードで解析している © ZOZO, Inc. 15

Slide 16

Slide 16 text

実際の会話ログ $ SOURCEKIT_LOGGING=3 /Applications/Xcode.app/Contents/MacOS/Xcode リクエスト (Xcode → SourceKit) 18:10:03 { ← ファイル全文を渡して解析開始 レスポンス (SourceKit → Xcode) { key.request: source.request.editor.open, key.diagnostics: [ { key.name: ".../Sample.swift", key.description: "value of optional type 'String?' must be ...", key.sourcetext: "//\n// key.diagnostics: [ { Sample.swift\n..." } key.description: "coalesce using '??' ...", key.fixits: [ { 18:10:15 { ← 変更箇所を送る key.offset: 487, key.request: source.request.editor.replacetext, ... key.length: 0, } key.sourcetext: " ?? <#default value#>" } ] 18:10:17 { ← 診断結果をリクエスト }, ... ] key.request: source.request.diagnostics, ... } ← Fix ボタンの中身 } ] } — シンタックスハイライトや補完等の表示も、全て SourceKit から出力される — fix-it も例外ではなく、diagnostics レスポンスに含まれるフィールドの1つ © ZOZO, Inc. 16

Slide 17

Slide 17 text

Swift 公式ガイドライン — Diagnostics.md「fix-it」全ルール — fix-it は複数行にまたがれるが、すべての環境で表示されるとは限らない — fix-it は、添付先の診断と同じファイル内になければならない — エラー / 警告本体に付ける fix-it は「唯一・明白・ほぼ確実に正しい」修 — note に fix-it を付けるなら、文言は「 fix-it が行う操作と、その理由 」を 正だけ 説明する — fix-it 付き note が複数あるときは選択肢として扱う。 最初の選択肢は最も — 本体に fix-it が付いているなら、その note には fix-it を付けない 安全なもの にする — 警告を黙らせる手段として「括弧を追加」より良い方法を探す — 必要ならプレースホルダ <#...#> を使う。 fix-it を出さないより、プレ ースホルダ入りで出す方が良い — 綴りは「fix-it」が正 (camelCase では FixIt / fixIt) 診断の種類(同ドキュメント「Errors vs. Warnings」より) — error: 不正なコード。ビルドが止まる — warning: 意図は明確だが直すべきコード。ビルドは通る — note: error/warning に付属する補足。原因の場所や fix-it の選択肢を示す 参照: https://github.com/swiftlang/swift/blob/main/docs/Diagnostics.md © ZOZO, Inc. 17

Slide 18

Slide 18 text

曖昧さゆえに出せない例 — 修正候補が一意に決まらない let totalPrice: Double = 59.5 let itemCount: Int = 12 let average = totalPrice / itemCount error: binary operator '/' cannot be applied to operands of type 'Double' and 'Int' note: overloads for '/' exist with these partially matching parameter lists: (Double, Double), (Int, Int) ← 候補は2つに絞れているのに fix-it なし — Double(itemCount) なら小数の割り算、Int(totalPrice) なら整数の割り算 → 結果の値が変わる — average の型はこれから推論される ものなので期待型が存在せず、どちらに変換すべきかを決定できない → fix-it は出ない © ZOZO, Inc. 18

Slide 19

Slide 19 text

型を書くと fix-it が出る — 修正の方向が一意に決まる average に型を書いて、修正の方向が一意に決まる文脈にすればいい: let average: Double = totalPrice / itemCount error: cannot convert value of type 'Int' to expected argument type 'Double' let average: Double = totalPrice / itemCount ^ Double( ) ← 型を書いただけで fix-it が出る — average: Double と書いたことで期待型が固定され、itemCount を Double に変換する方向しかなくなる 同じ式でも「修正が一意に決まるか」で、fix-it を出す / 出さないが分かれている © ZOZO, Inc. 19

Slide 20

Slide 20 text

なぜ誤った修正案を出してくるのか Apply を押すと—— protocol P { func f() struct S: P { } func f() { code struct S: P { func f() throws {} } // throws が余計で準拠を満たせない } func f() throws {} // error: invalid redeclaration of 'f()' error: type 'S' does not conform to protocol 'P' } note: candidate throws, but protocol does not allow it ← 原因は正確に言い当てている note: add stubs for conformance ← でも出せる fix-it はこれだけ — コンパイラは「throws が原因」と分かっているのに、欲しい修正 (throws を消す) の fix-it が存在しない — 代わりに出るスタブ追加を適用すると、throws の有無だけ違う f() が並び、確実にコンパイルエラーになる © ZOZO, Inc. 20

Slide 21

Slide 21 text

ここまでのまとめ — fix-it = source range + replacement text。Apply は range を text で置き換えるだけ — fix-it は Parse, Sema, SILOptimizer のフェーズで生まれる — エラー / 警告本体に付くのは「唯一・明白・ほぼ確実に正しい」修正だけ — 一方で、原因が分かっているのに fix-it が出ないケースが実際にある © ZOZO, Inc. 21

Slide 22

Slide 22 text

実際に fix-it 関連で コントリビュートした話 © ZOZO, Inc. 22

Slide 23

Slide 23 text

実際に直した話 ① — 「Replace 'nonisolated' with 'nonisolated'」 computed property やメソッドに付けた nonisolated(unsafe) が 無意味なとき、コンパイラの提案がこれ: Replace 'nonisolated' with 'nonisolated' …同じものに置き換えろ?? — 何を直してほしいのか読み取れない。押しても何も変わらない fix-it — 意図: nonisolated(unsafe) → nonisolated に置き換えたか った 参照: https://github.com/swiftlang/swift/pull/88086 © ZOZO, Inc. 23

Slide 24

Slide 24 text

①の原因 — range の取り方を1箇所間違えていた // lib/Sema/TypeCheckAttr.cpp — visitNonisolatedAttr() 内 diag.fixItReplace(attr->getStartLoc(), "nonisolated") ← nonisolated キーワードだけを指す range diag.fixItReplace(attr->getRange(), "nonisolated") ← nonisolated(unsafe) 全体を指す range(修正後) — range の取り方を1つ間違っていたため「X を X に置き換えろ」という状態になっていた — 修正は1行。 Before: Replace 'nonisolated' with 'nonisolated' After: © ZOZO, Inc. Replace 'nonisolated(unsafe)' with 'nonisolated' 24

Slide 25

Slide 25 text

実際に直した話 ② — Apply すると finalclass が生まれる class MyClass: Sendable {} // warning: non-final class 'MyClass' // cannot conform to 'Sendable' ↓ Apply finalclass MyClass: Sendable {} // コンパイルエラー! — fix-it を押した結果、壊れたコードが生まれる — エディタは意味を理解せずテキストを貼るだけ — だからコンパイラ側 の text が1文字欠けるとそのまま壊れる 修正前: 実際に出ていた「Insert 'final'」 参照: https://github.com/swiftlang/swift/pull/88180 © ZOZO, Inc. 25

Slide 26

Slide 26 text

②の原因 — スペースが不足 // lib/Sema/TypeCheckConcurrency.cpp .fixItInsert(classDecl->getStartLoc(), "final") ← 末尾にスペースがない .fixItInsert(classDecl->getStartLoc(), ← 末尾にスペースを追加 (修正後) "final ") — 既存テストは診断メッセージしか検証していなかったので、壊れた fix-it がすり抜けていた © ZOZO, Inc. 26

Slide 27

Slide 27 text

直そうとしたら指摘が入った話 スライド20の「本当に欲しい修正は throws を消すこと」 throws を書くと準拠エラーになる (Xcode 実画面) note「Candidate throws…」に自作 fix-it が Apply として出る — TypeCheckProtocol.cpp の ThrowsConflict 診断に fixItRemove を添付 — protocolに準拠できるようにthrowsを削除するfix-itを追加 参照: https://github.com/swiftlang/swift/pull/88268 © ZOZO, Inc. 27

Slide 28

Slide 28 text

レビュワーからの指摘 実際に入ったレビューコメント — 本体に throw する処理があるなら、throws を消すと実装が壊れ、さらに多くのエラーを生むのでは? — 自分のテストケースが「エラーは消える」ことしか見ていなかった。適用後のコードが正しくあり続けるかが問われた — 対応方針: 本体に try / throw が無いときだけ fix-it を出すガードを追加予定 © ZOZO, Inc. 28

Slide 29

Slide 29 text

直した後の検証方法 — 3段構え 方法 確認できること 1回の確認にかかる時間 1. CLI で見る 診断が出ること・文言 (fix-it の中身までは見えない) 差分ビルド数分 2. テストを書く fix-it の中身 (range と置換テキスト) が正しいこと 差分ビルド数分 3. Xcode に差し替え Xcode 上で動作できる 数時間 (毎回フルビルド必須) © ZOZO, Inc. 29

Slide 30

Slide 30 text

方法 1. CLI で見る — ビルドしたコンパイラを直接叩く $ ~/build/.../swift-macosx-arm64/bin/swiftc -typecheck test.swift — 修正入りの swift-frontend で -typecheck → 診断が出ること・文言を数秒で確認できる — ただし fix-it の中身 (range と置換テキスト) までは表示されない。実際の見え方: error: value of optional type 'String?' must be unwrapped to a value of type 'String' let message: String = name |- note: coalesce using '??' to provide a default | when the optional value contains 'nil' `- note: force-unwrap using '!' to abort execution if the optional value contains 'nil' — 文言 (note) は見えるが、?? <#default value#> という挿入テキストや位置はどこにも出ない — fix-it の適用も CLI ではできない(-fixit-allはあるがobsoleteオプション) © ZOZO, Inc. 30

Slide 31

Slide 31 text

方法 2. テストを書く — -verify モードと fix-it 期待値 // expected-warning@+1 {{'nonisolated(unsafe)' has no effect on ...}} {{3-22=nonisolated}} nonisolated(unsafe) func nonisolatedUnsafe(otherActor: MyActor) -> Int { } ↑ 3文字目 ↑ 22文字目 — {{開始桁-終了桁=置換テキスト}} が fix-it の期待値。位置とテキストを桁単位で検証する — 一致すれば成功、不一致なら失敗 — 実際の Swift コンパイラのテストも、この方式で書かれている © ZOZO, Inc. 31

Slide 32

Slide 32 text

方法 2 の実例 — 修正がテストの数字にそのまま現れる PR #88086 のテスト差分 (range を直した) - {{3-14=nonisolated }} ← 3〜14桁 (11文字) = "nonisolated" (11文字) だけを置換していた + {{3-22=nonisolated }} ← 3〜22桁 (19文字) = "nonisolated(unsafe)" (19文字) 全体を置換 PR #88180 のテスト差分 (期待値を新設した) - // expected-warning @+1 {{non-final class 'Klass' cannot conform ... }} + // expected-warning @+1 {{non-final class 'Klass' cannot conform ... }} {{1-1=final }} — #88180 では、既存テストは診断の文言しか検証していなかった → fix-it の中身が壊れていても通っていた © ZOZO, Inc. 32

Slide 33

Slide 33 text

方法 3. Xcode にコンパイラを差し替える — ツールチェーンを作る — ツールチェーン (.xctoolchain) = コンパイラ・標準ライブラリ・SourceKit など、 ビルドに使う道具一式のパッケージ — Xcode は使うツールチェーンをメニューから切り替えられる → 自分でビルドしたコンパイラを差し込める $ ./swift/utils/build-toolchain com.example # .xctoolchain 一式を作る (数時間) $ tar -xzf swift-LOCAL-*-osx.tar.gz -C ~/ # → ~/Library/Developer/Toolchains/ へ展開 Toolchains メニューで自分のツールチェーンに切り替え © ZOZO, Inc. 33

Slide 34

Slide 34 text

方法 3-2. 実際に適用して動作確認 自分の書いた fix-it が Apply ボタンとして出る (直した2件) Xcode26.6 - Swift 6.3.3 Xcode26.6 - Swift 6.3.3 自作ツールチェーン 自作ツールチェーン 提案が「Replace 'nonisolated(unsafe)' with ‘nonisolated'」に。 挿入テキストがスペース込みの "final " に。 Apply しても finalclass にならない range が (unsafe) まで含む正しい置換になった © ZOZO, Inc. 34

Slide 35

Slide 35 text

まとめ Apply ボタンの正体 Swift に Contribute しよう! — fix-it = コンパイラが診断に添える「 どこを (range) + 何に — fix-it は対応しやすい (text) 」という情報 — fix-it は唯一・明白・ほぼ確実に正しい修正の場合にしか — テストも書きやすい — 不具合を実際に Xcode で確認できる 出さない — fix-it は Xcode に常駐する SourceKit がエディタへ届ける。 だからビルドしなくても、タイプした瞬間に表示される © ZOZO, Inc. 35

Slide 36

Slide 36 text

No content