R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

代码高亮的可行方案

Update 20180704:负向零宽断言我手边只有 Chrome 支持,所以目前还是不可用的,还好 PrismJS 提供了一种 Hack 的方法可以支持这个特性的很有限的部分:当传入 lookbehind 属性时,会删掉 match 数组的第零项,这样第一个括号的内容就成了匹配到的东西……我已经给 PrismJS 提了 Pull Request(地址在 这里),但尚未被合并,仅供参考。


背景

作为一个强迫症,我对博客里的代码高亮一向是要求很高的,一个好的代码高亮库,需要满足如下几点:屏幕大、电量足……啊走错了,应该是配色好看、识别准确、插件生态丰富。之前从 highlight.js 换到 PrismJS,就是因为前者的插件不够多。关于配色,我一直很喜欢 Monokai,自己的 CSS 功底也不差,于是就照着改了一份 CSS 文件。PrismJS 的插件超级多,也基本满足了我的需求。但唯一一点不爽的是,highlight.js 可以将函数的参数高亮出来,但是 PrismJS 截止到目前还没有这个功能,于是我就自己试着改。

打开了 PrismJS 的 GitHub 仓库,发现里面有好多语言,于是决定还是从最常用的 JavaScript 开始改起。

先附上成果吧!修改的部分在这儿:Revisions · JavaScript file highlight for Prism.js (support function arguments)

PrismJS 的原理

看了一圈代码,PrismJS 的原理大概是按照特定的规则将代码文件 Split 成多个部分,并为它们打个标记(用一个 <span> 包住),然后对它们再使用一些规则 Split 成更小的部分并打标记(包住),大概是这样:

+------+-------------+---------------------------------+------+
| Step | Class       | String                          | Next |
|------+-------------+---------------------------------+------|
| 0    | tag         | <UpdateJudge element={element}> | 1    |
|------+-------------+---------------------------------+------|
| 1    | tag         | <UpdateJudge                    | 2    |
|      | attr-name   | element                         |      |
|      | script      | ={element}                      | 3    |
|      | punctuation | >                               |      |
|------+-------------+---------------------------------+------|
| 2    | punctuation | <                               |      |
|------+-------------+---------------------------------+------|
| 3    | punctuation | =                               |      |
|      | punctuation | {                               |      |
|      | punctuation | }                               |      |
+------+-------------+---------------------------------+------+

上面第一列是解析的阶段,第二列可以确定 <span>class,第三列是新解析出来的要被 <span> 包住的内容。没有被打标记的文字的样式就是从父元素继承。

原理就是这么简单,所以它对语言的识别有多厉害,就是看规则有多强大。

PrismJS 的规则

看了看 PrismJS 的规则,用法确实不难,毕竟官方是提供了 文档 的,难点是在规则的设计上。就以比较简单的 CSS 规则 为例吧:

Prism.languages.css = {
    comment: /\/\*[\s\S]*?\*\//,
    atrule: {
        pattern: /@[\w-]+?.*?(?:;|(?=\s*\{))/i,
        inside: {
            rule: /@[\w-]+/
        }
    },
    url: /url\((?:(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1|.*?)\)/i,
    // ....
    // several lines hidden
    // ....
    punctuation: /[(){};:]/
};

PrismJS 将会对代码字符串自上而下的使用正则来 Split,也就是说,首先先使用 comment 对应的正则将代码分割开,再对分割出来的每一个小块使用 atrule 对应的正则分割……以此类推,最后用 punctuation 对应的正则做最后一步分割。如果某个规则有 inside 属性,那么对于使用这个规则分割开的区域,只会用 inside 里面提供的规则继续分割。

大概可以想到这样设计比较合理——例如你把 punctuation 的规则放到了最上面,那么在刚开始的时候,整个代码就被分割成了一片片纯英文区域,是不可能再用其它正则来分割的。

对于函数参数的规则设计

以我对 JavaScript 的了解,函数参数可能会在如下的几种情况出现(这篇文章可以当成是对我代码的一个测试了):

// normal function
function test1(a, [b]) {}

// anonymous function
function () {}

// arrow function
(a, [b]) => {}
({ a, b = 0 }) => {}

// class method
class A {
    test2(a, [b]) {}
    test3({ a, b = 0 }) {}
}

但是与此同时,不能匹配到这种情况:

// if | while | for | switch
if (a || b(c)) {}

// function call
test4(a)

箭头函数没有歧义,特别好识别,所以就不说了;对于 if 之类的情况,只需要加一个零宽断言判断它前面是否是这些关键字即可;需要注意的是空格可能到处都有,所以不能忘了加足了 \s*……总之就是一系列智力游戏。

由于我没有把整个项目下下来,所以没有单元测试,我只能自己写一个 HTML 来测试(差不多相当于把上面那两个代码块写进去)。最终的成果就是文章开头的那个链接啦!只需要再在 CSS 中添加 argument 的样式(Monokai 是橙色)就搞定了。

这不禁让我想起了 Regex Golf 这个游戏,于是也顺便给大家安利一下它吧!可带劲啦!

与 highlight.js 和 VSCode 的对比

highlight.js

highlight.js 使用的是另一种方式:使用正则表达式的 lastIndex 特性。首先定义好语法规则(规则的格式在 这里),然后将这些规则用 | 符号拼成一个超大的正则(代码中叫 terminators),不断使用这个正则来匹配原字符串,将匹配到的部分截取出来,递归使用该规则的子规则匹配这个子串。通俗一点来讲,就是“外层是 while (true),内层是递归处理”。

(话说一开始看到代码中有个变量叫 lexeme,我还以为要去复习编译原理了……)

之前我在使用的时候,发现它可以匹配到函数参数列表。于是仔细看了一下 JavaScript 语言的规则规则,发现对于一些情况还是没有做处理:

  • 参数列表有复杂语法(解构、赋值等)的函数,比如前文的 test1
  • ES6 Class 中的函数,比如前文的 test2、test3

虽然其实也可以加上这些处理,但规则比 PrismJS 复杂好多,不太想去研究了;以及这个库都快一年没更新了,JavaScript 的语法规则快两年没更新了,不敢用……

VSCode

VSCode 对于各种高亮做的都很好,不愧是微软爸爸的东西。翻了翻代码,发现 VSCode 和它的参照品 Atom、我以前用的 Sublime Text 一样,都使用了一种 .tmLanguage 格式的文件,上网查了一下,是 TextMate 的“Language Grammer”文件,语法规则在 这里。至于具体是如何解析的,我发现 VSCode 使用了自家的一个叫 vscode-textmate 的库来解析这种文件。翻了一下,发现有点麻烦,还调用了一个叫 oniguruma 的正则库,并不适合放到网页里加载,只能放到离线的编辑器里。

发现跟 Match 相关的代码在 这里,看起来逻辑很简单,只要不断调用 matchRulematchInjections 就行了,但这儿简单就说明别的地方麻烦。但由于时间有限,没去做深入研究,因此不知道 Rule 和 Injections 是个什么东西、是怎么构造出来的。

又去看了一眼 .tmLanguage 文件,发现对于正常的网页来说大得离谱,看来是能考虑到的情况都考虑到了……甚至对于这种奇葩代码也能比较好的处理(当然,本文中的这段代码高亮会有问题):

render() {
    return (
        <Tag
            // a piece of comment
            /* another comment */
            props1={value1}
            props2={value2}
        />
    );
}

然而 PrismJS 是没法处理这种情况的,因为前者一开始就被注释给分割开了;highlight.js 应该是可以的,只要允许 Tag 中可以包含注释就行了。

基于语法树的代码高亮

这个思路基本能保证绝对的准确,但我目前还没看到有开源的实现(可能确实有但我没找到),我觉得可能的原因是太麻烦,毕竟这跟编译器之间就差一个转换的功能了,对于一般的页面展示来讲,没有必要做到这一步。

不过 VS 和 IDEA 应该是用的这种方式,毕竟参数与其它变量颜色都不同,这可不是只靠正则就能区分开的。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1114 天