为crayon代码高亮插件添加Kotlin支持

2021-01-31

crayon语法高亮插件实在太久没更新了,升级WordPress后有兼容性问题,已经卸载。因此,文中的一段使用crayon插件样式的代码将不会正常显示。

虽然换了 Enlighter 插件,但目前还没找到令人完全满意的代码高亮插件。


crayon插件(aramk/crayon-syntax-highlighter)算是比较老的一个wordpress插件了。其第一个版本发布于2012年,最后一次release在2016年,直到2018年有人开了个issue告诉作者wordpress把这个插件标记为弃用才改了几行、提交了一次,而且没有发布release。也就是说差不多两年多没怎么维护过了。。。

这倒也不算什么,毕竟插件好好地运行。虽然这个插件支持挺多编程语言的,但还是缺了些语言。对我来说,Python的jinja2模板语言没有倒没什么,毕竟可以凑合着用HTML,然而却没有Kotlin。。。

在项目的PR页发现已经有人提交了一个Kotlin支持的PR(#414),但项目拥有者并没有同意这个PR,准确说是从2016年后就没后处理过PR了。抱着试一试的心态把这个PR添加的文件下载了下来,看了看效果。

以下是PR提交者给出的代码示例:


看上去不敢恭维。。。至少在我选的Vistual Assist主题中显示不佳。

之后就想着自己写写Kotlin的支持文件吧。首先找了一个wordpress上其他代码高亮插件参考参考EnlighterJS/Plugin.WordPress。在resources/EnlighterJS.min.js文件里看到了Kotlin相关的高亮规则。

e.Language.kotlin = new Class({
    Extends: e.Language.generic,
    setupLanguage: function(e) {
        this.keywords = {
            reserved:{
                csv: "package, as, typealias, class, this, super, val, var, fun, for, null, true, false, is, in, throw, return, break, continue, object, if, try, finally, else, while, do, when, interface, typeof",
                alias: "kw1"
            },
            keywords:{
                csv: "import, typealias, constructor, by, where, to, init, companion, abstract, final, enum, open, annotation, sealed, data, lateinit, override, private, protected, public, internal, vararg, noinline, crossinline, reified, const, suspend, tailrec, operator, infix, inline, external",
                alias: "kw2"
            }
        },
        this.patterns = {
            slashComments: {pattern:this.common.slashComments,alias:"co1"},
            multiComments: {pattern:this.common.multiComments,alias:"co2"},
            chars: {pattern:this.common.singleQuotedString,alias:"st0"},
            multiLineStrings: {pattern:/"""[\s\S]*"""/gm,alias:"st1"},
            strings: {pattern:this.common.doubleQuotedString,alias:"st1"},
            annotation: {pattern:/@[\W\w_][\w\d_]+/gm,alias:"st1"},
            numbers: {pattern:/\b((([0-9]+)?\.)?[0-9_]+([e][-+]?[0-9]+)?|0x[A-F0-9]+|0b[0-1_]+)\b/gim,alias:"nu0"},
            properties: {pattern:this.common.properties,alias:"me0"},
            brackets: {pattern:this.common.brackets,alias:"br0"},
            functionCalls: {pattern:this.common.functionCalls,alias:"kw4"}
        }
    }
})

reserved和keywords就是Kotlin保留的关键字了。之前提到的PR也是就分了这两类。但感觉上crayon划分更细。语言文件说明在这里langs/readme.md。实际上分得这么细,有些我都不太确定是不是理解对了。

关键字

参照这其他语言的文件把上面的关键字大致分成reserved、modifier、statement。

// reserved.txt
package
import
typealias
class
this
super
val
get
set
var
fun
null
true
false
object
interface
typeof
init
constructor

// modifier.txt
abstract
final
enum
open
annotation
sealed
data
override
lateinit
in
out
noinline
crossinline
vararg
reified
tailrec
operator
companion
infix
inline
external
private
internal
public
const
suspend

// statement.txt
as
is
in
for
if
else
try
catch
finally
while
do
when
continue
break
return
throw
by

  • 分类从EnlighterJS找来的关键字的时候发现有些关键字似乎没有给出,比如catch和by关键字。这里加上了。
  • in和out为泛型中表示逆变和协变的关键字,感觉上应该是一种装饰了。

有人可能会说怎么没有to和lazy。实际上to和lazy都是函数,你可以使用to和lazy作为变量名。

  • to是一个中缀扩展函数,所以调用时可以省略点和括号,相当于1.to(2)
  • lazy就是一个普通的函数了,它的参数是一个Lambda表达式,所以可以省略括号。它的返回值是实现getValue运算符方法的类的实例。

Kotlin中的位运算和to一样是中缀函数。于是,它们以及创建一个范围的until函数都被我放在了命名为inflx.txt的文件里:   shl shr ushr and xor or until to  

类型

之后整理了type.txt:

Any
Boolean
Int
Double
Float
Short
Number
Long
Char
String
Byte
Unit
Nothing
UByte
UInt
ULong
UShort
Array
IntArray
DoubleArray
FloatArray
ShortArray
ByteArray
CharArray
BooleanArray
LongArray
UByteArray
UIntArray
ULongArray
UShortArray
Sequence
false
null
true
Enum
Annotation
CharSequence
Comparable
Throwable
Exception
package
interface
class
Class

这里部分类型有些模棱两可,比如package,但default里有就学这加进去了。

  • 你可能发现除了Array还有一些IntArray、UIntArray这样的类型。它们的区别在于Array实际上是Object[] ,而IntArray等是基本类型数组,如int[] 。考虑到装箱和拆箱降低效率的问题,可以使用对应基本类型的数组。
  • U开头的类型表示它们是无符号的。它们是1.3版引入的实验性类型,使用时需要标记启用这个功能。无符号整型的实验性状态
  • 以上都是非空类型,考虑到如果加上可空的话文件太大不太好。所以,可空类型的问号就当做符号处理。

函数

下面是Kotlin内置的函数,function.txt:

with
print
println
arrayOf
arrayOfNulls
booleanArrayOf
byteArrayOf
charArrayOf
doubleArrayOf
emptyArray
enumValueOf
enumValues
floatArrayOf
intArrayOf
longArrayOf
shortArrayOf
emptyArray
emptyList
emptySet
emptyMap
emptySequence
listOf
listOfNotNull
arrayListOf
mutableListOf
mapOf
hashMapOf
linkedMapOf
mutableMapOf
sortedMapOf
setOf
hashSetOf
linkedSetOf
mutableSetOf
sortedSetOf
assert
check
checkNotNull
error
require
requireNotNull
lazy
lazyOf

不同于Java,Kotlin可以在文件顶层声明不属于类的函数(当然在JVM中函数的表示还是一个类的静态方法)。这里列出的内置函数大多是一些数组和容器有关的的函数,之前说到的lazy函数也在这里。

符号

然后整理了operator.txt与symbol.txt。

operator.txt: !! ?: ?. .. != == <= >= += -= *= /= ++ — && || + – * / % ? = > < !

symbol.txt:-> : ! @ $ % ( ) _ { } [ ] \ ; , .

Kotlin的符号似乎比较少,毕竟位运算使用的都是中缀函数。

  • 虽然Kotlin中并不需要分号,但有分号也是可以的,所以留下了它。
  • Java中内部类获取外部类引用时常用到Outer.this 这样的表达式,在Kotlin中使用的是“@”:this@Outer

语法匹配

到这里才是关键了。我们需要编写一些正则表达式去匹配源代码中的各种成分。下面是kotlin.txt的内容:

### KOTLIN LANGUAGE ###

#   ELEMENT_NAME [optional-css-class] REGULAR_EXPRESSION

    CASE_INSENSITIVE = OFF

    NAME                Kotlin
    VERSION             1.0.0

    COMMENT             (?default)
    STRING              (?:(?<!\\)""".*?(?<!\\)""")|(?default)
    MARK_AT:TAG         (?<=this|super|return|break|continue)@[A-Za-z_]\w*|\b[A-Za-z_]\w*@(?!\w)

    FUNCTION:KEYWORD    \b(?alt:function.txt)\b|(?<=\s)(?alt:infix.txt)(?=\s)
    NOTATION            (?<= |)@[\w.]+
    STATEMENT           \b(?alt:statement.txt)\b
    RESERVED            \b(?alt:reserved.txt)\b
    TYPE                \b(?alt:type.txt)\b
    MODIFIER            \b(?alt:modifier.txt)\b

    ENTITY              \b[A-Za-z_]\w*(?=\s*(\w+@\s*)?\{)|\b[A-Za-z_]\w*\b(?=\s*\([^\)]*\))|(?<!\.)\b[A-Za-z_]\w*\b(?=[^}=|,.:;"'\)]*{)
    GENERIC:ENTITY      (?<=<)[A-Za-z_]\w*(?=>|\s*:\s*[A-Za-z_]\w*>)|(?<=in|out|:)\s+[A-Za-z_]\w*(?=>)
    VARIABLE            [A-Za-z_]\w*(?=\s*(?alt:operator.txt)|\s*(?alt:symbol.txt)|\s+[A-Za-z_]\w*\s+\S|\s*$)

    IDENTIFIER          (?default)

    CONSTANT            (?<!\w)((\.\d[\d_]*|\d[\d_]*\.?)[\d_]*([eE]\d[\d_]*)?[fF]?|((0[bB])?[\d_]+|0[xX][\d_a-fA-F]*)[uUlL]?)\b
    OPERATOR            (?alt:operator.txt)
    SYMBOL              (?alt:symbol.txt)

根据crayon给出的语言文件文档(langs/readme.md)的说法,存在形如(?alt:file.txt)、(?default)或 (?default:element_name)、(?html:somechars)这三种特殊分组。

  • (?alt:file.txt):引用文件(之前写的reserved.txt等),以(LINE1|LINE2|…)格式(也就是正则表达式的可选路径)替换这个标签
  • (?default)或(?default:element_name):引用langs/default/default.txt中定义的规则
  • (?html:somechars):HTML转义。

COMMENT使用的是默认的匹配注释的正则表达式。

STRING,默认的正则缺少一种情况就是和Python类似的三引号字符串。

MARK_AT:TAG可以说是一种别名,被其正则匹配的内容标记为TAG,如<?php ?>。虽然Kotlin不是像PHP等语言一样把代码放在标签中。但毕竟叫TAG。。。这段正则为了匹配“@”标签。“@”一般用于以下三种场景:

  1. 上面已经提到的引用外部类的情况,与之类似的是获取外部类父类的引用super@Outer
  2. 返回某个指定标记的函数或Lambda表达式
    fun main(args: Array<String>) {
        args.forEach each@ {
            // 这个return会使main函数返回
            // return
    
            return@each  // 这才是结束forEach
        }
        println("Hello Kotlin.")
    }

    不过上面的情况是可以不标记标签的:
    fun main(args: Array<String>) {
        args.forEach {
            return@forEach
        }
        println("Hello Kotlin.")
    }
  3. 跳出指定标记的多层循环
    for (i in 0 until 5) { i@
        for (j in 0 until 5) {
            // 这里的continue作用于i的循环
            if (i == j) continue@i
        }
    }

所以,标签的匹配使用后瞻断言匹配“@”前面是this、super、return、break、continue的,接着匹配“@”后面是合法变量名的字符串。这样就匹配了标签使用时的情况。最外层可选路径的第二个路径就是匹配标签声明的情况了。合法变量名后跟“@”,且“@”后没有字母数字下划线。

MODIFIER简单得匹配了Kotlin中的注解,实际上除了标签和注解外,应该没有用到“@”的地方。包括注解声明使用的都是annotation关键字。

ENTITY这个元素我不太理解,感觉应该是指函数、类名之类的,正则中可选路径中第一个是我写的,后面都是从default.txt文件中复制来稍加修改的。第一部分用以匹配调用只有一个以Lambda表达式为参数的函数:

button.setOnClickListener {
    // do something
}

GENERIC匹配泛型,实际上会作为ENTITY高亮显示。Java的语言文件简单粗暴地用<\w+>匹配。我这里写详细了些,首先是不匹配<和>,让它们作为普通符号。分三种情况,第一种里面只有泛型名;第二种带“:”,限制泛型类型的请你概况,这中情况会匹配左侧泛型名;第三种包含in和out的情况,匹配其右侧合法变量名。

VARIABLE大部分也是从default.txt复制过来加以修改,添加了调用中缀方法时匹配方法名左侧变量的语句。

CONSTANT匹配数字常量。default.txt中给出的正则表达式有些简单,会匹配到Kotlin表范围的语句1..10。所以写了一个匹配比较准确的。包括二进制、十六进制、科学计数法、无论后面是F还是U还是L的、无论是小数点前后有没有数字的。只要是合法数字都能匹配。不过不合法的也可能被匹配到。。。当然,谁会让写错的代码一直挂在页面上呢?

FUNCTION、STATEMENT、RESERVED、TYPE、MODIFIER等都是简单匹配关键字,这里就不多赘述了。

TODO

  1. 中缀方法调用时,方法名不会被ENTITY匹配。目前没有想到好办法。如果后瞻断言可以变长匹配话或许有办法做到。
  2. 大多数情况下“:”后面多半是类型,类型似乎应该分在ENTITY。目前也没有想到万全之策匹配这部分。
  3. Kotlin内置的中缀扩展函数匹配太简单了,会匹配到同名的变量。这个无伤大雅就是了。

总结

写了一个有意思的例子用来展示效果:

import kotlin.math.abs
import kotlin.math.sqrt

data class Point(val x: Int, val y: Int) {
    infix fun distance(point: Point): Double {
        val dx = abs(point.x - x)
        val dy = abs(point.y - y)
        return sqrt(dx * dx + dy * dy.toDouble())
    }
    override fun toString(): String = "($x, $y)"
}

object Distance {
    class Wrapper(private val p1: Point) {
        infix fun to(p2: Point) = p1.distance(p2)
    }
    infix fun from(p: Point): Wrapper = Wrapper(p)
}

fun main(args: Array<String>) {
    val p1 = Point(1, 1)
    val p2 = Point(2, 2)
    // 就像写英语一样
    val dis: Double = Distance from p1 to p2
    println("The distance from $p1 to $p2 is $dis.")
}

这些Kotlin语言文件已经提交到了我fork出的crayon-syntax-highlighter仓库https://github.com/WaferJay/crayon-syntax-highlighter/tree/lang-kotlin。这次编写语言文件也算是对我Kotlin学习情况的自我检测了,这篇文章也算是对Kotlin一些让我映像比较深的特性的简单记录了。