
第一次看到“caveman”这个词我脑子里跳出的是石器时代拿着棍子追野猪的画面接着想到的是程序员圈里那个“原始人调试法”——不整花活先用最朴素的打印输出把逻辑跑通再说。后来在Emacs的配置社区里频繁碰到这个词才发现它还有另一层身份一个专门负责代码语法高亮的包名而且不少人已经把它当成了整理高亮规则的默认选择。这篇东西想聊的就是这个caveman它到底是什么为什么会在Emacs生态里被频繁提及三种高亮实现——tree-sitter、Vim风格正则、声明式规则——各自适合什么场景以及我在实际配置和迁移规则时踩过的坑。适合正在折腾Emacs外观、想把语法高亮做到“够用又不折腾”的人也适合那些从别的编辑器迁过来、看着一堆高亮规则就头疼的配置党。1. 为什么到处都在提caveman它到底做了什么先说结论caveman是一个面向Emacs的语法高亮管理工具它把原本散落在各个major mode里的高亮逻辑收敛到一个统一入口。你不需要在rust-ts-mode里配一遍rust的高亮在rust-mode里再配一遍到其他支持tree-sitter的模式里还要再维护一份——这些事caveman帮你集中处理。跟EditorConfig解决“不同编辑器缩进不一致”的思路很像caveman解决的是“高亮规则重复散落”的问题。一个项目里大家的缩进统一靠.editorconfig那语法高亮能不能也统一靠一份声明式配置caveman的想法就是可以。1.1 输出太分散才是真痛点在Emacs里语法高亮传统上靠font-lock体系完成。每个major mode自己定义一组keywords正则再用font-lock-add-keywords注册进去。这本身没什么问题问题出在“分散”上每种语言的高亮逻辑都写在自己的mode文件里想改样式得翻不同包的源码新的tree-sitter语法解析已经逐步普及但是各个mode接入的进度参差不齐有的用ts模式有的还在旧的regex模式遇到几种语言混写的文件比如HTML里嵌JavaScript高亮规则经常互相干扰。我自己最早碰到这问题是在维护一个老项目的时候。项目里有TypeScript、CSS、HTML还有一堆自定义配置文件。每个文件的高亮表现都不一样同一个关键词在A文件里是蓝色在B文件里变成了绿色当时唯一的解决办法就是给每个mode手动加font-lock规则改得越多越乱。caveman就是针对这种混乱状态设计的它提供一套中立的规则描述方式再配上一套与tree-sitter grammar对接的机制。你写一份规则它负责映射到具体editor的font-lock机制里高亮马上就能用。这套思路对大项目、多语言项目尤其友好因为规则可以随项目走而不是绑死在某个mode包上。1.2 为什么叫这么不正经的名字我一开始以为这是个搞笑项目看了文档才知道名字本身就是对设计哲学的描述。作者想表达的核心是高亮规则不应该过度设计先用最简单直接的方式把颜色弄对这就是“穴居人”风格——不搞复杂的框架不引入抽象层直接告诉编辑器“这种token给我染成红色、那种染成蓝色”。这个思路和社区里常说的caveman debugging一脉相承。所谓caveman debugging就是优先用最原始的手段定位问题比如加打印、加日志、最小化复现而不是一上来就挂调试器、上APM。caveman这个项目继承了同一个气质能用声明式规则说清楚的绝不写一堆elisp函数能用正则解决的绝不强制每次解析都跑完整AST。这种克制的设计在Emacs插件里挺难得。很多包第一版写完就想着加各种feature而caveman始终守着“高亮这点事别搞太复杂”的底线实际用起来也确实比那些又大又全的方案好维护得多。2. 三种高亮机制的边界选错了才是真正的坑caveman最需要花时间理解的部分就是它支持三套高亮实现tree-sitter、Vim风格正则、声明式规则。很多人以为这三者是“选一个用”的关系其实它们是分工关系。实现方式精确度性能开销维护成本适用场景tree-sitter高基于AST节点初次解析较高后续增量需要匹配grammar版本主流语言、嵌套复杂的代码Vim正则中等依赖编写水平低正则写久了自己都看不懂简单DSL、Markdown、配置文件声明式规则中低靠节点类型匹配最低几乎零维护自定义小语言、快速验证2.1 tree-sitter精确但绑版本是绕不开的代价tree-sitter的工作原理是把源码parse成一棵具体的语法树高亮的时候不是靠正则去猜而是直接读取AST节点所以它在处理注释、字符串、嵌套语法时有天然优势。比如一个字符串里面出现了//正则方案会把后面的内容误判成注释但tree-sitter知道那还是在字符串内部。代价就是版本绑定。tree-sitter每次解析都需要对应语言的grammar库而grammar库更新很频繁今天装的和明天装的可能就不是一个版本高亮结果也会跟着变。之前我给一个项目配Rust高亮grammar升了一个小版本之后所有if、loop都失去了关键词颜色排查了半天才发现是tree-sitter-rust的节点类型命名改了capture没对上。所以用tree-sitter方案时我一般会在项目里锁住grammar的commit版本避免顶着头条更新跑。这一点后面装环境时还会细说。2.2 Vim正则稳但真的会“顺藤摸瓜摸到沟里”Vim风格正则是给有Vim迁移习惯的人准备的。它的好处在于配置方式直观一行命中的区域就直接染色不需要额外依赖tree-sitter grammar。很多老配置、嵌入式开发环境、或者不想引入太重依赖的人会优先选它。但它最大的问题也和正则本身的特性有关容易跨行误匹配。一个正则匹配上了highlight范围可能从行首一路延伸到文件末尾这种“顺藤摸瓜摸到沟里”的现象我在Markdown代码块里遇得最多。代码块里的反引号、缩进内容如果正则写得不够精细整个文档后半部分会被染成同一种颜色那种视觉冲击真的让人瞬间不想再调了。2.3 声明式越简单能力上限越明显声明式规则是三套里最好上手的甚至不需要懂正则只需要把token类型和face对应起来就行。它适合什么场景呢比如你写了一个内部配置文件格式就几种键、值、注释、布尔值。你拿tree-sitter去解析这种文件纯属杀鸡用牛刀用声明式规则五秒钟就能配完。但如果你想高亮一门像C那样需要前置声明、模板、重载的语言声明式规则就完全不够用了因为它几乎没有上下文感知能力。所以我的建议是小语言、配置文件、DSL优先用声明式正统编程语言优先用tree-sitter在老环境里保底用正则。3. 动手装一回环境准备和基础配置我做过的很多次迁移经历都指向同一件事——大部分caveman配置问题不是出在规则上而是出在环境版本上。版本不对高亮就是不出这条经验是铁的。3.1 最小配置清单用use-package管理的话最小配置大概是这个样子(use-package caveman :ensure t :defer t :custom (caveman-auto-detect-grammar t) (caveman-fallback-highlighting vim-regex) :hook (prog-mode . caveman-mode))我的习惯是先把默认mode钩子挂上让所有prog-mode都自动激活caveman然后再针对特定语言关掉或微调。比如某些语言我已经有定制得很精细的原生mode就不希望caveman再插手那就单独加一条(text-mode . (lambda () (caveman-mode -1)))。依赖方面如果只使用声明式和正则一个普通Emacs就够了。如果使用tree-sitter方案需要确保Emacs版本里带有tree-sitter支持同时装好对应语言的grammar# 以Rust为例手动编译tree-sitter grammar git clone https://github.com/tree-sitter/tree-sitter-rust cd tree-sitter-rust cargo build --release cp target/release/librust.so ~/.emacs.d/tree-sitter/这里我强烈建议把grammar的commit记录一下。比如写进一个requirements文件或一个Makefile里不然几个月之后重新配环境高亮规则会因为grammar版本漂移而变得不可复现。3.2 装完先别急着写规则验证一下安装完第一件事不是写规则而是验证基础功能有没有生效。我会做三个检查打开一个支持的语言文件M-x caveman-mode确认mode状态是开启的把光标移到某个字符串或注释上用describe-char看face值确认它是caveman设置的face而不是原生mode的face临时改一份规则文件revert一下当前buffer看看高亮有没有跟着变。这三步如果在五分钟内全部通过基本可以确定是环境问题之外的规则问题了。很多时候装完看起来“没反应”其实是caveman的face被原生theme的face覆盖了这时候加一行(defface caveman-function-name-face ((t (:foreground #d19a66 :weight bold))) Face for function names.)然后指定theme使用这个face即可。记住caveman的定位是高亮规则管理不是颜色主题。它负责告诉你哪一段是什么类型的token但最终显示成什么颜色是theme决定的。这两层别搞混。4. 把高亮规则写明白一眼就懂的语言配置文件接下来说重头戏——规则怎么写。caveman的规则文件把每种语言的高亮定义成一张表每行一个pattern告诉caveman“什么类型的token该用哪个face”。我用一个自创的小配置语言来演示它只有字符串、注释、关键字、数字、函数名这五类token。4.1 一份最小可用的规则文件示例我习惯用TOML来组织规则因为它层级清晰写起来不折腾。结构大致是这样[language] name mapc extensions [.mapc] [[rules]] type string pattern \[^\]*\ face font-lock-string-face [[rules]] type comment pattern #.*$ face font-lock-comment-face [[rules]] type keyword pattern \\b(if|else|for|while|return)\\b face font-lock-keyword-face [[rules]] type number pattern \\b[0-9]\\b face font-lock-constant-face [[rules]] type function pattern ^[a-zA-Z_][a-zA-Z0-9_]*\\s*\\( face font-lock-function-name-face这里面的type、pattern、face字段对应的意思分别是type规则的类型标识用来和tree-sitter的capture做映射时用pattern正则表达式决定哪些文本会被识别出来face高亮使用的face名称这里是Emacs内置的font-lock系列face。如果你用的是tree-sitter方案而不是正则方案那pattern字段可以留空改成capture字段指向对应语言的tree-sitter capture名字。比如r语言里一切是string你直接映射到font-lock-string-face就完事。4.2 pattern匹配顺序和优先级初学者最容易忽略的就是顺序问题。规则是从上到下逐条匹配的后面的规则会覆盖前面的规则。比如上面示例里number规则写在keyword规则后面那么一个叫“if1”的标识符如果正则能匹配上会先被keyword规则染成关键字颜色再被number规则尝试覆盖。实战里我习惯把最长的、最具体的规则放前面短的、泛的规则放后面。注释规则如果放太靠后很可能被字符串规则吃掉导致注释颜色永远不出现。另外不建议把正则规则写得太过宽泛尤其是.*这种东西。一条.*规则能匹配一大片轻则高亮无意义重则把整个文件刷成同色。写正则的时候多锚定行首行尾多用\b做边界比事后加补充规则省太多事。5. 从Vim正则迁移或者把tree-sitter capture映射好二者怎么对齐5.1 Vim正则与Emacs方言的差异对照如果你是从Vim迁到Emacs最痛苦的就是正则方言差异。caveman支持Vim风格正则但跑到Emacs环境里还是有一层转换常见差异如下Vim正则写法含义Emacs/caveman对应写法\vvery magic模式一般不需要直接写\Vvery nomagic模式全部字面匹配Emacs用\做不到需手动转义\zs\ze设定匹配区域起点和终点用\\(...\\)分组替代\~上一次替换的字符串没有直接对应建议显式写出来\%(...\)分组但不捕获\\(?:...\\)举例说明Vim正则里要匹配一个“没被转义的双引号字符串”常见的写法是\zs[^]*\ze用Emacs的正则需要改写成\\([^]*\\)。少了这个转换经常出现高亮偏移一格的问题。多语言项目里还有一类经典踩坑场景是Markdown。Vim里大家习惯用\v魔法模式快速匹配但同一套正则放到caveman里就需要做完整转义。我的建议是一开始就别让Vim正则习惯延续到caveman规则文件里全部按Emacs方言来写免得同一套规则在两种模式下对不上。5.2 tree-sitter capture映射别硬搬tree-sitter方案的capture名字在不同语言间并不完全一致。拿最常见的几个来说capture语义映射到facestring字符串字面量font-lock-string-facecomment注释font-lock-comment-facekeyword关键字font-lock-keyword-facefunction.call函数调用font-lock-function-name-facetype类型名称font-lock-type-faceconstant.builtin内建常量font-lock-constant-face实际迁移的时候你会遇到大量非标准capture比如有的语言里泛型参数是type.parameter有的语法里函数名是identifier。这些capture如果不做映射就会被caveman直接丢弃结果就是那一部分文本没有任何高亮看起来像是“漏了一块”。排查办法很简单打开一个包含目标语法的文件启用“显示所有未命中字符”的调试机制caveman会把没有对应规则的token高亮成一种刺眼颜色。看到哪个位置变色就知道哪个capture没映射上再回queries文件里找出准确的capture名字补上就行。这个环节特别能体现caveman的价值你不需要知道每个tree-sitter grammar内部怎么组织的只需要维护一张capture映射表。6. 实测中踩过的三个坑处理思路一并贴出来6.1 注释块之后整段变色歪到文件末尾现象某段注释后面的所有代码都染成了注释色滚动页面的时候高亮范围跟着往下走。我第一次遇到以为是face配置写错了清空自定义规则之后问题依旧。后来定位出来是正则规则的问题——定义注释时用了/\*.*\*/学过正则的都知道.*默认不换行但如果Emacs的正则引擎在高亮里配置了跨行匹配这段规则就会从第一个/*一路匹配到最后一个*/中间无论多少代码都被算作注释。处理思路也很简单用更精确的边界把注释结束符明确写出来或者限制单行匹配规则字段里加:line-limited t再不行就把这条规则从正则方案挪到tree-sitter方案AST节点天然知道注释的确切范围。这个坑是“正则方案做注释高亮”里最经典的翻车场景越早熟悉越省心。6.2 模板字符串内部${}表达式被染成字符串色JavaScript里的模板字符串比如${name}在声明式规则里很容易整段匹配成字符串内部的动作变量、属性访问也被染成同一个颜色视觉上分不清哪里是插值。实际配置中我用的是tree-sitter方案正常来讲它能识别出${...}内部的表达式节点。但我在project里跑起来之后插值还是被染成了字符串色原因是caveman的规则优先级设置——我给了字符串规则很高的优先级让它优先匹配结果反过来压制了表达式节点的颜色。这里最重要的经验是优先级不是越高越好。priority只应该在确实需要覆盖错误染色时才设置。现在我的做法是让tree-sitter的节点capture保持默认优先级仅当出现真实冲突时才针对性调高某一条规则的优先级而不是全局设置一个超大的数字。6.3 规则一多输入开始卡顿配置完一套中等规模语言的规则之后我发现在大文件里输入字符会有明显延迟每个字符都要跑一遍全部规则的正则匹配开销自然大。处理思路分三层先将规则的适用范围缩小正则能限定语言特性的就限定好比如用锚定、字符类避免规则在无关区域做无用功把不必须实时高亮的复杂规则改成延迟计算caveman支持让某些规则在buffer空闲时才刷新这样输入时不受影响如果能用tree-sitter的区域就尽量用tree-sitter让解析器按AST增量更新而不是全量重新跑正则。这三层我目前已跑了一个月大文件的输入流畅度明显恢复了。实际用下来我用caveman最大的体会是别再追求把所有逻辑都塞在声明式规则里关键区域交给tree-sitter简单规则用声明式老配置兜底用正则三分天下各管一段才是使用它的正确姿态。如果你的高亮规则也开始越写越多先停下来做减法比继续堆规则有用得多。