65.9K
CodeProject 正在变化。 阅读更多。
Home

Slang 第一部分:将 C# 子集解析到 CodeDOM

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.43/5 (8投票s)

2019年12月4日

MIT

6分钟阅读

viewsIcon

10150

downloadIcon

176

初步了解一个工具,该工具可以极大地简化使用 CodeDOM 进行与语言无关的代码生成

引言

这是系列的第一部分,我们将构建一个工具,从 C# 源代码的一个子集生成语言无关的源代码。到最后我们还不能完全实现目标,但总得有个开始。我们将从工具的前端开始——解析。

背景

我们将使用微软的 CodeDOM 来表示从文档解析得到的解析树。CodeDOM 对于代码生成工具来说是一大福音,它提供了用任何存在 CodeDOM 提供程序的 .NET 语言格式化输出生成代码的功能。在 .NET 的“标准”发行版中,有 C# 和 VB,但也有针对其他语言的 NuGet 包。

不幸的是,使用它是一个我们真的不需要的头痛问题。对象模型冗长、文档糟糕,而且非常笨拙。例如,它甚至不使用泛型集合,而且它提供的“类型化”集合也很随意。你需要像下面这样的代码

var expr = new CodeFieldReferenceExpression(new CodeThisReferenceExpression(),"_state");

才能渲染这段代码(用 C# 语言)

this._state

这本身就是一个小小的噩梦。

我们更宁愿简单地使用

var expr=SlangParser.ParseExpression("this._state"); 

来达到同样的目的,对吧?这篇文章朝着这个方向迈出了巨大的一步。

使用这个烂摊子

这部分很简单。演示程序读取它自己的程序文件并将其转换为 VB。更难的是学习支持 C# 的哪些子集。我还没有为此编写语法。经验法则是,如果不存在 CodeDOM 对象,它就不能用 Slang 表示。这意味着很多运算符,如 +++=,以及 using 类型别名、嵌套命名空间、字段的 readonly 修饰符等,都无法使用。尽管有这些限制,但与直接使用 CodeDOM 相比,这仍然是巨大的改进。另外,注释目前会被从输出中剥离,行指示符也无法解析,因为这是一个初步版本。可能还会有 bug。你的体验可能不同。

using System;
using System.Collections.Generic;
using Slang;
namespace SlangDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(CodeDomUtility.ToString
             (SlangParser.ReadCompileUnitFrom("..\\..\\Program.cs"),"vb"));
        }
    }
}

输出

'------------------------------------------------------------------------------
' <auto-generated>
'     This code was generated by a tool.
'     Runtime Version:4.0.30319.42000
'
'     Changes to this file may cause incorrect behavior and will be lost if
'     the code is regenerated.
' </auto-generated>
'------------------------------------------------------------------------------

Option Strict Off
Option Explicit On

Imports Slang
Imports System
Imports System.Collections.Generic

Namespace SlangDemo
    Friend Class Program
        Public Shared Sub Main()
            Console.WriteLine(CodeDomUtility.ToString_
             (SlangParser.ReadCompileUnitFrom("..\..\Program.cs"), "vb"))
        End Sub
    End Class
End Namespace

它所做的只是使用 CodeDomUtility 来渲染从 ReadCompileUnitFrom() 返回的 CodeDOM 对象。

除了 SlangParser 上面提到的函数外,我们还有 ParseXXXX()ReadXXXXFromUrl() 方法来从各种来源解析各种构造。通常,我们会解析整个编译单元。

概念化这个混乱的局面

我们使用的是一个回溯递归下降解析器 像大多数递归下降解析器一样,这个解析器是手动编写的,而不是使用工具生成的。然而,我们目前正在使用一个分词器/词法分析器将原始文本分解为词素,而这个分词器是由一个工具生成的。该工具名为 Rolex,我在这里发布了一篇关于它是什​​么以及如何编写它的文章。将来,词法分析器本身可能会被手动编写,以克服当前实现的一些限制,但目前它是可用的。如果你想包含 rolex 二进制文件并为分词器设置自定义生成步骤,上面的链接包含构建这些二进制文件的源代码,而 SlangTokenizer.rl 包含设置生成步骤的说明。

解析器使用这些词素(表示为标记)来决定下一步解析什么。

static CodeExpression _ParseTerm(_PC pc)
{
    var lhs = _ParseFactor(pc);
    while (true)
    {
        var op = default(CodeBinaryOperatorType);
        _SkipComments(pc);
        switch (pc.SymbolId)
        {
            case ST.add: // +
                op = CodeBinaryOperatorType.Add;
                break;
            case ST.sub: // -
                op = CodeBinaryOperatorType.Subtract;
                break;
            default:
                return lhs;
        }
        pc.Advance();
        var rhs = _ParseFactor(pc);
        lhs = new CodeBinaryOperatorExpression(lhs, op, rhs);
    }
} 

这是相当标准的运算符优先级解析。然而,一些解析并不那么直接。

考虑一个类型转换

(long)1

我们在输入中可能会发现一个 (,但我们无法确定它是一个带括号的子表达式还是一个类型转换,直到我们进一步解析。如果我们解析查找类型转换而我们错了,那么我们就麻烦了,如果我们解析查找表达式而我们错了,情况也是如此。因此,我们回溯。

// possibly a cast, or possibly a subexpression
// we can't know for sure so this gets complicated
// basically we need to backtrack.
CodeExpression expr = null;
Exception ex=null;
var pc2 = pc.GetLookAhead();
pc2.EnsureStarted();
try
{
    expr = _ParseCast(pc2);
}
catch(Exception eex) { ex = eex; }
if(null!=expr)
{
    // now advance our actual pc
    // TODO: see if we can't add a dump feature
    // to the lookahead so we don't have to 
    // parse again. Minor, but sloppy.
    return _ParseCast(pc);

} else
{
    try
    {
        if (!pc.Advance())
            throw new ArgumentException("Unterminated cast or subexpression", "input");
        expr=_ParseExpression(pc);
        _SkipComments(pc);
        if(ST.rparen!=pc.SymbolId)
            throw new ArgumentException("Invalid cast or subexpression", "input");
        pc.Advance();
        return expr;
    }
    catch
    {
        if (null == ex)
            throw;
        throw ex;
    }
}

你可以看到这个 _PC(pc)对象(我们的解析上下文,我们稍后会讲到)被用来创建一个称为“向前查看”(look-ahead)的东西。然后我们像往常一样沿着这个向前查看进行解析,然后再丢弃它。实际上发生的是它正在运行一个尝试性的解析。向前查看光标(我们的解析上下文 pc2)不会前进其源解析上下文(pc)的位置,所以我们可以沿着向前查看解析任意数量的内容,而无需担心前进“真实”光标。这样,如果解析失败,我们就可以回到原来的位置,然后 `try` 其他方法,直到找到有效的方法。

我们的小 _PC 类,它管理着沿 IEnumerator<Token> 的运行光标,是我们获取当前标记以及如何前进输入的方式。它使用 LookAheadEnumerator<T> 来启用向前查看,它在底层使用 Queue<T> 作为向前查看缓冲区,所以当我们向前查看时,我们实际上是在前进实际的光标,但我们通过 Queue<T> 暴露一个外观(facade),通过缓冲区来掩盖这一点。解析上下文还必须将其当前标记预置到向前查看中,所以我们使用 ConcatEnumerator<Token> 来完成这个任务。我们也可以使用 LINQ,但我有这个现成的。否则,_PC 就相当直接了。

关于 CodeDOM 树要关注的主要一点是,尽管示例代码生成 VB 代码,但它在内部并不正确。CodeDOM 要求我们使用 CodePropertyReference 对象来引用属性,使用 CodeFieldReference 来引用字段。它还对什么是变量、(有时)什么是类型做出了假设。所以我们所有的成员引用碰巧都被报告为字段引用。更糟糕的是,我们的方法调用实际上被认为是委托调用,所以 Console.WriteLine(...) 被解释为委托字段 WriteLine 的委托调用!现在对于 VB 和 C# 来说,这无关紧要,但对于其他语言来说,这可能非常重要。

我们的 CodeDOM 树之所以是这样,是因为我们在解析过程中没有类型信息。没有它,我们就无法查询类型来找出什么是属性、什么是方法、什么是字段或事件,而且我们还不能这样做,因为我们的所有类型甚至还没有被解析。因此,我们将 CodeDOM 树的 UserData 条目标记为 slang:unresolved,以表明它们需要更多信息。

 

更新

我修复了一些解析 bug,并为 Slang 添加了一个 T4 风格的预处理器,所以现在,使用演示项目中的 Test.tt 中的类似内容

using System;
public class Test {
    public static void HelloWorld() {
<#for(var i =0;i<3;++i) {
#>        Console.WriteLine("Hello World! #<#=i+1#>");
<#}#>
    }
}

并使用以下代码段

var sw = new StringWriter();
using (var w = new StreamReader(@"..\..\Test.tt"))
    SlangPreprocessor.Preprocess(w, sw);
Console.WriteLine(CodeDomUtility.ToString(SlangParser.ParseCompileUnit(sw.ToString()),"vb"));
return;

你可以输出这个

Option Strict Off
Option Explicit On

Imports System

Public Class Test
    Public Shared Sub HelloWorld()
        Console.WriteLine("Hello World! #1")
        Console.WriteLine("Hello World! #2")
        Console.WriteLine("Hello World! #3")
    End Sub
End Class

所以现在你可以使用 T4 文本模板语法来构建你的 codedom 树。它不依赖于微软的 T4 库,但它还不支持属性或自定义程序集引用,以及任何花哨的功能。

使这一切工作的核心类是 SlangPreprocessor,其核心是一个方法,虽然有几个解析支持函数

public static void Preprocess(TextReader input,TextWriter output,string lang="cs")
{
    // TODO: Add error handling, even though output codegen errors shouldn't occur with this
    var method = new CodeMemberMethod();
    method.Attributes = MemberAttributes.Public |MemberAttributes.Static;
    method.Name = "Preprocess";
    method.Parameters.Add(new CodeParameterDeclarationExpression(typeof(TextWriter), "w"));
    int cur;
    var more = true;
    while(more)
    {
        var text = _ReadUntilStartContext(input);
        if(0<text.Length)
        {
            method.Statements.Add(new CodeMethodInvokeExpression(
                new CodeArgumentReferenceExpression("w"),
                "Write",
                new CodePrimitiveExpression(text)));
        }
        cur = input.Read();
        switch(cur)
        {
            case -1:
                more = false;
                break;
            case '=':
                method.Statements.Add(new CodeMethodInvokeExpression(
                    new CodeArgumentReferenceExpression("w"),
                    "Write",
                    new CodeSnippetExpression(_ReadUntilEndContext(-1, input))));
                break;
            default:
                method.Statements.Add(new CodeSnippetStatement(_ReadUntilEndContext(cur, input)));
                break;
        }
    }
    method.Statements.Add(new CodeMethodInvokeExpression(new CodeArgumentReferenceExpression("w"), "Flush"));
    var cls = new CodeTypeDeclaration("Preprocessor");
    cls.TypeAttributes = TypeAttributes.Public;
    cls.IsClass = true;
    cls.Members.Add(method);
    var ns = new CodeNamespace();
    ns.Types.Add(cls);
    var cu = new CodeCompileUnit();
    cu.Namespaces.Add(ns);
    var prov = CodeDomProvider.CreateProvider(lang);
    var opts = new CompilerParameters();
    var outp= prov.CompileAssemblyFromDom( opts,cu);
    var m = outp.CompiledAssembly.GetType("Preprocessor").GetMember("Preprocess")[0] as MethodInfo;
    m.Invoke(null, new object[] { output });
}

这占 T4 处理的 80% 的工作。其余的只是几个补救性的解析函数。它使用旧的 ASP/ASP.NET 的技巧,将上下文切换(由 <# #> 标签分隔)转换为 Write() 调用,然后使用 codedom 编译,再加载编译后的程序集,最后使用反射运行该程序集暴露的单个方法。它真的很简单,尽管它有点花哨。最终我会将其完善,甚至可能添加包含和其他有用的功能。

关注点

C# 的语法是我见过最具有欺骗性的简单语法之一。我曾以为 C 很难,但 C# 的歧义性带来了解析挑战。它看起来很简单,但实际上并非如此。它需要 GLR 解析或手工编写解析器才能解析。

历史

  • 2019年12月4日 - 初始提交
  • 2019年12月5日 - 更新
© . All rights reserved.