随笔

VS Code New Language Support

如果你想自己自定义的语言被 Visual Studio Code 所支持,从最基础的语法高亮,到智能提示,跳转到定义和错误检查,那么就需要基于它的扩展机制实现对于新语言的支持。

本次主要实现目标有三块,第一部分是语法高亮,第二部分是智能提示,跳转定义,第三部分是一些更高级的用法,更好的性能和跨编辑器支持。

Syntax highlighting

Install

通过官方提供的脚手架可以快速生成一个基础代码。

npm install -g yo generator-code
yo code

根据不同的选择会生成不同的模板目录,可以通过多次生成来看看不同的结果,这些不同的目录其实可以合并到一起当作一个插件使用,下图中创建了一个名为 XX Support 的插件,这个插件支持扩展名为 .xx 的文件。

yo code generate

Screenshot 2023-08-21 at 16.42.19.png

......
-- syntaxes
|-- xx.tmLanguage.json
-- themes
|-- xx-color-theme.json
-- src
|-- extension.ts
......

Language Token Split

如果之前没接触过相关信息的话,肯定一直在疑惑怎么去解析语法的,其实答案很简单,就是用正则一行行去匹配,这里强调一行行是因为没办法一次匹配多行,只能对一行去做正则。

它使用 TextMate语法规则去进行匹配,TextMate 是一款在 macOS 系统内的编辑器。

直接来到 xx.tmLanguage.json,关注两块内容patternsrepository,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,capturesname 有一些约定的写法,当然不按约定来也可以,只不过用约定的 name 会自动加上特定的颜色,后面自定义颜色的时候会用到这个 name

match,begin,end 都是写正则的属性,区别是 match 匹配的就是当前行,beginend 是匹配代码区域用的,比如一个函数体开头的申明信息,还有最后的关闭符号。

captures 就是正则匹配捕获到的信息,比如一行可能有多个关键信息,通过匹配得到的位置信息,专门提出来给自定义颜色的时候用。

另外它是支持嵌套使用的,比如上面代码中 patterns 嵌套 patterns,场景类似于 JavaScript 函数中的内部函数。

除了语法,Visual Studio Code 提供了一个简便配置符号的文件 language-configuration.json,在里面可以配置常用的如 JavaScript 函数体的大括号,小括号自动关闭,代码折叠范围等特性。

Theming

这次生成的选择是 New Color Theme。

Screenshot 2023-08-21 at 17.13.16.png

{
    "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"
  }
]

Select theme

最终效果如图所示,每个部分都分配到了自己的颜色

example

Debug

前端开发中有开发者工具可以看到节点的各种属性,这里当然也能看到当前节点的 scope。

inspect editor tokens and scopes

inspect example

Intellisense

第二部分主要依靠 Visual Studio Code 提供的 API 来实现智能提示,跳转到定义,错误检查等功能,这部分其实也可以在第三部分的语言服务器中做,但使用 API 会更简便些。

new Extension

VS Code API

VS Code API List

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}]')

SnippetString

最开始以为点击跳转定义需要转成类似于 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

经过前面的两个部分,可以发现我们的功能基本是满足的,为什么我们还需要用到语言服务器?

  1. 资源的占用,当计算需要占用资源时,会使界面卡住,而语言服务器是一个独立的进程
  2. 如果需要为多种编辑器提供新语言支持,只需要一套代码就可以使用

“一次编写到处运行”,这句话都要被用烂了,可想而知肯定是实现了一套协议,这个协议叫做LSP (Language Server Protocol),比如 jetbrains 的 IDE 都支持 LSP。

lsp-languages-editors.png

实现一个语言服务器需要两部分,一部分是桥梁连接编辑器和你的服务部分,服务部分就是你基于 LSP 实现的服务代码部分,两者之间通过 IPC 进行通信。

lsp-illustration.png

实现可以参考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
  }
]);

coexist.png

可以尝试将 resolve 放在一个延时函数中,这样可以更加直观的看到放在语言服务器中的好处。

References

本文链接:https://note.lilonghe.net//post/visual-studio-code-new-language-support.html

-- EOF --