Bracket pair colorization 10,000x faster

2021年9月29日,作者 Henning Dieterichs, @hediet_dev

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

为了让这一点更容易,在2016年,一位名叫 CoenraadS 的用户开发了很棒的 Bracket Pair Colorizer 扩展,用于为匹配的括号着色,并将其发布到 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 Marketplace 提供了更多由社区提供的此类扩展,它们都以极具创意的方式帮助识别匹配的括号对,包括:Rainbow BracketsSubtle Match BracketsBracket HighlighterBlockmanBracket Lens。这些各种各样的扩展表明 VS Code 用户确实渴望获得更好的括号支持。

性能问题

不幸的是,Decoration API 的非增量特性以及缺少对 VS Code 令牌信息的访问,导致 Bracket Pair Colorizer 扩展在处理大文件时速度较慢:当在 TypeScript 项目的 checker.ts 文件(包含超过42k行代码)的开头插入单个括号时,需要大约10秒才能更新所有括号对的颜色。在这10秒的处理过程中,扩展宿主进程会占用100%的 CPU,并且所有由扩展驱动的功能(如自动补全或诊断)都会停止工作。幸运的是,VS Code 的架构确保了 UI 保持响应,并且文档仍然可以保存到磁盘。

CoenraadS 意识到了这个性能问题,并在扩展的第2版中投入了大量精力来提高速度和准确性,方法是重用 VS Code 的令牌和括号解析引擎。然而,VS Code 的 API 和扩展架构并非旨在在涉及数十万个括号对时实现高性能的括号对着色。因此,即使在 Bracket Pair Colorizer 2 中,在文件开头插入 { 之后,颜色反映新的嵌套级别也需要一些时间

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

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

我们做了什么

相反,在1.60更新中,我们在 VS Code 的核心中重新实现了该功能,并将时间缩短到不到一毫秒——在这个特定的例子中,速度提高了10,000多倍。

可以通过添加设置 "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(log3N+E)\mathcal{O}(\mathrm{log}^3 N + E)其中NN是文档大小,而EE是编辑大小,假设括号对的嵌套级别上限为O(logN)\mathcal{O}(\mathrm{log} N).

此外,通过重用渲染器中现有的令牌及其增量令牌更新机制,我们获得了另一个巨大的(但恒定的)速度提升。

Web 版 VS Code

除了性能更高之外,新的实现还支持 VS Code for the Web,您可以在 vscode.devgithub.dev 中看到它的实际应用。由于 Bracket Pair Colorizer 2 重用 VS Code 令牌引擎的方式,无法将该扩展迁移到我们称之为 web extension 的形式。

我们的新实现不仅适用于 VS Code for the Web,也直接适用于 Monaco Editor

括号对着色的挑战

括号对着色的关键是快速确定视口中所有括号及其(绝对)嵌套级别。视口可以描述为文档中的一个范围,以行号和列号表示,通常只占整个文档的一小部分。

不幸的是,括号的嵌套级别取决于它前面的“所有”字符:将任何字符替换为开括号“{”通常会增加后面所有括号的嵌套级别。

因此,当最初为文档末尾的括号着色时,必须处理整个文档中的每个字符。

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

Bracket Pair Colorizer 扩展中的实现通过在插入或删除单个括号时再次处理整个文档来解决这个挑战(这对于小文档来说是非常合理的)。然后必须使用 VS Code Decoration API 删除并重新应用颜色,该 API 将所有颜色装饰发送到渲染器。

如前所述,这对于包含数十万个括号对(以及同样多的颜色装饰)的大文档来说是很慢的。因为扩展无法增量更新装饰,必须一次性替换所有装饰,所以 Bracket Pair Colorizer 扩展也无法做得更好。尽管如此,渲染器以一种巧妙的方式组织所有这些装饰(使用所谓的 interval tree),因此在接收到(可能是数十万个)装饰后,渲染速度总是很快的。

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

然而,我们仍然希望能够以(多)对数时间查询视口中的所有括号及其嵌套级别,就像使用 VS Code 的装饰 API(使用前面提到的 interval tree)一样。

算法复杂度

可以跳过有关算法复杂度的部分。

在下文中,NN指文档的长度。更正式地说,我们的目标是查询给定大小为O(logkN+R)\mathcal{O}(\mathrm{log}^k N + R)范围内的所有括号的时间复杂度,其中RR和足够小的kk(我们目标是k=2k = 2)。在渲染视口时查询括号,因此查询速度必须非常快。

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

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

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

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

只有第三个出现的“}”才关闭括号对。

对于令牌语言不规则的语言(例如带有 JSX 的 TypeScript),这会变得更加困难

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 已经有一个高效且同步的机制来维护用于语法高亮的令牌信息,我们可以重用它来识别开括号和闭括号。

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

作为旁注,当在文档开头应用更改所有后续令牌的编辑(例如为 C-like 语言插入 /*)时,VS Code 不会一次性重新分词长文档,而是分块进行。这确保了 UI 不会冻结,即使分词是在渲染器中同步发生的。

基本算法

核心思想是使用递归下降解析器来构建描述所有括号对结构的抽象语法树 (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 中它们已经预先计算好。

然而,当在第一个树中插入单个字符时,只需要更新节点本身及其所有父节点的长度——所有其他长度保持不变。

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

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

这是带有长度注释的 AST 在 TypeScript 中的定义方式

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(log N) many nested bracket pairs
            {
                {} [1]
            }
        ...
    }
}

尚未涉及列表,但我们已经需要遍历O(logN)\mathcal{O}(\mathrm{log} N)许多节点才能找到位置 [1] 的括号对。幸运的是,嵌套更深的文档是不典型的,因此我们不在最坏情况分析中考虑它们。

现在,在最坏情况下,我们通过在每个嵌套的括号对中插入额外的NN直到文档大小达到O(NlogN)\mathcal{O}(\frac{N}{\mathrm{log} N})个括号对来填满文档。

{}{}{}{}{}{}{}{}... 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(logNlog  logN)=O(logN)\mathcal{O}(\mathrm{log} \frac{N}{\mathrm{log} N}) = \mathcal{O}(\mathrm{log} N - \mathrm{log}\;\mathrm{log} N ) = \mathcal{O}(\mathrm{log} N).

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

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

这也表明 AST 的最大高度为O(log2N)\mathcal{O}(\mathrm{log}^2 N).

增量更新

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

想法是重用用于初始化的递归下降解析器,并添加缓存策略,以便可以重用和跳过未受文本编辑影响的节点。

当递归下降解析器解析位置pp的括号对列表,并且下一个编辑位于位置ee时,它首先检查先前的 AST 是否在文本更改前的位置epe - p处有一个长度最多为pp的节点。如果是这种情况,则不需要重新解析此节点,并且底层分词器只需按节点的长度前进即可。消耗节点后,解析继续。请注意,此节点既可以是单个括号对,也可以是整个列表。此外,如果存在多个此类可重用节点,则应采用最长的一个。

以下示例显示了当插入单个开括号时(省略单个括号节点),哪些节点可以重用(绿色)

Reusable Nodes in AST

通过重新解析包含编辑的节点并重用所有未更改的节点来处理文本编辑后,更新后的 AST 如下所示。请注意,通过消耗3个节点 B、H 和 G,所有11个可重用节点都可以重用,并且只需重新创建4个节点(橙色)

Updated AST

如本例所示,平衡列表不仅使查询速度快,而且有助于一次重用大量节点块。

算法复杂度

可以跳过有关算法复杂度的部分。

让我们假设文本编辑替换了大小最多为EE的范围,并插入了最多EE个新字符。我们暂时也忽略没有开括号对应闭括号的罕见情况。

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

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

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

这也决定了更新操作的时间复杂度,但有一个警告。

我们如何重新平衡 AST?

不幸的是,最后一个示例中的树不再平衡。

当将重用的列表节点与新解析的节点组合时,我们必须做一些工作来维护 (2,3)-树属性。我们知道重用的节点和新解析的节点都是 (2,3)-树,但它们可能有不同的高度——所以我们不能只创建父节点,因为 (2,3)-树节点的所有子节点必须具有相同的高度。

我们如何高效地将所有这些不同高度的节点连接成一个单独的 (2,3)-树?

这可以很容易地简化为将较小的树前置或附加到较大树的问题:如果两棵树具有相同的高度,则创建包含两个子节点的列表就足够了。否则,我们将高度为h1h_1的较小树插入到高度为h2h_2的较大树中,如果节点最终有超过3个子节点,则可能会拆分节点(类似于 (2,3)-树的插入操作方式)。

因为这具有运行时O(h2h1)\mathcal{O}(h_2 - h_1),我们取3个相邻节点(aa, bbcc)并连接aabbbbcc(可能增加树的高度),具体取决于哪一对具有较小的高度差异。重复此操作直到所有节点都连接起来。作为额外的优化,我们寻找具有相同高度的节点序列,并在线性时间内为它们创建父列表。

为了平衡上一个示例中的列表 α 和 γ,我们对其子节点执行连接操作(红色列表违反 (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(logN)\mathcal{O}(\mathrm{log} N)的节点(那些我们重用的节点)和额外的O(log2N+E)\mathcal{O}(\mathrm{log}^2 N + E)个列表高度为0的节点(那些我们重新解析的节点)。

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

我们如何高效地找到可重用节点?

我们为此任务准备了两种数据结构:编辑前位置映射器节点读取器

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

中完成。当处理文本编辑和解析节点时,该组件为我们提供了可以潜在重用的节点的位置以及该节点可以具有的最大长度——显然,我们想要重用的节点必须短于到下一个编辑的距离。

节点读取器可以快速找到 AST 中给定位置处满足给定谓词的最长节点。要找到我们可以重用的节点,我们使用位置映射器来查找其旧位置和允许的最大长度,然后使用节点读取器来查找此节点。如果我们找到了这样的节点,我们就知道它没有改变,可以重用它并跳过它的长度。

因为节点读取器是按照单调递增的位置查询的,所以它不必每次都从头开始搜索,而是可以从上一个重用节点的末尾开始搜索。这里的关键是一个无递归的树遍历算法,它可以深入节点,也可以跳过它们或返回到父节点。当找到可重用节点时,遍历停止并继续下一个对节点读取器的请求。

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

令牌更新

当在不包含文本 */ 的长 C 风格文档开头插入 /* 时,整个文档会变成一个注释,所有令牌都会更改。

因为令牌是在渲染器进程中同步计算的,所以重新分词不能一次性发生而冻结 UI。

相反,令牌会随时间分批更新,以确保 JavaScript 事件循环不会阻塞太久。虽然这种方法不会减少总阻塞时间,但它提高了更新过程中 UI 的响应能力。最初对文档进行分词时也使用相同的机制。

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

这样,不仅分词即使在文档中的所有令牌都更改时也保持高性能,而且括号对着色也如此。

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

为了避免在打开文档并导航到其末尾时括号对颜色闪烁,我们在初始分词过程完成之前维护两个括号对 AST。第一个 AST 是在没有令牌信息的情况下构建的,并且不接收令牌更新。第二个最初是第一个 AST 的克隆,但接收令牌更新,并随着分词的进行和令牌更新的应用而越来越偏离。最初使用第一个 AST 查询括号,但一旦文档完全分词,第二个 AST 就会接管。

因为深度克隆几乎和重新解析文档一样昂贵,所以我们实现了写时复制,从而实现了克隆O(1)\mathcal{O}(1).

长度编码

编辑器视图使用行号和列号描述视口。颜色装饰也期望以行/列范围表示。

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

请注意,这种方法与直接按行索引的数据结构(例如使用字符串数组描述文档的行内容)有显著不同。特别是,这种方法可以跨行和行内进行单次二分查找。

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

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

作为一个实现细节,我们将这些长度编码为一个数字以减少内存压力。JavaScript 支持最大为25312^{53} - 1的整数,因此我们可以为行数和列数各使用多达26位。不幸的是,v8 将大于2312^{31} 的数字存储在堆中,因此这种编码技巧的效果不如我们预期的那么好。

进一步的困难:未闭合的括号对

到目前为止,我们假设所有括号对都是平衡的。然而,我们也希望支持未闭合和未打开的括号对。递归下降解析器的优点在于我们可以使用锚点集来改进错误恢复。

考虑以下示例

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

显然,位置 [2] 的 } 不会闭合任何括号对,代表一个未打开的括号。位置 [1] 和 [3] 的括号匹配得很好。然而,当在文档开头插入 { 时,情况发生了变化

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

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

特别是在以下示例中,[1] 应该是一个在 [2] 之前终止的未闭合括号

{
    ( [1]
} [2]
{}

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

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

在第一个示例中,位置 [2] 处的锚点集是{\{ ) }\},但意外字符是 }。因为它不属于锚点集,所以它被报告为未打开的括号。

这在重用节点时需要考虑:当在前面加上 { 时,不能重用 ( } ) 对。我们使用位集来编码锚点集,并为每个节点计算包含的未打开括号集。如果它们相交,我们就不能重用该节点。幸运的是,只有少数几种括号类型,所以这对性能影响不大。

未来展望

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

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

编码愉快!

Henning Dieterichs,VS Code 团队成员 @hediet_dev

© . This site is unofficial and not affiliated with Microsoft.