大话Emacs Shell Mode:当Shell遇见Emacs

在《大话Emacs Shell Mode:让工作更轻松的技巧》里面介绍了一些 Shell 环境下的日常操作如何在 GNU Emacs 的 Shell-mode 模式下变得轻松愉快。在接下来的这个部分里面,我将介绍一些针对 Shell 环境的扩展和定制。通过对 Emacs 的扩展和定制,将会使Emacs当中的Shell 操作变成一种更加舒服的享受。

第四回 我爱我家 —— 装修篇

进入和退出 Shell Mode

轻轻的我走了,正如我轻轻的来;我轻轻的招手,作别西天的云彩。

但是在 Emacs Shell Mode 的缺省设计里面,没有能够让我们如此轻松和优雅的进入与退出。这就是在这一节当中我们要进行定制和扩展的地方。

Shell buffer 的进入

首先是进入。在本文的第一部分有一个小技巧,介绍了在 GNU Emacs 中如何打开多个 Shell buffer —— 我们需要将现有的 Shell buffer 重命名,然后才能再次打开一个叫做 *shell*的 Shell buffer。这是 Emacs 创建 Shell buffer 时使用的默认名称。

这是一个很不优雅的行为。这样的细节工作应该由 Emacs 事先料理好,我所需要的只是优雅的进入。实现这个目的有两种做法,一种是在创建 Shell buffer 的时候就把它修改成一个独特的名字;另外一种做法是在创建出 Shell buffer 之后,根据用户的使用情况来自动修改 Shell buffer 的名称。由于工作特点的关系,我选择的是第二种方案。

在我的工作环境当中,绝大多数时间都要登录到远程的机器上去工作。所以我非常希望 Shell buffer 的名称能够被自动修改成我所登录的目标机器的名称,这样在我登录大量的机器进行操作的时候,就可以方便的通过 buffer 名称来进行分辨。这就是我选择第二套方案的原因。我首先接受 Emacs 创建出来的默认 buffer,然后在我登录远程机器的时候 Emacs 会自动为我改名。如果我没有登录远程机器,那么它将保持默认的名称,或者由我主动的修改 buffer 名称。

接受默认的 buffer 名还有一个附加的好处——当你打开大量的 buffer 进行工作的时候,如果要回到这个默认的 Shell buffer,你不必在长长的 buffer 列表里面进行切换,只需要执行一个打开 Shell 的命令,也就是 M-x shell,Emacs 就会立刻把你带到这个默认的 Shell buffer 中来。为了能够更加方便的打开 Shell,我把这个命令绑定到了 C-c z组合键上:

(global-set-key (kbd "C-c z") (quote shell))

现在让我们看一看 Emacs 是如何在我登录远程机器的时候自动修改 Shell buffer 的名称的。实现这样的功能首先需要编写一个rename-buffer-in-ssh-login函数:

清单 1. rename-buffer-in-ssh-login 函数

(defun rename-buffer-in-ssh-login (cmd)
"Rename buffer to the destination hostname in ssh login"
(if (string-match "ssh [-_a-z0-9A-Z]+@[-_a-z0-9A-Z.]+[ ]*[^-_a-z0-9-A-Z]*$" cmd)
(let (( host (nth 2 (split-string cmd "[ @\n]" t) )))
(rename-buffer (concat "*" host)) ;
(add-to-list 'shell-buffer-name-list (concat "*" host));
(message "%s" shell-buffer-name-list)
)
)
)

这个函数会分析提供给它的命令。如果匹配预先定义的正则表达式,则截取 @字符后面的机器名,然后使用 rename-buffer命令修改当前 buffer 的名称。另外,由于在 GNU Emacs 的默认约定里将 Shell buffer 看作是一种临时 buffer,而临时 buffer 的名称通常会以一个 *字符开头,在这里仍然遵循这样的命名约定,在机器名称的前面添加一个了 *前缀。

要让这个函数工作,我们需要把它加入到一个 hook 变量 comint-input-filter-functions当中。

(add-hook 'comint-input-filter-functions 'rename-buffer-in-ssh-login)

comint-input-filter-functions是一个 comint-mode 的 hook。Shell-mode 实际上是由 comint-mode 派生出来的,所以 comint-mode 的 hook 在 Shell-mode 里面也能够工作。

comint-mode 或者 Shell-mode 在将输入到 buffer 中的命令传递给后台进程(在这里是 Shell 进程)去执行之前,会首先运行comint-input-filter-functions hook 当中的函数,同时将输入的命令作为参数传递给该中的函数。所以我们的 rename-buffer-in-ssh-login函数就可以跟踪输入到 buffer 当中的每一条命令,当发现有类似 ssh [email protected] 或者 ssh msg@hostB这样的命令的时候,就会执行预定的操作。同时正则表达式的设计还避免了在类似 ssh [email protected] ls /opt/IBM这样不以登录为目的的远程命令上面出现误动作的机会。

看到这里细心的读者也许注意到了一个细节,就是上面的代码里面被注释掉了两行内容。尤其是其中的第一行将截取下来的机器名加入到了一个 shell-buffer-name-list的列表里面。实际上这段代码的存在是为了跟踪 Shell buffer 名称的变化过程,然后配合另外一个函数 rename-buffer-in-ssh-exit,在退出每一次 ssh 登录的时候将 Shell buffer 的名称再改回来原来的样子。但是由于实际应用的复杂性,目前为止还没有找到一个十分满意的实现方案。有兴趣的读者可以尝试自己实现这个函数。

Shell buffer 的退出

进入的问题解决了,下面让我们来看一看退出的时候会有哪些问题。

当用户退出 Shell 会话之后,Emacs 并不会删除这个 Shell buffer,而是把它留在那里,等待用户的进一步的处理。

[email protected]$exit
exit
Process shell finished

如果用户这个时候再次执行 M-x shell命令,Emacs 会再次复用这个 buffer。

[email protected]$
[email protected]$exit
exit
Process shell finished
[email protected]$

首先这其实是一个非常正确的设计。因为 Shell buffer 里面的内容通常是非常重要的。甚至于有些时候我会在结束一天的工作之后把某一些 Shell buffer 保存成文件,以备日后查阅。这里面不仅仅有这一天以来执行过的所以命令的记录,还有所有这些命令的输出信息,甚至当我先后登录了几台不同的机器进行了不同的操作,所有这些工作也都记录在这个 Shell buffer 当中,可以说这个 buffer 就是我这一天以来所有足迹的记录。试想想,还有什么地方能够提供这么完整、详细的工作记录?另外还有什么地方能够提供如此方便的搜索功能?甚至连命令的输出信息都可以随意搜索?

但是,很快我就习惯了正确处理我的 Shell buffer。对于主要的 buffer 我已经习惯在退出之前就把它保存好了,那么这个时候是不是可以告诉 Emacs 不用这么拘谨了呢?事实上这个事情还真不好办。我曾经试图用 comint-output-filter-functionshook 去捕捉Process shell finished这样的信息,但是这样的信息是在 comint-mode 已经退出以后才由 Emacs 输出的,因此在这个 hook 里面完全捕捉不到。

直到有一天在翻看 Emacs 源代码的时候突然看到了 set-process-sentinel这个函数才找到了解决方案。 set-process-sentinel函数可以对一个特定的进程设置一个“哨兵”,当这个进程的状态发生变化的时候(比如说进程结束的时候),“哨兵”就会通知 Emacs 调用相应的函数来完成预定的工作。有了这个方案,我们只需要把删除 Shell buffer 的函数关联到正确的进程上就行了。

下面就是这两个函数:

清单 2. 两个函数

(defun kill-shell-buffer(process event)
"The one actually kill shell buffer when exit. "
(kill-buffer (process-buffer process))
)
(defun kill-shell-buffer-after-exit()
"kill shell buffer when exit."
(set-process-sentinel (get-buffer-process (current-buffer))
#'kill-shell-buffer)
)

其中 kill-shell-buffer的作用是删除进程对应的 buffer; kill-shell-buffer-after-exit函数的作用就是把 kill-shell-buffer函数关联到正确的进程上去。然后当我们把这个函数加入到 shell-mode-hook当中后,就可以在每次打开 Shell buffer 的时候得到正确的进程信息了。

(add-hook 'shell-mode-hook 'kill-shell-buffer-after-exit t)

outline in Shell Mode

这一节我们谈 outline-mode。Outline-mode 是 GNU Emacs 的一个非常好用的写作模式。使用 outline-mode 可以轻松方便的操作结构化文档,可以将文档内容分级展开,或者逐级隐藏,既能总揽全局,又可深入细节。outline-mode 是如此精彩,以至于 Carsten Dominik 教授在此基础上开发出了强大的 orgmode。

在这一节当中我们将要讨论一下如何将 outline-mode 的强大功能应用到 Shell-mode 当中。在进入细节之前,让我们先对 Outline-mode 进行一个简单的介绍。

Outline mode 当中,文档中的内容被分成两种结构,一种是“标题”,一种是“内容”。其中的“标题”又可以根据需要分成大小不同的级别。在对文档的内容进行折叠和展开操作的时候就是以这些“标题”的级别为依据的。例如下面这段摘自 GNU Emacs Manual 的示例:

* Food
This is the body,
which says something about the topic of food.
** Delicious Food
This is the body of the second-level header.
** Distasteful Food
This could have
a body too, with
several lines.
*** Dormitory Food
* Shelter
Another first-level topic with its header line.

当我们折叠起这段文档的时候,分别可以折叠成这样的形式

* Food...
* Shelter...

或者这样的形式

* Food...
** Delicious Food...
** Distasteful Food
* Shelter...

或者我们又可以将 Delicious Food单独展开

* Food...
** Delicious Food
This is the body of the second-level header.
** Distasteful Food
* Shelter...

那么这些示例和 Shell mode 又有什么关系呢? 如果我们把 Shell buffer 里的 * 命令 * 看作 outline-mode 的“标题”,将命令产生的输出看作是“内容”,那么是不是就可以像折叠起一篇普通的结构化文档那样将所有的 Shell 命令都折叠起来呢?就像下面这个示例所展示的这样:

清单 3. 示例

[email protected]$ cd ~/org...
2 : 2001 : 11:23:10 : ~/org
[email protected]$ ls *.el
calendar-setup.el dove-ext.el org-mode.el settings.el
color-theme.el keybindings.el plugins.el
[email protected]$ ee work.org &...
[email protected]$ Waiting for Emacs...
[email protected]$ ls...
[email protected]$ ee settings.el &...
[email protected]$ Waiting for Emacs...
[email protected]$ cd~/...
[email protected]$ ls...
[email protected]$ ...

当我们把 Shell buffer 里面的内容全部折叠起来,我们就看到了一条时间线。既能够于一瞥之间总览全部的历史,又可以随时深入任何一条命令的细节。相比与仅能告诉我们曾经做过什么的 history命令来说,这样的场景更像是一部“时间机器”。

那么该怎样实现这样的梦想呢?其中的关键就是要让 outline-mode 能够认出我们的“标题”。在 outline-mode 里面缺省的“标题”是一个 *,这个 *从文本行的第一个字符开始匹配,匹配上的,就是“标题”,匹配不上的,就是“内容”,匹配的次数越多,“标题”的级别越低。我们可以通过设置 outline-regexp变量的值来定义我们自己的“标题”。在 Shell mode 里面一个可行的办法就是将 Shell 提示符的内容定义为“标题”。如同下面的示例这样:

(setq outline-regexp ".*[bB]ash.*[#\$]")

设置标题以后,在 Shell mode 里面输入 M-x outline-minor-mode就可以享受 outline-mode 带来的便利了。例如上文示例中所示的结果使用一下三个操作就可以实现:

  • 输入 M-x hide-body或者 M-x hide-all命令折叠起 Shell buffer 里的所有命令
  • 移动光标到 ls *.el所在的行
  • 使用 M-x show-entry或者 M-x show-subtree命令展开 ls *.el命令

Enhanced outline in Shell Mode

在上一节里面讲述了通过设置 outline-regexp变量,使 outline-minor-mode可以在 shell-mode 中工作的方法,但是这样简单的设置很难避免会有一些负面的影响。因为 outline-regexp变量是一个全局变量,所以对 outline-regexp的值势必改变其他模式中的outline-minor-mode的行为方式,而这肯定不是你所希望的。

所以我在工作当中实际使用的是另外一种相对复杂一些的方法:使用一个函数为每一个 buffer 设置分别的 outline-regexp,并且把outline-regexp变量修改为特定 buffer 范围内的局部变量。下面就是这个函数:

清单 4. 设置 buffer 的函数

(defun set-outline-minor-mode-regexp ()
""
(let ((find-regexp
(lambda
(lst mode)
""
(let
((innerList
(car lst)))
(if innerList
(if
(string=
(car innerList)
mode)
(car
(cdr innerList))
(progn
(pop lst)
(funcall find-regexp lst mode))))))))
(outline-minor-mode 1)
(make-local-variable 'outline-regexp)
(setq outline-regexp (funcall find-regexp outline-minor-mode-list major-mode))))

这个函数首先定义了一个匿名函数,存储在 find-regexp变量中,这个函数通过递归的方式遍历一个嵌套列表,直至找到与给定模式对应的值;然后启动 outline-minor-mode,修改 outline-regexp为局部变量,然后调用上述的匿名函数设置正确的 outline-regexp。

要让这个函数能够工作,我们就需要把他加入到各个主模式的 hook 之中,如同下面的示例所示:

清单 5. 示例

(add-hook 'shell-mode-hook 'set-outline-minor-mode-regexp t )
(add-hook 'sh-mode-hook 'set-outline-minor-mode-regexp t )
(add-hook 'emacs-lisp-mode-hook 'set-outline-minor-mode-regexp t )
(add-hook 'perl-mode-hook 'set-outline-minor-mode-regexp t )

但是细心的读者应该看到了,这个 set-outline-minor-mode-regexp函数并没有接受任何参数,这是因为这些主模式在调用 hook 函数的时候是不会向它们传递任何参数的。那么我们需要的的数据从哪里来呢?显然这里需要一个全局变量 outline-minor-mode-list来存储 set-outline-minor-mode-regexp函数所需的所有数据。

清单 6. 全局变量 outline-minor-mode-list

(setq outline-minor-mode-list
(list '(emacs-lisp-mode "(defun")
'(shell-mode ".*[bB]ash.*[#\$] ")
'(sh-mode "function .*{")
'(perl-mode "sub ")
))

有了这些扩展,Emacs 就可以在创建一个新的 buffer 的时候,为这个 buffer 设置正确的 outline-regexp值了。

延伸阅读 hook

一些读者可能注意到,在本文的叙述中多次提到了 hook 这一概念,那么 hook 究竟是什么东西?他在 Emacs 里面有起到什么作用呢?在这里我给大家做一个简要的介绍。

简单来讲,hook 就是一个存储函数列表的 Lisp 变量,该列表里的每一个函数被称作这个 hook 的一个 hook 函数。GNU Emacs 的很多主模式(major modes)在完成初始化之后都会尝试寻找并调用对应该模式的 hook 变量里面的 hook 函数。因此 hook 就成为定制 Emacs 过程中一个非常重要的机制。我们可以通过添加 hook 函数的方式轻松的定制或扩展 Emacs 的行为。

最简单的 hook 用法就是直接调用已有的 Emacs 函数,例如启动特定的子模式(minor modes):

(add-hook 'shell-mode-hook 'outline-minor-mode t)

更加复杂的用法就如上文所示,编写自己的 hook 函数。

关于 hook 有几个细节需要注意

  • 绝大多数普通 hook 变量的名称都是在主模式的名称后面加上 -hook后缀来构成的
  • 但是,并不是所有 hook 变量都是这样命名的
  • 绝大多数普通 hook 函数被调用的时候是不会向它传递任何参数的,同时也不会理会函数的返回结果的
  • 但是,并不是所有 hook 函数都是这样调用的
  • 已经装入的 hook 函数将无法通过再次执行 add-hook来进行覆盖或修改。实际的结果将会装入该 hook 函数的多个版本。解决的办法之一是清除 hook 变量,然后再次装入:

相关推荐