使用 NRefactory 分析 C# 代码






4.99/5 (47投票s)
使用 NRefactory 编写一个独立的应用程序来重构您的 C# 代码。
- 下载 NRefactory 演示和示例二进制文件 - 1.1 MB
- 下载 NRefactory 演示和示例源代码 - 3.3 MB
- NRefactory 项目页面: https://github.com/icsharpcode/NRefactory/
- 在 NuGet 上获取最新的 NRefactory 版本: ICSharpCode.NRefactory
介绍
NRefactory 是 SharpDevelop 和 MonoDevelop IDE 中使用的 C# 分析库。它允许应用程序轻松地分析 C# 程序的语法和语义。它与 Microsoft 的 Roslyn 项目非常相似;不同之处在于它不是一个完整的编译器 – NRefactory 只分析 C# 代码,不生成 IL 代码。
本文描述了 NRefactory 5,这是一个于 2010 年 7 月开始的完整重写。重写的目标是拥有更精确的语法树(包括精确的 token 位置),并集成 IDE 特定的语义分析功能和重构到 NRefactory 库中。这允许我们在 SharpDevelop 和 MonoDevelop 之间共享它们。
NRefactory 5.0 最近随 MonoDevelop 3.0 一起发布。NRefactory 还用于 ILSpy 反编译器,Unity 的 IL-to-Flash 转换器,以及 C#-to-JavaScript 编译器 Saltarelle 的前端。
概述
NRefactory 提供了访问语法树、语义信息和类型系统的 API。
为了理解不同的阶段,请考虑这段简单的代码:
obj.DoStuff(0)
作为 C# 代码,这仅仅是一个字符串。将字符串解析为语法树会告诉我们它是一个调用表达式,其目标是一个成员引用。使用 NRefactory API,可以像这样手动构建此语法树。
new InvocationExpression {
Target = new MemberReferenceExpression {
Target = new IdentifierExpression("obj"),
MemberName = "DoStuff"
},
Arguments = {
new PrimitiveExpression(0)
}
}
请注意,语法树完全没有告诉我们 obj
或 DoStuff
是什么 – DoStuff
很可能是一个实例方法,而 obj
是一个局部变量、参数或当前类的字段。或者 obj
可以是一个类名,而 DoStuff
是一个包含委托的静态字段。NRefactory 解析器可以告诉我们这是哪一种情况。在这种情况下,解析器的输出是一个类似于此的语义树。
new InvocationResolveResult {
TargetResult = new LocalResolveResult('TestClass obj;'),
Member = 'public void TestClass.DoStuff(int)',
Arguments = {
new ConstantResolveResult('System.Int32', 0)
}
}
这是伪代码;这里的单引号内的字符串不是字符串,而是对类型系统的引用。
语法树
要解析 C# 代码,请使用 ICSharpCode.NRefactory.CSharp.CSharpParser
类。
CSharpParser parser = new CSharpParser();
SyntaxTree syntaxTree = parser.Parse(programCode);
在解析过程中检测到的语法错误可以使用 parser.Errors
集合检索。
可以使用 NRefactory.Demo 应用程序可视化语法树:
语法树的基类是 AstNode
– 演示应用程序树视图中的每个项目都是一个 AstNode。(“AST”一词代表抽象语法树)
每个节点都有一个子节点列表,每个子节点都有一个**角色**。子节点的角色描述了父节点与子节点之间的关系 - 它解释了子节点出现在父节点的哪个位置。
演示树视图中描述节点的标题遵循“node.Role: node.GetType().Name”的模式。在截图中,您可以看到选中的 IndexerExpression
“args[0]
”在 Target
角色中有子标识符“args
”,在 Argument
角色中有子项“0
”。对于多维数组,将有多个子项出现在 Argument
角色中,并用逗号分隔。
Token 本身也是 AstNodes。例如,开括号是一个 CSharpTokenNode
,位于
Roles.LBracket
角色中。这种灵活的 AST 结构允许我们在正确的位置添加注释 - 例如,解析代码“args[/*i*/0]
”将生成一个 IndexerExpression
,该表达式在“[
”和 Argument
之间有一个额外的 Comment
节点。
然而,这种灵活的语法树相当不方便 - 如果您需要在到处都需要过滤子节点,按角色过滤会变得非常冗长。此外,也不总是清楚哪些角色可以出现在给定的构造中。因此,语法树 API 包含额外的辅助属性。以下三行是等效的:
var arguments = indexer.Children.Where(c => c.Role == Roles.Argument).Cast<Expression>();
var arguments = indexer.GetChildrenByRole(Roles.Argument);
var arguments = indexer.Arguments;
便利属性还有一个额外的好处:它们从不返回 null
。如果索引器的目标表达式丢失(这可能发生在导致解析错误的非完整代码中),则将返回**空节点** Expression.Null
。这就是空对象模式。要测试一个节点是否为空节点,请检查 IsNull
属性。
遍历语法树
如果您想在语法树中查找某个构造,您需要遍历它,例如查找特定类型的构造。可以使用 AstNode.Descendants
属性轻松完成此操作,例如 syntaxTree.Descendants.OfType<InvocationExpression>()
。
然而,对于更复杂的操作,通常最好使用**访问者模式**。
syntaxTree.AcceptVisitor(new FindInvocationsVisitor());
class FindInvocationsVisitor : DepthFirstAstVisitor
{
public override void VisitInvocationExpression(InvocationExpression invocationExpression)
{
if (LooksLikeIndexOfCall(invocationExpression)) {
...
}
// Call the base method to traverse into nested invocations
base.VisitInvocationExpression(invocationExpression);
}
}
还有一个通用的 DepthFirstAstVisitor
版本可用,它允许从访问方法中返回值。这对于实现更复杂的源代码分析可能很有用。
识别代码模式
在分析 C# 代码以查找特定问题时,通常需要识别给定代码片段是否匹配某个语法模式。
例如,考虑模式“X a = new X(...);
”。重构引擎可能会建议将 X
替换为 var
。然而,使用语法树 API 识别此类构造可能非常繁琐。在我们的示例中,我们需要检查:
- 变量声明语句仅声明一个变量。
- 变量使用“new”表达式初始化。
- new 表达式使用的类型与变量声明的类型相同。
代码中:
bool CanBeSimplified(VariableDeclarationStatement varDecl)
{
if (varDecl.Variables.Count != 1)
return false;
VariableInitializer v = varDecl.Variables.Single();
ObjectCreateExpression oce = v.Initializer as ObjectCreateExpression;
if (oce == null)
return false;
// It is not clear yet how to compare two AST nodes for equality
// Equals() would just use reference equality
return ?AreEqualTypes?(varDecl.Type, oce.Type);
}
虽然在这种情况下不算太糟糕,但当检查更复杂的构造时,这种命令式的条件测试代码很快就会变得难以阅读。幸运的是,NRefactory 提供了一种声明式替代方案:**模式匹配**。
一个**模式**是一个包含特殊**模式节点**的语法树。模式的工作方式类似于 .NET 中的正则表达式,不同之处在于它们处理的是语法节点而不是字符。
在我们的例子中,我们可以使用这个模式:
var pattern = new VariableDeclarationStatement {
Type = new AnyNode("type"),
Variables = {
new VariableInitializer {
Name = Pattern.AnyString,
Initializer = new ObjectCreateExpression {
Type = new Backreference("type"),
Arguments = { new Repeat(new AnyNode()) }
}
}
}};
要使用此模式,请调用 IsMatch
或 Match
扩展方法。Match
方法返回一个对象,可用于检索匹配的详细信息,例如命名捕获组的内容。Match m = pattern.Match(someNode);
if (m.Success) {
// Replace redundant type name with 'var'
m.Get<AstType>("type").Single().ReplaceWith(new SimpleType("var"));
}
模式不必包含任何特殊的模式节点 – 任何普通语法树也可以用作模式。普通节点只会匹配其他语法上相同的节点(但会忽略空格和注释)。因此,模式匹配也回答了如何比较两个类型节点的问题:我们可以编写‘return varDecl.Type.IsMatch(oce.Type);
’。
模式是严格的 - 它们只匹配语法上相同的节点,任何变体都必须明确指定。事实上,我们的模式并不等同于前面的命令式代码:它将无法匹配 List<int> x = new List<int> { 0 };
,因为模式没有考虑对象/集合初始化器。为了修复模式,我们可以将 'Initializer = new OptionalNode(new AnyNode())
' 插入到 ObjectCreateExpression 中。
然而,这种严格性也可以是一种优势。我们的模式与命令式代码之间存在第二个区别:模式会拒绝 const int x = new int();
。这是一个有效的常量声明,但我们 const var
将是无效的 C# 代码 – 拒绝此代码是正确的!为了修复我们最初的 CanBeSimplified 方法,我们需要一个显式测试:
if (varDecl.Modifiers != Modifiers.None)
return false;
另一方面,我们可以通过在模式的初始化器中使用 Modifiers = Modifiers.Any
来使我们的模式接受常量声明。
这使得模式匹配成为实现重构的绝佳工具,因为您的重构不会意外地触及您未曾考虑过的情况。根据我在 ILSpy 工作中的经验,编写模式比编写手动检查所有边界条件的 C# 代码要容易得多。
摘要
以上是我们关于语法树的讨论。如果您想了解更多信息,请下载演示应用程序。它是学习 NRefactory 如何表示给定 C# 代码片段的绝佳工具。
类型系统
在我们可以讨论 C# 代码的语义分析之前,我们需要向 NRefactory 提供必要的信息。一个 C# 代码文件不能独立分析;我们需要知道哪些类和方法存在。这取决于同一项目中的其他代码文件以及引用的程序集和项目。在 NRefactory 中,所有这些信息合在一起称为类型系统 – 它不仅包含类型,还包含方法/属性/事件和字段。
NRefactory 中实际上有两种类型系统:**未解析**类型系统和**已解析**类型系统。未解析类型系统旨在作为构建类型系统的“暂存区”。未解析类型系统基本上包含与语法树中的声明相同的信息,但使用与语言无关的接口表示它。它不包含任何关于方法体的信息,并且比保留所有语法树更节省内存。
此图显示了 NRefactory 及其两种类型系统的架构:
红色框表示 API 是语言无关的;蓝色框表示 C# 特定 API。目前 C# 是 NRefactory 5 支持的唯一语言,但我们计划将来添加 VB 支持。
请注意,未解析类型系统是可序列化的(使用 .NET 的 BinaryFormatter
或 NRefactory 中包含的 FastSerializer
);这在 SharpDevelop 和 MonoDevelop 中用于加快解决方案加载速度(解析整个项目需要一些时间 – 在我的机器上,NRefactory 每秒解析 70,000 行)。
类型系统中有什么
类型系统提供的信息与 System.Reflection 大致相同。
类型系统的根对象是 ICompilation。一个编译由一个主程序集(正在编译的程序集)和一组引用的程序集组成。每个程序集都包含一组类型定义和程序集属性。类型定义包含其他类型定义和成员。这是**实体** - 类和成员定义的层次结构。
类型系统的另一部分当然是**类型**。NRefactory 中的类型是以下之一:
- 类型定义 – 类/结构/枚举/委托/接口类型。示例:
int
,IDisposable
,List<T>
- 数组类型。示例:
string[]
- 参数化类型。示例:
List<string>
- 指针类型。示例:
int*
- 托管引用类型。示例:
ref int
- 特殊类型:
dynamic
、未知类型或 null 字面量的类型 - 类型参数。示例:
T
可以使用 IType.Kind
属性区分不同类型的种类。特殊“未知类型”用于表示无法解析的类型(例如,由于缺少类而导致的编译器错误)。NRefactory 提供的 IType
永远不会为 null;SpecialType.UnknownType 用作空对象。
下表描述了语法树类与两种类型系统之间的关系
C# 语法树 | 未解析类型系统 | 已解析类型系统 |
AstType | ITypeReference | IType |
TypeDeclaration | IUnresolvedTypeDefinition | ITypeDefinition |
EntityDeclaration | IUnresolvedEntity | IEntity |
FieldDeclaration | IUnresolvedField | IField |
PropertyDeclaration / IndexerDeclaration | IUnresolvedProperty | IProperty |
MethodDeclaration / ConstructorDeclaration / OperatorDeclaration / Accessor | IUnresolvedMethod | IMethod |
EventDeclaration | IUnresolvedEvent | IEvent |
Attribute | IUnresolvedAttribute | IAttribute |
Expression | IConstantValue | ResolveResult |
PrivateImplementationType | IMemberReference | IMember |
ParameterDeclaration | IUnresolvedParameter | IParameter |
Accessor | IUnresolvedMethod | IMethod |
NamespaceDeclaration | UsingScope | ResolvedUsingScope |
- | - | INamespace |
SyntaxTree | IUnresolvedFile | - |
- | IUnresolvedAssembly | IAssembly |
- | IProjectContent | ICompilation |
大多数使用 NRefactory 的代码只需要处理语法树和已解析类型系统。
构建类型系统
要构建类型系统,我们需要向 NRefactory 提供与启动命令行上的 Microsoft C# 编译器所需的信息大致相同的信息:项目中的所有代码文件、引用的程序集列表以及一些项目设置。
NRefactory 本身不包含从 .sln
或 .csproj
文件读取该信息的代码。幸运的是,本文随附的示例应用程序包含此功能;您可以随意将其复制到您自己的项目中。
示例应用程序包含一个小型项目模型,由 Solution
、CSharpProject
和 CSharpFile
类组成。
对于每个项目,我们使用 MSBuild 打开 .csproj 文件,并确定相关的编译器设置。我们为每个项目创建一个 CSharpProjectContent
实例 - 这是未解析类型系统的根对象。请注意,此类是不可变的,因此每当我们添加元素/设置属性时,都需要使用返回值。
对于项目中的每个代码文件,我们使用 CSharpParser
解析它,然后将其添加到项目内容中,如下所示:
pc = pc.AddOrUpdateFiles(syntaxTree.ToTypeSystem());
示例应用程序还将完整的语法树存储在 CSharpFile
类中。这使得我们的示例应用程序更简单,但在加载大型解决方案时可能会导致大量内存使用。在 SharpDevelop IDE 中,我们通常只在内存中保留当前打开文件的语法树。对于其他文件,我们只保留更小的类型系统。
至于引用的程序集,我们使用 Microsoft.Build
库(.NET 框架的一部分)来运行 MSBuild ResolveAssemblyReferences
任务。此任务确定程序集文件的完整路径。这将根据项目的目标框架获取正确版本的程序集。
要将这些程序集加载到 NRefactory 中,我们可以使用 CecilLoader 类:
foreach (string assemblyFile in ResolveAssemblyReferences(p)) {
IUnresolvedAssembly assembly = new CecilLoader().LoadAssemblyFile(assemblyFile);
pc = pc.AddAssemblyReferences(assembly);
}
最后一步,在项目加载完成后,创建已解析的类型系统。compilation = pc.CreateCompilation();
在上面的描述中,我忽略了项目引用。CSharpProjectContent
类实现了 IAssemblyReference
,因此可以通过将项目内容传递给 AddAssemblyReferences
来创建项目引用,就像我们对程序集文件引用所做的那样。但是,在我们的解决方案加载逻辑中,我们不知道引用的项目是否已加载 - 它可能在项目列表中稍后出现。由于 CSharpProjectContent
是不可变的,直接使用项目内容将引用该项目内容的精确版本 - 这意味着无法通过这种方式创建循环引用。
为避免此问题,NRefactory 提供了 ProjectReference
类。只有在创建编译时,它才会查找引用的项目的正确版本。这种间接寻址允许我们以任何顺序构建未解析类型系统,甚至可以表示循环依赖。解决方案中可用项目内容的列表可以通过 DefaultSolutionSnapshot
类提供给 NRefactory。有关更多详细信息,请查看示例应用程序代码。
语义分析
有了类型系统,我们就拥有了进行语义分析所需的所有信息。
要检索 C# AST 节点的语义,我们可以使用 CSharpAstResolver
类。
CSharpAstResolver resolver = new CSharpAstResolver(compilation, syntaxTree, unresolvedFile);
ResolveResult result = resolver.Resolve(node);
要创建 C# AST 解析器,我们需要提供已解析的类型系统(编译)和 C# 语法树的根节点。可选地,我们还可以提供由语法树创建并注册到类型系统中的 IUnresolvedFile。如果提供,它将用于确定 AST 声明和类型系统之间的映射。否则,解析器将使用成员签名来确定此映射 – 这通常速度较慢,如果 C# 程序中有错误(例如,如果由于缺少类型而无法确定方法签名),则可能会失败。因此,最好在创建类型系统时存储 IUnresolvedFile,以便我们可以将其提供给解析器。
创建解析器实例后,将使用 Resolve()
方法来确定语法树中任何节点的语义。CSharpAstResolver
具有已解析节点的内部缓存,因此如果您使用同一个 CSharpAstResolver
解析以下示例中的两个 IndexOf()
调用,变量 tmp
的类型将只解析一次。
var tmp = a.complex().expression;
int a = tmp.IndexOf("a");
int b = tmp.IndexOf("b");
ResolveResult
是一个对象树,代表表达式的语义。您可以使用 NRefactory.Demo 中的“Resolve”按钮查看此树。
在语义树中,所有操作都已完全解析 – “args”已知是一个参数,“Length”已知指向
等。 System.Array.Length
语义树可能比语法树的节点少 – 例如,括号被忽略,常量在可能的情况下被折叠。
另一方面,语义树可能包含语法树中不存在的额外节点 – 在此示例中,ConversionResolveResult
是一个额外的节点,它表示从 int
到 double
的隐式转换。此类转换仅在解析父节点时出现在语义树中;仅解析“args.Length
”表达式将导致 MemberResolveResult
。
可以使用 CSharpAstResolver.GetConversion()
和 GetExpectedType()
方法来检索应用于给定节点的转换。
最后,CSharpAstResolver
还提供返回解析器状态(作用域中的局部变量等)在给定 AST 节点的方法。这可用于创建第二个 CSharpAstResolver
,该解析器在不同文件的上下文中分析一小段代码。例如,SharpDevelop 使用此功能在调试器中解析监视表达式,其上下文是当前指令指针。
示例:查找 IndexOf() 调用
string.IndexOf()
方法很难正确使用:如果您忘记指定字符串比较,它将使用区分区域性的比较。区分区域性的比较通常与程序员的期望相反,导致错误。例如,程序员可能期望调用 'text.Substring(text.IndexOf("file://") + 7)
' 总是返回第二个“/”之后的文本。然而,IndexOf
也会匹配带有连字 “fi”(单个 Unicode 字符)的“fi”,并且通过加 7,代码最终会跳过文件名中的第一个字符。各种语言中都有许多其他特殊字符可能导致类似问题。
因此,始终使用 StringComparison.Ordinal
是一个很好的指导原则。序数比较就像程序员期望的那样工作,并且在处理 URL、文件名以及几乎所有其他内容时都是正确的选择。
我们将使用 NRefactory 来查找所有缺少 StringComparison
参数的 string.IndexOf()
调用。
过程相对简单:在解析代码并按上述方式设置类型系统后,我们解析每个代码文件中的所有调用表达式,并确定正在调用哪个 IndexOf()
重载。
var astResolver = new CSharpAstResolver(compilation, file.SyntaxTree, file.UnresolvedFile);
foreach (var invocation in file.SyntaxTree.Descendants.OfType<InvocationExpression>()) {
// Retrieve semantics for the invocation:
var rr = astResolver.Resolve(invocation) as InvocationResolveResult;
if (rr == null) {
// Not an invocation resolve result - could be a UnknownMemberResolveResult instead
continue;
}
if (rr.Member.FullName != "System.String.IndexOf") {
// Invocation isn't a string.IndexOf call
continue;
}
if (rr.Member.Parameters.First().Type.FullName != "System.String") {
// Ignore the overload that accepts a char, as that doesn't take a StringComparison.
// (looking for a char always performs the expected ordinal comparison)
continue;
}
if (rr.Member.Parameters.Last().Type.FullName == "System.StringComparison") {
// Already using the overload that specifies a StringComparison
continue;
}
Console.WriteLine(invocation.GetRegion() + ": " + invocation.GetText());
file.IndexOfInvocations.Add(invocation);
}
修改 C# 代码
现在我们将扩展我们的示例应用程序,通过在正确的位置插入“, StringComparison.Ordinal
”来自动修复程序。
NRefactory 提供了两种代码修改方法。最简单的一种是修改语法树,然后从修改后的树生成整个文件的代码。然而,这有一个主要缺点:整个文件将被重新格式化(语法树保留注释,但不保留空格)。此外,如果解析文件时存在语法错误,解析后的语法树可能不完整;因此再次输出它可能会删除文件的部分内容。
我们将采用一种更复杂的方法:使用 AST 中存储的位置,我们将编辑应用于原始源代码文件。NRefactory 已经包含了一个为此目的的辅助类:DocumentScript
。
var document = new StringBuilderDocument(file.OriginalText);
var formattingOptions = FormattingOptionsFactory.CreateAllman();
var options = new TextEditorOptions();
using (var script = new DocumentScript(document, formattingOptions, options)) {
foreach (InvocationExpression expr in file.IndexOfInvocations) {
var copy = (InvocationExpression)expr.Clone();
copy.Arguments.Add(new IdentifierExpression("StringComparison").Member("Ordinal"));
script.Replace(expr, copy);
}
}
File.WriteAllText(Path.ChangeExtension(file.FileName, ".output.cs"), document.Text);
此代码首先将文本加载到文档中。IDocument
类似于 StringBuilder
(在 StringBuilderDocument
的情况下,它使用实际的 StringBuilder
作为底层数据结构),但还支持偏移量(从文件开头计数字符数)和 TextLocation
(行/列对)之间的映射。
DocumentScript
类允许将基于 AST 的重构应用于文档。此类的主要功能是它维护原始文档中的位置与当前文档中的位置之间的映射。例如,在文档开头插入新语句会导致所有其他语句向下移动一行,但 AST 仍然包含原始位置。在这种情况下,DocumentScript
会自动处理查找 AST 节点的新位置,以便进一步的替换按预期工作。
请注意,DocumentScript
期望被替换的 AST 节点属于旧文档状态(旧状态 = DocumentScript
构造函数调用时)。不一致可能导致替换失败。为避免因意外修改旧 AST(例如,忘记调用 Clone())而导致的错误,您可以调用 file.CompilationUnit.Freeze()
- 这将导致 AST 修改引发异常,从而更容易调试此类问题。
与其替换整个调用,我们还可以执行更具针对性的文本插入,放在闭合括号的前面:
int offset = script.GetCurrentOffset(expr.RParToken.StartLocation);
script.InsertText(offset, ", StringComparison.Ordinal");
从类型系统生成代码
我们的 AST 替换始终插入 StringComparison.Ordinal
,假设存在 using System;
。我们可以做得更好,使用 TypeSystemAstBuilder
辅助类让 NRefactory 为我们生成类型引用。这将在可能的情况下生成一个简短的类型引用;或者在必要时生成一个完全限定的类型引用。
为了使其正常工作,我们需要提取我们要插入代码的位置的*解析器上下文*(可用的 using、声明的变量等)。
// Generate a reference to System.StringComparison in this context:
var astBuilder = new TypeSystemAstBuilder(astResolver.GetResolverStateBefore(expr));
IType stringComparison = compilation.FindType(typeof(StringComparison));
AstType stringComparisonAst = astBuilder.ConvertType(stringComparison);
// Clone a portion of the AST and modify it
var copy = (InvocationExpression)expr.Clone();
copy.Arguments.Add(stringComparisonAst.Member("Ordinal"));
script.Replace(expr, copy);
使用 TypeSystemAstBuilder
,我们可以将任何类型系统实体转换回 C# AST 节点。这不仅适用于类型引用,还适用于属性、方法声明等。
总结
希望本文让您对 NRefactory 的工作原理有所了解。如果您想了解更多信息,请尝试使用演示应用程序来了解语法树和语义树。如果您有任何疑问,请随时使用下面的消息板。
历史
- 2009 年末:关于重构 SharpDevelop 代码完成类型系统的初步想法
- 2010-07-26:NRefactory 重写开始
- 2011-02-11:ILSpy 开始使用 NRefactory 5
- 2012-04-14:MonoDevelop 3.0 发布
- 2012-05-18:NRefactory 5.0.1 发布
- 2012-08-11:NRefactory 5.2 发布 + 本文发布