如果你想自己自定义的语言被 Visual Studio Code 所支持,从最基础的语法高亮,到智能提示,跳转到定义和错误检查,那么就需要基于它的扩展机制实现对于新语言的支持。
本次主要实现目标有三块,第一部分是语法高亮,第二部分是智能提示,跳转定义,第三部分是一些更高级的用法,更好的性能和跨编辑器支持。
Syntax highlighting
Install
通过官方提供的脚手架可以快速生成一个基础代码。
npm install -g yo generator-code
yo code
根据不同的选择会生成不同的模板目录,可以通过多次生成来看看不同的结果,这些不同的目录其实可以合并到一起当作一个插件使用,下图中创建了一个名为 XX Support
的插件,这个插件支持扩展名为 .xx
的文件。
......
-- syntaxes
|-- xx.tmLanguage.json
-- themes
|-- xx-color-theme.json
-- src
|-- extension.ts
......
Language Token Split
如果之前没接触过相关信息的话,肯定一直在疑惑怎么去解析语法的,其实答案很简单,就是用正则一行行去匹配,这里强调一行行是因为没办法一次匹配多行,只能对一行去做正则。
它使用 TextMate 的语法规则去进行匹配,TextMate 是一款在 macOS 系统内的编辑器。
直接来到 xx.tmLanguage.json
,关注两块内容patterns
和repository
,patterns 里面就是用到的所有匹配规则,repository 是存储规则的地方,通过 include
来引入到执行规则中。
这里是用 yaml 写的,因为 json 写正则要写很多转义符号很不方便,所以用 yaml 写然后转换成 json。
patterns:
- include: '#comments'
- include: '#entity'
repository:
comment-block:
begin: /\*
captures:
'0':
name: definition.comment.irmodel
end: \*/
name: comment.block.irmodel
comments:
patterns:
- name: comment.irmodel
match: ([/][/].*)
- include: '#comment-block'
entity:
patterns:
- name: entity.irmodel
begin: ^(\w+)\b(?=.*)
beginCaptures:
'1':
name: entity.name.irmodel
end: '\)'
patterns:
- include: '#declare'
- include: '#keywords'
- include: '#comments'
- name: entity.field.name
match: \b(\w+)[:]
注意代码中的关键词name
,match
,begin
,end
,captures
,name
有一些约定的写法,当然不按约定来也可以,只不过用约定的 name
会自动加上特定的颜色,后面自定义颜色的时候会用到这个 name
。
match
,begin
,end
都是写正则的属性,区别是 match
匹配的就是当前行,begin
和 end
是匹配代码区域用的,比如一个函数体开头的申明信息,还有最后的关闭符号。
captures
就是正则匹配捕获到的信息,比如一行可能有多个关键信息,通过匹配得到的位置信息,专门提出来给自定义颜色的时候用。
另外它是支持嵌套使用的,比如上面代码中 patterns
嵌套 patterns
,场景类似于 JavaScript 函数中的内部函数。
除了语法,Visual Studio Code 提供了一个简便配置符号的文件 language-configuration.json
,在里面可以配置常用的如 JavaScript 函数体的大括号,小括号自动关闭,代码折叠范围等特性。
Theming
这次生成的选择是 New Color Theme。
{
"name": "Comment",
"scope": [
"comment",
"comment.block"
],
"settings": {
"fontStyle": "italic",
"foreground": "#546E7A"
}
},
如上面代码 scope 中的数组放的就是前面所提到的 name
,通过不同的 name
给不同的语法提供颜色定义,如果想提供多套不同的颜色可以在 package.json 里面配置。
"themes": [
{
"label": "irmodel",
"uiTheme": "vs-dark",
"path": "./themes/irmodel-color-theme.json"
},
{
"label": "irmodel-light",
"uiTheme": "vs",
"path": "./themes/irmodel--color-theme.json"
}
]
最终效果如图所示,每个部分都分配到了自己的颜色
Debug
前端开发中有开发者工具可以看到节点的各种属性,这里当然也能看到当前节点的 scope。
Intellisense
第二部分主要依靠 Visual Studio Code 提供的 API 来实现智能提示,跳转到定义,错误检查等功能,这部分其实也可以在第三部分的语言服务器中做,但使用 API 会更简便些。
VS Code API
Visual Studio Code 提供了很多 API,基于想要实现的功能去注册相应的 API 即可,接下来会实现三个例子:Hover 提示,智能提示,点击跳转到定义。
implement
export function activate(context: vscode.ExtensionContext) {
registerHover();
registerBasicTypeCompletion();
registerEntityDefinition()
}
所有的代码都在 active 钩子函数里面去注册,基本上每个注册的回调函数都会传递给你两个参数,一个是当前文档,一个是光标所在的位置。
vscode.languages.registerHoverProvider('xx-support', {
provideHover(document, position, token) {
const range = document.getWordRangeAtPosition(position);
const text = document.getText(range);
......
return { contents: [hoverText] }
}
})
这里还是还第一部分一样,需要你手动去分词匹配语法规则,比如这里拿到当前鼠标悬浮的单词,需要你自行判断这个单词所处的环境(是否在正确的位置,是否是注释内)。
vscode.languages.registerCompletionItemProvider(
'xx-support',
{
provideCompletionItems: (document, position) => {
const range = new vscode.Range(
new vscode.Position(position.line, 0),
position
);
const text = document.getText(range);
const isEntity = getContextName(document, position);
let completionItemList: vscode.CompletionItem[] = [];
if (isEntity && basicTypeConfig.pattern.test(text)) {
completionItemList = completionItemList.concat(basicTypeConfig.list);
}
return completionItemList;
},
},
...completionTriggers
);
智能提示和鼠标悬浮基本一样,根据当前单词去匹配相应的数据返回供用户选择,上图演示的是在一个实体内部描述字段类型时候进行提示。
有些提示场景可能会需要改变鼠标位置,或者给占位提示,可以使用如下代码:
new vscode.SnippetString('[${1:entityName}]')
最开始以为点击跳转定义需要转成类似于 AST 的语法树才能做到,看了下文档发现还是基于手动去分词匹配实现的,全都是纯文本操作。
使用 exec 去匹配,然后逐行判断匹配到的字符是否满足场景
while ((match = regex.exec(document.getText())) !== null) {
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
if (match[0] === symbol) {
const position = document.positionAt(match.index);
if (!checkIsInComment(document, position)) {
definitionLinks.push(getLocationLink(document, position));
}
}
}
找到匹配的定义位置,然后拿到定义的 range,这样点击跳转过去的时候,那个 range 所在的区域会高亮一小会儿来发出提示。
export function getLocationLink(document: vscode.TextDocument, defPosition: vscode.Position) {
const fileUri: vscode.Uri = vscode.Uri.file(document.fileName);
const defRange = document.getWordRangeAtPosition(defPosition)
return new vscode.Location(fileUri, defRange || defPosition)
}
截止目前,一个具有语法高亮,智能提示,可以点击跳转到定义的新的语言支持插件就完成了,接下来就是打包发布到市场上来让每个人都可以安装。
Bundle & Publish
npm install -g @vscode/vsce
vsce bundle
打包完成后,前往 Visual Studio Code 的插件市场登录并创建自己的插件,创建好后继续到命令行(或者你也可以在网站上手动上传也可以)。
vsce login <publisher id>
vsce publish
Language Server
经过前面的两个部分,可以发现我们的功能基本是满足的,为什么我们还需要用到语言服务器?
- 资源的占用,当计算需要占用资源时,会使界面卡住,而语言服务器是一个独立的进程
- 如果需要为多种编辑器提供新语言支持,只需要一套代码就可以使用
“一次编写到处运行”,这句话都要被用烂了,可想而知肯定是实现了一套协议,这个协议叫做LSP (Language Server Protocol)
,比如 jetbrains 的 IDE 都支持 LSP。
实现一个语言服务器需要两部分,一部分是桥梁连接编辑器和你的服务部分,服务部分就是你基于 LSP 实现的服务代码部分,两者之间通过 IPC 进行通信。
实现可以参考Language Server Extension Guide,我们这里直接进入应用部分,即实现之前用 languages.* API 实现的功能。
// 智能提示
connection.onCompletion(
async ({ position, textDocument }: TextDocumentPositionParams): Promise<CompletionItem[]> => {
const document = documents.get(textDocument.uri);
const completionList: CompletionItem[] = [];
// ......
// return completionList
return new Promise((resolve) => {
resolve(completionList)
})
});
// 跳转定义
connection.onDefinition(({ textDocument, position }: DefinitionParams): DefinitionLink[] => {
const document = documents.get(textDocument.uri);
const definitionList: DefinitionLink[] = [];
// ......
return definitionList
})
可以看到跟之前的差别其实不是很大,只不过其中的类型都要从 vscode-languageserver
中导入,而不是从 vscode
包导入使用,另外就是可以异步返回数据,不会阻塞主线程逻辑。
另外,languages.* 和语言服务器两者是可以共存的,比如稍微改动下上面智能提示的返回代码。
resolve([
{
label: 'TypeScript',
kind: CompletionItemKind.Text,
data: 1
},
{
label: 'JavaScript',
kind: CompletionItemKind.Text,
data: 2
}
]);
可以尝试将 resolve 放在一个延时函数中,这样可以更加直观的看到放在语言服务器中的好处。