尝试以扩展 VS Code 中的代理模式!

括号对彩色化速度提升 10,000 倍

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 文件(包含超过 4.2 万行代码)的开头插入一个括号时,需要大约 10 秒才能更新所有括号对的颜色。在这 10 秒的处理过程中,扩展宿主进程的 CPU 占用率高达 100%,所有由扩展支持的功能,例如自动补全或诊断,都停止工作。幸运的是,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,针对高性能场景进行优化),但渲染器和扩展宿主之间的异步通信严重限制了作为扩展实现时括号对着色的速度。这个限制无法克服。特别是,一旦括号对出现在视口中,就不应该异步请求它们的颜色,因为这会在滚动大型文件时导致明显的闪烁。有关此问题的讨论可以在 问题 #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

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

我们的新实现不仅适用于 Web 版 VS Code,而且可以直接在 Monaco 编辑器 中运行!

括号对着色的挑战

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

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

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

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

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

正如前面所演示的,这对于包含数十万个括号对以及同样数量的颜色装饰的大型文档来说速度很慢。因为扩展无法增量更新装饰,而必须一次性替换所有装饰,所以括号对着色器扩展甚至无法做得更好。尽管如此,渲染器以巧妙的方式组织所有这些装饰(通过使用所谓的区间树),因此在收到(可能数十万个)装饰后,渲染速度始终很快。

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

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

算法复杂度

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

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

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

顺便提一下,当在文档开头应用一个编辑,改变所有后续令牌时(例如,对于 C 风格语言插入 /*),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,这 negatively 影响了最坏情况查询时间。这是由括号对 β 引起的,它在平衡列表树中充当叶子,但实际上包含另一个高度为 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 用于查询括号,但一旦文档完全分词,第二个 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