VS Code插件开发教程(11)语言服务器入门 Language Server Extension Guide

Php (75) 2023-03-24 21:04

大家好,我是编程小6,很高兴遇见你,有问题可以及时留言哦。

导言

如上一篇文章 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插件开发教程(11)语言服务器入门 Language Server Extension Guide_https://bianchenghao6.com/blog_Php_第1张

实现一个语言服务器

概览

VS Code中一个语言服务器有两部分组成:

  • 语言客户端(Language Client,下文简称LC):一个用JavaScriptTypeScript编写的VS Code插件,可以访问所有的 VS Code API,负责启动语言服务器
  • 语言服务器(Language Server,下文简称LS):一个语言分析程序,负责提供支持语言特性所需信息,运行在单独的进程中,可以用任何的编程语言开发。

HTML语言服务和PHP语言服务为例,HTMLLCPHPLC分别实例化了各自的LSLSLC之间通过LSP通信,HTMLLSTypeScript语言编写,PHPLSPHP语言编写。

VS Code插件开发教程(11)语言服务器入门 Language Server Extension Guide_https://bianchenghao6.com/blog_Php_第2张

一个处理纯文本文件的语言服务器插件示例

我们希望这个处理纯文本的语言服务器有代码自动补全和错误诊断的功能,我们讲项目名称命名为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时效果如下:

VS Code插件开发教程(11)语言服务器入门 Language Server Extension Guide_https://bianchenghao6.com/blog_Php_第3张

LS与LC的调试

对于LC来说,调试比较简单,和普通的插件一样。LS由于是LC启动的,所以我们需要给它绑定一个调试器。我们在run view种选择绑定给LSlaunch configuration,这样就完成了调试器的绑定。

LS的日志服务

如果LC用的是vscode-languageclient实现,则可以通过配置[langId].trace.server来让LCLS之间通过LC的名称通道来通信,对于上述示例而言,则是配置"languageServerExample.trace.server": "verbose"实现

VS Code插件开发教程(11)语言服务器入门 Language Server Extension Guide_https://bianchenghao6.com/blog_Php_第4张

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插件开发教程(11)语言服务器入门 Language Server Extension Guide_https://bianchenghao6.com/blog_Php_第5张

其它语言功能

VS Code中检测工具常以LS的形式实现,如ESLintjshint,不过除此之外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
            }
        }
    };
});

VS Code插件开发教程(11)语言服务器入门 Language Server Extension Guide_https://bianchenghao6.com/blog_Php_第6张

进阶

增量文档同步

本文中的示例采用的是vscode-languageserver提供的简单的文档管理器来做VS CodeLS之间的同步,这样做存在两个缺点:

  • 大量的数据被传输,因为文本文档的全部内容被重复发送到服务器
  • 不支持增量文档更新,导致多余的解析和语法树创建

对此,我们实现的时候应该解决文档增量更新的同步问题。对此需要用到三个钩子函数:

  • 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的人来说很有帮助。

相关文章

  • VS Code插件开发教程(1) Overview

  • VS Code插件开发教程(2) 起步 Get Started

  • VS Code插件开发教程(3) 插件能力一览 Capabilities

  • VS Code插件开发教程(4) 插件指南 Extension Guidesd

  • VS Code插件开发教程(5) 命令的使用 Command

  • VS Code插件开发教程(6) 颜色主题一览 Color Theme

  • VS Code插件开发教程(7) 树视图 Tree View

  • VS Code插件开发教程(8) Webview

  • VS Code插件开发教程(9)构建自定义编辑器 Custom Editor

  • VS Code插件开发教程(10)编程式语言特性 Programmatic Language Features

  • VS Code插件开发教程(11)语言服务器入门 Language Server Extension Guide

发表回复