この記事の要点
- Xcode 9 には新しいリファクタリングエンジンが搭載されました。1 つの Swift ソースファイル内で完結する local refactoring(例: Extract Method、Extract Expression)と、複数ファイル・複数言語にまたがる global refactoring(例: Global Rename)の 2 種類があります。
- local refactoring のロジックはコンパイラと SourceKit に実装され、swift リポジトリ でオープンソース化されています。そのため誰でも新しいリファクタリングアクションを言語に貢献できます。
- local refactoring は、カーソル位置だけで対象が決まる cursor-based と、開始位置・終了位置の範囲で対象を指定する range-based に分類されます。リポジトリは事前解析済みの
ResolvedCursorInfo/ResolvedRangeInfoを提供しており、これらを起点に実装できます。 - 新しいリファクタリングは、定義ファイルへのエントリ追加と「いつ表示するか(
isApplicable)」「どう書き換えるか(performChange)」の 2 つの関数を実装するだけで追加でき、swift-refactorコマンドラインツールでテストできます。
背景: 2 種類のリファクタリング
local refactoring は 1 つのファイルの中で完結する変換です。Extract Method や Extract Repeated Expression がこれにあたります。一方 global refactoring(Global Rename など)は複数ファイルにまたがってコードを変更するもので、Xcode 側の特別な連携を必要とし、当時は Swift コードベース単独では実装できませんでした。この記事は、それ自体でも十分に強力な local refactoring に焦点を当てています。
リファクタリングアクションは、エディタ上でのカーソル選択をきっかけに起動されます。初期化のされ方によって、アクションは次の 2 種類に分類されます。
- cursor-based refactoring: rename のように、Swift ソースファイル内のカーソル位置だけで対象が十分に特定できるもの。
- range-based refactoring: Extract Method のように、対象を特定するのに開始位置と終了位置の範囲を必要とするもの。
この 2 分類を実装しやすくするため、リポジトリは事前解析済みの結果として ResolvedCursorInfo と ResolvedRangeInfo を提供しています。たとえば ResolvedCursorInfo は、カーソル位置が式の先頭を指しているか、もしそうならその式に対応するコンパイラ上のオブジェクトは何か、あるいはカーソルが名前を指しているならその宣言は何か、といった共通の問いに答えてくれます。ResolvedRangeInfo は、与えられた範囲に複数の入口・出口があるかといった、範囲に関する情報をまとめています。
新しいリファクタリングを実装する際は、カーソルや範囲の生の表現から始める必要はなく、これら ResolvedCursorInfo / ResolvedRangeInfo を起点として、リファクタリング固有の解析を組み立てられます。
cursor-based refactoring の実装
cursor-based refactoring は、ソースファイル内のカーソル位置で起動されます。リファクタリングエンジンは、利用可能なアクションを IDE 上に表示し、変換を実行するために、各アクションが実装するメソッドを使います。
利用可能なアクションを表示するときの流れは次のとおりです。
- ユーザーが Xcode エディタ上で位置を選択する。
- Xcode が sourcekitd に対し、その位置で利用可能なリファクタリングアクションを問い合わせる。
- 実装済みの各アクションが
ResolvedCursorInfoを使って、その位置で適用可能かどうか判定される。 - 適用可能なアクションの一覧が sourcekitd から返り、Xcode が表示する。
ユーザーがアクションを選んだときは、Xcode が sourcekitd に実行を要求し、同じ位置から導いた ResolvedCursorInfo で再度適用可能性を確認したうえで、テキスト編集としての変換を実行し、その編集結果が Xcode エディタに適用されます。
例として String Localization リファクタリング(文字列リテラルを NSLocalizedString(...) で包む)の実装を見ていきます。まず RefactoringKinds.def にエントリを宣言します。
CURSOR_REFACTORING(LocalizeString, "Localize String", localize.string)
CURSOR_REFACTORING は、このリファクタリングがカーソル位置で初期化され、実装で ResolvedCursorInfo を使うことを示します。第 1 フィールド LocalizeString は Swift コードベース内での内部名で、対応するクラスは RefactoringActionLocalizeString という名前になります。文字列リテラル "Localize String" は UI に表示される名前、"localize.string" はツールチェインとソースエディタの通信に使う安定したキーです。このエントリから、クラスの雛形と呼び出し側のコードが自動生成されるため、実装者は必要な関数の中身に集中できます。
実装すべきは次の 2 つの関数です。
- いつアクションを表示するかを決める
isApplicable。 - どうコードを書き換えるかを決める
performChange。
isApplicable は ResolvedCursorInfo を入力に取り、メニューに「Localize String」を出すべき条件を判定します。この例では、カーソルが式の先頭を指していて、その式が補間(interpolation)を含まない文字列リテラルであることを確認すれば十分です。
bool RefactoringActionLocalizeString::
isApplicable(ResolvedCursorInfo CursorInfo) {
if (CursorInfo.Kind == CursorInfoKind::ExprStart) {
if (auto *Literal = dyn_cast<StringLiteralExpr>(CursorInfo.TrailingExpr) {
return !Literal->hasInterpolation(); // Not real API.
}
}
}
performChange では、isApplicable が受け取ったのと同じ ResolvedCursorInfo を参照できます。EditConsumer を使って、カーソルが指す式の前後にテキスト編集を発行します。
bool RefactoringActionLocalizeString::
performChange() {
EditConsumer.insert(SM, Cursor.TrailingExpr->getStartLoc(), "NSLocalizedString(");
EditConsumer.insertAfter(SM, Cursor.TrailingExpr->getEndLoc(), ", comment: \"\")");
return false; // Return true if code change aborted.
}
range-based refactoring の実装
range-based refactoring は、ソースファイル内の連続した範囲の選択で起動されます。Extract Expression リファクタリングを例にとると、まず RefactoringKinds.def に次のように宣言します。
RANGE_REFACTORING(ExtractExpr, "Extract Expression", extract.expr)
isApplicable の実装は cursor-based とほぼ同じですが、入力が ResolvedCursorInfo ではなく ResolvedRangeInfo になります。
bool RefactoringActionExtractExpr::
isApplicable(ResolvedRangeInfo Info) {
if (Info.Kind != RangeKind::SingleExpression)
return false;
auto Ty = Info.getType();
if (Ty.isNull() || Ty.hasError())
return false;
...
return true;
}
ここでは、選択範囲が単一の式であること(抽出を進めるための前提)と、抽出する式が正しい型を持つことを確認しています。さらに必要な条件は例では省略されています。
performChange では、同じ ResolvedRangeInfo を使ってテキスト編集を発行します。次の実装は、抽出対象の式で初期化されるローカル変数の宣言(例: let extractedExpr = foo())を組み立て、ローカルコンテキストの適切な位置に挿入したうえで、元の式の出現箇所をその変数への参照で置き換えます。
bool RefactoringActionExtractExprBase::performChange() {
llvm::SmallString<64> DeclBuffer;
llvm::raw_svector_ostream OS(DeclBuffer);
OS << tok::kw_let << " ";
OS << PreferredName;
OS << TyBuffer.str() << " = " << RangeInfo.ContentRange.str() << "\n";
Expr *E = RangeInfo.ContainedNodes[0].get<Expr*>();
EditConsumer.insert(SM, InsertLoc, DeclBuffer.str());
EditConsumer.insert(SM,
Lexer::getCharSourceRangeFromSourceRange(SM, E->getSourceRange()),
PreferredName)
return false; // Return true if code change aborted.
}
performChange の中では、ユーザー選択に対応する ResolvedRangeInfo だけでなく、edit consumer や source manager といった便利なユーティリティにもアクセスできるため、実装が容易になっています。
診断によるエラー通知
リファクタリングアクションは、自動コード変更の途中でさまざまな理由により中断する必要が生じることがあります。その際、リファクタリングの実装は 診断(diagnostic) を通じて失敗の原因をユーザーに伝えられます。リファクタリングの診断は、コンパイラ本体と同じ仕組みを使います。
たとえば rename リファクタリングで、与えられた新しい名前が無効な Swift の識別子だった場合にエラーを出したいとします。まず DiagnosticsRefactoring.def に診断のエントリを宣言します。
ERROR(invalid_name, none, "'%0' is not a valid name", (StringRef))
宣言後は、isApplicable または performChange の中でこの診断を使えます。中断する場合は performChange が true を返します。
bool RefactoringActionLocalRename::performChange() {
...
if (!DeclNameViewer(PreferredName).isValid()) {
DiagEngine.diagnose(SourceLoc(), diag::invalid_name, PreferredName);
return true; // Return true if code change aborted.
}
...
}
テスト
新しいリファクタリングの実装に対応して、テストすべきは次の 2 点です。
- 文脈に応じて利用可能なリファクタリングが正しく一覧に出ること。
- 自動コード変更がユーザーのコードを正しく更新すること。
どちらも、コンパイラと一緒にビルドされる swift-refactor コマンドラインツールでテストします。
文脈に応じたリファクタリングのテストでは、%refactor でカーソル位置を指定し、%FileCheck で出力を検証します。
func foo() {
print("Hello World!")
}
// RUN: %refactor -source-filename %s -pos=2:14 | %FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING
// CHECK-LOCALIZE-STRING: Localize String
-pos はリファクタリングアクションを取得するカーソル位置です。cursor-based なら -pos だけで十分ですが、range-based では終了位置を示す -end-pos も指定します。位置はすべて line:column 形式です。補間を含む文字列リテラルのように、アクションを誤って表示してはいけない状況のテストも必要です。
コード変換のテストでは、まず swift-refactor.cpp に、テスト対象のアクションを指定するためのフラグを追加します。
clEnumValN(RefactoringKind::LocalizeString, "localize-string", "Perform String Localization refactoring"),
そのうえで、リファクタリング前のコードに対して変換を実行し、期待される出力と一致するかを比較します。一致すれば成功、しなければ失敗です。
func foo() {
print("Hello World!")
}
// RUN: rm -rf %t.result && mkdir -p %t.result
// RUN: %refactor -localize-string -source-filename %s -pos=2:14 > %t.result/localized.swift
// RUN: diff -u %S/Outputs/localized.swift.expected %t.result/localized.swift
期待される出力は次のとおりです。
func foo() {
print(NSLocalizedString("Hello World!", comment: ""))
}
Xcode への統合と貢献のはじめ方
すべてを Swift コードベースに実装したら、ローカルでビルドしたオープンソースのツールチェインと連携して、Xcode 上で新しいリファクタリングを試せます。
build-toolchainでオープンソースのツールチェインをローカルにビルドする。- 展開して
/Library/Developer/Toolchains/にコピーする。 Xcode->Toolchainsからそのローカルツールチェインを選択する。
この記事は、新しいリファクタリングエンジンで実装できることのごく一部を紹介したにすぎません。リファクタリング変換の実装に興味があれば、Swift の issue データベースに Refactoring ラベルでアイデアを登録したり、既存のアイデアから着手したりできます。