语言服务器协议教程:从 VSCode 到 Vim

已发表: 2022-03-11

您所有工作的主要工件很可能是纯文本文件。 那么为什么不使用记事本来创建它们呢?

语法高亮和自动格式化只是冰山一角。 linting、代码完成和半自动重构呢? 这些都是使用“真正的”代码编辑器的好理由。 这些对我们的日常生活至关重要,但我们了解它们的工作原理吗?

在本语言服务器协议教程中,我们将稍微探讨一下这些问题,并找出是什么让我们的文本编辑器打勾。 最后,我们将一起实现一个基本的语言服务器以及 VSCode、Sublime Text 3 和 Vim 的示例客户端。

编译器与语言服务

我们现在将跳过语法突出显示和格式化,这是通过静态分析处理的——这本身就是一个有趣的话题——并专注于我们从这些工具中获得的主要反馈。 有两个主要类别:编译器和语言服务。

编译器接收您的源代码并输出不同的形式。 如果代码不遵循语言规则,编译器将返回错误。 这些都很熟悉。 这样做的问题是它通常非常缓慢且范围有限。 在您仍在创建代码时提供帮助怎么样?

这就是语言服务所提供的。 它们可以让您在代码库仍在工作时深入了解它,并且可能比编译整个项目要快得多。

这些服务的范围是多种多样的。 它可以像返回项目中所有符号的列表一样简单,也可以像返回重构代码的步骤那样复杂。 这些服务是我们使用代码编辑器的主要原因。 如果我们只是想编译并查看错误,我们可以通过几次击键来完成。 语言服务为我们提供了更多见解,而且速度非常快。

押注文本编辑器进行编程

请注意,我们尚未调用特定的文本编辑器。 让我们用一个例子来解释为什么。

假设您开发了一种名为 Lapine 的新编程语言。 这是一门漂亮的语言,编译器会给出非常棒的类似 Elm 的错误消息。 此外,您还可以提供代码完成、参考、重构帮助和诊断。

您首先支持哪个代码/文本编辑器? 那之后呢? 为了让人们采用它,你需要进行一场艰苦的战斗,所以你想让它尽可能简单。 您不想选择错误的编辑器并错过用户。 如果您与代码编辑器保持距离并专注于您的专长——语言及其特性会怎样?

语言服务器

输入语言服务器。 这些是与语言客户交谈并提供我们提到的见解的工具。 由于我们刚刚在假设情况中描述的原因,它们独立于文本编辑器。

像往常一样,另一层抽象正是我们所需要的。 这些承诺打破语言工具和代码编辑器的紧密耦合。 语言创建者可以将他们的功能包装在服务器中一次,代码/文本编辑器可以添加小的扩展来将自己变成客户端。 这对每个人来说都是一场胜利。 但是,为了促进这一点,我们需要就这些客户端和服务器的通信方式达成一致。

幸运的是,这不是假设。 Microsoft 已经开始定义语言服务器协议。

与大多数伟大的想法一样,它产生于必要性而非远见。 许多代码编辑器已经开始添加对各种语言特性的支持; 一些功能外包给第三方工具,一些在编辑器内部完成。 可扩展性问题出现了,微软率先进行了拆分。 是的,微软为将这些功能从代码编辑器中移出而不是将它们囤积在 VSCode 中铺平了道路。 他们本可以继续构建他们的编辑器,锁定用户——但他们让他们自由了。

语言服务器协议

语言服务器协议 (LSP) 于 2016 年定义,以帮助分离语言工具和编辑器。 上面还有很多 VSCode 指纹,但这是朝着编辑器不可知论方向迈出的重要一步。 让我们稍微检查一下协议。

客户端和服务器——想想代码编辑器和语言工具——通过简单的文本消息进行通信。 这些消息具有类似 HTTP 的标头、JSON-RPC 内容,并且可能来自客户端或服务器。 JSON-RPC 协议定义了请求、响应和通知以及围绕它们的一些基本规则。 一个关键特性是它被设计为异步工作,因此客户端/服务器可以无序地处理消息并具有一定程度的并行性。

简而言之,JSON-RPC 允许客户端请求另一个程序运行带参数的方法并返回结果或错误。 LSP 以此为基础,定义了可用的方法、预期的数据结构以及围绕事务的更多规则。 例如,客户端启动服务器时有一个握手过程。

服务器是有状态的,并且一次只能处理一个客户端。 但是,对通信没有明确的限制,因此语言服务器可以在与客户端不同的机器上运行。 不过,在实践中,这对于实时反馈来说会很慢。 语言服务器和客户端使用相同的文件并且非常健谈。

一旦您知道要查找什么,LSP 就会有大量的文档。 如前所述,其中大部分是在 VSCode 的上下文中编写的,尽管这些想法有更广泛的应用。 例如,协议规范都是用 TypeScript 编写的。 为了帮助不熟悉 VSCode 和 TypeScript 的探索者,这里有一个入门指南。

LSP 消息类型

语言服务器协议中定义了许多消息组。 它们可以大致分为“管理”和“语言功能”。 管理消息包含客户端/服务器握手、打开/更改文件等中使用的消息。重要的是,这是客户端和服务器共享它们处理的功能的地方。 当然,不同的语言和工具提供不同的功能。 这也允许增量采用。 Langserver.org 列出了客户端和服务器应支持的六项关键特性,其中至少一项是列出该列表所必需的。

语言特性是我们最感兴趣的。其中,有一个需要特别指出:诊断信息。 诊断是关键功能之一。 当您打开一个文件时,通常假定它会运行。 您的编辑应该告诉您文件是否有问题。 LSP 发生这种情况的方式是:

  1. 客户端打开文件并将textDocument/didOpen发送到服务器。
  2. 服务器分析文件并发送textDocument/publishDiagnostics通知。
  3. 客户端解析结果并在编辑器中显示错误指示符。

这是从您的语言服务中获取洞察力的被动方式。 一个更活跃的示例是查找光标下符号的所有引用。 这将是这样的:

  1. 客户端将textDocument/references发送到服务器,指定文件中的位置。
  2. 服务器找出符号,在此文件和其他文件中查找引用,并以列表响应。
  3. 客户端向用户显示引用。

黑名单工具

我们当然可以深入研究语言服务器协议的细节,但让我们把它留给客户端实现者。 为了巩固编辑器和语言工具分离的理念,我们将扮演工具创建者的角色。

我们将保持简单,而不是创建新的语言和功能,我们将坚持诊断。 诊断非常适合:它们只是关于文件内容的警告。 linter 返回诊断信息。 我们会做类似的东西。

我们将制作一个工具来通知我们想要避免的单词。 然后,我们将向几个不同的文本编辑器提供该功能。

语言服务器

首先,工具。 我们将把它直接烘焙到语言服务器中。 为简单起见,这将是一个 Node.js 应用程序,尽管我们可以使用任何能够使用流进行读写的技术来做到这一点。

这是逻辑。 给定一些文本,此方法返回匹配的列入黑名单的单词的数组以及找到它们的索引。

 const getBlacklisted = (text) => { const blacklist = [ 'foo', 'bar', 'baz', ] const regex = new RegExp(`\\b(${blacklist.join('|')})\\b`, 'gi') const results = [] while ((matches = regex.exec(text)) && results.length < 100) { results.push({ value: matches[0], index: matches.index, }) } return results }

现在,让我们让它成为一个服务器。

 const { TextDocuments, createConnection, } = require('vscode-languageserver') const {TextDocument} = require('vscode-languageserver-textdocument') const connection = createConnection() const documents = new TextDocuments(TextDocument) connection.onInitialize(() => ({ capabilities: { textDocumentSync: documents.syncKind, }, })) documents.listen(connection) connection.listen()

在这里,我们使用vscode-languageserver 。 这个名字有误导性,因为它当然可以在 VSCode 之外工作。 这是您看到的关于 LSP 起源的众多“指纹”之一。 vscode-languageserver负责处理较低级别的协议,并允许您专注于用例。 此片段启动连接并将其绑定到文档管理器。 当客户端连接到服务器时,服务器会告诉它它希望收到有关正在打开的文本文档的通知。

我们可以在这里停下来。 这是一个功能齐全但毫无意义的 LSP 服务器。 相反,让我们使用一些诊断信息来响应文档更改。

 documents.onDidChangeContent(change => { connection.sendDiagnostics({ uri: change.document.uri, diagnostics: getDiagnostics(change.document), }) })

最后,我们将更改的文档、我们的逻辑和诊断响应之间的点联系起来。

 const getDiagnostics = (textDocument) => getBlacklisted(textDocument.getText()) .map(blacklistToDiagnostic(textDocument)) const { DiagnosticSeverity, } = require('vscode-languageserver') const blacklistToDiagnostic = (textDocument) => ({ index, value }) => ({ severity: DiagnosticSeverity.Warning, range: { start: textDocument.positionAt(index), end: textDocument.positionAt(index + value.length), }, message: `${value} is blacklisted.`, source: 'Blacklister', })

我们的诊断负载将是通过我们的函数运行文档文本的结果,然后映射到客户端期望的格式。

该脚本将为您创建所有这些。

 curl -o- https://raw.githubusercontent.com/reergymerej/lsp-article-resources/revision-for-6.0.0/blacklist-server-install.sh | bash

注意:如果您对陌生人向您的机器添加可执行文件感到不舒服,请检查源代码。 它会为您创建项目、下载index.jsnpm link

上述 curl 命令的输出,为您安装项目。

完整的服务器源

最终的blacklist-server来源是:

 #!/usr/bin/env node const { DiagnosticSeverity, TextDocuments, createConnection, } = require('vscode-languageserver') const {TextDocument} = require('vscode-languageserver-textdocument') const getBlacklisted = (text) => { const blacklist = [ 'foo', 'bar', 'baz', ] const regex = new RegExp(`\\b(${blacklist.join('|')})\\b`, 'gi') const results = [] while ((matches = regex.exec(text)) && results.length < 100) { results.push({ value: matches[0], index: matches.index, }) } return results } const blacklistToDiagnostic = (textDocument) => ({ index, value }) => ({ severity: DiagnosticSeverity.Warning, range: { start: textDocument.positionAt(index), end: textDocument.positionAt(index + value.length), }, message: `${value} is blacklisted.`, source: 'Blacklister', }) const getDiagnostics = (textDocument) => getBlacklisted(textDocument.getText()) .map(blacklistToDiagnostic(textDocument)) const connection = createConnection() const documents = new TextDocuments(TextDocument) connection.onInitialize(() => ({ capabilities: { textDocumentSync: documents.syncKind, }, })) documents.onDidChangeContent(change => { connection.sendDiagnostics({ uri: change.document.uri, diagnostics: getDiagnostics(change.document), }) }) documents.listen(connection) connection.listen()

语言服务器协议教程:试驾时间

项目被link后,尝试运行服务器,指定stdio作为传输机制:

 blacklist-server --stdio

它现在在stdio上监听我们之前讨论过的 LSP 消息。 我们可以手动提供这些,但让我们创建一个客户端。

语言客户端:VSCode

由于这项技术起源于 VSCode,因此从那里开始似乎很合适。 我们将创建一个扩展,该扩展将创建一个 LSP 客户端并将其连接到我们刚刚创建的服务器。

有多种方法可以创建 VSCode 扩展,包括使用 Yeoman 和适当的生成generator-code 。 不过,为简单起见,让我们做一个准系统示例。

让我们克隆样板并安装它的依赖项:

 git clone [email protected]:reergymerej/standalone-vscode-ext.git blacklist-vscode cd blacklist-vscode npm i # or yarn

在 VSCode 中打开blacklist-vscode目录。

按 F5 启动另一个 VSCode 实例,调试扩展。

在第一个 VSCode 实例的“调试控制台”中,您将看到文本“看,妈妈。 延期!”

两个 VSCode 实例。左侧是运行 blacklist-vscode 扩展并显示其调试控制台输出,右侧是扩展开发主机。

我们现在已经有了一个基本的 VSCode 扩展,它不需要所有的花里胡哨。 让它成为一个 LSP 客户端。 关闭两个 VSCode 实例并从blacklist-vscode目录中运行:

 npm i vscode-languageclient

将 extension.js 替换为:

 const { LanguageClient } = require('vscode-languageclient') module.exports = { activate(context) { const executable = { command: 'blacklist-server', args: ['--stdio'], } const serverOptions = { run: executable, debug: executable, } const clientOptions = { documentSelector: [{ scheme: 'file', language: 'plaintext', }], } const client = new LanguageClient( 'blacklist-extension-id', 'Blacklister', serverOptions, clientOptions ) context.subscriptions.push(client.start()) }, }

这使用vscode-languageclient包在 VSCode 中创建 LSP 客户端。 与vscode-languageserver不同,它与 VSCode 紧密耦合。 简而言之,我们在这个扩展中所做的是创建一个客户端并告诉它使用我们在前面的步骤中创建的服务器。 回顾 VSCode 扩展细节,我们可以看到我们告诉它使用这个 LSP 客户端来处理纯文本文件。

要测试驱动它,请在 VSCode 中打开blacklist-vscode目录。 按 F5 启动另一个实例,调试扩展。

在新的 VSCode 实例中,创建一个纯文本文件并保存。 输入“foo”或“bar”并稍等片刻。 您将看到这些被列入黑名单的警告。

打开 test.txt 的新 VSCode 实例,显示带有错误下划线的“foo”和“bar”,并在问题窗格中显示有关它们的消息,称它们已被列入黑名单。

而已! 我们不必重新创建任何逻辑,只需协调客户端和服务器。

让我们为另一个编辑器再做一次,这次是 Sublime Text 3。这个过程将非常相似并且更容易一些。

语言客户端:Sublime Text 3

首先,打开 ST3 并打开命令面板。 我们需要一个框架来使编辑器成为 LSP 客户端。 输入“包控制:安装包”并按回车键。 找到“LSP”包并安装它。 完成后,我们可以指定 LSP 客户端。 有很多预设,但我们不会使用它们。 我们已经创建了自己的。

再次打开命令面板。 找到“首选项:LSP 设置”并按 Enter。 这将打开 LSP 包的配置文件LSP.sublime-settings 。 要添加自定义客户端,请使用以下配置。

 { "clients": { "blacklister": { "command": [ "blacklist-server", "--stdio" ], "enabled": true, "languages": [ { "syntaxes": [ "Plain text" ] } ] } }, "log_debug": true }

这在 VSCode 扩展中可能看起来很熟悉。 我们定义了一个客户端,告诉它处理纯文本文件,并指定语言服务器。

保存设置,然后创建并保存纯文本文件。 输入“foo”或“bar”并等待。 同样,您会看到这些被列入黑名单的警告。 处理方式——消息在编辑器中的显示方式——是不同的。 但是,我们的功能是相同的。 这次我们几乎没有做任何事情来增加对编辑器的支持。

语言“客户端”:Vim

如果您仍然不相信这种关注点分离可以轻松地在文本编辑器之间共享功能,这里是通过 Coc 将相同功能添加到 Vim 的步骤。

打开 Vim 并输入:CocConfig ,然后添加:

 "languageserver": { "blacklister": { "command": "blacklist-server", "args": ["--stdio"], "filetypes": ["text"] } }

完毕。

客户端-服务器分离让语言和语言服务蓬勃发展

将语言服务的职责与使用它们的文本编辑器分开显然是一种胜利。 它允许语言功能创建者专注于他们的专业,而编辑器创建者也可以这样做。 这是一个相当新的想法,但正在普及。

现在您已经有了工作的基础,也许您可​​以找到一个项目并帮助推动这个想法。 编辑器的火焰战争永远不会结束,但没关系。 只要语言能力可以存在于特定编辑器之外,您就可以自由使用您喜欢的任何编辑器。


Microsoft 金牌合作伙伴徽章。

作为 Microsoft 金牌合作伙伴,Toptal 是您的 Microsoft 专家精英网络。 与您需要的专家一起建立高绩效团队 - 在您需要的任何时间和地点!