如下代码,在 monaco 上注册 mylang 语言,并设置词法分析规则。
js
// 注册语言
monaco.languages.register({ id: 'myLang' });
// 设置词法分析规则
monaco.languages.setMonarchTokensProvider('myLang', {
tokenizer: {}
});
// 颜色主题设置
monaco.editor.defineTheme('logTheme', {});
其中 tokenizer 对象就是用来描述一个语言定义的 JSON 对象。
Monaco Editor 的 Tokenizer(分词器) 是实现代码高亮的核心组件。它通过 Monarch 库(一个基于 JSON 的声明式语法)将源代码文本拆分为一系列带有"类型标签"的 Token(记号),随后编辑器根据主题(Theme)为这些标签涂上不同的颜色。
下面我们就逐步来了解下这个规范。
词法分析简单原理
Monaco 的 Tokenizer 是一个状态机 。它逐行扫描代码,并始终处于某个特定的"状态"中(默认初始状态为 root)。其核心概念有:
-
状态(States) :解析器始终处于某个特定状态(初始为
root)。 -
正则匹配(Regex Matching) :在当前状态下,按顺序用正则表达式匹配当前行。
-
动作(Actions) :匹配成功后执行操作,如赋予 Token 类型、切换状态(
next)或推入状态栈(push)。
词法分析简单教程
初级:基础匹配(线性规则)
这是最简单的形态,通过正则表达式直接给匹配到的文字打上"标签"(Token Type)。
此时 Tokenizer 像一个简单的过滤器,扫描到哪算哪,没有"上下文"概念。
js
monaco.languages.setMonarchTokensProvider('myLang', {
tokenizer: {
root: [
[/\b(if|else|return)\b/, "keyword"], // 匹配关键字
[/[0-9]+/, "number"], // 匹配数字
[/[+-*/]/, "operator"], // 匹配运算符
[/[a-zA-Z_]\w*/, "identifier"], // 匹配变量名
]
}
});
中级:状态切换(处理字符串)
简单的正则无法处理跨越多个字符的结构(如引号包围的内容)。我们需要引入 状态(States) 。
如下示例:识别被双引号包围的字符串,且支持转义字符 "。
js
monaco.languages.setMonarchTokensProvider('myLang', {
tokenizer: {
root: [
[/"/, 'string.quote', '@string'], // 匹配到 " 时,标为 string.quote 并进入 string 状态
],
string: [
[/\./, 'string.escape'], // 匹配转义字符 如 "
[/[^"]/, 'string'], // 匹配非 " 字符
[/"/, 'string.quote', '@pop'] // / 遇到闭合引号,退出状态回到 root
]
},
});
高级:嵌套与栈操作(多行注释与嵌套结构)
识别多行注释 /* ... */,且支持注释嵌套如 /* a /* b */ c */。
js
monaco.languages.setMonarchTokensProvider('myLang', {
tokenizer: {
root: [
[//*/, 'comment', '@comment'], // 匹配到 /* 进入注释状态
],
comment: [
[/[^/*]+/, 'comment'],
[//*/, 'comment', '@push'], // 支持嵌套注释
['\*/', 'comment', '@pop'],
[/[/*]/, 'comment'],
],
},
});
主题
当配置好词法分析后,使用了默认的 token ,编辑器会自动进行对应的语言高亮。
当词法分析规则中配置了自定义的 token,我们也可以配置自定义 token 的颜色,如下所示。
js
// Define a new theme that contains only rules that match this language
monaco.editor.defineTheme("myCoolTheme", {
base: "vs",
inherit: false,
rules: [
{ token: "custom-info", foreground: "808080" },
{ token: "custom-error", foreground: "ff0000", fontStyle: "bold" },
{ token: "custom-notice", foreground: "FFA500" },
{ token: "custom-date", foreground: "008800" },
],
colors: {
"editor.foreground": "#000000",
},
});
Tokenizer 定义详解
js
// 伪代码示意
// tokenizer 的定义
tokenizer: {
// 包含若干状态(state)
// xxxx 状态(state)
xxxx: [
// xxxx 状态下包含若干规则(rule),
[regex, action], // 规则由 正则表达式(regex) 与动作(action) 组成
]
}
状态
分词器(tokenizer)由一个定义状态的对象构成。分词器的初始状态是其定义中的第一个状态。当分词器处于某个特定状态时,只会应用该状态下的规则。所有规则按顺序进行匹配,当第一个规则匹配成功后,就会执行其对应的操作来确定词法单元类别,后续规则不再进行尝试。因此,以最高效的方式对规则进行排序可能很重要,例如,将空白符和标识符规则放在前面。
规则
每个状态都被定义为一组规则数组,这些规则用于匹配输入。规则可以采用以下形式:
[ regex , action ]
{ regex: regex , action: action } 的简写
[ regex , action , next ]
{ regex: regex , action: action {next: next } } 的简写
{regex: regex , action: action }
当正则表达式与当前输入匹配时,就会执行相应操作来设置词法单元的类别。正则表达式(regex)既可以是一个正则表达式(使用/regex/的形式),也可以是一个表示正则表达式的字符串。示例如上述「基础匹配」内容。
{ include: state }
用于对规则进行良好的组织,并扩展为在状态中定义的所有规则。这是预先扩展的,对性能没有影响。如下示例,包含的 @whitespace 状态。
js
tokenizer: {
root: [
// ... others
// whitespace
{ include: '@whitespace' },
],
comment: [
[/[^/*]+/, 'comment' ],
[//*/, 'comment', '@push' ], // nested comment
["\*/", 'comment', '@pop' ],
[/[/*]/, 'comment' ]
],
whitespace: [
[/[ \t\r\n]+/, 'white'],
[//*/, 'comment', '@comment' ],
[///.*$/, 'comment'],
],
},
动作
一个动作决定了生成的词法单元(token)类别。一个动作可以有以下形式:
string
是 { token: string } 的简写。
-
[action1,...,actionN]-
一个包含 N 个动作的数组。这仅在正则表达式恰好由 N 个组(即带括号的部分)组成时才被允许。由于分词器的工作方式,你必须以这样一种方式定义这些组:所有组都出现在顶级位置并涵盖整个输入,例如,我们可以将带有ASCII码转义序列的字符定义为:
js/(')(\(?:[abnfrt]|[xX][0-9]{2}))(')/, ['string','string.escape','string']] -
-
{ token:tokenclass}- 一个定义用于 CSS 渲染的词法单元(token)类别的对象。常见的词法单元(token)例如有"keyword"(关键字)、"comment"(注释)或"identifier"(标识符)。你可以使用点来表示层级化的CSS名称,比如"type.identifier"或"string.escape"。
一个动作对象可以包含更多影响词法分析器状态的字段。以下属性是可识别的:
next:state
(字符串)如果已定义,它会将当前状态推入分词器栈,并使 state 成为当前状态。例如,这可用于开始对块注释进行分词:
js
['/\*', 'comment', '@comment' ]
请注意,这是以下内容的简写形式
{ regex: '/\*', action: { token: 'comment', next: '@comment' } }
在这里,匹配到的 /* 被赋予"comment"词法单元,分词器会使用 @comment 状态下的规则继续匹配输入。
next属性可以使用一些特殊状态:
"@pop"
弹出分词器栈以返回到之前的状态。例如,这可用于在看到结束标记后从块注释分词状态返回:
js
['\*/', 'comment', '@pop']
"@push"
推入当前状态并继续保持当前状态。在看到注释开始标记时处理嵌套块注释很有用,例如,在@comment状态下,我们可以这样做:
js
['/\*', 'comment', '@push']
"@popall"
弹出分词器栈中的所有内容并返回到顶层状态。这可用于在恢复过程中从深层嵌套级别"跳转"回初始状态。
-
{ cases: { guard1: action1, ..., guardN: actionN } }- 最后一种动作对象是 cases 语句。cases 对象包含一个对象,其中每个字段都充当一个守卫条件。每个守卫条件都会应用于匹配到的输入,一旦其中一个守卫条件匹配成功,就会执行相应的动作。需要注意的是,由于这些本身就是动作,所以 cases 可以嵌套。使用 cases 是为了提高效率:例如,我们先匹配标识符,然后检查该标识符是否可能是关键字或内置函数:
js[/[a-z_$][a-zA-Z0-9_$]*/, { cases: { '@typeKeywords': 'keyword.type', '@keywords': 'keyword', '@default': 'identifier' } } ]
守卫条件可以包括:
"@keywords"
keywords 属性必须在语言对象中定义,并且由字符串数组组成。如果匹配到的输入与该数组中的任何一个字符串匹配,那么这个守卫条件就会成功。(注意:所有情况都会预先编译,并且会使用高效的哈希映射来测试该列表)。高级用法:如果该属性指向单个字符串(而非数组),它会被编译成一个正则表达式,用于测试匹配到的输入。
"@default"
(或"@"或"")默认的守卫条件,始终会成功。
"@eos"
如果匹配到的输入已到达行尾,则成功。
"regex"
如果守卫条件不是以@(或)字符开头,则会被解释为一个正则表达式,用于测试匹配到的输入。注意:该正则表达式会被加上前缀\^和后缀,因此它必须与匹配到的输入完全匹配。这可用于测试特定的输入,例如,下面是来自Koka语言的一个示例,它使用这一特性根据声明进入不同的分词器状态:
js
[/[a-z](\w|-[a-zA-Z])*/,
{ cases:{ '@keywords': {
cases: { 'alias' : { token: 'keyword', next: '@alias-type' }
, 'struct' : { token: 'keyword', next: '@struct-type' }
, 'type|cotype|rectype': { token: 'keyword', next: '@type' }
, 'module|as|import' : { token: 'keyword', next: '@module' }
, '@default' : 'keyword' }
}
, '@builtins': 'predefined'
, '@default' : 'identifier' }
}
]