Language Server Protocol

Language Server Protocol(LSP)的概念

Language Server Protocol (语言服务器协议,简称 LSP)是微软于 2016 年提出的一套统一的通讯协议方案。该方案定义了一套编辑器或 IDE 与语言服务器之间使用的协议,该语言服务器提供自动完成、转到定义、查找所有引用等语言功能。

使用LSP的意义

每个开发IDE,都要为语言实现类如自动补全,转动定义,悬停在单词上提供文档的功能,传统上,需要个IDE根据自己的API实现上述工作,即使是相同的功能,也要根据不同IDE实现一遍重复的功能,代码却不同。

一个语言服务器旨在提供某个语言的智能化,并通过支持进程间通信的协议与开发工具进行通信。

LSP设计的目标是使该语言服务器和开发工具进行标准化的通信,这个语言服务可以在多个开发工具中重复使用,从而以最小的改动支持多种语言。语言服务器后端可以用PHP,Python或Java编写,LSP可以轻松地将其集成到各种工具中,该协议提供通用抽象级别的协议,以便工具可以提供丰富的语言服务,从而无需完全理解特定于底层域模型的细微差别。

提出LSP之前,各个编辑器(VSCode, Vim, Atom, Sublime…)各自为战,编辑器内部实现的特性和协议都不同。每换一个编辑器,就有可能要给该编辑器中支持的每门语言写一个对应的 Language Server,也就是说假设有 n 门语言,m 个编辑器,那全部编辑器适配所有语言的开发成本和复杂度为 n * m。

但有了LSP,就让语言的「静态分析服务」和「编辑器 / IDE」分离开来,这样上述情景下开发成本和复杂度就可以降低为线性的 n + m。

LSP对于语言提供商和工具的提供商都是双赢。

LSP如何工作

语言服务器会作为单独的进程运行,同时开发工具使用基于JSON-RPC的语言协议与服务器进行通信。

为了规范,Language Server Protocol 中的交互一般需要遵循如下生命周期:

语言服务器协议

1.初始化(Initialize)

由于 Language Server 启动后,并不知道当前编辑器的状态。因此,所有符合 LSP 规范的开发者工具在和符合 LSP 规范的 Language Server 建立连接后,第一个 RPC 请求永远是 initialize指令。initialize 指令的结构体比较复杂,主要是告知 Language Server 当前的工作区在哪里、客户端提供的能力(capacities)有哪些等等。

image-20220913111124189

Server 根据编辑器工具请求体内的配置信息初始化完成后,会响应 InitializeResult 结构体作为结果,同时告知客户端当前 Server 具有哪些能力。

由于不同编辑器的功能实现不一,因此 LSP 中大部分的服务端/客户端能力都是可选的:比如有的客户端不提供 codeLens 功能,有的服务端不提供代码补全功能等。双方是否具备这些能力都会在初始化阶段互相告知,以避免后续产生某些无效的功能请求。同时按照LSP 规范,客户端对 textDocument/didOpen、textDocument/didChange 和 textDocument/didClose 通知的支持是强制性的,客户端不能选择不支持它们。

2.打开文件(textDocument/didOpen)

每当开发者工具侧的用户在打开(或者在 Language Server 初始化前已经打开)了某个文件,开发者工具会向 Language Server 发出 textDocument/didOpen 通知,告知 Language Server 某个文件被打开。

「文档打开通知」从客户端发送到服务器,以表示新打开的文本文档。文档的内容现在由客户端管理,语言服务器不得尝试使用文档的 Uri 读取文档的内容。 从这个意义上说,「打开」意味着它由客户端「管理」。 这并不一定表示其内容会显示在编辑器中。在没有相应的「关闭通知」之前发送的情况下,客户端不能多次发送打开通知 —— 也就是说,打开和关闭通知必须一一匹配,并且特定 textDocument 的最大打开计数为 1。服务器满足请求的能力,与文本文档是打开还是关闭无关。

例如,通过VScode打开main.go文件:

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello World go!")
}

会发送的 textDocument/didOpen 通知结构体为:

{
    "jsonrpc": "2.0",
    "method": "textDocument/didOpen",
    "params": {
        "textDocument": {
            "uri": "file:///workspace/main.go",
            "languageId": "go",
            "version": 2,
            // 这里的文件内容为 Language Server 中虚拟文件的内容初始状态
            "text": "package main\n\nimport (\n\t"fmt"\n)\n\nfunc main() {\n    fmt.Println("Hello World go!")\n}"
        }
    }
}

Language Server 在得知文件被打开后,会试图维护一个“虚拟”的文件结构体,而不会去读取文件系统中对应文件的实际内容。后续的保存文件等操作是交由开发者工具直接写入文件系统完成的,Language Server 不负责同步文件内容。之后用户的编辑行为,都会通过事件通知的形式告知 Language Server。而 Language Server 则是根据编辑行为,维护和调整上述虚拟文件对象的数据结构,进而做出响应。

3.编辑文件(textDocument/didChange)

编辑文件总是发生在打开事件之后。

根据 LSP 规范,Language Server 允许的编辑操作的更新方式有三种:不更新、全量更新、增量更新。但大部分 Language Server 一般采用增量更新模式,即发送编辑产生的 “diff” 而非更新后的整体内容。

例如,在代码中新增一行”a”,客户端会产生如下请求:

{
    "jsonrpc":"2.0",
    "method":"textDocument/didChange",
    "params": {
        "textDocument": {
            "uri": "file:///workspace/main.go",
            "version": 37  // 这个版本号用于确认 change 的先后顺序
        },
        "contentChanges": [{ 
            "range": {
                "start": {
                    "line":8,
                    "character":4
                },
                "end": {
                    "line": 8,
                    "character": 4
                }
            },
            "rangeLength": 0,
            "text": "a"
        }]
    }
}

然后,服务端根据当前 change 的内容,更新内部的数据结构,决定是否产生某些 “行为”(比如代码诊断等)。

当用户需要跳转到指定函数或者变量定义时,IDE与语言服务器也通过这样的交互实现。

示例如下:

请求
{
  "jsonrpc": "2.0",
  "id": 24,
  "method": "textDocument/typeDefinition",
  "params": {
    "textDocument": {
      "uri": "file:///User/bytedance/java-hello/src/main/java/Main.java"
    },
    "position": {
      "line": 3,
      "character": 13
    },
    // ...其他参数
  },
}
响应
{
  "jsonrpc": "2.0",
  // Request 中的 id 为 24,因此 Server 端对应的 Response id 也必须为 24
  "id": 24,
  "result": {
    "uri": "file:///User/bytedance/java-hello/src/main/java/Main.java",
    "range": {
      "start": { "line": 7, "character": 25 },
      "end": { "line": 7, "character": 28 }
    }
  },
}

4.关闭文件 (textDocument/didClose)

按照规范内容,关闭的文件一般对应着一个已经由客户端打开的文件对象。

当文档在客户端关闭时,文档关闭通知从客户端发送到服务器。 文档的主文件现在存在于文档的 URI 指向的位置(例如,如果文档的 URI 是文件 URI,则主文件现在存在于磁盘上)。 与打开通知一样,关闭通知是关于管理文档内容的。 关闭通知需要发送先前的打开通知。服务器满足请求的能力与文本文档是打开还是关闭无关。

其它

1.语言服务器会访问文件系统中的文件么?

是的。Language Server 还是有可能读取文件系统中未被编辑器打开的文件

协议中仅仅规定,textDocument/didOpen 仅是不允许 Language Server 去打开“客户端已经打开的” 对应 URI 文件的内容,但允许 Language Server 读取工作区和已打开文件上下文中其他「未打开的文件」。

例如,import 其他库的时候的代码补全功能,Language Server 就需要访问文件系统以获取索引信息。

2.代码诊断如何实现?(需要再深入了解)

通过建立抽象语法树,做语法分析检查语法错误。

一些插件或者代码诊断工具,如 ESLint,可以在语法规范的 AST 中的节点中遍历访问,找出更多的 Lint 警告/错误。

3.代码补全如何实现?(需要再深入了解)

根据 LSP 中的规定,代码补全由客户端根据事件发起请求,遵循如下触发类型:

  1. 用户输入某个标识符(大部分情况下编辑器会自动执行这个事件)或敲击 Ctrl/Cmd + Space
  2. 用户正在输入某个关键字符(比如 “.”)
  3. 补全列表不完整,需要重新触发一次

之后,服务端会根据当前 输入光标的所在位置 以及 文件的上下文信息 来判断如何做代码补全。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇