用Emacs Shell替代zsh
Table of Contents
我做到了. 我已经不再需要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
目前我还没搞清楚 list
和 listify
之间的区别, 它们看起来作用是一样的:
$ 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作为参数. 这种情况下,我们需要分两步走:
- 需要先定义一个解析Eshell buffer的函数,该函数用于寻找传递给filter的参数(并且需要在解析出参数后,将光标移动到参数后)
- 还需要一个接受文件作参数的判断函数
这第一步,我们的解析函数会被调用来解析当前的文本内容,然后根据解析出来的内容返回用于过滤文件的判断函数:
(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
这个列表中.