语法转换入门

本教程基于 语法分析 入门和 语义分析 快速入门中探讨的概念和技术。 如果尚未完成那些快速入门,则应先完成,然后再开始本快速入门。

在本快速入门中,你将探索用于创建和转换语法树的技术。 结合您在前面快速入门教程中学到的技术,您可以创建自己的第一个命令行重构!

安装说明 - Visual Studio 安装程序

Visual Studio 安装程序中查找 .NET 编译器平台 SDK 有两种不同的方法:

使用 Visual Studio 安装程序进行安装 - 工作负载视图

不会自动选择 .NET 编译器平台 SDK 作为 Visual Studio 扩展开发工作负载的一部分。 必须将其选为可选组件。

  1. 运行 Visual Studio 安装程序
  2. 选择 “修改”
  3. 检查 Visual Studio 扩展开发 工作负载。
  4. 在摘要树中打开 Visual Studio 扩展开发 节点。
  5. 选中 .NET 编译器平台 SDK 的框。 你将在可选组件下找到最后一个组件。

(可选)还需要 DGML 编辑器 在可视化工具中显示图形:

  1. 在摘要树中打开 “单个组件 ”节点。
  2. 勾选 DGML 编辑器 的框

使用 Visual Studio 安装程序 - 单个组件选项卡进行安装

  1. 运行 Visual Studio 安装程序
  2. 选择 “修改”
  3. 选择 “单个组件 ”选项卡
  4. 请勾选 .NET 编译器平台 SDK 的选框。 可在 编译器、生成工具和运行时 部分的顶部找到它。

(可选)还需要 DGML 编辑器 在可视化工具中显示图形:

  1. 选中 DGML 编辑器 的框。 可在 “代码工具” 部分下找到它。

不可变性和 .NET 编译器平台

不可变性 是 .NET 编译器平台的基本原则。 创建不可变数据结构后无法更改。 多个使用者可以安全地共享和分析不可变数据结构。 没有一个消费者会以不可预知的方式影响另一个消费者的风险。 分析器不需要锁或其他并发控制机制。 此规则适用于语法树、编译、符号、语义模型以及遇到的所有其他数据结构。 API 不修改现有结构,而是根据与旧结构之间的指定差异创建新对象。 将此概念应用于语法树,以使用转换创建新树。

创建和转换树

为语法转换选择两种策略之一。 搜索要替换的特定节点或要插入新代码的特定位置时,最好使用工厂方法。 如果要扫描整个项目以查找要替换的代码模式,则重写器是最佳选择。

使用工厂方法创建节点

第一个语法转换演示工厂方法。 你将用 using System.Collections.Generic; 语句替换 using System.Collections; 语句。 此示例演示如何使用Microsoft.CodeAnalysis.CSharp.CSharpSyntaxNode工厂方法创建Microsoft.CodeAnalysis.CSharp.SyntaxFactory对象。 对于每种 节点令牌琐事,有一个工厂方法创建该类型的实例。 可以通过以从下到下的方式分层地组合节点来创建语法树。 然后,通过将现有节点替换为已创建的新树来转换现有程序。

启动 Visual Studio,并创建新的 C# Stand-Alone 代码分析工具 项目。 在 Visual Studio 中,选择“文件>新建项目”以显示“>项目”对话框。 在 Visual C#>扩展性 下,选择 Stand-Alone 代码分析工具。 本快速入门包含两个示例项目,因此请命名解决方案 SyntaxTransformationQuickStart,并将项目命名为 ConstructionCS。 单击 “确定”

此项目使用Microsoft.CodeAnalysis.CSharp.SyntaxFactory类的方法来构造一个Microsoft.CodeAnalysis.CSharp.Syntax.NameSyntax,以表示System.Collections.Generic命名空间。

请将 using 指令添加到 Program.cs 的顶部。

using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
using static System.Console;

你将创建 名称语法节点 来生成表示语句的 using System.Collections.Generic; 树。 NameSyntax 是 C# 中显示的四种类型的名称的基类。 将这四种类型的名称组合在一起,以创建可在 C# 语言中显示的任何名称:

使用该方法 IdentifierName(String) 创建 NameSyntax 节点。 在方法Main中添加Program.cs以下代码:

NameSyntax name = IdentifierName("System");
WriteLine($"\tCreated the identifier {name}");

前面的代码创建一个 IdentifierNameSyntax 对象并将其分配给变量 name。 许多 Roslyn API 返回基类,以便更轻松地处理相关类型。 当您构建QualifiedNameSyntax时,变量name(一个NameSyntax)可以重复使用。 生成示例时不要使用类型推理。 你将在此项目中自动执行该步骤。

已创建名称。 现在,是时候通过构建QualifiedNameSyntax将更多节点添加到树中了。 新树使用name作为名称的左侧,使用新的Collections作为IdentifierNameSyntax命名空间的右侧QualifiedNameSyntax。 将下列代码添加到 program.cs

name = QualifiedName(name, IdentifierName("Collections"));
WriteLine(name.ToString());

再次运行代码,并查看结果。 正在生成表示代码的节点树。 你将继续这种模式来为命名空间 System.Collections.Generic 构建 QualifiedNameSyntax。 将下列代码添加到 Program.cs

name = QualifiedName(name, IdentifierName("Generic"));
WriteLine(name.ToString());

再次运行程序以查看您已为新增代码构建的树。

创建修改后的树

你已生成一个包含一个语句的小型语法树。 用于创建新节点的 API 是创建单个语句或其他小代码块的正确选择。 但是,若要生成更大的代码块,应使用替换节点或将节点插入现有树的方法。 请记住,语法树是不可变的。 语法 API 不提供在构造后修改现有语法树的任何机制。 而是提供基于现有树更改生成新树的方法。 With* 方法是在从 SyntaxNode 派生的具体类或作为 SyntaxNodeExtensions 类中声明的扩展成员中定义的。 这些方法通过将更改应用到现有节点的子属性来创建一个新节点。 此外, ReplaceNode 扩展成员还可用于替换子树中的子代节点。 此方法还会更新父节点以指向新创建的子节点,并将这一过程重复到整棵树的最顶部——这一过程被称为重新调整树结构

下一步是创建表示整个(小)程序的树,然后对其进行修改。 将以下代码添加到类的 Program 开头:

        private const string sampleCode =
@"using System;
using System.Collections;
using System.Linq;
using System.Text;

namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(""Hello, World!"");
        }
    }
}";

注释

示例代码使用 System.Collections 命名空间而不是 System.Collections.Generic 命名空间。

接下来,将以下代码添加到方法底部 Main 以分析文本并创建树:

SyntaxTree tree = CSharpSyntaxTree.ParseText(sampleCode);
var root = (CompilationUnitSyntax)tree.GetRoot();

此示例使用 WithName(NameSyntax) 方法将 UsingDirectiveSyntax 节点中的名称替换为前面代码中构造的名称。

使用WithName(NameSyntax)方法创建新UsingDirectiveSyntax节点,以你在前面的代码中创建的名称更新System.Collections名称。 将以下代码添加到方法底部 Main

var oldUsing = root.Usings[1];
var newUsing = oldUsing.WithName(name);
WriteLine(root.ToString());

运行程序并仔细查看输出。 newUsing 尚未被放置在根目录中。 原始树尚未更改。

使用 ReplaceNode 扩展方法添加以下代码以创建新树。 新的树是通过将现有导入替换为更新的newUsing节点而产生的。 将此新树分配给现有 root 变量:

root = root.ReplaceNode(oldUsing, newUsing);
WriteLine(root.ToString());

再次运行程序。 这一次,树现在正确导入 System.Collections.Generic 命名空间。

使用 SyntaxRewriters 转换树

With*ReplaceNode方法提供了方便的方法来转换语法树的各个分支。 该 Microsoft.CodeAnalysis.CSharp.CSharpSyntaxRewriter 类对语法树执行多个转换。 Microsoft.CodeAnalysis.CSharp.CSharpSyntaxRewriter 类是 Microsoft.CodeAnalysis.CSharp.CSharpSyntaxVisitor<TResult> 的子类。 将CSharpSyntaxRewriter应用于特定类型的SyntaxNode转换。 无论它们出现在语法树中的哪个位置,都可以将转换应用于多个类型的 SyntaxNode 对象。 在本快速入门的第二个项目中,创建了一个命令行重构工具,用于在可以应用类型推断的任何地方,删除本地变量声明中的显式类型。

创建新的 C# Stand-Alone 代码分析工具 项目。 在 Visual Studio 中,右键单击 SyntaxTransformationQuickStart 解决方案节点。 选择 “添加新>项目 ”以显示 “新建项目”对话框。 在 Visual C#>扩展性下,选择 Stand-Alone 代码分析工具。 命名项目 TransformationCS ,然后单击“确定”。

第一步是创建一个继承自 CSharpSyntaxRewriter 的类,以便执行您的转换。 向项目添加新的类文件。 在 Visual Studio 中,选择 “项目>添加类...”。在“ 添加新项 ”对话框中,键入 TypeInferenceRewriter.cs 文件名。

将以下 using 指令添加到 TypeInferenceRewriter.cs 该文件:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

接下来,使 TypeInferenceRewriter 类扩展 CSharpSyntaxRewriter 类:

public class TypeInferenceRewriter : CSharpSyntaxRewriter

添加以下代码以声明专用只读字段,以在构造函数中保存 SemanticModel 并初始化它。 稍后需要此字段来确定可以使用类型推理的位置:

private readonly SemanticModel SemanticModel;

public TypeInferenceRewriter(SemanticModel semanticModel) => SemanticModel = semanticModel;

VisitLocalDeclarationStatement(LocalDeclarationStatementSyntax)重写方法:

public override SyntaxNode VisitLocalDeclarationStatement(LocalDeclarationStatementSyntax node)
{

}

注释

许多 Roslyn API 声明返回类型,这些类型是返回的实际运行时类型的基类。 在许多情况下,一种节点可能会被另一种类型的节点完全替换,甚至被删除。 在此示例中,该方法 VisitLocalDeclarationStatement(LocalDeclarationStatementSyntax) 返回一个 SyntaxNode而不是派生类型的 LocalDeclarationStatementSyntax值。 此重写程序基于现有节点返回一个新 LocalDeclarationStatementSyntax 节点。

本快速入门教程处理了局部变量的声明。 可以将它扩展到其他声明,例如 foreach 循环、 for 循环、LINQ 表达式和 lambda 表达式。 此外,此重写器将仅转换最简单的形式的声明:

Type variable = expression;

若要自行探索,请考虑为这些类型的变量声明扩展已完成的示例:

// Multiple variables in a single declaration.
Type variable1 = expression1,
     variable2 = expression2;
// No initializer.
Type variable;

将以下代码添加到方法正文 VisitLocalDeclarationStatement ,以跳过重写这些形式的声明:

if (node.Declaration.Variables.Count > 1)
{
    return node;
}
if (node.Declaration.Variables[0].Initializer == null)
{
    return node;
}

该方法通过返回 node 未修改的参数来指示不会进行重写。 如果 if 这两个表达式中都不为 true,则节点可能表示初始化的声明。 添加这些语句以提取声明中指定的类型名称,并使用字段绑定它 SemanticModel 以获取类型符号:

var declarator = node.Declaration.Variables.First();
var variableTypeName = node.Declaration.Type;

var variableType = (ITypeSymbol)SemanticModel
    .GetSymbolInfo(variableTypeName)
    .Symbol;

现在,添加此语句以绑定初始值设定项表达式:

var initializerInfo = SemanticModel.GetTypeInfo(declarator.Initializer.Value);

最后,添加以下 if 语句,以将现有类型名称替换为 var 关键字(如果初始值设定项表达式的类型与指定的类型匹配):

if (SymbolEqualityComparer.Default.Equals(variableType, initializerInfo.Type))
{
    TypeSyntax varTypeName = SyntaxFactory.IdentifierName("var")
        .WithLeadingTrivia(variableTypeName.GetLeadingTrivia())
        .WithTrailingTrivia(variableTypeName.GetTrailingTrivia());

    return node.ReplaceNode(variableTypeName, varTypeName);
}
else
{
    return node;
}

条件是必需的,因为声明可能会将初始值设定项表达式强制转换为基类或接口。 如果这样的话,赋值操作中左侧和右侧的类型不匹配。 在这些情况下删除显式类型将更改程序的语义。 var 指定为标识符而不是关键字,因为 var 是上下文关键字。 前导和尾随琐事(空格)从旧类型名称 var 传输到关键字,以保持垂直空白和缩进。 使用 ReplaceNodeWith* 转换 LocalDeclarationStatementSyntax 更简单,因为类型名称实际上是声明语句的孙子。

你已完成TypeInferenceRewriter。 现在返回到 Program.cs 文件以完成示例。 创建一个测试 Compilation 并从中获取 SemanticModel。 使用SemanticModel来尝试你的TypeInferenceRewriter。 你将最后执行此步骤。 同时,请声明一个占位符变量来表示您的测试编译:

Compilation test = CreateTestCompilation();

暂停片刻后,您应该会看到一个错误波浪线,报告不存在任何CreateTestCompilation 方法。 按 Ctrl+Period 打开提示灯,然后按 Enter 调用 生成方法存根 命令。 此命令将为类中CreateTestCompilation的方法生成方法存根Program。 稍后将返回以填写此方法:

C# 从使用情况生成方法

编写以下代码以遍历测试Compilation中的每个SyntaxTree。 对于每个树,请使用SemanticModel为该树初始化一个新的TypeInferenceRewriter

foreach (SyntaxTree sourceTree in test.SyntaxTrees)
{
    SemanticModel model = test.GetSemanticModel(sourceTree);

    TypeInferenceRewriter rewriter = new TypeInferenceRewriter(model);

    SyntaxNode newSource = rewriter.Visit(sourceTree.GetRoot());

    if (newSource != sourceTree.GetRoot())
    {
        File.WriteAllText(sourceTree.FilePath, newSource.ToFullString());
    }
}

在你创建的foreach语句内,添加以下代码以在每个源树上执行转换。 如果进行了任何编辑,此代码会有条件地写出新的转换树。 仅当重写程序遇到一个或多个可以使用类型推理简化的局部变量声明时,重写程序才应修改树:

SyntaxNode newSource = rewriter.Visit(sourceTree.GetRoot());

if (newSource != sourceTree.GetRoot())
{
    File.WriteAllText(sourceTree.FilePath, newSource.ToFullString());
}

应当在File.WriteAllText代码下看到波浪线。 选择灯泡,并添加必要的 using System.IO; 语句。

即将完成! 还剩最后一步:创建测试用例 Compilation。 由于您在本快速入门中一直没有使用类型推理,它本可以成为一个完美的测试用例。 遗憾的是,从 C# 项目文件创建编译超出了本演练的范围。 但幸运的是,如果你一直在仔细按照说明,有希望。 将方法的内容 CreateTestCompilation 替换为以下代码。 它创建一个与本快速入门中所述的项目相吻合的测试编译:

String programPath = @"..\..\..\Program.cs";
String programText = File.ReadAllText(programPath);
SyntaxTree programTree =
               CSharpSyntaxTree.ParseText(programText)
                               .WithFilePath(programPath);

String rewriterPath = @"..\..\..\TypeInferenceRewriter.cs";
String rewriterText = File.ReadAllText(rewriterPath);
SyntaxTree rewriterTree =
               CSharpSyntaxTree.ParseText(rewriterText)
                               .WithFilePath(rewriterPath);

SyntaxTree[] sourceTrees = { programTree, rewriterTree };

MetadataReference mscorlib =
        MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
MetadataReference codeAnalysis =
        MetadataReference.CreateFromFile(typeof(SyntaxTree).Assembly.Location);
MetadataReference csharpCodeAnalysis =
        MetadataReference.CreateFromFile(typeof(CSharpSyntaxTree).Assembly.Location);

MetadataReference[] references = { mscorlib, codeAnalysis, csharpCodeAnalysis };

return CSharpCompilation.Create("TransformationCS",
    sourceTrees,
    references,
    new CSharpCompilationOptions(OutputKind.ConsoleApplication));

交叉手指并运行项目。 在 Visual Studio 中,选择 “调试>开始调试”。 Visual Studio 会提示项目中的文件已更改。 单击“对所有内容选择是”以重新加载修改的文件。 观察他们,从中感受你的卓越。 请注意,代码看起来要更简洁,而没有所有这些显式和冗余类型说明符。

祝贺! 你已使用 编译器 API 编写自己的重构,用于搜索 C# 项目中的所有文件以查找某些语法模式,分析与这些模式匹配的源代码的语义,并对其进行转换。 你现在已正式成为重构作者!