本月我学习了42种编程语言,用于为 llamafile[2] 构建新的语法高亮器。我感觉自己已经完全沉浸在编程语言中。既然是万圣节,我想分享一些最诡异和最令人惊讶的语法。
我决定支持的语言包括Ada、Assembly、BASIC、C、C#、C++、COBOL、CSS、D、FORTH、FORTRAN、Go、Haskell、HTML、Java、JavaScript、Julia、JSON、Kotlin、ld、LISP、Lua、m4、Make、Markdown、MATLAB、Pascal、Perl、PHP、Python、R、Ruby、Rust、Scala、Shell、SQL、Swift、Tcl、TeX、TXT、TypeScript和Zig。这几乎涵盖了 TIOBE索引[3] 上的所有语言,除了 Scratch[4] ,因为它使用块而非文本,无法高亮。
新的高亮器和聊天机器人界面使得llamafile对我来说使用起来非常愉快,再加上像 gemma 27b it[5] 这样的开放权重模型已经变得如此出色,以至于我现在很少会想使用Claude。
令人惊讶的词法语法示例
在编写这个高亮器的同时,让我们来谈谈那些让我感到惊讶的词法语法。
C
C编程语言尽管声称简单,但实际上拥有任何语言中最奇怪的词法元素。首先,我们有三字符,这可能是为了帮助欧洲人在使用没有 #
、[
、\
、^
、{
、|
、}
和~
的键盘时使用C语言而发明的。你可以用 ??=
、??()
、??/
、??()
、??'
、??<
、??!
、??>
和??-
替换这些字符。直观,对吧?这意味着,例如,以下是完全有效的C代码。
int
main(int argc, char\* argv??(??))
??<
printf("hello world\\n");
??\>
至少在C23标准中trigraphs被移除之前是这样。然而编译器将永远支持这种语法以兼容旧软件,所以一个好的语法高亮器也应该如此。但是仅仅因为trigraphs已经正式死亡,并不意味着标准委员会没有想出其他奇怪的语法来替代它。考虑通用字符:
int \\uFEB2 = 1;
这个特性对于想要使用阿拉伯字符的变量名,同时保持源代码为纯ASCII很有用。我不确定为什么有人会使用它。我本希望可以这样滥用:
int
main(int argc, char\* argv\\u005b\\u005d)
\\u007b
printf("hello world\\n");
\\u007d
但是,如果通用字符不是使用标准委员会祝福的特定UNICODE平面,GCC会引发错误。
这个是我最喜欢的。你知道吗?如果在行尾使用反斜杠,C语言中的单行注释可以跨越多行?
//hi\\
there
大多数其他语言不支持这一点。即使是允许源代码中反斜杠转义的语言(如Perl、Ruby和Shell)也没有这个特定的C语言特性。据我所知,支持这一点的只有Tcl和GNU Make。语法高亮工具经常会在这一点上出错,比如Emacs和Pygments。尽管Vim似乎总是在反斜杠方面正确。
Haskell
每个C程序员都知道不能在多行注释中嵌套多行注释。例如:
/\*
hello
/\* again \*/
nope nope nope
\*/
然而在Haskell中,你可以。他们最终修复了这个bug。尽管他们采用了不同的语法。
\-- 在代码块中测试嵌套注释
let result3 = {- 这个注释包含
{- 一个嵌套注释 -}
-} 10 - 5
Tcl
关于Tcl最让我惊讶的是,标识符可以包含引号。例如,这个程序将打印 a"b
:
puts a"b
你甚至可以在变量名中使用引号,但是你只能使用 ${a"b}
符号引用它,而不是 $a"b
。
set a"b doge
puts ${a"b}
JavaScript
JavaScript有内置的正则表达式词法语法。但如果不仔细,很容易词法分析错误。考虑以下情况:
var foo = /\[/\]/g;
当我第一次编写词法分析器时,我会简单地扫描关闭的斜杠,并假设任何内部的斜杠都是转义的。当我高亮一些压缩代码时,这被证明是错误的。如果斜杠在字符集的方括号内,那么该斜杠不需要转义!
现在进入更奇怪的领域。
有一些不可见的 UNICODE 字符,称为行分隔符(u2028)和段落分隔符(u2029)。我不知道这些代码点的用例是什么,但 ECMAScript 标准将它们定义为行终止符,这实际上使它们与 \n
相同。由于这些是 Trojan Source 字符,我将我的 Emacs 配置为将它们渲染为 ↵ 和 ¶。然而,大多数软件并未被编写为识别这些字符,通常会将它们渲染为问号。据我所知,没有其他语言这样做。我利用这一点用于 SectorLISP,因为它让我能创建 C 和 JavaScript 的多语言代码。
javascript 语法高亮//¶\`
_... 仅限 C 的代码在这里 ..._
//\`
这就是我将 C 代码插入 JavaScript 文件的方式。
c 语法高亮//¶\`
#if 0
//\`
_... 仅限 JavaScript 的代码在这里 ..._
//¶\`
#endif
//\`
这就是我将 JavaScript 插入 C 源代码的方式。我在 lisp.js[6] 中的生产代码就是一个例子,这是支持我的 SectorLISP 博客文章[7] 的代码。它既可以在浏览器中运行,也可以用 GCC 编译并本地运行。llamafile 能够正确地语法高亮这些内容,但我还没找到另一个能做到的语法高亮器。不过这无关紧要,因为我怀疑 LLM 会打印这个。但想想这些边缘情况确实很有趣。
Shell
我们都熟悉 shell 脚本的 heredoc 语法,例如:
cat <<EOF
这是一个
多行
字符串
EOF
上述语法允许在 heredoc 字符串中放置 $foo
,尽管有一种引用语法可以禁用变量替换。
cat <<'END'
这不会打印 $var 的内容
END
如果你想让同事感到困惑,一个滥用这种语法的好方法是将 heredoc 标记替换为空字符串,在这种情况下,heredoc 将在下一个空行结束。例如,这个程序将在两行打印"hello"和"world":
cat <<''
hello
echo world
在支持 heredoc 的语言中(Shell、Ruby 和 Perl),还可以在同一行上有多个 heredoc。
cat /dev/fd/3 3<< E1 /dev/fd/4 4<< E2
foo
E1
bar
E2
在 shell 中还要注意的是,它类似于 Tcl,特殊字符如 #,你可能认为它总是开始一个注释,但实际上取决于上下文可能是有效代码。例如,在变量引用内部,# 可用于去除前缀。以下程序将打印"there"。
x\=hi-there
echo ${x#hi-}
字符串插值
你知道吗,从语法高亮的角度来看,Kotlin 字符串可以以 " 开头,但以 { 字符结尾?这就是它的字符串插值语法。许多语言允许在字符串中嵌入变量名引用,但 TypeScript、Swift、Kotlin 和 Scala 将字符串插值推向了鼓励在字符串内嵌入实际代码的极致。
val s2 = "${s1.replace("is", "was")}, but now is $a"
因此,要高亮 Kotlin、Scala 和 TypeScript 的字符串,必须计算大括号并维护解析器状态栈。对于 TypeScript 来说,这相对简单,只需要在有限状态机中添加几个状态。但对于 Kotlin 和 Scala 来说,情况变得非常复杂,因为它们支持双引号和三引号语法,并且两者都可以有插值值。所以最终解析器需要大约 13 个独立状态来进行字符串词法分析。Swift 也支持三引号的 "\(var)"
插值语法,但只需要 10 个状态即可支持。
Swift
Swift 对于在字符串内嵌入字符串有其独特的方法。它允许"双引号"、"""三引号"""和 /regex/ 字符串都可以用任意数量的 #hash# 标记包围,这些标记必须在每一侧镜像。这使得可以编写如下代码:
let threeMoreDoubleQuotationMarks = #"""
Here are three more double quotes: """
"""#
let threeMoreDoubleQuotationMarks = ##"""
Here are three more double quotes: #"""#
"""##
C#
C#支持Python的三引号多行字符串语法,但有一个这种语言特有的有趣的转折。C#解决"在字符串内嵌入字符串"问题的方式是,允许你使用四引号甚至五引号的字符串。无论你在左侧放置多少引号,都将用于在另一端终止字符串。
Console.WriteLine("");
Console.WriteLine("\\"");
Console.WriteLine("""""");
Console.WriteLine("""""");
Console.WriteLine(""" yo "" hi """);
Console.WriteLine("""" yo """ hi """");
Console.WriteLine(""""First
"""100 Prime"""
Numbers:
"""");
依我看,这是最好的方式,因为对于有限状态机来说解码实际上更简单。使用经典的Python三引号字符串,你需要额外的规则,以确保它是一个双引号字符,或者恰好是三个。通过允许任意数量的引号,验证规则更少。所以最终得到一个更强大且更易实现的表达性语言。这就是我们对微软的期望。
他们接下来会想出什么?
FORTH
通常,对计算机来说更容易解码的代码,对人类来说更难理解,FORTH就是证明。FORTH可能是最简单的语言,因为它在空白边界上标记所有内容。甚至字符串的语法也是一个标记。例如:
c" hello world"
在每种其他语言中,这将与"hello world"
意思相同。
FORTRAN和COBOL
我设想llamafile的用例之一是,一旦所有FORTRAN和COBOL程序员退休,它可以帮助银行系统不至于崩溃。假设你刚被雇佣来维护一个充满机密信息的秘密大型机,使用公共业务导向语言。感谢llamafile,你可以询问一个你控制的隔离AI,比如 Gemma 27b[4] ,为你编写COBOL和FORTRAN代码。它无法打印穿孔卡,但可以突出显示穿孔卡语法。这是FORTRAN代码的语法高亮显示:
*
* Quick return if possible.
*
IF ((M.EQ.0) .OR. (N.EQ.0) .OR.
+ (((ALPHA.EQ.ZERO).OR. (K.EQ.0)).AND. (BETA.EQ.ONE))) RETURN
*
* And if alpha.eq.zero.
*
IF (ALPHA.EQ.ZERO) THEN
IF (BETA.EQ.ZERO) THEN
DO 20 J = 1,N
DO 10 I = 1,M
C(I,J) = ZERO
10 CONTINUE
20 CONTINUE
ELSE
DO 40 J = 1,N
DO 30 I = 1,M
C(I,J) = BETA*C(I,J)
30 CONTINUE
40 CONTINUE
END IF
RETURN
END IF
FORTRAN有以下固定列规则。
• 在第1列放置*、c或C将使该行成为注释
• 在第6列放置非空格可以让你的行超过80个字符
• 通过在第1-5列放置数字来创建标签
这是一些正确语法高亮的COBOL代码。
``cobol 000100*Hello World in COBOL 000200 IDENTIFICATION DIVISION. 000300 PROGRAM-ID. HELLO-WORLD. 000400 000500 PROCEDURE DIVISION. 000600 DISPLAY 'Hello, world!'. 000700 STOP RUN.
对于COBOL,规则是:
* 在第7列放置\*会使该行成为注释
* 在第7列放置-可以让你的行超过80个字符
* 行号位于第1-6列。
### Zig
Zig对多行字符串有自己独特的解决方案,以两个反斜杠为前缀。
```zig
const copyright =
\\\\ Copyright (c) 2024, Zig Incorporated
\\\\ All rights reserved.
;
我喜欢这种语法,因为它消除了我们一直需要使用Python三引号字符串时调用textwrap.dedent()
的需求。代价是分号很难看。这种字符串语法真的应该被那些不需要分号的语言考虑,如Go、Scala、Python等。
Lua
Lua 有一种非常独特的多行字符串语法,它在解决"在字符串中嵌入字符串"问题时采用了类似于 C# 和 Swift 的方法。它通过使用双方括号,并允许在它们之间放置任意数量的等号来工作。
\-- 这是一个注释
\[\[你好 \[=\[\]=\] \]\] 那里
\[\[你好 \[=\[\]=\] \]\] 那里
\[==\[你好 \[=\[\]=\] \]==\] 你好
\[==\[你好 \]=\]==\] 你好
\[==\[你好 \]===\]==\] 你好
\[====\[你好 \]===\]====\] 你好
更有趣的是,它还可以用于注释。
\--\[\[
注释 #1
\]\]
print("你好")
\--\[==\[
注释 \[\[#2\]\]
\]==\]
print("世界")
汇编语言
汇编语言是最难进行语法高亮的语言之一,这是由于其各种方言的分散性。我试图使用 llamafile 构建一个能够较好地处理 AT&T、nasm 等语法的工具。以下是 nasm 语法:
section .data
message db '你好,世界!', 0xa ; 消息字符串,以换行符结尾
section .text
global \_start
\_start:
; 将消息写入标准输出
mov rax, 1 ; 写入的系统调用号
mov rdi, 1 ; 标准输出的文件描述符
mov rsi, message ; 消息字符串的地址
mov rdx, 13 ; 消息长度
syscall
; Exit the program
mov rax, 60 ; System call number for exit
xor rdi, rdi ; Exit code 0
syscall
以下是 AT&T 语法:
/ 系统调用
.globl \_syscall,csv,cret,cerror
\_syscall:
jsr r5,csv
mov r5,r2
add $04,r2
mov $9f,r3
mov (r2)+,r0
bic $!0377,r0
bis $sys,r0
mov r0,(r3)+
mov (r2)+,r0
mov (r2)+,r1
mov (r2)+,(r3)+
mov (r2)+,(r3)+
mov (r2)+,(r3)+
mov (r2)+,(r3)+
mov (r2)+,(r3)+
sys 0; 9f
bec 1f
jmp cerror
1: jmp cret
.data
9: .=.+12.
以下是 GNU 语法:
/ setjmp() 用于 x86-64
// 这也是一个注释
; 这也是
\# 这也是!
! 你好 sparc
setjmp: lea 8(%rsp),%rax
mov %rax,(%rdi)
mov %rbx,8(%rdi)
mov %rbp,16(%rdi)
mov %r12,24(%rdi)
mov %r13,32(%rdi)
mov %r14,40(%rdi)
mov %r15,48(%rdi)
mov (%rsp),%rax
mov %rax,56(%rdi)
xor %eax,%eax
ret
对于关键字,我发现最简单的方法是将行中第一个标识符(不是后跟冒号)视为关键字。这种方法使大多数我尝试过的汇编代码看起来相当合理。
注释语法确实很复杂。我非常喜欢原始 UNIX 注释,它只需要一个斜杠。GNU as 至今仍支持这些注释,但只有在行首时(UNIX 最初可以在任何地方放置注释,因为当时的 as
没有进行算术运算的能力)。Clang 根本不支持固定注释,所以它们在开源代码中已经不再实用了。
但这个故事还有更有趣的地方。原始 UNIX 汇编器的另一个奇怪之处是它没有在字符文字上使用结束引号。所以,我们会说 'x'
来获取 x 的 0x78,但在原始 UNIX 源代码中,你会说 'x
。这是 GNU as 继续支持但 LLVM 不支持的另一件事。无论如何,由于存在大量使用这种语法的代码,任何好的语法高亮器都需要支持它。
GNU 汇编器允许标识符被引用,因此你可以在符号中放置几乎任何字符。
最后,仅仅高亮汇编是不够的。汇编器通常与C预处理器或m4一起使用。相信我,很多开源代码都是这样做的。因此,以dnl
、m4_dnl
或C
开头的行也应被视为注释。
Ada
Ada是一种非常简单的词法分析语言,但有一件事我还没完全理解,那就是它对单引号的使用。Ada可以像C一样有字符字面量,例如'x'
。但单引号也可以用于引用属性,例如Foo'Size
。单引号甚至可以嵌入表达式和调用函数。例如,程序:
``ada with Ada.Text_IO;
procedure main is S : String := Character'(')')'Image; begin Ada.Text_IO.Put_Line("The value of S is: " & S); end main;
将输出:
The value of S is: ')'
因为我们声明了一个字符,给它赋值,然后通过`Image`函数将其转换为`String`表示。
### BASIC
让我们来谈谈初学者通用符号指令代码。在我克隆的仓库中,我遇到了这个老式的Commodore BASIC程序,它打破了我对语法高亮的许多假设。
```basic
10 rem cbm basic v2 example
20 rem comment with keywords: for, data
30 dim a$(20)
35 rem the typical space efficient form of leaving spaces out:
40 fort=0to15:poke646,t:print"{revers on} ";:next
50 geta$:ifa$=chr$(0):goto40
55 rem it is legal to omit the closing " on line end
60 print"{white}":print"bye...
70 end
我们会注意到这个特定的BASIC实现不需要在字符串末尾添加闭合引号,变量名有这些奇怪的符号,并且像goto
这样的关键字会急切地从标识符中提取。
Visual BASIC还有这种奇怪的日期字面量语法:
Dim v As Variant ' Declare a Variant
v = #1/1/2024# ' Hold a date
这很难进行词法分析,因为VB甚至有预处理器指令。
#If DEBUG Then
<WebMethod()\>
Public Function SomeFunction() As String
#Else
<WebMethod(CacheDuration:=86400)\>
Public Function SomeFunction() As String
#End If
Perl
Perl是最难高亮的语言之一。它存在于shell和编程语言之间的精神鸿沟中,并继承了两者的复杂性。Perl今天不像曾经那样流行,但其影响仍然很深远。Perl使正则表达式成为语言的一等公民,Perl中正则表达式的工作方式已被许多其他编程语言采用,如Python。然而,正则表达式的词法语法仍然相当独特。
例如,在Perl中,你可以像sed一样替换文本:
my $string = "HELLO, World!";
$string =~ s/hello/Perl/i;
print $string; \# Output: Perl, World!
像sed一样,Perl还允许你用任意标点字符替换斜杠,因为这样可以更容易地在正则表达式中放置斜杠。
$string =~ s!hello!Perl!i;
你可能不知道,也可以使用镜像字符,在这种情况下,你需要插入一个额外的字符:
$string =~ s{hello}{Perl}i;
然而,s///
并不是唯一需要像字符串一样高亮的东西。Perl有各种各样的魔法前缀。
/case sensitive match/
/case insensitive match/i
y/abc/xyz/e
s!hi!there!
m!hi!i
m;hi;i
qr!hi!u
qw!hi!h
qq!hi!h
qx!hi!h
m\-hi-
s\-hi-there-g
s"hi"there"g
s@hi@there@ yo
s{hi}{there}g
使这个高亮变得棘手的是,你需要考虑上下文,以免错误地认为y/x/y/
是除法公式。幸运的是,Perl使这相对容易,因为变量总是可以依赖于有符号,通常是$
表示标量,@
表示数组,%
表示哈希。
my $greeting = "Hello, world!";
\# Array: A list of names
my @names = ("Alice", "Bob", "Charlie");
\# 哈希:年龄字典
my %ages = ("Alice" => 30, "Bob" => 25, "Charlie" => 35);
\# 打印问候语
print "$greeting\n";
\# 打印数组中的每个名字
foreach my $name (@names) {
print "$name\n";
}
这帮助我们避免了解析语言语法的需要。
Perl还有这种为源代码编写手册页的古怪约定。基本上,行首的任何 =word 都会启动它,而 =cut
会结束它。
#!/usr/bin/perl
\=pod
=head1 名称
my_silly_script - 展示 =cut 语法的 Perl 脚本
=head1 概要
my_silly_script [选项]
=head1 描述
这个脚本没有任何有用的功能,但展示了 Perl 中 POD 文档的奇特 =cut 语法。
=head1 选项
没有选项。
=head1 作者
你的名字 <your.email@example.com>
=head1 版权
版权所有 (c) 2023 你的名字。保留所有权利。
=cut
print "Hello, world!\n";
Ruby
在所有语言中,我为最后保留了最好的,那就是 Ruby。现在这是一种语法逃避所有理解尝试的语言。Ruby 是所有早期语言的联合,甚至没有正式文档。他们的手册有一个 关于 Ruby 语法[8] 的部分,但细节很少。每当我尝试测试语法高亮,通过连接硬盘上所有的 .rb 文件,总有另一个文件以某种方式破坏它。
def `(command)
return "just testing a backquote override"
end
由于 ruby 支持反引号语法如 var = \
echo hello``,我不太确定如何判断上面的反引号不是要作为字符串高亮。另一个例子是:
when /\\.\*\\.h/
options\[:includes\] <<arg; true
when /--(\\w+)=\\"?(.\*)\\"?/
options\[$1.to\_sym\] = $2; true
Ruby 有一个 <<
操作符,它也支持 heredoc(就像 Perl 和 Shell 一样)。因此,我不太确定如何判断上面的代码不是 heredoc。是的,这段代码确实存在。就连 Emacs 也会搞错。在我评估过的 42 种语言中,这可能是迄今为止最令人震惊的。也许是 Ruby 在没有解析的情况下不可能 Lex。即使进行了解析,我仍然不确定如何才能理解这一点。
But wait, it gets even better. This is actually valid Ruby code: 但是,更加离谱的是,下面的代码是有效的:
puts "This is #{<<HERE.strip} evil"
incredibly
HERE
没错。
Complexity of Supported Languages
如果我根据每种语言的语法高亮所需的代码行数来对编程语言的复杂性进行排名,那么 FORTH 是最简单的语言,而 Ruby 是最复杂的。
125 highlight\_forth.cpp 266 highlight\_lua.cpp 132 highlight\_m4.cpp 282 highlight\_csharp.cpp 149 highlight\_ada.cpp 282 highlight\_rust.cpp 160 highlight\_lisp.cpp 297 highlight\_python.cpp 163 highlight\_test.cpp 300 highlight\_java.cpp 166 highlight\_matlab.cpp 321 highlight\_haskell.cpp 186 highlight\_cobol.cpp 335 highlight\_markdown.cpp 199 highlight\_basic.cpp 337 highlight\_js.cpp 200 highlight\_fortran.cpp 340 highlight\_html.cpp 211 highlight\_sql.cpp 371 highlight\_typescript.cpp 216 highlight\_tcl.cpp 387 highlight\_kotlin.cpp 218 highlight\_tex.cpp 387 highlight\_scala.cpp 219 highlight.cpp 447 highlight\_asm.cpp 220 highlight\_go.cpp 449 highlight\_c.cpp 225 highlight\_css.cpp 455 highlight\_swift.cpp 225 highlight\_pascal.cpp 560 highlight\_shell.cpp 230 highlight\_zig.cpp 563 highlight\_perl.cpp 235 highlight\_make.cpp 624 highlight\_ruby.cpp 239 highlight\_ld.cpp
263 highlight\_r.cpp
参考链接
1. 贾斯汀的网页: https://justine.lol/index.html
2. llamafile: https://github.com/Mozilla-Ocho/llamafile/
3. TIOBE索引: https://www.tiobe.com/tiobe-index/
4. Scratch: https://en.wikipedia.org/wiki/Scratch_%28programming_language%29
5. gemma 27b it: https://huggingface.co/Mozilla/gemma-2-27b-it-llamafile
6. lisp.js: https://justine.lol/sectorlisp2/lisp.js
7. SectorLISP 博客文章: https://justine.lol/sectorlisp2/
8. 关于 Ruby 语法: https://ruby-doc.org/docs/ruby-doc-bundle/Manual/man-1.4/syntax.html