實例解析:如何開發 VSCode LSP 服務
作者:範文傑
來源:SegmentFault 思否社區
從一張動圖說起:
上圖應該大家經常使用的 錯誤診斷 功能,它能夠在你編寫代碼的過程中提示,那一塊代碼存在什麼類型的問題。
這個看似高大上的功能,從插件開發者的角度看其實特別簡單,基本上就是用上一篇文章《你不知道的 VSCode 代碼高亮原理》中簡單介紹過的 VSCode 開發語言特性的三種方案:
-
基於 Sematic Tokens Provider 協議的詞法高亮
-
基於 Language API 的編程式語法高亮
-
基於 Language Server Protocol 的多進程架構語法高亮
其中, Language Server Protocol 由於性能與開發效率上的優勢已經逐漸成爲主流實現方案,本文接下來會基於 LSP 展開介紹各種語言特性的實現細節,解答 LSP 的通訊模型與開發模式。
示例代碼
本文示例均已同步到 github,建議讀者先拉下代碼實際體驗:
# 1. clone 示例代碼
git clone git@github.com:Tecvan-fe/vscode-lsp-sample.git
# 2. 安裝依賴
npm i # or yarn
# 3. 使用 vscode 打開示例代碼
code ./vscode-lsp-sample
# 4. 在 vscode 中按下 F5 啓動調試
順利執行完畢後,可以看到插件的調試窗口:
核心代碼有:
-
server/src/server.ts
:LSP 服務端代碼,提供代碼補全、錯誤診斷、代碼提示等常見語言功能的示例 -
client/src/extension.ts
:提供一系列 LSP 參數,包括 Server 的調試端口、代碼入口、通訊方式等。 -
packages.json
:主要提供了語法插件所需要的配置信息,包括: -
activationEvents
:聲明插件的激活條件,代碼中的onLanguage:plaintext
意爲打開 txt 文本文件時激活 -
main
:插件的入口文件
其中,
client/src/extension.ts
與 packages.json
都比較簡單,本文過多介紹,重點在於 server/src/server.ts
文件,接下來我們逐步拆解,解析不同語言特性的實現細節。
如何編寫 Language Server
Server 結構解析
示例項目的 server/src/server.ts
實現了一個小型但完整的 Language Server 應用,核心代碼:
// 要素1: 初始化 LSP 連接對象
const connection = createConnection(ProposedFeatures.all);
// 要素2: 創建文檔集合對象,用於映射到實際文檔
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
connection.onInitialize((params: InitializeParams) => {
// 要素3: 顯式聲明插件支持的語言特性
const result: InitializeResult = {
capabilities: {
hoverProvider: true
},
};
return result;
});
// 要素4: 將文檔集合對象關聯到連接對象
documents.listen(connection);
// 要素5: 開始監聽連接對象
connection.listen();
從示例代碼可以總結出 Language Server 的 5 個必要步驟:
-
創建
connection
對象,用於實現客戶端與服務器之間的信息互通 -
創建
documents
文檔集合對象,用於映射客戶端正在編輯的文件 -
在
connection.onInitialize
事件中,顯式聲明插件支持的語法特性,例如上例中返回對象包含hoverProvider: true
聲明,表示該插件能夠提供代碼懸停提示功能 -
將
documents
關聯到connection
對象 -
調用
connection.listen
函數,開始監聽客戶端消息
上述
connection
、documents
等對象定義在 npm 包:
vscode-languageserver/node
vscode-languageserver-textdocument
這是一個基本模板,主要完成了 Language Server 各種初始化操作,後續就可以使用 connection.onXXX
或 documents.onXXX
監聽各類交互事件,並在事件回調中返回符合 LSP 協議的結果,或者顯式調用通訊函數如 connection.sendDiagnostics
發送交互信息。
接下來我們通過幾個簡單實例,分析各項語言特性的實現邏輯。
懸停提示
當鼠標停留在語言元素如函數、變量、符號等 token 時,VSCode 會顯示 token 對應描述與幫助信息:
要實現懸停提示功能,首先需要聲明插件支持 hoverProvider
特性:
connection.onInitialize((params: InitializeParams) => {
return {
capabilities: {
hoverProvider: true
},
};
});
之後,需要監聽 connection.onHover
事件,並在事件回調中返回提示信息:
connection.onHover((params: HoverParams): Promise<Hover> => {
return Promise.resolve({
contents: ["Hover Demo"],
});
});
OK,這就是一個很簡單的語言特性示例了,本質上就是監聽事件 + 返回結果,非常簡單。
代碼格式化
代碼格式化是一個特別有用的功能,能夠幫助用戶快速、自動完成代碼的美化處理,實現效果如:
實現懸停提示功能,首先需要聲明插件支持 documentFormattingProvider
特性:
{
...
capabilities : {
documentFormattingProvider: true
...
}
}
之後,監聽 onDocumentFormatting
事件:
connection.onDocumentFormatting(
(params: DocumentFormattingParams): Promise<TextEdit[]> => {
const { textDocument } = params;
const doc = documents.get(textDocument.uri)!;
const text = doc.getText();
const pattern = /\b[A-Z]{3,}\b/g;
let match;
const res = [];
// 查找連續大寫字符串
while ((match = pattern.exec(text))) {
res.push({
range: {
start: doc.positionAt(match.index),
end: doc.positionAt(match.index + match[0].length),
},
// 將大寫字符串替換爲 駝峯風格
newText: match[0].replace(/(?<=[A-Z])[A-Z]+/, (r) => r.toLowerCase()),
});
}
return Promise.resolve(res);
}
);
示例代碼中,回調函數主要實現將連續大寫字符串格式化爲駝峯字符串,效果如圖:
函數簽名
函數簽名特性在用戶輸入函數調用語法時觸發,此時 VSCode 會根據 Language Server 返回的內容,顯示該函數的幫助信息。
實現函數簽名功能,需要首先聲明插件支持 documentFormattingProvider
特性:
{
...
capabilities : {
signatureHelpProvider: {
triggerCharacters: ["("],
}
...
}
}
之後,監聽 onSignatureHelp
事件:
connection.onSignatureHelp(
(params: SignatureHelpParams): Promise<SignatureHelp> => {
return Promise.resolve({
signatures: [
{
label: "Signature Demo",
documentation: "幫助文檔",
parameters: [
{
label: "@p1 first param",
documentation: "參數說明",
},
],
},
],
activeSignature: 0,
activeParameter: 0,
});
}
);
實現效果:
錯誤提示
注意,錯誤提示的實現邏輯與上述事件 + 響應的模式有一點點不同:
-
首先不需要通過
capabilities
做額外聲明; -
監聽的是
documents.onDidChangeContent
事件,而不是connection
對象上的事件 -
不是在事件回調中用
return
語句返回錯誤信息,而是調用connection.sendDiagnostics
發送錯誤消息
完整示例:
// 增量錯誤診斷
documents.onDidChangeContent((change) => {
const textDocument = change.document;
// The validator creates diagnostics for all uppercase words length 2 and more
const text = textDocument.getText();
const pattern = /\b[A-Z]{2,}\b/g;
let m: RegExpExecArray | null;
let problems = 0;
const diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text))) {
problems++;
const 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: "Diagnostics Demo",
};
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VSCode.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
});
這段邏輯診斷代碼中是否存在連續大寫字符串,通過 sendDiagnostics
發送相應的錯誤信息,實現效果:
如何識別事件與響應體
上述示例,我有意忽略大多數實現細節,更關注實現語言特性的基本框架和輸入輸出。授人以魚不如授人以漁,所以接下來我們花一點點時間瞭解從哪裏獲取這些接口、參數、響應體的信息。有兩個非常重要的鏈接:
-
https://zjsms.com/egWtqPj/ , VSCode 官網關於可編程語言特性的說明文檔
-
https://zjsms.com/egWVTPg/ ,LSP 協議官網
這兩個網頁提供了 VSCode 所支持的所有語言特性的詳細介紹,可以在這裏找到你想要實現的特性的概念性描述,例如對於代碼補齊:
嗯,有點複雜且太過 detail,不過還是很有必要耐心瞭解下,讓你對即將要做的事情有一個高層概念上的理解。
此外,如果你選擇使用 TS 編寫 LSP,事情會變得更簡單。vscode-languageserver
包提供了非常完善的 Typescript 類型定義,我們完全可以藉助 ts + VSCode 的代碼提示找到需要使用的監聽函數:
之後,根據函數簽名找到參數、結果的類型定義
之後,就可以根據類型定義,有針對性地處理參數,返回對應結構的數據。
深入理解 LSP
看完示例後,我們再反過頭來看看 LSP。LSP —— Language Server Protocol 本質上是一種基於 JSON-RPC 的進程間通訊協議,LSP 本身包含兩大塊內容:
-
定義 client 與 server 之間的通訊模型,也就是誰、在什麼時候、以什麼方式向對方發送什麼格式的信息,接收方又以什麼方式返回響應信息
-
定義通訊信息體,也就是以什麼格式、什麼字段、什麼樣的值表達信息狀態
作爲類比,HTTP 協議專門用於描述網絡節點間如何傳輸、理解超媒體文檔的網絡通訊協議;而 LSP 協議則專門用於描述 IDE 中,用戶行爲與響應之間的通訊方式與信息結構。
總結一下,LSP 架構的工作流程如下:
-
編輯器如 VSCode 跟蹤、計算、管理用戶行爲模型,在發生某些特定的行爲序列時,以 LSP 協議規定的通訊方式向 Language Server 發送動作與上下文參數
-
Language Server 根據這些參數異步地返回響應信息
-
編輯器再根據響應信息處理交互反饋
簡單說,編輯器負責與用戶直接交互, Language Server 負責在背後默默計算如何響應用戶的交互動作,兩者以進程粒度分離、解耦,在 LSP 協議框架下各司其職又協作共生。就好像我們通常開發的 Web 應用中,前端負責與用戶交互,服務端負責管理諸如權限、業務數據、業務狀態流轉等不可見的部分。
目前,LSP 協議已經發展到 3.16 版本,覆蓋大多數語言特性,包括:
-
代碼補全
-
代碼高亮
-
定義跳轉
-
類型推斷
-
錯誤檢測
-
等等
得益於 LSP 清晰的設計,這些語言特性的開發套路都很相似,學習曲線很平滑,開發的時候基本上只需要關心監聽那個函數,返回什麼格式的結構,可以說掌握上述幾個示例之後就可以很簡單地上手了。
過去,IDE 對語言特性的支持是集成在 IDE 或者以同構插件形式實現的,在 VSCode 中這種同構擴展能力以 Language API 或 Sematic Tokens Provider 接口方式提供,這兩種方式在上一篇文章《你不知道的 VSCode 代碼高亮原理》都有過介紹了,雖然架構上比較簡單,容易理解,但有一些明顯硬傷:
-
插件開發者必須複用 VSCode 本身的開發語言、環境,例如 Python 語言插件就必須用 JavaScript 寫
-
同一個編程語言需要爲不同 IDE 重複開發相似的擴展插件,重複投入
LSP 最大的優勢就是將 IDE 客戶端與實際計算交互特性的服務端隔離開來,同一個 Language Service 可以重複應用在多個不同 Language Client 中。
此外,LSP 協議下客戶端、服務器分別在各自進程運行,在性能上也會有正向收益:
-
確保 UI 進程不卡頓
-
Node 環境下,充分利用多核 CPU 能力
-
由於不再限定 Language Server 的技術棧,開發者可以選擇更高性能的語言,例如 Go
總的來說,就是很強。
總結
本文介紹了 VSCode 下,開發一款基於 LSP 的語言插件所需要具備的最最基本的技能,實際開發的時候通常還會混合另一種技術:嵌入式語法 —— Embedded Languages Server ,實現複雜的多語言複合支持,如果有人感興趣,我們下週可以聊聊。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DfKKNXyd0cbLrlD_Kcdm5Q