> 作者:野声 > 来源:Alibaba F2E公众号
![image.png](https://ucc.alicdn.com/pic/developer-ecology/c6b6f62caf39484f9e28fd5f8d827888.png)
when people ask me to recommend a text editor 图源:Twitter@tpope(https://twitter.com/tpope/status/1172743697315835904)
VSCode 为何可以支持如此之多的编程语言?如何为一门新语言编写语言插件?又有哪些语言特性可以被应用呢?本次分享为大家介绍了 VSCode 提供的编程语言相关的能力,详细讲解了代码高亮原理、```languages.*``` API、Language Server Protocol 等内容。 再往下讲之前,要先讲一下 VSCode 插件入门。有基础的朋友可以跳过该段往下继续看。 # VSCode 插件极速入门
![image.png](https://ucc.alicdn.com/pic/developer-ecology/f7f92b9ff14f4a349b5cdc89eb75eceb.png)
上图就是一个插件的全貌,VSCode 插件由两部分组成:配置声明和代码。 我们可以声明一些配置项,比如代码入口文件、插件激活的时机、菜单项、快捷键等等。 我们的业务逻辑代码放在代码入口文件的 ```activate``` 函数内,VSCode 在我们声明的 ```activationEvents```被触发后,会执行我们的入口函数。 我们在入口函数中通过调用 VSCode 给我们提供的 API(如 ```vscode.languages.xxx```)来做各种功能。 VSCode 提供了非常丰富的 API(数不胜数),比如说用户可以在编辑器区域、状态栏等各个地方添加自己的组件;比如说可以操作编辑器、操作文件树、提示消息等等。同样的,也有丰富的 API 提供了语言编辑支持,如补全、代码高亮等等。VSCode 帮我们搭好了一整套架子,我们需要往里填内容。 # 什么是语言插件? 语言插件就是 VSCode 整个插件生态/系统中关于 **编辑/编程语言支持** 的那一部分。我们能用 VSCode 编辑各种不同的编程语言,靠的就是这些插件以及背后的开发者。 像我们在 VSCode 中编辑代码时的语法高亮,自动补全等都是语言插件带给我们的。VSCode 的本体也是没有加入各种语言的编辑能力的,它也是靠内置的插件来完成的,如:css-language-features\typescript-language-features 等等。 VSCode 提供了一堆 API (对应不同语言特性的贡献点)来让开发者实现各种语言特性。 代码高亮
![image.png](https://ucc.alicdn.com/pic/developer-ecology/e6d008b561614b8f9da0901b4722d03c.png)
自动补全
![image.png](https://ucc.alicdn.com/pic/developer-ecology/7728640defd4460a930c2ab488e649a7.png)
我们在 VSCode 插件市场上也能看到各种语言插件:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/fd5def05c77040f68880155d872d378f.png)
如何实现「语言插件」的这些功能? VSCode 将提供的语言特性大致分为了两种: 1.声明式语言特性 通过编写配置文件来定义一些特性。 2.编程式语言特性 a.languages.* API 编写代码,调用 vscode.languages.* API b.Language Server Protocol 编写遵守 Language Server Protocol 的语言服务器。 **声明式语言特性(Declarative language features)** 来个例子:
![640.gif](https://ucc.alicdn.com/pic/developer-ecology/956a7002e90946ba9514b610c82d0778.gif)
当我们输入左符号的时候,会自动补全右符号。 当我们选中一段内容的时候,输入符号时会自动左右环绕上。 我们可以快速注释反注释。 我们通过配置文件来定义一些特性,一些可以做到的特性: 1. 代码高亮 2. Snippet 补全 3. 括号匹配 4. 括号自动闭合 5. 括号 auto surrounding 6. 注释/反注释 7. 缩进 8. 折叠 9. ... 稍微一提:列表中的某几点,VSCode 也给我们提供了编程配置的方式,用以定义更细致,精巧的操作, 接下来拿两点来大概介绍一下: **1、代码高亮** 比如说代码高亮: 代码高亮就是在展示代码的时候将不同的部分用不同的风格和颜色展示,比如注释和正常代码的颜色、风格不同,字符串和数字展示的颜色不同。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/9322b83088bf4c85ba8034496efbf905.png)
我们在下文会着重介绍一下代码高亮。 **2、语言配置** 比如我们提供一门语言的基础配置,这个文件控制着基本的编程特性,比如说注释反/注释,括号匹配/补全,折叠等。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/02f3dd256ed142a38daa6b171102df81.png)
配置也是比较基本的: 1.注释/反注释 可以声明行级和块级注释 2.括号定义 可以声明括号配对,在高亮括号,括号跳转等地方会用到。 3.符号自动关闭(auto closing) 当我们输入一个单个字符的时候,比如 ```'``` ,如果文本后面是空格,VSC 可以自动帮我们补全另一个 ```'```。我们可以定义这些字符。 4.符号自动环绕(auto surrounding) 当我们选中一段文本的时候,输入这个符号,VSC 会帮我们在选中文本前后输入这一对符号。 5.折叠 VSC 默认支持根据缩进的折叠,也可以支持定义的折叠标记。也可以通过 Language Server 返回相应的 textDocument/foldingRange 请求来折叠。 6.... 参考:https://code.visualstudio.com/api/language-extensions/language-configuration-guide **编程式语言特性(Programmatic language features)** 还有一些特性是我们需要编写程序来实现的,比如说自动补全,代码诊断,定义跳转;这个程序会分析你的代码然后给出各个功能的建议。 来个比较稀有的栗子:
![640 (1).gif](https://ucc.alicdn.com/pic/developer-ecology/b6b59c32990b4ea0aa51dd4129d4a5b4.gif)
``` const doSelectionRanges = (document: TextDocument, positions: Position[]): SelectionRange[] => { const getSelectionRange = (position: Position): SelectionRange => { // 代码不重要 } return positions.map(getSelectionRange); } vscode.languages.registerSelectionRangeProvider( { scheme: "file", language: "html", }, { provideSelectionRanges(document, positions, token) { return doSelectionRanges(document, positions); }, } ); ``` 我们通过调用 ```vscode.languages.registerSelectionRangeProvider``` 这个 API,传入条件(第一个参数)和具体执行代码的回调函数(第二个参数)。当用户在满足条件的文件中执行 selectionRange 的操作时,VSCode 会查找到我们传入的回调并执行,然后根据执行结果做出正确表现。 VSCode 提供了各种各样的 API,涵盖了编写代码的全流程,比如 VSCode 文档中列出的这些(其实并没有列完):
![image.png](https://ucc.alicdn.com/pic/developer-ecology/9de21dfe387443ef84e532feb6c0b0a8.png)
我们要做的就是实现这些 API 的具体功能(下文会讲一下实现各种能力时要用到的东西,比如解析 AST 等等....) **Language Server Protocol** **What is this** 大概介绍完了 languages.* API,我们来说一下 LSP(Language Server Protocol,语言服务协议),微软定义了一套 Language Server Protocol,语言服务协议,这个东西其实也是很简单的一个东西,就是将原来 VSC 里面的功能抽出来,在单独的一个程序中实现,实现编辑器、语言、语言服务的解耦合。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/daa3afe28e864cf68f9e653918ae7a4e.png)
原来我们在插件中做的逻辑,现在移到了一个专门的语言服务器中去做,插件负责接受 IDE 和语言服务的中转。 **历史故事**
![image.png](https://ucc.alicdn.com/pic/developer-ecology/755e6892a8274db5b2486be93332ebcf.png)
一个伟大的事物肯定是其来有自的,最开始,OmniSharp 这个 C# 的插件提出了语言服务器的概念,它采用了 HTTP 通信,使用 JSON 作为交换数据格式,并且成功的集成到了 VSCode 等多个编辑器中。大概在同一时间,为了能支持在多个 IDE 中使用插件,微软开始为 TypeScript 编写语言服务器,使用了 stdin/stdout 的通信方式,也使用了 JSON 作为交换数据格式。最后,TypeScript 的语言插件也成功的引入到了 Sublime 和 VSC 中。 VSCode 团队在引入了这两种不同的语言服务器后,决定要探索一种新的通用的语言服务协议,能让不同的 IDE 消费同样的语言服务。对于不同的 IDE 只需要实现一次协议就行,对于不同的语言也只要实现一次语言服务即可。 他们以 TypeScript 的语言服务器为蓝本,并且参考了 VSCode 的 languages API,提出了语言服务协议的概念,可以覆盖一般编程语言中的 **补全/诊断/定义/类型** 功能。 **How** Language Server Protocol 约定了语言服务的客户端和服务端使用 JSON-RPC 进行通信,一个请求格式就长这样: ``` Content-Length: ...\r\n \r\n { "jsonrpc": "2.0", "id": 1, "method": "textDocument/didOpen", "params": { ... } } ``` 其中的 method 就是用户触发的一次事件,比如还有: - textDocument/completion 从用户光标处补全代码 - textDocument/signatureHelp 从用户光标处获取函数签名信息 - ... 定义了各种接口格式,比如如何代表一个某个位置: ``` interface Position { /** * 位置在代码文件中的第几行 */ line: uinteger; /** * 代码在该行的第几个字符(指该字符之后的位置) */ character: uinteger; } ``` LSP 下的通信流程大概就长图里这样:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/0894b5dc030c4086b2222be773bf0664.png)
图源:https://microsoft.github.io/language-server-protocol/overviews/lsp/overview/
一般来说Client 与 IDE 层连接着,接受 IDE 层发出的用户事件,然后根据这个事件类型请求 Server 相应的接口,Server 接收到请求后分析用户的代码,然后将符合 Language Server Protocol 的响应返回给 Client,Client 接收到响应之后将信息返回给 IDE,IDE 再展示出相应规范的组件。 原来的 VSCode languages.* API 在 LSP 中大概都有实现。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/2027e2cce62d42bfaca49137ea2a6e6f.png)
**优势** 原来不同的编辑器对一门相同的语言要单独实现一份自己的代码,N 种编程语言,M 个 IDE,那就要写 ```N * M``` 份代码,现在有了一层中间的抽象,就只需要写 ```N + M``` 份代码就好。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/f05306fb4e4c4f09a2b66634f1e45e69.png)
图源:Language Server Extension Guide - VSCode (https://code.visualstudio.com/api/language-extensions/language-server-extension-guide)
比如说原来想在 VSCode 上写一个语言插件,那就只能用 Node.js 重新写一遍静态分析工具。而现在你可以用自身这门编程语言,可以很大程度上利用自身的生态。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/9fd8cd84e0bd44dba57b1e11356158bd.png)
图源:microsoft/vscode-docs - GitHub(https://github.com/microsoft/vscode-docs/blob/5112c1b325da0dd942e278329024c342038184b7/docs/extensions/images/overview/extensibility-architecture.png)
还有一个好处是语言服务器运行在一个单独的进程中,这也带来一些好处: 1. Language Server 属于 CPU 密集型程序,为了正确地验证一个文件,Language Server 需要解析大量的文件,为它们构建抽象语法树,并执行静态程序分析。在 VSCode 的插件进程模型中,每个窗口的所有插件共享一个 Extension Host,如果语言服务卡住的话,所有的插件都会受到影响。在独有进程中运行语言服务器可以避免与单进程模型相关的性能问题。 2. 我们可以在插件进程中随时重启语言服务进程,语言服务卡住了就重启单个进程。跟原来重启 VSC 相比带来了一些微小的使用体验优化。 由于各个编程语言上对 LSP 基本都有 Server 实现了,所以我们的编写体验还是和上面直接调用 VSCode API 差不多。 # 中场休息 接下来就是真正的干货部分了,会重点介绍语言服务的几点如何实现: 1.深入理解代码高亮 看完就能自己写一个主题插件了。 2.通过编程实现一些语言特性 看完就能自己动手写一个小玩具了。 3.值得介绍的其他的一些小特性 最后介绍一下大家对语言插件感兴趣的几个点。 # 代码高亮 VSCode 有两种方法进行代码高亮,语法和词法,词法高亮就是根据词法规则来定义高亮。而语法高亮允许我们编写程序来分析出 token,获得更精准的高亮显示。我们这里只讲词法高亮,在最后会简单提一下语法高亮。 VSCode 的代码高亮分为两步: 1.符号化(Tokenization):使用 TextMate language grammars(语言语法) 来进行词法解析。 TextMate language grammars 是由 TextMate Editor 采用的分词语法,然后被众多编辑器接受并支持,并且社区开发了各种语言的语法文件。 language grammars 用于为代码中的元素(如关键字、注释、字符串等)分配名称。这样做的目的是允许我们根据编写名称的选择器进行样式化(语法高亮展示)。 2.样式化(Theming):使用 scope selector(范围选择器) 来选取第一步分析出的特定元素,然后为它们指定样式。 上一步的 language grammars 只是用来为文档元素指定名称,这一步我们需要使用 scope selector 选择特定元素(就像使用 CSS 选择器选择 HTML 元素一样)。然后我们可以指定选定元素的字体样式、字体颜色等。 对于前端同学而言,~~理解这两步就像喝水一样简单~~,因为其实就是在 HTML 中这样: 1. 为 DOM 树的某一个节点标记 className。 2. 通过 CSS 选择器选定节点,然后为其配置样式文件。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/1fab3d0614f047a2bf30324c403436b5.png)
图中就很形象的展示了这两步的信息,```textmate scopes``` 中是词法分析出的该元素的名称,然后下面的 foreground 展示的是通过 ```entity.name``` 设置的一个样式:```{ "foreground": "#6F42C1" }```。 在 VSCode 中,输入命令 ```Developer: Inspect Editor Tokens and Scopes``` 即可唤起该调试页面。 **符号化(Tokenization)** 就是词法分析。通过编写 grammars 来为文档元素(比如关键字、注释、字符串等)分配一个作用域名称(scope),然后我们可以根据不同的名称进行样式化(设置颜色,字符样式)。 “as you walk through the text, look for this pattern and assign this scope.” 我们来展示一个简单的例子: ``` "keywords": { "patterns": [ { "name": "keyword.control.test", "match": "\\b(if|else|while|for|return)\\b" } ] }, ``` 这是一个最基本的 pattern,键为 ```keywords```,代表这条规则的名称;值的对象中有个 ```patterns```,我们要在里面写规则,规则里只有一个 match 字段,match 字段里是一个正则表达式,对于匹配到 match 规则的文本都标记为 name:```keyword.control.test```。 样式化后就是这种效果:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/ed7070137ed84913a1cce24d301836ae.png)
我们把为元素分配的这个 name 叫做**作用域(scope)**,我们在接下来的样式化环节可以使用**作用域选择器(scope selector)**来选择不同的元素。 scope 是一种用 ```.``` 来分级的文本结构,比如 ```a.b``` 和 ```a.b.c``` 是父子关系,使用选择器 ```a.b``` 可以选择到 ```a.b.c```。 从设计上来说你可以设置任意文字为 scope,如 aaa.bbb.ccc。但是从方便编写着色规则来说,官方还是**推荐**了一套命名约定:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/baf9e387fbad41d2a6ef30d4daa99998.png)
按照这个规则,我们可以把 C 语言中的双引号字符串的 scope 设为 ```string.quoted.double.c```,TypeScript 中的单引号字符串设置为 ```string.quoted.single.ts```,然后我们只需要设置一个选择器 ```string.quoted``` 的样式即可。 language grammars 还允许我们使用更多精准的语法规则,如: ``` "strings": { "name": "string.quoted.double.test", "begin": "\"", "end": "\"", "patterns": [ { "name": "constant.character.escape.test", "match": "\\\\." } ] } ``` 可以看到这里出现了两个正则表达式,```begin``` 和 ```end```,在被 ```begin``` 和 ```end``` 之间匹配的所有内容都被标记为 ```name```(也包括了 ```begin``` 和 ```end``` 本身的内容)。如果 ```begin``` 已经匹配了,没有匹配到 ```end```,则会将文本结束当成 ```end```。在这种模式下,还可以再设置开始和结束之间内容的子 pattern。 样式化效果如下:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/51717f39d2614ddbbd35be516471efeb.png)
此外还有一些其他的语法字段,这里简单列举一下: 1. ```contentName``` 与 name 差不多,但是只意味着 begin 和 end 之间的内容,开区间。 2. ```captures```, ```beginCaptures```, ```endCaptures``` ```captures``` 是一个对象,键代表了 match 的内容的组,值是赋值的 scope ``` "match": "(@selector\\()(.*?)(\\))", "captures": { "1": { "name": "storage.type.objc" }, "3": { "name": "storage.type.objc" } } ```
![image.png](https://ucc.alicdn.com/pic/developer-ecology/faa6b311ff6b47ea8cdfc98907daa0d9.png)
```beginCapture``` 和 ```endCapture``` 就是针对 begin 和 end 里的内容的分组捕获。 3.```include``` 允许引用语法中其他的规则,就可以做到递归分析、简化规则等。 完整的语法规则请见:https://macromates.com/manual/en/language_grammars 最后,在整个文本匹配完后,整个 scope 树也会构造完成,第二行构造出的 scope 树大概是:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/62671e2d69f047249c7fa68f127bb177.png)
更复杂的例子可能是这样:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/7e3e30821ceb45458667674ad397973a.png)
**样式化(Theming)** 在我们对代码进行好词法分析后,我们就可以设置相应 scope 的样式了。可以设置字符颜色,样式等。 这个其实就是主题插件来做的事情,根据配色,对常见的 scope 设置相应颜色。 我们可以通过 VSCode 提供的 ```editor.tokenColorCustomizations``` 来体验一下修改 token 颜色: 你可以通过命令 ```Developer: Inspect Editor Tokens and Scopes``` 来查看编辑器中某一个元素的 scope 信息,展示信息中的 textmate scopes 是按树的层级展示的,越前面的代表越深,越后面的代表层级更浅。当我们为一个 scope 设置着色后,具有该父作用域的所有 scope 都会被着色,如果有设置更具体的着色规则,则会被应用。 将 JSON 的 property 都设置为红色
![image.png](https://ucc.alicdn.com/pic/developer-ecology/b4b3ecbcf5ac41a9ad4bd585b7676005.png)
将 json 中的 string 都设置为粗体,粉色;由于 property 的颜色被其他更细致的选择器覆盖了,所以并没有展示粉色:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/59fad39c448c411985a3661337d4afd4.png)
可以看到,在写好 scope 选择器之后,就可以设置相应 scope 的样式了,可以设置前景色,字符样式(bold, italic, underline)。 ``` { "scope": "source.json string", "settings": { "foreground": "#FF0000", "fontStyle": "bold" } } ``` 这里简单说一下 scope selector 的几个基本规则: 1. scope selector 是前缀匹配的,```string``` 可以匹配到 ```string.quoted```, ```string.quoted``` 可以匹配到 ```string.quoted.double```。 2. 空的 scope selector 可以匹配到任意的 scope,但是优先级最低。 3. 子级选择器:以空格为分割,按照 scope 树的层级顺序描述。 4. 同级选择器:使用 ```,``` 分割。 5. 选择器排序规则:会使用最棒的(层级最深,描述最细致)一个匹配。 更详细的规则见:https://macromates.com/manual/en/scope_selectors - 延伸阅读:CSS Tricks - Creating a VS Code theme(https://css-tricks.com/creating-a-vs-code-theme/) **词法分析 VS 语法分析** 在 VSC 1.43 之后,我们也可以通过新 API(Semantic Token Provider)来编程定义语法高亮,更精准。 比如说:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/b24bfc9ca4c64c21ac6aabd02f1269bb.png)
可以看到语法分析的第 10 行的 ```langaugeModes``` 的颜色和参数声明的颜色是一样的了,而词法分析的结果却只是一个属性值。13 行的 ```getFoldingRanges``` 的颜色被设置成了函数而非属性,更 make sense。 要做语法分析,我们就需要解析代码的 AST 来获取每个 token 的准确意义,具体在 VSC 中的配置也与词法分析有所不同,可以参考文档:https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide # Snippet snippets 也是我们在使用 VSC 时会经常接触到的一个东西,也是一个加速代码开发的好东西。至少嘛,能让你打日志的时候比别人快一点。 举个例子:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/810361e74d1d4b3589943dbc1da4c28a.png)
snippet 也是通过编写配置文件来定义的,大概内容就是这样: ``` { "Print to console": { "prefix": "log", "body": [ "console.log('$1');", "$2" ], "description": "Log output to console" } } ``` - ```Print to console``` 是 snippet 的名字,会在详细信息中展示出来。 - ```prefix``` 是 触发的字符(trigger),可以是一个列表,也可以是一个字符串。用户输入的字符如果是 prefixes 的子串的话(Substring matching),这个 snippet 会被触发。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/e04db932cb85475fa8bf0b240bc70fef.png)
- ```body``` 里定义的就是要被写入的内容,可以使用 $0 $1 来进行光标占位,然后可以用 tab 切换位置。也可以使用 ${1:defaultText} 来设置默认文本。具体占位规则还有很多丰富的格式,可以在这查看。 参考:https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets # 编程式语言特性 本部分内容只放具体逻辑代码,所有代码见:https://github.com/lengthmin/vscode-npm-enhanced。 我们通过编写一个增强 npm package.json 的插件来展示几个 languages.* API 的用法。VSCode 的 API 之多,一篇文章放不下。 **需求** 1. 我们希望能在 package.json 中点击 dependencies 或者其他依赖声明中的项,就跳转到 node_modules 下相应的包的 package.json 中。 2. 我们希望点击版本号的时候能直接打开 npmjs.com。 3. 我们希望鼠标悬浮在 dependecies 上的时候有一些提示。 **实现** **1、跳转到依赖的 package.json 文件** 要实现第一点,我们可以使用 VSCode 提供的 documentLink 能力,该能力可以给代码文本中的某一段加上超链(链接可为网址,文件地址等),用户可点击这部分内容然后打开该链接。 vscode.languages.registerDocumentLinkProvider 的函数签名见: ``` registerDocumentLinkProvider(selector: DocumentSelector, provider: DocumentLinkProvider): Disposable ``` 传入的第一个参数是文档选择器(DocumentSelector),第二个参数 DocumentLinkProvider 可以认为是一个回调接口,我们通过实现该接口的方法(实现接口定义的返回值)。当 VSCode 打开了命中文档选择器的文档时,你编写的代码会被执行。 可以来看一下 DocumentLinkProvider 接口: ``` interface DocumentLinkProvider { provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult; resolveDocumentLink(link: T, token: CancellationToken): ProviderResult; } class DocumentLink { range: Range; target?: Uri; tooltip?: string; } ``` 我们一般只需要实现 provideDocumentLinks 方法即可,该方法返回一个 DocumentLink 数组,DocumentLink 记录了一处要标记为超链接的位置信息(range)和链接信息(target),VSCode 获取了这个信息后将它们渲染出来,然后用户就可以点击了。 首先,我们仅仅需要在 package.json 这个文件里进行 documentLink,我们的 DocumentSelector 可以写成: ``` vscode.languages.registerDocumentLinkProvider( { scheme: "file", pattern: "**/package.json", }, { provideDocumentLinks(document) { // ... }, } ); ``` 然后我们来补全 provideDocumentLinks 的逻辑: 我们需要获取该文件的内容,然后将内容解析为抽象语法树(Abstract Syntax Tree,AST),VSCode 提供了一个 vscode-json-languageservice 帮我们做这件事情。 在这里可以引申出一个问题: 『我们不可以通过 ```JSON.parse``` 来将文件内容转为一个对象,然后直接通过键来获取值以完成我的功能吗?』 确实,如果我们想实现一些简单的功能,不需要知道用户光标位置,不需要涉及到某个键值在文档中的位置的时候,可以直接通过解析文本成对象来完成相应功能。 但是如果我们想知道用户光标处的信息是什么,是一个键,还是一个值,它的值又是什么。单凭一个 JSON 对象我们很难获取光标处的整体内容。所以这个时候我们就需要 AST,AST 上的每个节点都代表 JSON 对象中的一块结构,会携带该结构的起止位置,类型等。 可以通过 https://astexplorer.net/ 来查看一下 ast 的例子,我们之后使用的 vscode-json-languageservice 生成的 AST 与例子不太一样,但大同小异。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/49c35b6bb8ae4a608824a81adc26c425.png)
一个抽象语法树的例子 接下来我们就要解析用户的 package.json 文件,```provideDocumentLink``` 有一个参数是 document,该参数有用户打开的文件 URI 地址,还封装了诸如 ```getText, positionAt``` 等方法方便使用。 ``` import { getLanguageService } from "vscode-json-languageservice"; const jsonLanguageService = getLanguageService({}); // ... provideDocumentLinks(document) { const result: vscode.DocumentLink[] = []; const jsonDocument = jsonLanguageService.parseJSONDocument(document); result.push( ...doDependencyLink(jsonDocument, document, "dependencies"), ...doDependencyLink(jsonDocument, document, "devDependencies") ); return result; }, // ... ``` 如代码所示,我们可以通过 ```getLanguageService({}).parseJSONDocument(document)``` 获取该 JSON 的 AST;我们需要遍历这棵抽象语法树,找到 dependencies 和 devDependencies 节点,然后获取该节点下每个依赖的名称、在文本中的起止位置,然后构造要跳转到的文件的 URI,我们就以最简单的方式来构造这个地址:当前 ```package.json``` 同级目录下的 ```node_modules``` 下的对应依赖的 ```package.json```。 这样我们就实现了将 package.json 文件中的所有依赖的名字都加上了超链接,用户点击某个依赖的名字时,跳转到 node_modules 下对应的包的 package.json 中。 比如说如何找到 dependencies 的节点: ``` const result: vscode.DocumentLink[] = []; const dependencies = jsonDocument.root?.children?.find((child) => { if (child.type === "property" && child.keyNode.value === field) { return true; } }); if (!dependencies) { return result; } ``` 通过遍历抽象语法树的第一层子节点,找到一个 property 节点且这个 property 节点的键为 dependencies。 ``` export interface PropertyASTNode extends BaseASTNode { readonly type: 'property'; readonly keyNode: StringASTNode; readonly valueNode?: ASTNode; readonly colonOffset?: number; } ``` 这是 VSCode 定义的 PropertyASTNode,顾名思义,就是一个键值对属性的 AST。其中 keyNode 就是键,valueNode 就是值。因为我们知道 dependencies 的值也是一个对象,所以我们要判断一下 valueNode 的类型是不是 ObjectASTNode: ``` export interface ObjectASTNode extends BaseASTNode { readonly type: 'object'; readonly properties: PropertyASTNode[]; } ``` 当我们拿到了 dependencies 的值并且它是一个 ObjectASTNode 的时候,我们就可以遍历它的 properties 属性,这个列表的每一项都是 PropertyASTNode,它的键就是我们需要的依赖名称,值就是该依赖的版本。 ``` const _valueNode = (dependencies as PropertyASTNode).valueNode; if (_valueNode?.type === "object") { _valueNode.properties.forEach((child) => { result.push({ range: new vscode.Range( document.positionAt(child.keyNode.offset), document.positionAt(child.keyNode.offset + child.keyNode.length) ), target: vscode.Uri.parse( path.join( document.uri.path, "..", "node_modules", child.keyNode.value, "package.json" ) ), }); }); } ``` 然后我们就生成每一项的键的位置范围(将键在文本中的偏移值转换成 DocumentLink 需要的 Position 类型,也就是转换成第 n 行,第 m 列的格式),根据依赖名称构造一个目标文件的地址,这样我们的第一个功能就完成啦~
![640 (2).gif](https://ucc.alicdn.com/pic/developer-ecology/34bae241a7e54106b340af127a3c0bbb.gif)
**2、跳转到 npmjs.com** 然后想实现第二个功能也非常简单,就是取 dependencies 的每一项的值节点,然后做一样的事情就可以了: ``` if (child.valueNode) { result.push({ range: new vscode.Range( document.positionAt(child.valueNode.offset), document.positionAt(child.valueNode.offset + child.valueNode.length) ), target: vscode.Uri.parse( `https://npmjs.com/package/${child.keyNode.value}` ), }); } ``` 获取 valueNode 的偏移、值,拼接成要打开 npm 网站的链接地址。 3、dependencies 悬浮提示 需求的第三点是 希望鼠标悬浮在 dependecies 上的时候有一些提示,这个功能可以通过 vscode.languages.registerHoverProvider 来实现: ``` vscode.languages.registerHoverProvider( { scheme: "file", pattern: "**/package.json" }, { async provideHover(document, position, token) { const jsonDoc = jsonLanguageService.parseJSONDocument(document); const node = jsonDoc.getNodeFromOffset(document.offsetAt(position)); let tmpNode = node?.parent; let depType = ""; if ( tmpNode && tmpNode.type === "property" && tmpNode.keyNode.type === "string" && (tmpNode.keyNode.value === "dependencies" || tmpNode.keyNode.value === "devDependencies") ) { depType = tmpNode.keyNode.value; } if (depType) { return new vscode.Hover( `当前位置是 ${depType}, value: ${node?.value}` ); } }, } ); ``` 我们注册一个 providerHover 的回调,当用户悬浮在某处时,我们能获取到用户的光标位置,然后调用 vscode-json-languageservice 来解析当前 JSON 文件的 AST,然后调用 JSONDocument.getNodeFromOffset 来获取指定位置的 AST 节点,该函数内部的实现就是递归查找数的子节点,然后找到某个节点,它的 offset 小于等于的我们传入的 offset。
![image.png](https://ucc.alicdn.com/pic/developer-ecology/d8b2d8549b17466883c92546e98ed086.png)
找到我们需要的节点后,就可以读取它的值,然后拼接成我们想展示的一句话即可。 我们以两个 vscode.languages.* 的 API 来介绍了 VSCode 的插件机制,对于其他的 API 你可以按照一样的流程来实现。 # Language Server Protocol 按照我们前面所讲的,Language Server Protocol 约定了一层中间抽象,在 LSP 的定义中,一个完整的 Language Server 分为了两部分:Client 和 Server: 顾名思义,Client 就是连接着 IDE/编辑器 的这部分,在 VSCode 中就是一个插件,该插件会处理各种 VSCode 的事件,然后向 Server 发送符合 LSP 的请求,在接收到响应后,再转为 IDE 的组件/动作等。 Server 就负责接收请求,做一些代码的静态分析,各种运算等,然后将符合 LSP 的响应返回即可。 一般在开发过程中,我们都会选择封装好的 client/server 的 SDK,使用 client SDK,它帮我们隐藏了与编辑器交互的细节,使用 server SDK,它隐藏了与 client 交互的细节,我们只需要专注于业务开发即可。 你可以在这(https://microsoft.github.io/language-server-protocol/implementors/sdks/)查看社区上的 SDK,微软官方维护了一份 Node.js 的 Language Server SDK:https://github.com/microsoft/vscode-languageserver-node。 这里我们简单演示一下 Node.js 的基于 vscode-languageclient 的 Client 端和基于 vscode-languageserver 的 Server 端的代码,以下代码来自 VSCode 插件示例: Client 端是一个 VSCode 插件,它会在自己被激活的时候新启动打包后的 Server 端的代码: ``` import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; let client: LanguageClient; export function activate(context: ExtensionContext) { let serverModule = context.asAbsolutePath( path.join('server', 'out', 'server.js') ); let serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc }, // ... }; let clientOptions: LanguageClientOptions = { // ... }; client = new LanguageClient( 'languageServerExample', 'Language Server Example', serverOptions, clientOptions ); client.start(); } ``` 我们需要设置 Client 端的一些配置,比如 encoding,输出,文件选择器等,还需要设置 Server 的启动方式、通信方式、通信配置等等等等,然后 Client 端就可以根据我们的配置连接上 Server 端了。 连接之后双方会交换一些初始信息,比如当前 Client 支持什么能力,Server 支持什么能力,初始化后整个语言插件就可用了。 我们再来看一下 Server 端, ``` import { createConnection, InitializeParams, } from 'vscode-languageserver/node'; const connection = createConnection(ProposedFeatures.all); connection.onInitialize((params: InitializeParams) => { // 读取客户端能力,返回自己的能力 const capabilities = params.capabilities; return { // ... }; } connection.onInitialized(() => { // 初始化完成 }); connection.onDidChangeConfiguration(change => { // 当客户端改变了配置 }); connection.onCompletion( (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => { // 客户端发起补全请求 } ); // ... // 在 connection 上挂上各种语言特性的回调 connection.listen(); ``` vscode-languageserver 帮我们封装了各种与客户端的交互,我们只需要建立一个连接,然后在其上加入各种语言特性的回调,最后调用一下 listen 监听请求,服务端也就启动成功了。 我们需要在这些回调里编写具体的代码完善各种特性。 # 也许是一些答疑解惑 **1、如何做到 HTML 中的补全?** 首先我们知道用户触发补全事件的位置,然后遍历 AST,找到用户光标位置所属的 AST Node(一般这一步需要你结合编译器等各种工具来分析),然后我们根据扫描结果,标记用户的状态,比如可能是 1. 输入标签名 2. 输入标签属性 3. 输入标签值 4. ... 我们根据这些不同的状态来进行不同的补全。 **2、多语言服务** 我们都知道 Vue 是一个结合了多种语言的语言服务,一个 .vue 文件中,我们需要实现 JS/TS/HTML/CSS 多种语言服务,所以我们需要抽象出一个中间层:
![image.png](https://ucc.alicdn.com/pic/developer-ecology/8d3b75d78dc649d2a9a491854fca5475.png)
该层知道用户请求当前打开的文件属于哪个语言服务,并且调用该语言服务的功能。 # 结语 我们大概介绍了一下 VSCode 给我们提供的语言能力以及各种能力该如何实现,也简单介绍了一些例子。 实现一门新语言的静态分析不是一件简单的事, VSCode 官方维护了很多的语言插件,都在 https://github.com/microsoft/vscode/tree/main/extensions 这个仓库中,你可以查看他们的源码来进一步学习。 # 参考链接 代码高亮部分的参考: 1.Syntax Highlight Guide | VSCode https://code.visualstudio.com/api/language-extensions/syntax-highlight-guide 2.Writing a TextMate Grammar: Some Lessons Learned https://www.apeth.com/nonblog/stories/textmatebundle.html 3.你不知道的 VSCode 代码高亮原理 - 范文杰 https://segmentfault.com/a/1190000040211606 4.Language Grammars | TextMate https://macromates.com/manual/en/language_grammars 编程式语言特性的参考: 1.Language Server Protocol Specification https://microsoft.github.io/language-server-protocol/specifications/specification-current/ 2.如何开发一款 VS Code 语言插件 — 以 vetur 为例 https://www.bilibili.com/video/BV1sh411z7Vq