語言伺服器延伸模組指南

正如您在程式語言功能 (Programmatic Language Features) 主題中所見,您可以透過直接使用 languages.* API 來實作語言功能。然而,語言伺服器擴充功能 (Language Server Extension) 提供了一種實作此類語言支援的替代方案。

本主題

為什麼需要語言伺服器?

語言伺服器是一種特殊的 Visual Studio Code 擴充功能,為許多程式語言提供編輯體驗。透過語言伺服器,您可以實作 VS Code 支援的自動完成、錯誤檢查(診斷)、跳轉至定義以及許多其他語言功能

然而,在為 VS Code 實作語言功能支援時,我們發現了三個常見問題:

首先,語言伺服器通常使用其原生程式語言實作,這在將其與具備 Node.js 執行環境的 VS Code 整合時會帶來挑戰。

此外,語言功能可能非常耗用資源。例如,若要正確驗證檔案,語言伺服器需要解析大量檔案、為其建立抽象語法樹 (AST) 並執行靜態程式分析。這些作業可能會佔用大量 CPU 和記憶體,我們必須確保 VS Code 的效能不受影響。

最後,將多種語言工具與多種程式碼編輯器整合可能需要耗費大量心力。從語言工具的角度來看,它們需要適應具有不同 API 的程式碼編輯器。從程式碼編輯器的角度來看,它們無法預期語言工具提供統一的 API。這使得在 N 個程式碼編輯器中實作 M 種語言的支援,變成了 M * N 的工作量。

為了克服這些問題,微軟制定了 語言伺服器協定 (Language Server Protocol, LSP),將語言工具與程式碼編輯器之間的通訊標準化。透過這種方式,語言伺服器可以用任何語言實作,並在各自的處理程序 (process) 中執行以避免效能損耗,因為它們是透過語言伺服器協定與程式碼編輯器進行通訊。此外,任何符合 LSP 的語言工具都可以與多個符合 LSP 的程式碼編輯器整合,而任何符合 LSP 的程式碼編輯器也可以輕鬆掛載多個符合 LSP 的語言工具。LSP 對語言工具供應商和程式碼編輯器廠商而言是雙贏的!

LSP Languages and Editors

在本指南中,我們將

  • 說明如何使用提供的 Node SDK 在 VS Code 中建置語言伺服器擴充功能。
  • 說明如何執行、偵錯、記錄並測試語言伺服器擴充功能。
  • 為您指出有關語言伺服器的一些進階主題。

實作語言伺服器

總覽

在 VS Code 中,語言伺服器包含兩個部分:

  • 語言用戶端 (Language Client):一個用 JavaScript / TypeScript 編寫的標準 VS Code 擴充功能。此擴充功能可存取所有 VS Code 命名空間 API
  • 語言伺服器 (Language Server):在獨立處理程序中執行的語言分析工具。

如上所述,在獨立處理程序中執行語言伺服器有兩個好處:

  • 只要能遵循語言伺服器協定與語言用戶端通訊,分析工具可以使用任何語言實作。
  • 由於語言分析工具通常會大量耗用 CPU 和記憶體,在獨立處理程序中執行可避免效能損耗。

以下是 VS Code 執行兩個語言伺服器擴充功能的示意圖。HTML 語言用戶端和 PHP 語言用戶端是使用 TypeScript 編寫的標準 VS Code 擴充功能。它們分別實例化各自的語言伺服器,並透過 LSP 與之通訊。儘管 PHP 語言伺服器是用 PHP 編寫的,但它仍然可以透過 LSP 與 PHP 語言用戶端進行通訊。

LSP Illustration

本指南將教您如何使用我們的 Node SDK 建置語言用戶端/伺服器。本文檔其餘部分假設您已熟悉 VS Code 擴充功能 API

LSP 範例 - 用於純文字檔案的簡單語言伺服器

讓我們建置一個簡單的語言伺服器擴充功能,為純文字檔案實作自動完成和診斷功能。我們也將涵蓋用戶端與伺服器之間的設定同步。

如果您希望直接跳到程式碼:

複製 Microsoft/vscode-extension-samples 儲存庫並開啟該範例。

> git clone https://github.com/microsoft/vscode-extension-samples.git
> cd vscode-extension-samples/lsp-sample
> npm install
> npm run compile
> code .

上述指令會安裝所有相依性並開啟包含用戶端與伺服器程式碼的 lsp-sample 工作區。以下是 lsp-sample 結構的大致概述:

.
├── client // Language Client
│   ├── src
│   │   ├── test // End to End tests for Language Client / Server
│   │   └── extension.ts // Language Client entry point
├── package.json // The extension manifest
└── server // Language Server
    └── src
        └── server.ts // Language Server entry point

解釋「語言用戶端」

首先讓我們看看 /package.json,它描述了語言用戶端的功能。當中有兩個有趣的區塊:

首先,請查看 configuration 區塊:

"configuration": {
    "type": "object",
    "title": "Example configuration",
    "properties": {
        "languageServerExample.maxNumberOfProblems": {
            "scope": "resource",
            "type": "number",
            "default": 100,
            "description": "Controls the maximum number of problems produced by the server."
        }
    }
}

此區塊向 VS Code 貢獻了 configuration 設定。範例將解釋這些設定如何在啟動時以及每次設定變更時傳送至語言伺服器。

注意:如果您的擴充功能需相容於 1.74.0 之前的 VS Code 版本,您必須在 /package.jsonactivationEvents 欄位中宣告 onLanguage:plaintext,以告知 VS Code 在開啟純文字檔案(例如副檔名為 .txt 的檔案)時立即啟用該擴充功能。

"activationEvents": []

實際的語言用戶端原始程式碼與對應的 package.json 位於 /client 資料夾中。/client/package.json 檔案中的有趣之處在於它透過 engines 欄位參照了 vscode 擴充功能主機 API,並增加了對 vscode-languageclient 函式庫的相依性。

"engines": {
    "vscode": "^1.52.0"
},
"dependencies": {
    "vscode-languageclient": "^7.0.0"
}

如前所述,用戶端是作為標準的 VS Code 擴充功能實作的,因此它可以存取所有 VS Code 命名空間 API。

以下是對應的 extension.ts 檔案內容,它是 lsp-sample 擴充功能的進入點:

import * as path from 'path';
import { workspace, ExtensionContext } from 'vscode';

import {
  LanguageClient,
  LanguageClientOptions,
  ServerOptions,
  TransportKind
} from 'vscode-languageclient/node';

let client: LanguageClient | undefined;

export async function activate(context: ExtensionContext) {
  // The server is implemented in node
  let serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));
  // The debug options for the server
  // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
  let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };

  // If the extension is launched in debug mode then the debug server options are used
  // Otherwise the run options are used
  let serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    debug: {
      module: serverModule,
      transport: TransportKind.ipc,
      options: debugOptions
    }
  };

  // Options to control the language client
  let clientOptions: LanguageClientOptions = {
    // Register the server for plain text documents
    documentSelector: [{ scheme: 'file', language: 'plaintext' }],
    synchronize: {
      // Notify the server about file changes to '.clientrc files contained in the workspace
      fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
    }
  };

  // Create the language client and start the client.
  client = new LanguageClient(
    'languageServerExample',
    'Language Server Example',
    serverOptions,
    clientOptions
  );

  // Start the client. This will also launch the server
  await client.start();
}

export async function deactivate() {
  await client?.dispose();
  client = undefined;
}

解釋「語言伺服器」

注意:從 GitHub 儲存庫複製的「伺服器」實作已包含最終的教學實作。若要跟隨本教學,您可以建立一個新的 server.ts 或修改已複製版本的內容。

在此範例中,伺服器也是使用 TypeScript 實作,並使用 Node.js 執行。由於 VS Code 已內建 Node.js 執行環境,除非您對執行環境有特殊需求,否則無需自行提供。

語言伺服器的原始程式碼位於 /server。伺服器 package.json 檔案中的有趣區塊為:

"dependencies": {
    "vscode-languageserver": "^7.0.0",
    "vscode-languageserver-textdocument": "^1.0.1"
}

這會載入 vscode-languageserver 函式庫。

以下是一個使用所提供的文字文件管理器的伺服器實作,它透過將增量差異 (incremental deltas) 從 VS Code 傳送到伺服器來同步文字文件。

import {
  createConnection,
  TextDocuments,
  Diagnostic,
  DiagnosticSeverity,
  ProposedFeatures,
  InitializeParams,
  DidChangeConfigurationNotification,
  CompletionItem,
  CompletionItemKind,
  TextDocumentPositionParams,
  TextDocumentSyncKind,
  InitializeResult
} from 'vscode-languageserver/node';

import { TextDocument } from 'vscode-languageserver-textdocument';

// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
let connection = createConnection(ProposedFeatures.all);

// Create a simple text document manager.
let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);

let hasConfigurationCapability: boolean = false;
let hasWorkspaceFolderCapability: boolean = false;
let hasDiagnosticRelatedInformationCapability: boolean = false;

connection.onInitialize((params: InitializeParams) => {
  let capabilities = params.capabilities;

  // Does the client support the `workspace/configuration` request?
  // If not, we fall back using global settings.
  hasConfigurationCapability = !!(
    capabilities.workspace && !!capabilities.workspace.configuration
  );
  hasWorkspaceFolderCapability = !!(
    capabilities.workspace && !!capabilities.workspace.workspaceFolders
  );
  hasDiagnosticRelatedInformationCapability = !!(
    capabilities.textDocument &&
    capabilities.textDocument.publishDiagnostics &&
    capabilities.textDocument.publishDiagnostics.relatedInformation
  );

  const result: InitializeResult = {
    capabilities: {
      textDocumentSync: TextDocumentSyncKind.Incremental,
      // Tell the client that this server supports code completion.
      completionProvider: {
        resolveProvider: true
      }
    }
  };
  if (hasWorkspaceFolderCapability) {
    result.capabilities.workspace = {
      workspaceFolders: {
        supported: true
      }
    };
  }
  return result;
});

connection.onInitialized(() => {
  if (hasConfigurationCapability) {
    // Register for all configuration changes.
    connection.client.register(DidChangeConfigurationNotification.type, undefined);
  }
  if (hasWorkspaceFolderCapability) {
    connection.workspace.onDidChangeWorkspaceFolders(_event => {
      connection.console.log('Workspace folder change event received.');
    });
  }
});

// The example settings
interface ExampleSettings {
  maxNumberOfProblems: number;
}

// The global settings, used when the `workspace/configuration` request is not supported by the client.
// Please note that this is not the case when using this server with the client provided in this example
// but could happen with other clients.
const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 };
let globalSettings: ExampleSettings = defaultSettings;

// Cache the settings of all open documents
let documentSettings: Map<string, Thenable<ExampleSettings>> = new Map();

connection.onDidChangeConfiguration(change => {
  if (hasConfigurationCapability) {
    // Reset all cached document settings
    documentSettings.clear();
  } else {
    globalSettings = <ExampleSettings>(
      (change.settings.languageServerExample || defaultSettings)
    );
  }

  // Revalidate all open text documents
  documents.all().forEach(validateTextDocument);
});

function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  if (!hasConfigurationCapability) {
    return Promise.resolve(globalSettings);
  }
  let result = documentSettings.get(resource);
  if (!result) {
    result = connection.workspace.getConfiguration({
      scopeUri: resource,
      section: 'languageServerExample'
    });
    documentSettings.set(resource, result);
  }
  return result;
}

// Only keep settings for open documents
documents.onDidClose(e => {
  documentSettings.delete(e.document.uri);
});

// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent(change => {
  validateTextDocument(change.document);
});

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  // In this simple example we get the settings for every validate run.
  let settings = await getDocumentSettings(textDocument.uri);

  // The validator creates diagnostics for all uppercase words length 2 and more
  let text = textDocument.getText();
  let pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  let problems = 0;
  let diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
    problems++;
    let 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: 'ex'
    };
    if (hasDiagnosticRelatedInformationCapability) {
      diagnostic.relatedInformation = [
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Spelling matters'
        },
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Particularly for names'
        }
      ];
    }
    diagnostics.push(diagnostic);
  }

  // Send the computed diagnostics to VS Code.
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

connection.onDidChangeWatchedFiles(_change => {
  // Monitored files have change in VS Code
  connection.console.log('We received a file change event');
});

// This handler provides the initial list of the completion items.
connection.onCompletion(
  (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
    // The pass parameter contains the position of the text document in
    // which code complete got requested. For the example we ignore this
    // info and always provide the same completion items.
    return [
      {
        label: 'TypeScript',
        kind: CompletionItemKind.Text,
        data: 1
      },
      {
        label: 'JavaScript',
        kind: CompletionItemKind.Text,
        data: 2
      }
    ];
  }
);

// This handler resolves additional information for the item selected in
// the completion list.
connection.onCompletionResolve(
  (item: CompletionItem): CompletionItem => {
    if (item.data === 1) {
      item.detail = 'TypeScript details';
      item.documentation = 'TypeScript documentation';
    } else if (item.data === 2) {
      item.detail = 'JavaScript details';
      item.documentation = 'JavaScript documentation';
    }
    return item;
  }
);

// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);

// Listen on the connection
connection.listen();

新增簡單的驗證

若要在伺服器中新增文件驗證,我們為文字文件管理器新增了一個監聽器,每當文字文件內容變更時就會呼叫它。接著由伺服器決定驗證文件的最佳時機。在範例實作中,伺服器會驗證純文字文件,並標記所有全大寫的單字。對應的程式碼片段如下所示:

// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent(async change => {
  let textDocument = change.document;
  // In this simple example we get the settings for every validate run.
  let settings = await getDocumentSettings(textDocument.uri);

  // The validator creates diagnostics for all uppercase words length 2 and more
  let text = textDocument.getText();
  let pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  let problems = 0;
  let diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
    problems++;
    let 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: 'ex'
    };
    if (hasDiagnosticRelatedInformationCapability) {
      diagnostic.relatedInformation = [
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Spelling matters'
        },
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Particularly for names'
        }
      ];
    }
    diagnostics.push(diagnostic);
  }

  // Send the computed diagnostics to VS Code.
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
});

診斷功能的提示與技巧

  • 如果開始與結束位置相同,VS Code 會在該位置的單字下方顯示波浪底線。
  • 如果您想要從該位置一直畫波浪底線到行尾,請將結束位置的字元設為 Number.MAX_VALUE。

若要執行語言伺服器,請執行以下步驟:

  • 按下 ⇧⌘B (Windows, Linux Ctrl+Shift+B) 以啟動建置工作。該工作會同時編譯用戶端與伺服器。
  • 開啟 「執行」 檢視,選擇 「Launch Client」 啟動設定,然後按 「開始偵錯」 按鈕以啟動額外的 VS Code 「擴充功能開發主機」 實例來執行擴充功能程式碼。
  • 在根目錄中建立一個 test.txt 檔案並貼上以下內容:
TypeScript lets you write JavaScript the way you really want to.
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
ANY browser. ANY host. ANY OS. Open Source.

此時,「擴充功能開發主機」 實例看起來會像這樣:

Validating a text file

同時對用戶端與伺服器進行偵錯

偵錯用戶端程式碼就像偵錯一般擴充功能一樣簡單。在用戶端程式碼中設定中斷點,然後按下 F5 偵錯該擴充功能。

Debugging the client

由於伺服器是由執行在擴充功能(用戶端)中的 LanguageClient 所啟動,我們需要將偵錯器附加到執行中的伺服器。為此,切換至 「執行與偵錯」 檢視,選擇 「Attach to Server」 啟動設定,並按下 F5。這會將偵錯器附加到伺服器。

Debugging the server

語言伺服器的記錄支援

如果您使用 vscode-languageclient 來實作用戶端,您可以指定 [langId].trace.server 設定,指示用戶端將語言用戶端與伺服器之間的通訊記錄到與語言用戶端 name 相同的頻道中。

對於 lsp-sample,您可以設定:"languageServerExample.trace.server": "verbose"。現在前往「Language Server Example」頻道,您應該會看到記錄檔。

LSP Log

在伺服器中使用設定檔

在撰寫擴充功能的用戶端部分時,我們已經定義了一個設定來控制回報問題的最大數量。我們也在伺服器端寫入了從用戶端讀取這些設定的程式碼。

function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  if (!hasConfigurationCapability) {
    return Promise.resolve(globalSettings);
  }
  let result = documentSettings.get(resource);
  if (!result) {
    result = connection.workspace.getConfiguration({
      scopeUri: resource,
      section: 'languageServerExample'
    });
    documentSettings.set(resource, result);
  }
  return result;
}

現在我們唯一需要做的,是在伺服器端監聽設定變更,如果設定變更了,就重新驗證已開啟的文字文件。為了能夠重複使用文件變更事件處理的驗證邏輯,我們將程式碼提取到 validateTextDocument 函式中,並修改程式碼以配合 maxNumberOfProblems 變數。

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  // In this simple example we get the settings for every validate run.
  let settings = await getDocumentSettings(textDocument.uri);

  // The validator creates diagnostics for all uppercase words length 2 and more
  let text = textDocument.getText();
  let pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  let problems = 0;
  let diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
    problems++;
    let 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: 'ex'
    };
    if (hasDiagnosticRelatedInformationCapability) {
      diagnostic.relatedInformation = [
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Spelling matters'
        },
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Particularly for names'
        }
      ];
    }
    diagnostics.push(diagnostic);
  }

  // Send the computed diagnostics to VS Code.
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

處理設定變更的方法是為連線新增一個針對設定變更的通知處理常式。對應的程式碼如下所示:

connection.onDidChangeConfiguration(change => {
  if (hasConfigurationCapability) {
    // Reset all cached document settings
    documentSettings.clear();
  } else {
    globalSettings = <ExampleSettings>(
      (change.settings.languageServerExample || defaultSettings)
    );
  }

  // Revalidate all open text documents
  documents.all().forEach(validateTextDocument);
});

再次啟動用戶端並將設定變更為最多回報 1 個問題,結果會產生如下驗證:

Maximum One Problem

新增額外的語言功能

語言伺服器通常實作的第一個有趣功能是文件驗證。從這個角度來看,即使是 linter (檢查器) 也算是一種語言伺服器,在 VS Code 中,linter 通常就是作為語言伺服器來實作的(例如 eslintjshint)。但語言伺服器能做的不僅於此。它們可以提供程式碼完成、尋找所有參考或跳轉至定義。下方的範例程式碼為伺服器新增了程式碼完成功能。它建議使用「TypeScript」和「JavaScript」這兩個單字。

// This handler provides the initial list of the completion items.
connection.onCompletion(
  (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
    // The pass parameter contains the position of the text document in
    // which code complete got requested. For the example we ignore this
    // info and always provide the same completion items.
    return [
      {
        label: 'TypeScript',
        kind: CompletionItemKind.Text,
        data: 1
      },
      {
        label: 'JavaScript',
        kind: CompletionItemKind.Text,
        data: 2
      }
    ];
  }
);

// This handler resolves additional information for the item selected in
// the completion list.
connection.onCompletionResolve(
  (item: CompletionItem): CompletionItem => {
    if (item.data === 1) {
      item.detail = 'TypeScript details';
      item.documentation = 'TypeScript documentation';
    } else if (item.data === 2) {
      item.detail = 'JavaScript details';
      item.documentation = 'JavaScript documentation';
    }
    return item;
  }
);

data 欄位用於在解析 (resolve) 處理常式中唯一識別一個完成項目。data 屬性對於協定來說是透明的。由於底層訊息傳遞協定基於 JSON,因此 data 欄位應僅包含可序列化為 JSON 的資料。

剩下要做的是告訴 VS Code 伺服器支援程式碼完成請求。為此,請在初始化處理常式中標記對應的功能:

connection.onInitialize((params): InitializeResult => {
    ...
    return {
        capabilities: {
            ...
            // Tell the client that the server supports code completion
            completionProvider: {
                resolveProvider: true
            }
        }
    };
});

下方的螢幕截圖顯示了在純文字檔案上執行的完成後程式碼:

Code Complete

測試語言伺服器

為了建立高品質的語言伺服器,我們需要建立一個涵蓋其功能的良好測試套件。測試語言伺服器有兩種常見方法:

  • 單元測試:如果您想透過模擬傳送給它的所有資訊來測試語言伺服器中的特定功能,這非常有用。VS Code 的 HTML / CSS / JSON 語言伺服器就是採用這種測試方法。LSP npm 模組也使用此方法。請參閱此處查看一些使用 npm 協定模組編寫的單元測試。
  • 端對端 (End-to-End) 測試:這與 VS Code 擴充功能測試類似。此方法的優點是它透過實例化一個帶有工作區的 VS Code 實例、開啟檔案、啟用語言用戶端/伺服器並執行 VS Code 指令來執行測試。如果您有檔案、設定或相依性(例如 node_modules)難以或無法進行模擬,這種方法更為優越。受歡迎的 Python 擴充功能就是採用這種測試方法。

您可以使用任何選擇的測試架構來進行單元測試。這裡我們介紹如何對語言伺服器擴充功能進行端對端測試。

開啟 .vscode/launch.json,您可以找到一個 E2E 測試目標:

{
  "name": "Language Server E2E Test",
  "type": "extensionHost",
  "request": "launch",
  "runtimeExecutable": "${execPath}",
  "args": [
    "--extensionDevelopmentPath=${workspaceRoot}",
    "--extensionTestsPath=${workspaceRoot}/client/out/test/index",
    "${workspaceRoot}/client/testFixture"
  ],
  "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"]
}

如果您執行此偵錯目標,它將以 client/testFixture 作為作用中工作區來啟動一個 VS Code 實例。VS Code 隨後將繼續執行 client/src/test 中的所有測試。作為偵錯提示,您可以在 client/src/test 的 TypeScript 檔案中設定中斷點,執行時將會觸發這些中斷點。

讓我們看看 completion.test.ts 檔案:

import * as vscode from 'vscode';
import * as assert from 'assert';
import { getDocUri, activate } from './helper';

suite('Should do completion', () => {
  const docUri = getDocUri('completion.txt');

  test('Completes JS/TS in txt file', async () => {
    await testCompletion(docUri, new vscode.Position(0, 0), {
      items: [
        { label: 'JavaScript', kind: vscode.CompletionItemKind.Text },
        { label: 'TypeScript', kind: vscode.CompletionItemKind.Text }
      ]
    });
  });
});

async function testCompletion(
  docUri: vscode.Uri,
  position: vscode.Position,
  expectedCompletionList: vscode.CompletionList
) {
  await activate(docUri);

  // Executing the command `vscode.executeCompletionItemProvider` to simulate triggering completion
  const actualCompletionList = (await vscode.commands.executeCommand(
    'vscode.executeCompletionItemProvider',
    docUri,
    position
  )) as vscode.CompletionList;

  assert.ok(actualCompletionList.items.length >= 2);
  expectedCompletionList.items.forEach((expectedItem, i) => {
    const actualItem = actualCompletionList.items[i];
    assert.equal(actualItem.label, expectedItem.label);
    assert.equal(actualItem.kind, expectedItem.kind);
  });
}

在此測試中,我們:

  • 啟用擴充功能。
  • 執行指令 vscode.executeCompletionItemProvider,並帶入 URI 和位置來模擬完成觸發。
  • 針對我們預期的完成項目來驗證回傳的完成項目。

讓我們深入探討 activate(docURI) 函式。它定義在 client/src/test/helper.ts 中:

import * as vscode from 'vscode';
import * as path from 'path';

export let doc: vscode.TextDocument;
export let editor: vscode.TextEditor;
export let documentEol: string;
export let platformEol: string;

/**
 * Activates the vscode.lsp-sample extension
 */
export async function activate(docUri: vscode.Uri) {
  // The extensionId is `publisher.name` from package.json
  const ext = vscode.extensions.getExtension('vscode-samples.lsp-sample')!;
  await ext.activate();
  try {
    doc = await vscode.workspace.openTextDocument(docUri);
    editor = await vscode.window.showTextDocument(doc);
    await sleep(2000); // Wait for server activation
  } catch (e) {
    console.error(e);
  }
}

async function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

在啟用部分中,我們:

  • 使用 package.json 中定義的 {publisher.name}.{extensionId} 來取得擴充功能。
  • 開啟指定的文件,並在作用中文字編輯器中顯示它。
  • 暫停 2 秒,以確保語言伺服器已完成實例化。

準備就緒後,我們便可以執行對應於每個語言功能的 VS Code 指令,並針對回傳結果進行驗證。

還有一個測試涵蓋了我們剛才實作的診斷功能。請參閱 client/src/test/diagnostics.test.ts

進階主題

到目前為止,本指南已涵蓋:

  • 語言伺服器與語言伺服器協定的簡要概述。
  • VS Code 中語言伺服器擴充功能的架構。
  • lsp-sample 擴充功能,以及如何開發、偵錯、檢查和測試它。

還有一些我們無法在本指南中詳述的進階主題。我們將提供這些資源的連結,以供您深入研究語言伺服器開發。

其他語言伺服器功能

語言伺服器目前除程式碼完成外,還支援以下語言功能:

  • 文件反白 (Document Highlights):反白文字文件中所有「相同」的符號。
  • 懸停提示 (Hover):提供在文字文件中選取之符號的懸停資訊。
  • 簽章說明 (Signature Help):提供文字文件中選取之符號的簽章說明。
  • 跳轉至定義 (Goto Definition):提供文字文件中選取之符號的跳轉定義支援。
  • 跳轉至類型定義 (Goto Type Definition):提供文字文件中選取之符號的類型/介面定義跳轉支援。
  • 跳轉至實作 (Goto Implementation):提供文字文件中選取之符號的實作定義跳轉支援。
  • 尋找參考 (Find References):尋找文字文件中選取之符號的所有全專案參考。
  • 列出文件符號 (List Document Symbols):列出文字文件中定義的所有符號。
  • 列出工作區符號 (List Workspace Symbols):列出全專案的所有符號。
  • 程式碼動作 (Code Actions):計算給定文字文件和範圍要執行的指令(通常為美化/重構)。
  • CodeLens:計算給定文字文件的 CodeLens 統計資訊。
  • 文件格式化 (Document Formatting):包括整份文件的格式化、文件範圍格式化以及輸入時格式化。
  • 重新命名 (Rename):全專案範圍的符號重新命名。
  • 文件連結 (Document Links):計算並解析文件內的連結。
  • 文件色彩 (Document Colors):計算並解析文件內的色彩,以便在編輯器中提供色彩選擇器。

程式語言功能主題詳細介紹了上述每一項語言功能,並提供關於如何透過語言伺服器協定或直接從您的擴充功能使用擴充 API 來實作它們的指導。

增量文字文件同步

此範例使用 vscode-languageserver 模組提供的簡單文字文件管理器,在 VS Code 和語言伺服器之間同步文件。

這有兩個缺點:

  • 由於文字文件的整個內容會重複傳送至伺服器,因此傳輸的資料量很大。
  • 如果使用現有的語言函式庫,這些函式庫通常支援增量文件更新,以避免不必要的解析和抽象語法樹建立。

因此,該協定也支援增量文件同步。

為了使用增量文件同步,伺服器需要安裝三個通知處理常式:

  • onDidOpenTextDocument:在 VS Code 中開啟文字文件時呼叫。
  • onDidChangeTextDocument:在 VS Code 中文字文件內容變更時呼叫。
  • onDidCloseTextDocument:在 VS Code 中關閉文字文件時呼叫。

以下是說明如何在連線上掛載這些通知處理常式,以及如何在初始化時回傳正確功能的程式碼片段:

connection.onInitialize((params): InitializeResult => {
    ...
    return {
        capabilities: {
            // Enable incremental document sync
            textDocumentSync: TextDocumentSyncKind.Incremental,
            ...
        }
    };
});

connection.onDidOpenTextDocument((params) => {
    // A text document was opened in VS Code.
    // params.uri uniquely identifies the document. For documents stored on disk, this is a file URI.
    // params.text the initial full content of the document.
});

connection.onDidChangeTextDocument((params) => {
    // The content of a text document has change in VS Code.
    // params.uri uniquely identifies the document.
    // params.contentChanges describe the content changes to the document.
});

connection.onDidCloseTextDocument((params) => {
    // A text document was closed in VS Code.
    // params.uri uniquely identifies the document.
});

/*
Make the text document manager listen on the connection
for open, change and close text document events.

Comment out this line to allow `connection.onDidOpenTextDocument`,
`connection.onDidChangeTextDocument`, and `connection.onDidCloseTextDocument` to handle the events
*/
// documents.listen(connection);

直接使用 VS Code API 實作語言功能

雖然語言伺服器有很多優點,但它們並非擴充 VS Code 編輯功能的唯一選擇。若您只想為某種文件類型新增一些簡單的語言功能,請考慮使用 vscode.languages.register[LANGUAGE_FEATURE]Provider 作為選項。

這裡有一個 completions-sample,它使用 vscode.languages.registerCompletionItemProvider 為純文字檔案新增了一些片段作為完成項目。

更多說明 VS Code API 用法的範例可在 https://github.com/microsoft/vscode-extension-samples 找到。

語言伺服器的容錯解析器 (Error Tolerant Parser)

大多數時候,編輯器中的程式碼是不完整且語法不正確的,但開發人員仍然期望自動完成和其他語言功能能夠運作。因此,語言伺服器需要一個容錯解析器:解析器從部分完整的程式碼中產生有意義的 AST,而語言伺服器則基於該 AST 提供語言功能。

當我們改進 VS Code 中的 PHP 支援時,我們意識到官方的 PHP 解析器不具備容錯能力,無法直接在語言伺服器中重複使用。因此,我們致力於開發 Microsoft/tolerant-php-parser,並留下了詳細的 說明筆記,這些筆記可能有助於需要實作容錯解析器的語言伺服器開發人員。

常見問題

當我嘗試附加到伺服器時,出現「無法連線到執行階段處理程序 (timeout after 5000 ms)」?

如果您在嘗試附加偵錯器時伺服器未執行,就會看到此逾時錯誤。用戶端會啟動語言伺服器,因此請確保您已啟動用戶端,以讓伺服器處於執行狀態。如果您的用戶端中斷點干擾了伺服器的啟動,您可能也需要將其停用。

我已經讀完了本指南和 LSP 規範,但我仍有未解決的問題。我該去哪裡尋求協助?

請在 https://github.com/microsoft/language-server-protocol 開啟一個 Issue。

© . This site is unofficial and not affiliated with Microsoft.