ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Emacs 配置优化:用 context-mode 把 minor-mode 切换变成声明式规则表

Emacs 配置优化:用 context-mode 把 minor-mode 切换变成声明式规则表 你身边有没有这样一类 Emacs 用户配置文件里堆了几十段 advice 和 hook每个 major-mode 都挂上三五个 lambda只为了让不同场景下自动打开或关闭某些 minor-mode结果改一行配置要翻半天还总在奇怪的地方踩到冲突。我曾经就是这种人直到把 context-mode 接进来那段反复增删的胶水代码才从两百多行缩到了二十多行。先说清楚这里的 context-mode 指 Emacs 生态里那个根据上下文自动切换 minor mode 的小包不是什么玄学框架。它做的事情本质上很简单把“当前 buffer 符合什么条件时启用/关闭哪个 mode”这条逻辑从散落的 hook 里统一抽出来变成一张声明式的规则表。如果你经常在代码、文档、配置文件之间来回切换又不想每个文件类型都手动敲 M-x这篇文章应该能帮你省下不少时间。我会从为什么需要它讲起然后给出可直接抄的最小配置、规则系统的设计逻辑、实测场景以及我排错时踩过的几个真实的坑。1. 为什么需要 context-mode从手动切换模式说起1.1 典型痛点mode 的开与关不该靠手工先说个场景。我平时写 elisp 的时候会打开 highlight-symbol-mode方便看变量在哪些地方被引用写 C/C 的时候要开 modern-c-style因为它会把缩进和光标行为都调成更现代的风格写 Markdown 的时候又要开 visual-line-mode避免长段落被硬折断。这些需求单独看都很合理但放一起就麻烦了每次切换文件我都得自己检查当前开着什么、缺着什么然后手动补。一开始我用 major-mode hook 解决比如(add-hook emacs-lisp-mode-hook #highlight-symbol-mode) (add-hook c-mode-hook #modern-c-style)看起来还行但需求一旦复杂一点就露馅。比如我想“在某个项目里用 tab 缩进在另一个项目里用空格缩进”或者“大文件里关掉行号普通文件里打开行号”hook 里就开始出现各种条件判断。再往后多个 hook 之间还可能互相打架你在 c-mode-hook 里开了 linum-mode又在 find-file-hook 里判断“如果是大文件就关掉”这几段代码的执行顺序一旦没理清行为就变得非常随机。这种时候真正缺的其实是一个统一的、可排序、可调试的规则入口。1.2 context-mode 的定位把“上下文”和“模式”绑定成规则表context-mode 的核心思路是把配置从“命令式”改成“声明式”。你不需要再关心某个 hook 什么时候跑、跑了之后会不会被后续 hook 覆盖你只需要声明在什么条件下什么 mode 应该处于什么状态。剩下的由 context-mode 在合适的时机去检查。它管的东西主要有三类打开某个模式比如进入 elisp 文件就开启 highlight-symbol-mode。关闭某个模式比如进入大文件就把 display-line-numbers-mode 关掉。执行一段自定义逻辑比如根据项目目录设置 tab-width或者在特定 buffer 里改一个变量的值。对比一下就清楚了。传统写法(add-hook emacs-lisp-mode-hook (lambda () (highlight-symbol-mode 1))) (add-hook c-mode-hook (lambda () (modern-c-style 1))) (add-hook python-mode-hook (lambda () (anaconda-mode 1)))用 context-mode 的规则表写法(context-mode-define-rule :name elisp-highlight :file \\.el\\ :major-modes (emacs-lisp-mode) :enable (highlight-symbol-mode)) ;; 其他规则类似区别不在于少写了几行而在于所有“在什么环境下开什么模式”的决策变成了一张表你可以从上到下读它、改它而不是在一堆 hook 之间追来追去。1.3 适合谁使用不是所有人都需要 context-mode。如果你的工作是单一语言环境比如只写 Python那直接(add-hook python-mode-hook #whatever-mode)就够了没必要多引一个包。它更适合下面这几类人配置里已经有超过三处按 major-mode 分叉的 lambda且开始出现“我只想在某类文件里才这样”的需求。经常在不同类型文件之间切换手动开关 minor-mode 的频率高到影响心流。希望把配置文件整理得更声明式、更好向他人解释。维护一份跨工作、跨项目的配置需要根据目录或项目上下文自动调整行为。如果你符合其中两条后面这些内容应该对你有用。2. 上手与最小配置从零开始跑通第一条规则2.1 安装与启用use-package / 手动 require我建议直接用 use-package 管理。安装代码长这样(use-package context-mode :ensure t :config (context-mode-define-rule :name elisp-highlight :file \\.el\\ :major-modes (emacs-lisp-mode) :enable (highlight-symbol-mode)) (context-mode-on))如果你是手动管理的用户把 context-mode.el 放到 load-path 里然后(require context-mode)之后再(context-mode-on)即可。这里有个细节值得注意不同版本里 context-mode 暴露的接口名可能略有差异。我下面写的context-mode-define-rule来自我在用的版本如果你的包管理器装的是更新版本先执行M-x describe-function搜一下context-mode-开头的函数确认函数名别直接抄完发现报void-function又来问我。启用时机也讲一下context-mode-on本身只是启动规则检查的开关真正的检查发生在 buffer 切换、文件打开、major-mode 变更这些时机。所以我一般放在use-package的:config末尾这个位置能覆盖绝大部分场景。2.2 第一条规则怎么写先写一个最有代表性的打开 elisp 文件时自动开启 highlight-symbol-mode。(context-mode-define-rule :name elisp-highlight :file \\.el\\ :major-modes (emacs-lisp-mode) :enable (highlight-symbol-mode))逐行解释一下:name是给规则起的名字会在日志和排查时出现。建议起得能一眼看出意图。:file是一个正则表达式匹配 buffer 的文件名。\\.el\\的意思是“以 .el 结尾”。:major-modes是允许触发这条规则的 major-mode 列表。这里限定为emacs-lisp-mode。:enable是进入上下文时要启用的小模式列表。你可以发现它既是文件名过滤又是 mode 过滤。两者是“与”的关系文件后缀匹配并且当前 major-mode 在列表里规则才生效。这样加一层比较好因为有些文件的后缀和实际 major-mode 并不对应单靠一个条件容易误触发。对比传统写法(add-hook emacs-lisp-mode-hook (lambda () (when (string-match-p \\.el\\ (buffer-file-name)) (highlight-symbol-mode 1))))逻辑一样但可读性天差地别。规则表的方式让你一眼就知道这条规则在什么条件下干了什么。2.3 启用时机问题daemon 与 GUI 场景的差异如果你的 Emacs 是以 daemon 模式启动的也就是常说的emacs --daemon或者用过server-start这里有个小坑daemon 启动时可能还没有真正打开任何 buffer或者当前 buffer 是*scratch*这类特殊 buffer。这时候如果规则里有依赖buffer-file-name的谓词它拿到的可能是 nil容易导致规则直接报错或者被跳过。我的做法是daemon 环境下把context-mode-on放到emacs-startup-hook里而不是在:config里直接调用(use-package context-mode :ensure t :config (context-mode-define-rule ...) (add-hook emacs-startup-hook #context-mode-on))GUI 和终端环境下都这么写也没问题因为emacs-startup-hook在初始化完成后一定执行。如果你发现规则一直不生效先检查这一步很多人都是卡在启用时机上规则定义了但检查引擎根本没跑起来。3. 规则系统的设计逻辑匹配条件、动作与作用域3.1 匹配条件的常见写法context-mode 的匹配条件不止是文件名。我实际用下来觉得有四种条件最常见整理成表格条件类型写法示例用途文件名正则:file \\.el\\按文件后缀或路径匹配major-mode 列表:major-modes (emacs-lisp-mode)按当前主模式匹配buffer 名正则:buffer \\*.*\\*匹配特殊 buffer 或临时 buffer自定义谓词:predicate (lambda () ( (buffer-size) 1000000))任意 Lisp 表达式返回非 nil 即命中前两个是最常用的后两个是我后来才发现的用法。特别是自定义谓词它让 context-mode 从一个“文件类型分发器”变成了真正的“上下文分发器”。比如我想根据当前项目目录判断缩进风格(context-mode-define-rule :name company-tab-indent :predicate (lambda () (string-prefix-p /home/me/work/company-a/ (or (buffer-file-name) ))) :actions ((setq-local tab-width 4) (setq-local indent-tabs-mode t)))注意这里我用了:actions而不是:enable因为我不只是想开个模式而是想设置两个 buffer 局部变量。这种写法让规则不只是“开/关模式”还能顺带做初始化。3.2 动作不局限于 minor-mode规则支持三类动作灵活度比大多数人想象的要大:enable进入上下文时启用这些小模式。:disable进入上下文时禁用小模式。:actions进入上下文时执行任意 Lisp 表达式。有同学可能会问:actions和直接用 hook 有什么区别区别在于 context-mode 会统一管理这些规则的生命周期。当你离开这个上下文时它可以根据配置把模式状态恢复回去或者按其他规则重新计算这是手写 hook 很难做到的部分。举个例子我想在写 LaTeX 时自动把 TeX 相关的 mode 清理干净避免 AUCTeX 的一些 buffer 局部模式残留(context-mode-define-rule :name latex-cleanup :file \\.tex\\ :major-modes (latex-mode LaTeX-mode) :disable (preview-mode) :actions ((setq-local TeX-auto-untabify t)))这条规则的效果是进入.tex文件后preview-mode被关掉同时TeX-auto-untabify被设置为 t确保 Tab 不会混进文件里。如果你的团队有“禁止 tab 缩进”的规范这种写法比在导出钩子里到处埋点干净得多。3.3 作用域与优先级buffer-local 还是全局生效context-mode 的规则天然是 buffer-local 的——它只影响当前 buffer。这点很重要因为很多全局开启的模式恰恰需要按 buffer 单独关掉。比如display-line-numbers-mode是全局模式但你在阅读长文档时往往不想要行号。这时可以用规则在当前 buffer 里禁用它(context-mode-define-rule :name no-linum-in-org :file \\.org\\ :major-modes (org-mode) :disable (display-line-numbers-mode))规则之间的优先级怎么处理我的经验是context-mode 会按规则定义的顺序从上到下匹配多条规则命中时是叠加关系。也就是说规则 A 启用 highlight-symbol-mode规则 B 同时禁用 highlight-symbol-mode两条规则都命中时会乱。遇到这种情况你要想清楚到底哪条规则是兜底、哪条是特例。兜底规则比如“所有代码文件都开 highlight”放在前面特例规则比如“这个特殊目录不开 highlight”放在后面让后定义的特例把先定义的兜底“抵消”掉。虽然 context-mode 不强制你这么做但这个顺序习惯能让规则表的行为可预测得多。3.4 性能规则多会不会拖慢每个 buffer 切换规则检查发生在什么时机我观察下来主要是 buffer 切换、文件打开和 major-mode 变更这几个点。如果你的规则一两百条每条都是一个简单的正则匹配开销几乎可以忽略。但如果你的自定义谓词里写了大量正则回溯或者每次都去解析整个文件内容那就有性能风险了。我自己踩过一次在一个谓词里用了过于复杂的正则匹配(string-match-p foo\\(bar\\)?\\|baz\\(qux\\)? ...)去匹配文件名结果这个正则的某些分支在特定字符串上会出现灾难性回溯导致每次切换 buffer 都卡一下。后来我把正则拆成两个独立的string-match-p用or连接问题立刻消失。如果你规则数量很多建议把高频条件前置。比如major-mode判断通常比文件名正则快那就把:major-modes写在前面:file写在后面。这样一条规则在第一步就被过滤掉不会继续执行后面的重逻辑。4. 实测场景自动管理高亮、缩进与大文件保护4.1 按语言自动打开高亮与补全辅助这是 context-mode 最常见的用法进入某种语言的 buffer自动打开该语言生态里的辅助模式。我的一段配置如下(context-mode-define-rule :name elisp-tools :file \\.el\\ :major-modes (emacs-lisp-mode) :enable (highlight-symbol-mode outline-minor-mode eldoc-mode)) (context-mode-define-rule :name c-tools :file \\.\\(?:c\\|cc\\|cpp\\|h\\|hpp\\)\\ :major-modes (c-mode c-mode) :enable (modern-c-style cquery-mode)) (context-mode-define-rule :name python-tools :file \\.py\\ :major-modes (python-mode) :enable (anaconda-mode pyvenv-mode))每条规则看起来都差不多但好处是你不用再为每个 mode 单独写 hook 了。以后想给 C 文件加一个irony-mode只需要改一行想从 Python 规则里去掉pyvenv-mode也是一行。这种“一张表管所有语言”的配置方式特别适合多语言开发的场景。我为这个项目组维护的配置里语言相关的规则占了大半改动频率很高但每次改动都很快因为不会牵扯到其他 hook 的执行顺序。4.2 按项目目录自动切换缩进风格跨项目开发时缩进风格经常不一致。A 项目用 4 空格B 项目用 tabC 项目用 2 空格。手写 hook 的话你得在每个项目的.dir-locals.el里配或者用一个全局变量到处判断项目根目录。用 context-mode 可以直接把项目目录写进规则(context-mode-define-rule :name project-a-style :predicate (lambda () (string-prefix-p /path/to/project-a/ (or (buffer-file-name) ))) :actions ((setq-local tab-width 2) (setq-local indent-tabs-mode nil))) (context-mode-define-rule :name project-b-style :predicate (lambda () (string-prefix-p /path/to/project-b/ (or (buffer-file-name) ))) :actions ((setq-local tab-width 4) (setq-local indent-tabs-mode t)))这种方式比.dir-locals.el更显式你可以直接在配置里看到所有项目的规则不用每个项目单独维护文件。当然如果你本来就在团队里统一使用.dir-locals.el那用哪个都行。我个人偏爱 context-mode因为当项目多到几十个时看一张规则表比挨个进项目目录翻配置快得多。4.3 大文件自动关闭开销型 minor-mode有些 minor-mode 在小文件里很好用碰到大文件就非常卡典型的就是行号显示、语法检查、自动补全。用 context-mode 做“大文件保护”非常顺手(defun my-buffer-big-p (optional size) ( (buffer-size) (or size (* 1024 1024 10)))) (context-mode-define-rule :name big-file-protection :predicate (lambda () (my-buffer-big-p)) :action (lambda () (display-line-numbers-mode -1) (flycheck-mode -1) (company-mode -1)))注意这里我写的是:action单数动作部分版本支持这种写法用来替代:actions对整个规则体做统一处理。实际效果是一旦 buffer 超过 10MB自动关掉行号、语法检查和补全。这个规则应该放在规则表偏后的位置因为它是一个“保护性特例”不需要覆盖其他规则。用了之后打开几 MB 的日志文件或者生成的代码文件Emacs 不会再卡到让人抓狂。这个场景是我日常受益最大的一个强烈推荐给经常处理大文件的同学。4.4 终端和 GUI 环境的不同策略还有一个容易被忽略的维度Emacs 可能在图形界面跑也可能在终端里跑。有些 minor-mode 在终端里表现不佳比如某些依赖弹窗的补全框架或者依赖鼠标交互的模式。可以用(display-graphic-p)做谓词来区分(context-mode-define-rule :name gui-only-company :predicate (lambda () (display-graphic-p)) :major-modes (prog-mode) :enable (company-mode))这么写在终端里打开代码文件时不会强制启动 company-mode避免掉帧和输入延迟。反过来你也可以写一条规则在非图形环境下启用一些更适合终端的模式比如term-line-mode、whitespace-mode之类的。总之context-mode 的“上下文”不局限于文件类型和目录Emacs 自身运行状态也算上下文。只要你敢想规则表可以覆盖很多原本要写一堆(when (display-graphic-p) ...)的场景。5. 踩坑与排错上下文匹配不生效怎么定位5.1 症状一规则完全没触发最典型的问题配置写好了context-mode-on也调用了但规则就是不生效。我做过的排查链路是这样第一步确认规则有没有被加载。我一般直接M-x eval-buffer重新执行一遍配置如果规则表真的加载了日志里通常会有记录。没有日志的话可以用C-h v context-mode-alist查看当前规则表里是否包含刚才定义的规则。第二步如果规则表里有问题多半出在匹配条件。单独判断一下条件是否成立比如M-: (string-match-p \\.el\\ (buffer-file-name)) ; 看看返回的是不是 nil如果返回 nil说明当前 buffer 的文件名不符合正则。这一步能排除九成的“正则写错”问题。第三步如果条件成立还是没触发检查有没有别的地方把 mode 又关掉了。常见的是after-change-major-mode-hook里有其他代码在作祟。你可以暂时注释掉所有自定义 hook单独跑 context-mode看能否复现。经验是大多数“规则不生效”都是条件判断与事实不符而不是包本身的问题。拿到问题先别怀疑包先验证条件。5.2 症状二mode 开到了不该开的地方规则匹配太宽是另一个常见问题。比如我只写了:file \\.el\\没限制:major-modes结果*scratch*buffer 如果被重命名过也可能被命中。还有 minibuffer 和一些特殊 buffer它们的文件名可能是 nil但 predicate 函数如果没有判断就会被误认为是“空文件”而触发。解决方法是在规则里加上明确的 major-mode 限制或者在谓词里做防护(context-mode-define-rule :name safe-elisp :file \\.el\\ :major-modes (emacs-lisp-mode lisp-interaction-mode) :predicate (lambda () (not (minibufferp))) :enable (highlight-symbol-mode))这里加了一个(not (minibufferp))谓词确保任何 minibuffer 场景都不会触发。之后的经验是凡是要按文件匹配的规则最好都显式加一个:major-modes白名单。你永远不知道 Emacs 会把什么 buffer 塞给你。5.3 症状三和 display-line-numbers 等全局 mode 互相干扰display-line-numbers-mode这种全局模式是个经典干扰源。全局开启后每个 buffer 默认都有行号。你想在 org-mode 里关掉行号于是写了:disable (display-line-numbers-mode)但发现不管用。原因是context-mode 的:disable动作是在检查时执行(display-line-numbers-mode -1)但全局模式global-display-line-numbers-mode会在后续的after-change-major-mode-hook里把它重新打开。你的规则执行完了人家又把行号加回来等于白干。解决思路是绕过全局模式直接控制 buffer 局部行为。你可以用变量display-line-numbers做成 buffer-local 的并在谓词里直接设置(context-mode-define-rule :name org-no-linum :file \\.org\\ :major-modes (org-mode) :actions ((setq-local display-line-numbers nil)))这么做行号在这个 buffer 中被真正关掉且不受全局模式后续的影响。类似的冲突也常见于flycheck-mode、company-mode它们都有自己的全局模式或 hook 钩子。遇到“规则写了但没效果”先想一下是不是有全局模式把你关掉的模式又打开了。5.4 症状四daemon 启动时出现 null buffer用emacs --daemon启动时规则里的谓词函数可能会在没有当前 buffer 的情况下被调用。此时(buffer-file-name)返回 nil(current-buffer)可能是一个特殊 buffer如果你在谓词里写了(with-current-buffer (current-buffer) ...)大概率会报错。我在 daemon 模式遇到过几次规则报wrong-type-argument的错误。后来统一在谓词开头做防护(defun my-safe-buffer-file-name () (and (buffer-file-name) (file-exists-p (buffer-file-name)) (buffer-file-name)))所有依赖文件名的谓词都走这个函数nil 就直接返回不会继续往后执行。这个防护我建议人人都加上因为你不知道未来哪条规则会在什么奇奇怪怪的时机被执行。6. 什么情况下不建议用 context-mode6.1 全局模式本来就该全局开有些 minor-mode 你希望所有文件都用上比如whitespace-mode的空间符显示、which-func-mode的函数名显示这类直接一行全局开启就完事儿了。不要把全局需求硬写成 context-mode 规则那纯粹是浪费规则表空间还增加机器负担。我见过有人为display-line-numbers-mode写了一条“所有文件都开行号”的规则这在功能上是重复的全局模式本身就能做到。6.2 单一 hook 能解决就别引规则引擎如果需求只是“打开 org 文件时做一件事”那(add-hook org-mode-hook #my-org-init)是更简单、更直白的方案。context-mode 的优势在于多个条件叠加、多个动作互斥、上下文跨越文件类型和目录这些复杂度场景。如果规则表里一共就两条每条还都长得差不多那没必要多引这个包。工具是拿来解决复杂度的不是拿来制造复杂度的。6.3 权衡当配置变成玄学的时候我自己用大半年后有个体会规则表虽然清晰但随着时间推移十几条规则放在一起人还是容易忘掉某条规则的意图。特别是:actions里带 buffer 局部变量设置时调试起来比单纯开一个 mode 要难得多。所以我现在有一条维护铁律每条规则必须起一个好名字并且在规则旁边写注释说明为什么需要它。比如(context-mode-define-rule :name org-cleanup ;; 为什么需要org 导出时不需要行号且需要把 tab 一律展开避免 diff 混乱 :file \\.org\\ :major-modes (org-mode) :actions ((setq-local display-line-numbers nil) (setq-local indent-tabs-mode nil)))否则三个月后你回来看规则表看到一个陌生的规则名根本不知道当初为什么写它。规则表的好处是集中坏处也是集中——一旦有人乱改出问题的影响面比单条 hook 大得多。保持规则表的简洁和注释完整它才能真正成为你配置的资产而不是另一堆需要维护的负债。如果你决定不引入 context-mode用好了 hook 也完全没问题如果你引入了记住这只是一个把“上下文”和“模式”绑定起来的工具真正的好配置还是来自克制的设计。对我而言把零散的上下文逻辑收敛到统一规则表之后新配置的维护成本真的掉了一半不止这个改进值得试一试。
返回列表