闲谈Monaco Editor-自定义语言之Monarch

6,190

什么是Monarch

Monarch 是 Monaco Editor 自带的一个语法高亮库,通过它,我们可以用类似 Json 的语法来实现自定义语言语法高亮功能。本文将通过编写一个简单的自定义日志语言(下文简称 log )来介绍 Monarch 的使用。

开始

初始化

首先,我们需要在 monaco 里注册一下我们的 log 语言。

monaco.languages.register({ id: 'log' });

很简单,我们只需要传入语言的 id 即可,但是,现在这个语言除了有个名字,还空空如也,所以,接下来,我们就要开始给 log 语言加上我们的语法高亮功能。

monaco.languages.setMonarchTokensProvider('log', monarchObj);

monaco 提供了setMonarchTokensProvider函数来让我定义语言的高亮功能,而monarchObj就是我们所需要填写的 Monarch 所规定的 Json 内容。

Monarch

Monarch 由一系列 Json 键值对组成,他有许多属性,其中最重要的就是 tokenizer 属性,我们描述语法的代码就写在这里面。先来看一个简单的例子:

monaco.languages.setMonarchTokensProvider('log', {
	tokenizer: {
		root:[
			[/\d+/,{token:"keyword"}],
			[/[a-z]+/,{token:"string"}]
		],
	}
});

我们在 tokenizer 中定义了一个 root 属性,roottokenizer 中的一个 state , 这就是我们用来编写解析规则(rule)的地方,在 rule 中,我们可以编写匹配文本的正则表达式,然后再给匹配到的文本设置一个执行动作的 action ,在 action 中,我们可以给匹配到的文本设置 token class

在我们的例子中,我们在 root 中设置了两个 rule ,分别用来匹配数字字母,匹配成功后就接着执行对应的 action ,最后在 action 中,我们设置了匹配文本的 token class :keywordstring。最终效果如图:

这里有些人就会有疑问,token class 是 css class 吗?本质上,token class 其实就是设置 css 中的 class ,不过,keyword != .keyword ,Monarch 中会有一层对应关系,keyword 对应着 css 中的 .mtk8,而 string 对应着 css 中的 .mtk5。Monarch 中内置了以下几种 token class:

identifier         entity           constructor
operators          tag              namespace
keyword            info-token       type
string             warn-token       predefined
string.escape      error-token      invalid
comment            debug-token
comment.doc        regexp
constant           attribute

delimiter .[curly,square,parenthesis,angle,array,bracket]
number    .[hex,octal,binary,float]
variable  .[name,value]
meta      .[content]

不过上面的高亮代码还存在一点问题

我们发现大写没有识别出来,这时,我们可以再给完善以下匹配字符串的 rule 正则表达式。

tokenizer: {
	root:[
	[/\d+/,{token:"keyword"}],
	[/[a-zA-Z]+/,{token:"string"}]
	],
}

假如我们的语言是忽略大小写的,那么,我们可以直接添加一条 ignoreCase 属性。

monaco.languages.setMonarchTokensProvider('log', {
    ignoreCase: true,
    tokenizer: {
	    root:[
            [/\d+/, {token: "keyword"}],
            [/[a-z]+/, {token: "string"}]
	    ],
    }
});

最终效果如下:

了解了 Monarch 的基本结构,下面,我们就开始正式编写 log 语言。

log 语言

我们要实现的 log 语言主要是用来区分显示不同类型的日志,大体效果如下:

我们以 [error], [info], [warning] 作为一行的开头,从而代表日志的级别。如图所示, error 后的日志将全部为红色,直到遇到下一个日志级别。

标记日志级别

首先,我们来标记一下[error][info]这些日志级别的显示。

tokenizer: {
	root: [
		[/^\[error\]/, { token: "custom-error" }],
		[/^\[info\]/, { token: "custom-info" }],
		[/^\[warning\]/, { token: "custom-warning" }]
	]
}
//设置含有custom-error等token class的主题
monaco.editor.defineTheme('logTheme', {
    base: 'vs',
    inherit: true,
    rules: [
        { token: 'custom-info', foreground: '808080' },
        { token: 'custom-error', foreground: 'ff0000', fontStyle: 'bold' },
        { token: 'custom-warning', foreground: 'FFA500' }
    ]
});
monaco.editor.create(document.getElementById("container"), {
    theme: 'logTheme',
    value: getCode(),
    language: 'log'
});

我们写了三条 rule ,分别将 [error] 标记为 custom-error[info] 标记为 custom-info[warning] 标记为 custom-warning 。我们发现,这些 rule 都是类似的,所以,我们可以想办法把他们合在一起。

tokenizer: {
	root: [
		[/^\[(\w+)\]/, { token: "custom-$1" }]
	]
}

这里我们用到了一个美元符号 $,它代表取正则表达式第几个匹配项,$0代表取所有的匹配项(例:[error]),$1 代表取第一个匹配项(例:error)。上述代码将日志类型作为参数传入了 token class ,与 custom- 做拼接,从而组成了最终的 token class,例如 custom-error

不过,还有一个小问题,那就是除了errorinfowarning这三个日志类型,其余的 [debug][test] 也会被匹配进去。这时候,我们需要引入一个新的工具:cases

{ cases: { guard1: action1, ..., guardN: actionN } }

cases 和普通的 if ,else if 语法一样,可以写多个判断条件(guard),然后根据不同 guard 去执行对应的 action

guard 和正则表达式类似,功能是用来匹配文本,当他不以 @$ 开头时,他就是一个普通的正则表达式,不过,当他以 @$ 开头时,他才是一个真正意义上的 guard 。

guard 有固定的结构 [pat][op]matchpat 代表匹配的文本op 代表一个比较符match 则是要比较的内容

pat$ 开头,和我们上文正则表达式使用的 $1 含义是一样的,不过这边 $# 代表全部匹配文本,而正则表达式是使用 $0 代表全部匹配文本。另外,我们还可以用 $Sn 来获取当前 state的名字,例如在 root state 下 $S0 就代表 root

opmatch 稍微复杂点,可以是这几个内容

  • ~regex or !~regex :匹配/不匹配一个正则
  • @attribute or !@attribute :匹配/不匹配一个属性,属性定义在 Monarch 的根层级下,可以是数组、字符串、正则。
  • ==str or !=str :匹配/不匹配一个字符串
  • @default :匹配默认情况
  • @eos : 一行结束,则匹配成功

有了这些工具,我们可以接着写我们的高亮代码

{
    keywords: ['error', 'info', 'warning'],
    tokenizer: {
        root: [
            [/^\[(\w+)\]/, {
                cases: {
                    "$1@keywords": { token: 'custom-$1' },
                    "@default": { token: "string" }
                }
            }]
        ]
    }
}

这里,我们用到了 $1@keywords 来判断日志类型($1) 是否存在于 keywords 数组中,还用到了 @default 来匹配未定义的日志类型。最终效果如下:

到了这里,我们终于完成了日志类型的高亮,接下来,就可以开始处理日志了

标记日志

tokenizer: {
        root: [
            [/^\[(\w+)\]/, {
                cases: {
                    "$1@keywords": {token:'custom-$1', next:"@text.$1"},
                    "@default":'string'
                }
            }],
        ],
        text:[
            [/^\[(\w+)\]/,{token:"@rematch",next:"@pop"}],
            [/.*/,{token:"custom-$S2"}]
        ]
}

这里第一次出现了 next: "@text.$1",意思是由当前 root state 跳入 text state,并且把 root state 放入 tokenizer 栈中,在 text state 中,我们又可以通过 next:@pop 回到栈的第一个 state 中,也就是我们的 root state

这里还有一个 @rematch,意思是,匹配到了当前文本,但是,不做任何操作,让后续的 rule 再匹配一次。

总结起来,上述代码的逻辑就是匹配到日志类型之后,我们携带着日志类型($1) 进入到了 text state ,在 text state 中,我们将后续文本(.*) 都标记成和 日志类型相同的 token class ,然后在遇到日志类型标记之后,利用 @rematch@pop 重新回到 root state 再次执行匹配。效果如下:

再进一步,我们可以简化一下代码

{
    keywords: ['error', 'warning', 'info'],
    header: /\[(\w+)\]/,
    tokenizer: {
        root: [
            [/^@header/, {
                cases: {
                    "$1@keywords": { token: 'custom-$1', next: "@text.$1" },
                    "@default": 'string'
                }
            }],
        ],
        text: [
            [/^@header/, { token: "@rematch", next: "@pop" }],
            [/.*/, { token: "custom-$S2" }]
        ]
    }
}

我们将匹配日志类型的正则表达式提取为一个单独的 header ,然后通过 @ 来嵌入。但是这里的 @guard@ 不同,他只支持正则表达式,而不支持数组类型

结尾

本文介绍了 Monarch 的基本概念和使用方法,不过篇幅有限,本文无法介绍其他 Monarch 提供的功能,例如括号匹配,语言嵌入等,也还有许多细节点未列出,同学们如果有兴趣想深入研究,可以阅读官方文档与示例