语言服务器协议

什么是语言服务器协议?

在编辑器或 IDE 中为编程语言支持丰富的编辑功能(如源代码自动完成或转到定义)在传统上非常具有挑战性且耗时。 通常需要采用编辑器或 IDE 的编程语言编写域模型(扫描程序、分析程序、类型检查器、生成器等)。 例如,Eclipse CDT 插件在 Eclipse IDE 中支持 C/C++,是采用 Java 编写的,因为 Eclipse IDE 本身是采用 Java 编写的。 遵循此方法意味着在 TypeScript for Visual Studio Code 中实现 C/C++ 域模型,在 C# for Visual Studio 中实现单独的域模型。

如果开发工具可以重复使用特定于语言的现有库,则创建特定于语言的域模型也会容易得多。 但是,这些库通常采用编程语言本身实现(例如,良好的 C/C++ 域模型采用 C/C++ 来实现)。 在技术上,可以将 C/C++ 库集成到使用 TypeScript 编写的编辑器中,但很难做到。

语言服务器

另一种方法是在其自己的进程中运行库,并使用进程间通信与之通信。 来回发送的消息便形成协议。 语言服务器协议 (LSP) 是对开发工具与语言服务器进程之间交换的消息进行标准化的产物。 使用语言服务器或守护程序不是一个全新或新颖的想法。 Vim 和 Emacs 等编辑器已经这样做了一段时间,来提供语义自动完成支持。 LSP 的目标是简化此类集成,并提供一个有用框架,向各种工具公开语言功能。

借助公共协议可以通过重复使用语言域模型的现有实现,将编程语言功能集成到开发工具中,而不会有没什么麻烦。 语言服务器后端可以采用 PHP、Python 或 Java 编写,LSP 使它可以轻松集成到各种工具中。 该协议在通用抽象级别上工作,以便工具可以提供丰富的语言服务,而无需完全了解特定于基础域模型的细微差别。

有关 LSP 的工作是如何开始的

LSP 随时间推移而演变,目前版本为 3.0。 它始于 OmniSharp 提出语言服务器的概念,为 C# 提供丰富的编辑功能。 最初,OmniSharp 使用具有 JSON 有效负载的 HTTP 协议,已集成到多个编辑器中,包括 Visual Studio Code

大约在同一时间,Microsoft 开始从事 TypeScript 语言服务器,其想法是在 Emacs 和 Sublime Text 等编辑器中支持 TypeScript。 在此实现中,编辑器通过 stdin/stdout 与 TypeScript 服务器进程进行通信,并使用受 V8 调试器协议启发的 JSON 有效负载进行请求和响应。 TypeScript 服务器已集成到 TypeScript Sublime 插件和 VS Code 中,用于实现丰富的 TypeScript 编辑。

集成两种不同的语言服务器后,VS Code 团队开始探索适用于编辑器和 IDE 的公共语言服务器协议。 公共协议使语言提供者可以创建可由不同 IDE 使用的单个语言服务器。 语言服务器使用者只需实现协议的客户端一次。 这会使语言提供者和语言使用者都处于双赢状态。

语言服务器协议从 TypeScript 服务器使用的协议开始,使用受 VS Code 语言 API 启发的更多语言功能进行扩展。 由于其简单性和现有库,因此该协议受 JSON-RPC 支持以进行远程调用。

VS Code 团队实现多个 Linter 语言服务器来响应对文件执行 Lint 分析操作(扫描)的请求并返回一组检测到的警告和错误,从而创建协议原型。 目标是在用户对文档进行编辑时对文件执行 Lint 分析操作,这意味着在编辑器会话期间会有许多 Lint 分析请求。 使服务器保持启动并运行十分有意义,这样便无需为每个用户编辑启动新的 Lint 分析进程。 实现了多个 Linter 服务器,包括 VS Code 的 ESLint 和 TSLint 扩展。 这两个 Linter 服务器在 TypeScript/JavaScript 中实现,可在 Node.js 上运行。 它们共享一个实现协议的客户端和服务器部件的库。

LSP 的工作原理

语言服务器在其自己的进程中运行,Visual Studio 或 VS Code 等工具使用 JSON-RPC 上的语言协议与服务器通信。 语言服务器在专用进程中运行的另一个优点是可避免与单个进程模型相关的性能问题。 如果客户端和服务器都采用 Node.js 编写,则实际传输通道可以是 stdio、套接字、命名管道或节点 ipc。

下面是工具与语言服务器在例程编辑会话期间如何通信的示例:

lsp flow diagram

  • 用户在工具中打开一个文件(称为文档):工具会向语言服务器通知打开了文档(“textDocument/didOpen”)。 从现在起,有关文档内容的信息不再处于文件系统上,而是由工具保存在内存中。

  • 用户进行编辑:工具向服务器通知文档更改(“textDocument/didChange”),程序的语义信息会由语言服务器更新。 发生这种情况时,语言服务器会分析此信息,并向工具通知检测到的错误和警告(“textDocument/publishDiagnostics”)。

  • 用户在编辑器中对符号执行“转到定义”:工具发送具有两个参数的“textDocument/definition”请求:(1) 文档 URI,(2) 从中发起到服务器的“转到定义”请求的文本位置。 服务器使用文档 URI 和符号定义在文档中的位置进行响应。

  • 用户关闭文档(文件):工具发送“textDocument/didClose”通知,向语言服务器告知文档现在不再处于内存中,并且当前内容在文件系统上现在是最新状态。

此示例演示协议如何在编辑器功能级别(如“转到定义”、“查找所有引用”)与语言服务器通信。 协议使用的数据类型是编辑器或 IDE“数据类型”,如当前打开的文本文档和游标的位置。 数据类型不在编程语言域模型级别(该级别通常提供抽象语法树和编译器符号(例如解析的类型、命名空间、...))。这大大简化了协议。

现在,让我们更详细地了解“textDocument/definition”请求。 下面是在客户端工具与语言服务器之间传递的用于 C++ 文档中的“转到定义”请求的有效负载。

这是请求:

{
    "jsonrpc": "2.0",
    "id" : 1,
    "method": "textDocument/definition",
    "params": {
        "textDocument": {
            "uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/use.cpp"
        },
        "position": {
            "line": 3,
            "character": 12
        }
    }
}

这是响应:

{
    "jsonrpc": "2.0",
    "id": "1",
    "result": {
        "uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/provide.cpp",
        "range": {
            "start": {
                "line": 0,
                "character": 4
            },
            "end": {
                "line": 0,
                "character": 11
            }
        }
    }
}

回顾一下,在编辑器级别而不是编程语言模型级别描述数据类型是语言服务器协议成功的原因之一。 与跨不同编程语言标准化抽象语法树和编译器符号相比,标准化文本文档 URI 或游标位置要简单得多。

当用户使用不同的语言时,VS Code 通常会为每个编程语言启动一个语言服务器。 以下示例显示了用户在其中处理 Java 和 SASS 文件的会话。

java and sass

功能

并非每个语言服务器都支持协议定义的所有功能。 因此,客户端和服务器通过“功能”宣布其支持的功能集。 例如,服务器宣布它可以处理“textDocument/definition”请求,但它可能无法处理“workspace/symbol”请求。 同样,客户端可以宣布,它们能够在保存文档之前提供“即将保存”通知,以便服务器可以计算文本编辑来自动设置编辑文档格式。

集成语言服务器

语言服务器与特定工具的实际集成不由语言服务器协议定义的,而是留给工具实现者。 某些工具集成语言服务器的方式通常是具有可以启动任何类型的语言服务器并与之通信的扩展。 其他工具(如 VS Code)对每个语言服务器创建自定义扩展,以便扩展仍能够提供一些自定义语言功能。

为了简化语言服务器和客户端的实现,有适用于客户端和服务器部件的库或 SDK。 这些库是针对不同语言提供的。 例如,有一个语言客户端 npm 模块用于简化语言服务器与 VS Code 扩展的集成,而另一个语言服务器 npm 模块使用 Node.js 编写语言服务器。 这是支持库的当前列表

在 Visual Studio 中使用语言服务器协议