导言
如上一篇文章 VS Code插件开发教程(10)编程式语言特性 Programmatic Language Features 所讲到的那样,我们可以直接用languages.*
接口来提供语言特性的支持,而语言服务器拓展则是另外一种实现途径,本文将介绍的主要内容有:
- 语言服务器的优势
- 利用
Microsoft/vscode-languageserver-node
库实现一个简单的语言服务器 - 如何运行、调试、测试语言服务器
- 给出一些关于语言服务器的高级主题
为什么我们需要语言服务器
语言服务器是VS Code
插件体系中比较特殊的一种,解决的事不同编程语言的编辑,使之具备诸如自动补全、错误提示、跳转定义等功能。通常如果我们想要实现上述的语言特性,需要考虑三点问题。
- 语言服务器本身有自己的实现架构和实现方式,如何与
VS Code
相互配合是个问题 - 有些语言特性的支持需要做跨文件分析,会耗费大量的CPU和内存,如何即支持了语言特性同时又不影响
VS Code
的正常使用是个问题 - 我们对语言的支持是建立在编辑器基础上的,当我们实现了对一种语言的支持后,自然而然的希望能够在更多的编辑器里也能使用,如何更好的跨编辑器复用是个问题,否则
m
种语言、n
种编辑器会导致m*n
种结果,这不是我们所希望的
为了解决这个问题,VS Code
给出的解决方案是 Language Server Protocol(下文中简称 LSP
),该协议将语言特性的实现和编辑器之间的通信做了标准化,对语言特性的支持可以用任何语言来实现并运行在独立的进程中,不会影响到VS Code
进程,而且由于和编辑器之间的通信协议是标准化的所以可以轻易的移植到其它编辑器上
实现一个语言服务器
概览
在VS Code
中一个语言服务器有两部分组成:
- 语言客户端(Language Client,下文简称
LC
):一个用JavaScript
或TypeScript
编写的VS Code
插件,可以访问所有的 VS Code API,负责启动语言服务器 - 语言服务器(Language Server,下文简称
LS
):一个语言分析程序,负责提供支持语言特性所需信息,运行在单独的进程中,可以用任何的编程语言开发。
以HTML
语言服务和PHP
语言服务为例,HTML
的LC
和PHP
的LC
分别实例化了各自的LS
,LS
和LC
之间通过LSP
通信,HTML
的LS
用TypeScript
语言编写,PHP
的LS
用PHP
语言编写。
一个处理纯文本文件的语言服务器插件示例
我们希望这个处理纯文本的语言服务器有代码自动补全和错误诊断的功能,我们讲项目名称命名为LSP-Sample
,代码的目录结构如下:
.
├── 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
LC实现
首先看下整个插件的的/package.json
文件:
{
"name": "lsp-sample",
"description": "A language server example",
"author": "Microsoft Corporation",
"license": "MIT",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "https://github.com/Microsoft/vscode-extension-samples"
},
"publisher": "vscode-samples",
"categories": [],
"keywords": [
"multi-root ready"
],
"engines": {
"vscode": "^1.43.0"
},
"activationEvents": [
"onLanguage:plaintext"
],
"main": "./client/out/extension",
"contributes": {
"configuration": {
"type": "object",
"title": "Example configuration",
"properties": {
"languageServerExample.maxNumberOfProblems": {
"scope": "resource",
"type": "number",
"default": 100,
"description": "Controls the maximum number of problems produced by the server."
},
"languageServerExample.trace.server": {
"scope": "window",
"type": "string",
"enum": [
"off",
"messages",
"verbose"
],
"default": "off",
"description": "Traces the communication between VS Code and the language server."
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -b",
"watch": "tsc -b -w",
"postinstall": "cd client && npm install && cd ../server && npm install && cd ..",
"test": "sh ./scripts/e2e.sh"
},
"devDependencies": {
"@types/mocha": "^8.2.2",
"@types/node": "^12.12.0",
"@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0",
"eslint": "^7.26.0",
"mocha": "^8.3.2",
"typescript": "^4.2.3"
}
}
activationEvents-onLanguage:plaintext
,这段代码告知VS Code
当纯文本文件被打开时激活插件onLanguage
事件接受一个语言标记符,在这里语言标记符是plaintext
。每种语言有一个自己的标记符号,该符号大小写敏感,我们可以在 Known language identifiers 找到所有已知的语言标记,如果想创建一个自己的新语言,可以在package.json
中配置:
{
"contributes": {
"languages": [{
"id": "python",
"extensions": [".py"],
"aliases": ["Python", "py"],
"filenames": [],
"firstLine": "^#!/.*\\bpython[0-9.-]*\\b",
"configuration": "./language-configuration.json"
}]
}
}
接着看configuration
部分:
"configuration": {
"type": "object",
"title": "Example configuration",
"properties": {
"languageServerExample.maxNumberOfProblems": {
"scope": "resource",
"type": "number",
"default": 100,
"description": "Controls the maximum number of problems produced by the server."
}
}
}
这部分配置我们会在LS
中用到,主要是配置LS
的参数
LC
的源代码如下:
/* --------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
* ------------------------------------------------------------------------------------------ */
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) {
// The server is implemented in node
let serverModule = context.asAbsolutePath(
path.join('server', 'out', 'server.js')
);
// The debug options for the server
// --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };
// If the extension is launched in debug mode then the debug server options are used
// Otherwise the run options are used
let serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: debugOptions
}
};
// Options to control the language client
let clientOptions: LanguageClientOptions = {
// Register the server for plain text documents
documentSelector: [{ scheme: 'file', language: 'plaintext' }],
synchronize: {
// Notify the server about file changes to '.clientrc files contained in the workspace
fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
}
};
// Create the language client and start the client.
client = new LanguageClient(
'languageServerExample',
'Language Server Example',
serverOptions,
clientOptions
);
// Start the client. This will also launch the server
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}
LS实现
在本示例中,LS
是用typescript
编写的,运行在Node.js
环境中,这样选择的好处是VS Code
为我们提供了一个Node.js
运行环境,不必为LS
能否运行而担心。LS
的源码位于package.json
中,其引用了两个代码库:
"dependencies": {
"vscode-languageserver": "^7.0.0",
"vscode-languageserver-textdocument": "^1.0.1"
}
下面是一个LS
的代码实现,其利用文本文档管理器来负责服务器和VS Code
之间的文件内容同步
import {
createConnection,
TextDocuments,
Diagnostic,
DiagnosticSeverity,
ProposedFeatures,
InitializeParams,
DidChangeConfigurationNotification,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams,
TextDocumentSyncKind,
InitializeResult
} from 'vscode-languageserver/node';
import {
TextDocument
} from 'vscode-languageserver-textdocument';
// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
let connection = createConnection(ProposedFeatures.all);
// Create a simple text document manager.
let documents: TextDocuments < TextDocument > = new TextDocuments(TextDocument);
let hasConfigurationCapability: boolean = false;
let hasWorkspaceFolderCapability: boolean = false;
let hasDiagnosticRelatedInformationCapability: boolean = false;
connection.onInitialize((params: InitializeParams) => {
let capabilities = params.capabilities;
// Does the client support the `workspace/configuration` request?
// If not, we fall back using global settings.
hasConfigurationCapability = !!(
capabilities.workspace && !!capabilities.workspace.configuration
);
hasWorkspaceFolderCapability = !!(
capabilities.workspace && !!capabilities.workspace.workspaceFolders
);
hasDiagnosticRelatedInformationCapability = !!(
capabilities.textDocument &&
capabilities.textDocument.publishDiagnostics &&
capabilities.textDocument.publishDiagnostics.relatedInformation
);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
// Tell the client that this server supports code completion.
completionProvider: {
resolveProvider: true
}
}
};
if (hasWorkspaceFolderCapability) {
result.capabilities.workspace = {
workspaceFolders: {
supported: true
}
};
}
return result;
});
connection.onInitialized(() => {
if (hasConfigurationCapability) {
// Register for all configuration changes.
connection.client.register(DidChangeConfigurationNotification.type, undefined);
}
if (hasWorkspaceFolderCapability) {
connection.workspace.onDidChangeWorkspaceFolders(_event => {
connection.console.log('Workspace folder change event received.');
});
}
});
// The example settings
interface ExampleSettings {
maxNumberOfProblems: number;
}
// The global settings, used when the `workspace/configuration` request is not supported by the client.
// Please note that this is not the case when using this server with the client provided in this example
// but could happen with other clients.
const defaultSettings: ExampleSettings = {
maxNumberOfProblems: 1000
};
let globalSettings: ExampleSettings = defaultSettings;
// Cache the settings of all open documents
let documentSettings: Map < string, Thenable < ExampleSettings >> = new Map();
connection.onDidChangeConfiguration(change => {
if (hasConfigurationCapability) {
// Reset all cached document settings
documentSettings.clear();
} else {
globalSettings = < ExampleSettings > (
(change.settings.languageServerExample || defaultSettings)
);
}
// Revalidate all open text documents
documents.all().forEach(validateTextDocument);
});
function getDocumentSettings(resource: string): Thenable < ExampleSettings > {
if (!hasConfigurationCapability) {
return Promise.resolve(globalSettings);
}
let result = documentSettings.get(resource);
if (!result) {
result = connection.workspace.getConfiguration({
scopeUri: resource,
section: 'languageServerExample'
});
documentSettings.set(resource, result);
}
return result;
}
// Only keep settings for open documents
documents.onDidClose(e => {
documentSettings.delete(e.document.uri);
});
// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent(change => {
validateTextDocument(change.document);
});
async function validateTextDocument(textDocument: TextDocument): Promise < void > {
// In this simple example we get the settings for every validate run.
let settings = await getDocumentSettings(textDocument.uri);
// The validator creates diagnostics for all uppercase words length 2 and more
let text = textDocument.getText();
let pattern = /\b[A-Z]{2,}\b/g;
let m: RegExpExecArray | null;
let problems = 0;
let diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
problems++;
let diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: textDocument.positionAt(m.index),
end: textDocument.positionAt(m.index + m[0].length)
},
message: `${m[0]} is all uppercase.`,
source: 'ex'
};
if (hasDiagnosticRelatedInformationCapability) {
diagnostic.relatedInformation = [{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range)
},
message: 'Spelling matters'
},
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range)
},
message: 'Particularly for names'
}
];
}
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VS Code.
connection.sendDiagnostics({
uri: textDocument.uri,
diagnostics
});
}
connection.onDidChangeWatchedFiles(_change => {
// Monitored files have change in VS Code
connection.console.log('We received a file change event');
});
// This handler provides the initial list of the completion items.
connection.onCompletion(
(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
// The pass parameter contains the position of the text document in
// which code complete got requested. For the example we ignore this
// info and always provide the same completion items.
return [{
label: 'TypeScript',
kind: CompletionItemKind.Text,
data: 1
},
{
label: 'JavaScript',
kind: CompletionItemKind.Text,
data: 2
}
];
}
);
// This handler resolves additional information for the item selected in
// the completion list.
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';
}
return item;
}
);
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
// Listen on the connection
connection.listen();
为了实现文档错误诊断功能,我们通过注册documents.onDidChangeContent
来获知到纯本文文档发生变化并做校验。启动上述的插件后,我们创建一个文件test.txt
:
TypeScript lets you write JavaScript the way you really want to.
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
ANY browser. ANY host. ANY OS. Open Source.
当我们打开test.txt
时效果如下:
LS与LC的调试
对于LC
来说,调试比较简单,和普通的插件一样。LS
由于是LC
启动的,所以我们需要给它绑定一个调试器。我们在run view
种选择绑定给LS
的launch configuration
,这样就完成了调试器的绑定。
LS的日志服务
如果LC
用的是vscode-languageclient
实现,则可以通过配置[langId].trace.server
来让LC
和LS
之间通过LC
的名称通道来通信,对于上述示例而言,则是配置"languageServerExample.trace.server": "verbose"
实现
LS读取配置
写LC
的时候定义了问题最大上报数量,在LS
中是这样读取该配置的:
function getDocumentSettings(resource: string): Thenable < ExampleSettings > {
if (!hasConfigurationCapability) {
return Promise.resolve(globalSettings);
}
let result = documentSettings.get(resource);
if (!result) {
result = connection.workspace.getConfiguration({
scopeUri: resource,
section: 'languageServerExample'
});
documentSettings.set(resource, result);
}
return result;
}
用户配置可能会发生变化,为了在LS
监听这种变化并在发生变化时重新校验,我们需要将校验代码复用,提取出validateTextDocument
函数:
async function validateTextDocument(textDocument: TextDocument): Promise < void > {
// In this simple example we get the settings for every validate run.
let settings = await getDocumentSettings(textDocument.uri);
// The validator creates diagnostics for all uppercase words length 2 and more
let text = textDocument.getText();
let pattern = /\b[A-Z]{2,}\b/g;
let m: RegExpExecArray | null;
let problems = 0;
let diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
problems++;
let diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: textDocument.positionAt(m.index),
end: textDocument.positionAt(m.index + m[0].length)
},
message: `${m[0]} is all uppercase.`,
source: 'ex'
};
if (hasDiagnosticRelatedInformationCapability) {
diagnostic.relatedInformation = [{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range)
},
message: 'Spelling matters'
},
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range)
},
message: 'Particularly for names'
}
];
}
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VS Code.
connection.sendDiagnostics({
uri: textDocument.uri,
diagnostics
});
}
监听代码如下:
connection.onDidChangeConfiguration(change => {
if (hasConfigurationCapability) {
// Reset all cached document settings
documentSettings.clear();
} else {
globalSettings = < ExampleSettings > (
(change.settings.languageServerExample || defaultSettings)
);
}
// Revalidate all open text documents
documents.all().forEach(validateTextDocument);
});
启动插件,然后将最大报错数量改为1,校验结果可以看到变化:
其它语言功能
在VS Code
中检测工具常以LS
的形式实现,如ESLint
、jshint
,不过除此之外LS
还可以实现其它的语言功能,示例中就提供了代码补全:
// This handler provides the initial list of the completion items.
connection.onCompletion(
(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
// The pass parameter contains the position of the text document in
// which code complete got requested. For the example we ignore this
// info and always provide the same completion items.
return [{
label: 'TypeScript',
kind: CompletionItemKind.Text,
data: 1
},
{
label: 'JavaScript',
kind: CompletionItemKind.Text,
data: 2
}
];
}
);
// This handler resolves additional information for the item selected in
// the completion list.
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';
}
return item;
}
);
用data
字段作为补全项的唯一标志,需要能够序列化成JSON
。为了代码补全能够运行,还需要在onInitialize
函数中作相应的配置:
connection.onInitialize((params): InitializeResult => {
...
return {
capabilities: {
...
// Tell the client that the server supports code completion
completionProvider: {
resolveProvider: true
}
}
};
});
进阶
增量文档同步
本文中的示例采用的是vscode-languageserver
提供的简单的文档管理器来做VS Code
和LS
之间的同步,这样做存在两个缺点:
- 大量的数据被传输,因为文本文档的全部内容被重复发送到服务器
- 不支持增量文档更新,导致多余的解析和语法树创建
对此,我们实现的时候应该解决文档增量更新的同步问题。对此需要用到三个钩子函数:
onDidOpenTextDocument
:当文本文档被打开时调用onDidChangeTextDocument
:当文本文档的内容发生变化时调用onDidCloseTextDocument
:当文本文档被关闭时调用
如下是其简单的使用示例:
connection.onInitialize((params): InitializeResult => {
...
return {
capabilities: {
// Enable incremental document sync
textDocumentSync: TextDocumentSyncKind.Incremental,
...
}
};
});
connection.onDidOpenTextDocument((params) => {
// A text document was opened in VS Code.
// params.uri uniquely identifies the document. For documents stored on disk, this is a file URI.
// params.text the initial full content of the document.
});
connection.onDidChangeTextDocument((params) => {
// The content of a text document has change in VS Code.
// params.uri uniquely identifies the document.
// params.contentChanges describe the content changes to the document.
});
connection.onDidCloseTextDocument((params) => {
// A text document was closed in VS Code.
// params.uri uniquely identifies the document.
});
容错处理
绝大多数时间,编辑器里的代码是处于非完全态的、是处于语法错误的状态(如输入中时),但我们希望依然可以实现自动补全等语言功能,因此需要做好错误的兼容处理。VS Code
官方团队在实现对PHP
语言支持的时候,发现官方的PHP
解析器不支持错误兼容,没法直接用在LS
中,因此VS Code
官方团队自己实现了一个支持错误兼容的版本 tolerant-php-parser,并积累了很多关于这方面的细节 HowItWorks,这对想开发LS
的人来说很有帮助。