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 场景下慢的原因有两个:
- 单线程机制: Emacs 同一时刻只能运行一个线程, 当 LSP 服务器直接返回大量 JSON 内容(比如 volar 会在敲一个字符的瞬间返回 5 万行以上的字符串数据)时, Elisp 无法在 40ms 内完成 JSON 内容的语义解析, 就会让用户觉得非常卡顿
- 频繁的 GC 操作: LSP 服务器无时无刻不在向编辑器发送大量的补全信息、诊断信息、候选词文档等信息, 当大量的中间状态数据需要保存在 Elisp 内存时, 就会频繁的触发 Emacs 进行 GC 操作去回收内存, 而这种高频触发 GC 的环境, 也会让用户感觉持续的卡顿, 因为 Emacs 进行 GC 的操作超过 150ms 时, 用户就会被卡的非常难受
capf 不适合 LSP 协议
从我开发 lsp-bridge 的过程看, capf 的协议只适合一些简单的补全后端, 非常不适合 LSP 协议, 主要有三个方面:
- 把 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 来实现这些功能
- 基于接口的动态查询: capf 认为你应该保存候选词列表, 不管查询 candidate、 icon、 annotation 还是 deprecated 信息, 补全菜单绘制的时候, 都先要根据 candidates 接口函数找到所有的候选词, 再通过候选词作为 Key 去查询图标、 备注等信息, 最后再由补全前端进行聚合渲染。 当 LSP 服务器返回超过几千个的候选词(比如 TypeScript), 每次菜单绘制都需要进行大量的搜索查询操作, 极大的浪费了 Emacs 的计算资源。 而现代计算机的内存都是非常大的, 我们完全可以用经典的 “空间换时间” 的思路去大幅提升这一块的补全性能
- 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 的设计主要有几点考虑:
- 独立进程作为 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 消息的负担。
- 多线程和完全异步设计: Emacs 编辑文件时就发送请求给 Python 进程, Python 进程接受到消息后通过子线程的方式和 LSP 服务器进行交互, 真正处理完 LSP 数据后才会返回给 Emacs, 整个过程大量使用了多线程和完全异步设计, 不会因为任何计算导致 Emacs 卡顿
- 唯一 key 的设计: lsp-bridge 的 Key 使用
index,label
的形式在 Emacs 和 Python 进程之间进行数据交换, 同时引入 display-label 的机制, 针对 label 一样但 annotation 不一样, 或者 label 太长的情况, 都能很好的处理, 而且代码异常简单容易维护 - 一次补全设计: 补全菜单选中时, 会直接根据 LSP 协议进行分析, 一次插入并展(比如函数模板参数展开), 不会再发生
capf 插入再删除再插入
引起的补全菜单闪烁问题 - 最小查询设计: LSP 服务器返回候选词信息(图标、显示名称、真实名称、备注信息、插入信息、参数模板信息等)的时候就生成到 Hashtab 中, 补全菜单渲染时直接用现成数据即可, 不需要再通过 capf 接口做多次查询, 大大提升了数千候选词规模时菜单过滤的性能
- 补全菜单和 LSP 候选词计算解耦: LSP 服务器计算好候选词以后, 更新 buffer 的 Hashtab 即可, 补全菜单(acm)只用每次过滤用户输入时取用最新的 Hashtab, 从而实现补全前端完全无闪烁的效果。 company 和 corfu 的设计都是假设菜单弹出那一瞬间, candidate 数据都是准备好且不会再更新的, 而实际情况是 LSP 服务器会根据用户输入的内容, 无时无刻不在输出新的补全数据列表, 而怎么让 company/corfu 在补全菜单显示的时候动态刷新最新的 LSP 补全内容, 需要编写大量的 monkey patch, 再加上 capf 的设计, 这一块的代码逻辑会异常复杂、 丑陋和难以维护。 所以在修复大量相关的 bug 后, 我在想, 为什么不按照 LSP 的协议特性写一个新的补全前端呢? 这也是我最终编写 acm.el 的原因, 事实证明, acm 的代码量更少、 代码实现更简单, 同时也更容易维护
- 高性能单词补全后端: dabbrev 用于非语义场景非常有用, 比如根据上下文来创建新的函数或者变量名, 但是实时地对所有打开的文件进行分词任务对 Emacs 来说是一个不小的性能压力, 甚至会因为打开文件太多而导致补全卡顿, lsp-bridge 用了同样的多线程机制, 在后台实时分析打开文件的单词列表, 减轻 Emacs 的分析工作, 最终实现任意文件规模下, 实时无卡顿进行单词联想补全
- 两种识别模式: 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的疑问。