EMACS-DOCUMENT

=============>集思广益

用Emacs Shell替代zsh

我做到了. 我已经不再需要Zshell , Fish , Bash 等等这些东西了…至少大部分时候都不再需要了. 它们都是很不错的工具,只不过我的工作流是以编辑器来驱动的. 我启动Emacs,然后仅仅在需要管理文件之类的操作时才进入shell,而不是先进入shell四处游荡,然后再开始编辑文件.

大多数Emacs用户都会拆分Emacs window然后在Emacs中启动一个shell,并在需要时进入该shell window中进行操作,不需要时则切换到其他window. 不过我发现Emacs的Eshell似乎更适合于我,我越用就越发的爱上它了.

不过eshell有一个问题就是缺少文档…而且还有点难以理解. 因此我才撰写了本文. 不过在我开始之前,我要给Mickey Petersen的新书《Mastering Emacs》做个广告,它里面有一篇"mastering the eshell" 写得特别好(而且免费就能阅读).

1 Why?

shell其实就是一个由命令驱动的REPL. 你输入命令然后查看结果,然后输入另一个命令…如此往复. 如果输出结果只有几行,你会让它直接输出,如果输出结果有几百行,你会通过管道将结果传递给less命令.

不过在eshell中,你根本无需将结果传递给另一个pager,如果你发现输出结果太多内容了,只需要按下 C-c C-p 就会帮你跳到最后输入命令的头部,and then C-v your way down. 甚至于,你可以直接搜索你想要的那部分内容. 在eshell中执行命令意味着,这些命令的输出都会经过Emacs pager的处理.

更酷的是,你可以像Plan 9 那样启用Eshell的智能显示功能, 这时,执行命令后,如果命令输出过长,你的光标会自动留在输入命令的位置,直到你输入了一个非光标移动的键,光标才会跳到输入下一条命令的地方.

Eshell拥有如下几个优点:

  • 它是由Emacs Lisp写成的, 因此它是跨平台的.
  • 你不仅仅可以使用脚本和程序,你还可以使用Emacs函数… 想用Lisp写你的shell脚本?没问题!
  • 它的使用体验也比一般的shell要好.

但是当一个程序想要直接操作终端时,就不适于Eshell了.^1

你可能也尝试过Eshell, 我想你一定为它的独特性所吸引. 现在让我们更近一步的了解它…

2 Starting the Shell

我的工作流是以Emacs所驱动的,偶尔才会用到shell. 我常常会在shell中输入一些命令后,然后回到原来的工作中. 当我想弹出一个shell时,我使用下列函数来创建一个指定buffer的window(该window位于原始window的下方,占三分之一的高度),并开启eshell(它会自动进入当前buffer的目录中).

(defun eshell-here ()
  "Opens up a new shell in the directory associated with the
current buffer's file. The eshell is renamed to match that
directory to make multiple eshell windows easier."
  (interactive)
  (let* ((parent (if (buffer-file-name)
                     (file-name-directory (buffer-file-name))
                   default-directory))
         (height (/ (window-total-height) 3))
         (name   (car (last (split-string parent "/" t)))))
    (split-window-vertically (- height))
    (other-window 1)
    (eshell "new")
    (rename-buffer (concat "*eshell: " name "*"))

    (insert (concat "ls"))
    (eshell-send-input)))

(global-set-key (kbd "C-!") 'eshell-here)

下面是我自定义的函数 x, 它会退出shell并关闭该window.

(defun eshell/x ()
  (insert "exit")
  (eshell-send-input)
  (delete-window))

3 Lisp REPL? Almost

EShell 同时也是一个 Lisp REPL. 下面是一些例子:

$ (message "hello world")
"hello world"

不过,在shell中,相比语法的清晰度我们更在意输入的简洁性与速度, 因此,在这种情况下,我们可以省略掉两边的括号:

$ message "hello world"
"hello world"

eshell/ 为前缀的函数在Eshell中执行时可以省略掉这个前缀, 也就是说你可以直接输入echo而实际调用的是 eshell/echo 函数:

$ echo "hello world"
"hello world"

不过如果你把函数调用放入括号内,则你需要输入函数的全称:

$ (eshell/echo "hello world")
"hello world"

那么传递的参数类型是什么呢? 在普通shell中,所有的参数都是字符串类型的,不过在Eshell中就不一定了:

$ echo hello world
("hello" "world")

结果是一个由两个字符串组成的list. 然而,你并不能把echo的结果传递给car… 至少不能直接传递过去:

$ car echo hello world

会返回一个错误, 下面这样也会报错:

$ car (list hello world)

你会发现,一点你把代码纳入括号内,你就必须严格遵守elisp的相关语法规定了,所以你应该这么做:

$ car (list "hello" "world")

EShell定义了一个名为 listify 的命令(译者注:这里严格来说是eshell/listify函数,但在eshell中不严格区分命令还是函数,所以按照shell的说法说成是命令了,下面在不区分函数或命令时也一样),能将传递给它的参数转换为字符串列表:

$ listify hello world
("hello" "world")

不过如果你想把这个命令的结果传递给别的命令,比如car,你需要将之用大括号括起来,它的意思是说,以shell的方式执行命令,但是将返回的结果作为lisp对象来对待:

$ car { listify hello world }
hello

目前我还没搞清楚 listlistify 之间的区别, 它们看起来作用是一样的:

$ listify hello world
("hello" "world")

$ list hello world
("hello" "world")

$ listify 1 2 3
(1 2 3)

$ list 1 2 3
(1 2 3)

$ list "hello world"
(#("hello world" 0 11
   (escaped t)))

$ listify "hello world"
(#("hello world" 0 11
   (escaped t)))

说了这么多,其实我的意思就是说,你既可以把Eshell当成是一个shell,也可以把它当成是一个Lisp REPL,你也可以既把它当成是shell也把它当成是Lisp REPL,只要你不要被搞糊涂了就成.

4 Variables

在Eshell的文档中有这么一段话

由于Eshell是基于Emacs的REPL(1), 它并没有自己的作用域, 因此它存储变量的方式跟你在Elisp程序中是一样的.

运行 printenv 会显示出那些环境变量,使用 setenv 来设置环境变量:

$ setenv A "hello world"
$ getenv A
"hello world"

使用 setq 来未普通的Emacs变量来赋值:

$ setq B hello world
$ echo $B
hello
$ setq B "hello world"
$ echo $B
hello world

通过在变量名前加 $, 你可以查看所有Emacs变量的值:

$ echo $recentf-max-menu-items
25

需要注意的是,同名的环境变量的值会覆盖Emacs普通变量的值:

$ setenv C hello
$ setq C goodbye
$ echo $C
hello

左后,你可以从文件中读取Eshell变量的设置:

$ cat blah.eshell
setq FOO 42
setq BLING "bongy"

$ . blah.eshell
42
bongy

$ echo $FOO
42

$ echo $BLING
bongy

5 Loops

在shell中经常需要逐个地处理多个文件. 在Eshell中,你既可以使用lisp中的dolist来实现,也可以使用类似shell的语法来实现:

$ for file in *.org {
  echo "Upcasing: $file"
  mv $file $file(:U)
}

上例中的 (:U)是一个转换器,会将它之前的内容转换为大写形式. 我会在下一部分内容对它中进行讲解(这也是Eshell最出色的特性之一).

你可能会发现,上例中的 *.org 传递给 for 循环语句的是一个用来迭代的list. 另外,如果有多于1个的参数传递给 for 时,也会创建一个list,例如:

$ for i in 1 2 3 4 { echo $i }

若传递给 for 的是多个list,则这些list会合并(flatten)成一个list, 因此你可以像下面这样操作:

$ for file in emacs* zsh* { ... }

6 File Selection

若你要做的仅仅是重命名一个文件,或修改某个目录下所有文件的访问权限,那你根本无需用到shell,用dired甚至是Finder就足够了. shell只有在你想操作一部分匹配某模式的文件时才能比较方便. Eshell由于其特有的的filter(偷师于Zshell的modifiers)功能而尤为表现出众:

$ ls -al *.mp3(U)   # Show songs I own

上例中的 *.mp3 这部分就是我们所熟知的globbing pattern,而后面的(U)部分则进一步对结果进行了过滤. 在本例中,仅仅会输出宿主为你自己的那些文件.

你可以用下面两个命令来获取相关帮助信息:

$ eshell-display-predicate-help
$ eshell-display-modifier-help

你可能之前有接触过predicates(因为它们跟ZShell中的意义很接近), 不过更酷的是,你可以通过编写Elisp代码来新增自己的predicates 和 modifiers.

6.1 File Filter Predicates

下面是filter predicates的一份列表. 可以叠加多个filter predicate,也就是说输入 ls **/*(IW) 会列出当前目录及其子目录中那些同组用户及其他用户可读的文件.

. Regular files
* Executable files
@ Symlinks
p named pipes
s sockets
U Owned by current UID
u Owned by the given user account or UID, e.g. (u'howard')
g Owned by the given group account or GID, e.g. (g100)
r Readable by owner (A is readable by group)
R Readable by World
w Writable by owner (I is writable by group)
W Writable by World
x Executable by owner (E is executable by group)
X Executable by world
s setuid (for user)
S setgid (for group)
t Sticky bit
% Other file types.

"filter predicates" 的用法很直观. 比如要列出所有的目录只需要:

ls -ld *(/)

有些"filter predicates"可以接受其他选项参数,例如要列出所有属于howard的文件,可以这样做:

ls -ld *(u'howard')

% 需要第二个参数来指定文件的类型. 这里文件类型的说明与 ls 命令的输出一致,例如 %c 表示字符设备. 下面是一份来自 ls man page的列表:

b Block special file
c Character special file
d Directory
l Symbolic link
s Socket link
p FIFO

可以整合多个"filter predicates". 比如要列出所有你拥有的符号链接,可以这样:

ls -l *(@U)

你也可以列出不属于你的所有符号链接,方法是加一个前缀^:

ls -l *(@^U)

时间与大小相关的filter需要额外的参数. 下面内容摘自 eshell-display-predicate-help 的输出内容:

a[Mwhms][+-](N|'FILE') access time +/-/= N months/weeks/hours/mins/secs (days if unspecified) if FILE specified, use as comparison basis; so a+’file.c’ shows files accessed before file.c was last accessed. m[Mwhms][+-](N|'FILE') modification time… c[Mwhms][+-](N|'FILE') change time… L[kmp][+-]N file size +/-/= N Kb/Mb/blocks

下面展示了一些案例:

要列出目录中昨天之后才修改过的所有org-mode文件,需要输入:

ls *.org(m-1)

这里的 m 表示修改时间, - 表示减法, 1 是要减去的天数,我们这里没有指定时间单位,默认就是天. 要列出最近8小时内修改过的文件,我们需要输入:

ls *.org(mh-8)

压缩最近30天都没有访问过的所有文件:

bzip2 -9v **/*(a+30)

这里 ** 表示递归引用的各层子目录.

列出大于等于50k(用了符号+)的Shell脚本(以.sh结尾的可执行的文件):

ls ***/*.sh(*Kl+50)

要表示大于等于50K,我们先写单位为K,然后用+表示大于或等于,最后接一个大小. 三个星 *** 表示递归搜索各个子目录,但并不包括符号链接.

6.2 Modifiers

Modifiers与上面提到的filters很类似, 只不过它是以冒号开始的, 而且它的作用是用来修改字符串,文件名或由字符串/文件名组成的列表的. 例如, :U 会将字符串或文件名转换为大写形式:

for f in *(:U) { echo $f }

输出为:

AB-TESTING-EXPERIMENTS.ORG
AB-TESTING-PRESENTATION.ORG
ACTIONSCRIPT-NOTES.ORG
ADIUM-PLUGINS-AND-EXTENSIONS.ORG
ALFRED.ORG
ANGULARJS-BOILERPLATE.ORG
ANGULARJS-MODULES.ORG
ANGULARJS-TESTING.ORG
APPLESCRIPT-RECIPES.ORG
APPLESCRIPT-SKYPE.ORG
...

modifiers也可以作用域变量. 下例的输出结果与上例中的输出一样:

for f in * { echo $f(:U) }

下面是完整的用于修改字符串或文件名的modifiers列表:

:L      lowercase                                
:U      uppercase                                
:C      capitalize                               
:h      dirname                                  
:t      basename                                 
:e      file extension                           
:r      strip file extension                     
:q      escape special characters                
:S      split string at any whitespace character 
:S/PAT/ split string at each occurrence of /PAT/ 
:E      evaluate again                           

下面是用于修改list的modifiers的列表:

:o            sort alphabetically                           
:O            reverse sort alphabetically                   
:u            unique list (typically used after :o or :O)   
:R            reverse the list                              
:j            join list members, separated by a space       
:j/PAT/       join list members, separated by PAT           
:i/PAT/       exclude all members not matching PAT          
:x/PAT/       exclude all members matching PAT              
:s/pat/match/ substitute PAT with MATCH                     
:g/pat/match/ substitute PAT with MATCH for all occurrences 

要将所有你拥有的文件的扩展名前添加字符串 -foobar,你可以这样:

for F in *(U) { mv $F $F(:r)-foobar.$F(:e) }

6.3 Custom Filter Predicates

你知道的,Emacs最棒的地方在于它能够自定义任何东西,当然也包括你的shell体验拉.

如Mickey Petersen所言, 我们还可以通过创建自己的判断函数来过滤文件. 我们要是能有一个filter来根据org-mode文件内部的 #+TAGS 部分来过滤文件那该多好啊. 这样的话,如果我有个文件是以如下内容开头的:

#+TITLE:  Alfred
#+AUTHOR: Howard Abrams
#+DATE:   [2013-05-15 Wed]
#+TAGS:   mac technical

那么,我只要输入下面那样的语句就能找出所有包含mac标签的org文件了. like:

ls *.org(T'mac')

如果创建的filter可以不接任何参数,即它可以只用一个符号来代替,那么我们可以为 eshell-predicate-alist 添加一个元组来指定filter符号与相应的判断函数(返回值要么是true要么是nil). 像下面那样:

(add-to-list 'eshell-predicate-alist '(?P . eshell-primary-file))

不过在本例中, 符号T还需要接受一个tag作为参数. 这种情况下,我们需要分两步走:

  1. 需要先定义一个解析Eshell buffer的函数,该函数用于寻找传递给filter的参数(并且需要在解析出参数后,将光标移动到参数后)
  2. 还需要一个接受文件作参数的判断函数

这第一步,我们的解析函数会被调用来解析当前的文本内容,然后根据解析出来的内容返回用于过滤文件的判断函数:

(add-to-list 'eshell-predicate-alist '(?T . (eshell-org-file-tags)))

我这里将两个步骤整合到一个函数中, 该函数完成第一个步骤的工作后,会返回一个lambda表达式用于完成第二个步骤.

第一步是通过解析光标后面的文本来获取tag的内容(被单引号括起来了), 然后将光标移动到tag参数后为后面的过滤函数的执行作准备(用goto-char跳转到匹配的结尾处).

(defun eshell-org-file-tags ()
  "Helps the eshell parse the text the point is currently on,
looking for parameters surrounded in single quotes. Returns a
function that takes a FILE and returns nil if the file given to
it doesn't contain the org-mode #+TAGS: entry specified."

  ;; Step 1. Parse the eshell buffer for our tag between quotes
  ;;         Make sure to move point to the end of the match:
  (if (looking-at "'\\([^)']+\\)'")
      (let* ((tag (match-string 1))
             (reg (concat "^#\\+TAGS:.* " tag "\\b")))
        (goto-char (match-end 0))

        ;; Step 2. Return the predicate function:
        ;;         Careful when accessing the `reg' variable.
        `(lambda (file)
           (with-temp-buffer
             (insert-file-contents file)
             (re-search-forward ,reg nil t 1))))
    (error "The `T' predicate takes an org-mode tag value in single quotes.")))

第二步是返回一个函数,该函数会将指定文件的内容加载到一个临时buffer中,然后通过正则表达式搜索内容是否匹配包含指定的标签. 如果没有搜索到匹配内容返回nil(即为假),其他任何返回值都认为是真.

现在我可以只搜索Homebrew命令的内容而不会误找出与啤酒相关的内容了.

$ grep brew *.org(T'mac')

由于这里的grep调用的是Emacs的grep函数,因此它会将匹配的结果显示在一个buffer中,而且我只需要点击一下就会自动加载好文件准备给我编辑了.

7 Summary

当然,EShell的精髓在于能与Emacs进行整合, 例如可以通过配置 highlight-regexp 来高亮输出中的关键字,还能将输出结果重定向到Emacs buffer中:

$ ls -al > #<buffer some-notes.org>

然后可以在结果中按下 C-c | 将输出结果转换成一个org-mode下的表格进行下一步的操作.

虽然Eshell内建于Emacs中,无需任何定制就能用,我还是做了一些改进以期能帮助到他人.

8 Footnotes:

^1

top 这样的程序在Eshell中不能很好的工作,因为这种程序会尝试用原始的VT100控制代码来修改终端显示,然而Eshell假设所运行的程序输出的都是标准文本输出.

好在,在你输入 top 后, eshell会发现 top 被列在它的黑名单中了(准确地说,这种黑名单叫做eshell-visual-commands), 然后就会让它在一个特殊的comit buffer中显示.

在实践中,我根本没有注意到这个局限,因为大多数我使用的程序都实际上是被重写的Emacs函数. 不过如果你发现有个程序在Eshell中工作的不好,不妨试试把这个程序纳入到 eshell-visual-commands 这个列表中.