Re: 从零开始的语言服务器开发冒险:文档链接
develop lsp
“小女孩,你看,我遵守了诺言。”
– 刘慈欣《三体Ⅲ·死神永生》
完结系列文章,并赠送一点边角料。
文档链接
这里快速实现一个文档链接的 LSP feature :将文档中所有的链接用波浪线标出,当用户光标停留在链接处时,显示超链接的路径。用户可以按快捷键打开链接,如果链接是网页则直接打开链接,如果是文本则用当前编辑器打开该文本。
以下是 clangd 实现的文档链接。(妈妈再也不担心我找不到头文件在哪里啦)
neomutt 是笔者非常喜欢的一款电子邮件阅读器。其配置文件 neomuttrc 亦是语法酷似 vim script 和 zathurarc 的 DSL 。
这里回到系列文章第一篇的方案:在不依靠抽象语法树仅靠正则表达式的情况下实现语言服务器。
在 neomuttrc 中,用 source XXX
来包含文件 XXX
。我们知道匹配 source XXX
的 XXX
的正则表达式是 (?<=\bsource\b\s)\S+
,所以:
import re
PAT = re.compile(r"(?<=\bsource\b\s)\S+")
仿照系列文章第一篇,我们实现一个语言服务器:
from lsprotocol.types import *
from pygls.server import LanguageServer
class MuttLanguageServer(LanguageServer):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
- 获取文档内容
- 循环:在每一行搜索正则表达式
- 循环:对每个搜索结果成一个文档链接对象
- 返回所有的文档链接对象
import os
class MuttLanguageServer(LanguageServer):
def __init__(self, *args: Any, **kwargs: Any) -> None:
# ...
@self.feature(TEXT_DOCUMENT_DOCUMENT_LINK)
def document_link(params: DocumentLinkParams) -> list[DocumentLink]:
document = self.workspace.get_document(params.text_document.uri)
links = []
for i, line in enumerate(document.source.splitlines()):
for m in PAT.finditer(line):
_range = Range(
Position(i, m.start()), Position(i, m.end())
)
url = os.path.join(
os.path.dirname(params.text_document.uri),
os.path.expanduser(line[m.start() : m.end()]),
)
links += [DocumentLink(_range, url)]
return links
本文代码开源于 mutt-language-server 。
用正则表达式实现这个 LSP feature 是为了再次强调一件事,就是:
语言服务器协议只规定语言服务器和语言客户端如何通信,不规定具体的实现方式。无论使用什么语言、什么算法,哪怕是一个人坐在小黑屋里,接受输入,这个人给出符合标准的返回都是合法的语言服务器!请灵活地使用任何技术去实现它!
碎碎念
术语翻译
不少术语都是笔者自己翻译的,比如很多 LSP features ,微软的标准文档就是英文,哪有什么官方的中文翻译……
笔者尽量做到信和达,所以会增加一些词语,比如在 UI 设计中, hover 指当鼠标挪到某个按钮上时显示该按钮用途文档的气泡窗口。在 LSP 中就翻译为文档悬停,因为单纯的悬停总是让人联想到直升机的悬停实在出戏
调试
neovim 有插件 playground 可以查看光标下 token 在抽象语法树中的节点信息:
陷阱
系列文章中其实有不少设计不妥的“陷阱”。它们的存在是出于以下原因:
- 如果要用更妥当的设计,需要花费更大的笔墨解释更多与主线不相关的内容和知识。
- 编程并非一蹴而就的。笔者最初实现这些语言服务器时也走了不少弯路,后面才慢慢不断改进。所谓不妥的设计其实更多是笔者最初实现代码时最符合直觉的下意识反应,这些设计可能不符合用户体验、性能等条件,但绝对让读者意识到这东西是怎么来的,远比向新手甩出根本不易得的“易得”、很难发现的“不难发现”、注意不到的“注意到”、不显然的“显然”来炫技更加友好。
- 笔者的能力有限,明知道设计不妥,但一时无法想到更好的解决方案
无论如何,笔者保证这些陷阱都是善意而非恶意的。(不过说陷阱是善意的确实很奇怪?)
接下来我们将撕开所有真相的遮羞布:
- 系列文章一实现的补全有严重的性能问题。加载包信息的代码不需要每次补全都加载。但如果放在初始化阶段只加载一次,会因为过慢导致语言服务器和语言客户端通信超时。注意到本地读取包信息是 CPU bound 任务,从 PYPI 服务器上读取包名是 IO bound 任务,可以分别引入线程和协程异步加载。
- 系列文章二实现的解析器连 zathurarc 的 token 中不能出现换行符都没考虑到。需要额外设置
extras
属性。 - 系列文章三的
PositionFinder
额外定义了left_equal
和right_equal
,只有在系列文章四的代码补全才会用到。因为在用户输入前被输入的节点不存在文本,范围是个空集,比如[3, 3)
,如果不设置right_equal
根本匹配不到这个节点。
尽管没有多少读者在看文章时会动手运行代码查看结果,但还是在这里给出所有解释和解决方法,作为对发现问题的读者的嘉奖~
教科书 v.s. 教程
除去科普 LSP 历史的文章外,系列文章每篇都试图解释清楚一个概念,再给出相关的实践:
- LSP 相关标准,如何实现语言服务器
- 抽象语法树,如何实现解析器
- 跳转,如何实现搜索算法
- 验证器,如何实现验证模式
- 总结
笔者本人反对什么概念原理通通不提,只有手把手的保姆级教程,也不感冒全篇都是道理的不带任何实践性质的教科书。好的知识普及应当是理论和实践相辅相成的。如果读者仅仅是照着保姆级教程抄一大段代码,切换到新的相近的但没有保姆级教程的问题时会有独立思考的能力吗?如果全是理论,又显得太枯燥了一点。当然,这也只是笔者个人观点而已。
后续
有后续嘛?应该不会有。因为:
- 写文章讲究厚积薄发。光这几篇系列文章差不多就是笔者一个月以上阅读各类标准文档,实践 LSP 相关技术的积累了。笔者已经没有更多的积累了。
- 系列文章从结构看已经很完整了
- 确实没人看……
心里话
最后说点心里话。
- 冯诺伊曼心算大整数运算比当时的计算机还快还准
- 老一辈工程师靠穿孔纸带能把人送上天
- 在笔者老师的时代面试时需要在白纸上手写代码
- 在笔者这一代离开了会自动补全的代码编辑器都不知道该怎么写代码了
这是一代不如一代吗?不是这样的。时代发展就是会带来新的技术。笔者也只是尝试拥抱这种新的技术,甚至更加深入地了解这种技术。
回忆笔者了解技术的源头,是笔者因为老师项目的缘故接触了许许多多的 DSL ,像设备树源文件 *.dts
, bitbake
语言 *.bb
, Makefile
, cmake
, autoconf
, gdb script
等等,在编辑器编辑这些 DSL 根本没有任何补全,有些甚至连语法高亮都不一定有,所以开始抽出业余时间研究这些补全是怎么来的,如何支持补全这些语言等等。
诚然,有的语言问世时间已久,甚至有极大的影响力,但对代码补全、定义跳转却没有任何好的支持。这不是很奇怪吗?因为:
- Compelled: 每个人都不得不用这门语言
- Awkward: 同时每个人又都抱怨写这门语言就像在用记事本写代码一样痛苦
- Pleased: 于是每个人都乐意挺身而出开发能让编写这门语言更轻松的语言服务器
啊, CAP 不能三全,所以大家是都不乐意牺牲自己的时间嘛?
很多技术、工具确实无利可图,它们的出现就是一部分人牺牲自己的时间让更多的人得到方便。开源圈正是:
- 某一部分精通 A 领域的人牺牲自己的时间让所有人在 A 领域得到了方便(好用的工具、软件、算法等等)
- 另一部分精通 B 领域的人牺牲自己的时间让所有人在 B 领域得到了方便
- …
- 最终,所有人都得到了所有领域的方便,其中有人可能还不用做牺牲
让我们朝着最终“所有人都得到了所有领域的方便”迈进吧。本系列文章和代码也不过是一点小小的牺牲。也希望大家在享受编辑器编辑代码的便利之时想起默默无闻之辈做出的牺牲。