语言服务器简史

01 Jul 2023 6575 words 22 minutes BY-SA 4.0
develop lsp

让我们来稍稍看一下代码编辑器“引擎盖下的秘密”。

coc.nvim

编辑器和集成开发环境(IDE)

享受有趣的命令,带着屏幕上闪烁的一切。

– Bill Joy (vi 之父)

编辑器圣战(黑客文化和自由软件社区争论哪一种代码编辑器更好)相对的, 编辑器和集成开发环境的选择也常常是争论的焦点。用户要么:

目前大多数用户普遍的共识是:

那么问题来了:

每一种语言的开发工具(不光是编辑器、解释器、调试器、构建系统,还包括语法高亮、类型标注、注释自动生成等工具)是怎么支持编辑器的? 为何用户用编辑器打开一个文件就能看到语法高亮?为何用户执行一个快捷键就能跳转到函数的定义?为何用户写了有错或虽然没有错但不太好的代码会有错误或警告的悬停? 为何用户能得到代码补全?在这一切的背后到底隐藏了什么秘密?

语言服务器协议(LSP)问世前的编辑器

一般的,编辑器允许用户通过编写插件的形式获得对开发工具的支持,例如:

有 $n$ 种编辑器和 $m$ 种语言的开发工具,那么我们要开发 $n \times m$ 个插件。

支持多种语言的工具,完成功能后退出

出于方便,开始有对 m 种语言提供某一功能的统一工具出现。

它们的原理是:

扫描当前目录下所有支持语言的文件,生成缓存文件(例如 tags)记录变量、函数定义的位置信息,查找这些信息即可。当然生成缓存文件其实很慢,这考验编辑器插件的开发者能不能正确编写出异步的插件。如果只是简单的同步插件,用户光启动编辑器就要等半天……

!_TAG_FILE_FORMAT	2	/extended format; --format=1 will not append ;" to lines/
!_TAG_FILE_SORTED	1	/0=unsorted, 1=sorted, 2=foldcase/
!_TAG_PROGRAM_AUTHOR	Darren Hiebert	/dhiebert@users.sourceforge.net/
!_TAG_PROGRAM_NAME	Exuberant Ctags	//
!_TAG_PROGRAM_URL	http://ctags.sourceforge.net	/official site/
!_TAG_PROGRAM_VERSION	Development	//
main	main.c	/^int main(int argc, char *argv[])$/;"	f

类似也有支持多种不同语言的代码格式化工具 astyle, prettier 等等,动机同上。

支持多种语言的工具,以守护进程的形式在后台运行等待与编辑器的再次交互

这其实已经是语言服务器的雏形了。 YouCompleteMe 提供了对多种语言的代码补全支持。 YouCompleteMe 没有流行开来,不过不必惋惜因为我们马上就要介绍语言服务器了。

语言服务器协议

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

– David Wheeler (剑桥大学计算机科学教授)

语言服务器协议成功将 $n$ 种编辑器和 $m$ 种语言的开发工具的 $n \times m$ 个插件开发难题简化为开发 $n$ 个语言客户端和 $m$ 个语言服务器的 $n + m$ 个软件通过 LSP 通信的问题。

compare

一图胜千言:

LSP

语言服务器协议通常提供以下功能:(完整功能见官网

文档悬停

hover

coc-translator

代码补全

根据上下文和用户输入的前几个字符(例如 pr )预测用户的完整输入(例如 printf()),并按优先级排好,预测结果包含预测的类型(例如关键词、函数、变量会有一个不同的标记,例如 k, f, v)。可以显示对应的文档悬停。

补全源:补全的来源,包括:

completion

IME

snippet

代码格式化

代码诊断

通常是某些事件后自动触发(例如文件打开(didOpen),文件修改(didChange))。显示提示、信息、警告、错误 4 个等级的诊断。 通常可以有:

diagnostic

定义、声明、引用跳转

可以用正则表达式查找,但一般的做法还是解析抽象语法树。

注意:有些语言存在头文件,定义、声明可以不在一个位置。

命令

执行某个命令。可以是同步、异步,也可以开子线程。通常是某些语言专属的功能。例如:

基本上不同语言很难普遍存在的功能都会放在这里。

command

代码操作 (code action)

笔者目前不是很清楚,但似乎是某种给定范围的命令,类似 vim 带有范围的命令。

action

语言客户端

语言客户端即支持语言服务器协议的编辑器。分为 2 种:

本身不支持语言服务器协议,但可以通过语言服务器协议插件成为语言客户端

以 vim 为例:

coc.nvim

这应该是 Vim 最有名的 LSP 插件。严格来说它不仅是语言服务器,还提供了一套用 js 编写 vim 插件的框架,或将 VSCode 插件移植到 Vim 的一套相仿的 API 。

可以通过修改 coc-settings.json 使能某个语言服务器,或者为某个语言服务器创建 Coc 插件来使能该语言服务器。

vim-lsp

vim script 在很久之前不支持异步,所以在此前有相当多的 vim 的 LSP 插件都只能用别的语言编写,这不可避免的引入了依赖项。 所以这个用纯 vim script 实现的异步 LSP 插件值得一提。

原生语言客户端

注意,即便原生支持 LSP ,也可以有插件简化使能语言服务器的配置。

配置方法参见文档

语言服务器

语言服务器通常可以分为通用语言服务器和专用语言服务器:

笔者自己在学习中也基于 LSP SDK 动手实现了一些语言服务器:

语言服务器协议之外

并不是所有编辑器需要的功能都被语言服务器协议囊括在内了。

语法高亮

Vim Syn

Vim 原生是通过 vim script 的 syntax 和 highlight 2 个关键词来实现语法高亮的。比如:

syn keyword requirementsKeyword implementation_name implementation_version os_name platform_machine platform_release platform_system platform_version python_full_version platform_python_implementation python_version sys_platform contained
syn match requirementsPackageName "\v^([a-zA-Z0-9][a-zA-Z0-9\-_\.]*[a-zA-Z0-9])"
syn match requirementsVersion "\v\d+[a-zA-Z0-9\.\-\*]*"
" ...

hi def link requirementsKeyword Keyword
hi def link requirementsPackageName Identifier
hi def link requirementsVersion Number
" ...

再由 colorscheme 插件定义 Keyword, Identifier 到底应该是什么样式。用户也可以通过重新 highlight default link 将特定于某一种语言的高亮组高亮到其他高亮组。

Vim 社区也有讨论到底要不要支持 Vim Syn 以外的语法高亮方式

tmLanguage

TextMate 使用的一种描述语法高亮的 XML ,后被 VSCode 等广泛采用。但编写 XML 实在是太不友好了!

sublime-syntax

Sublime 使用的一种描述语法高亮的 YAML ,语法与 tmLanguage 兼容。后被 bat 采用。

name: Requirements.txt
scope: source.requirements-txt
contexts:
  main:
    - match: (?i)\d+[\da-z\-_\.\*]*
      scope: constant.other.version-control.requirements-txt
    - match: (?i)^[a-z\d_\-\.]*[a-z\d]
      scope: variable.parameter.package-name.requirements-txt
    # ...

Treesitter

是目前唯一支持增量式语法高亮的引擎:用户修改文本后只需要发送改动的部分将可以计算出如何从修改前的语法高亮得到修改后的语法高亮,不需要将完整的文本重新计算语法高亮。卖点就是性能。不过实现难度很大,像之前的语法高亮引擎要支持某种新语言只会正则表达式的开发者需要编写一个 vim 脚本或 XML/YAML 即可,而 treesitter 需要创建 1 个二进制程序来解析文本生成一个抽象语法树——这也带来了分发上的难题。而且实现难度很大也导致来很多语言的 treesittier 实现有很多 bug 。

查找列表

leaderf

尽管 Coc.nvim 和 VSCode 都有内置的查找列表功能,但它确实不属于 LSP:

其他

$ tree an_example_vim_plugin
 .
├──  addon-info.json
├──  autoload
│  └──  requirements
│     ├──  utils.vim
│     └──  ...
├──  compiler
│  └──  pip_compile.vim
├──  doc
│  └──  requirements-vim.txt
├──  ftdetect
│  └──  requirements.vim
├──  ftplugin
│  └──  requirements.vim
├──  LICENSE
├──  README.md
└──  syntax
   └──  requirements.vim

indent/*.vim 的缩进可以看成代码格式化的特例。 compiler/*.vim 可以看成代码诊断的特例。除去之后剩下:

https://user-images.githubusercontent.com/32936898/199681341-1c5cfa61-4411-4b67-b268-7cd87c5867bb.png https://user-images.githubusercontent.com/32936898/199681363-1094a0be-85ca-49cf-a410-19b3d7965120.png https://user-images.githubusercontent.com/32936898/199681368-c34c2be7-e0d8-43ea-8c2c-d3e865da6aeb.png