Language Server Protocol 簡稱 LSP ,是微軟於 2016 所定的一個協定,專門用來輔助 Visual Studio Code 開發用的,目標是讓 Code-Editor 能便利地支援更多的程式語言。設計理念是把語言撰寫領域模型如:掃描器、剖析器、類型檢查器等這些提供輔助功能的部分,拆出去用公定的介面來做溝通,給各自領域的人開發。

LSP 專門用於描述 Code-Editor 中,用戶行爲與響應之間通訊方式傳輸資料結構,像是 VSCode 的 IntelliSense 提供的 auto-completion 就可以基於這個協定,支援更多不同的 coding language。 現在支援 LSP 的編輯器也不少,不是只有 VSCode ,還有 Eclipse、Vim、NeoVim、都已經支援了,在 langserver.org 可以看到各個 client 的支援狀況。


實現每個 Code-Editor 上的每個程式語言的輔助功能如:auto-completion、跳轉或 hover 出現文檔等等,都是一項花時間心力的工作。傳統上必須對每個開發工具重複這項工作,因為每種程式開發平台都提供不同的 API 來實現相同的功能,而 Language Server Protocol 背後的理念是標準化,把上述這些功能都可以抽象化為一系列的「行為事件」,因此只要遵循 LSP 協定實作某個語言的特性功能後,Code-Editor 只需要呼叫該語言的 Language Server ,即可實現程式碼提示、定義跳轉、程式碼診斷等功能

Language Server Protocol Specification

Language Server Protocol 是使用 JSON-RPC 溝通,故本質上是一種基於進程間通訊的協議,依靠這個協議讓 Extension 和 Language Server 進行溝通。由於 LSP 是一個「雙工協議」,通訊是雙向都可以發起的,故每個 RPC 事件會需要定義 : 發起方向是否需要對方回應。舉例來說,在 Language Server Protocol Specification 定義文件中,找一個 request 來看一下:

在文件中看到 title 最右邊尾部,有個藍色小圖示, 有一個從左到右然後回轉的箭頭,這就表示是 Extension 發起請求,且要求 Language Server 的必須回傳事件

由於 Language Server Protocol 沒有限制一定要所有功能都有支援,所以有些 Language Server 可能沒有支援特定功能,故具備哪些能力都會在初始化階段告知,以避免後續產生某些無效的功能請求。

LSP 通訊流程

Language Server 和 Extension 會持續不斷地進行各式各樣的請求體通訊,其互動一般需要遵循以下生命週期,工作流程如下:

初始化 (Initialize)

由於 Extension 和 Language Server 都作為單獨的 Process 運行,故都不會知道彼此目前的狀態,所以第一個 RPC 請求一定是 Extension 傳送 initialize 請求,包含一些初始化參數 ; Server 收到請求後開始準備啟動 Language Server,之後會回傳 initialized 完成通知給 Extension 表示 Language Server 已經開始運作,同時告知 Extension 目前 Server 具有哪些能力。

雖然不用每個事件都強制要實現,但當然也有些通知事件是一定要實現的,例如 :

  • textDocument/didOpen
  • textDocument/didChange
  • textDocument/didClose

隸屬於 Text Document Synchronization。

啟用完成後,Client 和 Language Server 就可以相互溝通了,Client 和 Server 都可以相互發送請求/通知,發送的各種請求有哪些,也是參考 Language Server Protocol Specification - 3.17,簡單舉例如有 :

  • textDocument/completion(下語法提示快捷鍵,請求取得智慧提示清單)
  • textDocument/hover

事件流程

初始化完成之後,接下來舉個實際例子,下圖解釋我們在開發時經常使用 Go to Definition 的通訊流程:

textDocument/didOpen

打開 Document 時, Extension 會偵測到並發通知 textDocument/didOpen 給 Language Server,然後文檔內容會保存到 Extension 記憶體中,也同步 synchronized 內容給 Language Server。以下是一個範例通知結構,VSCode 打開 main.go 文件 :

package main

import (
    "fmt"
)

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

這時 extension 會發送 jsonrpc 請求:

{
    "jsonrpc": "2.0",
    "method": "textDocument/didOpen",
    "params": {
        "textDocument": {
            "uri": "file:///workspace/main.go",
            "languageId": "go",
            "version": 2,
            "text": "package main\n\nimport (\n\t"fmt"\n)\n\nfunc main() {\n    fmt.Println("Hello World go!")\n}"
        }
    }
}
textDocument/didChange

再來當我們開始編輯 Document 時, Language Server 會發送編輯產生的 diff 部分 而非更新後的整體內容。舉例來說:

  1. 我們在 code 中新增一行 a
package main
import (
    "fmt"
)

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

Extension 會偵測到並更新文檔的內容,然後發通知 textDocument/didChange 給 Language Server:

{
    "jsonrpc":"2.0",
    "method":"textDocument/didChange",
    "params": {
        "textDocument": {
            "uri": "file:///workspace/main.go",
            "version": 37  // 用於確認先後順序
        },
        "contentChanges": [{
            "range": {
                "start": {
                    "line":8,
                    "character":4
                },
                "end": {
                    "line": 8,
                    "character": 4
                }
            },
            "rangeLength": 0,
            "text": "a"
        }]
    }
}

Language Server 會更新的記憶體中的內容,再來是決定是否產生某些行為,如程式碼診斷發送 textDocument/publishDiagnostics 通知回給 Extension。

再舉例來說 :

  1. 假設 user 使用了 Go to Definition,則 Extension 會發送 textDocument/definition,以下範例是一個具體 textDocument/definition request/response json 格式:

    ## request
    {
        "jsonrpc": "2.0",
        "id" : 1,
        "method": "textDocument/definition",  ##  method 表觸發的事件
        "params": {
            "textDocument": {
                "uri": "file:///VSCode/Playgrounds/cpp/use.cpp"
            },
            "position": {
                "line": 3,
                "character": 12
            }
        }
    }
    
    ## response
    {
        "jsonrpc": "2.0",
        "id": 1,
        "result": {
            "uri": "file:///VSCode/Playgrounds/cpp/provide.cpp",
            "range": {
                "start": {
                    "line": 0,
                    "character": 4
                },
                "end": {
                    "line": 0,
                    "character": 11
                }
            }
        }
    }
    

上述通知傳送給 Language Server 後,會回應 responses ,並在 Go to Definition 使> 用位置顯示 Result。

Go to Definition 簡單說法就是所謂引用跳轉功能,我們會點選 class、function、> variable 等等,看看還有哪些地方有出現相同引用 ; 有哪些地方有呼叫等等。

textDocument/didClose

最後當我們關閉 Document 時,Extension 會偵測到並發通知 textDocument/didClose 給 Language Server 表示 Document 已不再在記憶體中。

由於 Code-Editor 和 Language Server 是兩個進程 process,所以如果 Language Server 掛了,編輯器進程本身也會存在,不用擔心還沒修改好的程式碼因此遺失的問題。

結語

上述範例說明 Extension 使用 LSP 和 Language server 的互動流程,不難發現 LSP 的中立性和抽象性,因為不包含任何 program symbol information。


參考資料