Z

monaco-editor指南-自定义语言高亮

monaco支持目前绝大部分的语言高亮,但只是高亮,不涉及任何语法提示或补全,只有前端系的语言才支持语法提示与补全,就像VS Code那样。在大多数情况下,monaco支持的语言高亮已经可以满足基本需求了,但如果你有一门特殊的语言,那就需要自己实现高亮。

在语法高亮前,首先想象如果没有monaco,你自己要对一段语言字符串做高亮,该怎么做?语法高亮,实际上高亮的是字符串中的token,例如let x = 12;中包含letx=12;这5个token,他们属于不同的类型,例如keyword、identifier、symbol、number,所以只要对这几个类型预定义高亮样式,tokenizer过程中将字符串切割为不同类型的token并附加对应样式即可。

所以语法高亮,实际上是tokenizer的过程。最简单的办法是直接根据空白字符切割字符串进行tokenizer,但缺点是不直观,可维护性低,并且可能要处理很多语言导致的歧义。monaco并没有使用这种办法,而是使用Monarch进行tokenizer过程的描述,它是声明式的tokenizer过程,比直接切割字符串更加直观。

Monarch文档页面,可以看到monaco是怎么定义语言的,Monarch配置是一个JSON对象,里面配置了tokenizer的规则,接着我们用它来实现自己的语言高亮。

 1import { languages } from "monaco-editor";
 2
 3languages.register({
 4    id: "test-lang",
 5});
 6
 7interface ITestLanguage extends languages.IMonarchLanguage {
 8    keywords: string[];
 9    typeKeywords: string[];
10}
11
12const languageConfig: ITestLanguage = {
13    defaultToken: "",
14    ignoreCase: false,
15
16    typeKeywords: ["int", "char", "float", "bool"],
17    keywords: ["if", "else", "function"],
18
19    brackets: [
20        { open: "{", close: "}", token: "delimiter.curly" },
21        { open: "[", close: "]", token: "delimiter.square" },
22        { open: "(", close: ")", token: "delimiter.parenthesis" },
23    ],
24
25    tokenizer: {
26        root: [
27            [/\d+/, "number"],
28
29            [/[{}()\[\]]/, "@brackets"],
30
31            [/[;,.]/, "delimiter"],
32
33            [
34                /[a-z_$][\w$]*/,
35                {
36                    cases: {
37                        "@typeKeywords": "typeKeywords",
38                        "@keywords": "keywords",
39                        "@default": "identifier",
40                    },
41                },
42            ],
43
44            { include: "@comment" },
45        ],
46
47        comment: [
48            [/\/\/.*$/, "comment"],
49            [
50                /\/\*/,
51                {
52                    token: "comment",
53                    log: "$# comment push",
54                },
55                "@comment",
56            ],
57            [
58                /[^\/*]/,
59                {
60                    token: "comment.content",
61                    log: "$# in comment",
62                },
63            ],
64            [
65                /\*\//,
66                {
67                    token: "comment",
68                    log: "$# comment pop",
69                },
70                "@pop",
71            ],
72        ],
73    },
74};
75
76languages.setMonarchTokensProvider("test-lang", languageConfig);
77
78console.log(languages.setMonarchTokensProvider);

这是一个简单的tokenizer,它包含了字符串,数字,括号,注释的匹配。下面来详细解读一下它。首先是两个属性配置defaultToken与ignoreCase,用于配置默认token为空,并且是大小写敏感的。

接着是两个特殊字符串枚举,表示当遇到数组中某个字符时,当前token为这个key。例如遇到了int,那么他就是一个typeKeywords,在主题设置中配置了该类型,就能对int进行高亮显示了。但只配置了这个还不够,特殊字符串枚举必须通过@include配置在tokenizer中,具体配置稍后再看。

接着是brackets,用于配置括号,并标记其类型。这里支持了圆括号、方括号、花括号三种类型,并为其配置了不同了token class。

下面就到了tokenizer的核心:tokenizer属性,它是tokenizer过程的具体配置,上面的只是预定义了一些常量规则,并没有实际配置起来,只有在tokenizer属性中配置了,才会真正发生作用。

tokenizer的类型是[name: string]: IMonarchLanguageRule[],其中IMonarchLanguageRule是一个联合类型,由三种子类型组成:

  • IShortMonarchLanguageRule1
  • IShortMonarchLanguageRule2
  • IExpandedMonarchLanguageRule

这三种也只是类型别名,具体类型分别是:

  • [RegExp, IMonarchLanguageAction]
  • [RegExp, IMonarchLanguageAction, string]
  • {regex?: string | RegExp, action?: IMonarchLanguageAction, include?: string}

其中IMonarchLanguageAction也是联合类型,其子类型分别为:

  • IShortMonarchLanguageAction
  • IExpandedMonarchLanguageAction
  • IShortMonarchLanguageAction[]
  • IExpandedMonarchLanguageAction[]

虽然类型多,但核心配置思路只有一个,就是定义匹配该类型的正则表达式,定义该token的class,如果该类型还有进一步的状态,则配置它进入的下一个状态。

那么IShortMonarchLanguageRule1只是IShortMonarchLanguageRule2的简写形式,只是忽略的下一个状态配置,IShortMonarchLanguageRule2也只是IExpandedMonarchLanguageRule的简写形式,IMonarchLanguageAction同理。最简单的情况下你可以将IMonarchLanguageAction写为字符串,表示该token的class,如果还有更加详细的配置,则是IExpandedMonarchLanguageAction。

接着我们回到tokenizer配置中,monaco会根据tokenizer配置从上到下依次对正则进行匹配,并执行第一个匹配到的正则对应的规则。注意,这一点非常重要,这意味着你写的规则顺序也会影响最终的匹配结果。

当匹配到/\d+/时,表示该token是一个number,匹配到/[{}()\[\]]/时,@开头表示引用一个规则,这里我们直接使用前面配置好的brackets,下面的/[;,.]/同理。

当匹配到/[a-z_$][\w$]*/时,这种情况可能有多种规则,他可能是一个keywords、typeKeywords或者identifier,那么我们可以用case来做条件匹配,当匹配到的结果在keywords中时(使用@keywords引用前面定义过的),表示它是一个keywords,@default表示case的默认情况,当@typeKeywords与@keywords均未匹配到时,该token处于默认情况,即identifier。

{ include: "@comment" }表示直接引用下面comment状态作为规则,该形式是为了组织定义规则,在monaco编译规则阶段时,它会被提前展开,不会对性能有影响。

comment是一个较复杂的状态,comment不仅包含单行注释,还包含多行注释,并且多行注释/**/之间的所有字符均属于注释内容,这就需要将规则配置为有状态的。

数组的第一行用于匹配单行注释,这个很简单。接着是/\/\*/,当匹配到它时,则进入comment状态,也就是@comment表示的。这里/\/\*/规则的第二个元素不是一个字符串了,而是一个对象,其实字符串就是该对象的简写形式,当对象只配置了token时,则可以直接写成字符串,这里我们增加了log属性,monaco在匹配到该规则时会在控制台打印"$# in comment",其中$#表示该规则匹配到的内容。

进入到comment状态后,遇到的非/\*字符均为comment,这条规则确保了/**/之间的所有字符均为注释。

最后遇到/\*\//时,规则的下一个状态是@pop,表示pop出当前状态,也就是从comment状态中退出。

现在我们已经实现了一个简单的语言高亮配置了,当然实际情况可能比这复杂得多,比如数字要支持16进制,更多关键字配置,字符串配置,我们可参照Monarch示例中的语言定义进行配置。

语言高亮只定义token切割是不够的,还需要定义token class对应的样式,下面是根据上面配置简单配置的语言:

 1import { editor } from "monaco-editor";
 2
 3const themeConfig: editor.IStandaloneThemeData = {
 4    colors: {},
 5    base: "vs",
 6    inherit: false,
 7    rules: [
 8        {
 9            token: "delimiter.curly",
10            foreground: "#68217a",
11        },
12        {
13            token: "delimiter.square",
14            foreground: "#008800",
15        },
16        {
17            token: "delimiter.parenthesis",
18            foreground: "#ff0000",
19        },
20        {
21            token: "number",
22            foreground: "#2ecc71",
23            fontStyle: "italic",
24        },
25        {
26            token: "comment",
27            foreground: "#adadad",
28            fontStyle: "italic",
29        },
30        {
31            token: "typeKeywords",
32            foreground: "#e67e22",
33        },
34        {
35            token: "keywords",
36            foreground: "#2980b9",
37        },
38        {
39            token: "identifier",
40            foreground: "#8e44ad",
41        },
42        {
43            token: "comment.content",
44            foreground: "#ff0000",
45        },
46    ],
47};
48
49editor.defineTheme("test-theme", themeConfig);

最后一步,在editor.create中配置语言我们定义的test-lang及主题:

1const editorInstance = editor.create(ele, {
2    ...,
3    language: "test-lang",
4    theme: "test-theme",
5});

最终运行起来就是这个效果