如何写 Emacs 命令

我用 Emacs 写文档,敲代码,似乎已有十五年了,期间曾有两三次叛逃到了 Vim 阵营,最长的一次,约长达两年。

这两年,听说不少 Emacs 用户叛逃到了 VS Code 阵营。我安之若素。不过,我并非 Emacs 资深用户。我驾驭 Emacs 的能力,仅仅比新手更知道 Emacs 的配置文件在哪儿,怎样抄别人的配置,以及不甚畏惧 Lisp 代码。

Emacs 可能已经不再是世上最好的文本编辑器了,但它可能是世界上唯一支持用户使用 Lisp 语言为它写扩展的文本编辑器。Lisp 语言很有趣,Emacs 也就很有趣。最好的,不必有趣。有趣的,不必最好。再者,客观而言,别的编辑器能做的事,有什么是 Emacs 做不到的么?莫要忘记,Emacs 是伪装成文本编辑器的操作系统。

今天我要给像我这样的伪装成 Emacs 老手的新手写一篇关于编写 Emacs 命令的入门文章。因为我昨天写了一个 Emacs 命令。它不是我人生中所写的最初的 Emacs 命令。在它们之前,我也写过一些别的命令。只是倘若这次再不认真记录一下,以后遇到类似的问题,我的表现依然像是一个新手,一时竟然不知如何下手。

问题

写 Emacs 命令,就是用 Emacs Lisp——下文一直将其简称为 ELisp——编写程序。

编程是为了解决问题。如果没有问题,就不需要学习编程,否则,学会的只是某种编程语言的语法,而不是用这种语言编程的技艺。

我有问题。因为我在 Emacs 里曾经用了一个我根本就记不住的正则表达式,将

![foo.png](https://upload-images.jianshu.io/upload_images/11203728-22a5ea9d16a8c1da.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![bar.png](https://upload-images.jianshu.io/upload_images/11203728-a26c53d305a61e9d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![xxx.png](https://upload-images.jianshu.io/upload_images/11203728-50bc5a679b288b7a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

... ... ...

转化为

[foo]: https://upload-images.jianshu.io/upload_images/11203728-22a5ea9d16a8c1da.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240

[bar]: https://upload-images.jianshu.io/upload_images/11203728-a26c53d305a61e9d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240

[xxx]: https://upload-images.jianshu.io/upload_images/11203728-50bc5a679b288b7a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240
... ...

熟悉 Markdown 的人,或许能辨识出,我是将图片列表转化为图片的引用列表。不熟悉 Markdown 的人,就对比一下上述的代码块,看看它们有什么不同。

区域选择

假设存在文件 foo.md,其内容如下:

... 正文 ...

图片列表:

![农夫和-T21.jpg](https://upload-images.jianshu.io/upload_images/11203728-0e9cadcfda448325.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![干将.jpg](https://upload-images.jianshu.io/upload_images/11203728-c2f31785a97068f6.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![齿刃.jpg](https://upload-images.jianshu.io/upload_images/11203728-79f77acea166aa65.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

![猎刀.jpg](https://upload-images.jianshu.io/upload_images/11203728-589e82eb51688e52.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

... ... ...

用 Emacs 打开 foo.md 之后,知道如何选中上述的图片列表区域么?如果不知道,就用鼠标去划拉吧,只要能选出它们即可,但是我还是建议用 Emacs 预绑定的键,不知道,学一下又不难。只需 C-@C-a, C-nC-e 这几个键就足以实现下图所示的文本区域选择:

区域选择结果

文本匹配与替换

对于选中的文本区域,可使用 Emacs 的命令 M-x replace-regexp,使用正则表达式捕捉文本,并对其进行替换。可解决我的问题的正则表达式的匹配和替换命令为

匹配:\!\[\(.*\)\..+\](\(.*\))
替换:[\1]: \2

由于用于文本匹配的正则表达式使用了分组捕获,所以在替换命令里可基于捕获的结果构造替换文本。

上述文本匹配和替换命令可表示为

M-x replace-regexp RET \!\[\(.*\)\..+\](\(.*\)) RET [\1]: \2 RET

其中,RET 表示敲回车键。

如果不会编写 Emacs 命令,或者因为懒惰而不愿去编写 Emacs 命令,那么每次遇到此类问题时,能一气呵成地将文本匹配和替换的正则表达式写正确,不知道别人如何,我经常做不到。

可否以将上述操作也写成一个命令呢?

可以。

在哪儿写?

如果是自己写 Emacs 命令,在哪都行。我是在 myfun.el 文件里写,该文件位于 $HOME/.myscript/emacs。为了让 Emacs 能够发现我写的命令,我需要将 myfun.el 文件的加载路径在 Emacs 的配置文件里告诉 Emacs。

我的 Emacs 配置文件是 $HOME/.emacs.d/init.el,因为我用的是 Linux。其他操作系统,有各自的路径。在 init.el 文件中增加:

(load "~/.my-scripts/emacs/myfun")

然后,我便可以将 myfun.el 文件当作我的试验室了。

一个什么都不能做的命令

在 myfun.el 中写下

(defun foo ()
  (interactive))

然后,打开一个新的 Emacs 窗口 1,执行命令 M-x foo,亦即摁住 Alt 键,再摁 x 键,然后松开,Emacs 底部的微缓冲区(Minibuffer)会接受输入,在其中输入 foo,回车。

foo 命令什么都没做,所以 Emacs 执行了它,什么变化都不会出现。但是,倘若执行命令 C-h f foo,Emacs 会显示以下信息:

foo is an interactive Lisp function in
‘~/.my-scripts/emacs/myfun.el’.

(foo)

  Probably introduced at or before Emacs version 1.2.

Not documented.

上述信息里说,foo 是具有交互性的 Lisp 函数。

在 Emacs 环境里,具有交互性的 Lisp 函数就是 Emacs 的命令。那么,有不具有交互性的 Lisp 函数吗?试试将 foo 修改为

(defun foo ())

然后,再开启一个新的 Emacs 窗口,再次执行 M-x foo,这一次 Emacs 发生了变化,至少在 Emacs 27.1 版本里,它自以为是,认为我是要执行 foonote-mode 命令……亦即,此时 foo 已不再是命令了。它是什么呢?再度执行 C-h f foo,Emacs 显示

foo is a Lisp function in ‘~/.my-scripts/emacs/myfun.el’.

(foo)

  Probably introduced at or before Emacs version 1.2.

Not documented.

此时,foo 单纯是一个 Lisp 函数了。

defun 不是消除快乐,而是 define function 的缩写。我如此轻易地就写出了一个 Lisp 函数,并且执行了它,我觉得挺快乐。

习题:理解 C-h k C-@

提示: C-h 是摁住 Ctrl 键,再摁 h 键,然后松开。 C-@ 是摁住 Ctrl 键,再摁住 Shift,最后摁下数字 2 键,然后松开,这是因为 @ 在 2 上面,需要 Shift 切换。

区域的起点和终点

在 Emacs 里选取的文本区域,它有起点和终点,可分别通过 Emacs 提供的函数 region-beginningregion-end 获取。为什么不是 region-beginregion-end 呢?因为 begin 不具名词性。借助 Emacs 提供的 message 函数,我可以让 foo 命令在微缓冲区中显示文本区域的起点和终点。

新的 foo 命令的定义如下:

(defun foo ()
  (interactive)
  (message "文本区域 [%d,%d)" (region-beginning) (region-end)))

如果完全不懂 Lisp,但是略懂 C 语言,那么上述代码我可以大致翻译为 C 代码:

void foo(void) {
    printf("文本区域 [%d,%d)", region-beginning(), region-end());
}

当我在 Emacs 里执行 M-x foo 时,相当于 C 代码的主函数调用了 foo

int main(void) {
    foo();
    return 0;
}

文本匹配和替换函数

既然我可以执行 M-x replace-regexp 命令对选中的区域内的文本进行匹配和替换,那么在 foo 函数是否也能调用它呢?

当然能。但是,需要先了解一下这个函数,执行 C-h f replace-regexp,便可查阅关于它的文档:

replace-regexp is an interactive compiled Lisp function in
‘replace.el’.

(replace-regexp REGEXP TO-STRING &optional DELIMITED START END
BACKWARD REGION-NONCONTIGUOUS-P)

  This function is for interactive use only;
  in Lisp code use `re-search-forward' and `replace-match' instead.
  Probably introduced at or before Emacs version 21.1.

Replace things after point matching REGEXP with TO-STRING.
Preserve case in each match if ‘case-replace’ and ‘case-fold-search’
are non-nil and REGEXP has no uppercase letters.

... ... ...

Third arg DELIMITED (prefix arg if interactive), if non-nil, means replace
only matches surrounded by word boundaries.  A negative prefix arg means
replace backward.

虽然文档是英文的,但即使翻译成中文,我也是看不太懂。我能看懂的部分,写成伪代码就是

(replace-regexp
   "\\!\\[\\(.*\\)\\..+\\](\\(.*\\))"
   "[\\1]: \\2"
    nil (region-beginning) (region-end) ...接下来的我不懂...)

其中的 nil 对应的那个参数,它的含义我也是不太懂,我只是试着将它的值设为 nil,也不知结果如何。但是,我在 Emacs Lisp 手册上曾经看到过函数帮助文档 2 给出的格式标记说明,凡出现在 &optional 记号之后的参数,它们是可选参数。这意味着,我能保证

(replace-regexp
   "\\!\\[\\(.*\\)\\..+\\](\\(.*\\))"
   "[\\1]: \\2"
    nil (region-beginning) (region-end))

至少是合法的代码。将它置入 foo 函数的定义,即

(defun foo ()
  (interactive)
  (replace-regexp
   "\\!\\[\\(.*\\)\\..+\\](\\(.*\\))"
   "[\\1]: \\2"
    nil (region-beginning) (region-end)))

试了一下,M-x foo 工作得很好。在此,需要解释一下,为什么 foo 函数调用的 replace-regexp 比直接 M-x replace-regexp 多了很多 \ 。因为在 Lisp 语言中,\ 有它自身的作用,即转义符,要将它作为字面值使用,需要用它对它本身进行转义,就成了 \\

M-x foo

后记

问题得到了很好的解决。事实上,我解决的远比上文最后给出的 foo 函数还要好,但我不想在这篇文章上浪费太多时间。因为网络上不仅有很好的 Emacs 教程,也有很好的 ELisp 入门教程 3 。还有个怪人,叫李杀,他为 Emacs 和 ELisp 写了许多优秀的教程 4 。这篇文章真正想表达的是,一旦有了想去解决的问题,就不那么畏惧或轻视 Lisp 了,哪怕它是 Emacs Lisp。

对于愿意去解决问题的人,从不会落入形而上学的窠臼。因为他们会想方设法让自己能够走下去,直至达到目的。问题解决之后,倘若没有新的问题,形而上学一下也无妨,就如我此刻。

附录

更全面的 foo 函数的定义:

(defun jianshu-image-refs ()
  (interactive)
  (save-restriction
    (let (x y text)
      (setq x (region-beginning))
      (setq y (region-end))
      (setq text (buffer-substring-no-properties x y))
      (setq text (replace-regexp-in-string
                  "\\!\\[\\(.*\\)\\..+\\](\\(.*\\))"
                  "[\\1]: \\2"
                  text))
      (delete-region x y)
      (insert text)
      (flush-lines "^$" x (+ x (length text))))))

  1. 确切地说,应该是开启一个新的 Emacs 进程。「窗口」在 Emacs 环境里,另具深意。
  2. https://www.gnu.org/software/...
  3. https://bzg.fr/en/learn-emacs...
  4. http://ergoemacs.org>

你可能感兴趣的:(emacselisp)