major mode 作者必看:如何用 font-lock 实现语法高亮

先开个头,之后慢慢更新

网上有很多声称教人写 major mode 的教程,但是我认为都没有深入到能让人写出高质量的 font lock,而 Emacs 的自带文档也有很多是没写清楚的,所以在这里我记下一些在改进 j-mode, elvish-mode 和写出自已原创的 mkfile-mode, teco-mode 这些我认为质量比大部分 emacs mode 甚至其它所有编辑器实现的都要好的 font lock 代码时积累的体会。这篇文章只会讲写 font lock 时的难点,具体实现简单的可以参考 Emacs 自带的 python-mode, js-mode, 我写的 sysrpl-mode,稍复杂的 org mode, 和我写的 teco-mode。

我写在 mode 都在 https://github.com/LdBeth/InfernoEmacs/blob/master/core/

写在前面: 为什么(还)要用 font-lock

以前的话还没有 tree sitter 可以选,我也用 treesit 写过 rnc-ts-mode,但如何写 treesit parser 这里不会讲,而我做的 teco-mode 和 j-mode 都是无法很好用 context free grammar 描述的语言,这时用 Emacs 的 font-lock 反而有极大优势。

font-lock 的限制

因为性能原因,font-lock 很多时候要在没有完整代码的情況完成高亮,这是比单纯实现一个 parser 难的事,也就是所谓的增量 parser,所以这里要劝退一波:没写过 parser 的就先不要往下看了。即使能实现增量 parsing,很多时候也要在尽可能和编程语言的 parsing 规则近似和不卡住 emacs 之间做出取舍。

一切的最基础: syntax-table

这个相当于 parser 中的 lexing 阶段,严格来说不属于 font lock,但做为实现准确的 font-lock 的前提很多 major mode 连 syntax-table 都没做好,虽然其实对大部分编程语言没啥大影响罢了。syntax-table 本身能用来决定的东西有几样:(-, w, _, /, ')什么样的字符能组成 symbol 和 operator,(", \ ,$, |) 字符串和 (<, >, !)注释的格式,((, )) 括号的匹配。为了能减轻在处理不完整代码时的负担,能用 syntax-table 实现越多功能那就越好,尤其是复杂 string 和 comment 的高亮,虽然在 font lock keywords 里不是不能做,但效果定然不会比用 syntax property 处理好,实现了正确的 syntax-table,就能用之后提到的 syntax-ppss 做为 parser 来处理复杂的高亮情況。

理所當然地,设计一个合理的 syntax-table 要对编程语言的语法有全方位的了解。

string/comment fence

指 syntax 中的 "|""!",它们一般不直接设在 syntax-table 里,而是由下面的 syntax-propertize-function 设置。两个具有 "|" syntax 的字符(可以是不同字符)中间的內容会被 Emacs 处理成 string,自动上色,优先级高于中间的其它 string 和 comment property。"!" 同理,即是处理成 comment。

syntax-propertize-function

很多语言的字符 syntax properties 和它前面的字符相关的,这个变量的函数就是处理这种情况的。比如 J 当中定义字面量可以是

{{)n }}

这里的 ) 是声明语法的一部分,不和其它括号匹配,就要在 syntax-propertize-function 里处理。用 syntax-propertize-rules 宏可以处理大多数 context free language 的情況,但对于极度 context sensitive 的 TECO 来说,手写 syntax-propertize-function 不可避。这个 function 接受 beginend,在 function 中越过 end 是不可靠的,因为很可能后面的字符都没加载进来,但向前越过 begin 一般是可行的,而且在这个 function 里可以用 syntax-ppss 调查 begin 之前的 syntax 状态,如 begin 是不是处在字符串里。

换句话说,如果一个编程语言不但是 context sensitive 还需要从后向前 parse,那 Emacs 也搞不定。

其他常用场景有,注释的开头由超过两个字符组成时,单靠 syntax table 做不了,就要用这个函数处理。

这个函数退出时应该保证 point 在最后一个 syntax propertied 字符的正后面。 这样 Emacs 会在下次调用这个函数时从这个位置开始。这个函数没有 propertize 的字符会用 syntax-table 里的值。

syntax-ppss

syntax-ppssparse-partial-sexp 的区別是前者用的是 Emacs 调用 parse-partial-sexp 后缓存的数据,所以后者参数是个 region,前者是调查一个 position,注意 syntax-ppss 如果有参数是会移动 point 的,所以要用 save-excursion 保存当前 buffer position。

它最常见的用处是处理如 Python 的 multiline string,在 syntax-propertize-function 中遇到 ''' 时调查当前是否在 string 中,如果不是,开始一段新的 multiline string,如果是,而且 string 的开头不是 """ 则当做正常字符,否则开头是 ''' 就关闭 multiline string。

""" 可以有 '''  """
''' 可以有 """ '''

Emacs 的正则表达式

有几个在其他正则实现通常没有的符号,beginning/end of symbol 和 beginning/end of word,在正确设置 syntax table 的情况下能简化 font lock 需要的正则。正则的使用不在这篇文章的范围里。

要注意的是,用 char class […] 时,][^- 都是特殊字符,而且要作为 char class 成员时不是通过 escape 而是通过一定排列达成的,建议参考 rx 宏的生成结果。

如何正确滥用 font lock keyword

font-lock-keywords,顾名意义,可以用来实现编程语言中关鍵字的高亮,一般作为 font-lock-defaults 的第一个元素在 define-derived-mode 內设定,而不是直接设置,因为 Emacs 可能会把它进行编译。

注意关键字的正则一般应当以 \\` (bos) 或 \\< (bow) 开头,不然一般的变量名的 substring 中有关鍵字也会被高亮。

除了关键字之外,还可以处理如把函数定义的函数名染成 function name face,此外像 elisp-mode 对没有合理换行的语句标注 warning face 也是通过这个功能实现的。

对于在 operator 之间可以不需要空格的语言,比如 J,font-lock-keywords 中的顺序是比较重要的,不同順序会影响,推荐长的关键字/操作符放在列表的前面,优先处理。这个顺序也可以用 HIGHLIGHTMATCH-HIGHLIGHT 形式中

(SUBEXP FACENAME [OVERRIDE [LAXMATCH]])

OVERRIDE 来影响。

MATCH-ANCHORED 则是用來处理一些关键字只有在特定区域/局部才有效,需要高亮的情况。

font-lock-keywords 的文档写得很含糊,但一般常用的形式是 docstring 中的 (MATCHER HIGHLIGHT ...)

("正则" (1 'face1) (2 'face2))

数字是正则里的 subgroup,0 表示整个正则,注意 face 得是一个 eval 成 face 符号的表达式,推荐优先用现成的 font-lock-*-face。Emacs 定义了很多变量如 font-lock-keyword-face 的值为符号本身,但不是所有 font lock 开头的 face 都这样定义了。

正则可以替換成一个 function,理论上可以用它来做一些副作用,但如 string 和 comment 还是应該优先用 syntax-propertize-function 实现(除非是 J 那样普通的 string 不能跨行这样的情況),因为 font-lock-keywords 是被假定为在一行內做匹配。

face 也可以是一个返回 nil 的 function,用 match-data 提供的范围直接调用 add-face-text-property 之类的。(返回一个 face 当然也可以)。比如要实现类似 ligature 的效果,一个有用的函数是

(compose-region beg end "display" 'decompose-region)

'decompose-region 参数保证之后删除到组合后的字符时不会整个删掉。

我对多行高亮的见解

因为 Emacs 的 font lock 机制设计成只需要重新 font lock 修改的区域,即使默认会把区域扩大到整行,也会出现 font lock 实际需要獲得比修改区域大的范围才能成功的情況。具体案例参考 请教个 font-lock-add-keywords 的问题,通过正则高亮了一个区域,但是编辑其中的内容后新添加的 face 就失效了

原理是利用 Emacs 的 text-property 来保存多行结构的边界。实际上,除了 multiline font lock 的 API,自己定义额外的 text-property 也是可行的。

font-lock-syntactic-face-function

这个变量应該通过 font-lock-defaults 设置的,但 font-lock-defaults doctoring 里没写。所以一般只能通过看代码发现了。

这个变量的函数主要用途是区別普通 string 和 docstring,分別上不同 face。不过 comment 也归它管。

特定上下文环境的高亮

少女更新中

其他没有讲的话题

如何实现代码缩进和元素之间的移动

很遗憾,这个大部分是技术活和体力活,除了自己发挥想象力,和通过不断试错来改进以外,我没有什么能提示的。

30 个赞

久旱逢甘霖啊 :smile:

有感兴趣的关于 font lock 的问题可以在这里提出来。

正在写 wgsl tree-sitter parser,写完了写 major-mode 的时候来参考参考

那估计不用看,因为不讲 treesit

之后还得搓个没有 tree-sitter parser 时的 fallback

感觉这种简单的语言直接用 emacs 本身的支持做起来应该也很容易,写 tree-sitter 纯纯的练练手

太好啦!正想学学这方面的内容,持续关注中…

提个问题,是不是有些font-lock-**-face只有 *-ts-mode能用,我今天发现 用traditional font lock的时候font-lock-function-call-face不起作用,改成font-lock-function-name-face就生效了,在源码里搜这个face发现只有内置的*-ts-mode用这个face才发现事情不对,然而文档里没说,只有这里提了一嘴。。。

;; From News.29
** New faces for font-lock.
These faces are primarily meant for use with tree-sitter.  They are:
'font-lock-bracket-face', 'font-lock-delimiter-face',
'font-lock-escape-face', 'font-lock-function-call-face',
'font-lock-misc-punctuation-face', 'font-lock-number-face',
'font-lock-operator-face', 'font-lock-property-name-face',
'font-lock-property-use-face', 'font-lock-punctuation-face',
'font-lock-regexp-face', and 'font-lock-variable-use-face'.
```

这不是强制规范,想遵守想不遵守都可以。

我的意思是我在写major-mode的时候,发现用font-lock-function-call-face来高亮关键字不生效,应该被高亮的文本用describe-char查看没有任何face,用font-lock-function-name-face就能正常显示高亮(同样的regex)。

目前不知道该怎么写个最小配置复现我的问题 :face_holding_back_tears:,因为set-text-propertiesput-text-properties是可以正常apply这个font-lock-function-call-face

'font-lock-function-call-face 注意 quote

font-lock 写错的话是不会报错的,就会表现成不生效

1 个赞

感谢指点,对backquote理解不到位把这个点忘了 :face_holding_back_tears:

(defconst custom-font-lock-keywords
  `((,(rx expresstions)
     . facename)         ;不需要quote
    (,(rx expresstions)
     (1 'facename))))    ;注意quote

不是这样的,通用形式

(defconst custom-font-lock-keywords
  `((,(rx expresstions)
     . 'facename)))

也是要这样才对。

Emacs 对比较早出现的 face 额外定义了这些变量才能不用 ',但新的 face 都没这么定义了,这其实就是 Emacs 本身不一致还从来没有地方记载导致容易踩坑的地方。或者说也算是符合 docstring 了,但光看 font-lock-keywords 的 docstring 也还是不容易想出来是了

(defvar font-lock-doc-face		'font-lock-doc-face
  "Face name to use for documentation.")

(defvar font-lock-doc-markup-face       'font-lock-doc-markup-face
  "Face name to use for documentation mark-up.")

(defvar font-lock-keyword-face		'font-lock-keyword-face
  "Face name to use for keywords.")

(defvar font-lock-builtin-face		'font-lock-builtin-face
  "Face name to use for builtins.")

(defvar font-lock-function-name-face	'font-lock-function-name-face
  "Face name to use for function names.")

(defvar font-lock-variable-name-face	'font-lock-variable-name-face
  "Face name to use for variable names.")
3 个赞

期待少女更新

今讀 rainbow-delimiters.el 的代碼,以 syntax-ppss 斷括號前後,以 font lock 渲染。憶從前用 doom-emacs,啟用 lsp 亦有彩虹括號,查詢後似 syntax table 之屬。特來聞訊,願聽詳解,幸能請教,感之不盡。

parse-partial-sexpsyntax-ppss (前者的缓存利用版本,2 和 6 的结果会可能不正确) 的结果里有

Value is a list of elements describing final state of parsing:
 0. depth in parens.
 1. character address of start of innermost containing list; nil if none.
 2. character address of start of last complete sexp terminated.
...
 6. the minimum paren-depth encountered during this scan.
...
 9. List of positions of currently open parens, outermost first.

这些信息主要通过 syntax property 计算。lsp (以 eglot 为例)是不会改变 syntax property 的,全靠 emacs 自己在 language mode 里的实现,当然对一般的编程语言也用不到太复杂的 syntax-propertize-function,所以彩虹括号能不能用和 lsp 是没关系的。

electric pair mode 也同理

1 个赞

如果我没理解错的话,想要类似的支持,只有 lsp 是不行的,必须有一个可用的 xxx-mode,比如写 c 就要 c-mode

是的