如何在 monaco 中实现自定义语言的高亮

如下代码,在 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 定义详解

更多内容参考:microsoft.github.io/monaco-edit...

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' }
  }
]
相关推荐
携欢2 分钟前
portswigger靶场之修改序列化数据类型通关秘籍
android·前端·网络·安全
前端小L3 分钟前
专题二:核心机制 —— reactive 与 effect
javascript·源码·vue3
GuMoYu3 分钟前
npm link 测试本地依赖完整指南
前端·npm
代码老祖3 分钟前
vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮
前端·javascript
未等与你踏清风4 分钟前
Elpis npm 包抽离总结
前端·javascript
代码猎人4 分钟前
如何使用for...of遍历对象
前端
秋天的一阵风6 分钟前
🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)
前端·开源·全栈
林恒smileZAZ6 分钟前
【Vue3】我用 Vue 封装了个 ECharts Hooks
前端·vue.js·echarts
颜酱7 分钟前
用填充表格法-继续吃透完全背包及其变形
前端·后端·算法
代码猎人7 分钟前
new操作符的实现原理是什么
前端