Language Server 擴充功能指南

如您在程式化語言功能主題中所見,可以直接使用 languages.* API 來實作語言功能。然而,Language Server 擴充功能提供實作這類語言支援的替代方法。

本主題

為何需要 Language Server?

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

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

首先,Language Server 通常以其原生程式設計語言實作,這對將其與具有 Node.js 執行階段的 VS Code 整合構成挑戰。

此外,語言功能可能會耗用大量資源。例如,為了正確驗證檔案,Language Server 需要剖析大量檔案、為其建構抽象語法樹狀結構,並執行靜態程式分析。這些作業可能會產生大量的 CPU 和記憶體使用量,我們需要確保 VS Code 的效能不受影響。

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

為了解決這些問題,Microsoft 指定了Language Server Protocol,該協定標準化了語言工具和程式碼編輯器之間的通訊。透過這種方式,Language Server 可以用任何語言實作,並在其自己的程序中執行,以避免效能成本,因為它們透過 Language Server Protocol 與程式碼編輯器通訊。此外,任何符合 LSP 的語言工具都可以與多個符合 LSP 的程式碼編輯器整合,並且任何符合 LSP 的程式碼編輯器都可以輕鬆地採用多個符合 LSP 的語言工具。LSP 對於語言工具提供者和程式碼編輯器廠商而言都是雙贏的!

LSP Languages and Editors

在本指南中,我們將

  • 說明如何使用提供的 Node SDK 在 VS Code 中建置 Language Server 擴充功能。
  • 說明如何執行、偵錯、記錄和測試 Language Server 擴充功能。
  • 向您指出有關 Language Server 的一些進階主題。

實作 Language Server

總覽

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

  • 語言用戶端:以 JavaScript/TypeScript 撰寫的一般 VS Code 擴充功能。此擴充功能可以存取所有 VS Code 命名空間 API
  • 語言伺服器:在個別程序中執行的語言分析工具。

如上所述,在個別程序中執行 Language Server 有兩個好處

  • 分析工具可以用任何語言實作,只要它可以使用 Language Server Protocol 與語言用戶端通訊即可。
  • 由於語言分析工具通常會大量使用 CPU 和記憶體,因此在個別程序中執行它們可以避免效能成本。

以下是在 VS Code 中執行兩個 Language Server 擴充功能的圖例。HTML Language Client 和 PHP Language Client 是以 TypeScript 撰寫的一般 VS Code 擴充功能。它們各自具現化對應的 Language Server,並透過 LSP 與其通訊。雖然 PHP Language Server 是以 PHP 撰寫,但它仍然可以透過 LSP 與 PHP Language Client 通訊。

LSP Illustration

本指南將教您如何使用我們的 Node SDK 建置 Language Client/Server。剩餘的文件假設您熟悉 VS Code 擴充功能 API

LSP 範例 - 適用於純文字檔案的簡單 Language Server

讓我們建置一個簡單的 Language Server 擴充功能,為純文字檔案實作自動完成和診斷功能。我們還將介紹用戶端/伺服器之間組態的同步處理。

如果您偏好直接跳至程式碼

複製存放庫 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."
        }
    }
}

此章節將 configuration 設定貢獻給 VS Code。範例將說明如何在啟動時以及每次設定變更時,將這些設定傳送至語言伺服器。

注意:如果您的擴充功能與 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;

export 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
  client.start();
}

export function deactivate(): Thenable<void> | undefined {
  if (!client) {
    return undefined;
  }
  return client.stop();
}

說明「語言伺服器」

注意:從 GitHub 存放庫複製的「伺服器」實作具有最終逐步解說實作。若要遵循逐步解說,您可以建立新的 server.ts 或修改複製版本的內容。

在本範例中,伺服器也是以 TypeScript 實作,並使用 Node.js 執行。由於 VS Code 已隨附 Node.js 執行階段,因此除非您對執行階段有特定需求,否則無需提供自己的執行階段。

Language Server 的原始碼位於 /server。伺服器的 package.json 檔案中有趣的章節是

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

這會提取 vscode-languageserver 程式庫。

以下是伺服器實作,它使用提供的文字文件管理員,該管理員透過始終從 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。

若要執行 Language Server,請執行下列步驟

  • ⇧⌘B (Windows、Linux Ctrl+Shift+B) 以啟動建置工作。此工作會編譯用戶端和伺服器。
  • 開啟 執行 檢視,選取 啟動用戶端 啟動組態,然後按一下 開始偵錯 按鈕以啟動 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 啟動,因此我們需要將偵錯工具附加至正在執行的伺服器。若要執行此操作,請切換至 執行與偵錯 檢視,選取啟動組態 附加至伺服器,然後按一下 F5。這會將偵錯工具附加至伺服器。

Debugging the server

Language Server 的記錄支援

如果您使用 vscode-languageclient 實作用戶端,您可以指定設定 [langId].trace.server,指示用戶端將 Language Client/Server 之間的通訊記錄到 Language Client 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

新增其他語言功能

語言伺服器通常實作的第一個有趣功能是文件驗證。從這個意義上講,即使是語法檢查器也算作語言伺服器,而在 VS Code 中,語法檢查器通常以語言伺服器實作 (請參閱 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 欄位用於在解析處理常式中唯一識別完成項目。資料屬性對於協定是透明的。由於基礎訊息傳遞協定是以 JSON 為基礎,因此資料欄位應僅保留可序列化為 JSON 和從 JSON 序列化的資料。

所有遺漏的是告知 VS Code 伺服器支援程式碼完成要求。若要執行此操作,請在初始化處理常式中標記對應的功能

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

以下螢幕擷取畫面顯示在純文字檔案上執行的已完成程式碼

Code Complete

測試 Language Server

若要建立高品質的 Language Server,我們需要建置良好的測試套件,以涵蓋其功能。有兩種常見的 Language Server 測試方法

  • 單元測試:如果您想要透過模擬傳送至 Language Server 的所有資訊來測試 Language Server 中的特定功能,這會很有用。VS Code 的 HTML/ CSS/ JSON Language Server 採用此測試方法。LSP npm 模組也採用此方法。請參閱 這裡,瞭解一些使用 npm 協定模組撰寫的單元測試。
  • 端對端測試:這類似於VS Code 擴充功能測試。此方法的優點是,它透過具現化具有工作區的 VS Code 執行個體、開啟檔案、啟動 Language Client/Server,以及執行 VS Code 命令 來執行測試。如果您有難以或無法模擬的檔案、設定或相依性 (例如 node_modules),則此方法更佳。熱門的 Python 擴充功能採用此測試方法。

可以在您選擇的任何測試架構中執行單元測試。在這裡,我們說明如何對 Language Server 擴充功能執行端對端測試。

開啟 .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"]
}

如果您執行此偵錯目標,它將啟動 VS Code 執行個體,並以 client/testFixture 作為活動工作區。然後,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);
  });
}

在此測試中,我們

  • 啟動擴充功能。
  • 使用 URI 和位置執行命令 vscode.executeCompletionItemProvider,以模擬完成觸發。
  • 針對我們預期的完成項目判斷提示傳回的完成項目。

讓我們更深入探討 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));
}

在啟動部分,我們

  • 使用 {publisher.name}.{extensionId} (如 package.json 中所定義) 取得擴充功能。
  • 開啟指定的檔案,並在活動文字編輯器中顯示它。
  • 睡眠 2 秒,以便我們確定已具現化 Language Server。

準備就緒後,我們可以執行對應於每個語言功能的 VS Code 命令,並針對傳回的結果進行判斷提示。

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

進階主題

到目前為止,本指南涵蓋了

  • Language Server 和 Language Server Protocol 的簡要總覽。
  • VS Code 中 Language Server 擴充功能的架構
  • lsp-sample 擴充功能,以及如何開發/偵錯/檢查/測試它。

有些更進階的主題我們無法納入本指南。我們將包含這些資源的連結,以供進一步研究 Language Server 開發。

其他 Language Server 功能

以下語言功能目前在語言伺服器中支援,以及程式碼完成

  • 文件醒目提示:醒目提示文字文件中所有「相等」符號。
  • Hover:為文字文件中選取的符號提供 Hover 資訊。
  • 簽章說明:為文字文件中選取的符號提供簽章說明。
  • 跳至定義:為文字文件中選取的符號提供跳至定義支援。
  • 跳至類型定義:為文字文件中選取的符號提供跳至類型/介面定義支援。
  • 跳至實作:為文字文件中選取的符號提供跳至實作定義支援。
  • 尋找參考:尋找文字文件中選取的符號的所有專案範圍參考。
  • 列出文件符號:列出文字文件中定義的所有符號。
  • 列出工作區符號:列出所有專案範圍的符號。
  • 程式碼動作:計算要針對給定的文字文件和範圍執行的命令 (通常是美化/重構)。
  • CodeLens:計算給定文字文件的 CodeLens 統計資料。
  • 文件格式化:這包括整個文件、文件範圍和類型格式化。
  • 重新命名:專案範圍的符號重新命名。
  • 文件連結:計算和解析文件內的連結。
  • 文件色彩:計算和解析文件內的色彩,以在編輯器中提供色彩選取器。

程式化語言功能主題描述了上述每個語言功能,並提供有關如何透過語言伺服器協定或直接從擴充功能使用擴充性 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 實作語言功能

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

以下是 completions-sample,它使用 vscode.languages.registerCompletionItemProvider 為純文字檔案新增一些程式碼片段作為完成項目。

如需更多說明 VS Code API 用法的範例,請參閱 https://github.com/microsoft/vscode-extension-samples

Language Server 的容錯剖析器

大多數時候,編輯器中的程式碼是不完整的且語法不正確,但開發人員仍然希望自動完成和其他語言功能能夠運作。因此,容錯剖析器對於 Language Server 而言是必要的:剖析器從部分完成的程式碼產生有意義的 AST,而 Language Server 根據 AST 提供語言功能。

當我們改進 VS Code 中的 PHP 支援時,我們意識到官方 PHP 剖析器不是容錯的,並且無法直接在 Language Server 中重複使用。因此,我們致力於 Microsoft/tolerant-php-parser,並留下詳細的筆記,這些筆記可能有助於需要實作容錯剖析器的 Language Server 作者。

常見問題

當我嘗試附加至伺服器時,收到「無法連線至執行階段程序 (5000 毫秒後逾時)」?

如果您在嘗試附加偵錯工具時伺服器未執行,您將會看到此逾時錯誤。用戶端會啟動語言伺服器,因此請確定您已啟動用戶端,以便擁有正在執行的伺服器。如果您的用戶端中斷點干擾伺服器的啟動,您可能也需要停用它們。

我已閱讀本指南和 LSP 規格,但我仍然有未解決的問題。我可以在哪裡獲得協助?

請在 https://github.com/microsoft/language-server-protocol 開啟問題。