尝试 在 VS Code 中!

嵌入式编程语言

Visual Studio Code 为编程语言提供了丰富的语言功能。如您在语言服务器扩展指南中读到的,您可以编写语言服务器以支持任何编程语言。然而,为嵌入式语言启用这种支持需要更多的努力。

如今,嵌入式语言的数量不断增加,例如:

  • HTML 中的 JavaScript 和 CSS
  • JavaScript 中的 JSX
  • 模板语言中的插值,例如 Vue、Handlebars 和 Razor
  • PHP 中的 HTML

本指南重点介绍如何为嵌入式语言实现语言功能。如果您有兴趣为嵌入式语言提供语法高亮,您可以在语法高亮指南中找到相关信息。

本指南包含两个示例,演示了构建此类语言服务器的两种方法:语言服务请求转发。我们将回顾这两个示例,并总结每种方法的优缺点。

两个示例的源代码可在以下位置找到:

这就是我们将要构建的嵌入式语言服务器:

sample

这两个示例都为了说明目的而贡献了一种新语言 html1。您可以创建一个 .html1 文件并测试以下功能:

  • HTML 标签的补全
  • <style> 标签中 CSS 的补全
  • CSS 的诊断 (仅在语言服务示例中)

语言服务

语言服务是一个库,它实现了编程语言功能,用于单一语言。语言服务器可以嵌入语言服务来处理嵌入式语言。

以下是 VS Code 的 HTML 支持概述:

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,以使用已激活并为嵌入式语言注册了补全提供程序的扩展。

再次回到简单的例子:

<div></div>
<style>.foo { | }</style>

自动补全按以下方式进行:

  • 语言客户端使用 workspace.registerTextDocumentContentProviderembedded-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> 标签内获得 _. 补全。

编码和解码

文档的主要语言可能与其嵌入式语言具有不同的编码或转义规则。例如,根据HTML 规范,此 HTML 文档是无效的

<SCRIPT type="text/javascript">
  document.write ("<EM>This won't work</EM>")
</SCRIPT>

在这种情况下,如果嵌入式 JavaScript 的语言服务器返回包含 </ 的结果,则应将其转义为 <\/

结论

这两种方法都有其优缺点。

语言服务

  • + 对语言服务器和用户体验的完全控制。
  • + 不依赖于其他语言服务器。所有代码都在一个仓库中。
  • + 语言服务器可以在所有LSP 兼容的代码编辑器中重用。
  • - 可能难以嵌入用其他语言编写的语言服务。
  • - 需要持续维护以获取语言服务依赖项的新功能。

请求转发

  • + 避免嵌入与语言服务器语言不一致的语言服务所带来的问题(例如,在 Razor 语言服务器中嵌入 C# 编译器以支持 C#)。
  • + 无需维护即可从其他语言服务获取上游新功能。
  • - 不适用于诊断错误。VS Code API 不支持可以“拉取”(请求)诊断的诊断提供程序。
  • - 由于缺乏控制,难以与其他语言服务器共享状态。
  • - 跨语言功能可能难以实现(例如,当存在 <div class="foo"> 时,为 .foo 提供 CSS 补全)。

总的来说,我们建议通过嵌入语言服务来构建语言服务器,因为这种方法可以为您提供对用户体验的更多控制,并且服务器可以用于任何 LSP 兼容的编辑器。然而,如果您有一个简单的用例,其中嵌入内容可以在没有上下文或语言服务器状态的情况下轻松处理,或者如果您遇到捆绑 Node.js 库的问题,您可以考虑请求转发方法。