从Visual Studio里抓取抽象语法树(AST)
前几天测试一个代码生成的软件,测试目的是将软件生成的C#或者VB.NET源代码文件,和之前的基准C#或者VB.NET源代码文件进行对比。如果实际生成的文件和基准文件有不一致的地方,就说明,软件有潜在的编码失误(Bug)。
当前的方法是将两个文件读入内存,一行一行逐字逐字地对比。当然啦,为了避免空格的问题,文件事先已经将空格都删除掉了。但是,这种方法的问题是,很多时候,软件生成的源代码文件中,虽然代码行的放置顺序不一样,但是实现的功能是完全一样的。举个例子,在使用Visual Studio中编写Winform程序时,在InitializeComponent()函数里面,先生成创建按钮的代码,然后再生成文本框的代码;与先生成创建文本框的代码,然后再生成按钮的代码的效果是完全一样的。
那这样是否可以尝试这种实现,将两个文件读入内存中,然后将文件按照代码行排序后再对比?这样也不行,因为你不能将调用构造对象的代码放在使用对象的代码后面。
于是,我们就想是否能够通过对比实际的CodeDom与基准CodeDom来实现?一般来说,在.NET世界里,代码生成的功能都是通过CodeDom技术来实现的。CodeDom通俗点讲,就是一个抽象的代码树—不依赖任何编程语言,可以使用不同的语言生成器遍历CodeDom来生成不同语言的源代码文件—对CodeDom感兴趣的读者可以自己参考MSDN上面的说明。
然而这个方案还是被大家否决了,因为前几个版本的测试过程中,使用的是文件对比的模式,已经生成了很多基准源代码文件了。如果使用CodeDom技术,这就意味着需要为前面几百个基准源文件重新生成对应的基准CodeDom。
这个时候我想到使用编译器来分析两个源代码文件,然后对比结果的抽象语法树来达到类似CodeDom的功效。我有两个编译器可以支持这个方案,一个是csc.exe,另外一个是Visual Studio用来支持实时语法高亮显示的编译器。
为了支持实时的语法高亮显示以及智能感应功能,Visual Studio实际上在后台线程运行编译器进行实时编译,在需要执行语法高亮、智能感应、代码重构等功能时,Visual Studio会查询后台编译器里保存的符号表、抽象语法树来获取相关的实时信息。
但是这个编译器和我们日常工作编译C#(这里以C#为例)的编译器csc.exe不是同一个东西。之所以要另外为Visual Studio单独实现一个编译器,因为
1. 在进行实时语法高亮显示,智能感应等功能时。编译器不是处理一个完整的源代码。这跟csc.exe不一样,因为csc.exe处理的是完整的C#源代码。
2. 另外, csc.exe与支持语法高亮显示的编译器对于语法错误的态度也是不一样的,csc.exe可以不容忍任何语法错误,即一旦有语法错误发生,csc.exe可以拒绝处理后续的语义分析的工作。然而语法高亮编译器却不能这样,毕竟使用它的时候,程序员正在编写源代码,有很多尚未完成的地方。即使输入的源文件代码有很多的语法错误,语法高亮编译器也需要能够继续执行后续的编译任务(例如语义分析)。
3. 还有语法高亮编译器还需要可以实现增量编译的功能,即后续加入的源代码行合并到以编译好的代码中。比如说,在调试过程中,你可以在“立即”窗口里面定义一个变量,然后可以在同一个表达式里同时评估这个变量和被调程序已有的变量的计算结果。
下面两个.NET Assembly是Visual Studio用来支持C#实时语法高亮等功能的(实际上,你还需要一个Win32 C++的DLL文件,但是这个文件不会被我的程序直接用到):
1. Microsoft.VisualStudio.CSharp.Services.Language.dll
2. Microsoft.VisualStudio.CSharp.Services.Language.Interop.dll
这两个文件只有安装了Visual Studio才会有,你既可以在Visual Studio的安装文件夹里,也可以在GAC里面找到它们。
因为这两个DLL不是Visual Studio公开的API,所以它们和Visual Studio绑定的很紧密,即你只能在Visual Studio里使用它们,不能在其他程序中使用—除非你把Visual Studio SDK里由Visual Studio提供的晦涩的接口都实现了。
因此我的程序也就只好以Visual Studio的插件(Add in)的形式实现,在Visual Studio里(我用的Visual Studio 2010)创建一个新的Visual Studio Add-Ins工程,将上面两个DLL文件引用进来。在Exec函数里面实现对应的逻辑就好了,下面是相关代码:
public void Exec(string commandName, vsCommandExecOption executeOption, ref object varIn, ref object varOut, ref bool handled) { handled = false; if(executeOption == vsCommandExecOption.vsCommandExecOptionDoDefault) { if(commandName == "MyAddin1.Connect.MyAddin1") { // 实现自定义的逻辑 TestCSharpCompiler(); handled = true; return; } } }
private void TestCSharpCompiler() { // 获取当前Visual Studio的解决方案,如果Visual Studio还没有任何方案 // 就是默认的空解决方案 var solution = (Solution2)_applicationObject.Solution; // 创建一个新的“C# 命令行程序(C# Console Application)”工程 var csTemplatePath = solution.GetProjectTemplate("ConsoleApplication.zip", "CSharp"); // 工程名(Test Project)以及保存工程的文件夹路径(d:\temp\test) solution.AddFromTemplate(csTemplatePath, @"d:\temp\test", "Test Project", false); var project = solution.Projects.Item(1); // 将已有的文件(d:\temp\test.cs)添加到新创建的工程中 project.ProjectItems.AddFromFileCopy(@"d:\temp\test.cs"); // 激活编译器 var host = new IDECompilerHost(); var compiler = host.CreateCompiler(project); SourceFile source = null;
// 工程里一般都有很多文件,找到感兴趣的源文件 // 因为那个文件的抽象语法树是我要的东西 foreach (var file in compiler.SourceFiles) { if (string.Compare(file.Key.Value, @"d:\temp\test\test.cs", StringComparison.InvariantCultureIgnoreCase) == 0) { source = file.Value; break; } }
// 获取语法树的根节点,一般就是源文件最外层的命名空间 var tree = source.GetParseTree(); IDECompilation compilation = (IDECompilation)compiler.GetCompilation(); // 在语法树里获取第一个命名空间的节点 compilation.CompileTypeOrNamespace(tree.RootNode); var node = tree.RootNode as NamespaceDeclarationNode; // 获取命名空间节点里面的类定义、或者子命名空间、或者其它 // 可以定义在命名空间里面的元素的节点 foreach (var child in node.NamespaceMemberDeclarations.Root.Children) { if (child is BinaryExpressionNode) { var bnode = child as BinaryExpressionNode; var left = bnode.Left as ClassDeclarationNode; var right = bnode.Right as ClassDeclarationNode;
Trace.WriteLine(left.Identifier.Name.Text); Trace.WriteLine(right.Identifier.Name.Text); } else { Trace.WriteLine(child.AsName().Name.Text); } } } |
上面的代码只是做演示用的,里面解析的源代码(test.cs)已经包含到下面的完整工程的源文件里了(工程文件是Visual Studio 2010格式的):
/Files/killmyday/MyAddinForAST.zip