在 coq-mode 下开启行号显示

行号Line Number的显示似乎不应该成为一个问题。vscode 仓库里还有一个 issue 在询问怎么方便地关闭行号显示。而对于 Vim 用户来说一句 set :number 就可以开启行号显示。

没错Emacs 就是这么特别的编辑器。对我这样偶尔用一用的用户来说行号显示也确实是一个困扰我的问题。

问题是什么

我使用的是一个 Emacs 的发行版或者说一个比较成熟的配置文件Spacemacs. Spacemacs 的官方文档里要开启行号显示似乎只需要在 .spacemacs 里设置一下变量就可以了

1(setq-default dotspacemacs-line-numbers t)

这句话的意思就是 dotspacemacs-line-numbers 设置为 t.

同时为了支持 coq 语言我还装载了 coq layer. 但如果打开一个 coq 文件你也许会惊奇地发现这时 coq 源代码是没有行号的

按照正常的直觉设置为 t 应该是会在所有情况下显示行号的。那么为什么没有显示行号呢

临时解决方案

解决这个问题其实比搞清楚原因要容易。

稍加搜索我发现 spc t n 1可以在当前 buffer 打开行号这确实可以解决问题。按照 Emacs wiki 的说法这个快捷键调用的实际上是 Emacs 26.1 版本后引入的行号命令 display-line-numbers-mode.

Emacs 的命令可以通过 m-x或者 spc spc 执行。实际上一个命令就是一个 Emacs Lisp 的函数参考 Programming Elisp 文档只要一个函数的函数定义里有 (interactive)那么它就可以当成一个命令调用。

显然Spacemacs 也是通过这个函数进行行号相关设置。虽然不知道因为什么原因Spacemacs 没有给我的 .v 文件开启行号但是我们可以自己开启。

coq 层实际上使用了 coq-mode. coq-mode 是一个 Emacs 主模式Major Mode它给 coq 语言提供了支持并定义了一大堆快捷键。

Emacs 支持一种叫做 Hooks 的机制它允许我们在某个模式加载时执行一系列操作。既然 Spacemacs 不会给 coq-mode 开启行号我们就通过这种机制手动执行 display-line-numbers-mode.

1(defun dotspacemacs/user-config ()
2  "Configuration for user code:
3This function is called at the very end of Spacemacs startup, after layer
4configuration.
5Put your configuration code here, except for variables that should be set
6before packages are loaded."
7  (add-hook 'coq-mode-hook (lambda () (display-line-numbers-mode 1)))
8)

这里的 dotspacemacs/user-config Spacemacs 配置文件的一部分在这里可以执行一些自定义操作。

这当然就解决了问题。其实Spacemacs 在文档里还提到了一个选项 :enabled-for-modes. 在这个选项里加上 coq-mode 可以直接解决问题。不过我直到搞清楚了问题的来源才发现了有这个选项。

1(setq-default dotspacemacs-line-numbers
2  '(:enabled-for-modes coq-mode))

为什么

我首先注意到的是 dotspacemacs-line-numbers 的注释

1;; Control line numbers activation.
2;; If set to `t', `relative' or `visual' then line numbers are enabled in all
3;; `prog-mode' and `text-mode' derivatives. If set to `relative', line
4;; numbers are relative. If set to `visual', line numbers are also relative,
5;; but only visual lines are counted. For example, folded lines will not be
6;; counted and wrapped lines are counted as multiple lines.
7;; This variable can also be set to a property list for finer control:
8;; (default nil)

这段注释里提到如果把这个变量设置为 t那么它会默认在 prog-mode text-mode 里开启行号。这两个 mode 被称为 Basic Major Mode. 一个很自然的猜想是, coq-mode 不是它们之一。

打开一个 coq 文件执行 m-x describe-mode会看到

1Coq mode defined in ‘coq-mode.el’:
2Major mode for Coq scripts.

进入 coq-mode.el 的源代码查看一下玄机就在第 182

1(defun coq--parent-mode ()
2  (if coq-use-pg (proof-mode) (prog-mode)))
3
4;;;###autoload
5(define-derived-mode coq-mode coq--parent-mode "Coq"
6  "Major mode for Coq scripts.

这个 coq--parent-mode 显然就是 coq-mode 的父模式parent mode),Emasc 的模式也有一个继承树一个模式继承另一个父模式这棵树的根不确定有几种情况

  • 自己
  • Fundamental Mode
  • Basic Major Mode

coq-use-pg 的值在我的环境里为 t它的父模式是 proof-mode. proof-mode 的父模式又是什么呢

StackOverflow 一个问题提供了相关代码

1(defun derived-modes (mode)
2  "Return a list of the ancestor modes that MODE is derived from."
3  (let ((modes   ())
4        (parent  nil))
5    (while (setq parent (get mode 'derived-mode-parent))
6      (push parent modes)
7      (setq mode parent))
8    (setq modes  (nreverse modes))))

用这个函数确实可以得到一些正常模式的继承路径比如我们刚刚说的 proof-mode:

1ELISP> (derived-modes 'proof-mode)
2(prog-mode)

是的proof-mode 的父模式就是 prog-mode. 如果你还记得刚才的文档的话prog-mode dotspacemacs-line-numbers 设置为 t 的时候也应该显示行号。

那么究竟是什么地方错了呢不妨给这个函数输入 'coq-mode:

1ELISP> (derived-modes 'coq-mode)
2(coq--parent-mode)

它的输出是 (coq--parent-mode)这只是父模式的符号而不是求值后的父模式。可见(get 'coq-mode 'derived-parent-mode) 这个表达式只能拿到语法上的符号而非它真正的父模式。检查 define-derived-mode 的源代码可以发现 (get mode 'derived-mode-parent) 拿到的是这句话设置的父模式

1(put ',child 'derived-mode-parent ',parent)

那么Spacemacs 是这样得到父模式的吗Spacemacs spacemacs/layers/+spacemacs/spacemacs-defaults/funcs.el spacemacs//linum-enabled-for-current-major-mode 函数中检查是否在当前模式下开启行号显示

1(defun spacemacs//linum-enabled-for-current-major-mode ()
2  "Return non-nil if line number is enabled for current major-mode."
3  (let* ((disabled-for-modes
4          (spacemacs/mplist-get-values dotspacemacs-line-numbers
5                                       :disabled-for-modes))
6         (user-enabled-for-modes
7          (spacemacs/mplist-get-values dotspacemacs-line-numbers
8                                       :enabled-for-modes))
9         ;; default `enabled-for-modes' to '(prog-mode text-mode), because it is
10         ;; a more sensible default than enabling in all buffers - including
11         ;; Magit buffers, terminal buffers, etc. But don't include prog-mode or
12         ;; text-mode if they're explicitly disabled by user
13         (enabled-for-modes (or user-enabled-for-modes
14                                (seq-difference '(prog-mode text-mode)
15                                                disabled-for-modes
16                                                #'eq)))
17         (enabled-for-parent (or (and (equal enabled-for-modes '(all)) 'all)
18                                 (apply #'derived-mode-p enabled-for-modes)))
19         (disabled-for-parent (apply #'derived-mode-p disabled-for-modes)))
20    (or
21     ;; special case 'all: enable for any mode that isn't specifically disabled
22     (and (eq enabled-for-parent 'all) (not disabled-for-parent))
23     ;; current mode or a parent is in :enabled-for-modes, and there isn't a
24     ;; more specific parent (or the mode itself) in :disabled-for-modes
25     (and enabled-for-parent
26          (or (not disabled-for-parent)
27              ;; handles the case where current major-mode has a parent both in
28              ;; :enabled-for-modes and in :disabled-for-modes. Return non-nil
29              ;; if enabled-for-parent is the more specific parent (IOW derives
30              ;; from disabled-for-parent)
31              (spacemacs/derived-mode-p enabled-for-parent disabled-for-parent)))
32     ;; current mode (or parent) not explicitly disabled
33     (and (null user-enabled-for-modes)
34          enabled-for-parent            ; mode is one of default allowed modes
35          disabled-for-modes
36          (not disabled-for-parent)))))

可以看到它通过 derived-mode-p 函数询问当前 buffer major mode 是不是预设的 enabled-for-modes 的子模式。dervied-mode-p Emacs 提供的一个函数文档声称如果当前 buffer 的主模式是 modes 的子模式那么 (derived-mode-p modes) 将返回非 nil 值。

再检查 derived-mode-p 的源代码我们终于发现了问题的根源。

1(defun provided-mode-derived-p (mode &rest modes)
2  "Non-nil if MODE is derived from one of MODES or their aliases.
3Uses the `derived-mode-parent' property of the symbol to trace backwards.
4If you just want to check `major-mode', use `derived-mode-p'."
5  (while
6      (and
7       (not (memq mode modes))
8       (let* ((parent (get mode 'derived-mode-parent))
9              (parentfn (symbol-function parent)))
10         (setq mode (if (and parentfn (symbolp parentfn)) parentfn parent)))))
11  mode)
12
13(defun derived-mode-p (&rest modes)
14  "Non-nil if the current major mode is derived from one of MODES.
15Uses the `derived-mode-parent' property of the symbol to trace backwards."
16  (apply #'provided-mode-derived-p major-mode modes))

dervied-mode-p 同样通过 (get mode 'derived-mode-parent) 来得到某个模式的父模式而这对于 coq-mode 来说会得到 coq--parent-mode, 继承链在这里就断了。

Emacs 中的模式定义最后确实是定义了一个函数。然而如果 'parent 不能求值出正确的父模式Emacs 对于 mode 的预设函数就会得不到期望的结果。显然coq-mode 不应该这样定义 coq--parent-mode. 那么到底应该怎么定义 coq--parent-mode

最终解决方案

Emacs Lisp 的全局变量和全局函数是不同的这和 Scheme 很不一样。如果直接把 coq--parent-mode 定义为一个变量会使得 coq-mode 无法加载。

1(defvar coq--parent-mode
2  (if coq-use-pg 'proof-mode 'prog-mode))

万幸的是Emacs Lisp 提供了一个叫做 defalias 的函数它可以把 coq--parent-mode 定义为一个 alias.

1(defalias 'coq--parent-mode
2  (if coq-use-pg 'proof-mode 'prog-mode))

上面的修改就足以使得 Spacemacs 可以自动侦测到 coq-mode 派生自 prog-mode从而开启行号显示。我已经提交了一个 PR并合入了主分支。