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

LexContext:文本输入的流式光标

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.20/5 (3投票s)

2020年1月17日

MIT

6分钟阅读

viewsIcon

9008

downloadIcon

90

使用这个简单的类来管理位置跟踪、生命周期、错误报告以及基本的解析/词法分析

引言

这是一个我之前讨论过的主题,但是代码变得有些陈旧且臃肿,所以我决定进行简化、重写并写一篇配套的文章。

我编写大量的解析器和词法分析器以及它们的生成器,在此过程中,我开发了一些简单的工具来处理常见的陷阱。这里介绍的类是我许多代码的基础。我在许多项目中都使用它或它的一个变体,包括Parsley。它提供了一个统一的接口,可以处理各种不同的输入源,管理生命周期、位置信息(如行号和列号)、捕获输入、错误报告,以及一个比TextReaderIEnumerator<char>更好的光标,所有这些都包含在一个轻量级的类中。

我将重新介绍我在导言中提到的上一篇文章中已经涵盖的内容。如果您一直跟随上一篇文章的内容,我所做的主要更改是移除了前瞻以从类中榨取一点性能,并将一些辅助方法分离到单独的文件中,使得核心API极其精简,同时将扩展功能保留在一个单独的包含文件中。

概念化这个混乱的局面

尽管API很小,但这个类涵盖了词法分析和解析的几个陷阱,所以我们将分节讨论它们。

统一输入和生命周期管理

LexContext提供了Create()CreateFrom()方法,它们分别接受IEnumerator<char>TextReader。还有一个CreateFrom()重载,接受一个文件名。此外,还有一个CreateFromUrl()方法,允许您创建一个LexContext来处理由URL指定的远程或本地源。

此外,LexContext提供了Close()方法,并且是可处置的,以便您可以关闭底层的文件或网络流(如果有的话)。

无论实例是如何创建的,访问它的接口都是相同的。

游标管理

在编写词法分析器或无词法分析器的解析器时,您通常需要保留光标下的当前字符,并将其传递给您创建的各种解析或词法分析函数。稍后我将演示,但基本上,如果您不这样做,您会发现自己在使用TextReaderPeek()方法,而这是很糟糕的(TM),因为它阻止您的代码在网络流或其他不可搜索的源(如控制台输入)上工作。

IEnumerator<char>在这方面更好,因为它提供了Current属性,该属性提供对我们所需的光标下字符的访问。然而,与TextReader不同的是,如果光标超出了流的末尾,IEnumerator<char>会抛出异常,并且除了在您调用MoveNext()时,不会给出任何指示。这是非常不利的,因为它意味着您必须自己跟踪结束条件,如果必须将这种簿记纳入您的解析例程,可能会使解析代码变得相当复杂。

LexContext,就像TextReader一样,即使光标不在字符上也会报告当前光标值。与IEnumerator<char>一样,它通过Current属性报告当前字符,并且这样做时无需查找。当光标处于初始位置时,它将用-1/EndOfInput报告输入结束,或者在开始之前用-2/BeforeInput报告。如果LexContext已被关闭/处置,它还将报告-3/Disposed

Advance()的行为类似于TextReader.Read(),它会移动光标并返回读取的字符。在调用Advance()之后,可以通过Current属性再次访问该字符。您可以根据需要使用最适合您代码的机制来检索当前字符。

使用这些可以消除上述陷阱,从而使您的词法/解析代码执行几乎变得微不足道,同时保留检查不可搜索流的能力。

位置信息

LexContext跟踪位置、行号和列号,以及当前文档的当前文件或URL(如果有)。这通常用于错误报告,但也可以用于任何需要的目的。制表符宽度为4,除非使用TabWidth属性另行指定。将其设置为输入设备的制表符宽度可以准确地跟踪列信息。对于控制台以及大多数底层输入源,默认值应该是合适的。LineColumn属性是以1为基数的,而Position属性是以0为基数的。FileOrUrl包含当前文档的源位置。

错误处理

ExpectingException是在词法分析器/解析器遇到错误时抛出的异常。它报告错误位置信息、消息以及可选的预期字符或符号集合。您可以手动抛出它,但通常它是在调用Expecting()的结果中引发的。

在解析过程中,您调用Expecting()来指示您期望Current是您期望的字符系列中的一个。如果Current不是其中一个字符,则会自动填充包含当前上下文信息的ExpectingException并将其抛出。

捕获输入

在解析或词法分析时,您通常需要捕获您正在检查的输入,以便在解析/词法分析期间稍后检索,作为子解析的一部分,或者仅仅是为了从您的词法分析或解析函数中报告。LexContext提供了一个CaptureBuffer来帮助完成这项工作。

CaptureBuffer本身就是一个StringBuilder,而LexContext提供了Capture()来捕获光标下的当前字符(如果存在),GetCapture()来检索全部或部分捕获的缓冲区,以及ClearCapture()来清除捕获缓冲区。

如果您需要执行子解析,只需将您需要子解析的内容捕获到捕获缓冲区中,然后为捕获的字符串创建一个新的LexContext!然后,您可以将此LexContext馈送给您的子解析函数。

使用这个烂摊子

我提供了一个简单的JSON最小化器作为示例。它大约是JSON解析器的一半,因为它将解析后的文本加载到字典和列表中,具体取决于它们是JSON对象还是JSON数组。它没有做的是规范化标量值(如字符串)。字符串将返回带有引号和嵌入转义字符,数字、布尔值和null将作为字符串返回。

static void Main(string[] args)
{
    // minifies JSON. Does so by parsing into an intermediary graph
    // this step wasn't required, but makes it easier to adapt
    // the code to a real world JSON parser

    // holds our json data
    IDictionary<string, object> json = null;
            
    // parse our file
    using (var pc = LexContext.CreateFrom(@"..\..\Burn Notice.2919.tv.json"))
        json = _ParseJsonObject(pc);
            
    // write our json data out
    _WriteJsonTo(json, Console.Out);
}
static object _ParseJson(LexContext pc)
{
    // parses a JSON object, array, or value
    pc.TrySkipWhiteSpace();
    switch (pc.Current)
    {
        case '{':
            return _ParseJsonObject(pc);
        case '[':
            return _ParseJsonArray(pc);
        default:
            return _ParseJsonValue(pc);
    }
}
static IDictionary<string, object> _ParseJsonObject(LexContext pc)
{
    // a JSON {} object - our objects are dictionaries
    var result = new Dictionary<string, object>();
    pc.TrySkipWhiteSpace();
    pc.Expecting('{');
    pc.Advance();
    pc.Expecting(); // expecting anything other than end of input
    while ('}' != pc.Current && -1 != pc.Current) // loop until } or end
    {
        pc.TrySkipWhiteSpace();
        // _ParseJsonValue parses any scalar value, but we only want 
        // a string so we check here that there's a quote mark to 
        // ensure the field will be a string.
        pc.Expecting('"');
        var fn = _ParseJsonValue(pc);
        pc.TrySkipWhiteSpace();
        pc.Expecting(':');
        pc.Advance();
        // add the next value to the dictionary
        result.Add(fn, _ParseJson(pc));
        pc.TrySkipWhiteSpace();
        pc.Expecting('}', ',');
        // skip commas
        if (',' == pc.Current) pc.Advance();
    }
    // make sure we're positioned on the end
    pc.Expecting('}');
    // ... and read past it
    pc.Advance();
    return result;
}
static IList<object> _ParseJsonArray(LexContext pc)
{
    // a JSON [] array - our arrays are lists
    var result = new List<object>();
    pc.TrySkipWhiteSpace();
    pc.Expecting('[');
    pc.Advance();
    pc.Expecting(); // expect anything but end of input
                    // loop until end of array or input
    while (-1 != pc.Current && ']' != pc.Current)
    {
        pc.TrySkipWhiteSpace();
        // add the next item
        result.Add(_ParseJson(pc));
        pc.TrySkipWhiteSpace();
        pc.Expecting(']', ',');
        // skip the comma
        if (',' == pc.Current) pc.Advance();
    }
    // ensure we're on the final position
    pc.Expecting(']');
    // .. and read past it
    pc.Advance();
    return result;
}
static string _ParseJsonValue(LexContext pc)
{
    // parses a scalar JSON value, represented as a string
    // strings are returned quotes and all, with escapes 
    // embedded
    pc.TrySkipWhiteSpace();
    pc.Expecting(); // expect anything but end of input
    pc.ClearCapture();
    if ('\"' == pc.Current)
    {
        pc.Capture();
        pc.Advance();
        // reads until it finds a quote
        // using \ as an escape character
        // and consuming the final quote 
        // at the end
        pc.TryReadUntil('\"', '\\', true);
        // return what we read
        return pc.GetCapture();
    }
    pc.TryReadUntil(false, ',', '}', ']', ' ', '\t', '\r', '\n', '\v', '\f');
    return pc.GetCapture();
}

您可以看到,首先,有一些API函数,如TrySkipWhiteSpace()。这些都在LexContext.BaseExtensions.cs中。它们的作用就是驱动LexContext以完成所请求的操作,例如跳过空白字符。TrySkipUntil()/TryReadUntil()非常有用,因为它们可以识别转义。在上面,我们使用TryReadUntil()捕获了一个以\作为转义字符的字符串。

在我们完成了JSON对象到相应对象的解析后,我们只是将它们写入控制台,如下所示

static void _WriteJsonTo(object json, TextWriter writer)
{
    var d = json as IDictionary<string, object>;
    if (null != d)
        _WriteJsonObjectTo(d, writer);
    else
    {
        var l = json as IList<object>;
        if (null != l)
            _WriteJsonArrayTo(l, writer);
        else
            writer.Write(json);
    }
}
static void _WriteJsonObjectTo(IDictionary<string, object> json, TextWriter writer)
{
    var delim = "{";
    foreach (var field in json)
    {
        writer.Write(delim);
        _WriteJsonTo(field.Key, writer);
        writer.Write(":");
        _WriteJsonTo(field.Value, writer);
        delim = ",";
    }
    if ("{" == delim)
        writer.Write(delim);
    writer.Write("}");
}
static void _WriteJsonArrayTo(IList<object> json, TextWriter writer)
{
    var delim = "[";
    foreach (var item in json)
    {
        writer.Write(delim);
        _WriteJsonTo(item, writer);
        delim = ",";
    }
    if ("[" == delim)
        writer.Write(delim);
    writer.Write("]");
}

请注意,这适用于最小化,但它不是符合标准的JSON解析器。为了在演示中保持简单,它比JSON规范“宽松”一些,因为它允许比JSON规范更多的内容。namely,它不强制执行有效的字符串转义,接受更多种类的空白字符,并且不强制字符串在换行符处终止。尽管如此,只要JSON本身是有效的,这个程序就可以解析它。

历史

  • 2020年1月17日 - 初始提交
© . All rights reserved.