實例解析:如何開發 VSCode LSP 服務

作者:範文傑

來源:SegmentFault 思否社區

從一張動圖說起:

上圖應該大家經常使用的 錯誤診斷 功能,它能夠在你編寫代碼的過程中提示,那一塊代碼存在什麼類型的問題。

這個看似高大上的功能,從插件開發者的角度看其實特別簡單,基本上就是用上一篇文章《你不知道的 VSCode 代碼高亮原理》中簡單介紹過的 VSCode 開發語言特性的三種方案:

其中, 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 啓動調試

順利執行完畢後,可以看到插件的調試窗口:

核心代碼有:

其中,

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 等對象定義在 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,
    });
  }
);

實現效果:

錯誤提示

注意,錯誤提示的實現邏輯與上述事件 + 響應的模式有一點點不同:

完整示例:

// 增量錯誤診斷
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 發送相應的錯誤信息,實現效果:

如何識別事件與響應體

上述示例,我有意忽略大多數實現細節,更關注實現語言特性的基本框架和輸入輸出。授人以魚不如授人以漁,所以接下來我們花一點點時間瞭解從哪裏獲取這些接口、參數、響應體的信息。有兩個非常重要的鏈接:

這兩個網頁提供了 VSCode 所支持的所有語言特性的詳細介紹,可以在這裏找到你想要實現的特性的概念性描述,例如對於代碼補齊:

嗯,有點複雜且太過 detail,不過還是很有必要耐心瞭解下,讓你對即將要做的事情有一個高層概念上的理解。

此外,如果你選擇使用 TS 編寫 LSP,事情會變得更簡單。vscode-languageserver 包提供了非常完善的 Typescript 類型定義,我們完全可以藉助 ts + VSCode 的代碼提示找到需要使用的監聽函數:

之後,根據函數簽名找到參數、結果的類型定義

之後,就可以根據類型定義,有針對性地處理參數,返回對應結構的數據。

深入理解 LSP

看完示例後,我們再反過頭來看看 LSP。LSP —— Language Server Protocol 本質上是一種基於 JSON-RPC 的進程間通訊協議,LSP 本身包含兩大塊內容:

作爲類比,HTTP 協議專門用於描述網絡節點間如何傳輸、理解超媒體文檔的網絡通訊協議;而 LSP 協議則專門用於描述 IDE 中,用戶行爲與響應之間的通訊方式與信息結構。

總結一下,LSP 架構的工作流程如下:

簡單說,編輯器負責與用戶直接交互, Language Server 負責在背後默默計算如何響應用戶的交互動作,兩者以進程粒度分離、解耦,在 LSP 協議框架下各司其職又協作共生。就好像我們通常開發的 Web 應用中,前端負責與用戶交互,服務端負責管理諸如權限、業務數據、業務狀態流轉等不可見的部分。

目前,LSP 協議已經發展到 3.16 版本,覆蓋大多數語言特性,包括:

得益於 LSP 清晰的設計,這些語言特性的開發套路都很相似,學習曲線很平滑,開發的時候基本上只需要關心監聽那個函數,返回什麼格式的結構,可以說掌握上述幾個示例之後就可以很簡單地上手了。

過去,IDE 對語言特性的支持是集成在 IDE 或者以同構插件形式實現的,在 VSCode 中這種同構擴展能力以 Language API 或 Sematic Tokens Provider 接口方式提供,這兩種方式在上一篇文章《你不知道的 VSCode 代碼高亮原理》都有過介紹了,雖然架構上比較簡單,容易理解,但有一些明顯硬傷:

LSP 最大的優勢就是將 IDE 客戶端與實際計算交互特性的服務端隔離開來,同一個 Language Service 可以重複應用在多個不同 Language Client 中。

此外,LSP 協議下客戶端、服務器分別在各自進程運行,在性能上也會有正向收益:

總的來說,就是很強。

總結

本文介紹了 VSCode 下,開發一款基於 LSP 的語言插件所需要具備的最最基本的技能,實際開發的時候通常還會混合另一種技術:嵌入式語法 —— Embedded Languages Server ,實現複雜的多語言複合支持,如果有人感興趣,我們下週可以聊聊。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DfKKNXyd0cbLrlD_Kcdm5Q