🚀 在 VS Code 中获取

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

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

当在 Visual Studio Code 中处理深度嵌套的括号时,很难分辨哪些括号是匹配的,哪些不是。

为了简化这一过程,2016 年,一位名为 CoenraadS 的用户开发了出色的 Bracket Pair Colorizer 扩展,用于为匹配的括号着色,并将其发布到 VS Code 市场。此扩展非常受欢迎,现在是市场上下载量排名前 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 BracketsSubtle Match BracketsBracket HighlighterBlockmanBracket Lens。这些各式各样的扩展表明,VS Code 用户确实希望获得对括号的更好支持。

性能问题

遗憾的是,Decoration API 的非增量性质以及缺少对 VS Code 令牌信息的访问,导致 Bracket Pair Colorizer 扩展在大型文件上运行缓慢:当在 TypeScript 项目的 checker.ts 文件(超过 42k 行代码)的开头插入单个括号时,大约需要 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,针对高性能场景进行优化),但渲染器和扩展主机之间的异步通信严重限制了作为扩展实现的括号对颜色化的速度。这个限制无法克服。特别是,不应在括号对颜色出现在视口中时立即异步请求它们,因为这会在滚动浏览大型文件时导致明显的闪烁。有关此问题的讨论,请参见 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

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

我们的新实现不仅在面向 Web 的 VS Code 中有效,而且在 Monaco Editor 中也直接有效!

括号对颜色化的挑战

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

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

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

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

这是 Bracket Pair Colorization 扩展的另一个挑战,它对性能产生负面影响:它无法访问这些令牌,必须自行重新计算它们。我们长期思考如何高效可靠地将令牌信息公开给扩展,但得出的结论是,如果不将大量实现细节泄漏到扩展 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 中预先计算好了。

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

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

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

这就是如何在 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(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 如下所示。请注意,所有 11 个可重用节点都可以通过消耗 3 个节点 B、H 和 G 来重用,并且仅需重新创建 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(可能会增加树的高度),具体取决于哪对的高度差较小。重复此操作,直到连接所有节点。作为额外的优化,我们查找具有相同高度的节点序列,并在线性时间内为它们创建父列表。

为了平衡上一个示例中的列表 α 和 γ,我们对其子节点执行 concat 操作(红色列表违反了 (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 用于查询括号,但一旦文档完全标记化,第二个 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