Swift 本地重构
Xcode 9 包含一个全新的重构引擎。它可以转换单个 Swift 源文件内的本地代码,或者进行全局重构,例如重命名在多个文件甚至不同语言中出现的方法或属性。本地重构背后的逻辑完全在编译器和 SourceKit 中实现,现在已在 swift 仓库 中开源。因此,任何 Swift 爱好者都可以为该语言贡献重构操作。这篇文章讨论了如何实现一个简单的重构并在 Xcode 中展示出来。
重构的种类
本地重构发生在单个文件的范围内。本地重构的例子包括提取方法和提取重复表达式。全局重构,它跨多个文件更改代码(例如全局重命名),目前需要 Xcode 的特殊协调,并且目前无法在 Swift 代码库中自行实现。这篇文章侧重于本地重构,它本身就非常强大。
重构操作由用户在编辑器中的光标选择发起。根据它们的初始化方式,我们将重构操作分为基于光标或基于范围。基于光标的重构具有由 Swift 源文件中的光标位置充分指定的重构目标,例如重命名重构。相比之下,基于范围的重构需要起始和结束位置来指定其目标,例如提取方法重构。为了方便这两类重构的实现,Swift 仓库提供了预先分析的结果,称为 ResolvedCursorInfo 和 ResolvedRangeInfo,以回答关于 Swift 源文件中的光标位置或范围的几个常见问题。
例如,ResolvedCursorInfo 可以告诉我们源文件中的位置是否指向表达式的开头,如果是,则提供该表达式的相应编译器对象。或者,如果光标指向名称,ResolvedCursorInfo 会给出与该名称对应的声明。类似地,ResolvedRangeInfo 封装了关于给定源范围的信息,例如该范围是否具有多个入口点或出口点。
要为 Swift 实现新的重构,我们不需要从光标或范围位置的原始表示开始;相反,我们可以从 ResolvedCursorInfo 和 ResolvedRangeInfo 开始,在此基础上可以推导出特定于重构的分析。
基于光标的重构
基于光标的重构由 Swift 源文件中的光标位置发起。重构操作实现了一些方法,重构引擎使用这些方法在 IDE 上显示可用的操作并执行转换。
具体来说,对于显示可用的操作:
- 用户从 Xcode 编辑器中选择一个位置。
- Xcode 向 sourcekitd 发送请求,以查看该位置存在哪些可用的重构操作。
- 每个已实现的重构操作都会使用
ResolvedCursorInfo
对象进行查询,以查看该操作是否适用于该位置。 - 适用操作的列表作为来自 sourcekitd 的响应返回,并由 Xcode 显示给用户。
当用户选择其中一个可用操作时:
- Xcode 向 sourcekitd 发送请求,以对源位置执行所选操作。
- 特定的重构操作会使用从同一位置导出的
ResolvedCursorInfo
对象进行查询,以验证该操作是否适用。 - 要求重构操作使用文本源编辑执行转换。
- 源编辑作为来自 sourcekitd 的响应返回,并由 Xcode 编辑器应用。
要实现字符串本地化重构,我们首先需要在 RefactoringKinds.def 文件中声明此重构,条目如下所示:
CURSOR_REFACTORING(LocalizeString, "Localize String", localize.string)
CURSOR_REFACTORING
指定此重构在光标位置初始化,因此将在实现中使用 ResolvedCursorInfo。第一个字段 LocalizeString
指定此重构在 Swift 代码库中的内部名称。在本例中,与此重构对应的类名为 RefactoringActionLocalizeString
。字符串字面量 "Localize String"
是此重构的显示名称,将在 UI 中呈现给用户。最后,“localize.string” 是一个稳定的键,用于标识重构操作,Swift 工具链在与源编辑器通信时使用该键。此条目还允许 C++ 编译器为字符串本地化重构及其调用者生成类存根。因此,我们可以专注于实现所需的功能。
在指定此条目后,我们需要实现两个函数来教导 Xcode:
- 何时适合显示重构操作。
- 当用户调用此重构操作时,应应用什么代码更改。
这两个声明都是从上述条目自动生成的。为了满足 (1),我们需要在 Refactoring.cpp 中实现 RefactoringActionLocalizeString
的 isApplicable 函数,如下所示:
1 bool RefactoringActionLocalizeString::
2 isApplicable(ResolvedCursorInfo CursorInfo) {
3 if (CursorInfo.Kind == CursorInfoKind::ExprStart) {
4 if (auto *Literal = dyn_cast<StringLiteralExpr>(CursorInfo.TrailingExpr) {
5 return !Literal->hasInterpolation(); // Not real API.
6 }
7 }
8 }
将 ResolvedCursorInfo 对象作为输入,检查何时使用“localize string”填充可用重构菜单几乎是微不足道的。在这种情况下,检查光标是否指向表达式的开头(第 3 行),以及表达式是否为没有插值的字符串字面量(第 4 行和第 5 行)就足够了。
接下来,我们需要实现如果应用重构操作,光标下的代码应该如何更改。为此,我们必须实现 RefactoringActionLocalizeString
的 performChange 方法。在 performChange
的实现中,我们可以访问 isApplicable 接收的同一个 ResolvedCursorInfo
对象。
1 bool RefactoringActionLocalizeString::
2 performChange() {
3 EditConsumer.insert(SM, Cursor.TrailingExpr->getStartLoc(), "NSLocalizedString(");
4 EditConsumer.insertAfter(SM, Cursor.TrailingExpr->getEndLoc(), ", comment: \"\")");
5 return false; // Return true if code change aborted.
6 }
仍然以字符串本地化为例,performChange 函数的实现非常简单。在函数体中,我们可以使用 EditConsumer 在光标指向的表达式周围发出文本编辑,并使用适当的 Foundation API 调用,如第 3 行和第 4 行所示。
基于范围的重构
如上图所示,基于范围的重构是通过在 Swift 源文件中选择连续的代码范围来发起的。以提取表达式重构的实现为例,我们首先需要在 RefactoringKinds.def 中声明以下项。
RANGE_REFACTORING(ExtractExpr, "Extract Expression", extract.expr)
此条目声明提取表达式重构由范围选择发起,内部命名为 ExtractExpr
,使用 "Extract Expression"
作为显示名称,并使用 “extract.expr” 的稳定键用于服务通信目的。
为了教导 Xcode 何时应该提供此重构,我们还需要在 Refactoring.cpp 中为该重构实现 isApplicable,唯一的区别是输入是 ResolvedRangeInfo 而不是 ResolvedCursorInfo。
1 bool RefactoringActionExtractExpr::
2 isApplicable(ResolvedRangeInfo Info) {
3 if (Info.Kind != RangeKind::SingleExpression)
4 return false;
5 auto Ty = Info.getType();
6 if (Ty.isNull() || Ty.hasError())
7 return false;
8 ...
9 return true;
10 }
虽然比前面提到的字符串本地化重构中的对应实现稍微复杂一些,但此实现也是不言自明的。第 3 行到第 4 行检查给定范围的种类,必须是单个表达式才能继续提取。第 5 行到第 7 行确保提取的表达式具有格式良好的类型。现在示例中省略了需要检查的更多条件。感兴趣的读者可以参考 Refactoring.cpp 了解更多详情。对于代码更改部分,我们可以使用相同的 ResolvedRangeInfo 实例来发出文本编辑。
1 bool RefactoringActionExtractExprBase::performChange() {
2 llvm::SmallString<64> DeclBuffer;
3 llvm::raw_svector_ostream OS(DeclBuffer);
4 OS << tok::kw_let << " ";
5 OS << PreferredName;
6 OS << TyBuffer.str() << " = " << RangeInfo.ContentRange.str() << "\n";
7 Expr *E = RangeInfo.ContainedNodes[0].get<Expr*>();
8 EditConsumer.insert(SM, InsertLoc, DeclBuffer.str());
9 EditConsumer.insert(SM,
10 Lexer::getCharSourceRangeFromSourceRange(SM, E->getSourceRange()),
11 PreferredName)
12 return false; // Return true if code change aborted.
13 }
第 2 行到第 6 行构造了局部变量的声明,其初始值是正在提取的表达式,例如 let extractedExpr = foo()
。第 8 行在本地上下文中的适当源位置插入声明,第 9 行将表达式的原始出现替换为对新声明的变量的引用。如代码示例所示,在 performChange 的函数体中,我们不仅可以访问用户选择的原始 ResolvedRangeInfo,还可以访问其他重要的实用程序,例如编辑消费者和源管理器,从而使实现更加方便。
诊断
重构操作可能由于各种原因需要在自动代码更改期间中止。当这种情况发生时,重构实现可以通过诊断将此类失败的原因传达给用户。重构诊断采用与编译器本身相同的机制。以重命名重构为例,如果给定的新名称是无效的 Swift 标识符,我们希望发出错误消息。为此,我们首先需要在 DiagnosticsRefactoring.def 中为诊断声明以下条目。
ERROR(invalid_name, none, "'%0' is not a valid name", (StringRef))
声明后,我们可以在 isApplicable 或 performChange 中使用诊断。对于本地重命名重构,在 Refactoring.cpp 中发出诊断看起来像这样:
1 bool RefactoringActionLocalRename::performChange() {
...
2 if (!DeclNameViewer(PreferredName).isValid()) {
3 DiagEngine.diagnose(SourceLoc(), diag::invalid_name, PreferredName);
4 return true; // Return true if code change aborted.
5 }
...
6 }
测试
与实现新重构操作的两个步骤相对应,我们需要测试:
- 上下文可用的重构是否已正确填充。
- 自动代码更改是否正确更新了用户的代码库。
这两个部分都使用 swift-refactor 命令行实用程序进行测试,该实用程序与编译器一起构建。
上下文重构测试
1 func foo() {
2 print("Hello World!")
3 }
4 // RUN: %refactor -source-filename %s -pos=2:14 | %FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING
5 // CHECK-LOCALIZE-STRING: Localize String
让我们再次以字符串本地化为例。上面的代码片段是上下文重构操作的测试。类似的测试可以在 test/refactoring/RefactoringKind/ 中找到。
让我们更详细地看一下 RUN
行,从 %refactor
实用程序的使用开始:
%refactor -source-filename %s -pos=2:14 | %FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING
当用户将光标指向字符串字面量 “Hello World!” 时,此行将转储所有适用重构的显示名称。%refactor
是一个别名,当测试运行时,测试运行程序会将其替换为 swift-refactor
的完整路径。-pos
给出了应从中拉取上下文重构操作的光标位置。由于 String Localization
重构是基于光标的,因此仅指定 -pos
就足够了。要测试基于范围的重构,我们需要指定 -end-pos
以指示重构目标的结束位置。所有位置的格式均为 行:列
。
为了确保工具的输出是预期的输出,我们使用 %FileCheck
实用程序:
%FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING
这将根据所有带有前缀 CHECK-LOCALIZE-STRING
的后续行检查来自 %refactor
的输出文本。在本例中,它将检查可用重构是否包含 Localize String
。除了测试我们在正确的光标位置显示正确的操作之外,我们还需要测试可用的重构不会在诸如带有插值的字符串字面量等情况下被错误地填充。
代码转换测试
我们还应该测试,当应用重构时,自动代码更改是否符合我们的预期。作为准备工作,我们需要教导 swift-refactor 一个重构种类标志,以指定我们正在测试的操作。为了实现这一点,在 swift-refactor.cpp 中添加了以下条目:
clEnumValN(RefactoringKind::LocalizeString, "localize-string", "Perform String Localization refactoring"),
有了这样的条目,swift-refactor 可以专门测试字符串本地化的代码转换部分。典型的代码转换测试由两部分组成:
- 重构之前的代码片段。
- 转换后的预期输出。
测试在 (1) 中执行指定的重构,并将结果与 (2) 进行比较。如果两者相同,则测试通过,否则测试失败。
1 func foo() {
2 print("Hello World!")
3 }
4 // RUN: rm -rf %t.result && mkdir -p %t.result
5 // RUN: %refactor -localize-string -source-filename %s -pos=2:14 > %t.result/localized.swift
6 // RUN: diff -u %S/Iutputs/localized.swift.expected %t.result/localized.swift
1 func foo() {
2 print(NSLocalizedString("Hello World!", comment: ""))
3 }
以上两个代码片段构成了一个有意义的代码转换测试。第 4 行为重构产生的代码准备一个临时源目录;使用新添加的 -localize-string
,第 5 行在 "Hello World!"
的起始位置执行重构代码更改,并将结果转储到临时目录;最后,第 6 行将结果与第二个代码示例中所示的预期输出进行比较。
与 Xcode 集成
在 Swift 代码库中实现了上述所有部分之后,我们就可以通过与本地构建的开源工具链集成,在 Xcode 中测试/使用新添加的重构了。
-
运行 build-toolchain 以在本地构建开源工具链。
-
解压工具链并将其复制到
/Library/Developer/Toolchains/
。 -
通过
Xcode->Toolchains
指定本地工具链供 Xcode 使用,如下图所示。
潜在的本地重构思路
这篇文章仅触及了在新重构引擎中现在可以实现的一些功能。如果您对扩展重构引擎以实现其他转换感到兴奋,Swift 的 问题数据库 包含 一些等待实现的重构转换思路。如果您想提出新的重构思路,在 Swift 的 问题数据库 中提交一个带有 Refactoring
标签的任务就足够了。