为什么lsp-bridge不用capf?
Emacs
2022-06-26 5905字

capf设计了一系列接口来获取 candidate、 annotation、 deprecated、 exit-function 等信息, 以此来标准化补全后端的实现, 好处是大家都按照 capf 统一的接口去实现补全后端, 然后通过 company-mode 或者 corfu, 我们可以用同一套补全前端去显示不同后端的补全内容, 最大程度聚合不同Emacs插件开发者的力量。 capf的理念我是完全理解的, 如果只是从概念来理解, 我觉得这个初心特别好。

在我回答lsp-bridge为什么不基于capf构建的问题之前, 我想问大家一个问题: 补全框架设计最重要目标是什么?

从我的理解看, 所有补全框架最重要的目标是 “实现极致的补全速度, 不要因为卡顿去打断编程人员的思路”。 如果编程人员写代码的时候卡顿一下, 就会心烦意乱, 一旦正常的逻辑思路被负面情绪所影响, 就很难高质量地完成编程工作。

下面我将围绕着 “高性能补全架构“ 这个核心思想去分享lsp-bridge开发过程中的一些思考, 我相信大家阅读完这些观点后, 会自然的理解为什么 lsp-bridge 不基于 capf 机制去开发补全功能。

高性能UI的关键

从人眼视觉和动画原理看, 任何GUI程序要让用户感觉流畅的理论基础是, 每秒最少要刷新24帧, 我们按照每秒刷新25帧算, 也就是说一个界面绘制周期必须在40ms之内完成, 用户才会觉得界面操作流畅。 一般人两个键盘字符敲击的间隔在100ms ~ 150ms之间, 手速快的编程人员的敲击间隔会在100ms以内。

这意味着什么呢? 如果一个界面更新周期的耗时超过用户键盘敲击的间隔时间, 用户就无法即时的敲入下一个字符, 这也就是我们平常表达的 “卡顿”。

而计算机很多时候会进行大量计算, 计算耗时很容易就超过150ms, 所以一个高性能的UI设计最关键的是要引入多线程技术, 和用户相关的图形线程(在大多数桌面操作系统, 这里都指的主线程)需要保证所有界面绘制耗时都要低于40ms, 需要额外计算的时候就开启一个完全并发的子线程进行耗时计算, 子线程不管计算40ms还是4000ms, 都不会导致主线程一丝一毫的卡顿, 子线程完成计算后, 把数据通过队列的方式传递给主线程, 主线程再进行下一个周期的绘制任务, 同样保持在40ms以内, 循环往复, 只需要引入支持并发的多线程机制就可以实现高性能UI软件, 达到丝滑的界面操作体验。

而根据LSP协议的特点, 其实是LSP服务器通过stdio发送大量的数据回来, 是一个IO密集型的场景, 而不是CPU密集型的场景, 所以lsp-bridge选用”Python + 多线程”的方案完全可以满足LSP补全所需的性能, 同时采用Python脚本语言非常适合Emacser进行快速开发, 不需要像静态语言那样进行频繁的编译工作, 大大提升了开发效率。

Emacs的缺陷

Emacs在LSP场景下慢的原因有两个:

  1. 单线程机制: Emacs同一时刻只能运行一个线程, 当LSP服务器直接返回大量JSON内容(比如volar会在敲一个字符的瞬间返回5万行以上的字符串数据)时, Elisp无法在40ms内完成JSON内容的语义解析, 就会让用户觉得非常卡顿
  2. 频繁的GC操作: LSP服务器无时无刻不在向编辑器发送大量的补全信息、诊断信息、候选词文档等信息, 当大量的中间状态数据需要保存在Elisp内存时, 就会频繁的触发Emacs进行GC操作去回收内存, 而这种高频触发GC的环境, 也会让用户感觉持续的卡顿, 因为Emacs进行GC的操作超过150ms时, 用户就会被卡的非常难受

capf 不适合LSP协议

从我开发lsp-bridge的过程看, capf的协议只适合一些简单的补全后端, 非常不适合LSP协议, 主要有三个方面:

  1. 把candidate当作接口的Key: capf所有接口的lambda函数都以 candidate 作为参数, 什么意思呢? 就是你补全菜单看到的候选词就是capf搜索其他信息的Key, 但是LSP的协议经常会发送 candidate 一样但 annotation 不一样的候选词, 以Java语言为例, 一个同名函数经常来源于不同的模块, capf这样以 candidate 作为Key的设计, 导致LSP的补全后端要写非常多的丑陋代码去绕过这个设计缺陷。 还有一些LSP服务器会返回非常长的候选词, 比如Java LSP服务器就会返回函数的所有参数信息, 这时候我们需要引入 display-label 和 label 的设计, display-label用于候选词显示, 当候选词太长时可以用…省略号来表示, label来留存真实的候选词内容, 而capf并没有这样的机制, 我需要写大量的workaround来实现这些功能
  2. 基于接口的动态查询: capf认为你应该保存候选词列表, 不管查询 candidate、 icon、 annotation还是deprecated信息, 补全菜单绘制的时候, 都先要根据 candidates 接口函数找到所有的候选词, 再通过候选词作为Key去查询图标、 备注等信息, 最后再由补全前端进行聚合渲染。 当LSP服务器返回超过几千个的候选词(比如TypeScript), 每次菜单绘制都需要进行大量的搜索查询操作, 极大的浪费了Emacs的计算资源。 而现代计算机的内存都是非常大的, 我们完全可以用经典的 “空间换时间” 的思路去大幅提升这一块的补全性能
  3. exit-function接口: capf的exit-function接口是假设你先插入候选词以后, 再调用补全后端的exit-function接口进行操作, 我们以 volar(vue的LSP服务器)为例,当你输入 fun. 的时候, 补全菜单会弹出包括 var 的候选词列表, 当你在 var 候选词按回车时, volar会通过 additionalTextEdits 信息告诉编辑器最后补全成 fun?.var 的样子, 这其中的逻辑是, LSP服务器会通过 additionalTextEdits 信息让编辑器把光标移动到 . 之前的位置, 然后插入 ?.var 的字符串, 而capf创造的时候, 还没有LSP协议。 capf的设计是, 补全菜单敲入回车的时候就默认插入 var 变成 fun.var 的形式再调用补全后端的exit-function接口, 这样就导致在实现LSP后端的时候, 我们要写大量的workaround的代码去记住插入候选词之前的位置, 先要删除capf插入的候选词, 再进行复杂的LSP协议解析和替换字符串操作, 结果就是基于capf实现补全菜单在插入补全信息时, 你总会看到Emacs发生 ‘插入->删除->再插入’ 引起的闪烁问题

lsp-bridge 的设计

基于Emacs、 capf和现有LSP客户端的诸多问题, lsp-bridge的设计主要有几点考虑:

  1. 独立进程作为Emacs和LSP服务器之前的数据缓冲层: 因为在我们快速编码过程中, LSP服务器返回大量的数据都是编码临时过程的分析数据, 这些数据90%都是过期数据, 比如大量的诊断信息, 完全不用发给Emacs处理。 独立的Python进程会实时接收LSP服务器返回的海量数据, 只会把处理过后的微量数据返回给Emacs, 比如补全菜单信息, 其他的数据, 包括候选词文档、 诊断信息等都缓存在Python进程, 当Emacs需要的时候再返回。 比如用户选中补全菜单的某一项后才会向Python进程获取候选词对应的API文档, 而以前Emacs LSP客户端的实现面临 volar 每敲一个字符就会返回5万行以上的候选词文档信息时就很难处理, 不接受吧, LSP服务器不会再发送这些信息, 接受吧, 每敲一个字符就解析5万行以上的JSON数据, 不但卡而且大概率还用不上(因为用户很快就敲下一个字符了)。 再比如, LSP基本上你只要修改文档, 它就会一直发送诊断信息给编辑器, lsp-bridge会实时把所有的诊断信息都存储到Python进程端, 只有当用户停止敲键盘才会向Python进程查询最新的诊断信息, 而以前Emacs LSP客户端实现诊断信息处理时也同样为难, 不接受吧, 用户可能就没法看到实时的诊断信息, 接受吧, 编码过程中的诊断信息99%都是无用的, 大大增加了Emacs处理LSP消息的负担。
  2. 多线程和完全异步设计: Emacs编辑文件时就发送请求给Python进程, Python进程接受到消息后通过子线程的方式和LSP服务器进行交互, 真正处理完LSP数据后才会返回给Emacs, 整个过程大量使用了多线程和完全异步设计, 不会因为任何计算导致Emacs卡顿
  3. 唯一key的设计: lsp-bridge的Key使用 index,label 的形式在Emacs和Python进程之间进行数据交换, 同时引入 display-label 的机制, 针对 label 一样但 annotation 不一样, 或者 label 太长的情况, 都能很好的处理, 而且代码异常简单容易维护
  4. 一次补全设计: 补全菜单选中时, 会直接根据LSP协议进行分析, 一次插入并展(比如函数模板参数展开), 不会再发生 capf插入再删除再插入 引起的补全菜单闪烁问题
  5. 最小查询设计: LSP服务器返回候选词信息(图标、显示名称、真实名称、备注信息、插入信息、参数模板信息等)的时候就生成到Hashtab中, 补全菜单渲染时直接用现成数据即可, 不需要再通过capf接口做多次查询, 大大提升了数千候选词规模时菜单过滤的性能
  6. 补全菜单和LSP候选词计算解耦: LSP服务器计算好候选词以后, 更新buffer的Hashtab即可, 补全菜单(acm)只用每次过滤用户输入时取用最新的Hashtab, 从而实现补全前端完全无闪烁的效果。 company和corfu的设计都是假设菜单弹出那一瞬间, candidate数据都是准备好且不会再更新的, 而实际情况是LSP服务器会根据用户输入的内容, 无时无刻不在输出新的补全数据列表, 而怎么让 company/corfu 在补全菜单显示的时候动态刷新最新的LSP补全内容, 需要编写大量的 monkey patch, 再加上 capf 的设计, 这一块的代码逻辑会异常复杂、 丑陋和难以维护。 所以在修复大量相关的bug后, 我在想, 为什么不按照 LSP 的协议特性写一个新的补全前端呢? 这也是我最终编写acm.el的原因, 事实证明, acm的代码量更少、 代码实现更简单, 同时也更容易维护
  7. 高性能单词补全后端: dabbrev用于非语义场景非常有用, 比如根据上下文来创建新的函数或者变量名, 但是实时地对所有打开的文件进行分词任务对Emacs来说是一个不小的性能压力, 甚至会因为打开文件太多而导致补全卡顿, lsp-bridge用了同样的多线程机制, 在后台实时分析打开文件的单词列表, 减轻Emacs的分析工作, 最终实现任意文件规模下, 实时无卡顿进行单词联想补全
  8. 两种识别模式: lsp-bridge默认有两种模式, 检测到.git目录时(通过命令 git rev-parse --is-inside-work-tree 来判断), lsp-bridge会扫描整个目录文件来提供补全, 没有检测到.git目录时, lsp-bridge只会对打开的文件提供单文件补全。 通过更为通用的 git 机制来实现项目补全和单文件补全(比如Python脚本)的动态探测, 同时因为git的特性, 我们在嵌套git项目中也能很好的工作, 实现零心智负担的开箱即用

acm的策略

通过前面的论述, 大家应该清楚, lsp-bridge和acm的目标都是以 “实现极致的补全速度” 为前提, 在满足这个前提的情况下, acm已经实现了对 Elisp Symbol、 LSP、 Yasnippet、 Tempel、 Words、 Path甚至是English等后端的内在支持(抱歉我的英文非常不好), 而这些后端已经进行内置的融合设计, 比如优先显示Elisp/LSP/Words的补全信息, 菜单第一屏底部显示Yasnippet/Tempel信息, 当你在输入文件路径的时候, 自动切换成Path后端, 当你调用 lsp-bridge-toggle-english-helper 命令时, 临时进入英文单词补全。 你完全不需要为了融合不同的后端, 去折腾 company-transformer/corfu-cape等机制。 同时 acm 使用了SVG来绘制图标, 而不是使用 all-the-icons, 实现像素级的对齐和菜单大小动态调整。 我希望corfu的作者可以看一看acm的实现, 只要图标用svg来绘制, corfu代码中手工计算大小的代码就都可以删除, 让代码更加简洁。

最后

lsp-bridge和acm的目标是 “实现极致的补全速度” (同样的LSP服务器配置, Emacs不应该比VSCode/VI的补全速度慢, 没有道理), 同时做到真正的做到开箱即用, 用户只需要安装好LSP服务器后, 不用做任何配置就可以立即享受丝滑的智能语法补全体验。

capf时代, 大家的体验是什么呢? 下载lsp客户端, 折腾补全前端, 折腾图标显示, 折腾补全后端, 折腾补全后端的融合, 折腾不同插件包代码的融合, 甚至要写大量的 hook 和 advice 代码… 然后呢? 代码补全的时候还是卡的一批, 然后用户再查看大量的文档, 折腾大量的优化技巧, 延时技巧, 关掉很多选项, 最后安慰自己, 这就是Emacs的强大, 这就是我的强大, 你看我多么厉害, 折腾了几个月后, 我的补全速度还行… (我在此处深深的鄙视这种自己骗自己的行为)

我想说的是, 为什么同样的LSP服务器配置, VSCode能够做到丝滑的编程体验, Emacs以前那些LSP实现方案那么卡顿呢? 为什么在lsp-bridge已经完全实现了丝滑补全的效果后, 社区的一部分人还要找借口攻击Python的方案不够好呢? Emacs社区以前大量使用子进程调用外部工具输出文本结果再用一大堆正则过滤来增强Emacs, 但是这种基于文本过滤的方法限制很多, 大部分都属于Hacking Way的范畴。 lsp-bridge/EAF通过 “IPC + Thread” 的方法, 实现了 Elisp、 Python、 C++、 JavaScript等语言的融合编程方法(不同语言和语言库可以在运行时进行语义互调用), 经常会被误解为不 emacsy 的方案。 我相信这17年来写过的elisp的代码比大多数Emacser都多, 我深爱着Emacs, 也非常清楚Emacs的优势和缺陷, 我希望大家持有开放的心态去看Emacs未来的进化, 让其他编程语言和生态去扩展Emacs的能力会让Emacs变的更强更好, 我们Emacser应该有足够的生态自信去拥抱异构融合编程技术, 不要因为恐惧而固守己见, 甚至否定自己都不了解的技术。

希望这篇博文能够解答很多Emacser对lsp-bridge的疑问。