嵌入式程式語言
Visual Studio Code 為程式語言提供了豐富的功能。正如您在語言伺服器擴充功能指南中所讀到的,您可以編寫語言伺服器來支援任何程式語言。然而,為嵌入式語言啟用此類支援需要付出更多的努力。
如今,嵌入式語言越來越多,例如:
- HTML 中的 JavaScript 和 CSS
- JavaScript 中的 JSX
- 模板語言中的插值語法,例如 Vue、Handlebars 和 Razor
- PHP 中的 HTML
本指南重點介紹如何實作嵌入式語言的語言功能。如果您有興趣為嵌入式語言提供語法高亮顯示,可以在語法高亮指南中找到相關資訊。
本指南包含兩個範例,說明了建立此類語言伺服器的兩種方法:語言服務 (Language Services) 和 請求轉發 (Request Forwarding)。我們將審視這兩個範例,並總結每種方法的優缺點。
這兩個範例的原始程式碼可以在以下位置找到:
以下是我們將要建立的嵌入式語言伺服器:

為了說明起見,兩個範例都貢獻了一種新語言 html1。您可以建立一個 .html1 檔案並測試以下功能:
- HTML 標籤的自動完成
<style>標籤中 CSS 的自動完成- CSS 的診斷資訊(僅限於「語言服務」範例)
語言服務
語言服務是一個為單一語言實作程式化語言功能的函式庫。語言伺服器可以嵌入語言服務來處理嵌入式語言。
以下是 VS Code HTML 支援的概述:
- 內建的 html 擴充功能僅提供 HTML 的語法高亮和語言配置。
- 內建的 html-language-features 擴充功能包含一個 HTML 語言伺服器,用以提供 HTML 的程式化語言功能。
- HTML 語言伺服器使用 vscode-html-languageservice 來支援 HTML。
- CSS 語言伺服器使用 vscode-css-languageservice 來支援 HTML 中的 CSS。
HTML 語言伺服器分析 HTML 文件,將其分解為語言區域,並使用對應的語言服務來處理語言伺服器請求。
例如
- 對於
<|處的自動完成請求,HTML 語言伺服器使用 HTML 語言服務來提供 HTML 自動完成。 - 對於
<style>.foo { | }</style>處的自動完成請求,HTML 語言伺服器使用 CSS 語言服務來提供 CSS 自動完成。
讓我們來看看 lsp-embedded-language-service 範例,它是 HTML 語言伺服器的簡化版,實作了 HTML 和 CSS 的自動完成,以及 CSS 的錯誤診斷。
語言服務範例
注意:本範例假設您具備程式化語言功能主題和語言伺服器擴充功能指南的知識。此程式碼建立在 lsp-sample 之上。
原始程式碼可在 microsoft/vscode-extension-samples 取得。
與 lsp-sample 相比,用戶端程式碼是相同的。
如上所述,伺服器將文件分解為不同的語言區域,以處理嵌入的內容。
這是一個簡單的範例:
<div></div>
<style>.foo { }</style>
在這種情況下,伺服器檢測到 <style> 標籤,並將 .foo { } 標記為 CSS 區域。
對於特定位置的自動完成請求,伺服器使用以下邏輯來計算回應:
- 如果該位置位於任何區域內:
- 使用該區域語言的虛擬文件來處理,同時將所有其他區域替換為空白。
- 如果該位置位於任何區域外:
- 使用 HTML 虛擬文件來處理,同時將所有區域替換為空白。
例如,當在此位置進行自動完成時:
<div></div>
<style>.foo { | }</style>
伺服器確定該位置位於區域內,並計算出一個虛擬 CSS 文件,其內容如下(█ 代表空格):
███████████
███████.foo { | }████████
然後,伺服器使用 vscode-css-languageservice 來分析此文件並計算自動完成項目列表。因為內容現在不包含 HTML,CSS 語言服務可以順利處理。透過將所有非 CSS 內容替換為空白,我們省去了手動偏移位置的麻煩。
處理自動完成請求的伺服器程式碼:
connection.onCompletion(async (textDocumentPosition, token) => {
const document = documents.get(textDocumentPosition.textDocument.uri);
if (!document) {
return null;
}
const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
if (!mode || !mode.doComplete) {
return CompletionList.create();
}
const doComplete = mode.doComplete!;
return doComplete(document, textDocumentPosition.position);
});
負責處理所有落入 CSS 區域之語言伺服器請求的 CSS 模式:
export function getCSSMode(
cssLanguageService: CSSLanguageService,
documentRegions: LanguageModelCache<HTMLDocumentRegions>
): LanguageMode {
return {
getId() {
return 'css';
},
doComplete(document: TextDocument, position: Position) {
// Get virtual CSS document, with all non-CSS code replaced with whitespace
const embedded = documentRegions.get(document).getEmbeddedDocument('css');
// Compute a response with vscode-css-languageservice
const stylesheet = cssLanguageService.parseStylesheet(embedded);
return cssLanguageService.doComplete(embedded, position, stylesheet);
}
};
}
這是一種處理嵌入式語言的簡單且有效的方法。然而,這種方法也有一些缺點:
- 您必須持續更新語言伺服器所依賴的語言服務。
- 包含與語言伺服器編寫語言不同的語言服務可能具有挑戰性。例如,用 PHP 編寫的 PHP 語言伺服器要包含用 TypeScript 編寫的
vscode-css-languageservice會很麻煩。
我們現在將介紹請求轉發,它將解決上述問題。
請求轉發
簡而言之,請求轉發的工作方式與語言服務類似。請求轉發方法也接收語言伺服器請求,計算虛擬內容,並計算回應。
主要區別在於:
- 雖然語言服務方法使用函式庫來計算語言伺服器回應,但請求轉發將請求發送回 VS Code,以使用那些已啟動並為該嵌入式語言註冊了補全提供者 (completion provider) 的擴充功能。
再次以簡單的範例為例:
<div></div>
<style>.foo { | }</style>
自動完成的運作方式如下:
- 語言用戶端使用
workspace.registerTextDocumentContentProvider為embedded-content文件註冊虛擬文字文件提供者。 - 語言用戶端劫持了針對
<FILE_URI>的自動完成請求。 - 語言用戶端確定請求位置位於 CSS 區域內。
- 語言用戶端建構一個新的 URI,例如
embedded-content://css/<FILE_URI>.css。 - 語言用戶端接著呼叫
commands.executeCommand('vscode.executeCompletionItemProvider', ...)。- VS Code 的 CSS 語言伺服器會回應此提供者請求。
- 虛擬文字文件提供者為 CSS 語言伺服器提供虛擬內容,其中所有非 CSS 程式碼都被替換為空白。
- 語言用戶端收到來自 VS Code 的回應,並將其作為最終回應發送。
透過這種方法,即使我們的程式碼不包含任何理解 CSS 的函式庫,我們也能計算 CSS 自動完成。隨著 VS Code 更新其 CSS 語言伺服器,我們無需更新程式碼即可獲得最新的 CSS 語言支援。
現在讓我們檢視範例程式碼。
請求轉發範例
注意:本範例假設您具備程式化語言功能主題和語言伺服器擴充功能指南的知識。此程式碼建立在 lsp-sample 之上。
原始程式碼可在 microsoft/vscode-extension-samples 取得。
維護文件 URI 與其虛擬文件之間的對應關係,並為相應的請求提供它們。
const virtualDocumentContents = new Map<string, string>();
workspace.registerTextDocumentContentProvider('embedded-content', {
provideTextDocumentContent: uri => {
// Remove leading `/` and ending `.css` to get original URI
const originalUri = uri.path.slice(1).slice(0, -4);
const decodedUri = decodeURIComponent(originalUri);
return virtualDocumentContents.get(decodedUri);
}
});
透過使用語言用戶端的 middleware 選項,我們劫持了自動完成請求。
let clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'html' }],
middleware: {
provideCompletionItem: async (document, position, context, token, next) => {
// If not in `<style>`, do not perform request forwarding
if (
!isInsideStyleRegion(
htmlLanguageService,
document.getText(),
document.offsetAt(position)
)
) {
return await next(document, position, context, token);
}
const originalUri = document.uri.toString(true);
virtualDocumentContents.set(
originalUri,
getCSSVirtualContent(htmlLanguageService, document.getText())
);
const vdocUriString = `embedded-content://css/${encodeURIComponent(originalUri)}.css`;
const vdocUri = Uri.parse(vdocUriString);
return await commands.executeCommand<CompletionList>(
'vscode.executeCompletionItemProvider',
vdocUri,
position,
context.triggerCharacter
);
}
}
};
潛在問題
在實作嵌入式語言伺服器時,我們遇到了許多問題。雖然我們還沒有完美的解決方案,但我們想先提醒您,因為您很可能會遇到這些問題。
難以實作的語言功能
通常,跨越語言區域邊界的語言功能更難實作。例如,自動完成或懸停內容很容易實作,因為您可以檢測嵌入內容的語言並根據其計算回應。然而,格式化或重新命名等語言功能可能需要特殊處理。在格式化方面,您需要處理單一文件內多個區域的縮排和格式化設定。對於重新命名,要在不同文件的不同區域中運作可能具有挑戰性。
語言服務可能是有狀態的,難以嵌入
VS Code 的 HTML 支援提供了 HTML、CSS 和 JavaScript 語言功能。雖然 HTML 和 CSS 語言服務是無狀態的,但驅動 JavaScript 語言功能的 TypeScript 伺服器是有狀態的。我們只在 HTML 文件內提供基本的 JavaScript 支援,因為很難告知 TypeScript 專案的狀態。例如,如果您包含一個指向 CDN 上 lodash 函式庫的 <script> 標籤,您在 <script> 標籤內將無法獲得 _. 的自動完成。
編碼與解碼
文件的主語言可能有與其嵌入語言不同的編碼或跳脫 (escaping) 規則。例如,根據 HTML 規範,此 HTML 文件是無效的:
<SCRIPT type="text/javascript">
document.write ("<EM>This won't work</EM>")
</SCRIPT>
在這種情況下,如果嵌入式 JavaScript 的語言伺服器返回的結果包含 </,則應將其跳脫為 <\/。
結語
這兩種方法各有優缺點。
語言服務
- + 完全控制語言伺服器和使用者體驗。
- + 不依賴其他語言伺服器。所有程式碼都在同一個儲存庫中。
- + 語言伺服器可在所有 符合 LSP 的程式碼編輯器 中重複使用。
- - 可能難以嵌入以其他語言編寫的語言服務。
- - 需要持續維護以從語言服務依賴項中獲取新功能。
請求轉發
- + 避免了嵌入非語言伺服器編寫語言的語言服務的問題(例如,在 Razor 語言伺服器中嵌入 C# 編譯器以支援 C#)。
- + 無需維護即可從其他語言服務獲取上游的新功能。
- - 不適用於診斷錯誤。VS Code API 不支援可以「拉取」(pull/request) 診斷資訊的診斷提供者。
- - 由於缺乏控制,很難與其他語言伺服器共用狀態。
- - 跨語言功能可能難以實作(例如,當
<div class="foo">存在時,提供.foo的 CSS 自動完成)。
總體而言,我們建議透過嵌入語言服務來構建語言伺服器,因為這種方法讓您對使用者體驗有更多控制權,且伺服器可重複用於任何符合 LSP 的編輯器。但是,如果您的使用場景較簡單,嵌入內容無需上下文或語言伺服器狀態即可輕鬆處理,或者打包 Node.js 函式庫對您來說是個問題,您可以考慮「請求轉發」方法。