我日常用的编辑器是 Helix。我从来没深入用过 Vim,所以转到 Helix 并没有什么适应成本——反而更自然:Helix 开箱即用的体验很好,而且"先选择再操作"(select-then-act)的编辑模型比 Vim 的"先动词再名词"对我来说更直觉。
Helix 主线目前没有插件系统,这一直是社区里最大的痛点之一。不过 mattwparas 维护了一个 fork(steel-event-system 分支),集成了 Steel——一个嵌入式 Scheme 实现——作为插件运行时。Scheme 是 Lisp 的一个方言,而 Steel 是 Scheme 的一个方言,所以你大概能猜到写插件的体验是什么画风:很多括号。
我在 AUR 上打包了这个 fork:helix-steel-git,Arch 用户可以直接装。
其实我很早就知道这个 fork 的存在,但一直没有真正去用——原因很简单,我是真的怕括号。然而最近我彻底抛弃了 VSCode,全面转向 Helix 作为唯一的编辑器,随之而来的问题是:对插件的需求越来越强烈。而 Steel 生态目前能用的插件屈指可数,想要什么功能基本只能自己动手。于是我终于硬着头皮开始写 Scheme 了。
第一版:让 LLM 来
第一步是从社区已有的插件里复制了一套模板,建好 wakatime.hx 仓库,然后直接把需求丢给 Claude,让它生成第一版代码。毕竟我对 Lisp 一无所知,连基本语法都得现查。
Claude 确实给了我一个能跑的版本。问题是——LLM 生成的 Lisp 代码,人类读起来比手写的还要痛苦。比如获取编辑器版本号这段:
(define (editor-version)
(if *wakatime-editor-version*
*wakatime-editor-version*
(begin
(let ([spawned (spawn-process
(with-stdout-piped (command "hx" (list "--version"))))])
(if (Ok? spawned)
(let ([output (wait->stdout (Ok->value spawned))])
(if (Ok? output)
(let ([version (extract-version (Ok->value output))])
(set! *wakatime-editor-version* version)
version)
"unknown"))
"unknown")))))
嵌套的 if-let-if-let-if,层层剥洋葱式地处理 Ok? 结果——典型的"LLM 不知道这门语言的惯用写法,只好用最保守的方式堆逻辑"。能跑,但读起来让人头大。
学点 Scheme
在 LLM 生成的屎山上缝缝补补了一阵之后,我意识到这样下去不行——连自己的代码都读不懂,出了 bug 也没法调。于是我决定停下来,花点时间学一下 Scheme 的基本概念。
我有 ML/Haskell 的基础,而且一直听说 Lisp 也是函数式语言,心想应该不难上手。事实证明 Scheme 的核心概念确实简单得可怜:S-expression、define、lambda、let、cond、map/filter/fold——如果你写过任何一门 FP 语言,这些东西几分钟就能理解。没有复杂的类型系统,没有 monad,没有 trait bound,甚至连语法都几乎不存在——一切都是列表,一切都是表达式。
但括号是真的会把人套晕。Haskell 里你靠缩进和 $ 来组织结构,ML 里有 let...in、match...with 这些视觉锚点。Scheme 里只有括号,全是括号,写到第三层嵌套就开始数不清哪个右括号对应哪个左括号了。
让代码变得能读
我开始思考怎样提高可读性。
第一步:拆函数。 既然括号一多就头晕,那就把括号拆出去。比如前面那个 send-heartbeat!,原先把"路径校验→节流判断→构造参数→启动进程→等待结果→错误处理"全塞在一个函数里,嵌套五六层。拆完之后变成了这样:
(define (send-heartbeat-for-path! path is-write lineno cursorpos)
(cond
[(not (trackable-path? path)) (warn-untrackable-document!)]
[(not (should-send-heartbeat? path is-write)) #f]
[else
(record-heartbeat-instant! path)
(log-heartbeat-start! path is-write)
(spawn-heartbeat-thread! path is-write lineno cursorpos)]))
每个分支一行,逻辑一目了然。trackable-path?、should-send-heartbeat?、spawn-heartbeat-thread! 各自是独立的小函数,单独看都很简单。Scheme 里没有类型签名帮你理解意图,函数名就是唯一的文档——拆得越细,名字取得越好,代码越好读。
第二步:翻 Steel 的 API。 Steel Book 虽然很多 API 只有个名字没有具体文档,但从命名大概能猜出用途。翻了一圈之后收获不少,比如发现 ~> 这个 threading macro——它就是 Scheme 版的管道操作符,可以把嵌套调用展平成线性的数据流:
;; 之前:嵌套的 spawn-process + wait->stdout + extract-version
(define (detect-version-with command-name)
(~> (command command-name '("--version"))
with-stdout-piped
spawn-process
(ok-and-then wait->stdout)
(ok-and-then extract-version)))
再比如 ok-and-then(类似 Rust 的 and_then)可以链式处理 Result,unwrap-or 提供默认值,when/unless 替代单分支的 if——这些都是小东西,但组合起来让代码从"勉强能读"变成了"还挺舒服"。
不过 Steel 有一个坑:它同时存在两套错误处理机制。一套是 Scheme 传统的 exception——用 call-with-exception-handler 捕获,靠 raise 抛出;另一套是从 Rust 那边带过来的 Result 类型,Ok / Err 显式返回。有些 API 返回 Result(比如 spawn-process),有些直接抛 exception(比如 split-whitespace 遇到空字符串),混在一起用的时候非常别扭——你写着写着 ok-and-then 链,突然中间某个函数不返回 Result 而是直接炸了,整条链就废了。
为了让自己能舒服地用 Result 链式处理一切,我写了一个宏把 exception 风格的错误统一转成 Result:
(define-syntax try-result
(syntax-rules ()
[(try-result body ...)
(call/cc (lambda (k)
(call-with-exception-handler (lambda (exn) (k (Err exn)))
(lambda ()
(Ok (begin body ...))))))]))
用 call/cc 捕获当前 continuation,在 exception handler 里直接跳出并返回 Err,正常执行则包成 Ok。这样任何可能抛异常的表达式都能包一层 try-result,然后无缝接入 ok-and-then 链。比如 extract-version 里 split-whitespace 和 second 都可能炸,包一下就安全了:
(define (extract-version output)
(~> output trim split-whitespace second try-result))
写 Rust 的人对这个模式应该很熟悉——本质上就是手搓了一个穷人版的 ? 操作符。
这样一套组合拳下来——拆函数、用 threading macro、统一错误处理——代码的可读性提高了一大截。更重要的是 debug 终于不再是噩梦了:以前出了问题只能对着一整坨嵌套逻辑盲猜,现在每个小函数职责单一,哪一步出错一眼就能定位到。Scheme 本身没有什么高级的调试工具,能靠的就是代码本身足够清晰。
Plugin string:一个小字段引发的血案
代码结构理顺之后,接下来遇到的问题就是业务层面的了。
WakaTime 的 --plugin 参数用来标识编辑器和插件信息,后端靠它来分类统计。听起来就是拼个字符串的事,但实际上 WakaTime 官方后端和 Wakapi(自部署的开源替代)对这个字段的解析逻辑不一样,而 Wakapi 这边卡得非常严格。
最初 Claude 生成的版本拼了三段:helix/24.7 helix-steel-wakatime/0.1.0 helix-wakatime/0.1.0——Wakapi 解析出来的编辑器名不对。砍成两段 helix/24.7 wakatime-hx/0.1.0——Wakapi 还是不认,它的 user-agent parser 要求插件名严格匹配 <editor>-wakatime 的模式。
来回折腾了几个 commit 之后,最终定格在:
(define *wakatime-plugin-name* "helix-wakatime")
;; 生成结果: "helix/24.7 helix-wakatime/0.1.0"
只有 helix-wakatime 这个命名能同时喂饱两边:WakaTime 官方后端能认,Wakapi 也能正确从中解析出编辑器名 helix。一个字符串拼接,改了三次才对——这种问题没有文档,只能靠试。
去抖和限流
WakaTime 插件的核心工作就是在合适的时机发 heartbeat。但"合适的时机"并不简单——你不可能每敲一个字符就调一次 wakatime-cli,那样 CPU 和网络都受不了。很多社区插件做了限流(throttle):同一个文件在 2 分钟内只发一次 activity heartbeat。这没问题,但不够。
限流解决的是"发太多"的问题,但没解决"发太早"的问题。想象你打开一个文件快速输入了一行代码——每一次按键都触发 document-changed 事件,第一次按键就通过了限流检查并发出了 heartbeat,然后接下来 2 分钟内的编辑全被吞掉了。结果就是:heartbeat 记录的时间戳是你开始打字的瞬间,而不是你停下来的瞬间。对于统计来说差别不大,但如果你在意 heartbeat 的时间精度(比如用 Wakapi 看时间线),这个偏差会很明显。
所以我额外做了去抖(debounce):编辑事件不直接发 heartbeat,而是启动一个 2 秒的延迟回调。如果在这 2 秒内又有新的编辑,就丢弃旧的回调、重新计时。只有真正停下来 2 秒之后,才发出 heartbeat。
实现上用了 generation counter:每次编辑给对应文件的计数器 +1,延迟回调触发时检查计数器是否还和当时一致——不一致说明中间又有新编辑,直接丢弃:
(define (schedule-idle-heartbeat-for-path! doc-id path)
(let ([generation (bump-doc-generation! path)])
(enqueue-thread-local-callback-with-delay
*wakatime-idle-delay-ms*
(lambda () (send-idle-heartbeat-if-current! doc-id path generation)))))
(define (idle-heartbeat-current? doc-id path generation)
(and (editor-doc-exists? doc-id) (= (doc-generation path) generation)))
这比维护一个 timer ID 再 cancel 的方案要简洁——不需要取消任何东西,过期的回调自己就知道该静默退出。去抖之后再过限流,两层过滤下来,heartbeat 既不会太密也不会太早。
总结
写完这个插件之后,我对 Steel 的印象比预期好很多。它提供的 API 覆盖面相当不错:register-hook 可以监听文档打开、保存、关闭、内容变更、选区变化等事件;editor-document->path、editor-document->language、editor->doc-id 这些能直接拿到编辑器状态;spawn-native-thread 让你把耗时操作丢到后台线程;enqueue-thread-local-callback-with-delay 提供了原生的延迟回调;instant/now + duration-since 做时间计算;甚至连进程管理(spawn-process、wait、wait->stdout)和 Result 链式处理(ok-and-then、unwrap-or)都有。对一个编辑器插件运行时来说,这套 API 已经足够完整了——我觉得甚至比一些老牌编辑器(没错我说的就是 Emacs)的插件接口设计得更现代。
如果 Steel 有机会合入 Helix 主线,潜力会非常大。目前最大的障碍可能不是技术,而是 Scheme 本身——它会劝退不少人。说实话,同样是 FP,我还是更喜欢 Haskell 或 F# 的风格:有类型系统帮你兜底,有 pattern matching 让代码自文档化,不用在括号海里游泳。但话说回来,Haskell 和 F# 的运行时对嵌入式场景来说都太重了,而 Steel 作为一个纯 Rust 实现的 Scheme,嵌入成本极低,这大概是它被选中的最务实的原因。
插件仓库在 wakatime.hx,装了 helix-steel 的话可以直接用。