アクセス修飾子のつけ忘れを防ぐXcode Source Editor Extensionを作ってみた

## はじめに

[iOS Advent Calendar 2019]() 23日目の記事となります。
[iOSDC 2019にてこんなプロポーザル](https://fortee.jp/iosdc-japan-2019/proposal/190fc530-d175-4fa1-b4b0-20e4097d953e)を出してみて結局作っていなかったので作ってみたという内容になります。

n番煎じながら、Xcode Source Editor Extensionのおさらい〜各種手順、今回自分で利用する用途としてごくごく必要最小限で実装したソースコードを載せつつできることの解説を少々、といった内容です。
Xcode Source Editor Extensionガチ勢な方には非常に物足りない内容かと思われますのでご了承くださいませ...

## 作ったもの

選択範囲のソースコードに対して指定してある文字列へ置換するExtensionです。ソースコードは後述。

![zkwnq-fkesk.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/7c66ade0-e182-3fd9-ea33-56074c2fad28.gif)


## Xcode Source Editor Extension とは

Xcode上で現在表示しているソースコードへ読み書き選択などを一括でやるような用途で利用する場合が多い拡張機能です。
上記以外に、別ファイルの操作やインターネットへ情報を取得しにいってうんぬんを伴わない場合はApp Storeへリリースも可能。

Xcode8に追加以降、特にアップデートがないので2年前の資料のたいへんありがたい資料が現役で最新です。
簡単なものからテクニカルなものまで、2019年現在もこちらの資料を把握さえすれば作れてしまいます！!

[Xcode Source Editor Extensionの世界(完全版) / 20170916 #iosdc](https://speakerdeck.com/takasek/20170916-number-iosdc)
[続・Xcode Source Editor Extensionの世界 〜XPCとScripting Bridge〜](https://qiita.com/takasek/items/8b782848adc9ddff125e)

### すでにあるXcode Source Editor Extensionの探し方

上記の参考資料から抜粋

>- App Store にて `xcode extension` と検索
>- [Awesome native Xcode extensions](https://github.com/theswiftdev/awesome-xcode-extensions)から探す
>- [GitHubのtopicでリポジトリ探索](https://github.com/search?q=topic%3Axcode-extension&type=Repositories)

2個目が見やすくてお勧めです。
例えば選択したenum型のswitch-case文を生成してくれたり、選択したプロパティ群をアルファベット順に昇順に並び替えてくれたりなど「あるとちょっと便利かも」と感じる素敵なExtensionが豊富にございます。
興味のある方は是非覗いてみると良さそう。
~~IntelliJ製のIDEを知っているとこの挙動はXcode側で公式サポートしてくれよ・・・って思うものも多々ありますが~~

ちなみに今回わたしが実装したのはprivateへ直書きで差し替えるものでして、それ以外のその他修飾子への書き換えも対応している素敵なXcode Source Editor Extensionがすでにございます。
https://github.com/zoejessica/AccessControlKitty

## Xcode Source Editor Extensionの作り方

### 環境
Mac OS Catalina 10.15.2
Xcode 11.3
Swift 5.1

### 作成〜利用までの手順

１. Cocoa Application(Xcode 11.3では`App`) を新規作成

<img width="450" alt="スクリーンショット 2019-12-19 16.38.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/40e66637-30a5-2210-85e7-7ecab499416a.png">

２. TARGETS > ＋ボタンより `Xcode Source Editor Extension` を作成

<img width="200" alt="スクリーンショット 2019-12-19 20.22.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/232a34a8-9478-14a7-da53-022d3223438d.png">
<img width="400" alt="スクリーンショット 2019-12-19 20.23.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/b1bddab4-4cd1-c4be-d657-2d4dd21044e0.png">

３. 作成したスキームはActivateする
<img width="450" alt="スクリーンショット 2019-12-19 20.25.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/6f258bfc-37dc-1816-99ac-ed0d28e283a1.png">

そうすると、以下のようにファイルが作成されます。

<img width="300" alt="スクリーンショット 2019-12-22 18.16.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/4637e43a-81fe-c83d-e94d-014f4f095859.png">


４. `SourceEditorCommand.swift` へコマンド1個分のクラスがデフォルトで定義済みのため、 `Info.plist` へコマンドの名前を設定

<img width="900" alt="スクリーンショット 2019-12-22 18.23.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/5b97ce07-eb09-09d6-0bea-eb986086b69f.png">

４を設定した状態でデバッグすると、XcodeのEditorメニュー項目へ実行時のコマンド名が設定したが表示されるようになります。
<img width="300" alt="スクリーンショット 2019-12-22 18.25.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/63349eec-36b9-13a7-f31a-4efe538c514a.png">

５. `SourceEditorCommand.swift` の performメソッドへ実装

６. ２のスキーマを選択した状態で Product > Archive

７. ６のアプリを実行

８. Macのシステム環境設定 > 機能拡張 > Xcode Source Editor より、６の項目のチェックボックスをON
→Xcodeを再起動後、XcodeのEditorメニュー項目から選択できるようになる


## 実装

冒頭の[作ったもの](https://qiita.com/m-yamada1992/items/e78341d39376f9f743c7#%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE)の実装が以下。

```swift:SourceEditorCommand.swift
import Foundation
import XcodeKit

class SourceEditorCommand: NSObject, XCSourceEditorCommand {
    
    func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void {
        let buffer = invocation.buffer
        guard let selections = buffer.selections as? [XCSourceTextRange] else {
            completionHandler(nil)
            return
        }
        replaceLines(buffer: buffer, selections: selections, completionHandler: completionHandler)
    }
    
    private func replaceLines(buffer: XCSourceTextBuffer, selections: [XCSourceTextRange], completionHandler: @escaping (Error?) -> Void) {
        guard let lines = buffer.lines as? [String], selections.count > 0 else {
            completionHandler(nil)
            return
        }
        for selection in selections {
            if selection.start.line == selection.end.line {
                buffer.lines[selection.start.line] = replace(target: lines[selection.start.line])
                break
            }
            var index = selection.start.line
            while index <= selection.end.line {
                buffer.lines[index] = replace(target: lines[index])
                index += 1
            }
        }
        completionHandler(nil)
    }
    
    private func replace(target: String) -> String {
        let replaceWords = [
            "class ": "final class ",
            "let ": "private let ",
            "var ": "private var ",
            "lazy var ": "private lazy var ",
            "enum ": "private enum ",
            "struct ": "private struct "
        ]
        for words in replaceWords {
            guard let range = target.range(of: words.key) else {
                continue
            }
            guard target.range(of: words.value) == nil else {
                return target
            }
            return target.replacingCharacters(in: range, with: words.value)
        }
        return target
    }
}
```

replaceメソッドへ差し替え前後の文字列を辞書型で定義して、replaceLinesメソッドで選択した行の文字列を確認して差し替え後の文字列が含まれていなければ差し替えてるだけです。

### Xcode Source Editor Extensionでできること

```swift
    func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void {
```

こちらで受け取っている`invocation`に含まれている、以下の項目の書き換えです。

- `invocation.buffer.lines`
    - Xcode上で現在表示しているファイルに含まれる文字列
- `invocation.buffer.selections`
    - 現在の選択範囲

#### `invocation.buffer.selections` について

例: 
<img width="450" alt="スクリーンショット 2019-12-22 19.12.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113982/1fcb420c-8485-2a81-ef37-1f695acaf627.png">

こちらのように選択している場合、selectionsに含まれる値は以下のようなイメージ。

```swift
let selecton = invocation.buffer.selections.first
print("\(selecton.start.line),\(selecton.start.column)") // (6,0)
print("\(selecton.end.line),\(selecton.end.column)") // (9,18)
```

selections自体に選択している文字列が含まれているわけでなく、選択されている開始と終了位置が収納されている。
なので、選択範囲の文字列に対して何らかの操作を行う場合は`invocation.buffer.lines`について上記の開始・終了位置に基づいてループを回してあれこれすることになります。その一例が上記です。


## さいごに

例えばUITableViewのよく使うオーバーライドメソッドを使いまわしたいとかだとCode Snippetで事足りますが、それでも事足りないことを補完するような機能として使えるイメージが沸いた！効率UPの手札が少し増えたかも！って思っていただけると嬉しいです。