如何在Visual Studio代码中使括号对的着色速度提高10000倍

206 阅读28分钟

托架对的着色速度提高了1万倍

在Visual Studio Code中处理深度嵌套的括号时,要弄清楚哪些括号匹配,哪些不匹配是很难的。

为了让这个问题变得更简单,在2016年,一个名为CoenraadS的用户开发了令人敬畏的括号对着色器扩展,以使匹配的括号着色,并将其发布到VS Code Marketplace。这个扩展变得非常流行,现在是Marketplace上下载量最大的10个扩展之一,安装量超过600万。

为了解决性能和准确性问题,2018年,CoenraadS又推出了Bracket Pair Colorizer 2,现在也有超过300万的安装量。

Bracket Pair Colorizer扩展是VS Code可扩展性的一个很好的例子,它大量使用了Decoration API来给括号上色:

Two screenshots of the same code opened in VS Code. In the first screenshot, bracket pair colorization is disabled, in the second screenshot, it is enabled

我们很高兴地看到,VS Code市场提供了更多这样的社区提供的扩展,所有这些都有助于以非常有创意的方式识别匹配的括号对,包括。Rainbow Brackets,Subtle Match Brackets,Bracket Highlighter,Blockman, andBracket Lens.这些扩展的种类表明,VS Code的用户确实希望得到更好的括号支持。

性能问题

不幸的是,装饰API的非递增性和缺少对VS Code的标记信息的访问,导致括号对着色器扩展在大文件上很慢:当在TypeScript项目的checker.ts文件的开头插入一个单一的括号时,它需要大约10秒,直到所有括号对的颜色更新。在这10秒的处理过程中,扩展主机进程以100%的CPU速度燃烧,所有由扩展提供的功能,如自动完成或诊断,都会停止运作。幸运的是,VS Code的架构保证了用户界面保持响应,文件仍然可以被保存到磁盘。

CoenraadS意识到了这个性能问题,在扩展的第二版中,通过重新使用VS Code中的标记和括号解析引擎,花费了大量的精力来提高速度和准确性。然而,VS Code的API和扩展架构的设计并不允许在涉及数十万个括号对时进行高性能的括号对着色。因此,即使在括号对着色器2中,在文件的开头插入{ ,直到颜色反映出新的嵌套层次也需要一些时间。

A video of VS Code showing that the extension needs more than 10 seconds to process the text change in checker.ts

虽然我们很想提高扩展的性能(这当然需要引入更高级的API,为高性能场景进行优化),但渲染器和扩展主机之间的异步通信严重限制了作为扩展实现的括号对着色的速度。这个限制是无法克服的。特别是,括号对的颜色不应该在它们出现在视口时就被异步请求,因为这在滚动大文件时,会造成明显的闪烁。关于这个问题的讨论可以在问题#128465中找到。

我们所做的

相反,在1.60版本的更新中,我们在VS Code的核心部分重新实现了这个扩展,并将这个时间降低到了1毫秒以内--在这个特殊的例子中,这比原来快了1万多倍。

该功能可以通过添加设置"editor.bracketPairColorization.enabled": true

现在,即使对于有几十万个括号对的文件,更新也不再明显。请注意,在第2行输入{ ,第42,788行的括号颜色立即反映了新的嵌套级别:

A video of VS Code showing that the native implementation needs less than a millisecond to process the text change in checker.ts

一旦我们决定把它移到核心区,我们也借此机会研究如何使它尽可能快。谁会不喜欢算法上的挑战呢?

在不受公共API设计限制的情况下,我们可以使用(2,3)树、无递归的树形遍历、位运算、增量解析。和其他技术来减少扩展的最坏情况下的更新时间复杂度(即当一个文档已经被打开时处理用户输入的时间),从O(N+E)\mathcal{O}(N+E)O(N+E)到O(log3N+E)\mathcal{O}(\mathrm{log}^3 N+E)O(log3N+E),NNN是文档尺寸,EEE是编辑尺寸。假设括号对的嵌套级别由O(logN)\mathcal{O}(\mathrm{log} N)O(logN)约束。

此外,通过重用渲染器的现有标记和其增量标记更新机制,我们获得了另一个巨大的(但持续的)速度提升。

针对网络的VS代码

除了性能更强之外,新的实现还支持VS Code for the Web,你可以在github.dev中看到它的运行。由于Bracket Pair Colorizer 2重用VS Code标记引擎的方式,我们不可能将该扩展迁移为我们所说的网络扩展

我们的新实现不仅可以在VS Code for the Web中工作,还可以直接在Monaco Editor中工作

括号对着色的挑战

括号对着色是关于快速确定所有括号和它们在视口中的(绝对)嵌套级别。视口可以描述为文档中的行数和列数的范围,通常是整个文档的一个小部分。

不幸的是,一个括号的嵌套级别取决于它前面的所有字符:用开头的括号"{"替换任何字符,通常会增加所有后面括号的嵌套级别。

因此,在最初给文件结尾的括号着色时,整个文件的每一个字符都必须被处理。

A diagram that indicates that changing a single character influences the nesting level of all subsequent brackets

括号对着色器扩展中的实现解决了这一难题,每当插入或移除一个括号时,都要再次处理整个文档(这对小文档来说是非常合理的)。然后必须使用VS代码装饰API删除和重新应用颜色,该API将所有颜色装饰发送到渲染器。

正如前面所展示的,对于有几十万个括号对的大型文档来说,这是很慢的,因此同样有很多颜色装饰。因为扩展不能递增地更新装饰,而必须一次性地替换它们,所以括号对着色器扩展甚至不能做得更好。不过,渲染器还是以一种巧妙的方式(通过使用所谓的间隔树)来组织所有这些装饰,所以在收到(可能是数十万个)装饰后,渲染总是很快的。

我们的目标是不需要在每次按键时重新处理整个文档。相反,处理单个文本编辑所需的时间应该只随着文档长度的增加而呈对数增长(聚)。

然而,我们仍然希望能够在(多)对数时间内查询视口中的所有括号和它们的嵌套级别,就像使用VS Code的装饰API(它使用提到的区间树)那样。

算法的复杂性

请随意跳过关于算法复杂性的章节。

在下文中,NNN指的是文件的长度。更正式地说,我们的目标是在一个给定的大小RRR和一个合理的小kkk(我们的目标是fork=2k=2k=2)范围内查询所有括号的时间复杂度最多为O(logkN+R)\mathcal{O}(\mathrm{log}^k N + R)O(logkN+R)。大括号在渲染视口时被查询,因此查询它们必须非常快。

然而,当一个文件第一次被打开时,我们允许初始化时间复杂度为O(N)\mathcal{O}(N)O(N)(这是不可避免的。因为在最初给括号着色时,所有的字符都必须被处理),而当许多字符被修改或插入时,更新时间为O(logjN+E)\mathcal{O}(mathrm{log}^j N+E)O(logjN+E),同样是在一个合理的小jjj(我们的目标是j=3j= 3j=3)。我们还假设一个括号对的嵌套程度不会太深,最多只有O(logN)mathcal{O}(mathrm{log} N)O(logN),而且没有开口对应的闭合括号的数量可以忽略不计--违反这些假设的文档是不典型的,我们要找的算法不需要在这些文档上快速。

语言语义使方括号对着色变得困难

使得括号对着色真正困难的是对文件语言所定义的实际括号的检测。特别是,我们不想检测注释或字符串中的开括号或闭括号,正如下面的C语言例子所展示的:

{ /* } */ char str[] = "}"; }

只有第三次出现的"}"才会关闭括号对。

对于标记语言不规则的语言来说,这就变得更加困难了,比如TypeScript与JJSX。

Screenshot of TypeScript code, showing a function that contains a template literal with nested expressions. The template literal also contains a closing bracket at position 2. The function starts with the bracket at 1 and ends with the bracket at 3.

在[1]处的括号是否与[2]处的括号或[3]处的括号匹配?这取决于模板字面表达式的长度,只有具有无界状态的标记器(即非规则标记器)才能正确确定。

令牌的拯救

幸运的是,语法高亮必须解决一个类似的问题:前面代码片断中[2]处的括号应该被呈现为字符串还是纯文本?

事实证明,对于大多数括号对来说,只需忽略注释和字符串中的括号即可。<...> 是我们目前发现的唯一有问题的一对,因为这些括号通常既用于比较,又作为通用类型的对,同时具有相同的标记类型。

VS Code已经有一个高效和同步的机制来维护用于语法高亮的标记信息,我们可以重新使用它来识别开括号和闭括号。

这是括号对着色扩展的另一个挑战,它对性能产生了负面影响:它不能访问这些标记,必须自己重新计算它们。我们思考了很久,如何才能高效、可靠地将令牌信息暴露给扩展,但得出的结论是,如果不把大量的实现细节泄露给扩展的API,我们就无法做到这一点。因为扩展仍然需要为文档中的每个括号发送一个颜色装饰的列表,所以仅仅这样一个API甚至不能解决性能问题。

顺便提一下,当在文档的开头应用一个改变所有后续标记的编辑时(比如为类C语言插入/* ),VS Code不会一下子对长文档进行重新标记,而是在一段时间内分块进行。这样可以确保用户界面不被冻结,即使标记化在渲染器中同步进行。

基本算法

核心思想是使用一个递归解析器来建立一个抽象语法树(AST),描述所有括号对的结构。当发现一个括号时,检查标记信息,如果括号在注释或字符串中,就跳过它。一个标记器允许解析器偷看和读取这种括号或文本标记。

现在的诀窍是只存储每个节点的长度(同时为所有不是括号的东西设置文本节点,以弥补空白),而不是存储绝对的开始/结束位置。在只有长度的情况下,一个给定位置的括号节点仍然可以在AST中有效定位。

下图显示了一个带有长度注释的示范性AST:

Abstract Syntax Tree of Bracket Pairs With Relative Lengths

将其与使用绝对开始/结束位置的经典AST表示法相比较:

Abstract Syntax Tree of Bracket Pairs With Absolute Start/End Positions

两个AST都描述了同一个文档,但是当遍历第一个AST的时候,绝对位置必须要即时计算(这很便宜),而在第二个AST中已经预先计算好了。

然而,当在第一棵树上插入一个字符时,只有节点本身及其所有父节点的长度必须被更新--所有其他长度都保持不变。

当像第二棵树那样存储绝对位置时,文档中后来的每个节点的位置都必须被增加。

另外,通过不存储绝对偏移量,具有相同长度的叶子节点可以共享,以避免分配。

这就是在TypeScript中如何定义带有长度注释的AST:

type Length = ...;

type AST = BracketAST | BracketPairAST | ListAST | TextAST;

/** Describes a single bracket, such as `{`, `}` or `begin` */
class BracketAST {
    constructor(public length: Length) {}
}

/** Describes a matching bracket pair and the node in between, e.g. `{...}` */
class BracketPairAST {
    constructor(
        public openingBracket: BracketAST;
        public child: BracketPairAST | ListAST | TextAST;
        public closingBracket: BracketAST;
    ) {}

    length = openingBracket.length + child.length + closingBracket.length;
}

/** Describes a list of bracket pairs or text nodes, e.g. `()...()` */
class ListAST {
    constructor(
        public items: Array<BracketPairAST | TextAST>
    ) {}

    length = items.sum(item => item.length);
}

/** Describes text that has no brackets in it. */
class TextAST {
    constructor(public length: Length) {}
}

查询这样的AST以列出视口中的所有括号和它们的嵌套级别是相对简单的:做一个深度优先的遍历,即时计算当前节点的绝对位置(通过添加早期节点的长度),并跳过完全在请求范围之前或之后的节点的子节点。

这个基本的算法已经可以工作了,但有一些开放的问题:

  1. 我们怎样才能确保查询给定范围内的所有括号具有理想的对数性能?
  2. 当输入时,我们如何避免从头开始构建一个新的AST?
  3. 我们如何处理令牌块的更新?当打开一个大的文档时,标记最初是不可用的,但会逐块出现。

确保查询时间是对数的

当查询给定范围内的括号时,毁坏性能的是真正的长列表:我们不能对它们的子节点进行快速的二进制搜索,以跳过所有不相关的非相交节点,因为我们需要对每个节点的长度进行求和,以便在飞行中计算出绝对位置。在最坏的情况下,我们需要对所有的节点进行迭代。

在下面的例子中,我们必须查看13个节点(蓝色),直到我们找到位置24的括号:

Long list in Abstract Syntax Tree

虽然我们可以计算并缓存长度总和以实现二进制搜索,但这与存储绝对位置有同样的问题:每次单个节点增长或缩小时,我们都需要重新计算所有的长度,这对非常长的列表来说代价很高。

相反,我们允许列表拥有其他列表作为子代:

class ListAST {
  constructor(public items: Array<ListAST | BracketPairAST | TextAST>) {}

  length = items.sum(item => item.length);
}

这如何改善情况呢?

如果我们能确保每个列表只有一定数量的子代,并且类似于对数高度的平衡树,那么事实证明,这就足以获得查询括号所需的对数性能了。

保持列表树的平衡

我们使用(2,3)-树来强制这些列表是平衡的:每个列表必须有至少2个和最多3个孩子,并且一个列表的所有孩子在平衡列表树中必须有相同的高度。请注意,一个括号对在平衡树中被认为是高度为0的叶子,但它在AST中可能有孩子。

在初始化过程中从头开始构建AST时,我们首先收集所有的子代,然后将它们转换为这样的平衡树。这可以在线性时间内完成。

之前的例子中可能的(2,3)-树可以是下面的样子。请注意,我们现在只需要看8个节点(蓝色)就可以找到第24位的括号对,而且无论一个列表有2个还是3个孩子,都有一些自由度:

Balanced tree to describe lists in the AST

最坏情况下的复杂性分析

请随意跳过关于算法复杂性的章节。

现在,我们假设每个列表都类似于(2,3)-树,因此最多只有3个孩子。

为了最大限度地提高查询时间,我们看一下有O(logN)\mathcal{O}(\mathrm{log} N)O(logN)多个嵌套括号对的文件:

{
    {
        ... O(log N) many nested bracket pairs
            {
                {} [1]
            }
        ...
    }
}

还没有涉及到列表,但是我们已经需要遍历O(logN)\mathcal{O}(\mathrm{log} N)O(logN)个节点来找到[1]处的括号对。幸运的是,嵌套得更深的文件是不典型的,所以我们在最坏情况分析中不考虑它们。

现在,对于最坏的情况,我们通过在每个嵌套的括号对中插入额外的O(NlogN)\mathcal{O}(\frac{N}{mathrm{log} N})O(logNN)个括号对来填充文档,直到它有大小NNN:

{}{}{}{}{}{}{}{}... O(N / log N) many
{
    {}{}{}{}{}{}{}{}... O(N / log N) many
    {
        ... O(log N) many nested bracket pairs
            {
                {}{}{}{}{}{}{}{}... O(N / log N) many
                {} [1]
            }
        ...
    }
}

在同一嵌套层上的每个括号列表都会产生一个高度为O(logNlogN)=O(logN-logN)=O(logN)的树,\mathrm{O}(\frac{N}{mathrm{log} N})=\mathcal{O}(\mathrm{log} N - \mathrm{log}\。\`mathrm{log} N ) = `mathcal{O}(`mathrm{log} N)O(loglogNN)=O(logN-loglogN)=O(logN)。

因此,为了找到[1]处的节点,我们必须穿越O(logN)\mathcal{O}(\mathrm{log} N)O(logN)许多高度为O(logN)\mathcal{O}(\mathrm{log} N)O(logN)的平衡树。一旦我们找到了节点,想要收集大小为RRR的范围内的所有括号,我们必须最多读取O(R)\mathcal{O}(R)O(R)多个由最多O(log2N+R)\mathcal{O}(\mathrm{log}^2 N+R)O(log2N+R)内部节点连接的相邻叶节点。

因此,查询括号的最坏情况下的时间复杂度是O(log2N+R)\mathcal{O}(mathrm{log}^2 N+R)O(log2N+R)。

同时,这表明AST的最大高度为O(log2N)\mathcal{O}(\mathrm{log}^2 N)O(log2N)。

增量更新

高性能括号对着色的最有趣的问题仍未解决:给定当前的(平衡的)AST和替换了某一范围的文本编辑,我们如何有效地更新树以反映文本编辑?

我们的想法是重新使用用于初始化的递归下降解析器,并添加一个缓存策略,这样不受文本编辑影响的节点就可以被重用和跳过。

当递归下降解析器在位置ppp解析一个括号对列表,而下一次编辑在位置eee时,它首先检查之前的AST是否有一个长度最多为e-pe的节点--pe-pat的位置,即文字改变前pppused的位置。如果是这样的话,这个节点就不需要重新解析,底层的标记器就可以通过节点的长度来推进。在消耗该节点后,解析继续进行。注意,这个节点既可以是单个括号对,也可以是整个列表。另外,如果有多个这样的可重复使用的节点,应该取最长的一个。

下面的例子显示了在插入单个开口括号时哪些节点可以被重用(绿色)(省略单个括号节点):

Reusable Nodes in AST

在对文本编辑进行处理后,通过对包含编辑的节点进行清除,并重新使用所有未改变的节点,更新后的AST看起来如下。注意,所有11个可重用的节点都可以通过消耗3个节点B、H和G来重用,只有4个节点需要重新创建(橙色)。

Updated AST

正如这个例子所证明的,平衡列表不仅使查询变得快速,而且还有助于一次性重用大块的节点。

算法的复杂性

请随意跳过关于算法复杂度的章节。

让我们假设文本编辑将一个大小为EEE的范围替换为最多EEE的许多新字符。我们也暂时忽略了没有开口对应的闭合括号的罕见情况。

我们只需要重新解析与编辑范围相交的节点。因此,最多只有O(log2N+E)\mathcal{O}(\mathrm{log}^2 N+E)O(log2N+E)个节点需要被重新解析(与查询括号的时间复杂度的道理相同)--所有其他节点都可以被重新使用。

显然,如果一个节点不与编辑范围相交,那么它的任何子节点也不会。因此,我们只需要考虑重用那些不与编辑范围相交,但其父节点与编辑范围相交的节点(这将隐含地重用所有节点和其父节点都不与编辑范围相交的节点)。另外,这种父节点不能被编辑范围完全覆盖,否则它们的所有子节点都会与编辑范围相交。然而,AST中的每一层最多只有两个与编辑范围部分相交的节点。由于AST最多有O(log2N)\mathcal{O}(\mathrm{log}^2 N)O(log2N)个层次(受AST的高度限制),而每个节点最多有3个子节点。所有可重复使用的节点都可以通过最多消耗O(2⋅3⋅log2N)=O(log2N)\mathcal{O}(2\cdot 3\cdot \mathrm{log}^2 N)= \mathcal{O}(\mathrm{log}^2 N)O(2⋅3⋅log2N)=O(log2N)节点来覆盖。

因此,为了构建更新的树,我们最多需要重新解析O(log2N+E)\mathcal{O}(\mathrm{log}^2 N+E)O(log2N+E)个节点,并且可以重复使用O(log2N)\mathcal{O}(\mathrm{log}^2 N)O(log2N)个节点。

这也将决定更新操作的时间复杂性,但有一个注意事项。

我们如何对AST进行再平衡?

不幸的是,上一个例子中的树已经不平衡了。

当把一个重用的列表节点和一个新解析的节点结合起来时,我们必须做一些工作来保持(2,3)-树的属性。我们知道重复使用的节点和新解析的节点都已经是(2,3)-树了,但是它们可能有不同的高度--所以我们不能只是创建父节点,因为(2,3)-树节点的所有子节点都必须有相同的高度。

我们怎样才能有效地将所有这些混合高度的节点串联成一棵(2,3)树?

这可以很容易地简化为将一棵较小的树预置或追加到一棵较大的树上的问题:如果两棵树有相同的高度,只需创建一个包含两个子树的列表。否则,我们将heighth1h_1h1 的小树插入heighth2h_2h2 的大树中,如果最终有超过3个孩子,就有可能打散节点(类似于(2,3)树的插入操作)。

因为这有运行时间O(h2-h1)\mathcal{O}(h_2 - h_1)O(h2-h1),我们取3个相邻的节点(aaa,bbb,和ccc),我们想把它们连接起来,先连接eraaa和bbborbbbandccc(可能增加树的高度),取决于哪一对高度差比较小。这样反复进行,直到所有节点都被连接起来。作为一个额外的优化,我们寻找具有相同高度的节点序列,并在线性时间内为它们创建父列表。

为了平衡前面例子中的列表α和γ,我们对它们的子节点进行连接操作(红色的列表违反了(2,3)-树的属性,橙色的节点有意外的高度,绿色的节点在重新平衡时被重新创建)。

AST after balancing lists

因为在不平衡树中,列表B的高度为2,而括号对β的高度为0,所以我们需要将β追加到B中,并完成对列表α的处理。 剩下的(2,3)-树是B,因此它成为新的根并取代了列表α。继续看γ,它的孩子δ和H的高度是0,而G的高度是1。

我们首先连接δ和H,并创建一个高度为1的新父节点Y(因为δ和H有相同的高度)。然后我们把Y和G连接起来,创建一个新的父节点X(出于同样的原因)。然后,X成为父括号对的新子,取代了不平衡的列表γ。

在这个例子中,平衡操作有效地将最顶层列表的高度从3降到了2。然而,AST的总高度从4增加到了5,这对最坏情况下的查询时间有负面影响。这是由括号对β造成的,它在平衡列表树中充当叶子,但实际上包含另一个高度为2的列表。

在平衡父列表时考虑β的内部AST高度可以改善最坏情况,但会留下(2,3)-树的理论。

算法复杂度

请随意跳过关于算法复杂性的章节。

我们最多需要连接O(log2N)\mathcal{O}(\mathrm{log}^2 N)O(log2N)个节点,其最大列表高度为O(logN)\mathcal{O}(\mathrm{log} N)O(logN)的节点(我们重复使用的节点)和额外的O(log2N+E)\mathcal{O}(\mathrm{log}^2 N + E)O(log2N+E)个列表高度为0的节点(我们重新解析的那些节点)。

因为连接两个不同高度的节点的时间复杂度为O(logN)\mathcal{O}(\mathrm{log} N)O(logN),而且一个列表中所有被修复的节点都是相邻的,并且列表高度为0,所以整个更新操作的时间复杂度最多为O(log3N+E)\mathcal{O}(\mathrm{log}^3 N + E)O(log3N+E),前提是找到一个可重用节点可以做到足够快。

我们如何有效地找到可重用的节点呢?

我们有两个数据结构来完成这个任务:编辑前位置映射器节点阅读器

如果可能的话,位置映射器将新文件(应用编辑后)中的一个位置映射到旧文件(应用编辑前)。它还告诉我们当前位置和下一次编辑之间的长度(如果我们在编辑中,则为0)。这是在O(1)/mathcal{O}(1)O(1)中完成的。

当处理一个文本编辑和解析一个节点时,这个组件给了我们一个有可能重复使用的节点的位置和这个节点的最大长度--显然,我们想要重复使用的节点必须比到下一个编辑的距离短。

节点阅读器可以快速找到在AST中某一特定位置上满足特定谓词的最长的节点。为了找到一个可以重用的节点,我们使用位置映射器来查找它的旧位置和它的最大允许长度,然后使用节点阅读器来找到这个节点。如果我们找到了这样的节点,我们就知道它没有变化,可以重用它并跳过其长度。

因为节点阅读器被查询到的位置是单调增加的,所以它不必每次都从头开始搜索,而是可以从最后一个被重用的节点的末端开始搜索。这方面的关键是一个无递归的树形遍历算法,它可以深入到节点中,但也可以跳过它们或回到父节点中。当找到一个可重复使用的节点时,就停止遍历,继续向节点阅读器发出下一个请求。

单次查询节点阅读器的复杂度高达O(log2N)\mathcal{O}(\mathrm{log}^2 N)O(log2N),但我们非常肯定,由单一更新操作发出的所有请求的摊销复杂度也是O(log2N)\mathcal{O}(\mathrm{log}^2 N)O(log2N)。毕竟,节点阅读器只查询不受文本编辑影响的位置,并且总是采取从最后一个可重用节点到下一个可重用节点的最短路径。因此,我们认为节点读取器的效率足够高,不会影响更新算法的运行时复杂度。

代币更新

*/当在不包含文本的长C风格文档的开头插入/* ,整个文档就变成了一个注释,所有的标记都会发生变化。

由于令牌是在渲染器过程中同步计算的,因此重新令牌化不可能在不冻结用户界面的情况下一次性发生。

相反,令牌会随着时间的推移分批更新,这样JavaScript事件循环就不会被阻塞太久。虽然这种方法并没有减少总的阻塞时间,但它提高了更新过程中UI的响应速度。在最初对文档进行标记时,也采用了同样的机制。

幸运的是,由于括号对AST的增量更新机制,我们可以立即应用这样的分批令牌更新,将更新视为一个单一的文本编辑,用自己替换被重新标记的范围。一旦所有的标记更新都进来了,括号对AST就能保证处于与从头开始创建时相同的状态--即使用户在重新标记的过程中编辑了文档。

这样一来,不仅标记化是可执行的,即使文档中的所有标记都发生了变化,括号对着色也是如此。

然而,当一个文档在注释中包含大量不平衡的括号时,文档末尾的括号颜色可能会闪烁,因为括号对解析器知道这些括号应该被忽略。

为了避免在打开文档并浏览到其结尾时括号对颜色的闪烁,我们维护两个括号对AST,直到初始标记化过程完成。第一个AST是在没有标记信息的情况下建立的,不接收标记的更新。第二个AST最初是第一个AST的克隆,但会接收标记更新,并且随着标记化的进展和标记更新的应用,分歧越来越大。最初,第一个AST被用来查询括号,但是一旦文档被完全标记化,第二个AST就会接手。

因为深度克隆几乎和修复文档一样昂贵,我们实现了写时复制,使克隆在O(1)/mathcal{O}(1)O(1)中完成。

对长度的编码

编辑器视图用行号和列号来描述视口。颜色装饰也有望被表达为基于行/列的范围。

为了避免偏移量和基于行/列的位置之间的转换(可以用O(logN)\mathcal{O}(mathrm{log} N)O(logN)完成),我们对AST也使用基于行/列的长度。

请注意,这种方法与直接以行为索引的数据结构(比如用一个字符串数组来描述文档的行内容)有明显的区别。特别是,这种方法可以在行间和行内做一个单一的二进制搜索。

添加两个这样的长度很容易,但需要区分情况:虽然行数是直接添加的,但只有当第二个长度跨越零行时,第一个长度的列数才会包括在内。

令人惊讶的是,大部分的代码不需要知道长度是如何表示的。只有位置映射器变得明显更复杂,因为必须注意到一行可以包含多个文本编辑。

作为一个实现细节,我们把这种长度编码为一个数字,以减少内存压力。JavaScript支持253-12^{53}以下的整数。-1253-1,所以我们可以用26位来表示行和列的数量。不幸的是,v8将大于2312^{31}231的数字存储在中,所以这个编码技巧并不像我们想象的那么有效。

更多的困难。不封闭的括号对

到目前为止,我们假设所有的括号对都是平衡的。然而,我们还想支持非封闭和非开放的括号对。递归下降分析器的优点是我们可以使用锚集来改善错误恢复。

考虑一下下面的例子:

( [1]
} [2]
) [3]

显然,} ,在[2]处没有关闭任何括号对,代表一个未打开的括号。在[1]和[3]处的括号很好地匹配。然而,当在文件的开头插入{ ,情况就变了:

{ [0]
( [1]
} [2]
) [3]

现在,[0]和[2]应该被匹配,而[1]是一个未封闭的括号,[3]是一个未打开的括号。

特别是,在下面的例子中,[1]应该是一个未封闭的括号,终止于[2]之前:

{
    ( [1]
} [2]
{}

否则,打开一个小括号可能会改变不相关的后续括号对的嵌套级别。

为了支持这种错误的恢复,可以使用锚集来跟踪调用者可以继续使用的预期标记集。在前面的例子中的位置[1],锚集将是{{}}}}。因此,当解析[1]处的括号对发现[2]处的意外括号} ,它不会消耗它,并返回一个未封闭的括号对。

在第一个例子中,[2]处的锚集是{{)}/},但意外的字符是} 。因为它不是锚点集的一部分,所以它被报告为一个未打开的括号。

在重用节点时需要考虑这一点:当用{ 作为前缀时,这对( } ) 不能被重用。我们使用比特集来编码锚点集,并为每个节点计算包含未打开括号的集合。如果它们相交,我们就不能重复使用该节点。幸运的是,只有几种括号类型,所以这不会对性能产生太大影响。

继续前进

高效的括号对着色是一个有趣的挑战。有了新的数据结构,我们还可以更有效地解决与括号对有关的其他问题,如一般的括号匹配显示彩色的行范围

尽管JavaScript可能不是编写高性能代码的最佳语言,但通过降低渐进算法的复杂度可以获得很多速度,特别是在处理大的输入时。

编码愉快!