🚀 在 VS Code 中取得

語言伺服器擴充功能指南

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

本主題

為何需要語言伺服器?

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

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

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

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

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

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

LSP Languages and Editors

在本指南中,我們將

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

實作語言伺服器

概觀

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

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

如上簡述,在個別程序中執行語言伺服器有兩個好處

  • 分析工具可以使用任何語言實作,只要它可以遵循語言伺服器協定與語言用戶端通訊即可。
  • 由於語言分析工具通常在 CPU 和記憶體使用量方面都很繁重,因此在個別程序中執行它們可以避免效能成本。

以下是在 VS Code 中執行兩個語言伺服器擴充功能的圖示。HTML 語言用戶端和 PHP 語言用戶端是以 TypeScript 撰寫的一般 VS Code 擴充功能。它們各自實例化對應的語言伺服器,並透過 LSP 與它們通訊。雖然 PHP 語言伺服器是以 PHP 撰寫的,但它仍然可以透過 LSP 與 PHP 語言用戶端通訊。

LSP Illustration

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

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

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

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

  • lsp-sample:本指南的詳細註解的原始碼。
  • lsp-multi-server-samplelsp-sample 的詳細註解的進階版本,它為每個工作區資料夾啟動不同的伺服器執行個體,以支援 VS Code 中的多根工作區功能。

複製存放庫 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 執行階段,因此無需提供您自己的執行階段,除非您對執行階段有特定需求。

語言伺服器的原始碼位於 /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();

新增簡單驗證

若要將文件驗證新增至伺服器,我們會在文字文件管理員中新增一個接聽程式,每當文字文件的內容變更時,都會呼叫該接聽程式。然後由伺服器決定驗證文件的最佳時機。在範例實作中,伺服器會驗證純文字文件,並標記所有使用 ALL CAPS 的單字出現次數。對應的程式碼片段如下所示

// 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) 以啟動建置工作。此工作會編譯用戶端和伺服器。
  • 開啟執行檢視,選取啟動用戶端啟動組態,然後按下開始偵錯按鈕,以啟動額外的 擴充功能開發主機 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

語言伺服器的記錄支援

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

對於 lsp-sample,您可以設定此設定:"languageServerExample.trace.server": "verbose"。現在前往「語言伺服器範例」通道。您應該會看到記錄

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 欄位用於在解析處理常式中唯一識別完成項目。data 屬性對於協定是透明的。由於底層訊息傳遞協定是以 JSON 為基礎的,因此 data 欄位應僅保留可序列化為 JSON 和從 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 協定模組撰寫的一些單元測試。
  • 端對端測試:這與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"]
}

如果您執行此偵錯目標,它將啟動一個 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 秒鐘,因此我們確信已實例化語言伺服器。

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

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

進階主題

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

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

還有一些更進階的主題我們無法納入本指南。我們將包含指向這些資源的連結,以供進一步研究語言伺服器開發。

其他語言伺服器功能

以下語言功能目前在語言伺服器中與程式碼完成一起受到支援

  • 文件醒目提示:醒目提示文字文件中所有「相等」符號。
  • 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 來實作語言功能

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

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

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

語言伺服器的容錯剖析器

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

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

常見問題

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

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

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

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