VS Code 之 Semantic Highlighing

VS Code 之 Semantic Highlighing_第1张图片

VS Code 1.44 版本于 2020 年 3 月正式发布,其中包括无障碍、Remote Development 等多项重要改进,而其中最令人(我)激动的反而是 Semantic Tokens Provider API 。之所以时隔一年之后才写这篇文章,是因为在即将发布的 KAITIAN 2.0 (版本号暂定) 版本中也正式支持了这个 Feature。

Syntax highlighting

一直以来 VS Code 都使用基于 Textmate Grammar 的语法高亮方案,Textmate 语法实质上是由一组正则表达式组成的,对于不同的编程语言都需要声明不同的正则表达式,通过 grammar 将代码文本分割成符号,并为每个符号生成其作用域(scope)。基于 Textmate 语法的高亮主题即可将颜色和样式映射到作用域。Textmate 广泛的被应用于 Sublime Text 、Atom 等主流文本编辑器中,有着大量的语言及主题支持,VS Code 沿用这套方案也可以无缝继承 Textmate 的语言生态。

实际上 VS Code 的前身 Monaco Editor 也有另一套类似的高亮方案,被称为 Monarch,由于其仅服务于 Monaco Editor,这里不做展开。

如果仅以「文本编辑器」的定位来看,VS Code 使用 Textmate Grammar 的方案基本上没有太大问题,甚至对于大多数用户来说高亮是否语义化也不太会影响编码效率。唯一称的上问题的,是其底层是用与正则匹配的 oniguruma 正则引擎针对单行文本的匹配效率较低。所以前端同学会经常遇到使用 VS Code 打开某些 *.min.js  或 *.min.css  文件时编辑器瞬间会非常卡,以至于后来 VS Code 提供了一个选项,对于单行长度超过一定字符的文件停止高亮解析,并在打开这类文件时弹出警告提示。

在 1.45 版本中,VS Code 通过改进 oniguruma 内存使用的方式,将高亮的性能提高了三倍,具体可以参考这个 Pull Request,但由此带来的问题是对于前文中压缩后的代码高亮性能降低了四倍,所以非常不建议大家在压缩后的文件中强行开启 Syntan Highlighing。

在 1.45 版本中,VS Code 通过改进 oniguruma 内存使用的方式,将高亮的性能提高了三倍,具体可以参考这个 Pull Request,但由此带来的问题是对于前文中压缩后的代码高亮性能降低了四倍,所以非常不建议大家在压缩后的文件中强行开启 Syntan Highlighing。

整体着色效率对比(1.45)

VS Code 之 Semantic Highlighing_第2张图片

这是 1.44 版本中打开一个 3w 行 TypeScript 文件的效果,注意看右侧的 min

imap,着色过程是自上而下逐行进行的。

这是 1.53.0 版本的效果,仔细观察会发现右侧的 minimap 着色速度相比之前版本确实快了很多。

Semantic Highlighing

自 2015 年 VS Code 正式开源,对于语义化高亮的支持在社区中经常被提起,相比基于正则表达式,语义化高亮通常需要基于编程语言的符号表来实现,这意味着对于每一个语言的支持都需要有充分了解其语义的语言服务来提供符号信息。

VS Code 本身只是文本编辑器,对于编程语言的支持是渐进式的,通常需要一个声明式语言配置的插件以及一个基于 LSP (语言服务器协议)的语言插件。而语义化高亮则需要在语言服务插件初始化完成,并对项目分析完成后才能拿到语义信息。从这个层面来说,语义化高亮并不能完全替代基于正则的语法高亮,它更多是体验上的增强。

在官方提供正式的支持之前,社区内已经有一些自行实现的 Semantic Highlighing 方案,例如 ccls,这是一位 C++ 社区的开发者基于 cquery fork 的另一个 C++ 代码索引服务,兼容 LSP,同时也有对应的 VS Code 插件 vscode-ccls。

在这个实现中,作者扩展了 LSP,新增了一个$ccls/publishSemanticHighlight 请求,用于从 ccls 服务中拉取文件的语义信息,并支持通过自定义 Symbol 颜色的方式实现语义化高亮。

VS Code 之 Semantic Highlighing_第3张图片

Semantic Tokens Provider API

基于之前的了解,要针对某一编程语言实现语义化高亮,就需要对应的语言服务器实现 Semantic Tokens API 。这是一组语义化高亮接口,同时它也在目前版本(3.16)的[语言服务器协议](Semantic Token support)中被正式支持。它声明了编程语言应该以何种方式返回项目的语义信息,而编辑器则会根据这个规则来查找主题定义中支持语义化 Token 的作用域。

▐  Semantic Tokens API 的由来

非常有趣的是,号称「开源版 VS Code」的 Theia (类似于 KAITIAN) 团队一直在非常积极的推进一些新的语言服务草案。虽然最终合入的协议方案与 Theia 最早提出的有一定差异,但这个过程中离不开 Theia 团队与 VS Code 团队的积极沟通与推进,最终我们才能看到现在官方支持的 Semantic Tokens API。

▐  区分 API 与协议

这里需要明确一点,我们所说的 Semantic Tokens API 是指通过 VS Code 插件 API 暴露给插件用于提供 Semantic Tokens 的一组接口。而插件在实现了这些接口之后,还需要在与语言服务器通信时发送 LSP 规范下的相关请求来获取 Tokens。这里则需要对应的语言服务器本身来实现实际解析并返回 Tokens 的功能,目前 TypeScript 已经支持了 Semantic Tokens。

虽然 TypeScript 与 Language Server Protocol、VS Code 一样都是微软的开源产品,但实际上 TypeScript 的语言服务 (tsserver) 所使用的并不是标准 LSP 规范下的接口,这并不代表微软本身不支持自家的规范。相反的是,正因为微软有 C#、TypeScript 这些语言的实践经验,从而才将这些通用的语言相关接口抽象成了 LSP。目前 TypeScript 也有计划支持 LSP ,相关内容可以查看这个 issue。


▐  API 细节

如 VS Code 插件 API 所描述的,插件需要调用 registerDocumentSemanticTokensProvider 来注册一个 DocumentSemanticTokensProvider 的实例,同时需要提供一组用于描述所支持 Tokens 的映射(SemanticTokensLegend)。

DocumentSemanticTokensProvider 接口非常简单,核心是两个方法和一个事件

  • provideDocumentSemanticTokens————用于提供编码后的 Tokens

  • provideDocumentSemanticTokensEdits————针对提供 Tokens 的增量更新方法

  • onDidChangeSemanticTokens————Tokens 改变后触发的事件

这里的 Token 表示一个类、方法或者变量名,包含编程语言中常见的语法(tokenType),实际值取决于语言服务的实现。每个 Token 可以有一组对应的修饰符(tokenModifier),修饰符用于修饰一些语法,例如对一个类的方法,private、static、readonly 等就是其修饰符。

假设我们有如下一组 SemanticTokensLegend 声明

{
   tokenTypes: ['property', 'type', 'class'],
   tokenModifiers: ['private', 'static']
}

经过语言服务分析后返回了一组 Tokens 数组,这仅表示文档中3个 token 的信息。

[
  { line: 2, startChar:  5, length: 3, tokenType: "property", tokenModifiers: ["private", "static"]},
  { line: 2, startChar: 10, length: 4, tokenType: "type", tokenModifiers: [] },
  { line: 5, startChar:  2, length: 7, tokenType: "class", tokenModifiers: [] }
]

结构很简单,这里就不多做解释了。但我们需要考虑一个问题就是,对于几千甚至上万行的代码,这种结构所带来的内存消耗是很大的,所以协议规定了将 Tokens 返回到编辑器时需要经过特殊的编码,将其存储为数组,最大可能的降低内存消耗。VS Code API 文档中对编码过程做了详细的描述,这里介绍一下。

对于上述结构,tokenType 可以取提供的 tokenTypes 数组中的索引值,同时 tokenModifiers 长度是不固定的,将其编码为一个整数,这里有一个简单的实现方式。感兴趣的同学可以看一下具体实现原理。

function encodeModifiers(modifiers) {
    let res = 0;
    for (const i of modifiers) {
        res = res | (1 << i);
    }
    return res;
}


const testModifiers = [2, 3]; /** ['async', 'static'] */


const tokens = encodeModifiers(testModifiers);    // 12

经过这次编码后结果长这样

{ line: 2, startChar:  5, length: 3, tokenType: 0,/** property*/ tokenModifiers: 3 /** 为什么变成了 3?*/ },
{ line: 2, startChar: 10, length: 4, tokenType: 1, tokenModifiers: 0 },
{ line: 5, startChar:  2, length: 7, tokenType: 2, tokenModifiers: 0 }

原本的结构中,每个 token 都是它们在文档中的绝对定位。这样的结构对于增量更新不够友好,因为在输入时,并不是所有 token 都发生了改变,但决定定位使得我们即使插入一个空行都需要对所有 token 做更新。例如当我在第一行插入空行时,那么上述结构中的所有 line 都需要 + 1。而将他们修改为 tokens 之间相对定位就免去了这些消耗。

经过相对路径编码后,我们的结构发生了变化

// 第二行第五个字符开始,长度为3,token 类型为 property,modifiers 为 3 --> ['private', 'static']
{ deltaLine: 2, deltaStartChar: 5, length: 3, tokenType: 0, tokenModifiers: 3 },


// 相对于前一个 token,行不变(2行),起始字符为前一个 10 (前一个 token startChar + 5),长度为5,tokenType 为 type
{ deltaLine: 0, deltaStartChar: 5, length: 4, tokenType: 1, tokenModifiers: 0 },


// 相对于前一个 token,行 + 3(5行),起始字符为 2 (新一行,起始字符从0计算),长度为7,tokenType 为 class
{ deltaLine: 3, deltaStartChar: 2, length: 7, tokenType: 2, tokenModifiers: 0 }

在这个结构下,如果在第一行插入了空行,仅有第一个 token 的 deltaLine 需要更新为3,而第三个 token 与前一个 token 的相对距离并没有发生变化。

可以发现每个 token 都是由5个属性组成的 key/value 对,所以将这些 key 去除,只保留表示定位与 tokens 信息的数组。

[
  2,5,3,0,3,    // { line: 2, startChar:  5, length: 3, tokenType: "property", tokenModifiers: ["private", "static"]},
  0,5,4,1,0,    // { line: 2, startChar: 10, length: 4, tokenType: "type", tokenModifiers: [] },
  3,2,7,2,0,    // { line: 5, startChar:  2, length: 7, tokenType: "class", tokenModifiers: [] }
 ]

所以相比最开始的结果,最终返回给编辑器的只有一个数字组成的数组。

编辑器在渲染时,将每个 token 再反向解析回来,其中对于 tokenModifiers 的编码可以由如下方法来反编码。

const tokenModifiers = [
    'method',
    'interface',
    'async',
    'static',
    'class',
];


function decodeModifiers(res) {
    return tokenModifiers.filter((_, m) => res & 1 << m);
}


decodeModifiers(12) // ['async', 'static']

另外对于 Semantic Highlighting 这个需求来说,并不是代码中的每一个 token 都需要语义化解析,所以 VS Code 也提供了DocumentRangeSemanticTokensProvider,用于仅解析指定范围内的高亮信息。

如果一个语言同时注册了DocumentRangeSemanticTokensProvider与DocumentSemanticTokensProvider。

那么DocumentRangeSemanticTokensProvider只有第一次会被调用。

当完成第一次高亮解析后,它将被废弃,编辑器后续只会使用DocumentSemanticTokensProvider

▐  协议

完整的 Semantic Tokens 协议也包含两组请求,分别对应上述两个 API

  • textDocument/semanticTokens/full

  • textDocument/semanticTokens/range

语言服务器需要实现这两个请求,以供插件调用,由于协议大部分内容与插件 API 类似,感兴趣的可以点击 LSP 协议规范查看(https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_semanticTokens)

最后

微软在在 2016 年第一次提出 LSP 时,仅有极少部分语言提供了支持,同时社区对该协议的前景也不太乐观。但经过4年多的发展,目前已经有上百个不同语言的实现。同时在编辑器/IDE 支持度方面,也已经有 Eclipse、Sublime、Atom、Theia 以及 Vim、Emacs 等或社区或官方的支持。如果需要实现一个 IDE 或实现一门语言,支持 LSP 或基于 LSP 规范来实现已经是一个不争的事实。

References


  • [2016-6] 社区针对 Language Server Protocol 的 Feature Request, Support semantic highlighting

  • [2018-8] Theia 提出基础的 Semantic Highlighing 草案, Proposal of the semantic highlighting protocol extension

  • [2019-12] VS Code 提出 Semantic Tokens API , Semantic Tokens API

  • [2020-1] VS Code 提交 Semantic Tokens API,Add proposed support for semantic tokens

  • [2020-3] VS Code 1.44 版本正式支持 Semantic Tokens API, Semantic Highlighting Overview

  • [2020] LSP 3.16 发布,新增 Semantic Tokens 相关 API 协议说明,  What’s new in 3.16

✿  拓展阅读

VS Code 之 Semantic Highlighing_第4张图片

VS Code 之 Semantic Highlighing_第5张图片

VS Code 之 Semantic Highlighing_第6张图片

作者|柳千

编辑|橙子君

出品|阿里巴巴新零售淘系技术

VS Code 之 Semantic Highlighing_第7张图片

VS Code 之 Semantic Highlighing_第8张图片

你可能感兴趣的:(编程语言,java,js,python,javascript)