2023年,nvim以及其生态已经发展的愈来愈完善了。nvim内置的LSP(以及具体的语言服务)加上众多插件,可以搭建出支持各种类型语法检查、代码补全、代码格式化等功能的IDE。网络上关于如何配置的文章很多,但本人发现绝大多数的文章仅仅停留在配置本身,没有深入的解释这些插件的作用和它们之间的关系,这就导致了很多入门的小伙伴在配置、使用的过程中遇到各种问题也不知如何下手。本文将手把手,一步一步的演进并解释,帮助小伙伴了解这块的内容。
注意1:本文主要探讨nvim关于LSP、null-ls以及代码补全内容,不会详细介绍如何使用插件系统。 注意2:本文阅读前需要读者已经掌握了如何使用插件管理器来安装插件并setup插件配置。
认识LSP
在本文的开始,让我们先介绍一下LSP(Language Server Protocol,语言服务协议)。当然,网络上有很多详细的介绍LSP的内容,本文不会深入介绍它的实现机制,仅作为本文的入门的解释。
简单来讲,该协议定义了两端:Language Client(语言服务客户端)和Language Server(语言服务端),其核心是将代码编辑器文本界面的展示和**代码语言分析(语言支持,自动补全,定义与引用解析等)**解耦。通常,我们的文本编辑器就是一个客户端,而各种语言的解析则会有对应LSP协议实现的服务端。
为了让读者更加清楚的理解LSP的运作,我们编写有如下TypeScript代码:
代码语言:javascript复制// 1. 定义接口
interface User {
name: string;
}
// 2. 实现接口的对象
const user: User = {
name: 'hello'
}
// 3. 打印对象的age属性
console.log(user.age); // error
上述这段代码首先定义了一个名为User
的接口(interface User
),该接口拥有一个字段name
;然后,我们创建了一个基于User
接口的user实例;最后,我们打印了user的age属性。user并不具备age字段,所以按照严格的TypeScript语言规范来讲,代码编译肯定会有错误:
基于LSP的模型,我们可以将这个过程描述出来:
- 在编辑器上写入上述的TS代码;
- 编辑器将上述代码通过某种通讯协议发送给TypeScript语言服务器;
- TS语言服务读取TS代码,进行语法检查,得到了编译错误信息(包含行列数,基本的建议提示信息)返回给编辑器;
- 编辑器接收到错误信息,通过自己的方式展示在编辑器UI上。
现在,我们已经了解了基于LSP的代码分析处理流程,那么这个语言服务器在什么地方呢?首先,不要看到服务器三个字,就认为它一定是一个在远端的Web应用服务,语言服务器一般就是一个软件程序,只不过它能够处理专门解析你编写的程序代码,并做出响应。
使用LSP这套体系,有两个必备步骤:
- 获取并安装语言服务器程序;
- 启动语言服务器,让它处于运行状态。
有些语言服务器基于js编写实现,它一般是一个NPM包,我们以npm -g
全局安装的形式安装它(例如TypeScript的语言服务器的实现typescript-language-server
);有的语言服务器直接就是可执行程序(例如lua语言服务器lua-language-server
),我们从网络上下载它存放到电脑上。通常,我们会把它们的可执行文件路径加入到环境变量中,以便随时在命令行中启动它们。启动以后,它就在一个进程中默默的的等待着客户端(也就是编辑器)链接,并在建立连接以后,进行代码的分析处理工作。
nvim中的LSP
了解了LSP的基本概念以后,接下来我们介绍在nvim中的LSP模块。在nvim 0.5 版本以后,已经内置了语言服务客户端的接口(Lsp - Neovim docs,注意只是语言服务客户端部分),比较常用的API:
- vim.lsp.buf.hover():代码的TIPS悬浮展示。
- vim.lsp.buf.format():代码格式化。
- vim.lsp.buf.references():当前代码符号的引用查询。
- vim.lsp.buf.implementation():当前代码(主要是函数方法)的实现定位。
- vim.lsp.buf.code_action():当前代码的一些优化操作。
但需要注意,上述这些都是接口方法,它只是一个封装后的壳子方法,不具备具体的实现。具体的实现,需要为每一个编程语言单独配置。也就是说,nvim内置的lsp模块的运行架构如下:
面对不同的语言,我们按照对应的语言服务的要求来配置nvim的内置LSP模块。在官方的文档中给了如下的示例来启动一个LSP:
代码语言:javascript复制vim.lsp.start({
name = 'my-server-name',
cmd = {'name-of-language-server-executable'},
root_dir = vim.fs.dirname(vim.fs.find({'setup.py', 'pyproject.toml'}, { upward = true })[1]),
})
这段代码不过多赘述,因为它比起即将介绍的lspconfig插件来说,使用起来更加复杂。
nvim-lspconfig
每当有一个编程语言需要使用LSP的时候,我们都需要形如上述的nvim原生LSP配置来启动对应的语言服务器,同时还需要关注很多细节,譬如,你要手动启动它等等,这一点从用户体验上是比较不友好的。
为了更加方便快速的使用nvim的LSP模块,nvim官方提供了neovim/nvim-lspconfig这个插件。安装了这个插件以后,我们只需要进行少量且易于理解的配置,就能通过这个插件方便快捷的启动并使用语言服务了。
nvim-lspconfig通过插件管理器安装以后,我们就可以通过require的方式获取它,并通过它来配置某个编程语言的语言服务客户端。在lazy.nvim插件管理器下,配置如下:
本人使用lazy.nvim来管理插件。上述第一行的
"neovim/nvim-lspconfig"
代表要安装该插件;紧接着的config需要编写一个函数,代表插件安装后的配置阶段的自定义运行过程(详见lazy.nvim的文档),这个方法在nvim每次启动后,会被lazy.nvim调用,我们一般会在这个config的回调方法中获取插件实例调用其相关API进行配置。
无论使用何种插件管理器,nvim-lspconfig的使用流程都是一样:
- 安装nvim-lspconfig插件(通过lazy.nvim、packer等插件管理器,甚至是纯手工安装);
- 在确保该插件安装完成后的某个时机,获取nvim-lspconfig插件实例(
require('lspconfig')
),这个插件实例可以访问不同编程语言的语言服务客户端对象(例如上面的lspconfig['tsserver']
),每一个语言服务客户端对象都会有setup
方法,我们只需要通过这个方法传入对该语言的语言服务配置。
当然, 如果setup里面什么都不传,它会使用默认配置进行setup。像上面的lspconfig['tsserver']
,它其实就是针对TypeScript代码的语言服务配置,默认配置如下:
cmd
代表了在我们机器上安装的语言服务器的命令行启动方式,比如在我们机器上启动TypeScript的语言服务,则会调用命令:typescript-language-server --stdio
。
filetypes
代表了当遇到哪些文件类型的时候,会让语言服务建立连接。在本例中,只要你打开的文件类型是javascript、typescript等,就会建立编辑器客户端与语言服务的连接,连接完成以后,就能进行查看类型定义、格式化等语言处理操作了。
为了真的能启动语言服务器,我们按照文档提到的方式手动安装TypeScript和lua的语言服务器。在我的机器上,安装好以后,能够通过命令行方式访问得到:
让我们来梳理下上述demo的现状:
- 我们使用了0.5版本以上的nvim,它拥有内建的支持LSP客户端的模块;
- 我们安装了nvim-lspconfig插件,并在通过配置,让它在加载以后,又去setup了TypeScript和lua的语言服务配置;
- 我们在电脑上外部安装了TypeScript和lua的语言服务器,能够通过命令行访问到。
步骤1、2保证了我们的nvim具备了成为语言服务客户端的能力;步骤3保证了我们的电脑环境安装了所需要的语言服务器。
此时,当我们打开一个TS代码的时候,命令模式下键入LspInfo
,就会看到如下的信息:
弹出信息告诉我们,有一个tsserver
关联到了当前buffer(也就是这个demo.ts文件)。另外,在最后一行还能看到nvim-lspconfig显示了当前已经经过配置的语言服务有前面提到的lua_ls和tsserver。
一个buffer会有多个语言服务的客户端关联吗? 当然,比如一个文件里面既有TypeScript代码,又有css module(
import styles from './index.module.css'
),当我们把cssmodules的语言服务器配置进来时候,这份js文件打开的时候,就会同时被两个语言服务客户端关联,由两个语言服务器分析当前的代码内容了。
同时,我们可以测试一下LSP功能。譬如,将光标移动到user: User
的接口User
上时候,在命令模式下输入lua vim.lsp.buf.hover()
,就能出现一个接口描述描述:
亦或是,在错误代码的地方,调用lua vim.lsp.buf.code_action()
,来让语言服务器给出一定的建议操作:
当然,我们不需要每一次想要使用LSP提供的功能的时候都调用命令行方式进行,你可以在setup每一个语言服务之前,添加对事件"LspAttach"
的回调,以便在打开代码文件的时候触发该回调,设置对应buffer的keymap。
上面的例子,我们就配置了CTRL ALT l(L小写)
键来触发代码格式化(format),在我的mac机器上效果如下:
mac机器上CTRL显示为"^“;ALT(meta)键显示为"⌥”。
至于其它的LSP的接口API,例如查看类型定义、查看符号在哪里引用等,我们暂时不进行配置,因为接下来我们将继续介绍一个在基于nvim内置LSP的接口,各种UI、操作更加优雅现代化的插件:nvim-lspsaga。
nvim-lspsaga
使用nvim内置的LSP模块的时候,它的UI展示大家可以看到比较简陋,比如触发code_action的时候,也是在底端普通文本展示,不够沉浸式。而nvim-lspsaga这款插件补齐了nvim原生LSP模块关于用户体验的短板。
安装完成该插件以后,我们就可以通过Lspsaga暴露出的指令来使用经过Lspsaga封装的LSP的接口了。例如,在上面的例子中,我们在一段错误代码上使用命令:lua vim.lsp.buf.code_action()
,调用nvim内置的LSP的原生的API来获取代码建议操作:
但是,如果我们使用Lspsaga的code_action,就会发现一个非常舒服的UI:
除此之外,还有像是查看引用:Lspsaga peek_definition
等指令供我们使用,这里就不再演示了,读者完成配置以后,可以自行测试。
另外需要注意的是Lspsaga官方提到了nvim-treesitter是可选的依赖,但事实体验上强烈要求安装nvim-treesitter插件,因为像是用于像是代码大纲的"Lspsaga outlint"
命令,或是用于查看代码定义的"Lspsaga peek_definition"
命令,都会用到treesitter来进行代码块的解析处理,如果不安装会有报错的情况,影响体验。所以,接下来我们再补充介绍一下nvim-treesitter插件。
nvim-treesitter
在介绍nvim-treesitter之前,我们需要了解tree-sitter这个工具。tree-sitter是一款主要通过Rust编写的跨平台的代码解析器生成工具和增量解析库,它可以为源代码文件构建一颗具体语法树。也就是说,它能从源代码中解析出代码结构,比如哪些是变量,哪些是方法,基本代码结构是怎样的。正式由于该能力,支持代码高亮的编辑器基本上都会直接或间接使用到该工具。而nvim-treesitter则是tree-sitter和nvim之前的封装桥接插件。在nvim中要想体验代码高亮,基本上都离不开该插件。
当然,nvim-treesitter并不严格属于LSP体系。它的主要作用是对代码解析出各种符号、结构,以便呈现nvim中文本的高亮。但是,我们使用nvim想要搭建一套趁手的代码编写环境,基本上是离不开nvim-treesitter的,像上面的nvim-lspsaga在使用的过程中,也是会调用nvim-treesitter相关的API来提升插件体验,所以也一并安装吧。
对于nvim-treesitter,它同样将不同的语言进行了解耦拆分。你可以通过setup配置,来定义哪些文件要高亮。需要注意的是,配置对应语言启用高亮,nvim-treesitter会在第一次加载的时候,在你的机器上通过C/C 编译工具链编译对应语言的parser,并存放到插件所在目录/parser目录下,读者在安装遍以后,可以自行查看。
nvim的LSP、lspconfig与lspsaga之间的关系
看到这里,可能有的小伙伴对目前介绍的nvim内置的LSP模块、nvim-lspconfig与nvim-lspsaga插件的关系还有些疑惑,这里我们用一个关系图做一个简单的总结:
首先,nvim内置的LSP模块提供了诸如vim.lsp.buf.format()
、vim.lsp.buf.code_action()
等API,只要你配置好了对应编程语言的语言服务模块,那么调用这些指令就能看到效果。
但是,配置语言服务如果仅使用nvim原生的方式是比较复杂的,于是nvim官方提供了一个插件nvim-lspconfig,来帮助用户以更加简单快捷的方式来配置语言服务。
最后,由于nvim内置的LSP模块提供的接口在调用后的交互等比较简陋,于是有了nvim-lspsaga这个插件,实际上它的底层也是调用的nvim内置的vim.lsp相关的接口获得数据,只是经过封装以用户体验更好的方式展示了出来,同时,使用nvim-lspsaga的时候,最好也安装好了nvim-treesitter,一方面它可以完成代码的语法高亮,另一方面,lspsaga也会用到该插件的能力提升各种代码解析的体验。
有了上述关系,我们一般都不配置快捷键来映射vim.lsp.buf.code_actions()
等这些原生API调用,而是安装lspsaga插件,然后使用经过Lspsaga封装后的Lspsaga code_action
等指令调用。
PS:目前似乎lspsaga不支持format(也许我没找到),只有格式化代码还需要使用原生的
vim.lsp.buf.format()
调用,在LspAttach里面的回调中绑定keymap。
null-ls.nvim
Github地址:null-ls.nvim
在内建LSP、lspconfig以及lspsaga的加持下,nvim已经具备了支持LSP能力的,且用户体验较好的准IDE了。然而,有这样一个场景还没有涵盖到,那就是在语法已经正确的情况下进行代码的处理,包括prettier格式化、eslint代码处理。具体来讲,比如下面这样一段代码:
代码语言:javascript复制interface User {
name: string;
}
var user: User = {
name: "hello"
}
console.log(user);
上述这段代码,**从TypeScript语法规范的角度来看是没有问题的,完全能够通过TS的类型检查。**然而,上面的代码有两个问题:
- 使用
var
来声明一个变量,这已经是不推荐的变量声明方式了; name
字段的格式化不正确,一般我们使用2个或4个空格来对应一个Tab。
基于上述的问题,不难理解,语言服务通常只专注于代码本身的类型检查、代码编译是否正确,它一般不关注代码是否处于最佳实践,比如代码格式规范、代码使用规范等。为了补齐这块,null-ls
被推出。该插件主页提到了这个插件创造出来的动机:
Neovim doesn’t provide a way for non-LSP sources to hook into its LSP client. null-ls is an attempt to bridge that gap and simplify the process of creating, sharing, and setting up LSP sources using pure Lua. Neovim没有提供一种非LSP源连接到其LSP客户端的方式。null-ls试图弥合这个差距,简化使用纯Lua创建、共享和设置LSP源的过程。
这里面需要解读几点:
- 什么叫“非LSP源”呢?像是prettier、eslint,它们本身需要对程序代码进行结构、类型解析,然而它们又不关注代码的类型检查等,这类就属于“非LSP源”;
- 什么叫“使用纯Lua创建、共享和设置LSP源的过程”呢?还记得前面的TS语言服务、lua语言服务吗,他们都是实现了LSP协议的语言服务,各自分别用js和lua语言编写的,需要外部进程启动。而null-ls希望能够用lua来编写,构造一个类似支持在nvim内部运行语言服务的框架(虽然目前 prettier、eslint还是外部安装启动的