现代主流IDE通常都是支持多种语言的,以VSCode为例,可以通过安装扩展来满足各种语言特有的高亮、自动补全、语法检查等编辑功能。我们有幸身处一个百家争鸣、兴兴向荣的时代之中。即使只是IDE这一类产品,有传统运行在本地的客户端IDE,也有运行在云端的云IDE,还有各种为各类产品/引擎所定制的Web Editor等。常见的IDE产品就更多了,我们耳熟能详的除了一开始提到的VSCode,还有Eclipse、Visual Studio、IntelliJ IDEA、Xcode...... 而且这些产品大多支持多种语言,且各自有着不少的用户群体。
当我们要为某种语言提供一些额外的编辑功能时,通常仅提供一种IDE的扩展是不够的。如果要对多款IDE开发一款相同功能的扩展,因每款IDE的API及实现的语言不一样,有可能需要实现多次,至少也需要通过抽象和转换才能达到目的。
LSP (Language Sever Protocol)定义在工具(客户端)和语言智能提供者(服务端)之间的通信,集成自动完成、跳转到定义、查找所有引用等编辑功能的协议。将语言编辑能力的实现和IDE主体以一种统一的形式组合起来,将原来M(IDE或编辑器)*N(语言或语言扩展)的问题难度转变成M+N级别成为可能。
右图为未使用LSP前,每种语言都需要单独针对不同的IDE进行实现;左图为使用LSP之后,一份实现,同时可以在多款IDE中使用。这大大简化了语言类扩展在IDE上的实现成本,这里不仅仅是开发成本,还有很大一部分是学习IDE的API成本。
因LSP采用的是客户端/服务端(不局限于远程服务器)架构,语言特性相关的内容是独立在服务端运行的,在进程上完全独立,不会因为解析时间较长而对IDE本身产生影响。
LSP最初由微软提出,并在VSCode上实现,现包括Codenvy、Red Hat和Sourcegraph在内的多家公司汇聚在一起支持其发展。目前很多常见的IDE或编辑器已经有相应的扩展(或内置)支持了LSP,下面笔者简单列举一些常见的IDE和编辑器,更多内容请参阅官方文档。
IDE或编辑器 | 代码仓库 |
---|---|
Atom | atom-languageclient |
Eclipse IDE | Eclipse community, Eclipse LSP4E |
IntelliJ IDEA | Proprietary |
Sublime Text | lsp |
Visual Studio Code | vscode |
WebStorm | Proprietary |
vim9 | Vim9 LSP plugin |
JupyterLab | jupyterlab-lsp |
MS Monaco Editor | monaco-languageclient |
CodeMirror | codemirror-languageserver |
工作机制
语言服务在自己独立的进程中运行,使用LSP协议和主进程(客户端)进行通信,通信内容主要分为两类通知和请求。通知和请求的差别是:通知是单项的,一方向另一方发送消息;请求是双向的,有一方发起请求(可带参数),大部分时候能等到另一方的回应(Response)。 图为客户端(Visual Studio Code)和语言服务之间使用LSP进行通信的示例:
图中形如textDocument/didOpen
的内容均为通知或请求的方法名,Params为通知的信息或请求的入参,Response为请求的相应内容。 先对图中内容简单解释如下:
- 用户打开文档,客户端向语言服务发送通知,告知其文档已被打开
- 用户编辑文档,客户端向语法服务发送通知,告知其文档已被编辑;随后出发语言服务的诊断功能
- 诊断结束后,语言服务项客户端发送通知,告知检测到的错误和警告信息
- 用户发起跳转到定义操作,客户端项语言服务发送跳转到定义请求,语言服务经过分析得到具体位置,并回应给客户端
- 用户关闭文档,客户端向语言服务发送通知,告知其文档已被关闭
通常情况下,不同的扩展或语言服务会监听不同语言文件的操作,且语言服务的实现语言不受工具(客户端)语言的影响。如图:
LSP内容
LSP使用JSON-RPC语言协议进行通信,基本协议由头部和内容两部分组成,使用"\r\n"分隔进行分割,整体内容形如:
css
Content-Length: ...\r\n
\r\n
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/completion",
"params": {
...
}
}
目前支持两种头部字段:
字段名 | 值类型 | 描述 |
---|---|---|
Content-Length | number | 必选项,返回内容部分的字节长度 |
Content-Type | string | 内容部分的mime类型,默认值: application/vscode-jsonrpc; charset=utf-8 |
LSP提供了服务端生命周期(Server Lifecycle)、文档同步(Text Document Synchronization)、语言能力(Language Features)、工作区能力(Workspace Features)、窗口能力(Window Features)五大方面的协议内容。笔者简单罗列一些常用的方法(当前为3.17版本),以便读者对LSP有初步认识,更多内容请参阅协议规范的详细内容。
服务端生命周期(Server Lifecycle)
当前的协议规范定义服务端的生命周期是由客户端进行管理的,即由客户端决定何时启动、关闭进程。具体的内容有:
- Initialize Request:初始化请求是从客户端发送到服务器的第一个请求
- Initialized Notification:初始化完成通知,由客户端向服务端发送,此通知只发送一次
- Register Capability:注册能力请求,从服务端发送到客户端,用于在客户端注册新能力
- Shutdown Request:关闭请求是从客户端发送到服务器的,要求服务器关闭,但不退出
- Exit Notification:请求服务器退出其进程通知,客户端应等待收到关闭请求的响应后再发送退出通知
文档同步(Text Document Synchronization)
- DidOpenTextDocument Notification:文档打开通知是从客户端发送到服务端,用于通知服务端新打开了文本文档
- DidChangeTextDocument Notification:文档更改通知是从客户端发送到服务端,用于通知服务端文本文档已被更改
- DidSaveTextDocument Notification:文档保存通知是从客户端发送到服务端,用于通知服务端文档已被保存
- Notebook Document Synchronization:Notebook越来越受欢迎,此组协议内容允许Notebook及其Cell复用服务端的语言能力
语言特性(Language Features)
笔者认为语言特性是LSP中的关键模块,最初就是为了这些特性而生。这些语言特性基于文档的同步状态进行计算。
- Document Highlights Request:文本高亮请求是从客户端发送到服务端,用于解析给定文本文档位置的高亮情况
- Hover Request:浮动请求是从客户端发送到服务端,用于请求给定文本文档位置的悬停信息
- Folding Range Request:折叠范围请求是从客户端发送到服务端,以返回给定文本文档中所有折叠范围
- Selection Range Request:选择范围请求是从客户端发送到服务端,以返回给定位置数组处的建议选择范围
- Completion Request:补全请求是从客户端发送到服务端,以返回光标所在位置的补全建议信息
- Document Formatting Request:文档格式化请求从客户端发送到服务端,用于格式化整个文档
工作区特性(Workspace Features)
- Configuration Request:配置请求从服务端发送到客户端,用于请求获取配置设置内容
- DidCreateFiles Notification:已创建文件通知从客户端发送到服务端,用于告知服务端文件已经创建完成
- DidRenameFiles Notification:文件已重命名通知从客户端发送到服务端,用于告知服务端文件已经被重命名
- Execute a command:执行命令请求从客户端发送到服务端,用于在服务端触发命令执行
窗口特性(Window Features)
- ShowMessage Notification:显示消息通知从服务端发送到客户端,用于要求客户端显示特定消息
- ShowMessage Request:显示消息请求从服务端发送到客户端,用于请求客户端显示特定消息,较消息显示通知允许传递操作并等待来自客户端的回应
- Show Document Request:显示文档请求从服务端发送到客户端,要求客户端显示特定URI指定的资源
- Create Work Done Progress:显示工作进度条通知从服务端发送到客户端,用于请求客户端显示工作进度条
- Cancel a Work Done Progress:取消工作进度条显示通知从服务端发送到客户端,用于请求客户端取消工作进度条的显示
所有的协议内容都仅仅只是定义客户端和服务端的通信格式及相关内容,本身并不实现具体功能,这些功能都需在服务端或客户端实现。
示例
接下来,通过一个极其简单的示例,来感受一下如何实现基于LSP的VSCode语言服务扩展。 实现的扩展效果是,在文本文件中自动补全JavaScript和TypeScript 的两个字符串。
准备环境
VSCode提供了大量扩展示例,这些示例clone下来都是可以直接跑的,比自己重头配置一个环境简单多了。接下来的内容是 lsp-sample
这个示例再简化之后的说明。 目录结构如下:
scss
.
├── client // Language Client
│ ├── src
│ │ ├── test // End to End tests for Language Client / Server
│ │ └── extension.ts // Language Client entry point
├── package.json // The extension manifest
└── server // Language Server
└── src
└── server.ts // Language Server entry point
分别在根目录、client目录、server目录下执行 tnpm install
。 将此项目使用VSCode打开,使用顶部菜单中的「运行 -> 启动调试」或F5
进行调试。进入调试后,会弹出一个新的VSCode客户端来,新开发的能力就可在此进行效果预览。此客户端顶部会出现[扩展开发宿主]字样,如图:
代码解释
package.json
此处主要对根目录下的package.json,其中几个比较关键的配置内容进行解释。
json
{
"engines": {
"vscode": "^1.75.0"
},
}
此处指定vscode作为执行环境。
json
{
"activationEvents": [
"onLanguage:plaintext"
],
}
activationEvents 指定激活扩展的事件,此处表示打开纯文本文件时激活扩展。VSCode定义了12中激活扩展的方式,简单罗列如下,详细内容请查阅官方文档。
- onLanguage:当打开指定语言类型文件时,激活扩展
- onCommand:指定命令被调用时,激活扩展
- onDebug:当调试会话时,激活扩展
- workspaceContains:当打开一个包含匹配指定通配符文件的文件夹时,激活扩展
- onView:在侧边栏中展开指定视图时,激活扩展
- onFileSystem:当打开特定scheme的文件或文件夹时,激活扩展
- onUri:当打开扩展的系统范围Uri时,激活扩展;Uri 固定为
vscode
或vscode-insiders
- onWebviewPanel:当重置指定类型的 webview 时,激活扩展
- onCustomEditor:当创建指定类型的自定义编辑器时,激活扩展
- onAuthenticationRequest:当身份验证(调用
authentication.getSession()
时)匹配指定值时,激活扩展 - onStartupFinished:当VSCode启动完成之后,激活扩展;目前是所有可激活的扩展激活之后触发
- *:当启动VSCode时,激活扩展
json
{
"contributes": {
"configuration": {
"type": "object",
"title": "Example configuration",
"properties": {
"languageServerExample.trace.server": {
"scope": "window",
"type": "string",
"enum": [
"off",
"messages",
"verbose"
],
"default": "verbose",
"description": "Traces the communication between VS Code and the language server."
}
}
}
},
}
contributes
用于配置扩展程序支持的能力,configuration
设置一些配置项内容,更多内容请参与帮助文档。此示例中定义了传输哪些信息从服务端到客户端,将default值定义为verbose
,可以在客户端中显示所有服务端console出来的值,包括console.log
或connection.console.log
。如图:
client/src/extension.ts
typescript
import * as path from 'path';
import { workspace, ExtensionContext } from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: ExtensionContext) {
// 服务端代码所在路径
const serverModule = context.asAbsolutePath(
path.join('server', 'out', 'server.js')
);
// 配置服务端信息,transport 指定传输形式
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
}
};
const clientOptions: LanguageClientOptions = {
// 注册支持纯文本的文件服务
documentSelector: [{ scheme: 'file', language: 'plaintext' }],
synchronize: {
// 当 .clientrc 文件有变化时通知服务端
fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
}
};
// 创建客户端,关联服务端和客户端的相关信息
client = new LanguageClient(
'languageServerExample',
'Language Server Example',
serverOptions,
clientOptions
);
// 启动
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}
server/src/server.ts
typescript
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams,
TextDocumentSyncKind,
InitializeResult
} from 'vscode-languageserver/node';
import {
TextDocument
} from 'vscode-languageserver-textdocument';
const connection = createConnection(ProposedFeatures.all);
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
connection.onInitialize((params: InitializeParams) => {
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
// 告知客户端此服务支持提示补全
completionProvider: {
resolveProvider: true
}
}
};
return result;
});
connection.onCompletion(
(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
// 在此处编写基于入参(文档位置信息)提供相应的提示补全内容
connection.console.log('onCompletion', 'test');
console.log('onCompletion', _textDocumentPosition);
// 示例代码只是简单粗暴的提供了两项补全信息
return [
{
label: 'TypeScript',
kind: CompletionItemKind.Text,
data: 1
},
{
label: 'JavaScript',
kind: CompletionItemKind.Text,
data: 2
}
];
}
);
// 为提示信息补充更详细的内容
connection.onCompletionResolve(
(item: CompletionItem): CompletionItem => {
if (item.data === 1) {
item.detail = 'TypeScript details';
item.documentation = 'TypeScript documentation';
} else if (item.data === 2) {
item.detail = 'JavaScript details';
item.documentation = 'JavaScript documentation';
}
// console.log(item);
connection.console.log('onCompletionResolve');
console.log(item);
return item;
}
);
// 监听
documents.listen(connection);
// 监听
connection.listen();
后记
LSP是Language Server Protocol的缩写,它定义了客户端(IDE或编辑器)到语言服务的通信协议,主要用于针对不同语言编辑能力的提升。希望能够通过此方式达到开发一款语言服务扩展可同时服务于不同的IDE或编辑器上。现实情况可能并没有预期的那么理想,哪怕是支持了LSP的IDE或编辑器也有可能因为支持的版本不同而存在差异。但至少LSP为这一理想提供了一种可能,向这个方向迈进了一大步。 这是笔者的一份学习笔记,本身对LSP理解可能还不够深入,如有表达不当之处欢迎批评指正。
参考资料
VSCode语言服务扩展指南:code.visualstudio.com/api/languag... VSCode扩展示例:github.com/microsoft/v... 语言服务协议规范:microsoft.github.io/language-se...