Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
339 lines (248 sloc) 15.4 KB

emacs-script中的那些坑

Emacs可不仅仅是一个编辑器而已, 它还是一个完整的Emacs Lisp解释器及其运行环境. 我们不仅仅可以用EmacsLisp来扩展和定制Emacs,还能编写完整的程序应用呢. Nic Ferrier的 elnode 服务器就是一个最好的例子. 我们完全可以用EmacsLisp写一些规模较小的shell脚本与工具.

不过你真的开始写就会发现, 用EmacsLisp编程要比想想中复杂一些. 几十年来Emacs都是作为交互式的应用程序来用的,这给Emacs和EmacsLisp留下了深深的烙印,也使得用它来编写独立的非交互式脚本变得格外的困难.

Making Emacs Lisp scripts executable

如今Emacs提供了一个很方便的 --script 选项来加载并执行指定的文件^1, 然而如何分配一个合适的shebang呢? 新手常常会这么做:

#!/usr/bin/emacs --script
(message "Hello world")

然而Emacs并不是/bin/sh, 它的位置在不同系统上是不一样的. 甚至可能有多个不同版本的Emacs分布于不同的地方. 举个例子,在OSX上 /usr/bin/emacs 指向的是早已过时的Emacs 22,而实际使用的Emacs一般是通过Homebrew来安装的,它的路径是 /usr/local/bin/emacs.

通常我们会使用 /usr/bin/env 来解决这个问题:

#!/usr/bin/env emacs --script
(message "Hello world")

但这又带来了另一个可移植性问题: Linux并不会分割 shebang 后面的参数,因此会将 emacs --script 作为单个参数传递给 /usr/bin/env, 因此这个方法不管用.

正确的做法需要花一些小伎俩^2:

#!/bin/sh
":"; exec emacs --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello world")

这样就可以将EmacsLisp代码嵌入到POSIX shell脚本中了,该脚本会以合适的参数来调用emacs. 第二行中的分号可以为Emacs隐藏 exec 语句, 而对shell来说,无意义的冒号语句则将第二行变成一个合法的连续执行两条命令的语句. 而且这个冒号被引用起来了使之成为EmacsLisp中合法的字符串字面量.

第二行后面的文件本地变量让Emacs使用EmacsLisp Mode来处理该脚本,而不用管shebang的存在(译者注:一般Emacs会将带shebang的脚本看成是shell脚本,因此可能会进入sh-mode),同时还启用了静态绑定.

这个小花招对于任何遵守POSIX的shell都管用. 更棒的是,我们现在可以传递任意参数给emacs可执行文件了,这使得我们可以摆脱 --script 带来的一些小麻烦.

Inhibiting site-start

–script 选项基本上只是 --batch -l 的简写形式,也就是进入批处理模式并加载指定文件的意思. 批处理模式主要使得Emacs不再创建frame,并且在处理完所有命令行参数(也包括执行我们的脚本这一步)后自动退出. 此外, –batch 还会禁用用户的初始化文件. 不过Emacs依然会加载global site初始化文件:

–batch implies -q (do not load an initialization file), but site-start.el is loaded nonetheless. It also causes Emacs to exit after processing all the command options. In addition, it disables auto-saving except in buffers for which auto-saving is explicitly requested.

global site的初始化过程要做好多乱七八糟的事情,它会设置好全局安装好的package,在最坏的情况下会严重推迟Emacs的启动时间. 何况,在我们的脚本运行前加载那么多的packages也不太好.

我们可以通过添加 --quick 选项来明确禁止global site的初始化过程, 它能给我们一个纯净的不带任何初始化过程的Emacs.

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-
(message "Hello world")

如果你确实需要加载global site初始化过程,你可以在代码中明确加载site-run-file:

(load site-run-file 'no-error 'no-message)

Processing command line arguments

Emacs使用 command-line-args-left 来持有命令行参数,它其实也是argv的别名^3:

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-

(message "Hello: %S" argv)
$ ./hello.el 'John Doe'
Hello: ("John Doe")

然而当传递选项时却遇到了些问题:

$ ./hello.el --greeting 'Good morning %s!' 'John Doe'
Hello: ("--greeting" "Good morning %s!" "John Doe")
Unknown option `--greeting'

Emacs尝试自己去解释 --greeting,当然会提示无此选项了. 那么我们该怎样让Emacs不要去解释我们传递给脚本的选项呢?

startup.el的源代码,更准确地说是函数 command-line-1 的源代码指出了解决方案: Emacs会立即按照各参数出现的顺序来处理所有传递给它的命令行参数. 每处理完一个参数之后就将它从 argv 中删除掉,因此 argv 也有一个别名叫做 command-line-args-left.

由于 command-line-args-left 或者说 argv 是一个全局变量,我们可以在脚本退出前将所有 argv 中未处理的参数给全删掉:

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-

(message "Hello: %S" argv)
(setq argv nil)
$ ./hello.el --greeting 'Good morning %s!' 'John Doe'
Hello: ("--greeting" "Good morning %s!" "John Doe")

另外, 我们也可以强制让Emacs提前退出,这种方法也不错:

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-

(message "Hello: %S" argv)
(kill-emacs 0)

然而正如一名读者指出的, 这是这样还不够. Emacs现在虽然会忽略掉我们自定义的那些惨了,然而它还是会处理掉那些它自己支持的那些参数. 也就是说我们的脚本不可能支持 --version 参数了:

$ ./hello.el --version
GNU Emacs 25.0.50.1
Copyright (C) 2014 Free Software Foundation, Inc.
GNU Emacs comes with ABSOLUTELY NO WARRANTY.
You may redistribute copies of Emacs
under the terms of the GNU General Public License.
For more information about these matters, see the file named COPYING.

Emacs会输出自己的版本信息,并且在我们的脚本看到 --version 参数之前就退出了. 我们需要使用标准的双破折号来将传递给Emacs的选项从脚本参数中分离出来,这样以来我们的脚本就能处理那些Emacs支持的参数了^4:

#!/bin/sh
":"; exec emacs --quick --script "$0" -- "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-

(message "Hello: %S" argv)
(kill-emacs 0)

现在我们可以传递 --version 参数给脚本了,不过美中不足的是,双破折号也会作为参数传递到脚本中去,所以我们要记得把第一个参数舍去:

$ ./hello.el --version
Hello: ("--" "--version")

一般来说都会在一个循环中处理所有参数,每处理完一个参数就弹出这个参数. 不过在最开始的时候记得要弹出那个双破折号参数哦:

#!/bin/sh
":"; exec emacs --quick --script "$0" -- "$@" # -*- mode: emacs-lisp; lexical-binding: t; -*-

(let ((greeting "Hello %s!")
      options-done
      names)
  (pop argv)  ; Remove the -- separator
  (while argv
    (let ((option (pop argv)))
      (cond
       (options-done (push option names))
       ;; Don't process options after "--"
       ((string= option "--") (setq options-done t))
       ((string= option "--greeting")
        (setq greeting (pop argv)))
       ;; --greeting=Foo
       ((string-match "\\`--greeting=\\(\\(?:.\\|\n\\)*\\)\\'" option)
        (setq greeting (match-string 1 option)))
       ((string-prefix-p "--" option)
        (message "Unknown option: %s" option)
        (kill-emacs 1))
       (t (push option names)))

      (unless (> (length greeting) 0)
        (message "Missing argument for --greeting!")
        (kill-emacs 1))))

  (unless names
    (message "Missing names!")
    (kill-emacs 1))

  (dolist (name (nreverse names))
    (message greeting name))

  (kill-emacs 0))

现在Emacs不会再干扰我们自己的选项和参数了:

$ ./hello.el --greeting='Hello %s' 'John Doe' 'Donald Duck'
Hello John Doe
Hello Donald Duck

Standard output and input

在前面的例子中,我们在脚本中用 message 来输出文本. 这其实有点问题,我们不能正常地重定向输出了:

$ ./hello.el 'John Doe' 'Donald Duck' > /dev/null
Hello John Doe!
Hello Donald Duck!

message 将内容写到stderr中, 然而一个合格的脚本应该将内容通过stdout输出. 要想将内容输出到stdout,你需要使用 print, prin1, princ 等这一系列的函数. 所有这些函数都会将Lisp对象以打印的表示方法输出,但是不同的函数有不同的格式化方法与引用方式.

如果只是简单的输出, 可以选择 princ, 它输出时没有任何格式以及引用. 而且一般来说,不带引用的字符串的”打印表示方式”就是字符串本身,因此我们可以使用该函数来将一系列的名字输出到stdout中:

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-

(while argv
  (princ (format "Hello %s!" (pop argv)))
  (terpri))

(kill-emacs 0)

message 不同的是, princ 并不接受一个格式化字符串, 因此我们需要自己来调用 format 函数. terpri 则是一个只输出换行的小工具. 脚本输出的结果正是我们想要的,而且我们现在可以重定向输出了:

$ ./hello.el ‘John Doe’ ‘Donald Duck’ Hello John Doe! Hello Donald Duck! $ ./hello.el ‘John Doe’ ‘Donald Duck’ >/dev/null

刚才我们讲了标准输出,那么标准输入怎么处理呢? EmacsLisp没有明确的输入函数,但是minibuffer在batch模式下会从标准输入读取数据^5:

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-

(let (name)
  (while (and (setq name (ignore-errors (read-from-minibuffer "")))
              (> (length name) 0))
    (princ (format "Hello %s!" name))
    (terpri)))

(kill-emacs 0)

我们用 read-from-minibuffer 来从标准输入中读取数据,只要读到一个空字符串或者有错误发生. 记住,EOF(例如C-d)会引发一个error,因此我们可以像其他程序一样用 C-d 离开输入循环.

$ ./hello.el
John Doe
Hello John Doe!
Donald Duck
Hello Donald Duck!

这其实还是有它的局限性. 你只能一行一行地读取数据,而且不能直接访问 TTY. 前一个问题到还没什么,但后一个问题限制住了Emacs脚本处理图形的能力,并且它无法实现任何类似curses这样的文本UI.

请注意! Emacs24及其之前的版本的Emacs在batch模式下用 read-passwd 从标准输出读取密码时,会在终端上显示出密码的内容. Emacs25版本的 read-passwd 解决了这个问题.

Debugging

默认情况下, Emacs无论是interactive模式下还是在batch模式下,它的错误提示都非常的的简洁: 它仅仅是输出错误说明,但不显示任何调用栈的信息. 假设有这么一段脚本,其中包含了一些拼写错误:

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-

(message "%S" (+ (car argv) (cadr argv)))
(setq argv nil)

然而它的错误提示并没有太大的用处:

$ ./hello.el 10 20
Wrong type argument: number-or-marker-p, "10"

在interactive模式下, 我们只需要先执行 M-x toggle-debug-on-error 然后重新执行一次该命令就行了. 随后Emacs就会在触发error时进入调试状态,并输出调用栈信息.

然而在batch模式下, 我们无法重新执行出错的命令, 因此我们需要在一开始就通过设置 debug-on-error 的方法来启用跟踪调用栈的功能.

#!/bin/sh
":"; exec emacs --quick --script "$0" "$@" # -*-emacs-lisp-*-

(setq debug-on-error t)

(message "%S" (+ (car argv) (cadr argv)))

(setq argv nil)

这样一来产生错误时就会输出调用栈的信息了:

$ ./hello.el 10 20
Debugger entered--Lisp error: (wrong-type-argument number-or-marker-p "10")
  +("10" "20")
  (message "%S" (+ (car argv) (cadr argv)))
  eval-buffer(#<buffer  *load*> nil "/Users/swiesner/Developer/Sandbox/hello.el" nil t)  ; Reading at buffer position 140
  load-with-code-conversion("/Users/swiesner/Developer/Sandbox/hello.el" "/Users/swiesner/Developer/Sandbox/hello.el" nil t)
  load("/Users/swiesner/Developer/Sandbox/hello.el" nil t t)
  command-line-1(("-scriptload" "./hello.el" "10" "20"))
  command-line()
  normal-top-level()

Keep your hands clean

虽然我们都很热爱EmacsLisp, 但它确实不适合于编写脚本以及独立的程序. EmacsLisp 并不能算是一门独立的编程语言,也不是一个独立的运行环境. 它与Emacs紧密相连,而Emacs的主要功能还是作为一门交互式的文本编辑器来用的.

我写此文的意义一方面是希望能在你确实需要编写非交互式EmacsLisp程序(例如你可能需要用脚本来运行你的Emacs测试组件)时帮助到你. 但最主要还是想告诉你EmacsLisp在Emacs外是多么的难用.

不要没事找事了. 可以的话,尽量使用其他语言吧,像Python,Ruby等语言都不错. 如果你确实喜欢Lisp,那么就使用CommonLisp吧, SBCL 就很不错. 如果你能使用像OCaml或Haskell这样的纯函数式语言的话,那就更不错了.