Skip to content

Latest commit

 

History

History
535 lines (387 loc) · 20.4 KB

用Emacs-shell替代zsh.org

File metadata and controls

535 lines (387 loc) · 20.4 KB

用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” 写得特别好(而且免费就能阅读).

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, 我想你一定为它的独特性所吸引. 现在让我们更近一步的了解它…

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))

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,只要你不要被搞糊涂了就成.

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

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* { ... }

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.

File Filter Predicates

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

/Directories (may accept d … gotta verify that)
.Regular files
*Executable files
@Symlinks
pnamed pipes
ssockets
UOwned by current UID
uOwned by the given user account or UID, e.g. (u’howard’)
gOwned by the given group account or GID, e.g. (g100)
rReadable by owner (A is readable by group)
RReadable by World
wWritable by owner (I is writable by group)
WWritable by World
xExecutable by owner (E is executable by group)
XExecutable by world
ssetuid (for user)
Ssetgid (for group)
tSticky bit
%Other file types.

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

ls -ld *(/)

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

ls -ld *(u'howard')

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

bBlock special file
cCharacter special file
dDirectory
lSymbolic link
sSocket link
pFIFO

可以整合多个”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,然后用+表示大于或等于,最后接一个大小. 三个星 *** 表示递归搜索各个子目录,但并不包括符号链接.

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) }

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中,而且我只需要点击一下就会自动加载好文件准备给我编辑了.

Summary

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

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

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

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

Footnotes:

^1

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

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

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