某天我突然起了用 VSCode 写 Wolfram 语言的想法,但是惊讶而失望地发现 VSCode 这么丰富的插件中竟然没有一个像样的 Wolfram 语言支持。写一个 Wolfram 语言插件真的有那么难吗?于是怀着这种疑惑,我开启了实现 Wolfram 插件的不归路。
既然要做一个插件,第一步便是给出语法描述文件。因此这次我们就来简单地谈谈 Wolfram 的语法。
一些你可能不知道的事
- Wolfram 语言的数字语法即使是最基本的描述也要上百字符的正则表达式
- 要在字符串中表达一个字符,Wolfram 语言最多有 5 种不同的方式
- Wolfram 语言的字符在遇到转义字符时有 4 种截然不同的行为
- Wolfram 语法中有上百个运算符,其中甚至有运算符包含多个不同含义
- Wolfram 语法中有 7 种不同意义的括号(或许这并不值得惊讶)
- 这垃圾知乎专栏连 Wolfram 语法的注释的上色都是错的(
基本概念介绍
Wolfram 语言虽然是函数化语言,但包含了上百种内置特殊语法来简化函数的书写,它们中甚至有一些同名的运算符,需要依赖上下文环境才能确定含义(比如!
在一些环境下表示Not
,另一些环境下则表示Fractorial
)。Mathematica 更是包含了超过 6000 个内置符号,按照一般的语法描述的套路,它们也都是要以不同的方式高亮的。这其中还涉及内置符号的分类(比如True
是常量,Sort
是函数,Alignment
是选项等)。用一个文件描述这么复杂的语法,恐怕即使写出来了也会变得难以维护吧。
为了解决这个问题,我想出了一种办法,即扩展 YAML 语法以达到协助编程的方法。举个例子:
endCaptures: !raw
1: '#function-identifier'
2: keyword.operator.call.wolfram
会被编译成
{
"endCaptures": {
"1": {
"patterns": [
{
"include": "#function-identifier"
}
]
},
"2": {
"name": "keyword.operator.call.wolfram"
}
}
}
而像这样的 tag 还有不少。它们深入到语法的描述文件中,最终甚至可以用 20kb 不到的大小描述超过 200kb 的语法信息。在下面的介绍中,我也会使用一些最基本的标签,感兴趣的同学可以参考这里:
Shigma/vscode-wlgithub.com![44e53dd6a80c530948a7ab98b8a1978e.png](https://i-blog.csdnimg.cn/blog_migrate/0cb77a1f392765146ff6c8b975914c09.png)
解决了表述麻烦的问题以后,接下来就是描述语法的部分了。首先介绍一些基本术语。他们将作为变量以 Mustache 语法的形式替换任何出现的正则表达式(这里参考了 sublime-syntax 的实现)。
- alnum:
[0-9a-zA-Z]
- number:
(?:d+.?|.d)d*
- symbol:
[$a-zA-Z]+[$0-9a-zA-Z]*
Wolfram 基本语法
如果要描述最基本的 Wolfram 语法的话,个人认为应该包含下面几个部分(这里为了避免混淆我就都使用英文表述分类了):
- Shebang
- Numbers
- Strings
- Operators
- Variables
- Functions
- Patterns
- Bracketing
- Box Forms
- Comment blocks
- Shorthand expressions
- Escaping before newlines
下面将简要介绍一下每部分的语法。更多细节可以参见我的源代码,我留下了不少注释。
Shebang
这里是 shebang 语法的介绍。当然这个语法描述起来并没有什么困难的:A(#!).*(?=$)
。
Numbers
在 Wolfram 语法中,一个数字可以:
- 含有基数:
2^^10
,11^^a.a
- 含有精确度:
2`10
,11`
- 含有准确度:
2``10
,11``
- 使用科学计数法:
2*^10
,2*^-1.1
因此一个完整的数字语法是相当复杂的。它至少应该包括:
(?x)
(?:
([1-9]d*^^) # base
((?:{{alnum}}+.?|.{{alnum}}){{alnum}}*) # value
|
({{number}}) # value
)
(?:
(``(?:{{number}})?) # accuracy
|
(`(?:{{number}})?) # precision
)?
(*^[+-]?{{number}})? # exponent
比我们见过的任何语言都复杂,不是吗?当然即使写成了这样,这还不足够完整,因为事实上数字的值能使用的字符是由基数决定的。而且基数的范围也是有限制的,但是作为一个语法描述来说,做成这样已经足够复杂了,毕竟性能也是要考虑在内的重要因素。而且对于输入正确性的判断,我们还可以使用其他的方法进行判别(比如提供一个 Wolfram Language Linter 等等)。
值得注意的一点是:^^
, `
, ``
和*^
不应该被认为是操作符。因为它们具有和操作符完全不同的特征:它们的两侧不能打空格,而且他们的解析是和两侧相关的数字一同进行的。
参考文档:Input Syntax。
Strings
Wolfram 语法中的字符串也不是好惹的。除了必须以"
开始和结束这唯一的令人欣慰之处以外,字符串内部可能含有下列的特殊语法:
Named Characters
一些特殊 Unicode 字符在 Wolfram 语言内部是有名字的,一般地可以写作[{{alnum}}+]
。 然而并不是所有满足这种形式的字符串都是合法的(仅有 1048 个合法的命名字符),因此一个合格的语法描述还应该能够筛选出不合法的字段并给出提示。不过究竟如何获取所有特殊字符的名字就留给下一部分来介绍了。
Escaped Characters
Wolfram 语法的转义字符同样令人糟心。来看一个简单的例子:
Reap[
Scan[
Sow[#, Quiet @ Check[Length @ Characters @ ToExpression[""" <> # <> """], -1]] &,
CharacterRange[0, 127]
],
_,
#1 -> StringJoin[#2] &
] // Last
运行上面的代码之后,你会惊讶地发现,与一般的语言不同的是,Wolfram 语言的 ASCII 字符居然分为 4 类:
- 迷之消失的,即转义符和后面的字符一起不见的,包括
<>
- 不转义的,即转义符和之后的字符视为两个字符的,包括
#$',-89;=?]{|}~
- 可以转义的,即转义前后意义不同的,包括
!"%&()*+/@^_`bfnrt
- 一旦运行任何命令就直接报错的,包括剩下的全体 ASCII 字符
而非 ASCII 字符(即 Unicode 值大于 127 的所有字符)都不转义。我们可以看出,前三类字符都可以显示在反斜杠之后,而第四种属于错误语法,应该被以不同的颜色标明出来。
Encoded Characters
Wolfram 语言有 3 种用 Unicode 编码描述一个字符的方式:
- 3 位八进制数:
[0-7]{3}
- 2 位十六进制数:
.[0-9A-Fa-f]{2}
- 4 位十六进制数:
:[0-9A-Fa-f]{4}
相应地,以这三种语法的特征字符打头,但不能完全匹配之后特征的一切字符串都应该视为错误语法,在 Mathematica 中这些字符串也是会被报错的。
Embedded Box Forms
一个字符串内部也是允许嵌入 box forms(将在后面介绍)的。然而这从技术上是一件相当困难的事情,因此这里就不详述了。这部分的技术细节可能要等到第三部分再做深入的讨论。
参考文档:
- Input Syntax
- Special Characters
- Listing of Named Characters
- String Representation of Boxes
Operators
Wolfram 语言最美妙(个人观点勿喷)的特性之一便是大量好用的操作符。尽管海量的操作符在语法识别上会把人逼疯,但它却几乎不会给一个简单宽松的语法描述文件带来任何负担,我们只需要把它们列出来就行了!为了在类别中更好的借以区分,我把它们分成了 15 类:
Replace:
/. Replace
//. ReplaceAll
Call:
@ Prefix
@@ Apply
@@@ Apply
/@ Map
//@ MapAll
// Postfix
~ Infix
@* Composition
/* RightComposition
Comparison:
> Greater
< Less
>= GreaterEqual
<= LessEqual
== Equal
!= Unequal
=== SameQ
=!= UnsameQ
Logical:
! Not
|| Or
&& And
Assignment:
= Set
:= SetDelayed
^= UpSet
^:= UpSetDelayed
/: TagSet (TagUnset, TagSetDelayed)
=. Unset
+= AddTo
-= SubtractFrom
*= TimesBy
/= DivideBy
Rule:
-> Rule
:> RuleDelayed
<-> TwoWayRule
Condition:
/; Condition
Repeat:
.. Repeated
... RepeatedNull
Arithmetic:
+ Plus
- Minus, Subtract
* Multiply
/ Divide
^ Power
. Dot
! Factorial
!! Factorial2
' Derivative
** NonCommutativeMultiply
++ Increment, PreIncrement
-- Decrement, PreDecrement
Flow:
<< Get
>> Put
>>> PutAppend
String:
<> StringJoin
~~ StringExpression
| Alternatives
Span:
;; Span
Compound:
; CompoundExpression
Function:
& Function
Definition:
? Definition
?? FullDefinition
当然这里也有两个坑。其一是我们需要注意不同运算符的匹配顺序在我们的语法描述中始终是从前向后的,因此请注意不要将某个运算符的前缀放在这个运算符之前匹配,不然后面的运算符可能无法正确匹配。另一个更大的坑在于 Wolfram 语言存在着对应多个函数(即有歧义)的运算符。尽管在语言识别时可以通过上下文推断解决这一问题,但这对于仅仅使用静态格式的语法描述来说并不容易。事实上,一些运算符的歧义问题我还没有找到特别好的解决方案,而另一些运算符我并没有将它们列入这个表中,因为他们仅仅会出现在之后的特殊语境中。
最后,部分之前提到过的 named characters 也可以算作运算符的一种。然而将这其中真正意义上的运算符并不轻松,这里我就没有对命名字符进行进一步分类了。(如果有好的分类方法,欢迎向我提出,感激不尽!)
参考文档:Operators。
Variables
一个 Wolfram 语言的变量可以由反斜杠和之前定义的 symbol 相间构成。其中反斜杠之前的部分称为一个该变量的“上下文”。完整地表述起来应该是这样:
match: (`?(?:{{symbol}}`)*){{symbol}}
name: variable.other.wolfram
captures: !raw
1: variable.other.context.wolfram
Functions
函数在语法上与一个变量无异。但对于一个语法描述来说将函数用不同于变量的方式上色可是常见操作。但另一方面,函数在 Wolfram 中的表现形式可以是多种多样的。除了函数调用前一定是函数以外,下面介绍了最简单的 4 种判定一个变量是函数的方法。更多的判定方法会在之后的文中加以介绍。
- 任何出现在
(@{1,3}|//?@|[/@]*)
之前的标识符 - 任何出现在
(//|[@/]*)
之后的标识符 - 任何出现在用
~
分割开来的一系列表达式中偶数位的标识符 - 任何出现在 PatternTest 运算符(将在下面介绍)之后的标识符
Patterns
除了函数以外,模式(从某种意义上对应着其他语言中的参数)也应该以特殊的方式被匹配出来。在 Wolfram 语言中大致有两种体现一种模式的方式:
- 作为 pattern 函数的简写形式,即出现在
:(?=[^:>=])
之前 - 作为各种 blank 以及 default 函数的简写形式,即出现在下列符号之前的标识符:
(?x)
(_.) # Default
|
(_{1,3}) # Blank, BlankSequence, BlankNullSequence
({{identifier}})? # Head (here "identifier" means variable)
在一个模式之后,我们将允许之前没有定义过的两个操作符(同时他们也都是会引发歧义的操作符,用这种方式得到了解决)。
- Optional:
:
- PatternTest:
?
Bracketing
据我所知 Wolfram 语法包含至少 7 中不同意义的括号,尽管某些官方文档仅仅介绍了 4 种。一个一般的括号一般是这么描述的:
begin: (
beginCaptures: !all punctuation.section.parens.begin.wolfram
end: )
endCaptures: !all punctuation.section.parens.end.wolfram
name: meta.parens.wolfram
patterns: !push expressions
不相信一共有 7 种括号是吗?别急,下面这些你肯定都见过:
- parens:
(
and)
- braces:
{
and}
- brackets:
[
and]
- association:
<|
and|>
- parts:
[[
and]]
- box:
(
and)
并不怎么值得参考的参考文档:The Four Kinds of Bracketing in the Wolfram Language。
Box Forms
框符是 Wolfram 较为底层的语法了(以至于有些 Wolfram 内置函数遇到框符都会出一些神奇的 bug 而且一直没被修复)。尽管框符要描述起来还是挺复杂的,不过如果仅仅是最简单的上色支持则并不困难,我们大可以将其当做全体表达式语法加上下面几个操作符:
`
: FormBox@
: SqrtBox/
: FractionBox[%&+_^]
: x-scriptBox (x can be Sub/Super/Over/Under/...)*
: box constructors
参考文档:String Representation of Boxes。
Comment blocks
Wolfram 语法只支持块注释,即被包裹在一对(*
和*)
之间的注释:
begin: (*
end: *)
patterns: !push comment-block
不过这里有一个有意思的坑:在块注释内部,我们也应该提供块注释语法的支持,因为块注释是可嵌套的。比如下面的语法便是会经常出现在 .wl 文件中:
(* ::Input:: *)
(*(* some *)
(* comments *)*)
(看吧,这里知乎的代码上色就是错的,简直是典型反例~23333)
Shorthand expressions
尽管大部分操作符都是函数的简写形式,但也有一些函数的简写不能以普通的操作符来对待。下面是一些例子:
- Out:
%(d*|%*)
- MessageName:
(::)s*({{alnum}}+)
- Slot, SlotSequence:
(#[a-zA-Z]{{alnum}}*|##?d*)
- Get, Put, PutAppend:
(<<|>>>?) *([a-zA-Z0-9`/.!_:$*~?-]+) *(?=[)]},;]|$)
参考文档:Wolfram Language Syntax。
Escaping before newlines
最后,如果一个反斜杠出现在换行之前 (r?n
) ,它会使这个换行无效,这在任何地方都是生效的。(甚至它使得我们可以在一个变量名中间换行,这实在有点讨厌,目前我还没有能够实现内置变量名被换行符劈开后的识别呢。)
说了不少东西了,看起来 Wolfram 语法的确不是件简单的事情。那么下次要介绍的内容是如何从 Mathematica 中抓取语法相关信息。
最后欢迎任何感兴趣的同学向我提出宝贵的意见。仓库如下:
Shigma/vscode-wlgithub.com![44e53dd6a80c530948a7ab98b8a1978e.png](https://i-blog.csdnimg.cn/blog_migrate/0cb77a1f392765146ff6c8b975914c09.png)