Coding AgentがSwift Macroを展開するために

こんにちは、レシピ事業部の山田(@0x746572616e79)です。

AIコーディングの精度向上には、プロンプトを工夫することが有効な時期がありました。最近では、限られたコンテキストに何をどのタイミングで入れるかを設計する「コンテキストエンジニアリング」という考え方が注目されています。私たちのチームでも、長大なガイドラインを読ませるのではなく、AIが必要な時に正確なコンテキストを取得できる仕組み側の整備を進めています。

本記事ではその一例として、Swift Macroの展開結果をCoding Agentに渡すMCPサーバーを紹介します。

テスト用コードをマクロで宣言的に生成する

前提として、私たちのテスト基盤の話から始めます。以前、テスト用のモックは次のような手書きのクラスでした(説明用に簡略化しています)。

final class MockUserService: UserServiceProtocol {
    private(set) var fetchUserNameArgs: [Int] = []
    var fetchUserNameResult: String = ""

    func fetchUserName(id: Int) -> String {
        fetchUserNameArgs.append(id)
        return fetchUserNameResult
    }
}

プロトコルにメソッドが増えるたびにすべてのMockへ似たようなコードを書き足すことになり、退屈な割にミスが混入しやすい作業でした。そこで実装したのが、テスト用のモックコードを自動生成する @AutoMock というSwift Macroです。プロトコルに付与すると、コンパイル時にモッククラスが生成されます。

@AutoMock
protocol UserServiceProtocol: AnyObject {
    func fetchUserName(id: Int) -> String
}

これが以下のように展開されます。

class MockUserServiceProtocol: UserServiceProtocol {
    enum Invocation {
        case fetchUserName(id: Int)
        ...
    }
    private(set) var invocations: [Invocation] = []
    var fetchUserNameResult: String = ""
    var fetchUserNameStubHandler: ((Int) -> String)?
    func fetchUserName(id: Int) -> String {
        invocations.append(.fetchUserName(id: id))
        if let stubHandler = fetchUserNameStubHandler {
            return stubHandler(id)
        }
        return fetchUserNameResult
    }
}

呼び出し履歴の記録(invocations)、返り値の差し替え(fetchUserNameResult)などテストに必要な機能一式が揃ったモックが、宣言側の1行で定義されます。

同様に、値型のテストデータ生成には @AutoFake というマクロを使っています。struct/enumに付与すると fake() メソッドが生成されます。

@AutoFake
struct User {
    let name: String
    let age: Int
    let email: String
}

// ↓ 展開結果(抜粋)
extension User: AutoFakeable {
    static func fake(
        name: String = "", age: Int = 0, email: String = ""
    ) -> Self {
        Self(
            name: name, age: age, email: email
        )
    }
}

テストコードでは mock.fetchRecipesResult = [Recipe.fake()] のように組み合わせて使います。開発者にとってもAIにとっても、書き換えが必要なコードの量が大幅に減りました。ちなみに、既存の手書きMockから @AutoMock への移行作業自体もCoding Agentに任せて進めました。

マクロ展開後のコードが見えない

このマクロ群には、AIコーディングと組み合わせたときに固有の問題がありました。Swift Macroはコンパイル時に展開されるため、Coding Agentには展開後のコードが見えません。

Coding Agentがテストを書くには、MockUserServiceProtocol にどんなプロパティが生えているか、Invocation のcase名が何になるかを知る必要があります。最初はプロジェクトで管理しているガイドラインのMarkdownに生成ルールを文章で記述するアプローチを取りました。当時のガイドラインからcase命名規則の一部を抜粋します。

caseの命名規則: - 原則はメソッド名と同じ名前です - protocol名が InteractorDelegate, InteractorUIDelegate, ViewControllerDelegate というsuffixを持つ場合、protocol名から Delegate または UIDelegate を除いた名前をメソッド名から取り去ったものを、camelCase にしたものになります - メソッド名がプロトコル名の短縮形と完全一致する特殊ケース: (中略)sender object を除いた最初のパラメータの外部ラベルが case 名になります - オーバーロードメソッドの場合: 同じ名前のメソッドが複数ある場合、case 名の末尾にすべてのパラメータの外部ラベルを UpperCamelCase で連結したものを付加して区別します

人間が読んでも一度では理解しづらい規則です。マクロの仕様が増えるたびにガイドラインも育ち、最終的に445行に達していました。そしてCoding Agentはこの文章を読んでもしばしば誤認識し、次のようなコンパイルエラーを頻発させていました。

// Agentが生成したコード(誤)
#expect(delegate.invocations == [.sampleInteractorDidCancel])

// 実際に展開されるcase名(正)
#expect(delegate.invocations == [.didCancel])

エラーを見たCoding Agentがビルドを回して修正を試み、また別の箇所で誤認識する。このループはトークンも時間も浪費します。仕様を文章で説明して推測させるアプローチには限界があります。

マクロを展開するMCPサーバーをつくる

そこで発想を変え、仕様書を読ませて展開結果を推測させるのではなく実際に展開した結果そのものをAgentに渡す方針に切り替え、Swift Macroを展開してその結果を返すMCPサーバー「MacroExpanderMCP」を実装しました。

macroターゲットはimportできない

先ほどのセクションで登場したAutoFake, AutoMock共に役割は異なりますが構成はほとんど同じであるため、説明を一部省略するために以降はAutoMockマクロにフォーカスして話を進めます。

前提として、AutoMockは大きく2つのターゲットで構成されます。@AutoMock のようなマクロ宣言を置くターゲットと、SwiftSyntaxで実際の変換処理を行う実装ターゲットです。前者が AutoMock、後者が AutoMockImplementation という名前です。

MCPサーバーからは、この AutoMockImplementation にある展開ロジックをそのまま再利用したいところですが、実装ターゲットは Package.swift で .macro() として宣言される特殊なターゲットで、コンパイラプラグインとして別プロセスで動く前提のため、通常のターゲットから直接importできません。

とはいえ展開ロジックを二重管理したくはありません。そこで、プラグインのエントリポイント(AutoMockPlugin.swift)を除いた実装ファイルをsymlinkで共有し、通常の .target としてもビルドできるようにしました。

Packages/AutoMock/Sources/
├── AutoMock/                        # マクロ宣言
├── AutoMockImplementation/          # .macro() ターゲット(コンパイラプラグイン)
│   ├── AutoMockMacro.swift
│   ├── AutoMockMacroError.swift
│   ├── AutoMockPlugin.swift         # プラグインのエントリポイント
│   └── SwiftSyntaxExtensions.swift
└── AutoMockImplementationLib/       # .target(symlinkで実装を共有)
    ├── AutoMockMacro.swift -> ../AutoMockImplementation/AutoMockMacro.swift
    ├── AutoMockMacroError.swift -> ../AutoMockImplementation/AutoMockMacroError.swift
    └── SwiftSyntaxExtensions.swift -> ../AutoMockImplementation/SwiftSyntaxExtensions.swift
// Package.swift(抜粋)
.macro(
    name: "AutoMockImplementation",
    dependencies: [...]
),

// Library variant of the macro implementation for use outside the compiler plugin context.
// Sources are symlinked from AutoMockImplementation (excluding AutoMockPlugin.swift).
.target(
    name: "AutoMockImplementationLib",
    dependencies: [...],
    path: "Sources/AutoMockImplementationLib"
),

これによりMCPサーバーからコンパイラプラグインと全く同じ展開ロジックを呼び出せます。

SwiftSyntaxで構文レベルの展開をする

展開処理はSwiftSyntaxが提供する SyntaxProtocol.expand(macros:) をほぼそのまま使っています。

static func expandAutoMock(source: String) throws -> String {
    let macros: [String: any Macro.Type] = [
        "AutoMock": AutoMockMacro.self
    ]
    return try expand(source: source, macros: macros)
}

private static func expand(source: String, macros: [String: any Macro.Type]) throws -> String {
    let tree = Parser.parse(source: source)
    let expanded = tree.expand(
        macros: macros,
        contextGenerator: { _ in
            BasicMacroExpansionContext(
                sourceFiles: [tree: .init(moduleName: "ExpanderModule", fullFilePath: "input.swift")]
            )
        },
        indentationWidth: .spaces(4)
    )
    return expanded.description
}

単なる構文レベルの展開で型チェックは行われないため、渡すソースコードは依存する型が解決はしていません。プロトコル宣言のスニペットを文字列で渡すだけで展開結果が返ってきます。

そのためこれは、AutoMockやAutoFakeのように付与された宣言の構文だけで出力が決まるマクロでのみ成立するアプローチです。Swift Macroは展開時に MacroExpansionContext を通じてソース位置(location(of:))や外側を囲む宣言(lexicalContext)、一意な名前の生成(makeUniqueName(_:))も参照できます。これらに依存するマクロでは、ダミーのファイル名を与えたスニペット単体の展開結果が実際のコンパイル結果と一致する保証はありません。私たちのマクロは付与された宣言の構文だけを入力とするため、そのままコンパイラの外へ持ち出すことができました。

あとはこの展開処理をMCPのツールとして公開します。JSON-RPC 2.0 over stdioを話す小さなMCPサーバーで、標準入力を1行ずつ読んでJSONをパースし、initialize / tools/list / tools/call の3メソッドに応答するだけです。

struct MCPServer {
    func run() {
        while let line = readLine(strippingNewline: true) {
            // JSONをパースして initialize / tools/list / tools/call に応答する
            ...
        }
    }
}

expand_automock と expand_autofake の2つをツールとして公開し、リポジトリ直下の .mcp.json に登録しています。swift run で起動するので、チームメンバーは特別なセットアップなしにそのまま使えます。

{
  "mcpServers": {
    "macro-expander": {
      "command": "swift",
      "args": ["run", "--package-path", "Packages/MacroExpanderMCP", "MacroExpanderMCP"]
    }
  }
}

導入後は、Coding Agentがテストを書く前に expand_automock ツールを呼び出すようになります。

// MCPサーバーへの入力
expand_automock(source: """
    @AutoMock
    protocol SampleInteractorDelegate: AnyObject {
        func sampleInteractorDidCancel(_ interactor: SampleInteractor)
    }
""")

// MCPサーバーからの出力(展開結果)
class MockSampleInteractorDelegate: SampleInteractorDelegate {
    enum Invocation {
        case didCancel   // ← プレフィックスが省略されていることがわかる
        ...
    }
    private(set) var invocations: [Invocation] = []
    func sampleInteractorDidCancel(_ interactor: SampleInteractor) {
        invocations.append(.didCancel)
    }
}

先ほどの複雑な命名規則を文章で理解する必要はなくなり、「sampleInteractorDidCancel のcase名は didCancel になる」という事実が、展開結果として直接コンテキストに入ります。

ガイドライン側に残した指示はシンプルです。

生成コードの確認には MCP ツール mcp__macro-expander__expand_automock を使うこと。case 命名規則、associated value 規則、デフォルト値、Observer 関数判定といった詳細はそちらで実際の出力を確認できます。

まとめ

  • Coding Agentのためのコードベース整備の一例として、Swift Macroを展開して結果を返すMCPサーバーを紹介しました。
  • Coding AgentがSwiftMacroの細かい考慮事項を見逃す回数が減り、ビルドエラーをループする問題が減りました。また、仕様変更によるガイドラインの保守が不要になり445行あったAutoMockのガイドラインは、規約と典型的なAPIの紹介だけを残して77行になりました。
  • 一方で、このアプローチは付与された宣言の構文だけで出力が決まるマクロだから成立している点には注意が必要で、ソース位置や外側の宣言など展開コンテキストに依存するマクロでは、同じ手はそのまま使えません。

お知らせ

クックパッドは、2026年9月11日(金)〜9月13日(日)に有明セントラルタワーホール&カンファレンスおよびオンラインで開催される iOSDC Japan 2026 に、今年もゴールドスポンサーとして協賛しています。

今年はスポンサーブースの出展はありませんが、クックパッドのエンジニアも参加者として会場に足を運ぶ予定です。一緒に楽しみましょう!

クックパッドアプリのLiquid Glass対応

こんにちは、レシピ事業部でiOSエンジニアをしている山田(@0x746572616e79)です。

iOS 26で導入されたLiquid Glassは、iOS 27で強制的に有効化される予定です。対応を先送りにすると、その間に進む機能開発やデザイン調整がすべてLiquid Glassへの考慮がされないまま積み上がり、後から手戻りが増えていきます。早期に有効化しておくことで、新しい画面や機能を追加する段階でLiquid Glassを前提としたデザイン議論やベストプラクティスの調査ができるようになります。

こうした背景からクックパッドiOSアプリでは早めの対応を進めてきました。この記事では、UINavigationBarとUITabBar周りで遭遇した破壊的変化と、Liquid Glassの新しい仕組みを活用した事例を共有します。

UINavigationBar

カスタムtitleViewのUITextFieldがRTLでクラッシュする

まずはUINavigationBarに関わる問題です。

クックパッドアプリではいくつかの画面でnavigationItem.titleViewにカスタムの検索バーを設定していました。Liquid Glassを有効化してデバッグしている中で、モーダル遷移かつRTL表示の組み合わせでクラッシュすることに気づきました。ナビゲーションバーの内部レイアウトが刷新された影響でUISearchBarTextFieldのレイアウト計算が破綻し、CALayerのpositionにNaNが入ることが原因でした。 このクラッシュはiOS 26.2では再現されますがiOS 26.4では修正された模様ですが、クックパッドアプリはiOS17以降をサポートするためそのまま利用することはできません。 titleViewベースの検索バーをやめて、UISearchControllerベースに移行することで解決しました。

navigationItem.searchControllerとnavigationItem.preferredSearchBarPlacement = .stackedを用いたレイアウトであればiOS 26.2でも問題なく動作します。

// Before
navigationItem.titleView = customSearchBar

// After
let searchController = UISearchController(searchResultsController: nil)
searchController.searchBar.delegate = self
navigationItem.searchController = searchController
navigationItem.preferredSearchBarPlacement = .stacked

UISearchControllerへ移行することでLiquid Glassのガラス質感や検索バーのアニメーションも自然に適用されます。

ナビゲーションバーのアイテムが省略されてしまう

Liquid Glassではナビゲーションバーのバーボタンアイテムの扱いが大きく変わりました。従来はleftBarButtonItemsとrightBarButtonItemsの描画サイズが自動調整され、コンテンツの要求するサイズが長い場合でも他要素の表示領域を確保した上でいっぱいに広げることができました。クックパッドのレシピ詳細画面ではこの仕様を利用してレシピタイトルを左寄せにしていましたが、Liquid Glass有効下では各アイテムに最低44ptのタップ領域と8pt程度のスペースが確保されるようになり、収まらないアイテムは標準のoverflowボタンにまとめられるようになりました。

これにより表示可能領域よりも長いタイトルで全ての要素が標準のOverflowボタンに省略されてしまいました。

短いレシピタイトル
長いレシピタイトル

タイトルは、そもそもBarButtonItemsに入れること自体が少々特殊な対応だったためレイアウトの調整を頑張るのではなく、中央寄せになる挙動を許容しnavigationItem.titleViewへ移行することにしました。

また、これまでrightBarButtonItemsに自前でUIMenuを表示するOverflowボタンを表示していましたが、UIKitが表示する標準のoverflowボタンの中に意図せず省略されてしまうといった問題が起きやすくなるため、標準の仕組みへ乗ることにしました。具体的にはnavigationItem.additionalOverflowItemsにメニュー項目を登録して置くことで、自動的に標準のOverflowボタンを利用することができます。

navigationItem.additionalOverflowItems = UIDeferredMenuElement.uncached { completion in
    completion([
        UIAction(title: "Delete", attributes: .destructive) { _ in /* ... */ }
    ])
}

一点注意が必要なのが、UIBarButtonItem(customView:)で初期化したアイテムの扱いです。titleやimageで初期化した場合はUIKitが自動的にmenuRepresentationを設定してくれますが、customViewで初期化した場合はmenuRepresentationがnilになります。menuRepresentationがnilの場合、表示領域が足りなくなるとOverflowメニューにもナビゲーションバーにも表示されなくなります。

let item2 = UIButton()
item2.setTitle("Item2", for: .normal)
navigationItem.rightBarButtonItems = [
    UIBarButtonItem(title: "Item1", style: .plain, target: nil, action: nil),
    // Item2だけcustomView
    UIBarButtonItem(customView: item2),
    UIBarButtonItem(title: "Item3", style: .plain, target: nil, action: nil),
    UIBarButtonItem(title: "Item4", style: .plain, target: nil, action: nil),
    UIBarButtonItem(title: "Item5", style: .plain, target: nil, action: nil),
    UIBarButtonItem(title: "Item6", style: .plain, target: nil, action: nil)
]

Item1, Item3の間にItem2が入ってほしいが表示されない

customViewベースのアイテムを使う場合はmenuRepresentationを明示的に設定する必要があります。

let barButtonItem = UIBarButtonItem(customView: button)
barButtonItem.menuRepresentation = UIAction(title: "Item2") { _ in /* ... */ }

Item1, Item3の間にItem2が表示される

UITabBar

FABと「今日作る」ボタンをUITabBarに統合する

従来のクックパッドアプリでは、レシピ作成用のFAB(Floating Action Button)と今日の献立を開く「今日作る」ボタンをUITabBarの上にオーバーレイとして配置していました。タブバーだけLiquid Glassのデザインに切り替わった状態で、その上に従来のオーバーレイが乗っているとデザインの統一性がなく違和感があったため、Liquid Glassで追加された新しい仕組みを使ってタブバーに統合することにしました。

FABはUITabBarItem(tabBarSystemItem: .search)を使ってタブバー右端にピン留めし、「今日作る」ボタンはUITabAccessoryとしてタブバー下部に配置することで、オーバーレイを廃止してコンテンツの表示領域を広げました。

Before
After

Liquid GlassのUITabBarでは、tabBarSystemItem: .searchで作成したアイテムが他のタブとは独立してタブバーの右端に固定配置されます。本来は検索タブ用途のシステムアイテムですが、この固定配置の仕様を利用して、FABのようなアクションボタンをタブバーに統合しています。

献立アクセサリはUITabAccessoryで実現しています。Apple Musicのミニプレーヤーが代表例ですが、クックパッドでは今日の献立に登録されたレシピのサムネイルとカレンダーボタンを常時表示し、どの画面からでも献立にアクセスできるようにしています。

tabBarController.tabBarMinimizeBehavior = .onScrollDown
tabBarController.setBottomAccessory(
    UITabAccessory(contentView: accessoryContentView),
    animated: false
)

従来のオーバーレイボタンでは自前で表示領域を確保する都合上、コンテンツの邪魔にならないようラベルとアイコンのみの表示に留めていましたがUITabAccessoryではUIKit側が表示領域を管理してくれるため、今日作るレシピのサムネイルを表示するといった表示内容の自由度が高まりました。

コンテンツビューはtraitCollection.tabAccessoryEnvironmentで.inlineと.regularが切り替わるので、それぞれに合わせてラベルの表示有無やサムネイルの表示枚数を調整しています。

override func updateProperties() {
    super.updateProperties()

    switch traitCollection.tabAccessoryEnvironment {
    case .inline:
        // コンパクト表示に切り替え
    case .regular, .unspecified:
        // 通常表示に切り替え
    }
}
Inline
Regular

hidesBottomBarWhenPushedとの連携

デバッグ中に、hidesBottomBarWhenPushed = trueの画面に遷移した際、タブバーは隠れるのにアクセサリが残り続ける問題に気づきました。これはUINavigationControllerDelegateのwillShowでアクセサリの保存・復元を手動で行うことで解決しました。

func navigationController(_ nc: UINavigationController, willShow vc: UIViewController, animated: Bool) {
    if vc.hidesBottomBarWhenPushed {
        storedAccessory = tabBarController?.bottomAccessory
        tabBarController?.setBottomAccessory(nil, animated: false)
    } else if let stored = storedAccessory {
        tabBarController?.setBottomAccessory(stored, animated: false)
        storedAccessory = nil
    }
}

インタラクティブなpopジェスチャーがキャンセルされた場合の復元もtransitionCoordinatorのキャンセルハンドラで行っています。

contentScrollViewのコンテナVC転送

Liquid GlassのタブバーはtabBarMinimizeBehavior = .onScrollDownを設定することで、コンテンツのスクロールに連動してbottomAccessoryがタブバーの領域に収まるよう折りたたまれます。UIKitはcontentScrollView(for:)で返されるUIScrollViewを監視してこの挙動を実現しており、UIViewControllerを直接タブに配置している場合は自動で解決されます。

ただし、ViewControllerを入れ子にしていたりUIPageViewControllerを間に挟んでいる場合は注意が必要です。tabBarMinimizeBehaviorを設定しても反応する画面としない画面があり、特定のページでしかスクロール連動が機能しないなど意図しない挙動になることがあります。クックパッドでもカスタムのコンテナVCが子VCへ適切に転送できていない問題に遭遇し、contentScrollView(for:)のoverrideで対応しました。

override func contentScrollView(for edge: NSDirectionalRectEdge) -> UIScrollView? {
    children.first?.contentScrollView(for: edge)
}

まとめ

Liquid Glassはカスタム実装をOS標準パターンに置き換えること、追加された新しい仕組みを活用することの2軸を柱に対応を進めました。

標準APIに準拠するほど対応コストは下がります。UISearchControllerやoverflowメニューへの移行はその好例です。また、UITabAccessoryやtabBarSystemItem: .searchの固定配置といった新しい仕組みを活用することで、FABや献立ボタンをタブバーに統合しコンテンツの表示領域を広げることもできました。

一方ですべてを標準UIに寄せればいいわけではなく、サービスらしさを表現するためにカスタムが必要な箇所もあります。どこを標準に乗せてどこをこだわるか、そのバランスを意識しながら対応を進めていくことが大切です。特にUITabAccessoryはミニプレーヤー以外の用途での情報がまだ少なく、この記事が参考になれば幸いです。

RubyKaigi 2026 のクックパッドブースはこんな感じです

RubyKaigi 2026 のロゴ画像です。

こんにちは。レシピ事業部の石川です。

来週 4 月 22 日から 3 日間、RubyKaigi 2026 が開催されます。クックパッドは今年も Platinum スポンサーとして RubyKaigi に協賛いたします。また、スポンサーブースをご用意いたします。

今年のスポンサーブースでは、ここ 1 年のクックパッドでの開発の様子をあっちからこっちまでご紹介いたします。社内で Claude Code の話題が出たのは去年の 3 月でした。皆さまご存知のとおりそこからの 1 年でがらりと変わった開発環境の話は積もるものがございます。またこの 1 年の間もいくつかの新機能がリリースされました。その裏で動いている技術の話も、対面なら細かいニュアンスまでお伝えできます。その他、Rails アプリで pull request を出してからデプロイされるまでの時間を短くするための取り組みや多言語での検索システムの話など、技術の話のタネをいろいろとご用意いたします。

cookpad.com を提供している Rails アプリは現在 Ruby 4.0 & Bundler 4.0 で動いています。次の更新に向けてどんなことができそうか、トークを聞いて回りながら考えようと個人的に思っています。皆さまの考えもぜひお聞かせください。

それでは、現地で雑談できるのを楽しみにしております。また来週!