Skip to the content.

代码编辑器是如何工作的?(下)

上次我们提到了一些和文本编辑器有关的东西,主要包括了:

今天我们会讲讲常见的代码编辑器中的一些功能,以及如何它们实现中的一些困难。

代码编辑器的功能

代码编辑器的功能主要可以分为两部分:

  1. 代码编辑器与文本编辑器在文本展示上的差别
  2. 代码编辑器与文本编辑器在交互上的差别

代码编辑器的展示

为了使得用户能够更好地编辑代码,文本形式的代码编辑器往往会在文本进行渲染时添加额外的信息,来尽量贴近结构化编辑器的体验,从而使得代码这种“序列化”的数据能够更轻松地被人进行“反序列化”,甚至能够获得超出文本信息含量的洞见;这种额外的信息就是代码编辑器和文本编辑器在功能上的一个决定性的差别。

代码高亮(Code Highlighting)

代码高亮是代码编辑器展现额外信息时最为明显的特征之一。那么什么是代码高亮?

VSCode-semantic-highlighting

简单的来说,就是根据文本数据作为某种结构化信息进行理解时、其中的部分特殊结构使用不同的样式(颜色、字体、字重等)进行渲染,让用户能够迅速识别到其中的关键信息,例如:

随着代码编辑器交互形式的发展,代码高亮提供的信息也从基于 Lexer 的关键字高亮,增强到了现在基于语义分析完成的语义高亮。

广义的代码高亮还包括了:

缩进指引(Indentation Guide)

编程语言为了提供更强的表达能力,其语法结构往往会支持嵌套的结构;这在其作为可读文本时表现为 文本里通过特殊的语法结构形成的一个个嵌套的层级,例如 {},或是 beginend 等。

为了保持可读性,代码文本会把嵌套结构通过缩进来呈现;但嵌套层数、以及同一嵌套层级里的子结构增多,可读性还是难以维持。于是代码编辑器里便提供了缩进指引来方便使用者辨认位置对应的嵌套区域。

indentation-guide

但并非所有的缩进层级都具有固定的宽度,缩进指引的渲染行为一般也有以下几种:

假设文本的逻辑行 $i$ 由开头的宽度合计为 $h_i$ 的空白字符(空格或者制表符),以及剩下的以非空白字符开头组成。

  1. 一种最简单、有效、但适应性较差的思路:在行首的空白处按照当前配置的 Tab 字符宽度,每隔相同单位添加一个缩进指引(Notepad++/VSCode)

    notepad++ig

  2. 根据文本的结构进行添加:对于所有在当前逻辑行 $i$ 上方的行 $j$,如果满足 $\forall k \in (i, j), h_k > h_i > h_j$,那么我们可以在第 $i$ 行的 $h_j$ 处添加一个缩进指引(这是 IntelliJ IDEA 的缩进指引默认行为)

    ij-ig

  3. 根据代码的语法/语义进行添加(Visual Studio 的实现)

    vs-ig

代码折叠(Code Folding)

如果说缩进指引在原文本的渲染基础上提升了结构信息的可见度,那么代码折叠则是更进一步地让部分文本以占位符的形式呈现。

代码折叠在很大程度上改变了大规模代码的阅读模式,减少了用户在提取代码中关键信息时需要的努力。

那么代码折叠到底应该折叠哪些部分?占位符该如何在正常的文本中进行渲染?这个问题在不同的编辑器实现中当然也有着不同的结果。

  1. 根据按照文本结构进行折叠(Sublime/VSCode 的默认实现)。和上述缩进指引类似,我们一般会对满足以下条件的行区间进行折叠:

    对于第 $i$ 行,如果:

    • 存在 $k > i + 1$ 且 $h_k \leq h_i$(如果 $k$ 大于最大行则 $h_k$ 为 $0$),使得 $\forall j \in (i, k)$,都满足 $ h_j > h_i $

    那么我们可以将第 $i$ 行的末尾到第 $k$ 行开头部分的文本折叠起来

  2. 根据语法和语义进行折叠(VSCode 的部分语言支持/Visual Studio/IntelliJ IDEA)。能够将代码中的体现的结构对应的文本区间进行折叠,而非仅仅根据其文本的排版特性。

    而在此基础上,部分 IDE 还能够利用折叠区域的占位符体现据代码中的语义信息,例如:

    • Java 中的 Object Literal 在部分条件下以 Lambda 表达式的形式作为的占位符进行折叠
    • 依赖配置文件的 Key 在代码中以其 Value 的形式进行折叠

嵌入提示(Inlay Hints)

上面说的都是一些比较基础、或者说已经被实现很久了的功能,但嵌入提示则是一个相当新的功能。即便如此,我仍然认为它能够极大地改善代码编辑器的用户体验,可以说是一个声称做到了“代码理解”的 IDE 的标配功能。

那么什么是嵌入提示?它是为了解决什么问题的?

简单的来说,嵌入提示是一些与文本共同参与排版、但无法直接编辑的提示信息。现代编程语言(或者说设计思路更加现代的语言),往往都会提供大量的方式简化用户的代码编写流程,例如自动类型推导与可选的类型标注使得我们可以减少一些复杂类型的编写。但这同样也会带来一个问题,虽然编译器很容易通过上下文推断出这些信息,但代码阅读却很难自己完成这一套流程。为了降低代码理解的难度,代码编辑器最初提供了悬浮提示来获取这些隐藏在代码背后的信息;而嵌入提示则更进一步,省略了这种交互行为,直接将其作为不可编辑的提示块内嵌在文本中。

一般来说,嵌入提示的出现场合有:

除了直接嵌入在逻辑行内的嵌入提示之外,还能够以占据一整块编辑器水平空间的形式添加块嵌入,以提供更多的交互形式。常见的块嵌入有:

代码编辑器的交互

结构化编辑器虽然有诸多不便,但它能够让你始终是在一个“几乎是正确的”抽象语法树上进行编辑;文本编辑可能更加自由,但却需要额外的努力去维护文本作为代码的结构正确性。针对文本的代码编辑器尝试了各种方式来减少两者之间的 Gap,让编码者在享受文本编辑器的自由度的同时能够获得结构化编辑器的便利。

根据是否会对文本编辑器的交互产生影响,我们可以把这些功能分为两类:

下面稍微过一下一些比较重要的功能

代码补全

可以维护的代码不免包含大量的冗余信息,这些信息在阅读时能够帮助我们理解,但在编写时无疑会带来不少重复劳动;代码补全的主要目的就是尽量减少这些额外的工作。

completion

和上面的其它功能类似,在代码补全面世之后,它能够解决的问题也随着相关技术的发展变得越来越多,从最早期的关键字和词典补全、到根据代码中的符号进行补全、再到根据语义信息保留合理的补全项、以及当前和将来将会逐渐普及的基于统计和程序合成的补全内容生成。

一般而言,代码补全的行为如下:

括号匹配

括号(或者说成对的语法结构)对代码的结构起到了非常关键的作用。为了提供更好的代码浏览和编辑体验,代码编辑器提供了以下几个功能:

同时,括号作为一种结构标识,也会对代码的缩进产生影响:


除了上面我提到的这些,代码编辑器还有很多其它的功能;碍于篇幅问题,我们这里就只介绍到这里。

那么接下来,我们就可以开始探讨这些功能到底是怎么实现的了。

代码编辑器的实现

在上篇中我们说到,文本的布局和渲染流程大致可以分为下面几个步骤:

  1. 根据换行符将文本切分为逻辑行、或者说段落 Paragraph
  2. 将逻辑行中的文本根据 Bidi 算法切分成 Bidi Run,并计算得到它们的实际渲染顺序及方向
  3. 对于按照视觉顺序排序后的 Bidi Run,根据最大渲染宽度以及文本单词的定义在文本中插入若干软折行、使得通过软折行切分后的文本能够在限定的宽度内完成渲染
  4. 对于切分后的每一段 Bidi Run,交给文本定形引擎计算实际的字形后,以同一的样式进行绘制

在文本发生变更之后,我们原则上需要重新完成一遍上面的这些全部流程,来得到新的显示内容。但考虑到文本变更的局部性,其中的大部分步骤可以利用历史中计算得到的结果——也就是所谓的增量方案,以提升更新的效率。

但在引入了代码编辑器特性之后,我们不得不面对下面两个问题:

  1. 如何支持包含了这些特性的文本的渲染?
  2. 当文本发生变更时,渲染结果应该发生什么变化?

众所周知,理想状态下的代码编辑器特性应该满足:

但作为一个 GUI 程序,它对于实时性的要求可能需要保证获得最新结果的时延在 1 帧之内(考虑到 144hz 显示器的普及率,1 帧时间可以大致理解为 8ms);考虑到代码的量级(至少需要支持到 MB 级别)和算法的效率(仅仅是 Parse 可能都需要上百毫秒),这个目标可能不太现实。

我们退而求其次:

不是富文本编辑器的富文本编辑器

满足第一个前提,意味着编辑器需要支持对一段具有各种额外信息的文本的编辑操作。换句话说,虽然代码本身是平文本,但代码编辑器是富文本编辑器

既然如此,那么:

  1. 这些富文本是如何排版和渲染的?
  2. 这些富文本如何按照对于平文本的规则进行更新?

重新构建排版与渲染模型

我们可以将第一个问题换一个说法:

为了回答它,我们必须先知道各种编辑器特性是如何影响代码的排版与渲染的。

根据上面这些描述加上我们之前对于排版的理解,我们可以得到以下结论:

那么我们可以重新整理一下排版的(逻辑)流程:

  1. 根据折叠区间的折叠状态,将处于折叠状态的文本替换成该区间的占位符
  2. 将处理后的根据换行符将文本切分为 “逻辑行’”,对于 逻辑行’ 内的文本:
    1. 被折叠占位符以及特殊字符切分的文本根据 Bidi 算法切分成 Bidi Run,并计算得到它们的视觉顺序及方向,与特殊字符以及折叠占位符整合成一个片段序列
    2. 将未被折叠的内联嵌入插入到这个序列中
    3. 按照视觉顺序对序列中的片段的进行遍历和切分,在合适的位置插入软折行以保证折行之间的片段宽度和小于给定的编辑器宽度
  3. 将块嵌入插入到 逻辑行’ 序列中,对该序列中的元素依次进行渲染

考虑到文本渲染时可能需要保证统一的颜色和样式,我们还可能需要根据代码高亮的区间数据对文本片段进行切分,使得最终的片段序列中每一个片段内仅包含一种样式。

富文本的信息变更

那么我们再来看第二个问题。

由于大多数代码编辑器特性都可以被看做在原本文本中某个范围或者位置上添加的标记:

当文本(因为用户的编辑)发生变更时,就有可能无法实时获得正确的标记信息。 我们有必要将这些标记跟随文本进行同步变更,才能在文本渲染时产生合理的的结果:

假设对于文档中处于偏移量为 x 的标记,我们应用了单次的文本编辑 (range, str)

*如果是区间标记则对区间两端分别应用上述规则。

在获得了更新的区间数据后,我们就可以使用新的数据重新完成一次编辑器的排版和渲染了。(但折叠区域可能会由于文本的变更导致折叠状态发生改变,具体逻辑我们暂且按下不表)

富文本编辑的实时性

我们承诺了编辑器交互的实时性,也就是说(至少对于编辑器启动完成后的交互行为导致的)渲染流程需要在 1 帧的时间内完成。如何满足这一点?

我们将(富文本编辑器的轻量级)编辑行为的处理流程分以下几个步骤:

  1. 处理事件并得到对于文本数据的修改操作
  2. 将修改操作应用到文本数据结构上
  3. 将修改操作应用到富文本属性上,使它们和修改后的文本能够匹配
  4. 根据文本数据结构上发生的变化,对排版进行调整
  5. 对更新后的内容渲染

第一个步骤在编辑行为较为简单的情况下是可以保证实时性的(例如在按下 Backspace 后我们会拿到当前编辑器的选择区域,在不为空的情况下我们会提交一个将选择区域对应的文本删除的操作)。而第二个问题我们在上篇中已经讲过了(Rope/PieceTree 等数据结构可以保证 $O(\log n)$ 甚至更低的复杂度),也可以很轻松地满足我们的要求。

第三个问题该如何解决?

区间修改

在 2.1.2 中我们提到了每个标记或者范围标记在宿主、也就是文本发生变更时需要采取的同步策略。在区间标记的数量较小的时候我们可以直接用普通的容器对标记依次遍历以进行更新,但 $O(n)$ 的时间实在不是一个能接受的代价。

审视一下我们对于承载这些区间的容器的要求:

有没有更高效的手段来实现这个容器?答案是区间树(Interval Tree)。

区间树是一个维护区间的容器,通常使用平衡树进行实现。平衡树的键为区间的起始点,具有相同起始点的区间可以被保存在同一个树结点中(当然也可以使用不同的结点)。结点中会保存子树保存的区间中里最大的区间结束点,便于进行区间求交判断。

由于是基于平衡树实现的,我们可以很轻松地实现前 3 个要求,但我们该如何支持高效的区间替换的操作?

假设被替换的区间为 r,替换为了长度为 l 的文本;那么对于区间树中保存的区间 x,它与 r 的关系有下面几种可能(经过简化之后的版本):

  1. x.end <= r.beginx 不需要发生任何变化
  2. x.begin <= r.begin <= x.end <= r.endx 会被替换为 (x.begin, r.begin)
  3. x.begin <= r.begin <= r.end <= x.endx 会被替换为 (x.begin, x.end - r.length + l)
  4. r.begin <= x.begin <= x.end <= r.endx 会被直接移除
  5. r.begin <= x.begin <= r.end <= x.endx 会被替换为 (r.begin + l, x.end - r.length + l)
  6. r.end <= x.beginx 会被替换为 x - r.length + l

其中 1/4/6 三种情况我们都可以利用平衡树来进行批量操作:

第一种情况不需要任何操作

第四种情况我们需要找到所有区间 r 的子区间,根据平衡树和我们赋予结点的性质,最多会落到 $O(\log n)$ 个子树上,我们可以直接对它们进行删除,并更新对应的祖先结点

第六种情况我们同样可以找到所有满足条件的子树,考虑并不会频繁地对区间树中的区间进行全量遍历,我们完全可以通过一个 Lazy Tag 的形式,将实际的区间更新推迟到查询等行为时。对于这种对区间直接添加偏移量的操作,我们可以直接在对应的子树的 Tag 上添加该偏移量;在查询一个结点时,会从下至上遍历它的所有父结点,对 Tag 上的偏移量进行累加并得到当前子树的真实偏移量。

而考虑到有重合的区间数量在富文本编辑器这个场景下一般都是常数级别,对于其它情况下的区间我们可以将它们先从区间树中移除,经过变换之后再重新插入到区间树里。

局部绘制与增量排版

在解决了编辑器附加的信息与文本同步的问题后,我们还是回到了如何快速实现排版和渲染流程

很容易想到一些比较 ad hoc 的优化:

  1. 省略掉不必要的 Bidi 支持

Bidi 处理的复杂性想必看过上篇的人都会有所体会,这些复杂性也会体现在性能方面。同时目前主流的代码仍然以 LTR 文字为主,基于这个事实我们完全可以将“存在双向文字混排”作为稍小概率的事件进行处理。对于不包含 Bidi 文本的编辑,可以直接短路掉 Bidi Run 的创建和顺序调整流程;

  1. 省略复杂文本布局支持

和 Bidi 类似,复杂文本布局不仅仅是文本定形引擎复杂度的催化剂,调用它们的 API 同样也会导致不少的开销;在编辑器关闭了连字支持后,如果编辑器内不存在、且编辑操作尚未引入 Combining Character,那么我们可以仅使用简单文本布局来进行排版。

再回头看上面的排版渲染流程,它本身并没有太多优化的空间,即:

但我们可以发现:

  1. 代码编辑器并不需要对全部文本保证实时的布局和渲染,我们只需要对编辑器的可视部分进行绘制。
  2. 大部分的文本变更是局部的,我们完全可以利用之前已经计算过的内容,也就是所谓的增量方案。

实现局部绘制的前提是,知道需要绘制的范围内有哪些东西:即能够知道 XY 坐标到每个片段的映射。

考虑到排版实际上是计算片段到 XY 坐标的映射,我们可以根据排版过程中坐标计算时生成的数据提供这个功能。会影响片段在排版时的 Y 坐标的有两个因素:

对于硬折行,我们可以直接通过文本数据结构查到;软折行本身也类似于其他的标记,在计算得到结果后可以将它们保存在可以根据偏移量进行查询的容器中;块嵌入和软折行同样可以使用类似的方法进行查询。

而对于 X 坐标则需要不同的思路。考虑到每一行的片段的个数是常数级别,只要我们维护了偏移量与片段的映射,就可以根据从 Y 坐标获取的最近的折行找到当前视觉行最开头的片段,然后依次遍历每个片段即可。

片段遍历的大致实现如下:

通过可视区域的矩形范围,我们可以得知需要渲染的片段对应的视觉行区间,然后对于其中的片段进行重新绘制即可。


增量排版的目的是尽量减少文本发生变更时重新生成排版数据时花费的时间。为了方便讨论,我们先确定有哪些需要保存的排版数据:

那么一次文本变更会对之前保存的布局数据带来哪些影响?

对于行文本布局而言:行文本布局以按需的形式进行生成和保存,为了平衡内存占用和时间开销,缓存的文本布局可能会被销毁。如果被移除的区间端点所在的逻辑行与插入的新文本都不包含 RTL 文本,那么可以直接省略对于 Bidi 的处理并在对应行上添加标记;否则,直接销毁掉保存的对应文本布局;最后将行的数量与文本的实际逻辑行数量调整到一致。

嵌入所在的位置处于被删除的区间中时,需要将其从编辑器中移除。

折叠区间由于区间端点的删除可能会导致折叠状态发生变化

软折行:文本变更会导致最多从 变更开始 到 替换后的文本所在的逻辑行末尾 范围内的逻辑行重新计算软折行。在从变更开始位置对(经过折叠处理后的)逻辑行中的片段进行遍历并插入新的软折行;如果发现(文本替换范围后)存在旧的软折行经过变更导致的偏移后的位置 与 新插入的软折行所在的位置 相同,那么我们可以认为之后需要插入的软折行都是上一次计算时已经处理过的,并中止本次软折行计算。

至此,我们已经大致了解了代码编辑器作为富文本编辑器的功能实现。

一些额外的细节说明

  1. 代码折叠所标记的区间需要满足两两之间只有不相交与完全覆盖两种情况;当由于与文本的同步变更导致某些区间无法满足这个特性时,需要对其中一个进行删除
  2. 嵌入的变化、折叠状态的变更等行为都会导致软折行的重新计算,并触发相应区域的重绘
  3. 编辑器需要获得其显示内容的大小,来正确提供滚动行为的支持:高度需要通过折行以及块嵌入的渲染大小得到、宽度需要动态维护最长行来获取,这都需要我们使用合适的数据结构来解决它们的性能问题;而在文本规模增大后,精确计算所有视觉行的宽度会导致性能无法满足要求,更好的做法是根据文本长度来对其渲染宽度进行估计,并在显示到对应的内容时再精确计算。

附加信息的获取与应用

放弃编辑器特性的实时性不代表我们不重视特性与文本的同步;相反,我们需要尽量减少用户在进行编辑之后、编辑器中显示出正确的附加信息的延迟,而这其中绝大多数的时间都花费在了计算变更后的代码分析上。

在座的各位想必都非常精通语言服务实现的相关知识,什么错误容忍的增量语法语义分析器应该都能随手实现,况且“如何从代码中解析得到这些信息”也不是这篇文章的重点,我就不再班门弄斧,讲解这些大家都知道的东西。

我们可以假设存在这么一个机制(或者可以直接把它当做 Language Server),支持接受以下来自编辑器的请求:

考虑到用户的编辑行为可能是随时都在发生的,而请求附加信息的异步过程在得到结果时可能已经过去了好几百毫秒。在这种耗时对比下,如果文本变更后发送请求,等得到结果时可能已经早已过期了;而且如果每次文本修改都发出一次请求,可能会导致请求产生堆积,响应越来越慢。

那么编辑器该在什么时候向它请求这些数据?请求的数据该如何使用?

首先需要明确两点:

在连续编辑的这个场景下,我们当然希望附加数据的生成过程是不会空闲的,即:

  1. 只要代码上的附加信息与之不匹配,那么此时一定存在一个尚未结束的异步请求

我们又希望获取的信息是尽量新的:

  1. 每次提交请求时,会以当前最新的文本作为请求信息的基础

而当我们拿到请求的信息后,它对应的文本可能是好久之前的文本,所以我们话需要:

  1. 获取的请求信息,在添加到编辑器之前需要参考它生成时的文本和当前的文本之间的区别
编辑行为:|    |   |    |       |                                |
数据更新:|               |           |               |                 |

那么我们可以得到一个大致的数据更新流程:

数据源拥有一个状态,它会是下面三个中的一种:

  1. Idle:当前处于空闲状态
  2. Working:目前处于工作状态,且提交的文本和当前的编辑器文本一致
  3. Pending:目前处于工作状态,且提交的文本和当前的编辑器文本不一致

在编辑器的事件循环中会遇到下面这些事件:

  1. 文本数据由于各种因素发生了变动,根据不同的状态我们需要采取不同的行为:
    • Idle:直接对变更后的文本进行一次提交,将状态转移到 Working
    • Working:将文本变更保存在变更队列中,并将状态转移到 Pending
    • Pending:将文本变更保存在变更队列中
  2. 数据源提交的异步任务结束了,对于其状态:
    • Working:代表拿到的信息和当前的文本是匹配的,可以直接应用在编辑器里
    • Pending:代表拿到的信息和当前的文本不匹配,需要参考变更队列中的变更进行变换后再应用到编辑器里;然后将变更队列中的变更数据添加到数据源之后再重新请求一次数据,清空变更队列

另外一个很重要的问题是:如何将得到的数据应用到编辑器中?在文本上的附加信息会随着文本规模增大而增大,直接在编辑器中应用这些数据可能会导致编辑器 UI 失去响应。

这个问题我们一般可以在两个层面进行解决:

  1. 从数据源获得的数据是增量的,即数据源可以返回应用在全部文本上的全量数据,也可以基于上一次返回的数据返回与最新结果的差异
  2. 对于获取的更新数据,可以采取分批的方式,保证在不影响帧率的情况下,一轮事件循环中在编辑器中应用尽量多的数据。(当然可视范围内的文本将会优先被更新)

总结

今天我们先大致过了一下代码编辑器的一些主要功能和它们的设计思路,然后基于这些特性讨论了代码编辑器在排版和数据更新等方面与普通的文本编辑器的差别,以及在满足性能要求的前提下大致的实现思路,希望大家在看完之后也能学到一点点东西吧。